5b6c89ea
refactor: share surface input encoding
a73x 2026-09-07 04:36
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -157,6 +157,10 @@ const mod_table = [_]ModSpec{ | |||
| 157 | // get libc for free through ghostty-vt; dropping the emulator made the | 157 | // get libc for free through ghostty-vt; dropping the emulator made the |
| 158 | // dependency visible rather than new. | 158 | // dependency visible rather than new. |
| 159 | .{ .name = "term", .path = "src/engine/term.zig", .link_libc = true, .wasm = true }, | 159 | .{ .name = "term", .path = "src/engine/term.zig", .link_libc = true, .wasm = true }, |
| 160 | // Surface-neutral keyboard, paste and pointer input become application | ||
| 161 | // bytes here. It has no connection or terminal I/O policy, so native, | ||
| 162 | // browser and future terminal surfaces can share one mapping. | ||
| 163 | .{ .name = "input", .path = "src/input.zig", .wasm = true }, | ||
| 160 | // The authoritative emulator and the daemon-side delta minting, reached | 164 | // The authoritative emulator and the daemon-side delta minting, reached |
| 161 | // as `Engine` and `engine.delta`. The ONE row that links ghostty-vt: a | 165 | // as `Engine` and `engine.delta`. The ONE row that links ghostty-vt: a |
| 162 | // client parses no VT, so this sits above `term` rather than inside it, | 166 | // client parses no VT, so this sits above `term` rather than inside it, |
| @@ -266,14 +270,14 @@ const mod_table = [_]ModSpec{ | |||
| 266 | // now, along with the rest of the terminal-facing machinery; what stays | 270 | // now, along with the rest of the terminal-facing machinery; what stays |
| 267 | // here is Target/Transport, the attach loop and the session's meanings. | 271 | // here is Target/Transport, the attach loop and the session's meanings. |
| 268 | // The core decoder, the hosts file, the handoff vocabulary, the pane | 272 | // The core decoder, the hosts file, the handoff vocabulary, the pane |
| 269 | // tree, the key table and the askpass carriage are CHILD FILES of this | 273 | // tree, the cancellation byte and the askpass carriage are CHILD FILES of this |
| 270 | // root re-exported as `client.core`, `.hosts`, `.handoff`, `.layout`, | 274 | // root re-exported as `client.core`, `.hosts`, `.handoff`, `.layout`, |
| 271 | // `.keymap`, `.askpass` — one row, so a second module claiming any of | 275 | // `.interrupt`, `.askpass` — one row, so a second module claiming any of |
| 272 | // those files is a file-in-multiple-modules compile error. `resolveHost` | 276 | // those files is a file-in-multiple-modules compile error. `resolveHost` |
| 273 | // sits here rather than in either front so the CLI wall and the browser | 277 | // sits here rather than in either front so the CLI wall and the browser |
| 274 | // hub resolve a host line the same way. Nothing here WRITES that file — | 278 | // hub resolve a host line the same way. Nothing here WRITES that file — |
| 275 | // `wall_host.recordHost` and `webhub_main` do. | 279 | // `wall_host.recordHost` and `webhub_main` do. |
| 276 | .{ .name = "client", .path = "src/client/client.zig", .link_libc = true, .imports = &.{ "term", "quic", "xdg", "serve", "dial", "link", "client_os" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | 280 | .{ .name = "client", .path = "src/client/client.zig", .link_libc = true, .imports = &.{ "term", "input", "quic", "xdg", "serve", "dial", "link", "client_os" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, |
| 277 | // ---- the two fronts ---- | 281 | // ---- the two fronts ---- |
| 278 | // The browser hub's HTTP/WebSocket decisions: Origin gate, route table, | 282 | // The browser hub's HTTP/WebSocket decisions: Origin gate, route table, |
| 279 | // WS endpoint naming. Assets are injected (the exe root @embedFiles | 283 | // WS endpoint naming. Assets are injected (the exe root @embedFiles |
| @@ -326,8 +330,8 @@ const mod_table = [_]ModSpec{ | |||
| 326 | .{ .name = "mux", .path = "src/cli/mux.zig", .link_libc = true, .imports = &.{ "daemon", "client", "wall", "agent", "webhub", "term", "proxy", "quic", "xdg", "spawn", "sockpath", "cliflags", "dial", "link", "server_os", "client_os" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | 330 | .{ .name = "mux", .path = "src/cli/mux.zig", .link_libc = true, .imports = &.{ "daemon", "client", "wall", "agent", "webhub", "term", "proxy", "quic", "xdg", "spawn", "sockpath", "cliflags", "dial", "link", "server_os", "client_os" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, |
| 327 | // Opt-in rows: no default artifact or test names them, so machines | 331 | // Opt-in rows: no default artifact or test names them, so machines |
| 328 | // without the viewer's system libraries keep all existing gates. | 332 | // without the viewer's system libraries keep all existing gates. |
| 329 | .{ .name = "native_core", .path = "src/gui/native_core.zig", .link_libc = true, .imports = &.{ "client", "term" }, .opt_in = true }, | 333 | .{ .name = "native_core", .path = "src/gui/native_core.zig", .link_libc = true, .imports = &.{ "client", "term", "input" }, .opt_in = true }, |
| 330 | .{ .name = "native", .path = "src/gui/native.zig", .link_libc = true, .imports = &.{ "native_core", "client", "term" }, .opt_in = true }, | 334 | .{ .name = "native", .path = "src/gui/native.zig", .link_libc = true, .imports = &.{ "native_core", "client", "term", "input" }, .opt_in = true }, |
| 331 | .{ .name = "muxg", .path = "src/cli/muxg.zig", .link_libc = true, .imports = &.{ "native", "client", "term", "cliflags", "sockpath", "xdg" }, .opt_in = true }, | 335 | .{ .name = "muxg", .path = "src/cli/muxg.zig", .link_libc = true, .imports = &.{ "native", "client", "term", "cliflags", "sockpath", "xdg" }, .opt_in = true }, |
| 332 | }; | 336 | }; |
| 333 | 337 | ||
| @@ -833,11 +837,11 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui | |||
| 833 | /// of all: it carries every argument parser but muxa's, its mains being | 837 | /// of all: it carries every argument parser but muxa's, its mains being |
| 834 | /// child files — a test that is never built is not a test (decisions.md). | 838 | /// child files — a test that is never built is not a test (decisions.md). |
| 835 | const test_order = [_][]const u8{ | 839 | const test_order = [_][]const u8{ |
| 836 | "script", "cliflags", "testtmp", "server_os", "client_os", "spawn", | 840 | "script", "cliflags", "testtmp", "server_os", "client_os", "spawn", |
| 837 | "dial", "link", "quic", "webhub", "agent", "term", | 841 | "dial", "link", "quic", "webhub", "agent", "input", |
| 838 | "engine", "rawmode", "delaypipe", "render", "wsclient", "ptyclient", | 842 | "term", "engine", "rawmode", "delaypipe", "render", "wsclient", |
| 839 | "pty", "sockpath", "serve", "xdg", "proxy", "wall", | 843 | "ptyclient", "pty", "sockpath", "serve", "xdg", "proxy", |
| 840 | "client", "daemon", "mux", | 844 | "wall", "client", "daemon", "mux", |
| 841 | }; | 845 | }; |
| 842 | 846 | ||
| 843 | comptime { | 847 | comptime { |
| @@ -1025,6 +1029,7 @@ pub fn build(b: *std.Build) void { | |||
| 1025 | const term_wasm_mod = wasm_mods[comptime idxOf("term")].?; | 1029 | const term_wasm_mod = wasm_mods[comptime idxOf("term")].?; |
| 1026 | const wasm_core_mod = wasmMod(b, wasm_target, "src/client/wasm_core.zig"); | 1030 | const wasm_core_mod = wasmMod(b, wasm_target, "src/client/wasm_core.zig"); |
| 1027 | wasm_core_mod.addImport("term", term_wasm_mod); | 1031 | wasm_core_mod.addImport("term", term_wasm_mod); |
| 1032 | wasm_core_mod.addImport("input", wasm_mods[comptime idxOf("input")].?); | ||
| 1028 | // Compile a tiny, never-embedded canary that calls the semantic decoder | 1033 | // Compile a tiny, never-embedded canary that calls the semantic decoder |
| 1029 | // through typed mode and clipboard result paths. This build-only object | 1034 | // through typed mode and clipboard result paths. This build-only object |
| 1030 | // forces the production API and its validation helpers through wasm | 1035 | // forces the production API and its validation helpers through wasm |
src/client/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -26,11 +26,9 @@ pub const hosts = @import("hosts.zig"); | |||
| 26 | pub const handoff = @import("handoff.zig"); | 26 | pub const handoff = @import("handoff.zig"); |
| 27 | pub const layout = @import("layout.zig"); | 27 | pub const layout = @import("layout.zig"); |
| 28 | pub const layoutfile = @import("layoutfile.zig"); | 28 | pub const layoutfile = @import("layoutfile.zig"); |
| 29 | // The seam carries `keymap.detach_key` and nothing else: the dial here and | 29 | // The dial and the prefix filter both watch for this local abort byte. A dial |
| 30 | // the prefix filter in interact.zig both watch for that one abort byte. It | 30 | // has no session to command and no surface input to encode. |
| 31 | // is keymap's rather than the filter's because a dial has no session to | 31 | pub const interrupt = @import("interrupt.zig"); |
| 32 | // command and no terminal to drive. | ||
| 33 | pub const keymap = @import("keymap.zig"); | ||
| 34 | pub const askpass = @import("askpass.zig"); | 32 | pub const askpass = @import("askpass.zig"); |
| 35 | pub const core = @import("client_core.zig"); | 33 | pub const core = @import("client_core.zig"); |
| 36 | pub const selection = @import("selection.zig"); | 34 | pub const selection = @import("selection.zig"); |
| @@ -819,7 +817,7 @@ fn drainAbortFd(abort_fd: std.posix.fd_t, alloc: std.mem.Allocator, carry: ?*std | |||
| 819 | var buf: [1024]u8 = undefined; | 817 | var buf: [1024]u8 = undefined; |
| 820 | const n = std.posix.read(abort_fd, &buf) catch 0; | 818 | const n = std.posix.read(abort_fd, &buf) catch 0; |
| 821 | if (n == 0) return false; | 819 | if (n == 0) return false; |
| 822 | if (std.mem.indexOfScalar(u8, buf[0..n], keymap.detach_key) != null) return error.UserAbort; | 820 | if (std.mem.indexOfScalar(u8, buf[0..n], interrupt.detach_key) != null) return error.UserAbort; |
| 823 | if (carry) |q| q.appendSlice(alloc, buf[0..n]) catch {}; | 821 | if (carry) |q| q.appendSlice(alloc, buf[0..n]) catch {}; |
| 824 | return true; | 822 | return true; |
| 825 | } | 823 | } |
| @@ -3011,7 +3009,7 @@ test { | |||
| 3011 | _ = @import("hosts.zig"); | 3009 | _ = @import("hosts.zig"); |
| 3012 | _ = @import("handoff.zig"); | 3010 | _ = @import("handoff.zig"); |
| 3013 | _ = @import("layout.zig"); | 3011 | _ = @import("layout.zig"); |
| 3014 | _ = @import("keymap.zig"); | 3012 | _ = @import("interrupt.zig"); |
| 3015 | _ = @import("askpass.zig"); | 3013 | _ = @import("askpass.zig"); |
| 3016 | } | 3014 | } |
| 3017 | 3015 | ||
src/client/discovery.zig
| Old | New | ||
|---|---|---|---|
| @@ -62,7 +62,7 @@ pub const Job = struct { | |||
| 62 | return self; | 62 | return self; |
| 63 | } | 63 | } |
| 64 | pub fn cancel(self: *Job) void { | 64 | pub fn cancel(self: *Job) void { |
| 65 | _ = std.posix.write(self.cancel_pipe[1], &.{client.keymap.detach_key}) catch {}; | 65 | _ = std.posix.write(self.cancel_pipe[1], &.{client.interrupt.detach_key}) catch {}; |
| 66 | } | 66 | } |
| 67 | /// Join before discarding this request or its callback context. Cancel is | 67 | /// Join before discarding this request or its callback context. Cancel is |
| 68 | /// independent of allocation and interrupts open, partial IO and reply waits. | 68 | /// independent of allocation and interrupts open, partial IO and reply waits. |
src/client/interrupt.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,8 @@ | |||
| 1 | //! The byte that interrupts a client dial, reconnect, or interactive prefix. | ||
| 2 | //! | ||
| 3 | //! Ctrl-\ is the live session command prefix (interact.PrefixFilter); while | ||
| 4 | //! dialling or reconnecting there is no session to command, so a bare press | ||
| 5 | //! still means "give up". This belongs with client cancellation rather than | ||
| 6 | //! surface input encoding because callers use it to interrupt local work. | ||
| 7 | |||
| 8 | pub const detach_key: u8 = 0x1c; | ||
src/client/keymap.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,386 +0,0 @@ | |||
| 1 | //! Normalized key events → VT byte sequences. Each shell produces the | ||
| 2 | //! normalized form and this module owns every byte a key event turns into, so | ||
| 3 | //! the tables unit-test natively with no browser in the loop. | ||
| 4 | //! | ||
| 5 | //! Scope: printable input, control characters, arrows and nav keys, function | ||
| 6 | //! keys, the xterm modifier-encoded CSI variants, bracketed paste. Deferred: | ||
| 7 | //! kitty/CSI-u. Platform-free — this must compile for wasm32-freestanding. | ||
| 8 | // Rationale: turning a key event into VT bytes is this module's whole contract, and it does so with no terminal in sight. | ||
| 9 | |||
| 10 | const std = @import("std"); | ||
| 11 | |||
| 12 | // Ctrl-\. In a live session it is the command prefix (interact.PrefixFilter); | ||
| 13 | // while dialling or reconnecting there is no session to command, so a bare | ||
| 14 | // press still means "give up", which is why the byte is named here and not | ||
| 15 | // beside the filter — the dialler must know it without linking a terminal. | ||
| 16 | pub const detach_key: u8 = 0x1c; | ||
| 17 | |||
| 18 | pub const Mods = packed struct { | ||
| 19 | shift: bool = false, | ||
| 20 | alt: bool = false, | ||
| 21 | ctrl: bool = false, | ||
| 22 | |||
| 23 | /// The xterm modifier parameter: 1 + shift + 2*alt + 4*ctrl. A CSI | ||
| 24 | /// sequence carries it only when it is > 1 — an unmodified key uses | ||
| 25 | /// the short form. | ||
| 26 | pub fn param(self: Mods) u8 { | ||
| 27 | return 1 + @as(u8, @intFromBool(self.shift)) + | ||
| 28 | 2 * @as(u8, @intFromBool(self.alt)) + | ||
| 29 | 4 * @as(u8, @intFromBool(self.ctrl)); | ||
| 30 | } | ||
| 31 | |||
| 32 | pub fn any(self: Mods) bool { | ||
| 33 | return self.shift or self.alt or self.ctrl; | ||
| 34 | } | ||
| 35 | }; | ||
| 36 | |||
| 37 | pub const Key = enum { | ||
| 38 | /// A printable character; `Event.cp` carries the codepoint. | ||
| 39 | char, | ||
| 40 | enter, | ||
| 41 | tab, | ||
| 42 | backspace, | ||
| 43 | escape, | ||
| 44 | up, | ||
| 45 | down, | ||
| 46 | left, | ||
| 47 | right, | ||
| 48 | home, | ||
| 49 | end, | ||
| 50 | insert, | ||
| 51 | delete, | ||
| 52 | page_up, | ||
| 53 | page_down, | ||
| 54 | f1, | ||
| 55 | f2, | ||
| 56 | f3, | ||
| 57 | f4, | ||
| 58 | f5, | ||
| 59 | f6, | ||
| 60 | f7, | ||
| 61 | f8, | ||
| 62 | f9, | ||
| 63 | f10, | ||
| 64 | f11, | ||
| 65 | f12, | ||
| 66 | }; | ||
| 67 | |||
| 68 | pub const Event = struct { | ||
| 69 | key: Key, | ||
| 70 | /// Codepoint for .char, 0 otherwise. | ||
| 71 | cp: u21 = 0, | ||
| 72 | mods: Mods = .{}, | ||
| 73 | }; | ||
| 74 | |||
| 75 | /// Every sequence this module can emit fits here with room to spare — | ||
| 76 | /// the widest today is the 7-byte modified tilde CSI; the slack is | ||
| 77 | /// headroom for forms not in the table yet. | ||
| 78 | pub const max_seq_len = 16; | ||
| 79 | pub const MouseFormat = enum { x10, utf8, sgr, urxvt, sgr_pixels }; | ||
| 80 | pub const mouse_max_seq_len = 64; | ||
| 81 | |||
| 82 | /// Encode one semantic mouse event. Coordinates are one-based on the wire. | ||
| 83 | pub fn encodeMouse(format: MouseFormat, button_base: u8, release: bool, cell_x: u16, cell_y: u16, pixel_x: u32, pixel_y: u32, mods: Mods, buf: []u8) []const u8 { | ||
| 84 | std.debug.assert(buf.len >= mouse_max_seq_len); | ||
| 85 | const base: u16 = if (release and format != .sgr and format != .sgr_pixels) 3 else button_base; | ||
| 86 | const button: u16 = base + @as(u16, @intFromBool(mods.shift)) * 4 + @as(u16, @intFromBool(mods.alt)) * 8 + @as(u16, @intFromBool(mods.ctrl)) * 16; | ||
| 87 | const x: u32 = if (format == .sgr_pixels) pixel_x else cell_x; | ||
| 88 | const y: u32 = if (format == .sgr_pixels) pixel_y else cell_y; | ||
| 89 | const bx = x +| 1; | ||
| 90 | const by = y +| 1; | ||
| 91 | switch (format) { | ||
| 92 | .sgr, .sgr_pixels => return std.fmt.bufPrint(buf, "\x1b[<{d};{d};{d}{c}", .{ button, bx, by, @as(u8, if (release) 'm' else 'M') }) catch unreachable, | ||
| 93 | .urxvt => return std.fmt.bufPrint(buf, "\x1b[{d};{d};{d}M", .{ button + 32, bx, by }) catch unreachable, | ||
| 94 | .x10 => { | ||
| 95 | if (bx > 223 or by > 223) return buf[0..0]; | ||
| 96 | buf[0] = 0x1b; | ||
| 97 | buf[1] = '['; | ||
| 98 | buf[2] = 'M'; | ||
| 99 | buf[3] = @intCast(button + 32); | ||
| 100 | buf[4] = @intCast(bx + 32); | ||
| 101 | buf[5] = @intCast(by + 32); | ||
| 102 | return buf[0..6]; | ||
| 103 | }, | ||
| 104 | .utf8 => { | ||
| 105 | if (bx > 2015 or by > 2015) return buf[0..0]; | ||
| 106 | buf[0] = 0x1b; | ||
| 107 | buf[1] = '['; | ||
| 108 | buf[2] = 'M'; | ||
| 109 | var n: usize = 3; | ||
| 110 | for ([_]u21{ button + 32, @intCast(bx + 32), @intCast(by + 32) }) |cp| { | ||
| 111 | n += std.unicode.utf8Encode(cp, buf[n..]) catch unreachable; | ||
| 112 | } | ||
| 113 | return buf[0..n]; | ||
| 114 | }, | ||
| 115 | } | ||
| 116 | } | ||
| 117 | |||
| 118 | pub fn encodeWheel(format: MouseFormat, up: bool, cell_x: u16, cell_y: u16, pixel_x: u32, pixel_y: u32, mods: Mods, buf: []u8) []const u8 { | ||
| 119 | return encodeMouse(format, if (up) 64 else 65, false, cell_x, cell_y, pixel_x, pixel_y, mods, buf); | ||
| 120 | } | ||
| 121 | |||
| 122 | pub fn encodeWheelArrow(up: bool, cursor_keys: bool, buf: []u8) []const u8 { | ||
| 123 | const bytes = encode(.{ .key = if (up) .up else .down }, buf); | ||
| 124 | if (cursor_keys) buf[1] = 'O'; | ||
| 125 | return bytes; | ||
| 126 | } | ||
| 127 | |||
| 128 | test "wheel encodes negotiated SGR and legacy forms" { | ||
| 129 | var buf: [mouse_max_seq_len]u8 = undefined; | ||
| 130 | try std.testing.expectEqualStrings("\x1b[<64;4;6M", encodeWheel(.sgr, true, 3, 5, 0, 0, .{}, &buf)); | ||
| 131 | try std.testing.expectEqualStrings("\x1b[97;4;6M", encodeWheel(.urxvt, false, 3, 5, 0, 0, .{}, &buf)); | ||
| 132 | try std.testing.expectEqualStrings("\x1bOA", encodeWheelArrow(true, true, &buf)); | ||
| 133 | try std.testing.expectEqualStrings("\x1b[B", encodeWheelArrow(false, false, &buf)); | ||
| 134 | try std.testing.expectEqualSlices(u8, &.{ 27, '[', 'M', 96, 255, 255 }, encodeWheel(.x10, true, 222, 222, 0, 0, .{}, &buf)); | ||
| 135 | try std.testing.expectEqual(@as(usize, 0), encodeWheel(.x10, true, 223, 0, 0, 0, .{}, &buf).len); | ||
| 136 | try std.testing.expectEqualStrings("\x1b[<64;225;226M", encodeWheel(.sgr_pixels, true, 0, 0, 224, 225, .{}, &buf)); | ||
| 137 | try std.testing.expectEqualSlices(u8, &.{ 27, '[', 'M', 96, 0xdf, 0xbf, 0xdf, 0xbf }, encodeWheel(.utf8, true, 2014, 2014, 0, 0, .{}, &buf)); | ||
| 138 | try std.testing.expectEqual(@as(usize, 0), encodeWheel(.utf8, true, 2015, 0, 0, 0, .{}, &buf).len); | ||
| 139 | try std.testing.expectEqualStrings("\x1b[<93;1;1M", encodeWheel(.sgr, false, 0, 0, 0, 0, .{ .shift = true, .alt = true, .ctrl = true }, &buf)); | ||
| 140 | try std.testing.expectEqualStrings("\x1b[<1;4;6m", encodeMouse(.sgr, 1, true, 3, 5, 0, 0, .{}, &buf)); | ||
| 141 | try std.testing.expectEqualSlices(u8, &.{ 27, '[', 'M', 51, 33, 33 }, encodeMouse(.x10, 0, true, 0, 0, 0, 0, .{ .ctrl = true }, &buf)); | ||
| 142 | try std.testing.expectEqualStrings("\x1b[35;4;6M", encodeMouse(.urxvt, 2, true, 3, 5, 0, 0, .{}, &buf)); | ||
| 143 | try std.testing.expectEqualStrings("\x1b[<64;4;6M", encodeWheel(.sgr, true, 3, 5, 0, 0, .{}, &buf)); | ||
| 144 | } | ||
| 145 | |||
| 146 | /// Encode one event into `buf` (at least max_seq_len bytes), returning the | ||
| 147 | /// slice written. An event this table has no bytes for — a bare modifier, | ||
| 148 | /// a .char with cp 0 — encodes to the empty slice, which callers send as | ||
| 149 | /// nothing rather than as a surprise. | ||
| 150 | pub fn encode(ev: Event, buf: []u8) []const u8 { | ||
| 151 | std.debug.assert(buf.len >= max_seq_len); | ||
| 152 | switch (ev.key) { | ||
| 153 | .char => { | ||
| 154 | if (ev.cp == 0) return buf[0..0]; | ||
| 155 | var n: usize = 0; | ||
| 156 | if (ev.mods.alt) { | ||
| 157 | buf[0] = 0x1b; | ||
| 158 | n = 1; | ||
| 159 | } | ||
| 160 | if (ev.mods.ctrl) { | ||
| 161 | // The terminal tradition: Ctrl clears bits 6-5 of the ASCII | ||
| 162 | // column, so ctrl-a..z are 0x01..0x1a and ctrl-[ is ESC. Anything | ||
| 163 | // outside the foldable range falls through and sends plain. | ||
| 164 | const c = ev.cp; | ||
| 165 | if (c == ' ' or (c >= '@' and c <= '_') or (c >= 'a' and c <= 'z')) { | ||
| 166 | buf[n] = @intCast(c & 0x1f); | ||
| 167 | n += 1; | ||
| 168 | return buf[0..n]; | ||
| 169 | } | ||
| 170 | } | ||
| 171 | const len = std.unicode.utf8Encode(ev.cp, buf[n..]) catch return buf[0..0]; | ||
| 172 | return buf[0 .. n + len]; | ||
| 173 | }, | ||
| 174 | .enter => return altable(ev.mods, "\r", buf), | ||
| 175 | .tab => { | ||
| 176 | // Shift+Tab is backtab, its own sequence; plain Tab is a byte. | ||
| 177 | // folder rule 4 exemption: Key encoding produces VT bytes without accessing a terminal. | ||
| 178 | if (ev.mods.shift) return copy("\x1b[Z", buf); | ||
| 179 | return altable(ev.mods, "\t", buf); | ||
| 180 | }, | ||
| 181 | .backspace => { | ||
| 182 | // DEL, the modern default. Ctrl+Backspace sends BS so shells | ||
| 183 | // can tell them apart; Alt prefixes either. | ||
| 184 | const base: []const u8 = if (ev.mods.ctrl) "\x08" else "\x7f"; | ||
| 185 | return altable(ev.mods, base, buf); | ||
| 186 | }, | ||
| 187 | .escape => return altable(ev.mods, "\x1b", buf), | ||
| 188 | .up => return cursorKey(ev.mods, 'A', buf), | ||
| 189 | .down => return cursorKey(ev.mods, 'B', buf), | ||
| 190 | .right => return cursorKey(ev.mods, 'C', buf), | ||
| 191 | .left => return cursorKey(ev.mods, 'D', buf), | ||
| 192 | .home => return cursorKey(ev.mods, 'H', buf), | ||
| 193 | .end => return cursorKey(ev.mods, 'F', buf), | ||
| 194 | .insert => return tildeKey(ev.mods, 2, buf), | ||
| 195 | .delete => return tildeKey(ev.mods, 3, buf), | ||
| 196 | .page_up => return tildeKey(ev.mods, 5, buf), | ||
| 197 | .page_down => return tildeKey(ev.mods, 6, buf), | ||
| 198 | // F1-F4 are SS3 letters unmodified (their VT220 lineage) and CSI | ||
| 199 | // 1;mP..S modified — the xterm scheme. | ||
| 200 | .f1 => return ss3Key(ev.mods, 'P', buf), | ||
| 201 | .f2 => return ss3Key(ev.mods, 'Q', buf), | ||
| 202 | .f3 => return ss3Key(ev.mods, 'R', buf), | ||
| 203 | .f4 => return ss3Key(ev.mods, 'S', buf), | ||
| 204 | .f5 => return tildeKey(ev.mods, 15, buf), | ||
| 205 | .f6 => return tildeKey(ev.mods, 17, buf), | ||
| 206 | .f7 => return tildeKey(ev.mods, 18, buf), | ||
| 207 | .f8 => return tildeKey(ev.mods, 19, buf), | ||
| 208 | .f9 => return tildeKey(ev.mods, 20, buf), | ||
| 209 | .f10 => return tildeKey(ev.mods, 21, buf), | ||
| 210 | .f11 => return tildeKey(ev.mods, 23, buf), | ||
| 211 | .f12 => return tildeKey(ev.mods, 24, buf), | ||
| 212 | } | ||
| 213 | } | ||
| 214 | |||
| 215 | fn copy(seq: []const u8, buf: []u8) []const u8 { | ||
| 216 | @memcpy(buf[0..seq.len], seq); | ||
| 217 | return buf[0..seq.len]; | ||
| 218 | } | ||
| 219 | |||
| 220 | /// A single-byte key that Alt turns into ESC + byte. Ctrl is either baked | ||
| 221 | /// into `base` by the caller or meaningless for the key. | ||
| 222 | fn altable(mods: Mods, base: []const u8, buf: []u8) []const u8 { | ||
| 223 | var n: usize = 0; | ||
| 224 | if (mods.alt) { | ||
| 225 | buf[0] = 0x1b; | ||
| 226 | n = 1; | ||
| 227 | } | ||
| 228 | @memcpy(buf[n .. n + base.len], base); | ||
| 229 | return buf[0 .. n + base.len]; | ||
| 230 | } | ||
| 231 | |||
| 232 | /// The two three-byte intro forms, which differ ONLY in the intro byte | ||
| 233 | /// and only while unmodified: modified, both spell the same CSI | ||
| 234 | /// parameter form (SS3 has nowhere to put a parameter). | ||
| 235 | fn introKey(mods: Mods, intro: u8, final: u8, buf: []u8) []const u8 { | ||
| 236 | if (mods.any()) | ||
| 237 | // folder rule 4 exemption: Key encoding produces VT bytes without accessing a terminal. | ||
| 238 | return std.fmt.bufPrint(buf, "\x1b[1;{d}{c}", .{ mods.param(), final }) catch unreachable; | ||
| 239 | buf[0] = 0x1b; | ||
| 240 | buf[1] = intro; | ||
| 241 | buf[2] = final; | ||
| 242 | return buf[0..3]; | ||
| 243 | } | ||
| 244 | |||
| 245 | /// CSI letter form: ESC [ <final>, or ESC [ 1 ; <mods> <final> when | ||
| 246 | /// modified. | ||
| 247 | fn cursorKey(mods: Mods, final: u8, buf: []u8) []const u8 { | ||
| 248 | return introKey(mods, '[', final, buf); | ||
| 249 | } | ||
| 250 | |||
| 251 | /// CSI tilde form: ESC [ <n> ~, or ESC [ <n> ; <mods> ~ when modified. | ||
| 252 | fn tildeKey(mods: Mods, n: u8, buf: []u8) []const u8 { | ||
| 253 | if (!mods.any()) | ||
| 254 | // folder rule 4 exemption: Key encoding produces VT bytes without accessing a terminal. | ||
| 255 | return std.fmt.bufPrint(buf, "\x1b[{d}~", .{n}) catch unreachable; | ||
| 256 | // folder rule 4 exemption: Key encoding produces VT bytes without accessing a terminal. | ||
| 257 | return std.fmt.bufPrint(buf, "\x1b[{d};{d}~", .{ n, mods.param() }) catch unreachable; | ||
| 258 | } | ||
| 259 | |||
| 260 | /// SS3 has nowhere to put a parameter, so a modified F1-F4 goes as CSI. | ||
| 261 | fn ss3Key(mods: Mods, final: u8, buf: []u8) []const u8 { | ||
| 262 | return introKey(mods, 'O', final, buf); | ||
| 263 | } | ||
| 264 | |||
| 265 | // folder rule 4 exemption: Key encoding produces VT bytes without accessing a terminal. | ||
| 266 | pub const paste_begin = "\x1b[200~"; | ||
| 267 | // folder rule 4 exemption: Key encoding produces VT bytes without accessing a terminal. | ||
| 268 | pub const paste_end = "\x1b[201~"; | ||
| 269 | |||
| 270 | // --------------------------------------------------------------------------- | ||
| 271 | |||
| 272 | test "keymap: the table" { | ||
| 273 | const cases = [_]struct { ev: Event, want: []const u8 }{ | ||
| 274 | // Printable, plain and modified. | ||
| 275 | .{ .ev = .{ .key = .char, .cp = 'a' }, .want = "a" }, | ||
| 276 | .{ .ev = .{ .key = .char, .cp = 'Z' }, .want = "Z" }, | ||
| 277 | .{ .ev = .{ .key = .char, .cp = 'a', .mods = .{ .ctrl = true } }, .want = "\x01" }, | ||
| 278 | .{ .ev = .{ .key = .char, .cp = 'z', .mods = .{ .ctrl = true } }, .want = "\x1a" }, | ||
| 279 | .{ .ev = .{ .key = .char, .cp = 'C', .mods = .{ .ctrl = true } }, .want = "\x03" }, | ||
| 280 | .{ .ev = .{ .key = .char, .cp = ' ', .mods = .{ .ctrl = true } }, .want = "\x00" }, | ||
| 281 | .{ .ev = .{ .key = .char, .cp = '[', .mods = .{ .ctrl = true } }, .want = "\x1b" }, | ||
| 282 | .{ .ev = .{ .key = .char, .cp = '_', .mods = .{ .ctrl = true } }, .want = "\x1f" }, | ||
| 283 | .{ .ev = .{ .key = .char, .cp = 'x', .mods = .{ .alt = true } }, .want = "\x1bx" }, | ||
| 284 | .{ .ev = .{ .key = .char, .cp = 'b', .mods = .{ .alt = true, .ctrl = true } }, .want = "\x1b\x02" }, | ||
| 285 | // UTF-8 out, straight through. | ||
| 286 | .{ .ev = .{ .key = .char, .cp = 0x6f22 }, .want = "\xe6\xbc\xa2" }, // 漢 | ||
| 287 | .{ .ev = .{ .key = .char, .cp = 0xe9 }, .want = "\xc3\xa9" }, // é | ||
| 288 | // The single-byte keys. | ||
| 289 | .{ .ev = .{ .key = .enter }, .want = "\r" }, | ||
| 290 | .{ .ev = .{ .key = .enter, .mods = .{ .alt = true } }, .want = "\x1b\r" }, | ||
| 291 | .{ .ev = .{ .key = .tab }, .want = "\t" }, | ||
| 292 | .{ .ev = .{ .key = .tab, .mods = .{ .shift = true } }, .want = "\x1b[Z" }, | ||
| 293 | .{ .ev = .{ .key = .backspace }, .want = "\x7f" }, | ||
| 294 | .{ .ev = .{ .key = .backspace, .mods = .{ .ctrl = true } }, .want = "\x08" }, | ||
| 295 | .{ .ev = .{ .key = .backspace, .mods = .{ .alt = true } }, .want = "\x1b\x7f" }, | ||
| 296 | .{ .ev = .{ .key = .escape }, .want = "\x1b" }, | ||
| 297 | // Cursor and nav keys, short forms. | ||
| 298 | .{ .ev = .{ .key = .up }, .want = "\x1b[A" }, | ||
| 299 | .{ .ev = .{ .key = .down }, .want = "\x1b[B" }, | ||
| 300 | .{ .ev = .{ .key = .right }, .want = "\x1b[C" }, | ||
| 301 | .{ .ev = .{ .key = .left }, .want = "\x1b[D" }, | ||
| 302 | .{ .ev = .{ .key = .home }, .want = "\x1b[H" }, | ||
| 303 | .{ .ev = .{ .key = .end }, .want = "\x1b[F" }, | ||
| 304 | .{ .ev = .{ .key = .insert }, .want = "\x1b[2~" }, | ||
| 305 | .{ .ev = .{ .key = .delete }, .want = "\x1b[3~" }, | ||
| 306 | .{ .ev = .{ .key = .page_up }, .want = "\x1b[5~" }, | ||
| 307 | .{ .ev = .{ .key = .page_down }, .want = "\x1b[6~" }, | ||
| 308 | // Function keys. | ||
| 309 | .{ .ev = .{ .key = .f1 }, .want = "\x1bOP" }, | ||
| 310 | .{ .ev = .{ .key = .f4 }, .want = "\x1bOS" }, | ||
| 311 | .{ .ev = .{ .key = .f5 }, .want = "\x1b[15~" }, | ||
| 312 | .{ .ev = .{ .key = .f6 }, .want = "\x1b[17~" }, | ||
| 313 | .{ .ev = .{ .key = .f10 }, .want = "\x1b[21~" }, | ||
| 314 | .{ .ev = .{ .key = .f11 }, .want = "\x1b[23~" }, | ||
| 315 | .{ .ev = .{ .key = .f12 }, .want = "\x1b[24~" }, | ||
| 316 | // Modified CSI spot checks (the matrix test below is exhaustive). | ||
| 317 | .{ .ev = .{ .key = .up, .mods = .{ .ctrl = true } }, .want = "\x1b[1;5A" }, | ||
| 318 | .{ .ev = .{ .key = .up, .mods = .{ .shift = true, .alt = true } }, .want = "\x1b[1;4A" }, | ||
| 319 | .{ .ev = .{ .key = .delete, .mods = .{ .shift = true } }, .want = "\x1b[3;2~" }, | ||
| 320 | .{ .ev = .{ .key = .f5, .mods = .{ .ctrl = true } }, .want = "\x1b[15;5~" }, | ||
| 321 | .{ .ev = .{ .key = .f1, .mods = .{ .shift = true } }, .want = "\x1b[1;2P" }, | ||
| 322 | // Nothing to send. | ||
| 323 | .{ .ev = .{ .key = .char, .cp = 0 }, .want = "" }, | ||
| 324 | }; | ||
| 325 | var buf: [max_seq_len]u8 = undefined; | ||
| 326 | for (cases) |case| { | ||
| 327 | const got = encode(case.ev, &buf); | ||
| 328 | std.testing.expectEqualSlices(u8, case.want, got) catch |err| { | ||
| 329 | std.debug.print("keymap: {any} -> {any}, want {any}\n", .{ case.ev, got, case.want }); | ||
| 330 | return err; | ||
| 331 | }; | ||
| 332 | } | ||
| 333 | } | ||
| 334 | |||
| 335 | test "keymap: the modifier matrix over every CSI-carrying key" { | ||
| 336 | // param = 1 + shift + 2*alt + 4*ctrl; every modified form carries it, | ||
| 337 | // every unmodified form omits it. Exhaustive over the seven non-empty | ||
| 338 | // modifier combinations x every key with a CSI form. | ||
| 339 | const csi_keys = [_]struct { key: Key, mid: []const u8, tail: []const u8 }{ | ||
| 340 | .{ .key = .up, .mid = "1;", .tail = "A" }, | ||
| 341 | .{ .key = .down, .mid = "1;", .tail = "B" }, | ||
| 342 | .{ .key = .right, .mid = "1;", .tail = "C" }, | ||
| 343 | .{ .key = .left, .mid = "1;", .tail = "D" }, | ||
| 344 | .{ .key = .home, .mid = "1;", .tail = "H" }, | ||
| 345 | .{ .key = .end, .mid = "1;", .tail = "F" }, | ||
| 346 | .{ .key = .insert, .mid = "2;", .tail = "~" }, | ||
| 347 | .{ .key = .delete, .mid = "3;", .tail = "~" }, | ||
| 348 | .{ .key = .page_up, .mid = "5;", .tail = "~" }, | ||
| 349 | .{ .key = .page_down, .mid = "6;", .tail = "~" }, | ||
| 350 | .{ .key = .f1, .mid = "1;", .tail = "P" }, | ||
| 351 | .{ .key = .f2, .mid = "1;", .tail = "Q" }, | ||
| 352 | .{ .key = .f3, .mid = "1;", .tail = "R" }, | ||
| 353 | .{ .key = .f4, .mid = "1;", .tail = "S" }, | ||
| 354 | .{ .key = .f5, .mid = "15;", .tail = "~" }, | ||
| 355 | .{ .key = .f6, .mid = "17;", .tail = "~" }, | ||
| 356 | .{ .key = .f7, .mid = "18;", .tail = "~" }, | ||
| 357 | .{ .key = .f8, .mid = "19;", .tail = "~" }, | ||
| 358 | .{ .key = .f9, .mid = "20;", .tail = "~" }, | ||
| 359 | .{ .key = .f10, .mid = "21;", .tail = "~" }, | ||
| 360 | .{ .key = .f11, .mid = "23;", .tail = "~" }, | ||
| 361 | .{ .key = .f12, .mid = "24;", .tail = "~" }, | ||
| 362 | }; | ||
| 363 | var buf: [max_seq_len]u8 = undefined; | ||
| 364 | var want_buf: [max_seq_len]u8 = undefined; | ||
| 365 | for (csi_keys) |k| { | ||
| 366 | var m: u8 = 2; | ||
| 367 | while (m <= 8) : (m += 1) { | ||
| 368 | const mods = Mods{ | ||
| 369 | .shift = (m - 1) & 1 != 0, | ||
| 370 | .alt = (m - 1) & 2 != 0, | ||
| 371 | .ctrl = (m - 1) & 4 != 0, | ||
| 372 | }; | ||
| 373 | try std.testing.expectEqual(m, mods.param()); | ||
| 374 | const want = try std.fmt.bufPrint(&want_buf, "\x1b[{s}{d}{s}", .{ k.mid, m, k.tail }); | ||
| 375 | const got = encode(.{ .key = k.key, .mods = mods }, &buf); | ||
| 376 | try std.testing.expectEqualSlices(u8, want, got); | ||
| 377 | } | ||
| 378 | } | ||
| 379 | } | ||
| 380 | |||
| 381 | // Forces semantic analysis of every pub decl under `zig build test`, so an | ||
| 382 | // unreferenced decl must at least compile (the silent-module-loss hazard, | ||
| 383 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. | ||
| 384 | test { | ||
| 385 | std.testing.refAllDeclsRecursive(@This()); | ||
| 386 | } | ||
src/client/open_wait.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,6 @@ | |||
| 1 | //! Shared cancellation and absolute deadline for one opening/request operation. | 1 | //! Shared cancellation and absolute deadline for one opening/request operation. |
| 2 | const std = @import("std"); | 2 | const std = @import("std"); |
| 3 | const keymap = @import("keymap.zig"); | 3 | const interrupt = @import("interrupt.zig"); |
| 4 | pub const Wait = struct { | 4 | pub const Wait = struct { |
| 5 | alloc: std.mem.Allocator, | 5 | alloc: std.mem.Allocator, |
| 6 | abort_fd: std.posix.fd_t = -1, | 6 | abort_fd: std.posix.fd_t = -1, |
| @@ -15,7 +15,7 @@ pub const Wait = struct { | |||
| 15 | var buf: [256]u8 = undefined; | 15 | var buf: [256]u8 = undefined; |
| 16 | const n = std.posix.read(self.abort_fd, &buf) catch 0; | 16 | const n = std.posix.read(self.abort_fd, &buf) catch 0; |
| 17 | if (n == 0) self.watching = false; | 17 | if (n == 0) self.watching = false; |
| 18 | if (std.mem.indexOfScalar(u8, buf[0..n], keymap.detach_key) != null) return error.UserAbort; | 18 | if (std.mem.indexOfScalar(u8, buf[0..n], interrupt.detach_key) != null) return error.UserAbort; |
| 19 | if (self.carry) |out| try out.appendSlice(self.alloc, buf[0..n]); | 19 | if (self.carry) |out| try out.appendSlice(self.alloc, buf[0..n]); |
| 20 | } | 20 | } |
| 21 | } | 21 | } |
| @@ -112,7 +112,7 @@ test "full Unix listen backlog remains cancellable and restores blocking only af | |||
| 112 | const Cancel = struct { | 112 | const Cancel = struct { |
| 113 | fn fire(fd: std.posix.fd_t) void { | 113 | fn fire(fd: std.posix.fd_t) void { |
| 114 | std.Thread.sleep(20 * std.time.ns_per_ms); | 114 | std.Thread.sleep(20 * std.time.ns_per_ms); |
| 115 | _ = std.posix.write(fd, &.{keymap.detach_key}) catch {}; | 115 | _ = std.posix.write(fd, &.{interrupt.detach_key}) catch {}; |
| 116 | } | 116 | } |
| 117 | }; | 117 | }; |
| 118 | const thread = try std.Thread.spawn(.{}, Cancel.fire, .{pipe[1]}); | 118 | const thread = try std.Thread.spawn(.{}, Cancel.fire, .{pipe[1]}); |
src/client/resolver.zig
| Old | New | ||
|---|---|---|---|
| @@ -4,6 +4,7 @@ | |||
| 4 | const std = @import("std"); | 4 | const std = @import("std"); |
| 5 | const quic = @import("quic"); | 5 | const quic = @import("quic"); |
| 6 | const Wait = @import("open_wait.zig").Wait; | 6 | const Wait = @import("open_wait.zig").Wait; |
| 7 | const interrupt = @import("interrupt.zig"); | ||
| 7 | const flag = "--internal-resolve"; | 8 | const flag = "--internal-resolve"; |
| 8 | const result_len = 21; | 9 | const result_len = 21; |
| 9 | 10 | ||
| @@ -80,7 +81,7 @@ test "cancel fd interrupts and reaps a stalled resolver child" { | |||
| 80 | const Cancel = struct { | 81 | const Cancel = struct { |
| 81 | fn fire(fd: std.posix.fd_t) void { | 82 | fn fire(fd: std.posix.fd_t) void { |
| 82 | std.Thread.sleep(20 * std.time.ns_per_ms); | 83 | std.Thread.sleep(20 * std.time.ns_per_ms); |
| 83 | _ = std.posix.write(fd, &.{@import("keymap.zig").detach_key}) catch {}; | 84 | _ = std.posix.write(fd, &.{interrupt.detach_key}) catch {}; |
| 84 | } | 85 | } |
| 85 | }; | 86 | }; |
| 86 | const thread = try std.Thread.spawn(.{}, Cancel.fire, .{pipe[1]}); | 87 | const thread = try std.Thread.spawn(.{}, Cancel.fire, .{pipe[1]}); |
src/client/session_pump.zig
| Old | New | ||
|---|---|---|---|
| @@ -4,6 +4,7 @@ | |||
| 4 | //! pumpTile is the first candidate to migrate here. | 4 | //! pumpTile is the first candidate to migrate here. |
| 5 | const std = @import("std"); | 5 | const std = @import("std"); |
| 6 | const client = @import("client.zig"); | 6 | const client = @import("client.zig"); |
| 7 | const app_input = @import("input"); | ||
| 7 | const term = @import("term"); | 8 | const term = @import("term"); |
| 8 | const proto = term.protocol; | 9 | const proto = term.protocol; |
| 9 | const Wire = @import("buffered_wire.zig").Wire; | 10 | const Wire = @import("buffered_wire.zig").Wire; |
| @@ -37,7 +38,7 @@ pub const Wheel = struct { | |||
| 37 | row: u16, | 38 | row: u16, |
| 38 | pixel_x: u32, | 39 | pixel_x: u32, |
| 39 | pixel_y: u32, | 40 | pixel_y: u32, |
| 40 | mods: client.keymap.Mods = .{}, | 41 | mods: app_input.Mods = .{}, |
| 41 | }; | 42 | }; |
| 42 | /// A GUI gesture belongs to the modes and wire admitted at its press. | 43 | /// A GUI gesture belongs to the modes and wire admitted at its press. |
| 43 | pub const MouseToken = struct { generation: u64, modes: proto.TermModes }; | 44 | pub const MouseToken = struct { generation: u64, modes: proto.TermModes }; |
| @@ -49,9 +50,9 @@ pub const Mouse = struct { | |||
| 49 | row: u16, | 50 | row: u16, |
| 50 | pixel_x: u32, | 51 | pixel_x: u32, |
| 51 | pixel_y: u32, | 52 | pixel_y: u32, |
| 52 | mods: client.keymap.Mods = .{}, | 53 | mods: app_input.Mods = .{}, |
| 53 | }; | 54 | }; |
| 54 | fn mouseFormat(modes: proto.TermModes) client.keymap.MouseFormat { | 55 | fn mouseFormat(modes: proto.TermModes) app_input.MouseFormat { |
| 55 | return if (modes.mouse_sgr_pixels) .sgr_pixels else if (modes.mouse_sgr) .sgr else if (modes.mouse_urxvt) .urxvt else if (modes.mouse_utf8) .utf8 else .x10; | 56 | return if (modes.mouse_sgr_pixels) .sgr_pixels else if (modes.mouse_sgr) .sgr else if (modes.mouse_urxvt) .urxvt else if (modes.mouse_utf8) .utf8 else .x10; |
| 56 | } | 57 | } |
| 57 | const HistoryRequest = struct { | 58 | const HistoryRequest = struct { |
| @@ -198,7 +199,7 @@ pub const Pump = struct { | |||
| 198 | }, | 199 | }, |
| 199 | .quit, .detach => { | 200 | .quit, .detach => { |
| 200 | self.closing.store(true, .release); | 201 | self.closing.store(true, .release); |
| 201 | ring(self.cancel_pipe[1], client.keymap.detach_key); | 202 | ring(self.cancel_pipe[1], client.interrupt.detach_key); |
| 202 | }, | 203 | }, |
| 203 | else => { | 204 | else => { |
| 204 | var owned = msg; | 205 | var owned = msg; |
| @@ -625,8 +626,8 @@ pub const Pump = struct { | |||
| 625 | fn writeMouse(wire: *Wire, event: Mouse) !bool { | 626 | fn writeMouse(wire: *Wire, event: Mouse) !bool { |
| 626 | const modes = event.token.modes; | 627 | const modes = event.token.modes; |
| 627 | if (event.kind == .release and modes.mouse_x10 and !modes.mouse_normal and !modes.mouse_button and !modes.mouse_any) return false; | 628 | if (event.kind == .release and modes.mouse_x10 and !modes.mouse_normal and !modes.mouse_button and !modes.mouse_any) return false; |
| 628 | var seq: [client.keymap.mouse_max_seq_len]u8 = undefined; | 629 | var seq: [app_input.mouse_max_seq_len]u8 = undefined; |
| 629 | const bytes = client.keymap.encodeMouse(mouseFormat(modes), event.button + @as(u8, if (event.kind == .motion) 32 else 0), event.kind == .release, event.col, event.row, event.pixel_x, event.pixel_y, event.mods, &seq); | 630 | const bytes = app_input.encodeMouse(mouseFormat(modes), event.button + @as(u8, if (event.kind == .motion) 32 else 0), event.kind == .release, event.col, event.row, event.pixel_x, event.pixel_y, event.mods, &seq); |
| 630 | if (bytes.len == 0) return false; | 631 | if (bytes.len == 0) return false; |
| 631 | try wire.send(.input, bytes); | 632 | try wire.send(.input, bytes); |
| 632 | return true; | 633 | return true; |
| @@ -706,8 +707,8 @@ pub const Pump = struct { | |||
| 706 | self.selection_revision +%= 1; | 707 | self.selection_revision +%= 1; |
| 707 | self.mu.unlock(); | 708 | self.mu.unlock(); |
| 708 | self.wake(); | 709 | self.wake(); |
| 709 | var seq: [client.keymap.mouse_max_seq_len]u8 = undefined; | 710 | var seq: [app_input.mouse_max_seq_len]u8 = undefined; |
| 710 | const bytes = if (modes.appMouse()) client.keymap.encodeWheel( | 711 | const bytes = if (modes.appMouse()) app_input.encodeWheel( |
| 711 | mouseFormat(modes), | 712 | mouseFormat(modes), |
| 712 | wheel.notches > 0, | 713 | wheel.notches > 0, |
| 713 | wheel.col, | 714 | wheel.col, |
| @@ -716,7 +717,7 @@ pub const Pump = struct { | |||
| 716 | wheel.pixel_y, | 717 | wheel.pixel_y, |
| 717 | wheel.mods, | 718 | wheel.mods, |
| 718 | &seq, | 719 | &seq, |
| 719 | ) else client.keymap.encodeWheelArrow(wheel.notches > 0, modes.cursor_keys, &seq); | 720 | ) else app_input.encodeWheelArrow(wheel.notches > 0, modes.cursor_keys, &seq); |
| 720 | if (bytes.len == 0) return; | 721 | if (bytes.len == 0) return; |
| 721 | // Bound a single mailbox event even if an input adapter supplies an | 722 | // Bound a single mailbox event even if an input adapter supplies an |
| 722 | // extreme delta. Ordinary wheels are one or a few notches per event. | 723 | // extreme delta. Ordinary wheels are one or a few notches per event. |
src/client/wasm_core.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,4 +1,4 @@ | |||
| 1 | //! The browser replica core: Grid + Replica + ClientCore + keymap compiled | 1 | //! The browser replica core: Grid + Replica + ClientCore + shared input compiled |
| 2 | //! to wasm32-freestanding. The JS shell is glue; every decision is on this side. | 2 | //! to wasm32-freestanding. The JS shell is glue; every decision is on this side. |
| 3 | //! | 3 | //! |
| 4 | //! FRAME-driven, not byte-driven: the host stages one payload and calls | 4 | //! FRAME-driven, not byte-driven: the host stages one payload and calls |
| @@ -15,7 +15,7 @@ const builtin = @import("builtin"); | |||
| 15 | const grid_mod = @import("term").grid; | 15 | const grid_mod = @import("term").grid; |
| 16 | const Grid = grid_mod.Grid; | 16 | const Grid = grid_mod.Grid; |
| 17 | const Replica = @import("term").replica.Replica; | 17 | const Replica = @import("term").replica.Replica; |
| 18 | const keymap = @import("keymap.zig"); | 18 | const input = @import("input"); |
| 19 | const proto = @import("term").protocol; | 19 | const proto = @import("term").protocol; |
| 20 | const client_core = @import("client_core.zig"); | 20 | const client_core = @import("client_core.zig"); |
| 21 | 21 | ||
| @@ -433,14 +433,14 @@ export fn mux_scroll_start(pages_up: u32, view_rows: u32) u32 { | |||
| 433 | // Input encoding | 433 | // Input encoding |
| 434 | // --------------------------------------------------------------------- | 434 | // --------------------------------------------------------------------- |
| 435 | 435 | ||
| 436 | /// `keymap.Key` by `@intFromEnum`, a table JS mirrors: char=0, enter=1, tab=2, | 436 | /// `input.Key` by `@intFromEnum`, a table JS mirrors: char=0, enter=1, tab=2, |
| 437 | /// backspace=3, escape=4, up=5, down=6, left=7, right=8, home=9, end=10, | 437 | /// backspace=3, escape=4, up=5, down=6, left=7, right=8, home=9, end=10, |
| 438 | /// insert=11, delete=12, page_up=13, page_down=14, f1..f12=15..26. `mods` is | 438 | /// insert=11, delete=12, page_up=13, page_down=14, f1..f12=15..26. `mods` is |
| 439 | /// bit0 shift, bit1 alt, bit2 ctrl. Returns bytes written, -3 on an unknown key. | 439 | /// bit0 shift, bit1 alt, bit2 ctrl. Returns bytes written, -3 on an unknown key. |
| 440 | export fn mux_key_encode(key: u32, cp: u32, mods: u32) i32 { | 440 | export fn mux_key_encode(key: u32, cp: u32, mods: u32) i32 { |
| 441 | const k = std.meta.intToEnum(keymap.Key, key) catch return -3; | 441 | const k = std.meta.intToEnum(input.Key, key) catch return -3; |
| 442 | if (cp > 0x10ffff) return -3; | 442 | if (cp > 0x10ffff) return -3; |
| 443 | const ev = keymap.Event{ | 443 | const ev = input.Event{ |
| 444 | .key = k, | 444 | .key = k, |
| 445 | .cp = @intCast(cp), | 445 | .cp = @intCast(cp), |
| 446 | .mods = .{ | 446 | .mods = .{ |
| @@ -449,8 +449,8 @@ export fn mux_key_encode(key: u32, cp: u32, mods: u32) i32 { | |||
| 449 | .ctrl = mods & 4 != 0, | 449 | .ctrl = mods & 4 != 0, |
| 450 | }, | 450 | }, |
| 451 | }; | 451 | }; |
| 452 | var buf: [keymap.max_seq_len]u8 = undefined; | 452 | var buf: [input.max_seq_len]u8 = undefined; |
| 453 | const seq = keymap.encode(ev, &buf); | 453 | const seq = input.encode(ev, &buf); |
| 454 | @memcpy(output_buf[0..seq.len], seq); | 454 | @memcpy(output_buf[0..seq.len], seq); |
| 455 | output_len = @intCast(seq.len); | 455 | output_len = @intCast(seq.len); |
| 456 | return @intCast(seq.len); | 456 | return @intCast(seq.len); |
| @@ -468,11 +468,11 @@ export fn mux_text_encode(len: u32) i32 { | |||
| 468 | } | 468 | } |
| 469 | 469 | ||
| 470 | export fn mux_paste_begin() i32 { | 470 | export fn mux_paste_begin() i32 { |
| 471 | return pasteMarker(keymap.paste_begin); | 471 | return pasteMarker(input.paste_begin); |
| 472 | } | 472 | } |
| 473 | 473 | ||
| 474 | export fn mux_paste_end() i32 { | 474 | export fn mux_paste_end() i32 { |
| 475 | return pasteMarker(keymap.paste_end); | 475 | return pasteMarker(input.paste_end); |
| 476 | } | 476 | } |
| 477 | 477 | ||
| 478 | /// Each marker on its own: a paste too big for one message is still ONE | 478 | /// Each marker on its own: a paste too big for one message is still ONE |
src/gui/frame.zig
| Old | New | ||
|---|---|---|---|
| @@ -2,7 +2,7 @@ | |||
| 2 | const std = @import("std"); | 2 | const std = @import("std"); |
| 3 | const client = @import("client"); | 3 | const client = @import("client"); |
| 4 | const term = @import("term"); | 4 | const term = @import("term"); |
| 5 | const keymap = client.keymap; | 5 | const input = @import("input"); |
| 6 | const native_core = @import("native_core"); | 6 | const native_core = @import("native_core"); |
| 7 | const model = native_core.workspace; | 7 | const model = native_core.workspace; |
| 8 | const runtime = native_core.runtime; | 8 | const runtime = native_core.runtime; |
| @@ -37,15 +37,15 @@ pub const Options = struct { | |||
| 37 | test_fifo: ?[]const u8 = null, | 37 | test_fifo: ?[]const u8 = null, |
| 38 | }; | 38 | }; |
| 39 | 39 | ||
| 40 | /// SDL keycode + mods → the keymap's event, or null for a key that types | 40 | /// SDL keycode + mods → the input event, or null for a key that types |
| 41 | /// (text input carries it) or means nothing to a session. | 41 | /// (text input carries it) or means nothing to a session. |
| 42 | pub fn keyEvent(key: u32, mod: u16) ?keymap.Event { | 42 | pub fn keyEvent(key: u32, mod: u16) ?input.Event { |
| 43 | const ctrl = mod & c.SDL_KMOD_CTRL != 0; | 43 | const ctrl = mod & c.SDL_KMOD_CTRL != 0; |
| 44 | const alt = mod & c.SDL_KMOD_ALT != 0; | 44 | const alt = mod & c.SDL_KMOD_ALT != 0; |
| 45 | const shift = mod & c.SDL_KMOD_SHIFT != 0; | 45 | const shift = mod & c.SDL_KMOD_SHIFT != 0; |
| 46 | if (mod & c.SDL_KMOD_MODE != 0 or (ctrl and mod & c.SDL_KMOD_RALT != 0)) return null; | 46 | if (mod & c.SDL_KMOD_MODE != 0 or (ctrl and mod & c.SDL_KMOD_RALT != 0)) return null; |
| 47 | const mods: keymap.Mods = .{ .ctrl = ctrl, .alt = alt, .shift = shift }; | 47 | const mods: input.Mods = .{ .ctrl = ctrl, .alt = alt, .shift = shift }; |
| 48 | const named: ?keymap.Key = switch (key) { | 48 | const named: ?input.Key = switch (key) { |
| 49 | c.SDLK_RETURN, c.SDLK_KP_ENTER => .enter, | 49 | c.SDLK_RETURN, c.SDLK_KP_ENTER => .enter, |
| 50 | c.SDLK_TAB => .tab, | 50 | c.SDLK_TAB => .tab, |
| 51 | c.SDLK_BACKSPACE => .backspace, | 51 | c.SDLK_BACKSPACE => .backspace, |
| @@ -789,14 +789,14 @@ fn report(ring: *const bench.Ring) void { | |||
| 789 | 789 | ||
| 790 | test "key mapping sends modifier punctuation and space through the shared encoder" { | 790 | test "key mapping sends modifier punctuation and space through the shared encoder" { |
| 791 | try std.testing.expect(keyEvent(c.SDLK_A, 0) == null); | 791 | try std.testing.expect(keyEvent(c.SDLK_A, 0) == null); |
| 792 | try std.testing.expectEqual(keymap.Key.up, keyEvent(c.SDLK_UP, 0).?.key); | 792 | try std.testing.expectEqual(input.Key.up, keyEvent(c.SDLK_UP, 0).?.key); |
| 793 | var buf: [keymap.max_seq_len]u8 = undefined; | 793 | var buf: [input.max_seq_len]u8 = undefined; |
| 794 | const cases = .{ .{ c.SDLK_BACKSLASH, @as(u8, 28) }, .{ c.SDLK_LEFTBRACKET, @as(u8, 27) }, .{ c.SDLK_RIGHTBRACKET, @as(u8, 29) }, .{ c.SDLK_SPACE, @as(u8, 0) } }; | 794 | const cases = .{ .{ c.SDLK_BACKSLASH, @as(u8, 28) }, .{ c.SDLK_LEFTBRACKET, @as(u8, 27) }, .{ c.SDLK_RIGHTBRACKET, @as(u8, 29) }, .{ c.SDLK_SPACE, @as(u8, 0) } }; |
| 795 | inline for (cases) |pair| { | 795 | inline for (cases) |pair| { |
| 796 | try std.testing.expectEqualSlices(u8, &.{pair[1]}, keymap.encode(keyEvent(pair[0], c.SDL_KMOD_CTRL).?, &buf)); | 796 | try std.testing.expectEqualSlices(u8, &.{pair[1]}, input.encode(keyEvent(pair[0], c.SDL_KMOD_CTRL).?, &buf)); |
| 797 | } | 797 | } |
| 798 | try std.testing.expectEqualSlices(u8, &.{ 27, 'X' }, keymap.encode(keyEvent('X', c.SDL_KMOD_LALT | c.SDL_KMOD_LSHIFT).?, &buf)); | 798 | try std.testing.expectEqualSlices(u8, &.{ 27, 'X' }, input.encode(keyEvent('X', c.SDL_KMOD_LALT | c.SDL_KMOD_LSHIFT).?, &buf)); |
| 799 | try std.testing.expectEqualSlices(u8, &.{ 27, '!' }, keymap.encode(keyEvent('!', c.SDL_KMOD_LALT | c.SDL_KMOD_LSHIFT).?, &buf)); | 799 | try std.testing.expectEqualSlices(u8, &.{ 27, '!' }, input.encode(keyEvent('!', c.SDL_KMOD_LALT | c.SDL_KMOD_LSHIFT).?, &buf)); |
| 800 | try std.testing.expect(keyEvent(c.SDLK_Q, c.SDL_KMOD_MODE | c.SDL_KMOD_RALT) == null); | 800 | try std.testing.expect(keyEvent(c.SDLK_Q, c.SDL_KMOD_MODE | c.SDL_KMOD_RALT) == null); |
| 801 | try std.testing.expect(keyEvent(c.SDLK_Q, c.SDL_KMOD_LCTRL | c.SDL_KMOD_RALT) == null); | 801 | try std.testing.expect(keyEvent(c.SDLK_Q, c.SDL_KMOD_LCTRL | c.SDL_KMOD_RALT) == null); |
| 802 | } | 802 | } |
| @@ -966,7 +966,7 @@ fn mouseButton(button: u8) ?u8 { | |||
| 966 | else => null, | 966 | else => null, |
| 967 | }; | 967 | }; |
| 968 | } | 968 | } |
| 969 | fn mouseMods() keymap.Mods { | 969 | fn mouseMods() input.Mods { |
| 970 | const mods = c.SDL_GetModState(); | 970 | const mods = c.SDL_GetModState(); |
| 971 | return .{ .shift = mods & c.SDL_KMOD_SHIFT != 0, .ctrl = mods & c.SDL_KMOD_CTRL != 0, .alt = mods & c.SDL_KMOD_ALT != 0 }; | 971 | return .{ .shift = mods & c.SDL_KMOD_SHIFT != 0, .ctrl = mods & c.SDL_KMOD_CTRL != 0, .alt = mods & c.SDL_KMOD_ALT != 0 }; |
| 972 | } | 972 | } |
| @@ -1330,7 +1330,7 @@ test "command key text is consumed and subsequent ordinary text is not suppresse | |||
| 1330 | event.key.key = c.SDLK_X; | 1330 | event.key.key = c.SDLK_X; |
| 1331 | try std.testing.expect(try events.handle(event)); | 1331 | try std.testing.expect(try events.handle(event)); |
| 1332 | try std.testing.expect(!events.ui.suppress_text); | 1332 | try std.testing.expect(!events.ui.suppress_text); |
| 1333 | // Repeated prefix exits mode; its shared keymap encoding is 0x1c. | 1333 | // Repeated prefix exits mode; its shared input encoding is 0x1c. |
| 1334 | // The integration fixture verifies the emitted byte count with a real PTY. | 1334 | // The integration fixture verifies the emitted byte count with a real PTY. |
| 1335 | event.key.key = c.SDLK_BACKSLASH; | 1335 | event.key.key = c.SDLK_BACKSLASH; |
| 1336 | event.key.mod = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT; | 1336 | event.key.mod = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT; |
| @@ -1338,8 +1338,8 @@ test "command key text is consumed and subsequent ordinary text is not suppresse | |||
| 1338 | try std.testing.expect(events.ui.command_mode); | 1338 | try std.testing.expect(events.ui.command_mode); |
| 1339 | try std.testing.expect(try events.handle(event)); | 1339 | try std.testing.expect(try events.handle(event)); |
| 1340 | try std.testing.expect(!events.ui.command_mode); | 1340 | try std.testing.expect(!events.ui.command_mode); |
| 1341 | var bytes: [keymap.max_seq_len]u8 = undefined; | 1341 | var bytes: [input.max_seq_len]u8 = undefined; |
| 1342 | try std.testing.expectEqualSlices(u8, &.{0x1c}, keymap.encode(keyEvent(c.SDLK_BACKSLASH, event.key.mod).?, &bytes)); | 1342 | try std.testing.expectEqualSlices(u8, &.{0x1c}, input.encode(keyEvent(c.SDLK_BACKSLASH, event.key.mod).?, &bytes)); |
| 1343 | } | 1343 | } |
| 1344 | 1344 | ||
| 1345 | test "copy chord is exact while plain interrupt remains terminal input" { | 1345 | test "copy chord is exact while plain interrupt remains terminal input" { |
src/gui/interaction.zig
| Old | New | ||
|---|---|---|---|
| @@ -4,7 +4,7 @@ | |||
| 4 | //! ownership and asynchronous actions bound to attachment identities. | 4 | //! ownership and asynchronous actions bound to attachment identities. |
| 5 | const std = @import("std"); | 5 | const std = @import("std"); |
| 6 | const client = @import("client"); | 6 | const client = @import("client"); |
| 7 | const keymap = client.keymap; | 7 | const app_input = @import("input"); |
| 8 | const model = @import("workspace.zig"); | 8 | const model = @import("workspace.zig"); |
| 9 | const runtime = @import("runtime.zig"); | 9 | const runtime = @import("runtime.zig"); |
| 10 | const picker_mod = @import("picker.zig"); | 10 | const picker_mod = @import("picker.zig"); |
| @@ -12,7 +12,7 @@ const persistence = @import("persistence.zig"); | |||
| 12 | 12 | ||
| 13 | pub const Key = enum { v, b, r, d, x, p, escape, enter, keypad_enter, up, k, down, j, left, h, right, l, backspace, other }; | 13 | pub const Key = enum { v, b, r, d, x, p, escape, enter, keypad_enter, up, k, down, j, left, h, right, l, backspace, other }; |
| 14 | /// code preserves platform key identity so aliases release independently. | 14 | /// code preserves platform key identity so aliases release independently. |
| 15 | /// kind describes GUI meaning; terminal carries the platform's keymap translation. | 15 | /// kind describes GUI meaning; terminal carries the platform's input translation. |
| 16 | pub const KeyDown = struct { | 16 | pub const KeyDown = struct { |
| 17 | code: u32, | 17 | code: u32, |
| 18 | kind: Key = .other, | 18 | kind: Key = .other, |
| @@ -20,7 +20,7 @@ pub const KeyDown = struct { | |||
| 20 | modified: bool = false, | 20 | modified: bool = false, |
| 21 | repeat: bool = false, | 21 | repeat: bool = false, |
| 22 | copy: bool = false, | 22 | copy: bool = false, |
| 23 | terminal: ?keymap.Event = null, | 23 | terminal: ?app_input.Event = null, |
| 24 | }; | 24 | }; |
| 25 | pub const PendingEnd = struct { key: model.Attachment, request: u64 }; | 25 | pub const PendingEnd = struct { key: model.Attachment, request: u64 }; |
| 26 | pub const Wheel = struct { | 26 | pub const Wheel = struct { |
| @@ -386,7 +386,7 @@ pub const Controller = struct { | |||
| 386 | self.dirty = true; | 386 | self.dirty = true; |
| 387 | } | 387 | } |
| 388 | 388 | ||
| 389 | pub fn mouseDown(self: *Controller, x: u32, y: u32, grab_x: u32, grab_y: u32, button: u8, mods: keymap.Mods) !void { | 389 | pub fn mouseDown(self: *Controller, x: u32, y: u32, grab_x: u32, grab_y: u32, button: u8, mods: app_input.Mods) !void { |
| 390 | if (self.hasPointerCapture() or button > 2 or self.command_mode) return; | 390 | if (self.hasPointerCapture() or button > 2 or self.command_mode) return; |
| 391 | if (self.picker != null or self.recovery != null or self.resize_mode or self.layout.hitDivider(x, y, grab_x, grab_y) != null) { | 391 | if (self.picker != null or self.recovery != null or self.resize_mode or self.layout.hitDivider(x, y, grab_x, grab_y) != null) { |
| 392 | if (button == 0) try self.pointerDown(x, y, grab_x, grab_y); | 392 | if (button == 0) try self.pointerDown(x, y, grab_x, grab_y); |
| @@ -412,7 +412,7 @@ pub const Controller = struct { | |||
| 412 | self.local_held = self.selection_drag.buttonHeld(); | 412 | self.local_held = self.selection_drag.buttonHeld(); |
| 413 | } | 413 | } |
| 414 | 414 | ||
| 415 | fn mouseAt(self: *Controller, key: model.Attachment, token: client.session_pump.MouseToken, kind: anytype, button: u8, x: u32, y: u32, mods: keymap.Mods) ?client.session_pump.Mouse { | 415 | fn mouseAt(self: *Controller, key: model.Attachment, token: client.session_pump.MouseToken, kind: anytype, button: u8, x: u32, y: u32, mods: app_input.Mods) ?client.session_pump.Mouse { |
| 416 | const p = self.layout.get(key.pane) orelse return null; | 416 | const p = self.layout.get(key.pane) orelse return null; |
| 417 | if (self.metrics.cell_w == 0 or self.metrics.cell_h == 0 or p.cols == 0 or p.rows == 0 or p.content.w == 0 or p.content.h == 0) return null; | 417 | if (self.metrics.cell_w == 0 or self.metrics.cell_h == 0 or p.cols == 0 or p.rows == 0 or p.content.w == 0 or p.content.h == 0) return null; |
| 418 | const px = std.math.clamp(x, p.content.x, p.content.x +| p.content.w -| 1); | 418 | const px = std.math.clamp(x, p.content.x, p.content.x +| p.content.w -| 1); |
| @@ -420,7 +420,7 @@ pub const Controller = struct { | |||
| 420 | return .{ .token = token, .kind = kind, .button = button, .col = @intCast(@min(@as(u32, p.cols - 1), (px - p.content.x) / self.metrics.cell_w)), .row = @intCast(@min(@as(u32, p.rows - 1), (py - p.content.y) / self.metrics.cell_h)), .pixel_x = px - p.content.x, .pixel_y = py - p.content.y, .mods = mods }; | 420 | return .{ .token = token, .kind = kind, .button = button, .col = @intCast(@min(@as(u32, p.cols - 1), (px - p.content.x) / self.metrics.cell_w)), .row = @intCast(@min(@as(u32, p.rows - 1), (py - p.content.y) / self.metrics.cell_h)), .pixel_x = px - p.content.x, .pixel_y = py - p.content.y, .mods = mods }; |
| 421 | } | 421 | } |
| 422 | 422 | ||
| 423 | pub fn mouseMove(self: *Controller, x: i64, y: i64, mods: keymap.Mods) !void { | 423 | pub fn mouseMove(self: *Controller, x: i64, y: i64, mods: app_input.Mods) !void { |
| 424 | if (self.app_drag) |app| { | 424 | if (self.app_drag) |app| { |
| 425 | if (!self.rt.accepts(app.key)) return self.cancelMouse(); | 425 | if (!self.rt.accepts(app.key)) return self.cancelMouse(); |
| 426 | const live = self.rt.get(app.key.pane) orelse return self.cancelMouse(); | 426 | const live = self.rt.get(app.key.pane) orelse return self.cancelMouse(); |
| @@ -441,7 +441,7 @@ pub const Controller = struct { | |||
| 441 | if (self.mouseAt(live.key, token, .motion, 3, @intCast(x), @intCast(y), mods)) |event| try live.pump.say(.{ .mouse = event }); | 441 | if (self.mouseAt(live.key, token, .motion, 3, @intCast(x), @intCast(y), mods)) |event| try live.pump.say(.{ .mouse = event }); |
| 442 | } | 442 | } |
| 443 | 443 | ||
| 444 | pub fn mouseUp(self: *Controller, x: u32, y: u32, button: u8, mods: keymap.Mods) !void { | 444 | pub fn mouseUp(self: *Controller, x: u32, y: u32, button: u8, mods: app_input.Mods) !void { |
| 445 | if (self.app_drag) |app| { | 445 | if (self.app_drag) |app| { |
| 446 | if (button != app.button) return; | 446 | if (button != app.button) return; |
| 447 | if (!self.rt.accepts(app.key)) return self.cancelMouse(); | 447 | if (!self.rt.accepts(app.key)) return self.cancelMouse(); |
| @@ -526,7 +526,7 @@ pub const Controller = struct { | |||
| 526 | } | 526 | } |
| 527 | /// Semantic wheel entry point. Transport routing is deliberately deferred | 527 | /// Semantic wheel entry point. Transport routing is deliberately deferred |
| 528 | /// until the pump has sampled the pane's current terminal modes. | 528 | /// until the pump has sampled the pane's current terminal modes. |
| 529 | pub fn wheel(self: *Controller, x: u32, y: u32, delta: f32, flipped: bool, mods: keymap.Mods) !void { | 529 | pub fn wheel(self: *Controller, x: u32, y: u32, delta: f32, flipped: bool, mods: app_input.Mods) !void { |
| 530 | if (self.picker != null or self.recovery != null or self.resize_mode or self.command_mode or self.drag != null) return; | 530 | if (self.picker != null or self.recovery != null or self.resize_mode or self.command_mode or self.drag != null) return; |
| 531 | const id = self.layout.hit(x, y) orelse return; | 531 | const id = self.layout.hit(x, y) orelse return; |
| 532 | const placement = self.layout.get(id) orelse return; | 532 | const placement = self.layout.get(id) orelse return; |
| @@ -561,10 +561,10 @@ pub const Controller = struct { | |||
| 561 | self.intent_dirty = self.intent_dirty or self.drag.?.changed; | 561 | self.intent_dirty = self.intent_dirty or self.drag.?.changed; |
| 562 | self.drag = null; | 562 | self.drag = null; |
| 563 | } | 563 | } |
| 564 | pub fn sendKey(self: *Controller, key: keymap.Event) !void { | 564 | pub fn sendKey(self: *Controller, key: app_input.Event) !void { |
| 565 | self.clearSelection(); | 565 | self.clearSelection(); |
| 566 | var buf: [keymap.max_seq_len]u8 = undefined; | 566 | var buf: [app_input.max_seq_len]u8 = undefined; |
| 567 | const bytes = keymap.encode(key, &buf); | 567 | const bytes = app_input.encode(key, &buf); |
| 568 | if (bytes.len != 0) try self.rt.input(bytes); | 568 | if (bytes.len != 0) try self.rt.input(bytes); |
| 569 | } | 569 | } |
| 570 | pub fn relayout(self: *Controller) !void { | 570 | pub fn relayout(self: *Controller) !void { |
src/input.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,375 @@ | |||
| 1 | //! Normalized surface input → VT application-input byte sequences. Each | ||
| 2 | //! frontend produces the normalized form and this module owns every byte an | ||
| 3 | //! event turns into, so the tables unit-test natively with no browser in the | ||
| 4 | //! loop. | ||
| 5 | //! | ||
| 6 | //! Scope: printable input, control characters, arrows and nav keys, function | ||
| 7 | //! keys, the xterm modifier-encoded CSI variants, bracketed paste. Deferred: | ||
| 8 | //! kitty/CSI-u. Platform-free — this compiles for wasm32-freestanding. | ||
| 9 | //! | ||
| 10 | //! This serializes semantic surface input only. It neither accesses nor drives | ||
| 11 | //! a terminal; session transports decide when and where the bytes are sent. | ||
| 12 | |||
| 13 | const std = @import("std"); | ||
| 14 | |||
| 15 | pub const Mods = packed struct { | ||
| 16 | shift: bool = false, | ||
| 17 | alt: bool = false, | ||
| 18 | ctrl: bool = false, | ||
| 19 | |||
| 20 | /// The xterm modifier parameter: 1 + shift + 2*alt + 4*ctrl. A CSI | ||
| 21 | /// sequence carries it only when it is > 1 — an unmodified key uses | ||
| 22 | /// the short form. | ||
| 23 | pub fn param(self: Mods) u8 { | ||
| 24 | return 1 + @as(u8, @intFromBool(self.shift)) + | ||
| 25 | 2 * @as(u8, @intFromBool(self.alt)) + | ||
| 26 | 4 * @as(u8, @intFromBool(self.ctrl)); | ||
| 27 | } | ||
| 28 | |||
| 29 | pub fn any(self: Mods) bool { | ||
| 30 | return self.shift or self.alt or self.ctrl; | ||
| 31 | } | ||
| 32 | }; | ||
| 33 | |||
| 34 | pub const Key = enum { | ||
| 35 | /// A printable character; `Event.cp` carries the codepoint. | ||
| 36 | char, | ||
| 37 | enter, | ||
| 38 | tab, | ||
| 39 | backspace, | ||
| 40 | escape, | ||
| 41 | up, | ||
| 42 | down, | ||
| 43 | left, | ||
| 44 | right, | ||
| 45 | home, | ||
| 46 | end, | ||
| 47 | insert, | ||
| 48 | delete, | ||
| 49 | page_up, | ||
| 50 | page_down, | ||
| 51 | f1, | ||
| 52 | f2, | ||
| 53 | f3, | ||
| 54 | f4, | ||
| 55 | f5, | ||
| 56 | f6, | ||
| 57 | f7, | ||
| 58 | f8, | ||
| 59 | f9, | ||
| 60 | f10, | ||
| 61 | f11, | ||
| 62 | f12, | ||
| 63 | }; | ||
| 64 | |||
| 65 | pub const Event = struct { | ||
| 66 | key: Key, | ||
| 67 | /// Codepoint for .char, 0 otherwise. | ||
| 68 | cp: u21 = 0, | ||
| 69 | mods: Mods = .{}, | ||
| 70 | }; | ||
| 71 | |||
| 72 | /// Every sequence this module can emit fits here with room to spare — | ||
| 73 | /// the widest today is the 7-byte modified tilde CSI; the slack is | ||
| 74 | /// headroom for forms not in the table yet. | ||
| 75 | pub const max_seq_len = 16; | ||
| 76 | pub const MouseFormat = enum { x10, utf8, sgr, urxvt, sgr_pixels }; | ||
| 77 | pub const mouse_max_seq_len = 64; | ||
| 78 | |||
| 79 | /// Encode one semantic mouse event. Coordinates are one-based on the wire. | ||
| 80 | pub fn encodeMouse(format: MouseFormat, button_base: u8, release: bool, cell_x: u16, cell_y: u16, pixel_x: u32, pixel_y: u32, mods: Mods, buf: []u8) []const u8 { | ||
| 81 | std.debug.assert(buf.len >= mouse_max_seq_len); | ||
| 82 | const base: u16 = if (release and format != .sgr and format != .sgr_pixels) 3 else button_base; | ||
| 83 | const button: u16 = base + @as(u16, @intFromBool(mods.shift)) * 4 + @as(u16, @intFromBool(mods.alt)) * 8 + @as(u16, @intFromBool(mods.ctrl)) * 16; | ||
| 84 | const x: u32 = if (format == .sgr_pixels) pixel_x else cell_x; | ||
| 85 | const y: u32 = if (format == .sgr_pixels) pixel_y else cell_y; | ||
| 86 | const bx = x +| 1; | ||
| 87 | const by = y +| 1; | ||
| 88 | switch (format) { | ||
| 89 | .sgr, .sgr_pixels => return std.fmt.bufPrint(buf, "\x1b[<{d};{d};{d}{c}", .{ button, bx, by, @as(u8, if (release) 'm' else 'M') }) catch unreachable, | ||
| 90 | .urxvt => return std.fmt.bufPrint(buf, "\x1b[{d};{d};{d}M", .{ button + 32, bx, by }) catch unreachable, | ||
| 91 | .x10 => { | ||
| 92 | if (bx > 223 or by > 223) return buf[0..0]; | ||
| 93 | buf[0] = 0x1b; | ||
| 94 | buf[1] = '['; | ||
| 95 | buf[2] = 'M'; | ||
| 96 | buf[3] = @intCast(button + 32); | ||
| 97 | buf[4] = @intCast(bx + 32); | ||
| 98 | buf[5] = @intCast(by + 32); | ||
| 99 | return buf[0..6]; | ||
| 100 | }, | ||
| 101 | .utf8 => { | ||
| 102 | if (bx > 2015 or by > 2015) return buf[0..0]; | ||
| 103 | buf[0] = 0x1b; | ||
| 104 | buf[1] = '['; | ||
| 105 | buf[2] = 'M'; | ||
| 106 | var n: usize = 3; | ||
| 107 | for ([_]u21{ button + 32, @intCast(bx + 32), @intCast(by + 32) }) |cp| { | ||
| 108 | n += std.unicode.utf8Encode(cp, buf[n..]) catch unreachable; | ||
| 109 | } | ||
| 110 | return buf[0..n]; | ||
| 111 | }, | ||
| 112 | } | ||
| 113 | } | ||
| 114 | |||
| 115 | pub fn encodeWheel(format: MouseFormat, up: bool, cell_x: u16, cell_y: u16, pixel_x: u32, pixel_y: u32, mods: Mods, buf: []u8) []const u8 { | ||
| 116 | return encodeMouse(format, if (up) 64 else 65, false, cell_x, cell_y, pixel_x, pixel_y, mods, buf); | ||
| 117 | } | ||
| 118 | |||
| 119 | pub fn encodeWheelArrow(up: bool, cursor_keys: bool, buf: []u8) []const u8 { | ||
| 120 | const bytes = encode(.{ .key = if (up) .up else .down }, buf); | ||
| 121 | if (cursor_keys) buf[1] = 'O'; | ||
| 122 | return bytes; | ||
| 123 | } | ||
| 124 | |||
| 125 | test "wheel encodes negotiated SGR and legacy forms" { | ||
| 126 | var buf: [mouse_max_seq_len]u8 = undefined; | ||
| 127 | try std.testing.expectEqualStrings("\x1b[<64;4;6M", encodeWheel(.sgr, true, 3, 5, 0, 0, .{}, &buf)); | ||
| 128 | try std.testing.expectEqualStrings("\x1b[97;4;6M", encodeWheel(.urxvt, false, 3, 5, 0, 0, .{}, &buf)); | ||
| 129 | try std.testing.expectEqualStrings("\x1bOA", encodeWheelArrow(true, true, &buf)); | ||
| 130 | try std.testing.expectEqualStrings("\x1b[B", encodeWheelArrow(false, false, &buf)); | ||
| 131 | try std.testing.expectEqualSlices(u8, &.{ 27, '[', 'M', 96, 255, 255 }, encodeWheel(.x10, true, 222, 222, 0, 0, .{}, &buf)); | ||
| 132 | try std.testing.expectEqual(@as(usize, 0), encodeWheel(.x10, true, 223, 0, 0, 0, .{}, &buf).len); | ||
| 133 | try std.testing.expectEqualStrings("\x1b[<64;225;226M", encodeWheel(.sgr_pixels, true, 0, 0, 224, 225, .{}, &buf)); | ||
| 134 | try std.testing.expectEqualSlices(u8, &.{ 27, '[', 'M', 96, 0xdf, 0xbf, 0xdf, 0xbf }, encodeWheel(.utf8, true, 2014, 2014, 0, 0, .{}, &buf)); | ||
| 135 | try std.testing.expectEqual(@as(usize, 0), encodeWheel(.utf8, true, 2015, 0, 0, 0, .{}, &buf).len); | ||
| 136 | try std.testing.expectEqualStrings("\x1b[<93;1;1M", encodeWheel(.sgr, false, 0, 0, 0, 0, .{ .shift = true, .alt = true, .ctrl = true }, &buf)); | ||
| 137 | try std.testing.expectEqualStrings("\x1b[<1;4;6m", encodeMouse(.sgr, 1, true, 3, 5, 0, 0, .{}, &buf)); | ||
| 138 | try std.testing.expectEqualSlices(u8, &.{ 27, '[', 'M', 51, 33, 33 }, encodeMouse(.x10, 0, true, 0, 0, 0, 0, .{ .ctrl = true }, &buf)); | ||
| 139 | try std.testing.expectEqualStrings("\x1b[35;4;6M", encodeMouse(.urxvt, 2, true, 3, 5, 0, 0, .{}, &buf)); | ||
| 140 | try std.testing.expectEqualStrings("\x1b[<64;4;6M", encodeWheel(.sgr, true, 3, 5, 0, 0, .{}, &buf)); | ||
| 141 | } | ||
| 142 | |||
| 143 | /// Encode one event into `buf` (at least max_seq_len bytes), returning the | ||
| 144 | /// slice written. An event this table has no bytes for — a bare modifier, | ||
| 145 | /// a .char with cp 0 — encodes to the empty slice, which callers send as | ||
| 146 | /// nothing rather than as a surprise. | ||
| 147 | pub fn encode(ev: Event, buf: []u8) []const u8 { | ||
| 148 | std.debug.assert(buf.len >= max_seq_len); | ||
| 149 | switch (ev.key) { | ||
| 150 | .char => { | ||
| 151 | if (ev.cp == 0) return buf[0..0]; | ||
| 152 | var n: usize = 0; | ||
| 153 | if (ev.mods.alt) { | ||
| 154 | buf[0] = 0x1b; | ||
| 155 | n = 1; | ||
| 156 | } | ||
| 157 | if (ev.mods.ctrl) { | ||
| 158 | // The terminal tradition: Ctrl clears bits 6-5 of the ASCII | ||
| 159 | // column, so ctrl-a..z are 0x01..0x1a and ctrl-[ is ESC. Anything | ||
| 160 | // outside the foldable range falls through and sends plain. | ||
| 161 | const c = ev.cp; | ||
| 162 | if (c == ' ' or (c >= '@' and c <= '_') or (c >= 'a' and c <= 'z')) { | ||
| 163 | buf[n] = @intCast(c & 0x1f); | ||
| 164 | n += 1; | ||
| 165 | return buf[0..n]; | ||
| 166 | } | ||
| 167 | } | ||
| 168 | const len = std.unicode.utf8Encode(ev.cp, buf[n..]) catch return buf[0..0]; | ||
| 169 | return buf[0 .. n + len]; | ||
| 170 | }, | ||
| 171 | .enter => return altable(ev.mods, "\r", buf), | ||
| 172 | .tab => { | ||
| 173 | // Shift+Tab is backtab, its own sequence; plain Tab is a byte. | ||
| 174 | if (ev.mods.shift) return copy("\x1b[Z", buf); | ||
| 175 | return altable(ev.mods, "\t", buf); | ||
| 176 | }, | ||
| 177 | .backspace => { | ||
| 178 | // DEL, the modern default. Ctrl+Backspace sends BS so shells | ||
| 179 | // can tell them apart; Alt prefixes either. | ||
| 180 | const base: []const u8 = if (ev.mods.ctrl) "\x08" else "\x7f"; | ||
| 181 | return altable(ev.mods, base, buf); | ||
| 182 | }, | ||
| 183 | .escape => return altable(ev.mods, "\x1b", buf), | ||
| 184 | .up => return cursorKey(ev.mods, 'A', buf), | ||
| 185 | .down => return cursorKey(ev.mods, 'B', buf), | ||
| 186 | .right => return cursorKey(ev.mods, 'C', buf), | ||
| 187 | .left => return cursorKey(ev.mods, 'D', buf), | ||
| 188 | .home => return cursorKey(ev.mods, 'H', buf), | ||
| 189 | .end => return cursorKey(ev.mods, 'F', buf), | ||
| 190 | .insert => return tildeKey(ev.mods, 2, buf), | ||
| 191 | .delete => return tildeKey(ev.mods, 3, buf), | ||
| 192 | .page_up => return tildeKey(ev.mods, 5, buf), | ||
| 193 | .page_down => return tildeKey(ev.mods, 6, buf), | ||
| 194 | // F1-F4 are SS3 letters unmodified (their VT220 lineage) and CSI | ||
| 195 | // 1;mP..S modified — the xterm scheme. | ||
| 196 | .f1 => return ss3Key(ev.mods, 'P', buf), | ||
| 197 | .f2 => return ss3Key(ev.mods, 'Q', buf), | ||
| 198 | .f3 => return ss3Key(ev.mods, 'R', buf), | ||
| 199 | .f4 => return ss3Key(ev.mods, 'S', buf), | ||
| 200 | .f5 => return tildeKey(ev.mods, 15, buf), | ||
| 201 | .f6 => return tildeKey(ev.mods, 17, buf), | ||
| 202 | .f7 => return tildeKey(ev.mods, 18, buf), | ||
| 203 | .f8 => return tildeKey(ev.mods, 19, buf), | ||
| 204 | .f9 => return tildeKey(ev.mods, 20, buf), | ||
| 205 | .f10 => return tildeKey(ev.mods, 21, buf), | ||
| 206 | .f11 => return tildeKey(ev.mods, 23, buf), | ||
| 207 | .f12 => return tildeKey(ev.mods, 24, buf), | ||
| 208 | } | ||
| 209 | } | ||
| 210 | |||
| 211 | fn copy(seq: []const u8, buf: []u8) []const u8 { | ||
| 212 | @memcpy(buf[0..seq.len], seq); | ||
| 213 | return buf[0..seq.len]; | ||
| 214 | } | ||
| 215 | |||
| 216 | /// A single-byte key that Alt turns into ESC + byte. Ctrl is either baked | ||
| 217 | /// into `base` by the caller or meaningless for the key. | ||
| 218 | fn altable(mods: Mods, base: []const u8, buf: []u8) []const u8 { | ||
| 219 | var n: usize = 0; | ||
| 220 | if (mods.alt) { | ||
| 221 | buf[0] = 0x1b; | ||
| 222 | n = 1; | ||
| 223 | } | ||
| 224 | @memcpy(buf[n .. n + base.len], base); | ||
| 225 | return buf[0 .. n + base.len]; | ||
| 226 | } | ||
| 227 | |||
| 228 | /// The two three-byte intro forms, which differ ONLY in the intro byte | ||
| 229 | /// and only while unmodified: modified, both spell the same CSI | ||
| 230 | /// parameter form (SS3 has nowhere to put a parameter). | ||
| 231 | fn introKey(mods: Mods, intro: u8, final: u8, buf: []u8) []const u8 { | ||
| 232 | if (mods.any()) return std.fmt.bufPrint(buf, "\x1b[1;{d}{c}", .{ mods.param(), final }) catch unreachable; | ||
| 233 | buf[0] = 0x1b; | ||
| 234 | buf[1] = intro; | ||
| 235 | buf[2] = final; | ||
| 236 | return buf[0..3]; | ||
| 237 | } | ||
| 238 | |||
| 239 | /// CSI letter form: ESC [ <final>, or ESC [ 1 ; <mods> <final> when | ||
| 240 | /// modified. | ||
| 241 | fn cursorKey(mods: Mods, final: u8, buf: []u8) []const u8 { | ||
| 242 | return introKey(mods, '[', final, buf); | ||
| 243 | } | ||
| 244 | |||
| 245 | /// CSI tilde form: ESC [ <n> ~, or ESC [ <n> ; <mods> ~ when modified. | ||
| 246 | fn tildeKey(mods: Mods, n: u8, buf: []u8) []const u8 { | ||
| 247 | if (!mods.any()) return std.fmt.bufPrint(buf, "\x1b[{d}~", .{n}) catch unreachable; | ||
| 248 | return std.fmt.bufPrint(buf, "\x1b[{d};{d}~", .{ n, mods.param() }) catch unreachable; | ||
| 249 | } | ||
| 250 | |||
| 251 | /// SS3 has nowhere to put a parameter, so a modified F1-F4 goes as CSI. | ||
| 252 | fn ss3Key(mods: Mods, final: u8, buf: []u8) []const u8 { | ||
| 253 | return introKey(mods, 'O', final, buf); | ||
| 254 | } | ||
| 255 | |||
| 256 | pub const paste_begin = "\x1b[200~"; | ||
| 257 | pub const paste_end = "\x1b[201~"; | ||
| 258 | |||
| 259 | // --------------------------------------------------------------------------- | ||
| 260 | |||
| 261 | test "input: the key table" { | ||
| 262 | const cases = [_]struct { ev: Event, want: []const u8 }{ | ||
| 263 | // Printable, plain and modified. | ||
| 264 | .{ .ev = .{ .key = .char, .cp = 'a' }, .want = "a" }, | ||
| 265 | .{ .ev = .{ .key = .char, .cp = 'Z' }, .want = "Z" }, | ||
| 266 | .{ .ev = .{ .key = .char, .cp = 'a', .mods = .{ .ctrl = true } }, .want = "\x01" }, | ||
| 267 | .{ .ev = .{ .key = .char, .cp = 'z', .mods = .{ .ctrl = true } }, .want = "\x1a" }, | ||
| 268 | .{ .ev = .{ .key = .char, .cp = 'C', .mods = .{ .ctrl = true } }, .want = "\x03" }, | ||
| 269 | .{ .ev = .{ .key = .char, .cp = ' ', .mods = .{ .ctrl = true } }, .want = "\x00" }, | ||
| 270 | .{ .ev = .{ .key = .char, .cp = '[', .mods = .{ .ctrl = true } }, .want = "\x1b" }, | ||
| 271 | .{ .ev = .{ .key = .char, .cp = '_', .mods = .{ .ctrl = true } }, .want = "\x1f" }, | ||
| 272 | .{ .ev = .{ .key = .char, .cp = 'x', .mods = .{ .alt = true } }, .want = "\x1bx" }, | ||
| 273 | .{ .ev = .{ .key = .char, .cp = 'b', .mods = .{ .alt = true, .ctrl = true } }, .want = "\x1b\x02" }, | ||
| 274 | // UTF-8 out, straight through. | ||
| 275 | .{ .ev = .{ .key = .char, .cp = 0x6f22 }, .want = "\xe6\xbc\xa2" }, // 漢 | ||
| 276 | .{ .ev = .{ .key = .char, .cp = 0xe9 }, .want = "\xc3\xa9" }, // é | ||
| 277 | // The single-byte keys. | ||
| 278 | .{ .ev = .{ .key = .enter }, .want = "\r" }, | ||
| 279 | .{ .ev = .{ .key = .enter, .mods = .{ .alt = true } }, .want = "\x1b\r" }, | ||
| 280 | .{ .ev = .{ .key = .tab }, .want = "\t" }, | ||
| 281 | .{ .ev = .{ .key = .tab, .mods = .{ .shift = true } }, .want = "\x1b[Z" }, | ||
| 282 | .{ .ev = .{ .key = .backspace }, .want = "\x7f" }, | ||
| 283 | .{ .ev = .{ .key = .backspace, .mods = .{ .ctrl = true } }, .want = "\x08" }, | ||
| 284 | .{ .ev = .{ .key = .backspace, .mods = .{ .alt = true } }, .want = "\x1b\x7f" }, | ||
| 285 | .{ .ev = .{ .key = .escape }, .want = "\x1b" }, | ||
| 286 | // Cursor and nav keys, short forms. | ||
| 287 | .{ .ev = .{ .key = .up }, .want = "\x1b[A" }, | ||
| 288 | .{ .ev = .{ .key = .down }, .want = "\x1b[B" }, | ||
| 289 | .{ .ev = .{ .key = .right }, .want = "\x1b[C" }, | ||
| 290 | .{ .ev = .{ .key = .left }, .want = "\x1b[D" }, | ||
| 291 | .{ .ev = .{ .key = .home }, .want = "\x1b[H" }, | ||
| 292 | .{ .ev = .{ .key = .end }, .want = "\x1b[F" }, | ||
| 293 | .{ .ev = .{ .key = .insert }, .want = "\x1b[2~" }, | ||
| 294 | .{ .ev = .{ .key = .delete }, .want = "\x1b[3~" }, | ||
| 295 | .{ .ev = .{ .key = .page_up }, .want = "\x1b[5~" }, | ||
| 296 | .{ .ev = .{ .key = .page_down }, .want = "\x1b[6~" }, | ||
| 297 | // Function keys. | ||
| 298 | .{ .ev = .{ .key = .f1 }, .want = "\x1bOP" }, | ||
| 299 | .{ .ev = .{ .key = .f4 }, .want = "\x1bOS" }, | ||
| 300 | .{ .ev = .{ .key = .f5 }, .want = "\x1b[15~" }, | ||
| 301 | .{ .ev = .{ .key = .f6 }, .want = "\x1b[17~" }, | ||
| 302 | .{ .ev = .{ .key = .f10 }, .want = "\x1b[21~" }, | ||
| 303 | .{ .ev = .{ .key = .f11 }, .want = "\x1b[23~" }, | ||
| 304 | .{ .ev = .{ .key = .f12 }, .want = "\x1b[24~" }, | ||
| 305 | // Modified CSI spot checks (the matrix test below is exhaustive). | ||
| 306 | .{ .ev = .{ .key = .up, .mods = .{ .ctrl = true } }, .want = "\x1b[1;5A" }, | ||
| 307 | .{ .ev = .{ .key = .up, .mods = .{ .shift = true, .alt = true } }, .want = "\x1b[1;4A" }, | ||
| 308 | .{ .ev = .{ .key = .delete, .mods = .{ .shift = true } }, .want = "\x1b[3;2~" }, | ||
| 309 | .{ .ev = .{ .key = .f5, .mods = .{ .ctrl = true } }, .want = "\x1b[15;5~" }, | ||
| 310 | .{ .ev = .{ .key = .f1, .mods = .{ .shift = true } }, .want = "\x1b[1;2P" }, | ||
| 311 | // Nothing to send. | ||
| 312 | .{ .ev = .{ .key = .char, .cp = 0 }, .want = "" }, | ||
| 313 | }; | ||
| 314 | var buf: [max_seq_len]u8 = undefined; | ||
| 315 | for (cases) |case| { | ||
| 316 | const got = encode(case.ev, &buf); | ||
| 317 | std.testing.expectEqualSlices(u8, case.want, got) catch |err| { | ||
| 318 | std.debug.print("input: {any} -> {any}, want {any}\n", .{ case.ev, got, case.want }); | ||
| 319 | return err; | ||
| 320 | }; | ||
| 321 | } | ||
| 322 | } | ||
| 323 | |||
| 324 | test "input: modifier matrix over every CSI-carrying key" { | ||
| 325 | // param = 1 + shift + 2*alt + 4*ctrl; every modified form carries it, | ||
| 326 | // every unmodified form omits it. Exhaustive over the seven non-empty | ||
| 327 | // modifier combinations x every key with a CSI form. | ||
| 328 | const csi_keys = [_]struct { key: Key, mid: []const u8, tail: []const u8 }{ | ||
| 329 | .{ .key = .up, .mid = "1;", .tail = "A" }, | ||
| 330 | .{ .key = .down, .mid = "1;", .tail = "B" }, | ||
| 331 | .{ .key = .right, .mid = "1;", .tail = "C" }, | ||
| 332 | .{ .key = .left, .mid = "1;", .tail = "D" }, | ||
| 333 | .{ .key = .home, .mid = "1;", .tail = "H" }, | ||
| 334 | .{ .key = .end, .mid = "1;", .tail = "F" }, | ||
| 335 | .{ .key = .insert, .mid = "2;", .tail = "~" }, | ||
| 336 | .{ .key = .delete, .mid = "3;", .tail = "~" }, | ||
| 337 | .{ .key = .page_up, .mid = "5;", .tail = "~" }, | ||
| 338 | .{ .key = .page_down, .mid = "6;", .tail = "~" }, | ||
| 339 | .{ .key = .f1, .mid = "1;", .tail = "P" }, | ||
| 340 | .{ .key = .f2, .mid = "1;", .tail = "Q" }, | ||
| 341 | .{ .key = .f3, .mid = "1;", .tail = "R" }, | ||
| 342 | .{ .key = .f4, .mid = "1;", .tail = "S" }, | ||
| 343 | .{ .key = .f5, .mid = "15;", .tail = "~" }, | ||
| 344 | .{ .key = .f6, .mid = "17;", .tail = "~" }, | ||
| 345 | .{ .key = .f7, .mid = "18;", .tail = "~" }, | ||
| 346 | .{ .key = .f8, .mid = "19;", .tail = "~" }, | ||
| 347 | .{ .key = .f9, .mid = "20;", .tail = "~" }, | ||
| 348 | .{ .key = .f10, .mid = "21;", .tail = "~" }, | ||
| 349 | .{ .key = .f11, .mid = "23;", .tail = "~" }, | ||
| 350 | .{ .key = .f12, .mid = "24;", .tail = "~" }, | ||
| 351 | }; | ||
| 352 | var buf: [max_seq_len]u8 = undefined; | ||
| 353 | var want_buf: [max_seq_len]u8 = undefined; | ||
| 354 | for (csi_keys) |k| { | ||
| 355 | var m: u8 = 2; | ||
| 356 | while (m <= 8) : (m += 1) { | ||
| 357 | const mods = Mods{ | ||
| 358 | .shift = (m - 1) & 1 != 0, | ||
| 359 | .alt = (m - 1) & 2 != 0, | ||
| 360 | .ctrl = (m - 1) & 4 != 0, | ||
| 361 | }; | ||
| 362 | try std.testing.expectEqual(m, mods.param()); | ||
| 363 | const want = try std.fmt.bufPrint(&want_buf, "\x1b[{s}{d}{s}", .{ k.mid, m, k.tail }); | ||
| 364 | const got = encode(.{ .key = k.key, .mods = mods }, &buf); | ||
| 365 | try std.testing.expectEqualSlices(u8, want, got); | ||
| 366 | } | ||
| 367 | } | ||
| 368 | } | ||
| 369 | |||
| 370 | // Forces semantic analysis of every pub decl under `zig build test`, so an | ||
| 371 | // unreferenced decl must at least compile (the silent-module-loss hazard, | ||
| 372 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. | ||
| 373 | test { | ||
| 374 | std.testing.refAllDeclsRecursive(@This()); | ||
| 375 | } | ||
src/tui/interact.zig
| Old | New | ||
|---|---|---|---|
| @@ -24,9 +24,9 @@ const client_os = @import("client_os"); | |||
| 24 | const paint_mod = @import("paint.zig"); | 24 | const paint_mod = @import("paint.zig"); |
| 25 | const askpass = @import("client").askpass; | 25 | const askpass = @import("client").askpass; |
| 26 | const select = @import("client").selection; | 26 | const select = @import("client").selection; |
| 27 | // The command prefix byte. Owned by keymap because the dialler in client.zig | 27 | // The command prefix byte also cancels a dial, so client owns it rather than |
| 28 | // watches for it with no session and no terminal in the picture. | 28 | // either surface. |
| 29 | const detach_key = @import("client").keymap.detach_key; | 29 | const detach_key = @import("client").interrupt.detach_key; |
| 30 | 30 | ||
| 31 | /// What `select` answers, in the shape `paint` asks for. Selection policy lives | 31 | /// What `select` answers, in the shape `paint` asks for. Selection policy lives |
| 32 | /// in `client.selection`; this adapter converts its absolute rows to the wall's | 32 | /// in `client.selection`; this adapter converts its absolute rows to the wall's |
web/mux.js
| Old | New | ||
|---|---|---|---|
| @@ -22,7 +22,7 @@ const ENV_FRAME = 0x00, ENV_CONTROL = 0x01; | |||
| 22 | // OSC 52's Pc for the system clipboard, the only target this page can honour. | 22 | // OSC 52's Pc for the system clipboard, the only target this page can honour. |
| 23 | const CLIPBOARD_TARGET_C = 0x63; // 'c' | 23 | const CLIPBOARD_TARGET_C = 0x63; // 'c' |
| 24 | 24 | ||
| 25 | // keymap.Key by @intFromEnum (wasm_core.zig pins the table). | 25 | // input.Key by @intFromEnum (wasm_core.zig pins the table). |
| 26 | const KEY = { | 26 | const KEY = { |
| 27 | char: 0, Enter: 1, Tab: 2, Backspace: 3, Escape: 4, | 27 | char: 0, Enter: 1, Tab: 2, Backspace: 3, Escape: 4, |
| 28 | ArrowUp: 5, ArrowDown: 6, ArrowLeft: 7, ArrowRight: 8, | 28 | ArrowUp: 5, ArrowDown: 6, ArrowLeft: 7, ArrowRight: 8, |
| @@ -1570,7 +1570,7 @@ document.addEventListener('keydown', (ev) => { | |||
| 1570 | const hasSelection = t.selection?.text !== null && t.selection?.text !== undefined; | 1570 | const hasSelection = t.selection?.text !== null && t.selection?.text !== undefined; |
| 1571 | // Copy is terminal-owned only when daemon-authoritative text exists. | 1571 | // Copy is terminal-owned only when daemon-authoritative text exists. |
| 1572 | // With no retained result, Firefox keeps Ctrl+Shift+C for Inspector and | 1572 | // With no retained result, Firefox keeps Ctrl+Shift+C for Inspector and |
| 1573 | // macOS keeps its Cmd family; plain Ctrl+C continues to the keymap. | 1573 | // macOS keeps its Cmd family; plain Ctrl+C continues to shared input. |
| 1574 | const selectionCopy = lower === 'c' && ( | 1574 | const selectionCopy = lower === 'c' && ( |
| 1575 | ev.metaKey || (ev.ctrlKey && !ev.altKey) | 1575 | ev.metaKey || (ev.ctrlKey && !ev.altKey) |
| 1576 | ); | 1576 | ); |
| @@ -1584,7 +1584,7 @@ document.addEventListener('keydown', (ev) => { | |||
| 1584 | // Ctrl+Shift+C/V everywhere, and on macOS the WHOLE Cmd family, which | 1584 | // Ctrl+Shift+C/V everywhere, and on macOS the WHOLE Cmd family, which |
| 1585 | // is how a mac copies and pastes at all. Consuming Cmd+V typed a | 1585 | // is how a mac copies and pastes at all. Consuming Cmd+V typed a |
| 1586 | // literal 'v' into the session and suppressed the paste event with it. | 1586 | // literal 'v' into the session and suppressed the paste event with it. |
| 1587 | // No preventDefault and no bytes — the keymap has no meta concept by | 1587 | // No preventDefault and no bytes — shared input has no meta concept by |
| 1588 | // design, so a Cmd chord the browser does not want is simply dropped. | 1588 | // design, so a Cmd chord the browser does not want is simply dropped. |
| 1589 | if (ev.metaKey) return; | 1589 | if (ev.metaKey) return; |
| 1590 | if (ev.ctrlKey && ev.shiftKey && (lower === 'c' || lower === 'v')) return; | 1590 | if (ev.ctrlKey && ev.shiftKey && (lower === 'c' || lower === 'v')) return; |
web/verify.js
| Old | New | ||
|---|---|---|---|
| @@ -3429,7 +3429,7 @@ async function main() { | |||
| 3429 | check('paste end len', e.mux_paste_end(), 6); | 3429 | check('paste end len', e.mux_paste_end(), 6); |
| 3430 | pasteWire.push(outBytes().toString('latin1')); | 3430 | pasteWire.push(outBytes().toString('latin1')); |
| 3431 | check('paste wire', pasteWire.join(''), '\x1b[200~two\nlines\x1b[201~'); | 3431 | check('paste wire', pasteWire.join(''), '\x1b[200~two\nlines\x1b[201~'); |
| 3432 | // The markers themselves, so a drift in keymap.zig's spelling is caught | 3432 | // The markers themselves, so a drift in input.zig's spelling is caught |
| 3433 | // here and not by an application quietly re-indenting. | 3433 | // here and not by an application quietly re-indenting. |
| 3434 | check('paste begin bytes', pasteWire[0], '\x1b[200~'); | 3434 | check('paste begin bytes', pasteWire[0], '\x1b[200~'); |
| 3435 | check('paste end bytes', pasteWire[pasteWire.length - 1], '\x1b[201~'); | 3435 | check('paste end bytes', pasteWire[pasteWire.length - 1], '\x1b[201~'); |