summaryrefslogtreecommitdiff
path: root/src/tui_input.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-08 10:41:30 -0600
committert <t@tjp.lol>2026-06-08 12:25:55 -0600
commite5ed00c52bb10aec811734c5568c881c41e58474 (patch)
treef51342f759efac79ec175dcc3ede7ebebc0bc470 /src/tui_input.zig
parenta5881243f9bb4642f90fa94179f7a5bff23e4657 (diff)
Replace print CLIRenderer with differential TUI engine
Implement TUI Phase 1: a raw-mode terminal, differential render engine, pinned input box + footer, and component model wired into the libpanto event stream. Adds foundation modules (theme, key, component, input, terminal), the render engine, components, and the app loop; removes the old print CLIRenderer and driveTurn REPL from main.zig. Also removes scratch status reports (tui-p1/, progress.md) for the now completed work.
Diffstat (limited to 'src/tui_input.zig')
-rw-r--r--src/tui_input.zig489
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);
+}