//! Input layer for the TUI: raw stdin bytes -> `Key` values. //! //! Scope (P1, intentionally minimal but correct): //! - printable chars (UTF-8, multi-byte) //! - enter, tab, backspace, delete //! - arrows, home, end, page up/down (CSI + a couple legacy forms) //! - Esc (standalone), Ctrl+C, Ctrl+D, and other Ctrl+ //! - bracketed paste: enable/disable + recognizing begin/end markers and //! surfacing pasted bytes as one literal-text `Key` rather than parsing //! each byte as a keypress. //! //! Deferred to a later phase (documented, not done here): //! - Full Kitty keyboard-protocol disambiguation (key release, exact //! modifier reporting, super/hyper, alternate-key reporting). We *send* a //! modest Kitty enable+query during negotiation but degrade gracefully: //! nothing here requires a response, and unrecognized sequences are //! consumed and dropped rather than mis-decoded. //! //! Shift+Enter limitation: without the Kitty protocol most terminals send the //! exact same bytes for Enter and Shift+Enter (`\r`), so this decoder cannot //! distinguish them and reports both as `.enter` with no shift modifier. When //! the Kitty protocol IS active, Shift+Enter arrives as a CSI-u sequence //! (`\x1b[13;2u`) which we DO decode, setting `mods.shift`. The `Key`/`Mods` //! model can therefore represent the distinction even on terminals that can't //! produce it; the input-box component (later sub-phase) keys off //! `mods.shift` on an `.enter` press. On terminals that can't express it, //! Enter submits and a separate binding (e.g. Alt+Enter / a config key) must //! insert a newline — that policy is the component's, not ours. //! //! The splitter (`decodeOne`) takes a byte buffer and returns the next decoded //! key plus how many bytes it consumed, so a batched read of several escape //! sequences is split into individual `Key`s and a partial trailing sequence //! can be retained for the next read. const std = @import("std"); const key = @import("tui_key.zig"); const Key = key.Key; const KeyCode = key.KeyCode; const Mods = key.Mods; // ---- Terminal control sequences this layer owns -------------------------- /// Enable bracketed paste. pub const enable_bracketed_paste = "\x1b[?2004h"; /// Disable bracketed paste. pub const disable_bracketed_paste = "\x1b[?2004l"; /// Bracketed-paste begin / end markers. pub const paste_begin = "\x1b[200~"; pub const paste_end = "\x1b[201~"; /// Kitty keyboard protocol: push a flags entry that asks for disambiguated /// escape codes (flag 1). We push (not set) so teardown can pop cleanly. This /// is best-effort; terminals that don't support it ignore it. pub const enable_kitty_keyboard = "\x1b[>1u"; /// Pop the Kitty keyboard flags entry we pushed. pub const disable_kitty_keyboard = "\x1b[ 0` on success. /// - `null` when `buf` holds only a partial/incomplete sequence and more /// bytes are needed (retain the tail and read more). An empty `buf` also /// returns `null`. /// /// Borrowing: any `[]const u8` in the result (printable `Key.text`, paste /// text) points into `buf`. Copy before the next read overwrites the buffer. pub fn decodeOne(buf: []const u8) ?Step { if (buf.len == 0) return null; const b = buf[0]; // ESC: could be a CSI/SS3 sequence, a bracketed paste, an Alt+, or a // lone Escape. if (b == 0x1b) return decodeEscape(buf); // Control bytes (C0). switch (b) { '\r', '\n' => return keyStep(.{ .code = .enter }, 1), '\t' => return keyStep(.{ .code = .tab }, 1), 0x7f, 0x08 => return keyStep(.{ .code = .backspace }, 1), 0x03 => return keyStep(.{ .code = .{ .char = 'c' }, .mods = .{ .ctrl = true } }, 1), // Ctrl+C 0x04 => return keyStep(.{ .code = .{ .char = 'd' }, .mods = .{ .ctrl = true } }, 1), // Ctrl+D else => {}, } // Other C0 control bytes => Ctrl+. 0x01..0x1a map to a..z. if (b >= 0x01 and b <= 0x1a) { const letter: u21 = @intCast('a' + (b - 1)); return keyStep(.{ .code = .{ .char = letter }, .mods = .{ .ctrl = true } }, 1); } // Printable: decode one UTF-8 codepoint. const seq_len = std.unicode.utf8ByteSequenceLength(b) catch { // Invalid lead byte — consume it as a Latin-1-ish fallback so we never // wedge on garbage input. return keyStep(.{ .code = .{ .char = b }, .text = buf[0..1] }, 1); }; if (buf.len < seq_len) return null; // partial UTF-8 at buffer end const cp = std.unicode.utf8Decode(buf[0..seq_len]) catch { return keyStep(.{ .code = .{ .char = b }, .text = buf[0..1] }, 1); }; return keyStep(.{ .code = .{ .char = cp }, .text = buf[0..seq_len] }, seq_len); } fn keyStep(k: Key, consumed: usize) Step { return .{ .decoded = .{ .key = k }, .consumed = consumed }; } /// Decode a sequence beginning with ESC (`buf[0] == 0x1b`). fn decodeEscape(buf: []const u8) ?Step { std.debug.assert(buf[0] == 0x1b); // Lone ESC with nothing after it: ambiguous (could be the start of a // longer sequence). We only commit to "Escape key" if we can see it's not // the start of CSI/SS3. With just one byte we can't tell -> need more. if (buf.len == 1) return null; const c1 = buf[1]; // CSI: ESC [ if (c1 == '[') return decodeCSI(buf); // SS3: ESC O (some terminals send arrows/home/end as SS3 in app mode) if (c1 == 'O') return decodeSS3(buf); // ESC followed by anything else: treat as Alt+. Decode the rest // recursively and OR in alt. (A real lone-Escape keypress is followed by // nothing more, handled below.) if (decodeOne(buf[1..])) |inner| { switch (inner.decoded) { .key => |ik| { var k = ik; k.mods.alt = true; return .{ .decoded = .{ .key = k }, .consumed = inner.consumed + 1 }; }, // A paste right after ESC is nonsensical; fall through to Escape. .paste => {}, } } // Couldn't make sense of what follows: report a lone Escape, consuming // just the ESC byte. return keyStep(.{ .code = .escape }, 1); } /// Decode an SS3 sequence: ESC O . Used by some terminals for arrows, /// home/end and F1..F4 in application-cursor mode. fn decodeSS3(buf: []const u8) ?Step { // buf[0]=ESC buf[1]='O' if (buf.len < 3) return null; // need the final byte const final = buf[2]; const code: ?KeyCode = switch (final) { 'A' => .up, 'B' => .down, 'C' => .right, 'D' => .left, 'H' => .home, 'F' => .end, 'P' => .f1, 'Q' => .f2, 'R' => .f3, 'S' => .f4, else => null, }; if (code) |c| return keyStep(.{ .code = c }, 3); // Unknown SS3 final: consume the 3 bytes and drop (degrade gracefully). return keyStep(.{ .code = .escape }, 3); } /// Decode a CSI sequence: ESC [ . /// /// Handles: /// - arrows / home / end / page up-down (with optional `1;` modifier) /// - the `~`-terminated numeric forms (Home=1~/7~, End=4~/8~, Ins, Del=3~, /// PgUp=5~, PgDn=6~, F5..F12) /// - the `[A`..`[D` arrows without params /// - bracketed paste begin (`200~`) — returns a `.paste` run spanning to the /// end marker /// - CSI-u (Kitty) for the keys we care about (notably Shift+Enter = /// `13;2u`) /// /// Unknown but well-formed CSI sequences are consumed and dropped. fn decodeCSI(buf: []const u8) ?Step { // buf[0]=ESC buf[1]='[' // Find the final byte: the first byte in 0x40..0x7e after the params. // Params/intermediates are 0x20..0x3f. If we run off the end, need more. var i: usize = 2; while (i < buf.len and isCSIParamOrIntermediate(buf[i])) : (i += 1) {} if (i >= buf.len) return null; // incomplete: no final byte yet const final = buf[i]; const params = buf[2..i]; const total = i + 1; // Bracketed paste begin: CSI 200 ~ -> scan to the end marker. if (final == '~' and std.mem.eql(u8, params, "200")) { return decodePaste(buf, total); } // CSI-u (Kitty): final 'u', params = "[;[:]]". if (final == 'u') return decodeKittyU(params, total); // Modifier suffix: many sequences are "1;" or // ";~". Split params on ';'. var first: []const u8 = params; var mod_field: ?[]const u8 = null; if (std.mem.indexOfScalar(u8, params, ';')) |semi| { first = params[0..semi]; mod_field = params[semi + 1 ..]; } const mods = parseMods(mod_field); // Letter-final forms: A/B/C/D arrows, H home, F end. const letter_code: ?KeyCode = switch (final) { 'A' => .up, 'B' => .down, 'C' => .right, 'D' => .left, 'H' => .home, 'F' => .end, 'P' => .f1, 'Q' => .f2, 'R' => .f3, 'S' => .f4, else => null, }; if (letter_code) |c| return keyStep(.{ .code = c, .mods = mods }, total); // Tilde-final numeric forms. if (final == '~') { const n = std.fmt.parseInt(u16, first, 10) catch return keyStep(.{ .code = .escape }, total); const code: ?KeyCode = switch (n) { 1, 7 => .home, 2 => null, // Insert — not in our KeyCode set; drop 3 => .delete, 4, 8 => .end, 5 => .page_up, 6 => .page_down, 11 => .f1, 12 => .f2, 13 => .f3, 14 => .f4, 15 => .f5, 17 => .f6, 18 => .f7, 19 => .f8, 20 => .f9, 21 => .f10, 23 => .f11, 24 => .f12, else => null, }; if (code) |c| return keyStep(.{ .code = c, .mods = mods }, total); return keyStep(.{ .code = .escape }, total); // unknown ~ form: drop } // Well-formed but unhandled CSI: consume and drop. return keyStep(.{ .code = .escape }, total); } /// Scan a bracketed-paste body starting after the `CSI 200~` begin marker /// (which spans `[0..begin_len)`), up to the `CSI 201~` end marker. Returns a /// `.paste` step covering the text between the markers. If the end marker /// isn't present yet, returns null (need more bytes). fn decodePaste(buf: []const u8, begin_len: usize) ?Step { const body = buf[begin_len..]; const end_idx = std.mem.indexOf(u8, body, paste_end) orelse return null; const text = body[0..end_idx]; const consumed = begin_len + end_idx + paste_end.len; return .{ .decoded = .{ .paste = text }, .consumed = consumed }; } /// Decode a Kitty CSI-u sequence's params (without the trailing `u`). We only /// resolve the subset P1 needs: a printable codepoint, Enter (13), Tab (9), /// Backspace (127), and Escape (27), with modifiers and (optionally) the /// release event. Anything else is consumed and dropped. fn decodeKittyU(params: []const u8, total: usize) ?Step { // params: "[:][;[:]]" var cp_field: []const u8 = params; var rest: ?[]const u8 = null; if (std.mem.indexOfScalar(u8, params, ';')) |semi| { cp_field = params[0..semi]; rest = params[semi + 1 ..]; } // The codepoint field may carry alternates after ':'; take the first. if (std.mem.indexOfScalar(u8, cp_field, ':')) |colon| cp_field = cp_field[0..colon]; const cp = std.fmt.parseInt(u21, cp_field, 10) catch return keyStep(.{ .code = .escape }, total); // Modifier + event live in `rest` as "[:]". var mod_field: ?[]const u8 = rest; var event = key.KeyEvent.press; if (rest) |r| { if (std.mem.indexOfScalar(u8, r, ':')) |colon| { mod_field = r[0..colon]; event = parseKittyEvent(r[colon + 1 ..]); } } const mods = parseMods(mod_field); const code: KeyCode = switch (cp) { 13 => .enter, 9 => .tab, 127 => .backspace, 27 => .escape, else => .{ .char = cp }, }; return .{ .decoded = .{ .key = .{ .code = code, .mods = mods, .event = event } }, .consumed = total }; } fn parseKittyEvent(field: []const u8) key.KeyEvent { const n = std.fmt.parseInt(u8, field, 10) catch return .press; return switch (n) { 1 => .press, 2 => .repeat, 3 => .release, else => .press, }; } /// Parse a CSI modifier field. The terminal encoding is `1 + bitmask` where /// the bitmask is shift=1, alt=2, ctrl=4, super=8. A null/empty field means no /// modifiers. fn parseMods(field: ?[]const u8) Mods { const f = field orelse return .{}; if (f.len == 0) return .{}; const raw = std.fmt.parseInt(u16, f, 10) catch return .{}; if (raw == 0) return .{}; const bits = raw - 1; return .{ .shift = (bits & 1) != 0, .alt = (bits & 2) != 0, .ctrl = (bits & 4) != 0, .super = (bits & 8) != 0, }; } fn isCSIParamOrIntermediate(c: u8) bool { return c >= 0x20 and c <= 0x3f; } // ---- Tests ---------------------------------------------------------------- test "printable ascii" { const s = decodeOne("a").?; try std.testing.expectEqual(@as(usize, 1), s.consumed); try std.testing.expectEqual(@as(u21, 'a'), s.decoded.key.code.char); try std.testing.expectEqualStrings("a", s.decoded.key.text.?); } test "multibyte utf8 printable" { // é = U+00E9 = 0xC3 0xA9 const s = decodeOne("\xc3\xa9rest").?; try std.testing.expectEqual(@as(usize, 2), s.consumed); try std.testing.expectEqual(@as(u21, 0xe9), s.decoded.key.code.char); } test "partial utf8 needs more bytes" { try std.testing.expect(decodeOne("\xc3") == null); } test "enter, tab, backspace" { try std.testing.expectEqual(KeyCode.enter, decodeOne("\r").?.decoded.key.code); try std.testing.expectEqual(KeyCode.enter, decodeOne("\n").?.decoded.key.code); try std.testing.expectEqual(KeyCode.tab, decodeOne("\t").?.decoded.key.code); try std.testing.expectEqual(KeyCode.backspace, decodeOne("\x7f").?.decoded.key.code); } test "ctrl-c and ctrl-d" { const c = decodeOne("\x03").?.decoded.key; try std.testing.expect(c.isCtrl('c')); const d = decodeOne("\x04").?.decoded.key; try std.testing.expect(d.isCtrl('d')); } test "csi arrows" { try std.testing.expectEqual(KeyCode.up, decodeOne("\x1b[A").?.decoded.key.code); try std.testing.expectEqual(KeyCode.down, decodeOne("\x1b[B").?.decoded.key.code); try std.testing.expectEqual(KeyCode.right, decodeOne("\x1b[C").?.decoded.key.code); try std.testing.expectEqual(KeyCode.left, decodeOne("\x1b[D").?.decoded.key.code); } test "csi home/end and modified arrow" { try std.testing.expectEqual(KeyCode.home, decodeOne("\x1b[H").?.decoded.key.code); try std.testing.expectEqual(KeyCode.end, decodeOne("\x1b[F").?.decoded.key.code); // Ctrl+Right = CSI 1;5C const s = decodeOne("\x1b[1;5C").?.decoded.key; try std.testing.expectEqual(KeyCode.right, s.code); try std.testing.expect(s.mods.ctrl); } test "csi tilde delete / pageup / pagedown" { try std.testing.expectEqual(KeyCode.delete, decodeOne("\x1b[3~").?.decoded.key.code); try std.testing.expectEqual(KeyCode.page_up, decodeOne("\x1b[5~").?.decoded.key.code); try std.testing.expectEqual(KeyCode.page_down, decodeOne("\x1b[6~").?.decoded.key.code); } test "ss3 arrows and f-keys" { try std.testing.expectEqual(KeyCode.up, decodeOne("\x1bOA").?.decoded.key.code); try std.testing.expectEqual(KeyCode.f1, decodeOne("\x1bOP").?.decoded.key.code); } test "lone escape" { // ESC followed by a non-sequence byte: Alt+x is decoded preferentially. const alt = decodeOne("\x1bx").?.decoded.key; try std.testing.expect(alt.mods.alt); try std.testing.expectEqual(@as(u21, 'x'), alt.code.char); } test "incomplete csi needs more bytes" { try std.testing.expect(decodeOne("\x1b[1;5") == null); try std.testing.expect(decodeOne("\x1b[") == null); } test "kitty shift+enter distinguishes from plain enter" { // CSI 13 ; 2 u -> Enter with shift. const s = decodeOne("\x1b[13;2u").?.decoded.key; try std.testing.expectEqual(KeyCode.enter, s.code); try std.testing.expect(s.mods.shift); // Plain enter has no shift. try std.testing.expect(!decodeOne("\r").?.decoded.key.mods.shift); } test "kitty release event" { // CSI 97 ; 1 : 3 u -> 'a' release. const s = decodeOne("\x1b[97;1:3u").?.decoded.key; try std.testing.expectEqual(@as(u21, 'a'), s.code.char); try std.testing.expectEqual(key.KeyEvent.release, s.event); } test "bracketed paste surfaces literal text" { const input = paste_begin ++ "hello\nworld" ++ paste_end ++ "x"; const s = decodeOne(input).?; try std.testing.expectEqualStrings("hello\nworld", s.decoded.paste); // Next decode should land on the trailing 'x'. const rest = input[s.consumed..]; try std.testing.expectEqual(@as(u21, 'x'), decodeOne(rest).?.decoded.key.code.char); } test "incomplete paste needs more bytes" { const input = paste_begin ++ "partial"; try std.testing.expect(decodeOne(input) == null); } test "splitter: batched read yields individual keys" { // Up, Down, 'a' in one buffer. const input = "\x1b[A\x1b[Ba"; var off: usize = 0; const s1 = decodeOne(input[off..]).?; try std.testing.expectEqual(KeyCode.up, s1.decoded.key.code); off += s1.consumed; const s2 = decodeOne(input[off..]).?; try std.testing.expectEqual(KeyCode.down, s2.decoded.key.code); off += s2.consumed; const s3 = decodeOne(input[off..]).?; try std.testing.expectEqual(@as(u21, 'a'), s3.decoded.key.code.char); off += s3.consumed; try std.testing.expectEqual(input.len, off); }