Something went wrong. Try again.
A charm-like tui library
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392const std = @import("std");const key = @import("key.zig");const Msg = @import("msg.zig").Msg;
pub const ParseError = error{ Incomplete, Invalid };
pub const Parsed = struct { msg: Msg, consumed: usize,};
/// Parse one terminal input event. Handles SGR mouse, focus/blur, bracketed-/// paste markers, then falls back to plain key parsing.////// `paste_start` and `paste_end` are emitted as markers only — paste payload/// accumulation between them is the caller's responsibility (e.g. the Tty's/// raw byte buffer).pub fn parse(buf: []const u8) ParseError!Parsed { if (buf.len == 0) return error.Incomplete;
if (buf[0] == 0x1b and buf.len >= 2 and buf[1] == '[') { if (buf.len < 3) return error.Incomplete; switch (buf[2]) { '<' => return parseMouseSgr(buf), 'I' => return .{ .msg = .focus, .consumed = 3 }, 'O' => return .{ .msg = .blur, .consumed = 3 }, else => {}, } if (std.mem.startsWith(u8, buf, "\x1b[200~")) return .{ .msg = .paste_start, .consumed = 6 }; if (std.mem.startsWith(u8, buf, "\x1b[201~")) return .{ .msg = .paste_end, .consumed = 6 };
// Other CSI finals we own: '_' (win32-input-mode key event) and 't' // (window-op, used for resize). Locate the final byte first; if not // found, key.parse below will detect the incomplete CSI. if (findCsiFinal(buf)) |idx| { switch (buf[idx]) { '_' => return parseWin32Input(buf, idx), 't' => return parseWindowOp(buf, idx), else => {}, } } }
const r = try key.parse(buf); const msg: Msg = blk: { if (r.msg.kind == .rune and r.msg.mods.ctrl) { if (r.msg.rune == 'c') break :blk .interrupt; if (r.msg.rune == 'd') break :blk .quit; } break :blk .{ .key_press = r.msg }; }; return .{ .msg = msg, .consumed = r.consumed };}
/// ESC [ < Cb ; Cx ; Cy (M|m)/// SGR-encoded mouse event, terminated by 'M' (press) or 'm' (release).fn parseMouseSgr(buf: []const u8) ParseError!Parsed { var i: usize = 3; while (i < buf.len) : (i += 1) { const c = buf[i]; if (c == 'M' or c == 'm') break; if (c != ';' and (c < '0' or c > '9')) return error.Invalid; } else return error.Incomplete;
const final = buf[i]; const params = buf[3..i];
var it = std.mem.splitScalar(u8, params, ';'); const cb_str = it.next() orelse return error.Invalid; const cx_str = it.next() orelse return error.Invalid; const cy_str = it.next() orelse return error.Invalid; if (it.next() != null) return error.Invalid;
const cb = std.fmt.parseInt(u32, cb_str, 10) catch return error.Invalid; const cx = std.fmt.parseInt(u32, cx_str, 10) catch return error.Invalid; const cy = std.fmt.parseInt(u32, cy_str, 10) catch return error.Invalid;
if (cx == 0 or cy == 0) return error.Invalid;
const bit_shift: u32 = 0x04; const bit_alt: u32 = 0x08; const bit_ctrl: u32 = 0x10; const bit_motion: u32 = 0x20; const bit_wheel: u32 = 0x40; const bit_add: u32 = 0x80; const bits_mask: u32 = 0x03;
const mods: Msg.Key.Mods = .{ .shift = (cb & bit_shift) != 0, .alt = (cb & bit_alt) != 0, .ctrl = (cb & bit_ctrl) != 0, };
var is_wheel = false; const button: Msg.Mouse.Button = blk: { if (cb & bit_add != 0) { // Buttons 8-11 → backward, forward, extra_10, extra_11. break :blk @enumFromInt(@intFromEnum(Msg.Mouse.Button.backward) + (cb & bits_mask)); } if (cb & bit_wheel != 0) { is_wheel = true; break :blk @enumFromInt(@intFromEnum(Msg.Mouse.Button.wheel_up) + (cb & bits_mask)); } break :blk @enumFromInt(@intFromEnum(Msg.Mouse.Button.left) + (cb & bits_mask)); };
const is_motion = (cb & bit_motion) != 0 and !is_wheel; const release = final == 'm';
const mouse: Msg.Mouse = .{ .x = @intCast(cx - 1), .y = @intCast(cy - 1), .button = button, .mods = mods, };
const msg: Msg = if (is_wheel) .{ .mouse_wheel = mouse } else if (is_motion) .{ .mouse_motion = mouse } else if (release) .{ .mouse_release = mouse } else .{ .mouse_click = mouse };
return .{ .msg = msg, .consumed = i + 1 };}
/// Locate the CSI final byte (range 0x40..=0x7e) in a `\x1b[...` sequence./// Returns the index of the final byte, or null if not present in `buf`./// `buf` must start with `\x1b[`.fn findCsiFinal(buf: []const u8) ?usize { var i: usize = 2; while (i < buf.len) : (i += 1) { const c = buf[i]; if (c >= 0x40 and c <= 0x7e) return i; } return null;}
/// ESC [ VK ; SC ; CH ; KD ; CKS ; RC _////// Win32-input-mode key event. The fields mirror Win32 KEY_EVENT_RECORD:/// VK = virtual key code (0 means pure Unicode)/// SC = virtual scan code (unused; informational)/// CH = Unicode code point (post-UTF-16 decode; 0 when VK alone)/// KD = 1 for key down, 0 for key up/// CKS = control key state bitmap (alt/ctrl/shift/etc)/// RC = repeat count (we emit a single event regardless)fn parseWin32Input(buf: []const u8, final_idx: usize) ParseError!Parsed { const params = buf[2..final_idx]; var it = std.mem.splitScalar(u8, params, ';'); const vk = parseDecParam(&it) orelse return error.Invalid; _ = parseDecParam(&it) orelse return error.Invalid; // scan code, unused const ch = parseDecParam(&it) orelse return error.Invalid; const kd = parseDecParam(&it) orelse return error.Invalid; const cks = parseDecParam(&it) orelse return error.Invalid; _ = parseDecParam(&it); // repeat count, optional if (it.next() != null) return error.Invalid;
const mods = win32Mods(cks); const kind = vkToKind(@truncate(vk));
var k: Msg.Key = .{ .kind = kind, .mods = mods }; if (kind == .rune) { if (ch == 0) return error.Invalid; k.rune = @truncate(ch); // Ctrl+letter on Win32 sends the letter VK (e.g. 'C'=0x43) with // CH=3 (the control byte) — promote to the letter rune so callers // see `ctrl+c` rather than U+0003. if (mods.ctrl and ch < 0x20 and vk >= 'A' and vk <= 'Z') { k.rune = @truncate(vk + ('a' - 'A')); } }
const press = kd != 0; if (press and mods.ctrl and k.kind == .rune) { if (k.rune == 'c') return .{ .msg = .interrupt, .consumed = final_idx + 1 }; if (k.rune == 'd') return .{ .msg = .quit, .consumed = final_idx + 1 }; } return .{ .msg = if (press) .{ .key_press = k } else .{ .key_release = k }, .consumed = final_idx + 1, };}
/// ESC [ Ps ; Ps ; Ps t////// Window manipulation. We emit `window_size` for the `8 ; height ; width t`/// variant (xterm's "report text area size" / charmbracelet's serialized/// WINDOW_BUFFER_SIZE_EVENT). Other ops are treated as Invalid so the/// surrounding decoder skips them.fn parseWindowOp(buf: []const u8, final_idx: usize) ParseError!Parsed { const params = buf[2..final_idx]; var it = std.mem.splitScalar(u8, params, ';'); const op = parseDecParam(&it) orelse return error.Invalid; if (op != 8) return error.Invalid; const h = parseDecParam(&it) orelse return error.Invalid; const w = parseDecParam(&it) orelse return error.Invalid; if (it.next() != null) return error.Invalid; return .{ .msg = .{ .window_size = .{ .width = @truncate(w), .height = @truncate(h) } }, .consumed = final_idx + 1, };}
fn parseDecParam(it: *std.mem.SplitIterator(u8, .scalar)) ?u32 { const s = it.next() orelse return null; if (s.len == 0) return 0; return std.fmt.parseInt(u32, s, 10) catch null;}
/// Win32 control-key state bitmap, matching the constants in/// platform/windows.zig.fn win32Mods(cks: u32) Msg.Key.Mods { const left_alt: u32 = 0x0002; const right_alt: u32 = 0x0001; const left_ctrl: u32 = 0x0008; const right_ctrl: u32 = 0x0004; const shift: u32 = 0x0010; const num_lock: u32 = 0x0020; const caps_lock: u32 = 0x0080; return .{ .alt = (cks & (left_alt | right_alt)) != 0, .ctrl = (cks & (left_ctrl | right_ctrl)) != 0, .shift = (cks & shift) != 0, .num_lock = (cks & num_lock) != 0, .caps_lock = (cks & caps_lock) != 0, };}
fn vkToKind(vk: u16) Msg.Key.Kind { return switch (vk) { 0x08 => .backspace, 0x09 => .tab, 0x0d => .enter, 0x1b => .escape, 0x20 => .space, 0x21 => .page_up, 0x22 => .page_down, 0x23 => .end, 0x24 => .home, 0x25 => .left, 0x26 => .up, 0x27 => .right, 0x28 => .down, 0x2d => .insert, 0x2e => .delete, 0x70...0x83 => |v| @enumFromInt(@intFromEnum(Msg.Key.Kind.f1) + (v - 0x70)), else => .rune, };}
test "mouse left click" { const p = try parse("\x1b[<0;10;5M"); try std.testing.expectEqual(@as(usize, 10), p.consumed); try std.testing.expectEqual(Msg.Mouse.Button.left, p.msg.mouse_click.button); try std.testing.expectEqual(@as(u16, 9), p.msg.mouse_click.x); try std.testing.expectEqual(@as(u16, 4), p.msg.mouse_click.y);}
test "mouse right release" { const p = try parse("\x1b[<2;1;1m"); try std.testing.expectEqual(Msg.Mouse.Button.right, p.msg.mouse_release.button);}
test "mouse wheel up" { const p = try parse("\x1b[<64;3;4M"); try std.testing.expectEqual(Msg.Mouse.Button.wheel_up, p.msg.mouse_wheel.button); try std.testing.expectEqual(@as(u16, 2), p.msg.mouse_wheel.x); try std.testing.expectEqual(@as(u16, 3), p.msg.mouse_wheel.y);}
test "mouse motion with button" { // 0 (left) + 32 (motion) = 32, button held + moving const p = try parse("\x1b[<32;5;5M"); try std.testing.expectEqual(Msg.Mouse.Button.left, p.msg.mouse_motion.button);}
test "mouse modifiers ctrl+shift" { // 0 (left) + 4 (shift) + 16 (ctrl) = 20 const p = try parse("\x1b[<20;1;1M"); try std.testing.expect(p.msg.mouse_click.mods.ctrl); try std.testing.expect(p.msg.mouse_click.mods.shift);}
test "mouse extended button forward" { // 128 + 1 = 129 → forward const p = try parse("\x1b[<129;1;1M"); try std.testing.expectEqual(Msg.Mouse.Button.forward, p.msg.mouse_click.button);}
test "mouse incomplete" { try std.testing.expectError(error.Incomplete, parse("\x1b[<0;1;1"));}
test "focus event" { const p = try parse("\x1b[I"); try std.testing.expectEqual(@as(usize, 3), p.consumed); try std.testing.expectEqual(Msg.focus, p.msg);}
test "blur event" { const p = try parse("\x1b[O"); try std.testing.expectEqual(Msg.blur, p.msg);}
test "paste start marker" { const p = try parse("\x1b[200~"); try std.testing.expectEqual(@as(usize, 6), p.consumed); try std.testing.expectEqual(Msg.paste_start, p.msg);}
test "paste end marker" { const p = try parse("\x1b[201~"); try std.testing.expectEqual(Msg.paste_end, p.msg);}
test "falls back to key parser" { const p = try parse("a"); try std.testing.expectEqual(Msg.Key.Kind.rune, p.msg.key_press.kind); try std.testing.expectEqual(@as(u21, 'a'), p.msg.key_press.rune);}
test "ctrl+c translates to interrupt" { const p = try parse(&.{0x03}); try std.testing.expectEqual(Msg.interrupt, p.msg);}
test "ctrl+d translates to quit" { const p = try parse(&.{0x04}); try std.testing.expectEqual(Msg.quit, p.msg);}
test "arrow key still routes to key parser" { const p = try parse("\x1b[A"); try std.testing.expectEqual(Msg.Key.Kind.up, p.msg.key_press.kind);}
test "window op: resize" { // CSI 8 ; 24 ; 80 t → 80x24 terminal const p = try parse("\x1b[8;24;80t"); try std.testing.expectEqual(@as(u16, 80), p.msg.window_size.width); try std.testing.expectEqual(@as(u16, 24), p.msg.window_size.height);}
test "window op: non-resize is invalid" { try std.testing.expectError(error.Invalid, parse("\x1b[5t")); // raise window}
test "win32 input: letter key down" { // VK 'A' (0x41=65), SC 30, CH 'a' (0x61=97), KD 1, CKS 0, RC 1 const p = try parse("\x1b[65;30;97;1;0;1_"); try std.testing.expectEqual(Msg.Key.Kind.rune, p.msg.key_press.kind); try std.testing.expectEqual(@as(u21, 'a'), p.msg.key_press.rune);}
test "win32 input: enter key" { // VK_RETURN=0x0d=13, CH 13, KD 1 const p = try parse("\x1b[13;28;13;1;0;1_"); try std.testing.expectEqual(Msg.Key.Kind.enter, p.msg.key_press.kind);}
test "win32 input: F5 with ctrl" { // VK_F5=0x74=116, CH 0, KD 1, CKS LEFT_CTRL_PRESSED=0x08 const p = try parse("\x1b[116;63;0;1;8;1_"); try std.testing.expectEqual(Msg.Key.Kind.f5, p.msg.key_press.kind); try std.testing.expect(p.msg.key_press.mods.ctrl);}
test "win32 input: key release" { // VK 'A', CH 'a', KD 0 → key_release const p = try parse("\x1b[65;30;97;0;0;1_"); try std.testing.expectEqual(Msg.Key.Kind.rune, p.msg.key_release.kind); try std.testing.expectEqual(@as(u21, 'a'), p.msg.key_release.rune);}
test "win32 input: ctrl+c → interrupt" { // VK 'C'=0x43=67, CH 3 (ctrl byte), KD 1, CKS LEFT_CTRL_PRESSED const p = try parse("\x1b[67;46;3;1;8;1_"); try std.testing.expectEqual(Msg.interrupt, p.msg);}
test "win32 input: alt+shift+rune" { // VK 'X'=88, CH 'X'=88, KD 1, CKS LEFT_ALT|SHIFT = 0x12 const p = try parse("\x1b[88;45;88;1;18;1_"); try std.testing.expect(p.msg.key_press.mods.alt); try std.testing.expect(p.msg.key_press.mods.shift);}