1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
|
//! Input layer for the TUI: raw stdin bytes -> `Key` values.
//!
//! Scope (P1, intentionally minimal but correct):
//! - printable chars (UTF-8, multi-byte)
//! - enter, tab, backspace, delete
//! - arrows, home, end, page up/down (CSI + a couple legacy forms)
//! - Esc (standalone), Ctrl+C, Ctrl+D, and other Ctrl+<letter>
//! - bracketed paste: enable/disable + recognizing begin/end markers and
//! surfacing pasted bytes as one literal-text `Key` rather than parsing
//! each byte as a keypress.
//!
//! Deferred to a later phase (documented, not done here):
//! - Full Kitty keyboard-protocol disambiguation (key release, exact
//! modifier reporting, super/hyper, alternate-key reporting). We *send* a
//! modest Kitty enable+query during negotiation but degrade gracefully:
//! nothing here requires a response, and unrecognized sequences are
//! consumed and dropped rather than mis-decoded.
//!
//! Shift+Enter limitation: without the Kitty protocol most terminals send the
//! exact same bytes for Enter and Shift+Enter (`\r`), so this decoder cannot
//! distinguish them and reports both as `.enter` with no shift modifier. When
//! the Kitty protocol IS active, Shift+Enter arrives as a CSI-u sequence
//! (`\x1b[13;2u`) which we DO decode, setting `mods.shift`. The `Key`/`Mods`
//! model can therefore represent the distinction even on terminals that can't
//! produce it; the input-box component (later sub-phase) keys off
//! `mods.shift` on an `.enter` press. On terminals that can't express it,
//! Enter submits and a separate binding (e.g. Alt+Enter / a config key) must
//! insert a newline — that policy is the component's, not ours.
//!
//! The splitter (`decodeOne`) takes a byte buffer and returns the next decoded
//! key plus how many bytes it consumed, so a batched read of several escape
//! sequences is split into individual `Key`s and a partial trailing sequence
//! can be retained for the next read.
const std = @import("std");
const key = @import("tui_key.zig");
const Key = key.Key;
const KeyCode = key.KeyCode;
const Mods = key.Mods;
// ---- Terminal control sequences this layer owns --------------------------
/// Enable bracketed paste.
pub const enable_bracketed_paste = "\x1b[?2004h";
/// Disable bracketed paste.
pub const disable_bracketed_paste = "\x1b[?2004l";
/// Bracketed-paste begin / end markers.
pub const paste_begin = "\x1b[200~";
pub const paste_end = "\x1b[201~";
/// Kitty keyboard protocol: push a flags entry that asks for disambiguated
/// escape codes (flag 1). We push (not set) so teardown can pop cleanly. This
/// is best-effort; terminals that don't support it ignore it.
pub const enable_kitty_keyboard = "\x1b[>1u";
/// Pop the Kitty keyboard flags entry we pushed.
pub const disable_kitty_keyboard = "\x1b[<u";
/// Modest negotiation setup to write at startup. Bracketed paste is the only
/// thing P1 strictly needs; the Kitty enable is opportunistic. Order: paste
/// first, then Kitty.
pub const negotiate_setup = enable_bracketed_paste ++ enable_kitty_keyboard;
/// Teardown to write on exit; the reverse of `negotiate_setup`.
pub const negotiate_teardown = disable_kitty_keyboard ++ disable_bracketed_paste;
// ---- Decode results -------------------------------------------------------
/// What a single decode step produced.
pub const Decoded = union(enum) {
/// A decoded key.
key: Key,
/// A run of pasted text (between bracketed-paste markers), surfaced
/// literally. `text` borrows from the input buffer.
paste: []const u8,
};
/// Result of one decode step: what was produced, and how many input bytes it
/// consumed. `null` from `decodeOne` means "need more bytes" (a partial
/// sequence at the end of the buffer); the caller should retain the unconsumed
/// tail and append the next read.
pub const Step = struct {
decoded: Decoded,
consumed: usize,
};
// ---- The splitter / decoder ----------------------------------------------
/// Decode the next key or paste run from the front of `buf`.
///
/// Returns:
/// - `Step` with `consumed > 0` on success.
/// - `null` when `buf` holds only a partial/incomplete sequence and more
/// bytes are needed (retain the tail and read more). An empty `buf` also
/// returns `null`.
///
/// Borrowing: any `[]const u8` in the result (printable `Key.text`, paste
/// text) points into `buf`. Copy before the next read overwrites the buffer.
pub fn decodeOne(buf: []const u8) ?Step {
if (buf.len == 0) return null;
const b = buf[0];
// ESC: could be a CSI/SS3 sequence, a bracketed paste, an Alt+<key>, or a
// lone Escape.
if (b == 0x1b) return decodeEscape(buf);
// Control bytes (C0).
switch (b) {
'\r', '\n' => return keyStep(.{ .code = .enter }, 1),
'\t' => return keyStep(.{ .code = .tab }, 1),
0x7f, 0x08 => return keyStep(.{ .code = .backspace }, 1),
0x03 => return keyStep(.{ .code = .{ .char = 'c' }, .mods = .{ .ctrl = true } }, 1), // Ctrl+C
0x04 => return keyStep(.{ .code = .{ .char = 'd' }, .mods = .{ .ctrl = true } }, 1), // Ctrl+D
else => {},
}
// Other C0 control bytes => Ctrl+<letter>. 0x01..0x1a map to a..z.
if (b >= 0x01 and b <= 0x1a) {
const letter: u21 = @intCast('a' + (b - 1));
return keyStep(.{ .code = .{ .char = letter }, .mods = .{ .ctrl = true } }, 1);
}
// Printable: decode one UTF-8 codepoint.
const seq_len = std.unicode.utf8ByteSequenceLength(b) catch {
// Invalid lead byte — consume it as a Latin-1-ish fallback so we never
// wedge on garbage input.
return keyStep(.{ .code = .{ .char = b }, .text = buf[0..1] }, 1);
};
if (buf.len < seq_len) return null; // partial UTF-8 at buffer end
const cp = std.unicode.utf8Decode(buf[0..seq_len]) catch {
return keyStep(.{ .code = .{ .char = b }, .text = buf[0..1] }, 1);
};
return keyStep(.{ .code = .{ .char = cp }, .text = buf[0..seq_len] }, seq_len);
}
fn keyStep(k: Key, consumed: usize) Step {
return .{ .decoded = .{ .key = k }, .consumed = consumed };
}
/// Decode a sequence beginning with ESC (`buf[0] == 0x1b`).
fn decodeEscape(buf: []const u8) ?Step {
std.debug.assert(buf[0] == 0x1b);
// Lone ESC with nothing after it: ambiguous (could be the start of a
// longer sequence). We only commit to "Escape key" if we can see it's not
// the start of CSI/SS3. With just one byte we can't tell -> need more.
if (buf.len == 1) return null;
const c1 = buf[1];
// CSI: ESC [
if (c1 == '[') return decodeCSI(buf);
// SS3: ESC O (some terminals send arrows/home/end as SS3 in app mode)
if (c1 == 'O') return decodeSS3(buf);
// ESC followed by anything else: treat as Alt+<that key>. Decode the rest
// recursively and OR in alt. (A real lone-Escape keypress is followed by
// nothing more, handled below.)
if (decodeOne(buf[1..])) |inner| {
switch (inner.decoded) {
.key => |ik| {
var k = ik;
k.mods.alt = true;
return .{ .decoded = .{ .key = k }, .consumed = inner.consumed + 1 };
},
// A paste right after ESC is nonsensical; fall through to Escape.
.paste => {},
}
}
// Couldn't make sense of what follows: report a lone Escape, consuming
// just the ESC byte.
return keyStep(.{ .code = .escape }, 1);
}
/// Decode an SS3 sequence: ESC O <final>. Used by some terminals for arrows,
/// home/end and F1..F4 in application-cursor mode.
fn decodeSS3(buf: []const u8) ?Step {
// buf[0]=ESC buf[1]='O'
if (buf.len < 3) return null; // need the final byte
const final = buf[2];
const code: ?KeyCode = switch (final) {
'A' => .up,
'B' => .down,
'C' => .right,
'D' => .left,
'H' => .home,
'F' => .end,
'P' => .f1,
'Q' => .f2,
'R' => .f3,
'S' => .f4,
else => null,
};
if (code) |c| return keyStep(.{ .code = c }, 3);
// Unknown SS3 final: consume the 3 bytes and drop (degrade gracefully).
return keyStep(.{ .code = .escape }, 3);
}
/// Decode a CSI sequence: ESC [ <params> <final>.
///
/// Handles:
/// - arrows / home / end / page up-down (with optional `1;<mods>` modifier)
/// - the `~`-terminated numeric forms (Home=1~/7~, End=4~/8~, Ins, Del=3~,
/// PgUp=5~, PgDn=6~, F5..F12)
/// - the `[A`..`[D` arrows without params
/// - bracketed paste begin (`200~`) — returns a `.paste` run spanning to the
/// end marker
/// - CSI-u (Kitty) for the keys we care about (notably Shift+Enter =
/// `13;2u`)
///
/// Unknown but well-formed CSI sequences are consumed and dropped.
fn decodeCSI(buf: []const u8) ?Step {
// buf[0]=ESC buf[1]='['
// Find the final byte: the first byte in 0x40..0x7e after the params.
// Params/intermediates are 0x20..0x3f. If we run off the end, need more.
var i: usize = 2;
while (i < buf.len and isCSIParamOrIntermediate(buf[i])) : (i += 1) {}
if (i >= buf.len) return null; // incomplete: no final byte yet
const final = buf[i];
const params = buf[2..i];
const total = i + 1;
// Bracketed paste begin: CSI 200 ~ -> scan to the end marker.
if (final == '~' and std.mem.eql(u8, params, "200")) {
return decodePaste(buf, total);
}
// CSI-u (Kitty): final 'u', params = "<codepoint>[;<mods>[:<event>]]".
if (final == 'u') return decodeKittyU(params, total);
// Modifier suffix: many sequences are "1;<mods><final>" or
// "<num>;<mods>~". Split params on ';'.
var first: []const u8 = params;
var mod_field: ?[]const u8 = null;
if (std.mem.indexOfScalar(u8, params, ';')) |semi| {
first = params[0..semi];
mod_field = params[semi + 1 ..];
}
const mods = parseMods(mod_field);
// Letter-final forms: A/B/C/D arrows, H home, F end.
const letter_code: ?KeyCode = switch (final) {
'A' => .up,
'B' => .down,
'C' => .right,
'D' => .left,
'H' => .home,
'F' => .end,
'P' => .f1,
'Q' => .f2,
'R' => .f3,
'S' => .f4,
else => null,
};
if (letter_code) |c| return keyStep(.{ .code = c, .mods = mods }, total);
// Tilde-final numeric forms.
if (final == '~') {
const n = std.fmt.parseInt(u16, first, 10) catch return keyStep(.{ .code = .escape }, total);
const code: ?KeyCode = switch (n) {
1, 7 => .home,
2 => null, // Insert — not in our KeyCode set; drop
3 => .delete,
4, 8 => .end,
5 => .page_up,
6 => .page_down,
11 => .f1,
12 => .f2,
13 => .f3,
14 => .f4,
15 => .f5,
17 => .f6,
18 => .f7,
19 => .f8,
20 => .f9,
21 => .f10,
23 => .f11,
24 => .f12,
else => null,
};
if (code) |c| return keyStep(.{ .code = c, .mods = mods }, total);
return keyStep(.{ .code = .escape }, total); // unknown ~ form: drop
}
// Well-formed but unhandled CSI: consume and drop.
return keyStep(.{ .code = .escape }, total);
}
/// Scan a bracketed-paste body starting after the `CSI 200~` begin marker
/// (which spans `[0..begin_len)`), up to the `CSI 201~` end marker. Returns a
/// `.paste` step covering the text between the markers. If the end marker
/// isn't present yet, returns null (need more bytes).
fn decodePaste(buf: []const u8, begin_len: usize) ?Step {
const body = buf[begin_len..];
const end_idx = std.mem.indexOf(u8, body, paste_end) orelse return null;
const text = body[0..end_idx];
const consumed = begin_len + end_idx + paste_end.len;
return .{ .decoded = .{ .paste = text }, .consumed = consumed };
}
/// Decode a Kitty CSI-u sequence's params (without the trailing `u`). We only
/// resolve the subset P1 needs: a printable codepoint, Enter (13), Tab (9),
/// Backspace (127), and Escape (27), with modifiers and (optionally) the
/// release event. Anything else is consumed and dropped.
fn decodeKittyU(params: []const u8, total: usize) ?Step {
// params: "<cp>[:<alt-cp>][;<mods>[:<event>]]"
var cp_field: []const u8 = params;
var rest: ?[]const u8 = null;
if (std.mem.indexOfScalar(u8, params, ';')) |semi| {
cp_field = params[0..semi];
rest = params[semi + 1 ..];
}
// The codepoint field may carry alternates after ':'; take the first.
if (std.mem.indexOfScalar(u8, cp_field, ':')) |colon| cp_field = cp_field[0..colon];
const cp = std.fmt.parseInt(u21, cp_field, 10) catch return keyStep(.{ .code = .escape }, total);
// Modifier + event live in `rest` as "<mods>[:<event>]".
var mod_field: ?[]const u8 = rest;
var event = key.KeyEvent.press;
if (rest) |r| {
if (std.mem.indexOfScalar(u8, r, ':')) |colon| {
mod_field = r[0..colon];
event = parseKittyEvent(r[colon + 1 ..]);
}
}
const mods = parseMods(mod_field);
const code: KeyCode = switch (cp) {
13 => .enter,
9 => .tab,
127 => .backspace,
27 => .escape,
else => .{ .char = cp },
};
return .{ .decoded = .{ .key = .{ .code = code, .mods = mods, .event = event } }, .consumed = total };
}
fn parseKittyEvent(field: []const u8) key.KeyEvent {
const n = std.fmt.parseInt(u8, field, 10) catch return .press;
return switch (n) {
1 => .press,
2 => .repeat,
3 => .release,
else => .press,
};
}
/// Parse a CSI modifier field. The terminal encoding is `1 + bitmask` where
/// the bitmask is shift=1, alt=2, ctrl=4, super=8. A null/empty field means no
/// modifiers.
fn parseMods(field: ?[]const u8) Mods {
const f = field orelse return .{};
if (f.len == 0) return .{};
const raw = std.fmt.parseInt(u16, f, 10) catch return .{};
if (raw == 0) return .{};
const bits = raw - 1;
return .{
.shift = (bits & 1) != 0,
.alt = (bits & 2) != 0,
.ctrl = (bits & 4) != 0,
.super = (bits & 8) != 0,
};
}
fn isCSIParamOrIntermediate(c: u8) bool {
return c >= 0x20 and c <= 0x3f;
}
// ---- Tests ----------------------------------------------------------------
test "printable ascii" {
const s = decodeOne("a").?;
try std.testing.expectEqual(@as(usize, 1), s.consumed);
try std.testing.expectEqual(@as(u21, 'a'), s.decoded.key.code.char);
try std.testing.expectEqualStrings("a", s.decoded.key.text.?);
}
test "multibyte utf8 printable" {
// é = U+00E9 = 0xC3 0xA9
const s = decodeOne("\xc3\xa9rest").?;
try std.testing.expectEqual(@as(usize, 2), s.consumed);
try std.testing.expectEqual(@as(u21, 0xe9), s.decoded.key.code.char);
}
test "partial utf8 needs more bytes" {
try std.testing.expect(decodeOne("\xc3") == null);
}
test "enter, tab, backspace" {
try std.testing.expectEqual(KeyCode.enter, decodeOne("\r").?.decoded.key.code);
try std.testing.expectEqual(KeyCode.enter, decodeOne("\n").?.decoded.key.code);
try std.testing.expectEqual(KeyCode.tab, decodeOne("\t").?.decoded.key.code);
try std.testing.expectEqual(KeyCode.backspace, decodeOne("\x7f").?.decoded.key.code);
}
test "ctrl-c and ctrl-d" {
const c = decodeOne("\x03").?.decoded.key;
try std.testing.expect(c.isCtrl('c'));
const d = decodeOne("\x04").?.decoded.key;
try std.testing.expect(d.isCtrl('d'));
}
test "csi arrows" {
try std.testing.expectEqual(KeyCode.up, decodeOne("\x1b[A").?.decoded.key.code);
try std.testing.expectEqual(KeyCode.down, decodeOne("\x1b[B").?.decoded.key.code);
try std.testing.expectEqual(KeyCode.right, decodeOne("\x1b[C").?.decoded.key.code);
try std.testing.expectEqual(KeyCode.left, decodeOne("\x1b[D").?.decoded.key.code);
}
test "csi home/end and modified arrow" {
try std.testing.expectEqual(KeyCode.home, decodeOne("\x1b[H").?.decoded.key.code);
try std.testing.expectEqual(KeyCode.end, decodeOne("\x1b[F").?.decoded.key.code);
// Ctrl+Right = CSI 1;5C
const s = decodeOne("\x1b[1;5C").?.decoded.key;
try std.testing.expectEqual(KeyCode.right, s.code);
try std.testing.expect(s.mods.ctrl);
}
test "csi tilde delete / pageup / pagedown" {
try std.testing.expectEqual(KeyCode.delete, decodeOne("\x1b[3~").?.decoded.key.code);
try std.testing.expectEqual(KeyCode.page_up, decodeOne("\x1b[5~").?.decoded.key.code);
try std.testing.expectEqual(KeyCode.page_down, decodeOne("\x1b[6~").?.decoded.key.code);
}
test "ss3 arrows and f-keys" {
try std.testing.expectEqual(KeyCode.up, decodeOne("\x1bOA").?.decoded.key.code);
try std.testing.expectEqual(KeyCode.f1, decodeOne("\x1bOP").?.decoded.key.code);
}
test "lone escape" {
// ESC followed by a non-sequence byte: Alt+x is decoded preferentially.
const alt = decodeOne("\x1bx").?.decoded.key;
try std.testing.expect(alt.mods.alt);
try std.testing.expectEqual(@as(u21, 'x'), alt.code.char);
}
test "incomplete csi needs more bytes" {
try std.testing.expect(decodeOne("\x1b[1;5") == null);
try std.testing.expect(decodeOne("\x1b[") == null);
}
test "kitty shift+enter distinguishes from plain enter" {
// CSI 13 ; 2 u -> Enter with shift.
const s = decodeOne("\x1b[13;2u").?.decoded.key;
try std.testing.expectEqual(KeyCode.enter, s.code);
try std.testing.expect(s.mods.shift);
// Plain enter has no shift.
try std.testing.expect(!decodeOne("\r").?.decoded.key.mods.shift);
}
test "kitty release event" {
// CSI 97 ; 1 : 3 u -> 'a' release.
const s = decodeOne("\x1b[97;1:3u").?.decoded.key;
try std.testing.expectEqual(@as(u21, 'a'), s.code.char);
try std.testing.expectEqual(key.KeyEvent.release, s.event);
}
test "bracketed paste surfaces literal text" {
const input = paste_begin ++ "hello\nworld" ++ paste_end ++ "x";
const s = decodeOne(input).?;
try std.testing.expectEqualStrings("hello\nworld", s.decoded.paste);
// Next decode should land on the trailing 'x'.
const rest = input[s.consumed..];
try std.testing.expectEqual(@as(u21, 'x'), decodeOne(rest).?.decoded.key.code.char);
}
test "incomplete paste needs more bytes" {
const input = paste_begin ++ "partial";
try std.testing.expect(decodeOne(input) == null);
}
test "splitter: batched read yields individual keys" {
// Up, Down, 'a' in one buffer.
const input = "\x1b[A\x1b[Ba";
var off: usize = 0;
const s1 = decodeOne(input[off..]).?;
try std.testing.expectEqual(KeyCode.up, s1.decoded.key.code);
off += s1.consumed;
const s2 = decodeOne(input[off..]).?;
try std.testing.expectEqual(KeyCode.down, s2.decoded.key.code);
off += s2.consumed;
const s3 = decodeOne(input[off..]).?;
try std.testing.expectEqual(@as(u21, 'a'), s3.decoded.key.code.char);
off += s3.consumed;
try std.testing.expectEqual(input.len, off);
}
|