diff options
Diffstat (limited to 'src/tui_input.zig')
| -rw-r--r-- | src/tui_input.zig | 489 |
1 files changed, 489 insertions, 0 deletions
diff --git a/src/tui_input.zig b/src/tui_input.zig new file mode 100644 index 0000000..fdd174c --- /dev/null +++ b/src/tui_input.zig @@ -0,0 +1,489 @@ +//! 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+<letter> +//! - 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[<u"; + +/// Modest negotiation setup to write at startup. Bracketed paste is the only +/// thing P1 strictly needs; the Kitty enable is opportunistic. Order: paste +/// first, then Kitty. +pub const negotiate_setup = enable_bracketed_paste ++ enable_kitty_keyboard; + +/// Teardown to write on exit; the reverse of `negotiate_setup`. +pub const negotiate_teardown = disable_kitty_keyboard ++ disable_bracketed_paste; + +// ---- Decode results ------------------------------------------------------- + +/// What a single decode step produced. +pub const Decoded = union(enum) { + /// A decoded key. + key: Key, + /// A run of pasted text (between bracketed-paste markers), surfaced + /// literally. `text` borrows from the input buffer. + paste: []const u8, +}; + +/// Result of one decode step: what was produced, and how many input bytes it +/// consumed. `null` from `decodeOne` means "need more bytes" (a partial +/// sequence at the end of the buffer); the caller should retain the unconsumed +/// tail and append the next read. +pub const Step = struct { + decoded: Decoded, + consumed: usize, +}; + +// ---- The splitter / decoder ---------------------------------------------- + +/// Decode the next key or paste run from the front of `buf`. +/// +/// Returns: +/// - `Step` with `consumed > 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+<key>, 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+<letter>. 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+<that key>. 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 <final>. 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 [ <params> <final>. +/// +/// Handles: +/// - arrows / home / end / page up-down (with optional `1;<mods>` 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 = "<codepoint>[;<mods>[:<event>]]". + if (final == 'u') return decodeKittyU(params, total); + + // Modifier suffix: many sequences are "1;<mods><final>" or + // "<num>;<mods>~". 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: "<cp>[:<alt-cp>][;<mods>[:<event>]]" + 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 "<mods>[:<event>]". + 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); +} |
