Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740const std = @import("std");const testing = std.testing;const Color = @import("Cell.zig").Color;const Event = @import("event.zig").Event;const Key = @import("Key.zig");const Mouse = @import("Mouse.zig");const uucode = @import("uucode");const Winsize = @import("main.zig").Winsize;
const log = std.log.scoped(.vaxis_parser);
const Parser = @This();
/// The return type of our parse method. Contains an Event and the number of/// bytes read from the buffer.pub const Result = struct { event: ?Event, n: usize,};
/// Shared cursor query state. The count and idle deadline are updated together/// because requests and responses are handled on different threads.pub const CursorPositionRequests = struct { io: std.Io, mutex: std.Io.Mutex = .init, count: usize = 0, last_request_at: std.Io.Timestamp = .zero,
pub fn request(self: *CursorPositionRequests) void { self.mutex.lockUncancelable(self.io); defer self.mutex.unlock(self.io); const now = std.Io.Timestamp.now(self.io, .awake); self.expire(now); self.count += 1; self.last_request_at = now; }
/// Consume a report, or cancel a request whose write failed. pub fn consume(self: *CursorPositionRequests) bool { self.mutex.lockUncancelable(self.io); defer self.mutex.unlock(self.io); self.expire(std.Io.Timestamp.now(self.io, .awake)); if (self.count == 0) return false; self.count -= 1; return true; }
pub fn pending(self: *CursorPositionRequests) usize { self.mutex.lockUncancelable(self.io); defer self.mutex.unlock(self.io); self.expire(std.Io.Timestamp.now(self.io, .awake)); return self.count; }
fn expire(self: *CursorPositionRequests, now: std.Io.Timestamp) void { if (self.last_request_at.durationTo(now).toNanoseconds() >= std.time.ns_per_s) self.count = 0; }};
const mouse_bits = struct { const motion: u8 = 0b00100000; const buttons: u8 = 0b11000011; const shift: u8 = 0b00000100; const alt: u8 = 0b00001000; const ctrl: u8 = 0b00010000; const leave: u16 = 0b100000000;};
// the state of the parserconst State = enum { ground, escape, csi, osc, dcs, sos, pm, apc, ss2, ss3,};
// a buffer to temporarily store text in. We need this to encode// text-as-codepointsbuf: [128]u8 = undefined,
/// Share Vaxis.cursor_position_requests when using a custom event loop./// Without pending requests, CSI R sequences retain their F3 key meaning.cursor_position_requests: ?*CursorPositionRequests = null,
/// Parse the first event from the input buffer. If a completion event is not/// present, Result.event will be null and Result.n will be 0////// If an unknown event is found, Result.event will be null and Result.n will be/// greater than 0pub fn parse(self: *Parser, input: []const u8, paste_allocator: ?std.mem.Allocator) !Result { std.debug.assert(input.len > 0);
// We gate this for len > 1 so we can detect singular escape key presses if (input[0] == 0x1b and input.len > 1) { switch (input[1]) { 0x4F => return parseSs3(input), 0x50 => return skipUntilST(input), // DCS 0x58 => return skipUntilST(input), // SOS 0x5B => return parseCsi(input, &self.buf, self.cursor_position_requests), // CSI 0x5D => return parseOsc(input, paste_allocator), 0x5E => return skipUntilST(input), // PM 0x5F => return parseApc(input), else => { // Anything else is an "alt + <char>" keypress const key: Key = .{ .codepoint = input[1], .mods = .{ .alt = true }, }; return .{ .event = .{ .key_press = key }, .n = 2, }; }, } } else return parseGround(input);}
/// Parse ground stateinline fn parseGround(input: []const u8) !Result { std.debug.assert(input.len > 0);
const b = input[0]; var n: usize = 1; // ground state generates keypresses when parsing input. We // generally get ascii characters, but anything less than // 0x20 is a Ctrl+<c> keypress. We map these to lowercase // ascii characters when we can const key: Key = switch (b) { 0x00 => .{ .codepoint = '@', .mods = .{ .ctrl = true } }, 0x08 => .{ .codepoint = Key.backspace }, 0x09 => .{ .codepoint = Key.tab }, 0x0A => .{ .codepoint = 'j', .mods = .{ .ctrl = true } }, 0x0D => .{ .codepoint = Key.enter }, 0x01...0x07, 0x0B...0x0C, 0x0E...0x1A, => .{ .codepoint = b + 0x60, .mods = .{ .ctrl = true } }, 0x1B => escape: { std.debug.assert(input.len == 1); // parseGround expects len == 1 with 0x1b break :escape .{ .codepoint = Key.escape, }; }, 0x7F => .{ .codepoint = Key.backspace }, else => blk: { var iter = uucode.utf8.Iterator.init(input); // return null if we don't have a valid codepoint const first_cp = iter.next() orelse return error.InvalidUTF8;
n = std.unicode.utf8CodepointSequenceLength(first_cp) catch return error.InvalidUTF8;
// Check if we have a multi-codepoint grapheme var code = first_cp; var grapheme_iter = uucode.grapheme.Iterator(uucode.utf8.Iterator).init(.init(input)); var grapheme_len: usize = 0; var cp_count: usize = 0;
while (grapheme_iter.nextCodePoint()) |result| { cp_count += 1; if (result.is_break) { // Found the first grapheme boundary grapheme_len = grapheme_iter.i; break; } }
if (grapheme_len > 0) { n = grapheme_len; if (cp_count > 1) { code = Key.multicodepoint; } }
break :blk .{ .codepoint = code, .text = input[0..n] }; }, };
return .{ .event = .{ .key_press = key }, .n = n, };}
inline fn parseSs3(input: []const u8) Result { if (input.len < 3) { return .{ .event = null, .n = 0, }; } const key: Key = switch (input[2]) { 0x1B => return .{ .event = null, .n = 2, }, 'A' => .{ .codepoint = Key.up }, 'B' => .{ .codepoint = Key.down }, 'C' => .{ .codepoint = Key.right }, 'D' => .{ .codepoint = Key.left }, 'E' => .{ .codepoint = Key.kp_begin }, 'F' => .{ .codepoint = Key.end }, 'H' => .{ .codepoint = Key.home }, 'P' => .{ .codepoint = Key.f1 }, 'Q' => .{ .codepoint = Key.f2 }, 'R' => .{ .codepoint = Key.f3 }, 'S' => .{ .codepoint = Key.f4 }, else => { log.warn("unhandled ss3: {x}", .{input[2]}); return .{ .event = null, .n = 3, }; }, }; return .{ .event = .{ .key_press = key }, .n = 3, };}
inline fn parseApc(input: []const u8) Result { if (input.len < 3) { return .{ .event = null, .n = 0, }; } const end = std.mem.indexOfScalarPos(u8, input, 2, 0x1b) orelse return .{ .event = null, .n = 0, }; if (input.len < end + 1 + 1) { return .{ .event = null, .n = 0, }; } const sequence = input[0 .. end + 1 + 1];
switch (input[2]) { 'G' => return .{ .event = .cap_kitty_graphics, .n = sequence.len, }, else => return .{ .event = null, .n = sequence.len, }, }}
/// Skips sequences until we see an ST (String Terminator, ESC \)inline fn skipUntilST(input: []const u8) Result { if (input.len < 3) { return .{ .event = null, .n = 0, }; } const end = std.mem.indexOfScalarPos(u8, input, 2, 0x1b) orelse return .{ .event = null, .n = 0, }; if (input.len < end + 1 + 1) { return .{ .event = null, .n = 0, }; } const sequence = input[0 .. end + 1 + 1]; return .{ .event = null, .n = sequence.len, };}
/// Parses an OSC sequenceinline fn parseOsc(input: []const u8, paste_allocator: ?std.mem.Allocator) !Result { if (input.len < 3) { return .{ .event = null, .n = 0, }; } var bel_terminated: bool = false; // end is the index of the terminating byte(s) (either the last byte of an // ST or BEL) const end: usize = blk: { // Find the first terminator: BEL (0x07) or ST (ESC \), whichever comes // first. A bare ESC that is not part of an ST begins a new sequence, so // we must stop rather than consume into it. var i: usize = 2; while (i < input.len) : (i += 1) { switch (input[i]) { 0x07 => { bel_terminated = true; break :blk i + 1; }, 0x1b => { if (i + 1 >= input.len) return .{ .event = null, .n = 0 }; if (input[i + 1] == 0x5c) break :blk i + 2; // ESC not followed by \: the OSC was never terminated and a // new escape sequence has begun. Consume the malformed OSC // prefix and leave the ESC for the next parse call. return .{ .event = null, .n = i }; }, else => {}, } } // No terminator yet: wait for more input. return .{ .event = null, .n = 0 }; };
// The complete OSC sequence const sequence = input[0..end];
const null_event: Result = .{ .event = null, .n = sequence.len };
// the end of the payload, exclusive of the terminator. Searches and // slices must never go past this const payload_end = if (bel_terminated) sequence.len - 1 else sequence.len - 2;
const semicolon_idx = std.mem.indexOfScalarPos(u8, sequence[0..payload_end], 2, ';') orelse return null_event; const ps = std.fmt.parseUnsigned(u16, sequence[2..semicolon_idx], 10) catch return null_event;
switch (ps) { 4 => { const color_idx_delim = std.mem.indexOfScalarPos(u8, sequence[0..payload_end], semicolon_idx + 1, ';') orelse return null_event; const ps_idx = std.fmt.parseUnsigned(u8, sequence[semicolon_idx + 1 .. color_idx_delim], 10) catch return null_event; const color_spec = sequence[color_idx_delim + 1 .. payload_end];
const color = Color.rgbFromSpec(color_spec) catch return null_event; const event: Color.Report = .{ .kind = .{ .index = ps_idx }, .value = color.rgb, }; return .{ .event = .{ .color_report = event }, .n = sequence.len, }; }, 10, 11, 12, => { const color_spec = sequence[semicolon_idx + 1 .. payload_end];
const color = Color.rgbFromSpec(color_spec) catch return null_event; const event: Color.Report = .{ .kind = switch (ps) { 10 => .fg, 11 => .bg, 12 => .cursor, else => unreachable, }, .value = color.rgb, }; return .{ .event = .{ .color_report = event }, .n = sequence.len, }; }, 52 => { const alloc = paste_allocator orelse return null_event; // OSC 52 ; c ; <base64 payload> if (semicolon_idx + 1 >= payload_end or sequence[semicolon_idx + 1] != 'c') return null_event; if (semicolon_idx + 3 > payload_end or sequence[semicolon_idx + 2] != ';') return null_event; const payload = sequence[semicolon_idx + 3 .. payload_end]; const decoder = std.base64.standard.Decoder; const size = decoder.calcSizeForSlice(payload) catch return null_event; const text = try alloc.alloc(u8, size); decoder.decode(text, payload) catch { alloc.free(text); return null_event; }; log.debug("decoded paste: {s}", .{text}); return .{ .event = .{ .paste = text }, .n = sequence.len, }; }, 7501 => { // Program status feature detection reply: OSC 7501 ; ? ST. Later // revisions may append pairs after the '?', which we ignore. const body = sequence[semicolon_idx + 1 .. payload_end]; if (!std.mem.startsWith(u8, body, "?")) return null_event; return .{ .event = .cap_program_status, .n = sequence.len, }; }, else => return null_event, }}
inline fn parseCsi(input: []const u8, text_buf: []u8, cursor_position_requests: ?*CursorPositionRequests) Result { if (input.len < 3) { return .{ .event = null, .n = 0, }; } // We start iterating at index 2 to get past the '[' const sequence = for (input[2..], 2..) |b, i| { switch (b) { 0x40...0xFF => break input[0 .. i + 1], else => continue, } } else return .{ .event = null, .n = 0 }; const null_event: Result = .{ .event = null, .n = sequence.len };
const final = sequence[sequence.len - 1]; if (final == 'R') report: { const pending = cursor_position_requests orelse break :report; if (pending.pending() == 0) break :report; var fields = std.mem.splitScalar(u8, sequence[2 .. sequence.len - 1], ';'); const row_buf = fields.next().?; const col_buf = fields.next() orelse break :report; if (fields.next() != null) break :report; // Only a complete pair of positive decimal coordinates is a report. // Otherwise, preserve ordinary key parsing (e.g. CSI R or CSI 1;2:3R). for (row_buf) |b| if (!std.ascii.isDigit(b)) break :report; for (col_buf) |b| if (!std.ascii.isDigit(b)) break :report; const row = std.fmt.parseInt(u16, row_buf, 10) catch break :report; const col = std.fmt.parseInt(u16, col_buf, 10) catch break :report; if (row == 0 or col == 0) break :report; if (pending.consume()) return .{ .event = .{ .cursor_position = .{ .row = row - 1, .col = col - 1 } }, .n = sequence.len, }; } switch (final) { 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'R', 'S' => { // Legacy keys // CSI {ABCDEFHPQS} // CSI 1 ; modifier:event_type {ABCDEFHPQS}
// Split first into fields delimited by ';' var field_iter = std.mem.splitScalar(u8, sequence[2 .. sequence.len - 1], ';');
// skip the first field _ = field_iter.next(); //
var is_release: bool = false; var key: Key = .{ .codepoint = switch (final) { 'A' => Key.up, 'B' => Key.down, 'C' => Key.right, 'D' => Key.left, 'E' => Key.kp_begin, 'F' => Key.end, 'H' => Key.home, 'P' => Key.f1, 'Q' => Key.f2, 'R' => Key.f3, 'S' => Key.f4, else => return null_event, }, };
field2: { // modifier_mask:event_type const field_buf = field_iter.next() orelse break :field2; var param_iter = std.mem.splitScalar(u8, field_buf, ':'); const modifier_buf = param_iter.next() orelse unreachable; const modifier_mask = parseParam(u8, modifier_buf, 1) orelse return null_event; key.mods = @bitCast(modifier_mask -| 1);
if (param_iter.next()) |event_type_buf| { is_release = std.mem.eql(u8, event_type_buf, "3"); } }
field3: { // text_as_codepoint[:text_as_codepoint] const field_buf = field_iter.next() orelse break :field3; var param_iter = std.mem.splitScalar(u8, field_buf, ':'); var total: usize = 0; while (param_iter.next()) |cp_buf| { const cp = parseParam(u21, cp_buf, null) orelse return null_event; const len = std.unicode.utf8CodepointSequenceLength(cp) catch return null_event; if (total + len > text_buf.len) return null_event; total += std.unicode.utf8Encode(cp, text_buf[total..]) catch return null_event; } key.text = text_buf[0..total]; }
const event: Event = if (is_release) .{ .key_release = key } else .{ .key_press = key }; return .{ .event = event, .n = sequence.len, }; }, 'Z' => { // Legacy keys // CSI Z return .{ .event = .{ .key_press = .{ .codepoint = Key.tab, .mods = .{ .shift = true }, } }, .n = sequence.len, }; }, '~' => { // Legacy keys // CSI number ~ // CSI number ; modifier ~ // CSI number ; modifier:event_type ; text_as_codepoint ~ var field_iter = std.mem.splitScalar(u8, sequence[2 .. sequence.len - 1], ';'); const number_buf = field_iter.next() orelse unreachable; // always will have one field const number = parseParam(u16, number_buf, null) orelse return null_event;
var key: Key = .{ .codepoint = switch (number) { 1, 7 => Key.home, 2 => Key.insert, 3 => Key.delete, 4, 8 => Key.end, 5 => Key.page_up, 6 => Key.page_down, 11 => Key.f1, 12 => Key.f2, 13 => Key.f3, 14 => Key.f4, 15 => Key.f5, 17 => Key.f6, 18 => Key.f7, 19 => Key.f8, 20 => Key.f9, 21 => Key.f10, 23 => Key.f11, 24 => Key.f12, 200 => return .{ .event = .paste_start, .n = sequence.len }, 201 => return .{ .event = .paste_end, .n = sequence.len }, 57427 => Key.kp_begin, else => return null_event, }, };
var is_release: bool = false; field2: { // modifier_mask:event_type const field_buf = field_iter.next() orelse break :field2; var param_iter = std.mem.splitScalar(u8, field_buf, ':'); const modifier_buf = param_iter.next() orelse unreachable; const modifier_mask = parseParam(u8, modifier_buf, 1) orelse return null_event; key.mods = @bitCast(modifier_mask -| 1);
if (param_iter.next()) |event_type_buf| { is_release = std.mem.eql(u8, event_type_buf, "3"); } }
field3: { // text_as_codepoint[:text_as_codepoint] const field_buf = field_iter.next() orelse break :field3; var param_iter = std.mem.splitScalar(u8, field_buf, ':'); var total: usize = 0; while (param_iter.next()) |cp_buf| { const cp = parseParam(u21, cp_buf, null) orelse return null_event; const len = std.unicode.utf8CodepointSequenceLength(cp) catch return null_event; if (total + len > text_buf.len) return null_event; total += std.unicode.utf8Encode(cp, text_buf[total..]) catch return null_event; } key.text = text_buf[0..total]; }
const event: Event = if (is_release) .{ .key_release = key } else .{ .key_press = key }; return .{ .event = event, .n = sequence.len, }; },
'I' => return .{ .event = .focus_in, .n = sequence.len }, 'O' => return .{ .event = .focus_out, .n = sequence.len }, 'M', 'm' => return parseMouse(sequence, input), 'c' => { // Primary DA (CSI ? Pm c) std.debug.assert(sequence.len >= 4); // ESC [ ? c == 4 bytes switch (input[2]) { '?' => return .{ .event = .cap_da1, .n = sequence.len }, else => return null_event, } }, 'n' => { // Device Status Report // CSI Ps n // CSI ? Ps n std.debug.assert(sequence.len >= 3); switch (sequence[2]) { '?' => { const delim_idx = std.mem.indexOfScalarPos(u8, input, 3, ';') orelse return null_event; const ps = std.fmt.parseUnsigned(u16, input[3..delim_idx], 10) catch return null_event; switch (ps) { 997 => { // Color scheme update (CSI 997 ; Ps n) // See https://github.com/contour-terminal/contour/blob/master/docs/vt-extensions/color-palette-update-notifications.md switch (sequence[delim_idx + 1]) { '1' => return .{ .event = .{ .color_scheme = .dark }, .n = sequence.len, }, '2' => return .{ .event = .{ .color_scheme = .light }, .n = sequence.len, }, else => return null_event, } }, else => return null_event, } }, else => return null_event, } }, 't' => { // XTWINOPS // Split first into fields delimited by ';' var iter = std.mem.splitScalar(u8, sequence[2 .. sequence.len - 1], ';'); const ps = iter.first(); if (std.mem.eql(u8, "48", ps)) { // in band window resize // CSI 48 ; height ; width ; height_pix ; width_pix t const height_char = iter.next() orelse return null_event; const width_char = iter.next() orelse return null_event; const height_pix = iter.next() orelse "0"; const width_pix = iter.next() orelse "0";
const winsize: Winsize = .{ .rows = std.fmt.parseUnsigned(u16, height_char, 10) catch return null_event, .cols = std.fmt.parseUnsigned(u16, width_char, 10) catch return null_event, .x_pixel = std.fmt.parseUnsigned(u16, width_pix, 10) catch return null_event, .y_pixel = std.fmt.parseUnsigned(u16, height_pix, 10) catch return null_event, }; return .{ .event = .{ .winsize = winsize }, .n = sequence.len, }; } return null_event; }, 'u' => { // Kitty keyboard // CSI unicode-key-code:alternate-key-codes ; modifiers:event-type ; text-as-codepoints u // Not all fields will be present. Only unicode-key-code is // mandatory
if (sequence.len > 2 and sequence[2] == '?') return .{ .event = .cap_kitty_keyboard, .n = sequence.len, };
var key: Key = .{ .codepoint = undefined, }; // Split first into fields delimited by ';' var field_iter = std.mem.splitScalar(u8, sequence[2 .. sequence.len - 1], ';');
{ // field 1 // unicode-key-code:shifted_codepoint:base_layout_codepoint const field_buf = field_iter.next() orelse unreachable; // There will always be at least one field var param_iter = std.mem.splitScalar(u8, field_buf, ':'); const codepoint_buf = param_iter.next() orelse unreachable; key.codepoint = parseParam(u21, codepoint_buf, null) orelse return null_event;
if (param_iter.next()) |shifted_cp_buf| { key.shifted_codepoint = parseParam(u21, shifted_cp_buf, null); } if (param_iter.next()) |base_layout_buf| { key.base_layout_codepoint = parseParam(u21, base_layout_buf, null); } }
var is_release: bool = false;
field2: { // modifier_mask:event_type const field_buf = field_iter.next() orelse break :field2; var param_iter = std.mem.splitScalar(u8, field_buf, ':'); const modifier_buf = param_iter.next() orelse unreachable; const modifier_mask = parseParam(u8, modifier_buf, 1) orelse return null_event; key.mods = @bitCast(modifier_mask -| 1);
if (param_iter.next()) |event_type_buf| { is_release = std.mem.eql(u8, event_type_buf, "3"); } }
field3: { // text_as_codepoint[:text_as_codepoint] const field_buf = field_iter.next() orelse break :field3; var param_iter = std.mem.splitScalar(u8, field_buf, ':'); var total: usize = 0; while (param_iter.next()) |cp_buf| { const cp = parseParam(u21, cp_buf, null) orelse return null_event; const len = std.unicode.utf8CodepointSequenceLength(cp) catch return null_event; if (total + len > text_buf.len) return null_event; total += std.unicode.utf8Encode(cp, text_buf[total..]) catch return null_event; } key.text = text_buf[0..total]; }
{ // We check if we have *only* shift, no text, and a printable character. This can // happen when we have disambiguate on and a key is pressed and encoded as CSI u, // for example shift + space can produce CSI 32 ; 2 u const mod_test: Key.Modifiers = .{ .shift = true, .caps_lock = key.mods.caps_lock, .num_lock = key.mods.num_lock, }; if (key.text == null and key.mods.eql(mod_test) and key.codepoint <= std.math.maxInt(u8) and std.ascii.isPrint(@intCast(key.codepoint))) { // Encode the codepoint as upper const upper = std.ascii.toUpper(@intCast(key.codepoint)); const n = std.unicode.utf8Encode(upper, text_buf) catch unreachable; key.text = text_buf[0..n]; key.shifted_codepoint = upper; } }
const event: Event = if (is_release) .{ .key_release = key } else .{ .key_press = key };
return .{ .event = event, .n = sequence.len }; }, 'y' => { // DECRPM (CSI ? Ps ; Pm $ y) const delim_idx = std.mem.indexOfScalarPos(u8, input, 3, ';') orelse return null_event; const ps = std.fmt.parseUnsigned(u16, input[3..delim_idx], 10) catch return null_event; const pm = std.fmt.parseUnsigned(u8, input[delim_idx + 1 .. sequence.len - 2], 10) catch return null_event; switch (ps) { // Mouse Pixel reporting 1016 => switch (pm) { 0, 4 => return null_event, else => return .{ .event = .cap_sgr_pixels, .n = sequence.len }, }, // Unicode Core, see https://github.com/contour-terminal/terminal-unicode-core 2027 => switch (pm) { 0, 4 => return null_event, else => return .{ .event = .cap_unicode, .n = sequence.len }, }, // Color scheme reportnig, see https://github.com/contour-terminal/contour/blob/master/docs/vt-extensions/color-palette-update-notifications.md 2031 => switch (pm) { 0, 4 => return null_event, else => return .{ .event = .cap_color_scheme_updates, .n = sequence.len }, }, else => return null_event, } }, 'q' => { // kitty multi cursor cap (CSI > 1;2;3;29;30;40;100;101 TRAILER) (TRAILER is " q") const second_final = sequence[sequence.len - 2]; if (second_final != ' ') return null_event; // check for any digits. we're not too picky about checking the supported cursor types here for (sequence[0 .. sequence.len - 2]) |c| switch (c) { '0'...'9' => return .{ .event = .cap_multi_cursor, .n = sequence.len }, else => continue, }; return null_event; }, else => return null_event, }}
/// Parse a param buffer, returning a default value if the param was emptyinline fn parseParam(comptime T: type, buf: []const u8, default: ?T) ?T { if (buf.len == 0) return default; return std.fmt.parseInt(T, buf, 10) catch return null;}
/// Parse a mouse eventinline fn parseMouse(input: []const u8, full_input: []const u8) Result { const null_event: Result = .{ .event = null, .n = input.len };
var button_mask: u16 = undefined; var px: i16 = undefined; var py: i16 = undefined; var xterm: bool = undefined; if (input.len == 3 and (input[2] == 'M') and full_input.len >= 6) { xterm = true; button_mask = full_input[3] - 32; px = full_input[4] - 32; py = full_input[5] - 32; } else if (input.len >= 4 and input[2] == '<') { xterm = false; const delim1 = std.mem.indexOfScalarPos(u8, input, 3, ';') orelse return null_event; button_mask = parseParam(u16, input[3..delim1], null) orelse return null_event; const delim2 = std.mem.indexOfScalarPos(u8, input, delim1 + 1, ';') orelse return null_event; px = parseParam(i16, input[delim1 + 1 .. delim2], 1) orelse return null_event; py = parseParam(i16, input[delim2 + 1 .. input.len - 1], 1) orelse return null_event; } else { return null_event; }
if (button_mask & mouse_bits.leave > 0) return .{ .event = .mouse_leave, .n = if (xterm) 6 else input.len };
const button: Mouse.Button = @fromBackingInt(@intCast(button_mask & mouse_bits.buttons)); const motion = button_mask & mouse_bits.motion > 0; const shift = button_mask & mouse_bits.shift > 0; const alt = button_mask & mouse_bits.alt > 0; const ctrl = button_mask & mouse_bits.ctrl > 0;
const mouse = Mouse{ .button = button, .mods = .{ .shift = shift, .alt = alt, .ctrl = ctrl, }, .col = px -| 1, .row = py -| 1, .type = blk: { if (motion and button != Mouse.Button.none) { break :blk .drag; } if (motion and button == Mouse.Button.none) { break :blk .motion; } if (xterm) { if (button == Mouse.Button.none) { break :blk .release; } break :blk .press; } if (input[input.len - 1] == 'm') break :blk .release; break :blk .press; }, }; return .{ .event = .{ .mouse = mouse }, .n = if (xterm) 6 else input.len };}
test "parse: single xterm keypress" { const alloc = testing.allocator_instance.allocator(); const input = "a"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = 'a', .text = "a", }; const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(1, result.n); try testing.expectEqual(expected_event, result.event);}
test "parse: single xterm keypress backspace" { const alloc = testing.allocator_instance.allocator(); const input = "\x08"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = Key.backspace, }; const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(1, result.n); try testing.expectEqual(expected_event, result.event);}
test "parse: single xterm keypress with more buffer" { const alloc = testing.allocator_instance.allocator(); const input = "ab"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = 'a', .text = "a", }; const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(1, result.n); try testing.expectEqualStrings(expected_key.text.?, result.event.?.key_press.text.?); try testing.expectEqualDeep(expected_event, result.event);}
test "parse: xterm escape keypress" { const alloc = testing.allocator_instance.allocator(); const input = "\x1b"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = Key.escape }; const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(1, result.n); try testing.expectEqual(expected_event, result.event);}
test "parse: xterm ctrl+a" { const alloc = testing.allocator_instance.allocator(); const input = "\x01"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = 'a', .mods = .{ .ctrl = true } }; const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(1, result.n); try testing.expectEqual(expected_event, result.event);}
test "parse: xterm alt+a" { const alloc = testing.allocator_instance.allocator(); const input = "\x1ba"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = 'a', .mods = .{ .alt = true } }; const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(2, result.n); try testing.expectEqual(expected_event, result.event);}
test "parse: xterm key up" { const alloc = testing.allocator_instance.allocator(); { // normal version const input = "\x1b[A"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = Key.up }; const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(3, result.n); try testing.expectEqual(expected_event, result.event); }
{ // application keys version const input = "\x1bOA"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = Key.up }; const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(3, result.n); try testing.expectEqual(expected_event, result.event); }}
test "parse: xterm shift+up" { const alloc = testing.allocator_instance.allocator(); const input = "\x1b[1;2A"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = Key.up, .mods = .{ .shift = true } }; const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(6, result.n); try testing.expectEqual(expected_event, result.event);}
test "parse: xterm shift+tab" { const alloc = testing.allocator_instance.allocator(); const input = "\x1b[Z"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = Key.tab, .mods = .{ .shift = true } }; const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(3, result.n); try testing.expectEqual(expected_event, result.event);}
test "parse: xterm insert" { const alloc = testing.allocator_instance.allocator(); const input = "\x1b[2~"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = Key.insert, .mods = .{} }; const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(input.len, result.n); try testing.expectEqual(expected_event, result.event);}
test "parse: VT220 home and end" { const alloc = testing.allocator_instance.allocator(); const cases = [_]struct { input: []const u8, codepoint: u21 }{ .{ .input = "\x1b[1~", .codepoint = Key.home }, .{ .input = "\x1b[4~", .codepoint = Key.end }, };
for (cases) |case| { var parser: Parser = .{}; const result = try parser.parse(case.input, alloc); const expected_event: Event = .{ .key_press = .{ .codepoint = case.codepoint } };
try testing.expectEqual(case.input.len, result.n); try testing.expectEqual(expected_event, result.event); }}
test "parse: paste_start" { const alloc = testing.allocator_instance.allocator(); const input = "\x1b[200~"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_event: Event = .paste_start;
try testing.expectEqual(6, result.n); try testing.expectEqual(expected_event, result.event);}
test "parse: paste_end" { const alloc = testing.allocator_instance.allocator(); const input = "\x1b[201~"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_event: Event = .paste_end;
try testing.expectEqual(6, result.n); try testing.expectEqual(expected_event, result.event);}
test "parse: osc52 paste" { const alloc = testing.allocator_instance.allocator(); const input = "\x1b]52;c;b3NjNTIgcGFzdGU=\x1b\\"; const expected_text = "osc52 paste"; var parser: Parser = .{}; const result = try parser.parse(input, alloc);
try testing.expectEqual(25, result.n); switch (result.event.?) { .paste => |text| { defer alloc.free(text); try testing.expectEqualStrings(expected_text, text); }, else => try testing.expect(false), }}
test "parse: focus_in" { const alloc = testing.allocator_instance.allocator(); const input = "\x1b[I"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_event: Event = .focus_in;
try testing.expectEqual(3, result.n); try testing.expectEqual(expected_event, result.event);}
test "parse: focus_out" { const alloc = testing.allocator_instance.allocator(); const input = "\x1b[O"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_event: Event = .focus_out;
try testing.expectEqual(3, result.n); try testing.expectEqual(expected_event, result.event);}
test "parse: kitty: shift+a without text reporting" { const alloc = testing.allocator_instance.allocator(); const input = "\x1b[97:65;2u"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = 'a', .shifted_codepoint = 'A', .mods = .{ .shift = true }, .text = "A", }; const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(10, result.n); try testing.expectEqualDeep(expected_event, result.event);}
test "parse: kitty: alt+shift+a without text reporting" { const alloc = testing.allocator_instance.allocator(); const input = "\x1b[97:65;4u"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = 'a', .shifted_codepoint = 'A', .mods = .{ .shift = true, .alt = true }, }; const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(10, result.n); try testing.expectEqual(expected_event, result.event);}
test "parse: kitty: a without text reporting" { const alloc = testing.allocator_instance.allocator(); const input = "\x1b[97u"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = 'a', }; const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(5, result.n); try testing.expectEqual(expected_event, result.event);}
test "parse: kitty: release event" { const alloc = testing.allocator_instance.allocator(); const input = "\x1b[97;1:3u"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = 'a', }; const expected_event: Event = .{ .key_release = expected_key };
try testing.expectEqual(9, result.n); try testing.expectEqual(expected_event, result.event);}
test "parse: single codepoint" { const alloc = testing.allocator_instance.allocator(); const input = "🙂"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = 0x1F642, .text = input, }; const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(4, result.n); try testing.expectEqual(expected_event, result.event);}
test "parse: single codepoint with more in buffer" { const alloc = testing.allocator_instance.allocator(); const input = "🙂a"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = 0x1F642, .text = "🙂", }; const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(4, result.n); try testing.expectEqualDeep(expected_event, result.event);}
test "parse: multiple codepoint grapheme" { const alloc = testing.allocator_instance.allocator(); const input = "👩🚀"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = Key.multicodepoint, .text = input, }; const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(input.len, result.n); try testing.expectEqual(expected_event, result.event);}
test "parse: multiple codepoint grapheme with more after" { const alloc = testing.allocator_instance.allocator(); const input = "👩🚀abc"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = Key.multicodepoint, .text = "👩🚀", };
try testing.expectEqual(expected_key.text.?.len, result.n); const actual = result.event.?.key_press; try testing.expectEqualStrings(expected_key.text.?, actual.text.?); try testing.expectEqual(expected_key.codepoint, actual.codepoint);}
test "parse: flag emoji" { const alloc = testing.allocator_instance.allocator(); const input = "🇺🇸"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = Key.multicodepoint, .text = input, }; const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(input.len, result.n); try testing.expectEqual(expected_event, result.event);}
test "parse: combining mark" { const alloc = testing.allocator_instance.allocator(); // a with combining acute accent (NFD form) const input = "a\u{0301}"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = Key.multicodepoint, .text = input, }; const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(input.len, result.n); try testing.expectEqual(expected_event, result.event);}
test "parse: skin tone emoji" { const alloc = testing.allocator_instance.allocator(); const input = "👋🏿"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = Key.multicodepoint, .text = input, }; const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(input.len, result.n); try testing.expectEqual(expected_event, result.event);}
test "parse: text variation selector" { const alloc = testing.allocator_instance.allocator(); // Heavy black heart with text variation selector const input = "❤︎"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = Key.multicodepoint, .text = input, }; const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(input.len, result.n); try testing.expectEqual(expected_event, result.event);}
test "parse: keycap sequence" { const alloc = testing.allocator_instance.allocator(); const input = "1️⃣"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = Key.multicodepoint, .text = input, }; const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(input.len, result.n); try testing.expectEqual(expected_event, result.event);}
test "parse: cursor position requests are counted" { var pending: CursorPositionRequests = .{ .io = testing.io }; pending.request(); pending.request(); var parser: Parser = .{ .cursor_position_requests = &pending }; const input = "\x1b[12;300R";
// Every incomplete prefix leaves the request outstanding. for (2..input.len) |len| { const result = try parser.parse(input[0..len], null); try testing.expectEqual(@as(usize, 0), result.n); try testing.expectEqual(@as(?Event, null), result.event); try testing.expectEqual(@as(usize, 2), pending.pending()); }
const first = try parser.parse(input ++ "x", null); try testing.expectEqual(input.len, first.n); try testing.expectEqual(@as(u16, 11), first.event.?.cursor_position.row); try testing.expectEqual(@as(u16, 299), first.event.?.cursor_position.col); try testing.expectEqual(@as(usize, 1), pending.pending());
const key = try parser.parse("x", null); try testing.expectEqual(@as(u21, 'x'), key.event.?.key_press.codepoint); try testing.expectEqual(@as(usize, 1), pending.pending());
const plain_f3 = try parser.parse("\x1b[R", null); try testing.expectEqual(Key.f3, plain_f3.event.?.key_press.codepoint); const release = try parser.parse("\x1b[1;2:3R", null); try testing.expectEqual(Key.f3, release.event.?.key_release.codepoint); try testing.expect(release.event.?.key_release.mods.shift); try testing.expectEqual(@as(usize, 1), pending.pending());
const second = try parser.parse("\x1b[1;1R", null); try testing.expectEqual(@as(u16, 0), second.event.?.cursor_position.row); try testing.expectEqual(@as(u16, 0), second.event.?.cursor_position.col); try testing.expectEqual(@as(usize, 0), pending.pending());
// With no outstanding query, the same bytes are once again a legacy key. const f3 = try parser.parse("\x1b[1;2R", null); try testing.expectEqual(Key.f3, f3.event.?.key_press.codepoint); try testing.expect(f3.event.?.key_press.mods.shift); try testing.expectEqual(@as(usize, 0), pending.pending());}
test "parse: invalid cursor position reports do not consume requests" { var pending: CursorPositionRequests = .{ .io = testing.io }; pending.request(); var parser: Parser = .{ .cursor_position_requests = &pending }; const invalid = [_][]const u8{ "\x1b[0;1R", "\x1b[1;0R", "\x1b[;1R", "\x1b[1;R", "\x1b[1R", "\x1b[1;2;3R", "\x1b[-1;1R", "\x1b[+1;1R", "\x1b[1;2:3R", "\x1b[65536;1R", "\x1b[1;65536R", "\x1b[?1;1R", }; for (invalid) |input| { const result = try parser.parse(input, null); try testing.expectEqual(input.len, result.n); if (result.event) |event| try testing.expect(event != .cursor_position); try testing.expectEqual(@as(usize, 1), pending.pending()); } const result = try parser.parse("\x1b[65535;65535R", null); try testing.expectEqual(@as(u16, 65534), result.event.?.cursor_position.row); try testing.expectEqual(@as(u16, 65534), result.event.?.cursor_position.col); try testing.expectEqual(@as(usize, 0), pending.pending());}
test "cursor requests: idle deadline follows the last request, not reports" { var pending: CursorPositionRequests = .{ .io = testing.io }; pending.request(); pending.last_request_at = pending.last_request_at.subDuration(.fromMilliseconds(500)); const previous = pending.last_request_at; pending.request(); try testing.expectEqual(@as(usize, 2), pending.pending()); try testing.expect(pending.last_request_at.nanoseconds > previous.nanoseconds);
const last_request = pending.last_request_at; try testing.expect(pending.consume()); try testing.expectEqual(last_request, pending.last_request_at); pending.expire(last_request.addDuration(.fromNanoseconds(std.time.ns_per_s - 1))); try testing.expectEqual(@as(usize, 1), pending.count); pending.expire(last_request.addDuration(.fromSeconds(1))); try testing.expectEqual(@as(usize, 0), pending.count);}
test "parse: expired cursor requests restore F3 and new requests start fresh" { var pending: CursorPositionRequests = .{ .io = testing.io }; var parser: Parser = .{ .cursor_position_requests = &pending }; pending.request(); pending.request(); pending.last_request_at = pending.last_request_at.subDuration(.fromSeconds(2));
const f3 = try parser.parse("\x1b[1;2R", null); try testing.expectEqual(Key.f3, f3.event.?.key_press.codepoint); try testing.expect(f3.event.?.key_press.mods.shift); try testing.expectEqual(@as(usize, 0), pending.count);
pending.request(); pending.request(); pending.last_request_at = pending.last_request_at.subDuration(.fromSeconds(2)); pending.request(); try testing.expectEqual(@as(usize, 1), pending.pending()); const report = try parser.parse("\x1b[1;2R", null); try testing.expectEqual(@as(u16, 1), report.event.?.cursor_position.col); try testing.expectEqual(@as(usize, 0), pending.pending());}
test "parse(csi): kitty multi cursor" { var buf: [1]u8 = undefined; { const input = "\x1b[>1;2;3;29;30;40;100;101 q"; const result = parseCsi(input, &buf, null); const expected: Result = .{ .event = .cap_multi_cursor, .n = input.len, };
try testing.expectEqual(expected.n, result.n); try testing.expectEqual(expected.event, result.event); } { const input = "\x1b[> q"; const result = parseCsi(input, &buf, null); const expected: Result = .{ .event = null, .n = input.len, };
try testing.expectEqual(expected.n, result.n); try testing.expectEqual(expected.event, result.event); }}
test "parse(csi): decrpm" { var buf: [1]u8 = undefined; { const input = "\x1b[?1016;1$y"; const result = parseCsi(input, &buf, null); const expected: Result = .{ .event = .cap_sgr_pixels, .n = input.len, };
try testing.expectEqual(expected.n, result.n); try testing.expectEqual(expected.event, result.event); } { const input = "\x1b[?1016;0$y"; const result = parseCsi(input, &buf, null); const expected: Result = .{ .event = null, .n = input.len, };
try testing.expectEqual(expected.n, result.n); try testing.expectEqual(expected.event, result.event); }}
test "parse(csi): primary da" { var buf: [1]u8 = undefined; const input = "\x1b[?c"; const result = parseCsi(input, &buf, null); const expected: Result = .{ .event = .cap_da1, .n = input.len, };
try testing.expectEqual(expected.n, result.n); try testing.expectEqual(expected.event, result.event);}
test "parse(csi): dsr" { var buf: [1]u8 = undefined; { const input = "\x1b[?997;1n"; const result = parseCsi(input, &buf, null); const expected: Result = .{ .event = .{ .color_scheme = .dark }, .n = input.len, };
try testing.expectEqual(expected.n, result.n); try testing.expectEqual(expected.event, result.event); } { const input = "\x1b[?997;2n"; const result = parseCsi(input, &buf, null); const expected: Result = .{ .event = .{ .color_scheme = .light }, .n = input.len, };
try testing.expectEqual(expected.n, result.n); try testing.expectEqual(expected.event, result.event); } { const input = "\x1b[0n"; const result = parseCsi(input, &buf, null); const expected: Result = .{ .event = null, .n = input.len, };
try testing.expectEqual(expected.n, result.n); try testing.expectEqual(expected.event, result.event); }}
test "parse(csi): mouse" { var buf: [1]u8 = undefined; const input = "\x1b[<35;1;1m"; const result = parseCsi(input, &buf, null); const expected: Result = .{ .event = .{ .mouse = .{ .col = 0, .row = 0, .button = .none, .type = .motion, .mods = .{}, } }, .n = input.len, };
try testing.expectEqual(expected.n, result.n); try testing.expectEqual(expected.event, result.event);}
test "parse(csi): mouse (negative)" { var buf: [1]u8 = undefined; const input = "\x1b[<35;-50;-100m"; const result = parseCsi(input, &buf, null); const expected: Result = .{ .event = .{ .mouse = .{ .col = -51, .row = -101, .button = .none, .type = .motion, .mods = .{}, } }, .n = input.len, };
try testing.expectEqual(expected.n, result.n); try testing.expectEqual(expected.event, result.event);}
test "parse(csi): xterm mouse" { var buf: [1]u8 = undefined; const input = "\x1b[M\x20\x21\x21"; const result = parseCsi(input, &buf, null); const expected: Result = .{ .event = .{ .mouse = .{ .col = 0, .row = 0, .button = .left, .type = .press, .mods = .{}, } }, .n = input.len, };
try testing.expectEqual(expected.n, result.n); try testing.expectEqual(expected.event, result.event);}
test "parse: disambiguate shift + space" { const alloc = testing.allocator_instance.allocator(); const input = "\x1b[32;2u"; var parser: Parser = .{}; const result = try parser.parse(input, alloc); const expected_key: Key = .{ .codepoint = ' ', .shifted_codepoint = ' ', .mods = .{ .shift = true }, .text = " ", }; const expected_event: Event = .{ .key_press = expected_key };
try testing.expectEqual(7, result.n); try testing.expectEqualDeep(expected_event, result.event);}
test { std.testing.refAllDecls(@This());}
test "parse: bel-terminated osc color report followed by da1" { // A BEL-terminated OSC 11 (background color) reply immediately followed by // a DA1 reply, as they can arrive together in one read(). The OSC must stop // at the BEL and must not consume into the following sequence. var parser: Parser = .{}; const input = "\x1b]11;rgb:0000/0000/0000\x07\x1b[?1;2c"; const result = try parser.parse(input, null); try testing.expectEqual(24, result.n); switch (result.event.?) { .color_report => {}, else => try testing.expect(false), } // The DA1 that follows must survive intact. const rest = try parser.parse(input[result.n..], null); switch (rest.event.?) { .cap_da1 => {}, else => try testing.expect(false), }}
test "parse: bel-terminated osc does not consume the following sequence" { // An OSC (BEL-terminated) directly followed by a CSI cursor-up. The OSC must // stop at the BEL and leave the CSI intact. var parser: Parser = .{}; const input = "\x1b]0;title\x07\x1b[A"; const result = try parser.parse(input, null); try testing.expectEqual(10, result.n); const rest = try parser.parse(input[result.n..], null); switch (rest.event.?) { .key_press => |key| try testing.expectEqual(Key.up, key.codepoint), else => try testing.expect(false), }}
test "parse: osc interrupted by escape leaves the next sequence intact" { // Per the DEC parser, ESC is an anywhere transition out of OSC. If it is not // the ST sequence (ESC \\), the malformed OSC should be discarded up to the // ESC and the new escape sequence should be parsed next. var parser: Parser = .{}; const input = "\x1b]0;title\x1b[A"; const result = try parser.parse(input, null); try testing.expectEqual(9, result.n); try testing.expectEqual(@as(?Event, null), result.event);
const rest = try parser.parse(input[result.n..], null); switch (rest.event.?) { .key_press => |key| try testing.expectEqual(Key.up, key.codepoint), else => try testing.expect(false), }}
test "parse: malformed osc color spec is consumed without event" { var parser: Parser = .{}; const input = "\x1b]4;1;rgb:zz/zz/zz\x1b\\"; const result = try parser.parse(input, null); try testing.expectEqual(input.len, result.n); try testing.expectEqual(@as(?Event, null), result.event);}
test "parse: osc 4 without color spec does not read past the sequence" { var parser: Parser = .{}; const input = "\x1b]4;1\x07\x1b]4;2;rgb:0000/0000/0000\x07"; const result = try parser.parse(input, null); try testing.expectEqual(6, result.n); try testing.expectEqual(@as(?Event, null), result.event);
// The complete OSC 4 that follows must survive intact. const rest = try parser.parse(input[result.n..], null); switch (rest.event.?) { .color_report => |report| try testing.expectEqual(2, report.kind.index), else => try testing.expect(false), }}
test "parse: truncated osc 52 is consumed without event" { var parser: Parser = .{}; const input = "\x1b]52;c\x1b\\"; const result = try parser.parse(input, testing.allocator); try testing.expectEqual(input.len, result.n); try testing.expectEqual(@as(?Event, null), result.event);}
test "parse: osc 52 with invalid base64 is consumed without event" { var parser: Parser = .{}; const input = "\x1b]52;c;!!not base64!!\x1b\\"; const result = try parser.parse(input, testing.allocator); try testing.expectEqual(input.len, result.n); try testing.expectEqual(@as(?Event, null), result.event);}
test "parse: osc 52 without payload separator is consumed without event" { var parser: Parser = .{}; const input = "\x1b]52;cXb3NjNTIgcGFzdGU=\x1b\\"; const result = try parser.parse(input, testing.allocator); try testing.expectEqual(input.len, result.n); try testing.expectEqual(@as(?Event, null), result.event);}
test "parse: osc 52 without an allocator is consumed without event" { var parser: Parser = .{}; const input = "\x1b]52;c;b3NjNTIgcGFzdGU=\x1b\\"; const result = try parser.parse(input, null); try testing.expectEqual(input.len, result.n); try testing.expectEqual(@as(?Event, null), result.event);}
test "parse: osc ending with escape waits for a possible ST" { // If the buffer ends at ESC, it may still become an ST when more bytes // arrive, so the parser should wait instead of discarding the OSC. var parser: Parser = .{}; const input = "\x1b]0;title\x1b"; const result = try parser.parse(input, null); try testing.expectEqual(0, result.n); try testing.expectEqual(@as(?Event, null), result.event);}
test "parse: osc 7501 program status feature detection reply" { var parser: Parser = .{}; for ([_][]const u8{ "\x1b]7501;?\x1b\\", "\x1b]7501;?\x07", // Programs must ignore anything a future revision adds after the '?' "\x1b]7501;?v=2:extra=1\x1b\\", }) |input| { const result = try parser.parse(input, null); try testing.expectEqual(input.len, result.n); try testing.expectEqual(@as(?Event, .cap_program_status), result.event); }
// Anything that is not a feature detection reply is consumed silently for ([_][]const u8{ "\x1b]7501;state=idle\x1b\\", "\x1b]7501;\x1b\\", "\x1b]7500;?\x1b\\", "\x1b]75011;?\x1b\\", }) |input| { const result = try parser.parse(input, null); try testing.expectEqual(input.len, result.n); try testing.expectEqual(@as(?Event, null), result.event); }}
test "parse: osc 7501 reply followed by da1" { var parser: Parser = .{}; const input = "\x1b]7501;?\x1b\\\x1b[?62;22c"; const result = try parser.parse(input, null); try testing.expectEqual(10, result.n); try testing.expectEqual(@as(?Event, .cap_program_status), result.event); const rest = try parser.parse(input[result.n..], null); try testing.expectEqual(@as(?Event, .cap_da1), rest.event);}
test "parse: apc split before the backslash of the terminator" { var parser: Parser = .{}; const input = "\x1b_Gi=1;OK\x1b\\"; const partial = try parser.parse(input[0 .. input.len - 1], null); try testing.expectEqual(0, partial.n); try testing.expectEqual(@as(?Event, null), partial.event); const result = try parser.parse(input, null); try testing.expectEqual(input.len, result.n); try testing.expectEqual(@as(?Event, .cap_kitty_graphics), result.event);}
fn codepointsInput(comptime count: usize) []const u8 { comptime var text: []const u8 = "\x1b[97;1;128512"; inline for (1..count) |_| text = text ++ ":128512"; return text ++ "u";}
test "parse: kitty: text as codepoints that fill the text buffer" { var parser: Parser = .{}; // 32 four-byte codepoints fill the 128-byte text buffer exactly const input = codepointsInput(32); const result = try parser.parse(input, null); try testing.expectEqual(input.len, result.n); try testing.expectEqual(128, result.event.?.key_press.text.?.len);}
test "parse: kitty: text as codepoints longer than the text buffer" { var parser: Parser = .{}; // 40 four-byte codepoints do not fit const input = codepointsInput(40); const result = try parser.parse(input, null); try testing.expectEqual(input.len, result.n);}