a73x

5b6c89ea

refactor: share surface input encoding

a73x   2026-09-07 04:36

Commit message
refactor: share surface input encoding

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~');