const 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); }