summaryrefslogtreecommitdiff
path: root/src/tui_input.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-08 14:48:33 -0600
committert <t@tjp.lol>2026-06-08 19:56:47 -0600
commit104001d25c9c8cb5ec45ced1678f7c7b70888808 (patch)
treee0fd9d5c89e953dd9db9253b87be8426e56d63f1 /src/tui_input.zig
parentb5eb3f1776a540a55d5675f786a4421c49a6283d (diff)
keybinding fixes
Diffstat (limited to 'src/tui_input.zig')
-rw-r--r--src/tui_input.zig111
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);
}
+