a73x

236e3205

feat: keymap.zig — normalized key events to VT bytes, tables pinned

a73x   2026-08-13 10:21

Commit message
feat: keymap.zig — normalized key events to VT bytes, tables pinned

The portable half of input (M-web Task 3): Event{key, cp, mods} in, VT
bytes out. Printable (UTF-8), the ctrl fold (bits 6-5 cleared over the
foldable column), alt as ESC prefix, backtab, arrows/nav/F-keys in
short and xterm modifier-CSI forms (param = 1+shift+2*alt+4*ctrl,
computed not tabulated), bracketed paste wrap. kitty/CSI-u deferred per
spec. Platform-free by construction for the wasm target.

Pinned by a spot table plus an exhaustive modifier matrix over every
CSI-carrying key (7 combinations x 22 keys).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

build.zig
Old New
@@ -196,6 +196,15 @@ pub fn build(b: *std.Build) void {
196 replica_mod.addImport("engine", engine_mod); 196 replica_mod.addImport("engine", engine_mod);
197 replica_mod.addImport("protocol", protocol_mod); 197 replica_mod.addImport("protocol", protocol_mod);
198 198
199 // Normalized key events -> VT bytes. A leaf with no platform imports —
200 // each shell (browser, later xkb) produces the normalized form and this
201 // owns the bytes; must compile for wasm32.
202 const keymap_mod = b.createModule(.{
203 .root_source_file = b.path("src/keymap.zig"),
204 .target = target,
205 .optimize = optimize,
206 });
207
199 // The socket path's identity and the right to bind it: the stale-socket 208 // The socket path's identity and the right to bind it: the stale-socket
200 // claim and the dev+ino record teardown compares against. A leaf — it 209 // claim and the dev+ino record teardown compares against. A leaf — it
201 // takes a path and nothing else, and knows no Server exists. 210 // takes a path and nothing else, and knows no Server exists.
@@ -404,7 +413,7 @@ pub fn build(b: *std.Build) void {
404 // absence here was a live hazard recorded in decisions.md — muxd's 413 // absence here was a live hazard recorded in decisions.md — muxd's
405 // entrypoint could grow tests that silently never ran, exactly as 414 // entrypoint could grow tests that silently never ran, exactly as
406 // mux_main.zig's five did before it was added. 415 // mux_main.zig's five did before it was added.
407 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, delta_mod, replica_mod, sockpath_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, quic_server_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, handoff_mod, paint_mod, render_mod, ptyclient_mod }) |mod| { 416 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, delta_mod, replica_mod, keymap_mod, sockpath_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, quic_server_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, handoff_mod, paint_mod, render_mod, ptyclient_mod }) |mod| {
408 const t = b.addTest(.{ .root_module = mod }); 417 const t = b.addTest(.{ .root_module = mod });
409 t.use_llvm = true; 418 t.use_llvm = true;
410 t.use_lld = true; 419 t.use_lld = true;
src/keymap.zig
Old New
@@ -0,0 +1,327 @@
1 //! Normalized key events → VT byte sequences. The portable half of input:
2 //! each shell (browser KeyboardEvent, later xkb) produces the normalized
3 //! form; this module owns every byte that reaches the PTY, so the tables
4 //! are unit-tested natively with no browser in the loop (M-web Task 3).
5 //!
6 //! v1 scope (spec): printable input, control characters, arrows and nav
7 //! keys, function keys, the xterm modifier-encoded CSI variants, and
8 //! bracketed paste. Explicitly deferred: kitty/CSI-u.
9 //!
10 //! Deliberately platform-free — no posix, no fds, no clocks — this module
11 //! must compile for wasm32-freestanding.
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 (the
73 /// longest is ESC + 4 UTF-8 bytes, or an 8-byte modified CSI).
74 pub const max_seq_len = 16;
75
76 /// Encode one event into `buf` (at least max_seq_len bytes), returning the
77 /// slice written. An event this table has no bytes for — a bare modifier,
78 /// a .char with cp 0 — encodes to the empty slice, which callers send as
79 /// nothing rather than as a surprise.
80 pub fn encode(ev: Event, buf: []u8) []const u8 {
81 std.debug.assert(buf.len >= max_seq_len);
82 switch (ev.key) {
83 .char => {
84 if (ev.cp == 0) return buf[0..0];
85 var n: usize = 0;
86 if (ev.mods.alt) {
87 buf[0] = 0x1b;
88 n = 1;
89 }
90 if (ev.mods.ctrl) {
91 // The terminal tradition: Ctrl clears bits 6-5 of the
92 // ASCII column, so ctrl-a..z are 0x01..0x1a, ctrl-@ (and
93 // ctrl-space) are NUL, ctrl-[ is ESC. Lowercase folds to
94 // the same control byte as its column; anything outside
95 // the foldable range falls through and sends plain.
96 const c = ev.cp;
97 if (c == ' ' or (c >= '@' and c <= '_') or (c >= 'a' and c <= 'z')) {
98 buf[n] = @intCast(c & 0x1f);
99 n += 1;
100 return buf[0..n];
101 }
102 }
103 const len = std.unicode.utf8Encode(ev.cp, buf[n..]) catch return buf[0..0];
104 return buf[0 .. n + len];
105 },
106 .enter => return altable(ev.mods, "\r", buf),
107 .tab => {
108 // Shift+Tab is backtab, its own sequence; plain Tab is a byte.
109 if (ev.mods.shift) return copy("\x1b[Z", buf);
110 return altable(ev.mods, "\t", buf);
111 },
112 .backspace => {
113 // DEL, the modern default. Ctrl+Backspace sends BS so shells
114 // can tell them apart; Alt prefixes either.
115 const base: []const u8 = if (ev.mods.ctrl) "\x08" else "\x7f";
116 return altable(ev.mods, base, buf);
117 },
118 .escape => return altable(ev.mods, "\x1b", buf),
119 .up => return cursorKey(ev.mods, 'A', buf),
120 .down => return cursorKey(ev.mods, 'B', buf),
121 .right => return cursorKey(ev.mods, 'C', buf),
122 .left => return cursorKey(ev.mods, 'D', buf),
123 .home => return cursorKey(ev.mods, 'H', buf),
124 .end => return cursorKey(ev.mods, 'F', buf),
125 .insert => return tildeKey(ev.mods, 2, buf),
126 .delete => return tildeKey(ev.mods, 3, buf),
127 .page_up => return tildeKey(ev.mods, 5, buf),
128 .page_down => return tildeKey(ev.mods, 6, buf),
129 // F1-F4 are SS3 letters unmodified (their VT220 lineage) and CSI
130 // 1;mP..S modified — the xterm scheme.
131 .f1 => return ss3Key(ev.mods, 'P', buf),
132 .f2 => return ss3Key(ev.mods, 'Q', buf),
133 .f3 => return ss3Key(ev.mods, 'R', buf),
134 .f4 => return ss3Key(ev.mods, 'S', buf),
135 .f5 => return tildeKey(ev.mods, 15, buf),
136 .f6 => return tildeKey(ev.mods, 17, buf),
137 .f7 => return tildeKey(ev.mods, 18, buf),
138 .f8 => return tildeKey(ev.mods, 19, buf),
139 .f9 => return tildeKey(ev.mods, 20, buf),
140 .f10 => return tildeKey(ev.mods, 21, buf),
141 .f11 => return tildeKey(ev.mods, 23, buf),
142 .f12 => return tildeKey(ev.mods, 24, buf),
143 }
144 }
145
146 fn copy(seq: []const u8, buf: []u8) []const u8 {
147 @memcpy(buf[0..seq.len], seq);
148 return buf[0..seq.len];
149 }
150
151 /// A single-byte key that Alt turns into ESC + byte. Ctrl is either baked
152 /// into `base` by the caller or meaningless for the key.
153 fn altable(mods: Mods, base: []const u8, buf: []u8) []const u8 {
154 var n: usize = 0;
155 if (mods.alt) {
156 buf[0] = 0x1b;
157 n = 1;
158 }
159 @memcpy(buf[n .. n + base.len], base);
160 return buf[0 .. n + base.len];
161 }
162
163 /// CSI letter form: ESC [ <final>, or ESC [ 1 ; <mods> <final> when
164 /// modified.
165 fn cursorKey(mods: Mods, final: u8, buf: []u8) []const u8 {
166 if (!mods.any()) {
167 buf[0] = 0x1b;
168 buf[1] = '[';
169 buf[2] = final;
170 return buf[0..3];
171 }
172 return std.fmt.bufPrint(buf, "\x1b[1;{d}{c}", .{ mods.param(), final }) catch unreachable;
173 }
174
175 /// CSI tilde form: ESC [ <n> ~, or ESC [ <n> ; <mods> ~ when modified.
176 fn tildeKey(mods: Mods, n: u8, buf: []u8) []const u8 {
177 if (!mods.any())
178 return std.fmt.bufPrint(buf, "\x1b[{d}~", .{n}) catch unreachable;
179 return std.fmt.bufPrint(buf, "\x1b[{d};{d}~", .{ n, mods.param() }) catch unreachable;
180 }
181
182 /// SS3 form for F1-F4: ESC O <final> unmodified, CSI 1 ; <mods> <final>
183 /// modified (SS3 has nowhere to put a parameter).
184 fn ss3Key(mods: Mods, final: u8, buf: []u8) []const u8 {
185 if (!mods.any()) {
186 buf[0] = 0x1b;
187 buf[1] = 'O';
188 buf[2] = final;
189 return buf[0..3];
190 }
191 return std.fmt.bufPrint(buf, "\x1b[1;{d}{c}", .{ mods.param(), final }) catch unreachable;
192 }
193
194 pub const paste_begin = "\x1b[200~";
195 pub const paste_end = "\x1b[201~";
196
197 /// Bracketed paste: the wrap and nothing else. The bytes between the
198 /// brackets are the paste verbatim — filtering (say, of a pasted ESC) is
199 /// a policy question this module does not answer in v1.
200 pub fn pasteInto(
201 list: *std.ArrayList(u8),
202 alloc: std.mem.Allocator,
203 bytes: []const u8,
204 ) !void {
205 try list.appendSlice(alloc, paste_begin);
206 try list.appendSlice(alloc, bytes);
207 try list.appendSlice(alloc, paste_end);
208 }
209
210 // ---------------------------------------------------------------------------
211
212 test "keymap: the table" {
213 const cases = [_]struct { ev: Event, want: []const u8 }{
214 // Printable, plain and modified.
215 .{ .ev = .{ .key = .char, .cp = 'a' }, .want = "a" },
216 .{ .ev = .{ .key = .char, .cp = 'Z' }, .want = "Z" },
217 .{ .ev = .{ .key = .char, .cp = 'a', .mods = .{ .ctrl = true } }, .want = "\x01" },
218 .{ .ev = .{ .key = .char, .cp = 'z', .mods = .{ .ctrl = true } }, .want = "\x1a" },
219 .{ .ev = .{ .key = .char, .cp = 'C', .mods = .{ .ctrl = true } }, .want = "\x03" },
220 .{ .ev = .{ .key = .char, .cp = ' ', .mods = .{ .ctrl = true } }, .want = "\x00" },
221 .{ .ev = .{ .key = .char, .cp = '[', .mods = .{ .ctrl = true } }, .want = "\x1b" },
222 .{ .ev = .{ .key = .char, .cp = '_', .mods = .{ .ctrl = true } }, .want = "\x1f" },
223 .{ .ev = .{ .key = .char, .cp = 'x', .mods = .{ .alt = true } }, .want = "\x1bx" },
224 .{ .ev = .{ .key = .char, .cp = 'b', .mods = .{ .alt = true, .ctrl = true } }, .want = "\x1b\x02" },
225 // UTF-8 out, straight through.
226 .{ .ev = .{ .key = .char, .cp = 0x6f22 }, .want = "\xe6\xbc\xa2" }, // 漢
227 .{ .ev = .{ .key = .char, .cp = 0xe9 }, .want = "\xc3\xa9" }, // é
228 // The single-byte keys.
229 .{ .ev = .{ .key = .enter }, .want = "\r" },
230 .{ .ev = .{ .key = .enter, .mods = .{ .alt = true } }, .want = "\x1b\r" },
231 .{ .ev = .{ .key = .tab }, .want = "\t" },
232 .{ .ev = .{ .key = .tab, .mods = .{ .shift = true } }, .want = "\x1b[Z" },
233 .{ .ev = .{ .key = .backspace }, .want = "\x7f" },
234 .{ .ev = .{ .key = .backspace, .mods = .{ .ctrl = true } }, .want = "\x08" },
235 .{ .ev = .{ .key = .backspace, .mods = .{ .alt = true } }, .want = "\x1b\x7f" },
236 .{ .ev = .{ .key = .escape }, .want = "\x1b" },
237 // Cursor and nav keys, short forms.
238 .{ .ev = .{ .key = .up }, .want = "\x1b[A" },
239 .{ .ev = .{ .key = .down }, .want = "\x1b[B" },
240 .{ .ev = .{ .key = .right }, .want = "\x1b[C" },
241 .{ .ev = .{ .key = .left }, .want = "\x1b[D" },
242 .{ .ev = .{ .key = .home }, .want = "\x1b[H" },
243 .{ .ev = .{ .key = .end }, .want = "\x1b[F" },
244 .{ .ev = .{ .key = .insert }, .want = "\x1b[2~" },
245 .{ .ev = .{ .key = .delete }, .want = "\x1b[3~" },
246 .{ .ev = .{ .key = .page_up }, .want = "\x1b[5~" },
247 .{ .ev = .{ .key = .page_down }, .want = "\x1b[6~" },
248 // Function keys.
249 .{ .ev = .{ .key = .f1 }, .want = "\x1bOP" },
250 .{ .ev = .{ .key = .f4 }, .want = "\x1bOS" },
251 .{ .ev = .{ .key = .f5 }, .want = "\x1b[15~" },
252 .{ .ev = .{ .key = .f6 }, .want = "\x1b[17~" },
253 .{ .ev = .{ .key = .f10 }, .want = "\x1b[21~" },
254 .{ .ev = .{ .key = .f11 }, .want = "\x1b[23~" },
255 .{ .ev = .{ .key = .f12 }, .want = "\x1b[24~" },
256 // Modified CSI spot checks (the matrix test below is exhaustive).
257 .{ .ev = .{ .key = .up, .mods = .{ .ctrl = true } }, .want = "\x1b[1;5A" },
258 .{ .ev = .{ .key = .up, .mods = .{ .shift = true, .alt = true } }, .want = "\x1b[1;4A" },
259 .{ .ev = .{ .key = .delete, .mods = .{ .shift = true } }, .want = "\x1b[3;2~" },
260 .{ .ev = .{ .key = .f5, .mods = .{ .ctrl = true } }, .want = "\x1b[15;5~" },
261 .{ .ev = .{ .key = .f1, .mods = .{ .shift = true } }, .want = "\x1b[1;2P" },
262 // Nothing to send.
263 .{ .ev = .{ .key = .char, .cp = 0 }, .want = "" },
264 };
265 var buf: [max_seq_len]u8 = undefined;
266 for (cases) |case| {
267 const got = encode(case.ev, &buf);
268 std.testing.expectEqualSlices(u8, case.want, got) catch |err| {
269 std.debug.print("keymap: {any} -> {any}, want {any}\n", .{ case.ev, got, case.want });
270 return err;
271 };
272 }
273 }
274
275 test "keymap: the modifier matrix over every CSI-carrying key" {
276 // param = 1 + shift + 2*alt + 4*ctrl; every modified form carries it,
277 // every unmodified form omits it. Exhaustive over the seven non-empty
278 // modifier combinations x every key with a CSI form.
279 const csi_keys = [_]struct { key: Key, mid: []const u8, tail: []const u8 }{
280 .{ .key = .up, .mid = "1;", .tail = "A" },
281 .{ .key = .down, .mid = "1;", .tail = "B" },
282 .{ .key = .right, .mid = "1;", .tail = "C" },
283 .{ .key = .left, .mid = "1;", .tail = "D" },
284 .{ .key = .home, .mid = "1;", .tail = "H" },
285 .{ .key = .end, .mid = "1;", .tail = "F" },
286 .{ .key = .insert, .mid = "2;", .tail = "~" },
287 .{ .key = .delete, .mid = "3;", .tail = "~" },
288 .{ .key = .page_up, .mid = "5;", .tail = "~" },
289 .{ .key = .page_down, .mid = "6;", .tail = "~" },
290 .{ .key = .f1, .mid = "1;", .tail = "P" },
291 .{ .key = .f2, .mid = "1;", .tail = "Q" },
292 .{ .key = .f3, .mid = "1;", .tail = "R" },
293 .{ .key = .f4, .mid = "1;", .tail = "S" },
294 .{ .key = .f5, .mid = "15;", .tail = "~" },
295 .{ .key = .f6, .mid = "17;", .tail = "~" },
296 .{ .key = .f7, .mid = "18;", .tail = "~" },
297 .{ .key = .f8, .mid = "19;", .tail = "~" },
298 .{ .key = .f9, .mid = "20;", .tail = "~" },
299 .{ .key = .f10, .mid = "21;", .tail = "~" },
300 .{ .key = .f11, .mid = "23;", .tail = "~" },
301 .{ .key = .f12, .mid = "24;", .tail = "~" },
302 };
303 var buf: [max_seq_len]u8 = undefined;
304 var want_buf: [max_seq_len]u8 = undefined;
305 for (csi_keys) |k| {
306 var m: u8 = 2;
307 while (m <= 8) : (m += 1) {
308 const mods = Mods{
309 .shift = (m - 1) & 1 != 0,
310 .alt = (m - 1) & 2 != 0,
311 .ctrl = (m - 1) & 4 != 0,
312 };
313 try std.testing.expectEqual(m, mods.param());
314 const want = try std.fmt.bufPrint(&want_buf, "\x1b[{s}{d}{s}", .{ k.mid, m, k.tail });
315 const got = encode(.{ .key = k.key, .mods = mods }, &buf);
316 try std.testing.expectEqualSlices(u8, want, got);
317 }
318 }
319 }
320
321 test "keymap: bracketed paste wraps" {
322 const alloc = std.testing.allocator;
323 var out: std.ArrayList(u8) = .empty;
324 defer out.deinit(alloc);
325 try pasteInto(&out, alloc, "two\nlines");
326 try std.testing.expectEqualStrings("\x1b[200~two\nlines\x1b[201~", out.items);
327 }