diff options
Diffstat (limited to 'src/tui_input.zig')
| -rw-r--r-- | src/tui_input.zig | 111 |
1 files changed, 100 insertions, 11 deletions
diff --git a/src/tui_input.zig b/src/tui_input.zig index 276b7e1..b05131c 100644 --- a/src/tui_input.zig +++ b/src/tui_input.zig @@ -19,7 +19,8 @@ //! Shift+Enter: in the bare legacy protocol Enter and Shift+Enter send the same //! byte (`\r`), so they're indistinguishable. To recover the distinction we //! negotiate at startup (see `negotiate_query`): we push the Kitty keyboard -//! protocol (flags 1|2|4) and query the terminal; if it confirms Kitty we use +//! protocol (flags 1|4 — disambiguate + report-alternates; NOT report-events, +//! see `enable_kitty_keyboard`) and query the terminal; if it confirms Kitty we use //! that, otherwise we fall back to xterm modifyOtherKeys mode 2. Either way: //! - Kitty-protocol terminals (ghostty/kitty/foot/…) send `\x1b[13;2u`. //! - xterm and tmux (which does NOT honor the Kitty push but DOES forward @@ -76,16 +77,25 @@ pub const paste_end = "\x1b[201~"; /// Kitty keyboard protocol: push a flags entry that asks for: /// flag 1 (0b001) disambiguate escape codes, -/// flag 2 (0b010) report event types (press/repeat/release), /// flag 4 (0b100) report alternate keys (shifted/base-layout key). -/// 1|2|4 = 7. This matches what pi requests. Flag 1 makes the terminal report -/// modified special keys — including Shift+Enter — as distinct CSI-u sequences -/// (`\x1b[13;2u`), which is exactly what we need; we deliberately do NOT set -/// flag 8 (report-all-keys), since that suppresses normal text input and forces -/// every printable key through the escape-sequence path. We push (not set) so -/// teardown can pop cleanly. Best-effort; unsupported terminals ignore it and -/// we fall back to modifyOtherKeys / legacy decoding. -pub const enable_kitty_keyboard = "\x1b[>7u"; +/// 1|4 = 5. Flag 1 makes the terminal report modified special keys — including +/// Shift+Enter — as distinct CSI-u sequences (`\x1b[13;2u`), which is exactly +/// what we need. +/// +/// We deliberately do NOT set flag 2 (report event types / press-repeat- +/// release). Nothing in the TUI consumes key-release events (no component sets +/// `Component.wantsKeyRelease`), and requesting them makes the terminal emit a +/// release sequence for every key. Under flag 2 a single arrow press produces +/// BOTH a press and a release event; since the editor treated each decoded +/// arrow as a motion, the cursor moved twice per physical press (once on key- +/// down, once on key-up). Dropping flag 2 removes the release events at the +/// source. `applyKey` still drops any `.release` it sees as defense-in-depth. +/// +/// We also do NOT set flag 8 (report-all-keys), since that suppresses normal +/// text input and forces every printable key through the escape-sequence path. +/// We push (not set) so teardown can pop cleanly. Best-effort; unsupported +/// terminals ignore it and we fall back to modifyOtherKeys / legacy decoding. +pub const enable_kitty_keyboard = "\x1b[>5u"; /// Query the terminal's currently-active Kitty flags: it replies `\x1b[?<n>u`. pub const query_kitty_flags = "\x1b[?u"; /// Primary Device Attributes query. Every terminal answers this (`\x1b[?...c`), @@ -109,7 +119,8 @@ pub const disable_modify_other_keys = "\x1b[>4;0m"; /// Startup negotiation to write once raw mode is engaged. We: /// 1. enable bracketed paste, -/// 2. push the Kitty flags we want (1|2|4), +/// 2. push the Kitty flags we want (1|4 = disambiguate + report-alternates; +/// deliberately NOT flag 2 report-events, see `enable_kitty_keyboard`), /// 3. query the now-active Kitty flags, then /// 4. send a Primary Device Attributes query as a sentinel. /// The app then reads the responses (`Decoded.negotiation`): a non-zero Kitty @@ -618,6 +629,67 @@ test "csi home/end and modified arrow" { try std.testing.expect(s.mods.ctrl); } +test "modified arrows: alt and ctrl, legacy CSI 1;<mods> form" { + // Alt+Left = CSI 1;3D (mods field 3 = 1 + alt) + const al = decodeOne("\x1b[1;3D").?.decoded.key; + try std.testing.expectEqual(KeyCode.left, al.code); + try std.testing.expect(al.mods.alt); + try std.testing.expect(!al.mods.ctrl); + // Alt+Right = CSI 1;3C + const ar = decodeOne("\x1b[1;3C").?.decoded.key; + try std.testing.expectEqual(KeyCode.right, ar.code); + try std.testing.expect(ar.mods.alt); + // Ctrl+Left = CSI 1;5D (tmux/modifyOtherKeys word-motion path) + const cl = decodeOne("\x1b[1;5D").?.decoded.key; + try std.testing.expectEqual(KeyCode.left, cl.code); + try std.testing.expect(cl.mods.ctrl); + try std.testing.expect(!cl.mods.alt); +} + +test "modified arrows: kitty functional CSU-u form (alt/ctrl)" { + // Some terminals (Kitty/Ghostty under disambiguate) report arrows as the + // functional-key codepoints 57350..57353 with a `;<mods>` field. + // Alt+Left = CSI 57350 ; 3 u + const al = decodeOne("\x1b[57350;3u").?.decoded.key; + try std.testing.expectEqual(KeyCode.left, al.code); + try std.testing.expect(al.mods.alt); + // Ctrl+Right = CSI 57351 ; 5 u + const cr = decodeOne("\x1b[57351;5u").?.decoded.key; + try std.testing.expectEqual(KeyCode.right, cr.code); + try std.testing.expect(cr.mods.ctrl); + // Plain functional Left = CSI 57350 u (no mods). + const pl = decodeOne("\x1b[57350u").?.decoded.key; + try std.testing.expectEqual(KeyCode.left, pl.code); + try std.testing.expect(!pl.mods.any()); + try std.testing.expectEqual(key.KeyEvent.press, pl.event); +} + +test "modified arrows: ESC-prefixed alt forms" { + // A terminal can emit alt+arrow as ESC + the arrow sequence. The recursive + // alt-prefix path in decodeEscape must OR in alt. + // ESC ESC [ D (alt + legacy left) + const a1 = decodeOne("\x1b\x1b[D").?.decoded.key; + try std.testing.expectEqual(KeyCode.left, a1.code); + try std.testing.expect(a1.mods.alt); + // ESC ESC O C (alt + SS3 right) + const a2 = decodeOne("\x1b\x1bOC").?.decoded.key; + try std.testing.expectEqual(KeyCode.right, a2.code); + try std.testing.expect(a2.mods.alt); +} + +test "arrow release events are tagged .release (so the editor can drop them)" { + // Even though we no longer REQUEST release events (flag 2 dropped), the + // decoder must still classify a release form correctly if one arrives, so + // applyKey's release guard is effective. Functional Left release: + // CSI 57350 ; 1 : 3 u (mods field 1 = none, event 3 = release) + const rel = decodeOne("\x1b[57350;1:3u").?.decoded.key; + try std.testing.expectEqual(KeyCode.left, rel.code); + try std.testing.expectEqual(key.KeyEvent.release, rel.event); + // A press (event 1) is a press. + const pre = decodeOne("\x1b[57350;1:1u").?.decoded.key; + try std.testing.expectEqual(key.KeyEvent.press, pre.event); +} + 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); @@ -657,6 +729,22 @@ test "kitty release event" { try std.testing.expectEqual(key.KeyEvent.release, s.event); } +test "negotiate_query pushes Kitty flags >5u (1|4, NOT report-events flag 2)" { + // Root-cause regression guard for the arrow double-move bug: we must NOT + // request flag 2 (report event types), or the terminal emits a key-RELEASE + // for every press and arrows move twice. The pushed flag set is + // disambiguate (1) | report-alternates (4) = 5, encoded as `\x1b[>5u`. + try std.testing.expectEqualStrings("\x1b[>5u", enable_kitty_keyboard); + // It must appear verbatim inside the startup negotiation string. + try std.testing.expect(std.mem.indexOf(u8, negotiate_query, "\x1b[>5u") != null); + // And the report-events form (>7u, i.e. 1|2|4) must NOT be pushed. + try std.testing.expect(std.mem.indexOf(u8, negotiate_query, "\x1b[>7u") == null); + // Sanity: the numeric flag set is exactly 1|4 with bit 2 (0b010) clear. + const flags: u8 = 1 | 4; + try std.testing.expectEqual(@as(u8, 5), flags); + try std.testing.expectEqual(@as(u8, 0), flags & 2); +} + test "negotiation: kitty flags reply" { const s = decodeOne("\x1b[?7u").?; try std.testing.expectEqual(@as(usize, 5), s.consumed); @@ -800,3 +888,4 @@ test "splitter: batched read yields individual keys" { off += s3.consumed; try std.testing.expectEqual(input.len, off); } + |
