//! Terminal input event decoder. //! //! Port of ultraviolet's `decoder.go` (and the small helper functions it //! uses). The `EventDecoder` turns a byte buffer of terminal input into the //! first recognized event plus its length. //! //! Port deviations (documented, matching the philosophy of this codebase): //! - `LegacyKeyEncoding` is a packed struct of bool flags instead of a //! Go integer bitmask. //! - The Windows Input Mode path (`parseWin32InputKeyEvent`) and the //! Terminfo database path are de-scoped: Windows sequences decode to //! `UnknownEvent`, and `UseTerminfo` is accepted but unused. //! - Grapheme clustering in `parseUtf8` is a pragmatic subset of //! `rivo/uniseg`, handling ZWJ sequences and combining marks without the //! full Unicode segmentation tables. //! - All event payloads borrow from the input buffer or from decoder-owned //! scratch, so a returned `Event` is only valid until the next `Decode`. const std = @import("std"); const ir = @import("root.zig"); const event = ir.event; const Key = ir.Key; const parser = ir.parser; const ansi = ir.ansi; const Mouse = ir.Mouse; const Event = event.Event; const Rgb = event.Rgb; const ModeSetting = event.ModeSetting; const xParseColor = event.xParseColor; const max_device_attrs = event.max_device_attrs; const max_window_op_args = event.max_window_op_args; const max_multi_events = event.max_multi_events; const t = std.testing; /// Flags controlling legacy terminal key encodings. Mirrors `uv.LegacyKeyEncoding`. pub const LegacyKeyEncoding = packed struct { /// Treat both Ctrl+Space and Ctrl+@ (NUL) as the same key. ctrl_at: bool = false, /// Treat both the Tab key and Ctrl+I (HT) as the same key. ctrl_i: bool = false, /// Treat both the Enter key and Ctrl+M (CR) as the same key. ctrl_m: bool = false, /// Treat both Escape and Ctrl+[ (ESC) as the same key. ctrl_open_bracket: bool = false, /// Map the backspace key to BS (0x08) instead of DEL (0x7F). backspace: bool = false, /// Recognize the legacy Find key instead of Home. find: bool = false, /// Recognize the legacy Select key instead of End. select: bool = false, /// Preserve high function keys (F13-F63) as symbols. f_keys: bool = false, }; /// A decoded event plus the number of input bytes it consumed. pub const DecodeResult = struct { n: usize, event: ?Event, }; /// Decodes terminal input events from a byte buffer. pub const EventDecoder = struct { /// Legacy key encoding flags. legacy: LegacyKeyEncoding = .{}, /// Whether to use the terminal type Terminfo database. Unused in this port. use_terminfo: bool = false, multi_scratch: [max_multi_events]Event = undefined, text_scratch: [256]u8 = undefined, clipboard_scratch: [2048]u8 = undefined, byte_scratch: [1]u8 = undefined, last_cks: u32 = 0, /// Builds a multi event from `events` (up to `max_multi_events`), /// terminating the remaining slots, and returning an event that points /// at this decoder's owned scratch. fn makeMulti(self: *EventDecoder, events: []const Event) Event { var n: usize = 0; for (events) |e| { if (n >= max_multi_events) break; self.multi_scratch[n] = e; n += 1; } while (n < max_multi_events) : (n += 1) { self.multi_scratch[n] = .{ .unknown = "" }; } return .{ .multi = &self.multi_scratch }; } /// Decodes the first recognized event sequence, returning it along with /// its length. Returns `{0, null}` for an empty buffer; unsupported /// sequences produce an `Unknown*Event`. pub fn decode(self: *EventDecoder, buf: []const u8) DecodeResult { if (buf.len == 0) return .{ .n = 0, .event = null }; const b = buf[0]; switch (b) { ansi.ESC => { if (buf.len == 1) { // Escape key return .{ .n = 1, .event = .{ .key_press = .{ .code = Key.Code.KeyEscape.rune() } } }; } switch (buf[1]) { 'O' => return self.parseSs3(buf), // Esc-prefixed SS3 'P' => return self.parseDcs(buf), // Esc-prefixed DCS '[' => return self.parseCsi(buf), // Esc-prefixed CSI ']' => return self.parseOsc(buf), // Esc-prefixed OSC '_' => return self.parseApc(buf), // Esc-prefixed APC '^' => return self.parseStTerminated(ansi.PM, '^', null, buf), // Esc-prefixed PM 'X' => return self.parseStTerminated(ansi.SOS, 'X', null, buf), // Esc-prefixed SOS else => { const sub = self.decode(buf[1..]); if (sub.event) |*e| { if (e.* == .key_press) { var k = e.key_press; k.text = ""; k.mod = .fromMask(k.mod.value() | alt_mod.value()); return .{ .n = sub.n + 1, .event = .{ .key_press = k } }; } } // Not a key sequence, nor an alt modified key sequence. return .{ .n = 1, .event = .{ .key_press = .{ .code = Key.Code.KeyEscape.rune() } } }; }, } }, ansi.SS3 => return self.parseSs3(buf), ansi.DCS => return self.parseDcs(buf), ansi.CSI => return self.parseCsi(buf), ansi.OSC => return self.parseOsc(buf), ansi.APC => return self.parseApc(buf), ansi.PM => return self.parseStTerminated(ansi.PM, '^', null, buf), ansi.SOS => return self.parseStTerminated(ansi.SOS, 'X', null, buf), else => { if (b <= ansi.US or b == ansi.DEL or b == ansi.SP) { return .{ .n = 1, .event = self.parseControl(b) }; } else if (b >= ansi.PAD and b <= ansi.APC) { // C1 control code: Ctrl+Alt+ const code: u21 = b - 0x40; return .{ .n = 1, .event = .{ .key_press = .{ .code = code, .mod = .{ .ctrl = true, .alt = true } } } }; } return self.parseUtf8(buf); }, } } fn parseCsi(self: *EventDecoder, b: []const u8) DecodeResult { if (b.len == 2 and b[0] == ansi.ESC) { // short cut if this is an alt+[ key return .{ .n = 2, .event = .{ .key_press = .{ .code = b[1], .mod = .{ .alt = true } } } }; } var cmd: parser.Cmd = .{ .value = 0 }; var params: [parser.max_params_size]parser.Param = undefined; @memset(¶ms, parser.missing_param); var params_len: usize = 0; var i: usize = 0; if (b[i] == ansi.CSI or b[i] == ansi.ESC) i += 1; if (i < b.len and b[i - 1] == ansi.ESC and b[i] == '[') i += 1; // Initial CSI byte if (i < b.len and b[i] >= '<' and b[i] <= '?') { cmd = cmd.applyPrefix(b[i]); } // Scan parameter bytes in the range 0x30-0x3F var j: usize = 0; while (i < b.len and params_len < params.len and b[i] >= 0x30 and b[i] <= 0x3F) : ({ i += 1; j += 1; }) { if (b[i] >= '0' and b[i] <= '9') { if (params[params_len] == parser.missing_param) { params[params_len] = 0; } params[params_len] *= 10; params[params_len] += @as(parser.Param, b[i]) - '0'; } if (b[i] == ':') { params[params_len] |= parser.has_more_flag; } if (b[i] == ';' or b[i] == ':') { params_len += 1; if (params_len < params.len) { params[params_len] = parser.missing_param; } } } if (j > 0 and params_len < params.len) { params_len += 1; } // Scan intermediate bytes in the range 0x20-0x2F var intermed: u8 = 0; while (i < b.len and b[i] >= 0x20 and b[i] <= 0x2F) : (i += 1) { intermed = b[i]; } cmd = cmd.applyIntermed(intermed); // Scan final byte in the range 0x40-0x7E if (i >= b.len or b[i] < 0x40 or b[i] > 0x7E) { // Special case for URxvt keys: CSI $ if (intermed == '$' and i > 0 and b[i - 1] == '$') { // Clone b[0..i-1] and append '~' var tmp: [256]u8 = undefined; var tlen: usize = i - 1; if (tlen > tmp.len) tlen = tmp.len; @memcpy(tmp[0..tlen], b[0..tlen]); tmp[tlen] = '~'; const sub = self.parseCsi(tmp[0 .. tlen + 1]); if (sub.event) |*e| { if (e.* == .key_press) { var k = e.key_press; k.mod = .fromMask(k.mod.value() | shift_mod.value()); return .{ .n = sub.n, .event = .{ .key_press = k } }; } } } return .{ .n = i, .event = .{ .unknown_csi = b[0..i] } }; } // Add the final byte cmd = cmd.applyFinal(b[i]); i += 1; const pa = parser.Params{ .items = params[0..params_len] }; // 'y' | '?'<'<') << parser.prefix_shift)) { return .{ .n = i, .event = parseSecondaryDevAttrs(pa) }; } // 'u' | '?'<'<') << parser.prefix_shift)) { const mok = pa.at(0, 0); if (mok.ok and mok.value == 4) { const val = pa.at(1, -1); if (val.ok and val.value != -1) { return .{ .n = i, .event = .{ .modify_other_keys = .{ .mode = val.value } } }; } } } // 'n' | '?'< return .{ .n = i, .event = .{ .dark_color_scheme = {} } }, 2 => return .{ .n = i, .event = .{ .light_color_scheme = {} } }, else => {}, } } } if (cmd.value == (@as(i32, 'I'))) { return .{ .n = i, .event = .{ .focus = {} } }; } if (cmd.value == (@as(i32, 'O'))) { return .{ .n = i, .event = .{ .blur = {} } }; } if (cmd.value == (@as(i32, 'R'))) { // Cursor position report OR modified F3 const row = pa.at(0, 1); const col = pa.at(1, 1); if (params_len == 2 and row.ok and col.ok) { const y = row.value - 1; const x = col.value - 1; const m = Event{ .cursor_position = .{ .x = x, .y = y } }; if (row.value == 1 and col.value - 1 <= @as(i32, 0xF)) { // Ambiguous: report both F3 (modified) and cursor position. const f3 = Event{ .key_press = .{ .code = Key.Code.KeyF3.rune(), .mod = .fromMask(modToMask(col.value - 1)) } }; return .{ .n = i, .event = self.makeMulti(&.{ f3, m }) }; } return .{ .n = i, .event = m }; } if (params_len != 0) return .{ .n = i, .event = .{ .unknown_csi = b[0..i] } }; // Unmodified key F3 (CSI R) — fallthrough handled inline below. var k: Key = .{}; k.code = Key.Code.KeyF3.rune(); const id = pa.at(0, 1).value; const mod = pa.at(1, 1).value; if (params_len > 2 and !pa.hasMore(1) or id != 1) { return .{ .n = i, .event = .{ .unknown_csi = b[0..i] } }; } if (params_len > 1 and id == 1 and mod != -1) { k.mod = .fromMask(modToMask(mod - 1)); } return .{ .n = i, .event = parseKittyKeyboardExt(pa, k) }; } // 'a','b','c','d','A'-'D','E','F','H','P','Q','S','Z' if (cmd.final() == 'a' or cmd.final() == 'b' or cmd.final() == 'c' or cmd.final() == 'd' or cmd.final() == 'A' or cmd.final() == 'B' or cmd.final() == 'C' or cmd.final() == 'D' or cmd.final() == 'E' or cmd.final() == 'F' or cmd.final() == 'H' or cmd.final() == 'P' or cmd.final() == 'Q' or cmd.final() == 'S' or cmd.final() == 'Z') { var k: Key = .{}; switch (cmd.final()) { 'a'...'d' => { k.code = Key.Code.KeyUp.rune() + (cmd.final() - 'a'); k.mod = .{ .shift = true }; }, 'A'...'D' => { k.code = Key.Code.KeyUp.rune() + (cmd.final() - 'A'); }, 'E' => k.code = Key.Code.KeyBegin.rune(), 'F' => k.code = Key.Code.KeyEnd.rune(), 'H' => k.code = Key.Code.KeyHome.rune(), 'P', 'Q', 'R', 'S' => { k.code = Key.Code.KeyF1.rune() + (cmd.final() - 'P'); }, 'Z' => { k.code = Key.Code.KeyTab.rune(); k.mod = .{ .shift = true }; }, else => {}, } const id = pa.at(0, 1).value; const mod = pa.at(1, 1).value; if (params_len > 2 and !pa.hasMore(1) or id != 1) { return .{ .n = i, .event = .{ .unknown_csi = b[0..i] } }; } if (params_len > 1 and id == 1 and mod != -1) { k.mod = .fromMask(modToMask(mod - 1)); } return .{ .n = i, .event = parseKittyKeyboardExt(pa, k) }; } if (cmd.final() == 'M') { // Handle X10 mouse if (i + 3 > b.len) { return .{ .n = i, .event = .{ .unknown_csi = b[0..i] } }; } return .{ .n = i + 3, .event = parseX10MouseEvent(b[i .. i + 3]) }; } // 'y' | '$'< UnknownEvent) return .{ .n = i, .event = .{ .unknown_csi = b[0..i] } }; } if (cmd.final() == '@' or cmd.final() == '^' or cmd.final() == '~') { if (params_len == 0) { return .{ .n = i, .event = .{ .unknown_csi = b[0..i] } }; } const param = pa.at(0, 0).value; if (cmd.final() == '~') { switch (param) { 27 => { // XTerm modifyOtherKeys 2 if (params_len != 3) { return .{ .n = i, .event = .{ .unknown_csi = b[0..i] } }; } return .{ .n = i, .event = parseXTermModifyOtherKeys(pa) }; }, 200 => return .{ .n = i, .event = .{ .paste_start = {} } }, 201 => return .{ .n = i, .event = .{ .paste_end = {} } }, else => {}, } } switch (param) { 1, 2, 3, 4, 5, 6, 7, 8, 11, 12, 13, 14, 15, 17, 18, 19, 20, 21, 23, 24, 25, 26, 28, 29, 31, 32, 33, 34, => { var k: Key = .{}; switch (param) { 1 => { if (self.legacy.find) { k.code = Key.Code.KeyFind.rune(); } else { k.code = Key.Code.KeyHome.rune(); } }, 2 => k.code = Key.Code.KeyInsert.rune(), 3 => k.code = Key.Code.KeyDelete.rune(), 4 => { if (self.legacy.select) { k.code = Key.Code.KeySelect.rune(); } else { k.code = Key.Code.KeyEnd.rune(); } }, 5 => k.code = Key.Code.KeyPgUp.rune(), 6 => k.code = Key.Code.KeyPgDown.rune(), 7 => k.code = Key.Code.KeyHome.rune(), 8 => k.code = Key.Code.KeyEnd.rune(), 11...15 => k.code = codeAdd(Key.Code.KeyF1.rune(), param - 11), 17...21 => k.code = codeAdd(Key.Code.KeyF6.rune(), param - 17), 23...26 => k.code = codeAdd(Key.Code.KeyF11.rune(), param - 23), 28, 29 => k.code = codeAdd(Key.Code.KeyF15.rune(), param - 28), 31...34 => k.code = codeAdd(Key.Code.KeyF17.rune(), param - 31), else => {}, } // modifiers const mod = pa.at(1, -1); if (params_len > 1 and mod.value != -1) { k.mod = .fromMask(modToMask(mod.value - 1)); } // Handle URxvt weird keys switch (cmd.final()) { '~' => return .{ .n = i, .event = parseKittyKeyboardExt(pa, k) }, '^' => { k.mod = .fromMask(k.mod.value() | ctrl_mod.value()); }, '@' => { k.mod = .fromMask(k.mod.value() | ctrl_mod.value() | shift_mod.value()); }, else => {}, } return .{ .n = i, .event = .{ .key_press = k } }; }, else => {}, } } if (cmd.value == (@as(i32, 't'))) { const rop = pa.at(0, 0); const op = rop.value; if (!rop.ok) return .{ .n = i, .event = .{ .unknown_csi = b[0..i] } }; switch (op) { 4 => { // Report Terminal window size in pixels if (params_len == 3) { const h = pa.at(1, 0); const w = pa.at(2, 0); if (h.ok and w.ok) { return .{ .n = i, .event = .{ .pixel_size = .{ .width = @intCast(w.value), .height = @intCast(h.value) } } }; } } }, 6 => { // Report Terminal character cell size if (params_len == 3) { const h = pa.at(1, 0); const w = pa.at(2, 0); if (h.ok and w.ok) { return .{ .n = i, .event = .{ .cell_size = .{ .width = @intCast(w.value), .height = @intCast(h.value) } } }; } } }, 8 => { // Report Terminal Window size in cells if (params_len == 3) { const h = pa.at(1, 0); const w = pa.at(2, 0); if (h.ok and w.ok) { return .{ .n = i, .event = .{ .window_size = .{ .width = @intCast(w.value), .height = @intCast(h.value) } } }; } } }, 48 => { // In band terminal size report if (params_len == 5) { const ch = pa.at(1, 0); const cw = pa.at(2, 0); const ph = pa.at(3, 0); const pw = pa.at(4, 0); if (ch.ok and cw.ok and ph.ok and pw.ok) { const win = Event{ .window_size = .{ .width = @intCast(cw.value), .height = @intCast(ch.value) } }; const pix = Event{ .pixel_size = .{ .width = @intCast(pw.value), .height = @intCast(ph.value) } }; return .{ .n = i, .event = self.makeMulti(&.{ win, pix }) }; } } }, else => {}, } // Any other window operation event. var args: [max_window_op_args]i32 = .{0} ** max_window_op_args; var args_len: usize = 0; var jj: usize = 1; while (jj < params_len and args_len < args.len) : (jj += 1) { const v = pa.at(jj, 0); if (v.ok) { args[args_len] = v.value; args_len += 1; } } return .{ .n = i, .event = .{ .window_op = .{ .op = op, .args = args } } }; } return .{ .n = i, .event = .{ .unknown_csi = b[0..i] } }; } fn parseSs3(self: *EventDecoder, b: []const u8) DecodeResult { _ = self; if (b.len == 2 and b[0] == ansi.ESC) { // short cut if this is an alt+O key const code = unicodeToLower(b[1]); return .{ .n = 2, .event = .{ .key_press = .{ .code = code, .mod = .{ .shift = true, .alt = true } } } }; } var i: usize = 0; if (b[i] == ansi.SS3 or b[i] == ansi.ESC) i += 1; if (i < b.len and b[i - 1] == ansi.ESC and b[i] == 'O') i += 1; // Scan numbers from 0-9 var mod: i32 = 0; while (i < b.len and b[i] >= '0' and b[i] <= '9') : (i += 1) { mod *= 10; mod += @as(i32, b[i]) - '0'; } // Scan a GL character (single byte in range 0x21-0x7E) if (i >= b.len or b[i] < 0x21 or b[i] > 0x7E) { return .{ .n = i, .event = .{ .unknown_ss3 = b[0..i] } }; } const gl = b[i]; i += 1; var k: Key = .{}; switch (gl) { 'a'...'d' => { k.code = Key.Code.KeyUp.rune() + (gl - 'a'); k.mod = .{ .ctrl = true }; }, 'A'...'D' => k.code = Key.Code.KeyUp.rune() + (gl - 'A'), 'E' => k.code = Key.Code.KeyBegin.rune(), 'F' => k.code = Key.Code.KeyEnd.rune(), 'H' => k.code = Key.Code.KeyHome.rune(), 'P'...'S' => k.code = Key.Code.KeyF1.rune() + (gl - 'P'), 'M' => k.code = Key.Code.KeyKpEnter.rune(), 'X' => k.code = Key.Code.KeyKpEqual.rune(), 'j'...'y' => k.code = Key.Code.KeyKpMultiply.rune() + (gl - 'j'), else => return .{ .n = i, .event = .{ .unknown_ss3 = b[0..i] } }, } // Handle weird SS3 Func if (mod > 0) { k.mod = .fromMask(modToMask(mod - 1)); } return .{ .n = i, .event = .{ .key_press = k } }; } fn parseOsc(self: *EventDecoder, b: []const u8) DecodeResult { const default_key = struct { fn f(bb: []const u8) Event { return .{ .key_press = .{ .code = bb[1], .mod = .{ .alt = true } } }; } }.f; if (b.len == 2 and b[0] == ansi.ESC) { return .{ .n = 2, .event = default_key(b) }; } var i: usize = 0; if (b[i] == ansi.OSC or b[i] == ansi.ESC) i += 1; if (i < b.len and b[i - 1] == ansi.ESC and b[i] == ']') i += 1; // Parse OSC command var start: usize = 0; var end: usize = 0; var cmd: i32 = -1; while (i < b.len and b[i] >= '0' and b[i] <= '9') : (i += 1) { if (cmd == -1) { cmd = 0; } else { cmd *= 10; } cmd += @as(i32, b[i]) - '0'; } if (i < b.len and b[i] == ';') { i += 1; start = i; } while (i < b.len) : (i += 1) { if (b[i] == ansi.BEL or b[i] == ansi.ESC or b[i] == ansi.ST or b[i] == ansi.CAN or b[i] == ansi.SUB) { break; } } if (i >= b.len) { return .{ .n = i, .event = .{ .unknown = b[0..i] } }; } end = i; i += 1; // Check 7-bit ST (string terminator) character switch (b[i - 1]) { ansi.CAN, ansi.SUB => return .{ .n = i, .event = .{ .ignored = b[0..i] } }, ansi.ESC => { if (i >= b.len or b[i] != '\\') { if (cmd == -1 or (start == 0 and end == 2)) { return .{ .n = 2, .event = default_key(b) }; } return .{ .n = i, .event = .{ .ignored = b[0..i] } }; } i += 1; }, else => {}, } if (end <= start) { return .{ .n = i, .event = .{ .unknown = b[0..i] } }; } const data = b[start..end]; switch (cmd) { 10 => return .{ .n = i, .event = .{ .foreground_color = xParseColor(data) orelse .{ .r = 0, .g = 0, .b = 0 } } }, 11 => return .{ .n = i, .event = .{ .background_color = xParseColor(data) orelse .{ .r = 0, .g = 0, .b = 0 } } }, 12 => return .{ .n = i, .event = .{ .cursor_color = xParseColor(data) orelse .{ .r = 0, .g = 0, .b = 0 } } }, 52 => { var parts = std.mem.splitScalar(u8, data, ';'); const head = parts.next() orelse ""; const tail = parts.next(); if (tail == null or head.len < 1) { return .{ .n = i, .event = .{ .clipboard = .{} } }; } const b64 = tail.?; var content: []const u8 = b64; if (base64Decode(self.clipboard_scratch[0..], b64)) |decoded| { content = decoded; } const sel: u8 = head[0]; return .{ .n = i, .event = .{ .clipboard = .{ .content = content, .selection = sel } } }; }, else => {}, } return .{ .n = i, .event = .{ .unknown_osc = b[0..i] } }; } fn parseStTerminated(self: *EventDecoder, intro8: u8, intro7: u8, fn_: ?*const fn ([]const u8) ?Event, b: []const u8) DecodeResult { _ = self; if (b.len == 2 and b[0] == ansi.ESC) { // short cut: alt-modified key switch (intro8) { ansi.SOS => return .{ .n = 2, .event = .{ .key_press = .{ .code = unicodeToLower(b[1]), .mod = .{ .shift = true, .alt = true } } } }, ansi.PM, ansi.APC => return .{ .n = 2, .event = .{ .key_press = .{ .code = b[1], .mod = .{ .alt = true } } } }, else => {}, } } var i: usize = 0; if (b[i] == intro8 or b[i] == ansi.ESC) i += 1; if (i < b.len and b[i - 1] == ansi.ESC and b[i] == intro7) i += 1; const start = i; while (i < b.len) : (i += 1) { if (b[i] == ansi.ESC or b[i] == ansi.ST or b[i] == ansi.CAN or b[i] == ansi.SUB) { break; } } if (i >= b.len) { return .{ .n = i, .event = .{ .unknown = b[0..i] } }; } const end = i; i += 1; // Check 7-bit ST (string terminator) character switch (b[i - 1]) { ansi.CAN, ansi.SUB => return .{ .n = i, .event = .{ .ignored = b[0..i] } }, ansi.ESC => { if (i >= b.len or b[i] != '\\') { if (start == end) { switch (intro8) { ansi.SOS => return .{ .n = 2, .event = .{ .key_press = .{ .code = unicodeToLower(b[1]), .mod = .{ .shift = true, .alt = true } } } }, ansi.PM, ansi.APC => return .{ .n = 2, .event = .{ .key_press = .{ .code = b[1], .mod = .{ .alt = true } } } }, else => {}, } } return .{ .n = i, .event = .{ .ignored = b[0..i] } }; } i += 1; }, else => {}, } // Call the user function to parse the sequence if (fn_) |f| { if (f(b[start..end])) |e| { return .{ .n = i, .event = e }; } } switch (intro8) { ansi.PM => return .{ .n = i, .event = .{ .unknown_pm = b[0..i] } }, ansi.SOS => return .{ .n = i, .event = .{ .unknown_sos = b[0..i] } }, ansi.APC => return .{ .n = i, .event = .{ .unknown_apc = b[0..i] } }, else => return .{ .n = i, .event = .{ .unknown = b[0..i] } }, } } fn parseDcs(self: *EventDecoder, b: []const u8) DecodeResult { if (b.len == 2 and b[0] == ansi.ESC) { return .{ .n = 2, .event = .{ .key_press = .{ .code = unicodeToLower(b[1]), .mod = .{ .shift = true, .alt = true } } } }; } var params: [16]parser.Param = undefined; @memset(¶ms, parser.missing_param); var params_len: usize = 0; var cmd: parser.Cmd = .{ .value = 0 }; var i: usize = 0; if (b[i] == ansi.DCS or b[i] == ansi.ESC) i += 1; if (i < b.len and b[i - 1] == ansi.ESC and b[i] == 'P') i += 1; // initial DCS byte if (i < b.len and b[i] >= '<' and b[i] <= '?') { cmd = cmd.applyPrefix(b[i]); } // Scan parameter bytes in the range 0x30-0x3F var j: usize = 0; while (i < b.len and params_len < params.len and b[i] >= 0x30 and b[i] <= 0x3F) : ({ i += 1; j += 1; }) { if (b[i] >= '0' and b[i] <= '9') { if (params[params_len] == parser.missing_param) { params[params_len] = 0; } params[params_len] *= 10; params[params_len] += @as(parser.Param, b[i]) - '0'; } if (b[i] == ':') params[params_len] |= parser.has_more_flag; if (b[i] == ';' or b[i] == ':') { params_len += 1; if (params_len < params.len) params[params_len] = parser.missing_param; } } if (j > 0 and params_len < params.len) params_len += 1; // Scan intermediate bytes in the range 0x20-0x2F var intermed: u8 = 0; while (i < b.len and b[i] >= 0x20 and b[i] <= 0x2F) : (i += 1) { intermed = b[i]; } cmd = cmd.applyIntermed(intermed); // Scan final byte in the range 0x40-0x7E if (i >= b.len or b[i] < 0x40 or b[i] > 0x7E) { return .{ .n = i, .event = .{ .unknown = b[0..i] } }; } cmd = cmd.applyFinal(b[i]); i += 1; const start = i; while (i < b.len) : (i += 1) { if (b[i] == ansi.ST or b[i] == ansi.ESC) break; } if (i >= b.len) { return .{ .n = i, .event = .{ .unknown = b[0..i] } }; } const end = i; i += 1; // Check 7-bit ST (string terminator) character if (i < b.len and b[i - 1] == ansi.ESC and b[i] == '\\') i += 1; const pa = parser.Params{ .items = params[0..params_len] }; // 'r' | '+'<'<') << parser.prefix_shift)) { return .{ .n = i, .event = .{ .terminal_version = b[start..end] } }; } // '|' | '!'< ansi.US and c < ansi.DEL) { // ASCII printable characters const code: u21 = c; var k: Key = .{ .code = code, .text = b[0..1] }; if (isUpper(code)) { k.code = unicodeToLower(code); k.shifted_code = code; k.mod = .{ .shift = true }; } return .{ .n = 1, .event = .{ .key_press = k } }; } // UTF-8: decode first rune const seq_len = std.unicode.utf8ByteSequenceLength(c) catch 1; if (b.len < seq_len) { return .{ .n = 1, .event = .{ .unknown = b[0..1] } }; } const r = std.unicode.utf8Decode(b[0..seq_len]) catch { return .{ .n = 1, .event = .{ .unknown = b[0..1] } }; }; var code: u21 = r; // Find the grapheme cluster length (pragmatic subset of uniseg) const clen = graphemeClusterLen(b); const text = b[0..clen]; // Check for multi-rune clusters -> KeyExtended var rune_count: usize = 0; var pos: usize = 0; while (pos < text.len and rune_count < 2) : (rune_count += 1) { const sz = std.unicode.utf8ByteSequenceLength(text[pos]) catch 1; if (pos + sz > text.len) break; pos += sz; } if (rune_count > 1) code = Key.key_extended; return .{ .n = clen, .event = .{ .key_press = .{ .code = code, .text = text } } }; } fn parseControl(self: *EventDecoder, b: u8) Event { switch (b) { ansi.NUL => { if (self.legacy.ctrl_at) { return .{ .key_press = .{ .code = '@', .mod = .{ .ctrl = true } } }; } return .{ .key_press = .{ .code = Key.Code.KeySpace.rune(), .mod = .{ .ctrl = true } } }; }, ansi.BS => return .{ .key_press = .{ .code = 'h', .mod = .{ .ctrl = true } } }, ansi.HT => { if (self.legacy.ctrl_i) { return .{ .key_press = .{ .code = 'i', .mod = .{ .ctrl = true } } }; } return .{ .key_press = .{ .code = Key.Code.KeyTab.rune() } }; }, ansi.CR => { if (self.legacy.ctrl_m) { return .{ .key_press = .{ .code = 'm', .mod = .{ .ctrl = true } } }; } return .{ .key_press = .{ .code = Key.Code.KeyEnter.rune() } }; }, ansi.ESC => { if (self.legacy.ctrl_open_bracket) { return .{ .key_press = .{ .code = '[', .mod = .{ .ctrl = true } } }; } return .{ .key_press = .{ .code = Key.Code.KeyEscape.rune() } }; }, ansi.DEL => { if (self.legacy.backspace) { return .{ .key_press = .{ .code = Key.Code.KeyDelete.rune() } }; } return .{ .key_press = .{ .code = Key.Code.KeyBackspace.rune() } }; }, ansi.SP => { self.byte_scratch[0] = ' '; return .{ .key_press = .{ .code = Key.Code.KeySpace.rune(), .text = self.byte_scratch[0..1] } }; }, else => { if (b >= ansi.SOH and b <= ansi.SUB) { const code: u21 = b + 0x60; return .{ .key_press = .{ .code = code, .mod = .{ .ctrl = true } } }; } else if (b >= ansi.FS and b <= ansi.US) { const code: u21 = b + 0x40; return .{ .key_press = .{ .code = code, .mod = .{ .ctrl = true } } }; } self.byte_scratch[0] = b; return .{ .unknown = self.byte_scratch[0..1] }; }, } } }; fn parseXTermModifyOtherKeys(params: parser.Params) Event { const xmod = params.at(1, 1).value; const xrune = params.at(2, 1).value; const mod = Key.Mod.fromMask(modToMask(xmod - 1)); const raw: u21 = toRune(xrune); const r: u21 = if (validRune(raw)) raw else 0xFFFD; switch (r) { ansi.BS => return .{ .key_press = .{ .mod = mod, .code = Key.Code.KeyBackspace.rune() } }, ansi.HT => return .{ .key_press = .{ .mod = mod, .code = Key.Code.KeyTab.rune() } }, ansi.CR => return .{ .key_press = .{ .mod = mod, .code = Key.Code.KeyEnter.rune() } }, ansi.ESC => return .{ .key_press = .{ .mod = mod, .code = Key.Code.KeyEscape.rune() } }, ansi.DEL => return .{ .key_press = .{ .mod = mod, .code = Key.Code.KeyBackspace.rune() } }, else => {}, } var k: Key = .{ .code = r, .mod = mod }; if (mod.value() <= 2) { // ModCtrl(Meta) ; 2 = ModShift _ = std.fmt.bufPrint(&text_scratch_global, "{u}", .{@as(u21, r)}) catch unreachable; k.text = text_scratch_global[0..1]; } return .{ .key_press = k }; } var text_scratch_global: [256]u8 = undefined; /// Kitty's private-use special-key wire codes (kickoff 0xE000). The tag value /// is the on-wire code; `keyCode` maps it to the XTerm `Key.Code` the decoder /// reports. A separate enum from `Key.Code` because the two numberings are /// unrelated (kitty's PUA vs XTerm's PUA runes + C0 control-code names). const KittyKey = enum(u16) { escape = 57344, enter = 57345, tab = 57346, backspace = 57347, insert = 57348, delete = 57349, left = 57350, right = 57351, up = 57352, down = 57353, page_up = 57354, page_down = 57355, home = 57356, end = 57357, caps_lock = 57358, scroll_lock = 57359, num_lock = 57360, print_screen = 57361, pause = 57362, menu = 57363, f1 = 57364, f2 = 57365, f3 = 57366, f4 = 57367, f5 = 57368, f6 = 57369, f7 = 57370, f8 = 57371, f9 = 57372, f10 = 57373, f11 = 57374, f12 = 57375, f13 = 57376, f14 = 57377, f15 = 57378, f16 = 57379, f17 = 57380, f18 = 57381, f19 = 57382, f20 = 57383, f21 = 57384, f22 = 57385, f23 = 57386, f24 = 57387, f25 = 57388, f26 = 57389, f27 = 57390, f28 = 57391, f29 = 57392, f30 = 57393, f31 = 57394, f32 = 57395, f33 = 57396, f34 = 57397, f35 = 57398, kp0 = 57399, kp1 = 57400, kp2 = 57401, kp3 = 57402, kp4 = 57403, kp5 = 57404, kp6 = 57405, kp7 = 57406, kp8 = 57407, kp9 = 57408, kp_decimal = 57409, kp_divide = 57410, kp_multiply = 57411, kp_minus = 57412, kp_plus = 57413, kp_enter = 57414, kp_equal = 57415, kp_sep = 57416, kp_left = 57417, kp_right = 57418, kp_up = 57419, kp_down = 57420, kp_page_up = 57421, kp_page_down = 57422, kp_home = 57423, kp_end = 57424, kp_insert = 57425, kp_delete = 57426, kp_begin = 57427, media_play = 57428, media_pause = 57429, media_play_pause = 57430, media_reverse = 57431, media_stop = 57432, media_fast_forward = 57433, media_rewind = 57434, media_next = 57435, media_prev = 57436, media_record = 57437, volume_lower = 57438, volume_raise = 57439, volume_mute = 57440, left_shift = 57441, left_ctrl = 57442, left_alt = 57443, left_super = 57444, left_hyper = 57445, left_meta = 57446, right_shift = 57447, right_ctrl = 57448, right_alt = 57449, right_super = 57450, right_hyper = 57451, right_meta = 57452, iso_level3_shift = 57453, iso_level5_shift = 57454, fn keyCode(self: KittyKey) Key.Code { const wire = @intFromEnum(self); // The F-key and keypad-digit runs keep their relative order in the // `Key.Code` numbering, so they map with a plain offset. if (wire >= 57364 and wire <= 57398) return @enumFromInt(1114156 + (@as(u21, wire) - 57364)); if (wire >= 57399 and wire <= 57408) return @enumFromInt(1114134 + (@as(u21, wire) - 57399)); return switch (self) { .escape => .KeyEscape, .enter => .KeyEnter, .tab => .KeyTab, .backspace => .KeyBackspace, .insert => .KeyInsert, .delete => .KeyDelete, .left => .KeyLeft, .right => .KeyRight, .up => .KeyUp, .down => .KeyDown, .page_up => .KeyPgUp, .page_down => .KeyPgDown, .home => .KeyHome, .end => .KeyEnd, .caps_lock => .KeyCapsLock, .scroll_lock => .KeyScrollLock, .num_lock => .KeyNumLock, .print_screen => .KeyPrintScreen, .pause => .KeyPause, .menu => .KeyMenu, .kp_decimal => .KeyKpDecimal, .kp_divide => .KeyKpDivide, .kp_multiply => .KeyKpMultiply, .kp_minus => .KeyKpMinus, .kp_plus => .KeyKpPlus, .kp_enter => .KeyKpEnter, .kp_equal => .KeyKpEqual, .kp_sep => .KeyKpSep, .kp_left => .KeyKpLeft, .kp_right => .KeyKpRight, .kp_up => .KeyKpUp, .kp_down => .KeyKpDown, .kp_page_up => .KeyKpPgUp, .kp_page_down => .KeyKpPgDown, .kp_home => .KeyKpHome, .kp_end => .KeyKpEnd, .kp_insert => .KeyKpInsert, .kp_delete => .KeyKpDelete, .kp_begin => .KeyKpBegin, .media_play => .KeyMediaPlay, .media_pause => .KeyMediaPause, .media_play_pause => .KeyMediaPlayPause, .media_reverse => .KeyMediaReverse, .media_stop => .KeyMediaStop, .media_fast_forward => .KeyMediaFastForward, .media_rewind => .KeyMediaRewind, .media_next => .KeyMediaNext, .media_prev => .KeyMediaPrev, .media_record => .KeyMediaRecord, .volume_lower => .KeyLowerVol, .volume_raise => .KeyRaiseVol, .volume_mute => .KeyMute, .left_shift => .KeyLeftShift, .left_ctrl => .KeyLeftCtrl, .left_alt => .KeyLeftAlt, .left_super => .KeyLeftSuper, .left_hyper => .KeyLeftHyper, .left_meta => .KeyLeftMeta, .right_shift => .KeyRightShift, .right_ctrl => .KeyRightCtrl, .right_alt => .KeyRightAlt, .right_super => .KeyRightSuper, .right_hyper => .KeyRightHyper, .right_meta => .KeyRightMeta, .iso_level3_shift => .KeyIsoLevel3Shift, .iso_level5_shift => .KeyIsoLevel5Shift, // f1..f35 and kp0..kp9 are handled by the affine offsets above. else => unreachable, }; } }; /// Kitty key code map. `null` when not a special code. fn kittyKey(code: i32) ?Key { // Kitty sends the C0 control-code names and the XTerm special-key runes // as `Key.Code` tags directly; resolve those through the enum so the // lookup is bounds-checked (null when the wire value is not a tag). if (std.enums.fromInt(Key.Code, code)) |c| { return .{ .code = c.rune() }; } if (std.enums.fromInt(KittyKey, code)) |k| { return .{ .code = k.keyCode().rune() }; } // Faulty C0 mappings some terminals (WezTerm) have, plus the C0 // control-code defaults (mirrors the init() in Go). switch (code) { 0 => return .{ .code = Key.Code.KeySpace.rune(), .mod = .{ .ctrl = true } }, else => { if (code >= ansi.SOH and code <= ansi.SUB) { return .{ .code = @intCast(code + @as(i32, 0x60)), .mod = .{ .ctrl = true } }; } else if (code >= ansi.FS and code <= ansi.US) { return .{ .code = @intCast(code + @as(i32, 0x40)), .mod = .{ .ctrl = true } }; } return null; }, } } const kitty_shift = 1; const kitty_alt = 1 << 1; const kitty_ctrl = 1 << 2; const kitty_super = 1 << 3; const kitty_hyper = 1 << 4; const kitty_meta = 1 << 5; const kitty_caps_lock = 1 << 6; const kitty_num_lock = 1 << 7; fn fromKittyMod(mod: i32) Key.Mod { var m: Key.Mod = .{}; if (mod & kitty_shift != 0) m.shift = true; if (mod & kitty_alt != 0) m.alt = true; if (mod & kitty_ctrl != 0) m.ctrl = true; if (mod & kitty_super != 0) m.super = true; if (mod & kitty_hyper != 0) m.hyper = true; if (mod & kitty_meta != 0) m.meta = true; if (mod & kitty_caps_lock != 0) m.caps_lock = true; if (mod & kitty_num_lock != 0) m.num_lock = true; return m; } fn parseKittyKeyboard(self: *EventDecoder, params: parser.Params) Event { var is_release = false; var key: Key = .{}; var param_idx: usize = 0; var sud_idx: usize = 0; var text_len: usize = 0; for (params.items) |p| { const pHasMore = parser.paramHasMore(p); switch (param_idx) { 0 => switch (sud_idx) { 0 => { const code = parser.paramValue(p, 1); var found = false; if (kittyKey(code)) |kk| { key = kk; found = true; } if (!found) { const r: u21 = toRune(code); const rr = if (validRune(r)) r else 0xFFFD; key.code = rr; } }, 2 => { // shifted key + base key if (isPrint(toRune(parser.paramValue(p, 1)))) { key.base_code = @intCast(parser.paramValue(p, 1)); } // fallthrough to case 1 if (isPrint(toRune(parser.paramValue(p, 1)))) { key.shifted_code = @intCast(parser.paramValue(p, 1)); } }, 1 => { if (isPrint(toRune(parser.paramValue(p, 1)))) { key.shifted_code = @intCast(parser.paramValue(p, 1)); } }, else => {}, }, 1 => switch (sud_idx) { 0 => { const mod = parser.paramValue(p, 1); if (mod > 1) { key.mod = fromKittyMod(mod - 1); if (key.mod.value() > 2) { // > ModShift key.text = ""; text_len = 0; } } }, 1 => switch (parser.paramValue(p, 1)) { 2 => key.is_repeat = true, 3 => is_release = true, else => {}, }, 2 => {}, else => {}, }, 2 => { const code = parser.paramValue(p, 0); if (code != 0) { var cb: [4]u8 = undefined; const c: u21 = toRune(code); if (validRune(c)) { const len = std.unicode.utf8Encode(c, &cb) catch unreachable; if (text_len + len <= self.text_scratch.len) { @memcpy(self.text_scratch[text_len .. text_len + len], cb[0..len]); text_len += len; } } } }, else => {}, } sud_idx += 1; if (!pHasMore) { param_idx += 1; sud_idx = 0; } } var key_mod = key.mod; // Remove num lock (doesn't affect text). key_mod.num_lock = false; const mod_shift: u16 = 1 << 0; const mod_caps_lock: u16 = 1 << 6; const km_val = key_mod.value(); const print_mod = km_val <= mod_shift or km_val == mod_caps_lock or km_val == (mod_shift | mod_caps_lock); const is_print_keypad = key.code >= Key.Code.KeyKpEqual.rune() and key.code <= Key.Code.KeyKpSep.rune(); var text: []const u8 = ""; if (text_len > 0) { text = self.text_scratch[0..text_len]; } var defined_text: [16]u8 = undefined; var defined_len: usize = 0; if (text.len == 0 and is_print_keypad and print_mod) { switch (key.code) { Key.Code.KeyKp0.rune()...Key.Code.KeyKp9.rune() => { defined_text[0] = @intCast('0' + (key.code - Key.Code.KeyKp0.rune())); defined_len = 1; }, Key.Code.KeyKpEqual.rune() => { @memcpy(defined_text[0..1], "="); defined_len = 1; }, Key.Code.KeyKpMultiply.rune() => { @memcpy(defined_text[0..1], "*"); defined_len = 1; }, Key.Code.KeyKpPlus.rune() => { @memcpy(defined_text[0..1], "+"); defined_len = 1; }, Key.Code.KeyKpMinus.rune() => { @memcpy(defined_text[0..1], "-"); defined_len = 1; }, Key.Code.KeyKpDecimal.rune() => { @memcpy(defined_text[0..1], "."); defined_len = 1; }, Key.Code.KeyKpDivide.rune() => { @memcpy(defined_text[0..1], "/"); defined_len = 1; }, Key.Code.KeyKpSep.rune() => { @memcpy(defined_text[0..1], ","); defined_len = 1; }, else => {}, } } var final_text: []const u8 = text; if (defined_len > 0) { @memcpy(self.text_scratch[0..defined_len], defined_text[0..defined_len]); text_len = defined_len; final_text = self.text_scratch[0..text_len]; } if (final_text.len == 0 and isPrint(key.code)) { const key_mod_val = key_mod.value(); if (key_mod_val == 0) { var cb: [4]u8 = undefined; const len = std.unicode.utf8Encode(key.code, &cb) catch unreachable; @memcpy(self.text_scratch[0..len], cb[0..len]); text_len = len; final_text = self.text_scratch[0..len]; } else { if (key.shifted_code != 0) { var cb: [4]u8 = undefined; const len = std.unicode.utf8Encode(key.shifted_code, &cb) catch unreachable; @memcpy(self.text_scratch[0..len], cb[0..len]); text_len = len; final_text = self.text_scratch[0..len]; } else { var target = key.code; if (key_mod.shift or key_mod.caps_lock) { target = unicodeToUpper(key.code); } else { target = unicodeToLower(key.code); } var cb: [4]u8 = undefined; const len = std.unicode.utf8Encode(target, &cb) catch unreachable; @memcpy(self.text_scratch[0..len], cb[0..len]); text_len = len; final_text = self.text_scratch[0..len]; } } } key.text = final_text; if (is_release) { return .{ .key_release = key }; } return .{ .key_press = key }; } fn parseKittyKeyboardExt(params: parser.Params, k: Key) Event { // Handle Kitty keyboard protocol extension for non-CSI-u sequences. if (params.len() > 2 and params.hasMore(1)) { switch (params.at(2, 1).value) { 2 => { var k2 = k; k2.is_repeat = true; return .{ .key_press = k2 }; }, 3 => return .{ .key_release = k }, else => {}, } } return .{ .key_press = k }; } fn parsePrimaryDevAttrs(params: parser.Params) Event { var da: [max_device_attrs]i32 = .{0} ** max_device_attrs; var n: usize = 0; for (params.items) |p| { if (n >= max_device_attrs) break; if (!parser.paramHasMore(p)) { da[n] = parser.paramValue(p, 0); n += 1; } } return .{ .primary_device_attrs = da }; } fn parseSecondaryDevAttrs(params: parser.Params) Event { var da: [max_device_attrs]i32 = .{0} ** max_device_attrs; var n: usize = 0; for (params.items) |p| { if (n >= max_device_attrs) break; if (!parser.paramHasMore(p)) { da[n] = parser.paramValue(p, 0); n += 1; } } return .{ .secondary_device_attrs = da }; } fn parseTertiaryDevAttrs(b: []const u8) Event { var out: [256]u8 = undefined; var n: usize = 0; var i: usize = 0; while (i + 1 < b.len and n < out.len) : (i += 2) { const hi = hexDigit(b[i]) orelse return .{ .unknown_dcs = b[0..0] }; const lo = hexDigit(b[i + 1]) orelse return .{ .unknown_dcs = b[0..0] }; out[n] = (hi << 4) | lo; n += 1; } return .{ .tertiary_device_attrs = out[0..n] }; } fn parseSGRMouseEvent(cmd: parser.Cmd, params: parser.Params) Event { var x = params.at(1, 1).value; const xok = params.at(1, 1).ok; if (!xok) x = 1; var y = params.at(2, 1).value; const yok = params.at(2, 1).ok; if (!yok) y = 1; const release = cmd.final() == 'm'; const b = params.at(0, 0).value; const parsed = parseMouseButton(b); x -= 1; y -= 1; const m: Mouse = .{ .x = x, .y = y, .button = parsed.btn, .mod = parsed.mod }; if (m.button.isWheel()) { return .{ .mouse_wheel = m }; } else if (!parsed.is_motion and release) { return .{ .mouse_release = m }; } else if (parsed.is_motion) { return .{ .mouse_motion = m }; } return .{ .mouse_click = m }; } fn parseX10MouseEvent(v: []const u8) Event { var b: i32 = v[0]; if (b >= 32) b -= 32; const parsed = parseMouseButton(@intCast(b)); const x: i32 = @as(i32, v[1]) - 32 - 1; const y: i32 = @as(i32, v[2]) - 32 - 1; const m: Mouse = .{ .x = x, .y = y, .button = parsed.btn, .mod = parsed.mod }; if (m.button.isWheel()) { return .{ .mouse_wheel = m }; } else if (parsed.is_motion) { return .{ .mouse_motion = m }; } else if (parsed.is_release) { return .{ .mouse_release = m }; } return .{ .mouse_click = m }; } const ParseMouseButtonResult = struct { mod: Key.Mod, btn: Mouse.Button, is_release: bool, is_motion: bool, }; fn parseMouseButton(b: i32) ParseMouseButtonResult { const bit_shift: i32 = 0b0000_0100; const bit_alt: i32 = 0b0000_1000; const bit_ctrl: i32 = 0b0001_0000; const bit_motion: i32 = 0b0010_0000; const bit_wheel: i32 = 0b0100_0000; const bit_add: i32 = 0b1000_0000; const bits_mask: i32 = 0b0000_0011; var mod: Key.Mod = .{}; if (b & bit_alt != 0) mod.alt = true; if (b & bit_ctrl != 0) mod.ctrl = true; if (b & bit_shift != 0) mod.shift = true; var btn: Mouse.Button = .none; var is_release = false; if (b & bit_add != 0) { btn = @enumFromInt(@intFromEnum(Mouse.Button.backward) + @as(u8, @intCast(b & bits_mask))); } else if (b & bit_wheel != 0) { btn = @enumFromInt(@intFromEnum(Mouse.Button.wheel_up) + @as(u8, @intCast(b & bits_mask))); } else { btn = @enumFromInt(@intFromEnum(Mouse.Button.left) + @as(u8, @intCast(b & bits_mask))); if (b & bits_mask == bits_mask) { btn = .none; is_release = true; } } var is_motion = false; if (b & bit_motion != 0 and !btn.isWheel()) { is_motion = true; } return .{ .mod = mod, .btn = btn, .is_release = is_release, .is_motion = is_motion }; } const alt_mod = Key.Mod{ .alt = true }; const ctrl_mod = Key.Mod{ .ctrl = true }; const shift_mod = Key.Mod{ .shift = true }; /// Safely converts an `i32` modifier value (already `mod - 1`) to the u16 /// bitmask that `KeyMod.fromMask` expects. fn modToMask(v: i32) u16 { if (v < 0) return 0; return @as(u16, @intCast(@as(u32, @intCast(v)) & 0x1FF)); } /// Adds a non-negative `i32` offset to a base rune code. fn codeAdd(base: u21, off: i32) u21 { if (off < 0) return base; return base + @as(u21, @intCast(off)); } /// Decodes `b64` (standard base64 with padding) into `dest`, returning the /// decoded slice on success or `null` on failure. fn base64Decode(dest: []u8, b64: []const u8) ?[]u8 { if (b64.len == 0) return dest[0..0]; const size = std.base64.standard.Decoder.calcSizeForSlice(b64) catch return null; if (size > dest.len) return null; std.base64.standard.Decoder.decode(dest[0..size], b64) catch return null; return dest[0..size]; } fn hexDigit(c: u8) ?u8 { return switch (c) { '0'...'9' => c - '0', 'a'...'f' => c - 'a' + 10, 'A'...'F' => c - 'A' + 10, else => null, }; } fn validRune(c: u21) bool { if (c > 0x10FFFF) return false; if (c >= 0xD800 and c <= 0xDFFF) return false; return true; } /// Clamps an unpacked CSI-u code point to a representable rune. Values above /// U+10FFFF map to 0x110000, which is not a valid rune, so downstream /// `validRune`/`isPrint` guards reject them (avoids an integer-cast panic on /// arbitrarily large parameters from the wire). fn toRune(code: i32) u21 { if (code < 0 or code > 0x10FFFF) return 0x110000; return @intCast(code); } fn isPrint(c: u21) bool { // Pragmatic: printable iff it is a valid non-control scalar. if (!validRune(c)) return false; if (c < 0x20) return false; if (c == 0x7F) return false; return true; } fn isUpper(c: u21) bool { return c >= 'A' and c <= 'Z'; } fn unicodeToUpper(c: u21) u21 { if (c >= 'a' and c <= 'z') return c - 32; return c; } fn unicodeToLower(c: u21) u21 { if (c >= 'A' and c <= 'Z') return c + 32; return c; } fn isCombiningMark(codepoint: u21) bool { // Compact table of common combining-mark ranges (General Category Mn). const ranges = [_][2]u21{ .{ 0x0300, 0x036F }, .{ 0x1AB0, 0x1AFF }, .{ 0x1DC0, 0x1DFF }, .{ 0x20D0, 0x20FF }, .{ 0xFE20, 0xFE2F }, .{ 0x0900, 0x0903 }, .{ 0x0E31, 0x0E31 }, .{ 0x0E34, 0x0E3A }, .{ 0x0E47, 0x0E4E }, }; for (ranges) |r| { if (codepoint >= r[0] and codepoint <= r[1]) return true; } return false; } const zwj: u21 = 0x200D; /// Returns the byte length of the leading grapheme cluster in `b`. Pragmatic /// subset of `rivo/uniseg`: base code point, then combining marks and ZWJ-joined /// sequences. Unmodified clusters of one rune return its UTF-8 length. fn graphemeClusterLen(b: []const u8) usize { if (b.len == 0) return 0; const seq_len = std.unicode.utf8ByteSequenceLength(b[0]) catch 1; if (b.len < seq_len) return 1; const first = std.unicode.utf8Decode(b[0..seq_len]) catch return 1; var len: usize = seq_len; var prev: u21 = first; var pos: usize = len; while (pos < b.len) { const sz = std.unicode.utf8ByteSequenceLength(b[pos]) catch break; if (pos + sz > b.len) break; const r = std.unicode.utf8Decode(b[pos .. pos + sz]) catch break; if (prev == zwj or isCombiningMark(r)) { pos += sz; len = pos; prev = r; } else { break; } } return len; } /// Parses an XTGETTCAP response payload into a capability string of the form /// `name[=value];...`, building the result into `scratch`. Returns an empty /// slice when `data` is empty or nothing could be decoded. fn parseTermcap(data: []const u8, scratch: []u8) []const u8 { if (data.len == 0) return ""; var out_len: usize = 0; var it = std.mem.splitScalar(u8, data, ';'); while (it.next()) |seg| { var parts = std.mem.splitScalar(u8, seg, '='); const name_hex = parts.next() orelse ""; const value_hex_opt = parts.next(); // The hex-decoded name lands at scratch[out_len..]; shift it right by // one byte to make room for the ';' between segments. Source and dest // overlap, so this needs memmove semantics, and the shift must happen // before the separator is written (writing it first would clobber the // decoded first byte). const name = hexDecodeInto(scratch[out_len..], name_hex) orelse continue; if (name.len == 0) continue; if (out_len > 0) { if (out_len + 1 + name.len > scratch.len) continue; std.mem.copyBackwards(u8, scratch[out_len + 1 .. out_len + 1 + name.len], name); scratch[out_len] = ';'; out_len += 1; } out_len += name.len; if (value_hex_opt) |vh| { if (vh.len > 0) { const value = hexDecodeInto(scratch[out_len..], vh) orelse continue; if (out_len + 1 + value.len <= scratch.len) { std.mem.copyBackwards(u8, scratch[out_len + 1 .. out_len + 1 + value.len], value); scratch[out_len] = '='; out_len += 1 + value.len; } } } } return scratch[0..out_len]; } /// Hex-decodes `s` into `dest`, returning the decoded slice or `null` if `s` /// is not valid hex. fn hexDecodeInto(dest: []u8, s: []const u8) ?[]const u8 { if (s.len % 2 != 0) return null; const out_len = s.len / 2; if (out_len > dest.len) return null; var i: usize = 0; while (i < out_len) : (i += 1) { const hi = hexDigit(s[i * 2]) orelse return null; const lo = hexDigit(s[i * 2 + 1]) orelse return null; dest[i] = (hi << 4) | lo; } return dest[0..out_len]; } test "decode ctrl keys" { var d: EventDecoder = .{}; const r = d.decode("\x00"); try t.expectEqual(1, r.n); const ev = r.event.?; try t.expectEqual(@as(std.meta.Tag(Event), .key_press), std.meta.activeTag(ev)); try t.expectEqual(0x20, ev.key_press.code); try t.expect(ev.key_press.mod.ctrl); const r2 = d.decode("\x09"); const ev2 = r2.event.?; try t.expectEqual(Key.Code.KeyTab.rune(), ev2.key_press.code); } test "decode arrows" { var d: EventDecoder = .{}; const r = d.decode("\x1b[A"); try t.expectEqual(3, r.n); const ev = r.event.?; try t.expectEqual(Key.Code.KeyUp.rune(), ev.key_press.code); } test "decode kitty special keys" { const cases = [_]struct { seq: []const u8, code: u21 }{ .{ .seq = "\x1b[127u", .code = Key.Code.KeyBackspace.rune() }, .{ .seq = "\x1b[57346u", .code = Key.Code.KeyTab.rune() }, .{ .seq = "\x1b[57348u", .code = Key.Code.KeyInsert.rune() }, .{ .seq = "\x1b[57352u", .code = Key.Code.KeyUp.rune() }, .{ .seq = "\x1b[57364u", .code = Key.Code.KeyF1.rune() }, .{ .seq = "\x1b[57399u", .code = Key.Code.KeyKp0.rune() }, .{ .seq = "\x1b[57454u", .code = Key.Code.KeyIsoLevel5Shift.rune() }, .{ .seq = "\x1b[1114119u", .code = Key.Code.KeyInsert.rune() }, }; for (cases) |c| { var d: EventDecoder = .{}; const r = d.decode(c.seq); const ev = r.event.?; try t.expectEqual(@as(std.meta.Tag(Event), .key_press), std.meta.activeTag(ev)); try t.expectEqual(c.code, ev.key_press.code); } } test "decode plain text" { var d: EventDecoder = .{}; const r = d.decode("H"); const ev = r.event.?; try t.expectEqual('h', ev.key_press.code); try t.expectEqualStrings("H", ev.key_press.text); } test "kitty oversized codes do not panic" { var d: EventDecoder = .{}; const r = d.decode("\x1b[9000000;2u"); try t.expect(r.n != 0); const ev = r.event.?; try t.expectEqual(@as(std.meta.Tag(Event), .key_press), std.meta.activeTag(ev)); // Out-of-range codes fall back to the replacement character. try t.expectEqual(@as(u21, 0xFFFD), ev.key_press.code); } test "fuzz decoder" { try t.fuzz({}, fuzzDecoder, .{ .corpus = &.{ "H", "\x1b[A", "\x1b[1;2H", "\x1b[<0;10;20M", "\x1b]0;title\x07", "\x1b[200~", "\x1b[27;5u", }, }); } fn fuzzDecoder(_: void, smith: *t.Smith) anyerror!void { var buf: [512]u8 = undefined; const len = smith.slice(&buf); var dec: ir.decoder.EventDecoder = .{}; dec.use_terminfo = smith.value(bool); dec.legacy = .{ .ctrl_at = smith.value(bool), .ctrl_i = smith.value(bool), .ctrl_m = smith.value(bool), .ctrl_open_bracket = smith.value(bool), .backspace = smith.value(bool), .find = smith.value(bool), .select = smith.value(bool), .f_keys = smith.value(bool), }; var i: usize = 0; while (i < len) { const r = dec.decode(buf[i..len]); if (r.n == 0) { i += 1; } else { i += r.n; } } } test "fuzz decoder helpers" { try t.fuzz({}, fuzzDecoderHelpers, .{ .corpus = &.{ "", "aGVsbG8=", "aGVsbG8", "2f2f", "2f2f2f2f2f2f", "3c;3e", "0;1;2;3", "1;2;3m", "\x00\x1f \xff", "e\u{301}\u{302}", }, }); } fn fuzzDecoderHelpers(_: void, smith: *t.Smith) anyerror!void { var buf: [512]u8 = undefined; const len = smith.slice(&buf); // Byte-slice decoders that never allocate. var scratch: [512]u8 = undefined; _ = base64Decode(&scratch, buf[0..len]); _ = hexDecodeInto(&scratch, buf[0..len]); _ = parseTermcap(buf[0..len], &scratch); _ = parseTertiaryDevAttrs(buf[0..len]); _ = graphemeClusterLen(buf[0..len]); for (buf[0..len]) |c| _ = hexDigit(c); // X10 mouse events need exactly three trailing bytes. if (len >= 3) _ = parseX10MouseEvent(buf[len - 3 .. len]); // SGR mouse: button; row; col + a press/release command. var m_params: [4]parser.Param = undefined; const mn = smith.index(m_params.len + 1); for (m_params[0..mn]) |*p| { if (smith.value(bool)) { p.* = smith.valueRangeLessThan(parser.Param, 0, 32); } else { p.* = parser.packParam(smith.value(parser.Param), smith.value(bool)); } } const cmd: u8 = if (smith.value(bool)) 'm' else 'M'; const m_cmd: parser.Cmd = .{ .value = @as(i32, cmd) | (@as(i32, '<') << parser.prefix_shift), }; _ = parseSGRMouseEvent(m_cmd, .{ .items = m_params[0..mn] }); // Kitty keyboard: full i32 parameter space (values outside the rune range // exercise the `toRune` clamping in `parseKittyKeyboard`). var kitty_params: [16]parser.Param = undefined; const kn = smith.index(kitty_params.len + 1); for (kitty_params[0..kn]) |*p| { const v: i32 = if (smith.value(bool)) smith.value(i32) else smith.valueRangeLessThan(i32, 0, 256); p.* = parser.packParam(v, smith.value(bool)); } var dec: EventDecoder = .{}; _ = parseKittyKeyboard(&dec, .{ .items = kitty_params[0..kn] }); }