Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1095,6 +1095,13 @@ Terminal replies that are not key presses (device attributes, cursor position
reports, OSC clipboard answers, Kitty graphics acknowledgements) are consumed
and discarded rather than surfacing as text.

Backspace has no single byte: most terminals send DEL (`0x7f`), xterm sends BS
(`0x08`) unless its `backarrowKey` resource is turned off. Both decode to
`.backspace`, so the key works everywhere without the application checking.
The cost is that a legacy encoding cannot distinguish Backspace from Ctrl+H —
they are the same byte. Bind Ctrl+H only where the Kitty keyboard protocol is
enabled, which reports it separately as `CSI 104;5u`.

For a buffer that is known to be complete — a test fixture, a recorded
session — `zz.input.keyboard.parseAll` decodes it in one call without any
retained state.
Expand Down
12 changes: 11 additions & 1 deletion src/input/keyboard.zig
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,13 @@ fn parseFramed(data: []const u8, frame_len: usize) StreamStep {
fn parseControl(c: u8) KeyEvent {
return switch (c) {
0 => .{ .key = .null_key, .modifiers = .{ .ctrl = true } },
// 0x08 is BS. Which byte Backspace sends is a terminal setting, not a
// standard: xterm sends BS unless `backarrowKey` is off, while most
// other terminals send DEL. A legacy encoding has no room to say which
// of Backspace and Ctrl+H was pressed, so BS is reported as the key
// people actually press. Terminals speaking the Kitty protocol send a
// real Ctrl+H as `CSI 104;5u`, which stays distinct.
8 => .{ .key = .backspace },
9 => .{ .key = .tab },
// In raw mode 0x0a is sent by the terminal itself
// Some editor-integrated terminals (Zed, possibly VSCode) use this
Expand All @@ -272,7 +279,7 @@ fn parseControl(c: u8) KeyEvent {
10 => .{ .key = .enter, .modifiers = .{ .shift = true } },
13 => .{ .key = .enter },
27 => .{ .key = .escape },
1...8, 11, 12, 14...26 => .{
1...7, 11, 12, 14...26 => .{
.key = .{ .char = 'a' + c - 1 },
.modifiers = .{ .ctrl = true },
},
Expand Down Expand Up @@ -424,6 +431,9 @@ fn parseKittyCsi(params: []const u16, sub_params: []const u16, has_colon: bool,

// Map keycode to Key
const key: Key = switch (keycode) {
// The protocol assigns Backspace the DEL keycode; 8 is accepted too
// for terminals that report the BS byte they would have sent.
8 => .backspace,
9 => .tab,
13 => .enter,
27 => .escape,
Expand Down
82 changes: 82 additions & 0 deletions tests/input_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,65 @@ test "Key parsing - backspace" {
const result = zz.input.keyboard.parse("\x7f");
try testing.expect(result.result == .key);
try testing.expect(result.result.key.key == .backspace);
try testing.expect(!result.result.key.modifiers.any());
}

// xterm sends BS rather than DEL for Backspace unless `backarrowKey` is
// turned off, so a parser that only knows DEL leaves the key dead there.
test "Key parsing - backspace sent as BS" {
const result = zz.input.keyboard.parse("\x08");
try testing.expect(result.result == .key);
try testing.expect(result.result.key.key == .backspace);
try testing.expect(!result.result.key.modifiers.any());
try testing.expectEqual(@as(usize, 1), result.consumed);
}

test "Key parsing - alt+backspace on both byte spellings" {
for ([_][]const u8{ "\x1b\x7f", "\x1b\x08" }) |seq| {
const result = zz.input.keyboard.parse(seq);
try testing.expect(result.result == .key);
try testing.expect(result.result.key.key == .backspace);
try testing.expect(result.result.key.modifiers.alt);
try testing.expect(!result.result.key.modifiers.ctrl);
try testing.expectEqual(@as(usize, 2), result.consumed);
}
}

// BS is the one control byte that is not Ctrl+letter; the rest of the range
// has to keep working.
test "Key parsing - control bytes around BS stay ctrl+letter" {
const ctrl_g = zz.input.keyboard.parse("\x07");
try testing.expect(ctrl_g.result.key.key.eql(.{ .char = 'g' }));
try testing.expect(ctrl_g.result.key.modifiers.ctrl);

const ctrl_i = zz.input.keyboard.parse("\x09");
try testing.expect(ctrl_i.result.key.key == .tab);
}

test "Key parsing - kitty backspace keycodes" {
for ([_][]const u8{ "\x1b[127u", "\x1b[8u" }) |seq| {
const result = zz.input.keyboard.parse(seq);
try testing.expect(result.result == .key);
try testing.expect(result.result.key.key == .backspace);
}

// A terminal in Kitty mode can still name Ctrl+H exactly.
const ctrl_h = zz.input.keyboard.parse("\x1b[104;5u");
try testing.expect(ctrl_h.result == .key);
try testing.expect(ctrl_h.result.key.key.eql(.{ .char = 'h' }));
try testing.expect(ctrl_h.result.key.modifiers.ctrl);
}

test "InputParser - BS backspace through the streaming decoder" {
const allocator = testing.allocator;
var parser: zz.InputParser = .{};

const events = try parser.feed(allocator, "ab\x08", 0);
defer allocator.free(events);

try testing.expectEqual(@as(usize, 3), events.len);
try testing.expect(events[2].key.key == .backspace);
try testing.expect(!events[2].key.modifiers.ctrl);
}

test "Key parsing - enter" {
Expand Down Expand Up @@ -177,6 +236,29 @@ test "TextArea accepts multi-character paste commits (IME-like)" {
try testing.expectEqualStrings("中文输入", value);
}

// End to end for issue #146: the byte an xterm actually sends has to reach the
// editing components as a delete, not as an unhandled Ctrl+H that they drop.
test "Editing components delete on the BS byte an xterm sends" {
const bs = zz.input.keyboard.parse("\x08").result.key;

var area = zz.TextArea.init(testing.allocator);
defer area.deinit();
try area.setValue("hello");
area.handleKey(.{ .key = .end });
area.handleKey(bs);

const value = try area.getValue(testing.allocator);
defer testing.allocator.free(value);
try testing.expectEqualStrings("hell", value);

var input = zz.TextInput.init(testing.allocator);
defer input.deinit();
try input.setValue("hello");
input.handleKey(.{ .key = .end });
input.handleKey(bs);
try testing.expectEqualStrings("hell", input.getValue());
}

test "TextArea keeps cursor on UTF-8 boundaries after vertical move" {
var area = zz.TextArea.init(testing.allocator);
defer area.deinit();
Expand Down
Loading