src/input.zig
Ref: Size: 16.7 KiB History
//! Normalized surface input → VT application-input byte sequences. Each
//! frontend produces the normalized form and this module owns every byte an
//! event turns into, so the tables unit-test natively with no browser in the
//! loop.
//!
//! Scope: printable input, control characters, arrows and nav keys, function
//! keys, the xterm modifier-encoded CSI variants, bracketed paste. Deferred:
//! kitty/CSI-u. Platform-free — this compiles for wasm32-freestanding.
//!
//! This serializes semantic surface input only. It neither accesses nor drives
//! a terminal; session transports decide when and where the bytes are sent.
const std = @import("std");
pub const Mods = packed struct {
shift: bool = false,
alt: bool = false,
ctrl: bool = false,
/// The xterm modifier parameter: 1 + shift + 2*alt + 4*ctrl. A CSI
/// sequence carries it only when it is > 1 — an unmodified key uses
/// the short form.
pub fn param(self: Mods) u8 {
return 1 + @as(u8, @intFromBool(self.shift)) +
2 * @as(u8, @intFromBool(self.alt)) +
4 * @as(u8, @intFromBool(self.ctrl));
}
pub fn any(self: Mods) bool {
return self.shift or self.alt or self.ctrl;
}
};
pub const Key = enum {
/// A printable character; `Event.cp` carries the codepoint.
char,
enter,
tab,
backspace,
escape,
up,
down,
left,
right,
home,
end,
insert,
delete,
page_up,
page_down,
f1,
f2,
f3,
f4,
f5,
f6,
f7,
f8,
f9,
f10,
f11,
f12,
};
pub const Event = struct {
key: Key,
/// Codepoint for .char, 0 otherwise.
cp: u21 = 0,
mods: Mods = .{},
};
/// Every sequence this module can emit fits here with room to spare —
/// the widest today is the 7-byte modified tilde CSI; the slack is
/// headroom for forms not in the table yet.
pub const max_seq_len = 16;
pub const MouseFormat = enum { x10, utf8, sgr, urxvt, sgr_pixels };
pub const mouse_max_seq_len = 64;
/// Encode one semantic mouse event. Coordinates are one-based on the wire.
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 {
std.debug.assert(buf.len >= mouse_max_seq_len);
const base: u16 = if (release and format != .sgr and format != .sgr_pixels) 3 else button_base;
const button: u16 = base + @as(u16, @intFromBool(mods.shift)) * 4 + @as(u16, @intFromBool(mods.alt)) * 8 + @as(u16, @intFromBool(mods.ctrl)) * 16;
const x: u32 = if (format == .sgr_pixels) pixel_x else cell_x;
const y: u32 = if (format == .sgr_pixels) pixel_y else cell_y;
const bx = x +| 1;
const by = y +| 1;
switch (format) {
.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,
.urxvt => return std.fmt.bufPrint(buf, "\x1b[{d};{d};{d}M", .{ button + 32, bx, by }) catch unreachable,
.x10 => {
if (bx > 223 or by > 223) return buf[0..0];
buf[0] = 0x1b;
buf[1] = '[';
buf[2] = 'M';
buf[3] = @intCast(button + 32);
buf[4] = @intCast(bx + 32);
buf[5] = @intCast(by + 32);
return buf[0..6];
},
.utf8 => {
if (bx > 2015 or by > 2015) return buf[0..0];
buf[0] = 0x1b;
buf[1] = '[';
buf[2] = 'M';
var n: usize = 3;
for ([_]u21{ button + 32, @intCast(bx + 32), @intCast(by + 32) }) |cp| {
n += std.unicode.utf8Encode(cp, buf[n..]) catch unreachable;
}
return buf[0..n];
},
}
}
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 {
return encodeMouse(format, if (up) 64 else 65, false, cell_x, cell_y, pixel_x, pixel_y, mods, buf);
}
pub fn encodeWheelArrow(up: bool, cursor_keys: bool, buf: []u8) []const u8 {
const bytes = encode(.{ .key = if (up) .up else .down }, buf);
if (cursor_keys) buf[1] = 'O';
return bytes;
}
test "wheel encodes negotiated SGR and legacy forms" {
var buf: [mouse_max_seq_len]u8 = undefined;
try std.testing.expectEqualStrings("\x1b[<64;4;6M", encodeWheel(.sgr, true, 3, 5, 0, 0, .{}, &buf));
try std.testing.expectEqualStrings("\x1b[97;4;6M", encodeWheel(.urxvt, false, 3, 5, 0, 0, .{}, &buf));
try std.testing.expectEqualStrings("\x1bOA", encodeWheelArrow(true, true, &buf));
try std.testing.expectEqualStrings("\x1b[B", encodeWheelArrow(false, false, &buf));
try std.testing.expectEqualSlices(u8, &.{ 27, '[', 'M', 96, 255, 255 }, encodeWheel(.x10, true, 222, 222, 0, 0, .{}, &buf));
try std.testing.expectEqual(@as(usize, 0), encodeWheel(.x10, true, 223, 0, 0, 0, .{}, &buf).len);
try std.testing.expectEqualStrings("\x1b[<64;225;226M", encodeWheel(.sgr_pixels, true, 0, 0, 224, 225, .{}, &buf));
try std.testing.expectEqualSlices(u8, &.{ 27, '[', 'M', 96, 0xdf, 0xbf, 0xdf, 0xbf }, encodeWheel(.utf8, true, 2014, 2014, 0, 0, .{}, &buf));
try std.testing.expectEqual(@as(usize, 0), encodeWheel(.utf8, true, 2015, 0, 0, 0, .{}, &buf).len);
try std.testing.expectEqualStrings("\x1b[<93;1;1M", encodeWheel(.sgr, false, 0, 0, 0, 0, .{ .shift = true, .alt = true, .ctrl = true }, &buf));
try std.testing.expectEqualStrings("\x1b[<1;4;6m", encodeMouse(.sgr, 1, true, 3, 5, 0, 0, .{}, &buf));
try std.testing.expectEqualSlices(u8, &.{ 27, '[', 'M', 51, 33, 33 }, encodeMouse(.x10, 0, true, 0, 0, 0, 0, .{ .ctrl = true }, &buf));
try std.testing.expectEqualStrings("\x1b[35;4;6M", encodeMouse(.urxvt, 2, true, 3, 5, 0, 0, .{}, &buf));
try std.testing.expectEqualStrings("\x1b[<64;4;6M", encodeWheel(.sgr, true, 3, 5, 0, 0, .{}, &buf));
}
/// Encode one event into `buf` (at least max_seq_len bytes), returning the
/// slice written. An event this table has no bytes for — a bare modifier,
/// a .char with cp 0 — encodes to the empty slice, which callers send as
/// nothing rather than as a surprise.
pub fn encode(ev: Event, buf: []u8) []const u8 {
std.debug.assert(buf.len >= max_seq_len);
switch (ev.key) {
.char => {
if (ev.cp == 0) return buf[0..0];
var n: usize = 0;
if (ev.mods.alt) {
buf[0] = 0x1b;
n = 1;
}
if (ev.mods.ctrl) {
// The terminal tradition: Ctrl clears bits 6-5 of the ASCII
// column, so ctrl-a..z are 0x01..0x1a and ctrl-[ is ESC. Anything
// outside the foldable range falls through and sends plain.
const c = ev.cp;
if (c == ' ' or (c >= '@' and c <= '_') or (c >= 'a' and c <= 'z')) {
buf[n] = @intCast(c & 0x1f);
n += 1;
return buf[0..n];
}
}
const len = std.unicode.utf8Encode(ev.cp, buf[n..]) catch return buf[0..0];
return buf[0 .. n + len];
},
.enter => return altable(ev.mods, "\r", buf),
.tab => {
// Shift+Tab is backtab, its own sequence; plain Tab is a byte.
if (ev.mods.shift) return copy("\x1b[Z", buf);
return altable(ev.mods, "\t", buf);
},
.backspace => {
// DEL, the modern default. Ctrl+Backspace sends BS so shells
// can tell them apart; Alt prefixes either.
const base: []const u8 = if (ev.mods.ctrl) "\x08" else "\x7f";
return altable(ev.mods, base, buf);
},
.escape => return altable(ev.mods, "\x1b", buf),
.up => return cursorKey(ev.mods, 'A', buf),
.down => return cursorKey(ev.mods, 'B', buf),
.right => return cursorKey(ev.mods, 'C', buf),
.left => return cursorKey(ev.mods, 'D', buf),
.home => return cursorKey(ev.mods, 'H', buf),
.end => return cursorKey(ev.mods, 'F', buf),
.insert => return tildeKey(ev.mods, 2, buf),
.delete => return tildeKey(ev.mods, 3, buf),
.page_up => return tildeKey(ev.mods, 5, buf),
.page_down => return tildeKey(ev.mods, 6, buf),
// F1-F4 are SS3 letters unmodified (their VT220 lineage) and CSI
// 1;mP..S modified — the xterm scheme.
.f1 => return ss3Key(ev.mods, 'P', buf),
.f2 => return ss3Key(ev.mods, 'Q', buf),
.f3 => return ss3Key(ev.mods, 'R', buf),
.f4 => return ss3Key(ev.mods, 'S', buf),
.f5 => return tildeKey(ev.mods, 15, buf),
.f6 => return tildeKey(ev.mods, 17, buf),
.f7 => return tildeKey(ev.mods, 18, buf),
.f8 => return tildeKey(ev.mods, 19, buf),
.f9 => return tildeKey(ev.mods, 20, buf),
.f10 => return tildeKey(ev.mods, 21, buf),
.f11 => return tildeKey(ev.mods, 23, buf),
.f12 => return tildeKey(ev.mods, 24, buf),
}
}
fn copy(seq: []const u8, buf: []u8) []const u8 {
@memcpy(buf[0..seq.len], seq);
return buf[0..seq.len];
}
/// A single-byte key that Alt turns into ESC + byte. Ctrl is either baked
/// into `base` by the caller or meaningless for the key.
fn altable(mods: Mods, base: []const u8, buf: []u8) []const u8 {
var n: usize = 0;
if (mods.alt) {
buf[0] = 0x1b;
n = 1;
}
@memcpy(buf[n .. n + base.len], base);
return buf[0 .. n + base.len];
}
/// The two three-byte intro forms, which differ ONLY in the intro byte
/// and only while unmodified: modified, both spell the same CSI
/// parameter form (SS3 has nowhere to put a parameter).
fn introKey(mods: Mods, intro: u8, final: u8, buf: []u8) []const u8 {
if (mods.any()) return std.fmt.bufPrint(buf, "\x1b[1;{d}{c}", .{ mods.param(), final }) catch unreachable;
buf[0] = 0x1b;
buf[1] = intro;
buf[2] = final;
return buf[0..3];
}
/// CSI letter form: ESC [ <final>, or ESC [ 1 ; <mods> <final> when
/// modified.
fn cursorKey(mods: Mods, final: u8, buf: []u8) []const u8 {
return introKey(mods, '[', final, buf);
}
/// CSI tilde form: ESC [ <n> ~, or ESC [ <n> ; <mods> ~ when modified.
fn tildeKey(mods: Mods, n: u8, buf: []u8) []const u8 {
if (!mods.any()) return std.fmt.bufPrint(buf, "\x1b[{d}~", .{n}) catch unreachable;
return std.fmt.bufPrint(buf, "\x1b[{d};{d}~", .{ n, mods.param() }) catch unreachable;
}
/// SS3 has nowhere to put a parameter, so a modified F1-F4 goes as CSI.
fn ss3Key(mods: Mods, final: u8, buf: []u8) []const u8 {
return introKey(mods, 'O', final, buf);
}
pub const paste_begin = "\x1b[200~";
pub const paste_end = "\x1b[201~";
// ---------------------------------------------------------------------------
test "input: the key table" {
const cases = [_]struct { ev: Event, want: []const u8 }{
// Printable, plain and modified.
.{ .ev = .{ .key = .char, .cp = 'a' }, .want = "a" },
.{ .ev = .{ .key = .char, .cp = 'Z' }, .want = "Z" },
.{ .ev = .{ .key = .char, .cp = 'a', .mods = .{ .ctrl = true } }, .want = "\x01" },
.{ .ev = .{ .key = .char, .cp = 'z', .mods = .{ .ctrl = true } }, .want = "\x1a" },
.{ .ev = .{ .key = .char, .cp = 'C', .mods = .{ .ctrl = true } }, .want = "\x03" },
.{ .ev = .{ .key = .char, .cp = ' ', .mods = .{ .ctrl = true } }, .want = "\x00" },
.{ .ev = .{ .key = .char, .cp = '[', .mods = .{ .ctrl = true } }, .want = "\x1b" },
.{ .ev = .{ .key = .char, .cp = '_', .mods = .{ .ctrl = true } }, .want = "\x1f" },
.{ .ev = .{ .key = .char, .cp = 'x', .mods = .{ .alt = true } }, .want = "\x1bx" },
.{ .ev = .{ .key = .char, .cp = 'b', .mods = .{ .alt = true, .ctrl = true } }, .want = "\x1b\x02" },
// UTF-8 out, straight through.
.{ .ev = .{ .key = .char, .cp = 0x6f22 }, .want = "\xe6\xbc\xa2" }, // 漢
.{ .ev = .{ .key = .char, .cp = 0xe9 }, .want = "\xc3\xa9" }, // é
// The single-byte keys.
.{ .ev = .{ .key = .enter }, .want = "\r" },
.{ .ev = .{ .key = .enter, .mods = .{ .alt = true } }, .want = "\x1b\r" },
.{ .ev = .{ .key = .tab }, .want = "\t" },
.{ .ev = .{ .key = .tab, .mods = .{ .shift = true } }, .want = "\x1b[Z" },
.{ .ev = .{ .key = .backspace }, .want = "\x7f" },
.{ .ev = .{ .key = .backspace, .mods = .{ .ctrl = true } }, .want = "\x08" },
.{ .ev = .{ .key = .backspace, .mods = .{ .alt = true } }, .want = "\x1b\x7f" },
.{ .ev = .{ .key = .escape }, .want = "\x1b" },
// Cursor and nav keys, short forms.
.{ .ev = .{ .key = .up }, .want = "\x1b[A" },
.{ .ev = .{ .key = .down }, .want = "\x1b[B" },
.{ .ev = .{ .key = .right }, .want = "\x1b[C" },
.{ .ev = .{ .key = .left }, .want = "\x1b[D" },
.{ .ev = .{ .key = .home }, .want = "\x1b[H" },
.{ .ev = .{ .key = .end }, .want = "\x1b[F" },
.{ .ev = .{ .key = .insert }, .want = "\x1b[2~" },
.{ .ev = .{ .key = .delete }, .want = "\x1b[3~" },
.{ .ev = .{ .key = .page_up }, .want = "\x1b[5~" },
.{ .ev = .{ .key = .page_down }, .want = "\x1b[6~" },
// Function keys.
.{ .ev = .{ .key = .f1 }, .want = "\x1bOP" },
.{ .ev = .{ .key = .f4 }, .want = "\x1bOS" },
.{ .ev = .{ .key = .f5 }, .want = "\x1b[15~" },
.{ .ev = .{ .key = .f6 }, .want = "\x1b[17~" },
.{ .ev = .{ .key = .f10 }, .want = "\x1b[21~" },
.{ .ev = .{ .key = .f11 }, .want = "\x1b[23~" },
.{ .ev = .{ .key = .f12 }, .want = "\x1b[24~" },
// Modified CSI spot checks (the matrix test below is exhaustive).
.{ .ev = .{ .key = .up, .mods = .{ .ctrl = true } }, .want = "\x1b[1;5A" },
.{ .ev = .{ .key = .up, .mods = .{ .shift = true, .alt = true } }, .want = "\x1b[1;4A" },
.{ .ev = .{ .key = .delete, .mods = .{ .shift = true } }, .want = "\x1b[3;2~" },
.{ .ev = .{ .key = .f5, .mods = .{ .ctrl = true } }, .want = "\x1b[15;5~" },
.{ .ev = .{ .key = .f1, .mods = .{ .shift = true } }, .want = "\x1b[1;2P" },
// Nothing to send.
.{ .ev = .{ .key = .char, .cp = 0 }, .want = "" },
};
var buf: [max_seq_len]u8 = undefined;
for (cases) |case| {
const got = encode(case.ev, &buf);
std.testing.expectEqualSlices(u8, case.want, got) catch |err| {
std.debug.print("input: {any} -> {any}, want {any}\n", .{ case.ev, got, case.want });
return err;
};
}
}
test "input: modifier matrix over every CSI-carrying key" {
// param = 1 + shift + 2*alt + 4*ctrl; every modified form carries it,
// every unmodified form omits it. Exhaustive over the seven non-empty
// modifier combinations x every key with a CSI form.
const csi_keys = [_]struct { key: Key, mid: []const u8, tail: []const u8 }{
.{ .key = .up, .mid = "1;", .tail = "A" },
.{ .key = .down, .mid = "1;", .tail = "B" },
.{ .key = .right, .mid = "1;", .tail = "C" },
.{ .key = .left, .mid = "1;", .tail = "D" },
.{ .key = .home, .mid = "1;", .tail = "H" },
.{ .key = .end, .mid = "1;", .tail = "F" },
.{ .key = .insert, .mid = "2;", .tail = "~" },
.{ .key = .delete, .mid = "3;", .tail = "~" },
.{ .key = .page_up, .mid = "5;", .tail = "~" },
.{ .key = .page_down, .mid = "6;", .tail = "~" },
.{ .key = .f1, .mid = "1;", .tail = "P" },
.{ .key = .f2, .mid = "1;", .tail = "Q" },
.{ .key = .f3, .mid = "1;", .tail = "R" },
.{ .key = .f4, .mid = "1;", .tail = "S" },
.{ .key = .f5, .mid = "15;", .tail = "~" },
.{ .key = .f6, .mid = "17;", .tail = "~" },
.{ .key = .f7, .mid = "18;", .tail = "~" },
.{ .key = .f8, .mid = "19;", .tail = "~" },
.{ .key = .f9, .mid = "20;", .tail = "~" },
.{ .key = .f10, .mid = "21;", .tail = "~" },
.{ .key = .f11, .mid = "23;", .tail = "~" },
.{ .key = .f12, .mid = "24;", .tail = "~" },
};
var buf: [max_seq_len]u8 = undefined;
var want_buf: [max_seq_len]u8 = undefined;
for (csi_keys) |k| {
var m: u8 = 2;
while (m <= 8) : (m += 1) {
const mods = Mods{
.shift = (m - 1) & 1 != 0,
.alt = (m - 1) & 2 != 0,
.ctrl = (m - 1) & 4 != 0,
};
try std.testing.expectEqual(m, mods.param());
const want = try std.fmt.bufPrint(&want_buf, "\x1b[{s}{d}{s}", .{ k.mid, m, k.tail });
const got = encode(.{ .key = k.key, .mods = mods }, &buf);
try std.testing.expectEqualSlices(u8, want, got);
}
}
}
// Forces semantic analysis of every pub decl under `zig build test`, so an
// unreferenced decl must at least compile (the silent-module-loss hazard,
// decisions.md). Pub decls only: std.meta.declarations sees nothing private.
test {
std.testing.refAllDeclsRecursive(@This());
}