diff --git a/REFERENCE.md b/REFERENCE.md index 287c2c3..eab5b4a 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -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. diff --git a/src/input/keyboard.zig b/src/input/keyboard.zig index 42c8f6d..5cd0a35 100644 --- a/src/input/keyboard.zig +++ b/src/input/keyboard.zig @@ -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 @@ -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 }, }, @@ -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, diff --git a/tests/input_tests.zig b/tests/input_tests.zig index 9bb90d2..b69b254 100644 --- a/tests/input_tests.zig +++ b/tests/input_tests.zig @@ -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" { @@ -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();