Skip to main content

lattice_ui_tui/
chord.rs

1//! Crossterm → `KeyChord` adapter for the TUI renderer.
2//!
3//! Phase 5.4 split: the renderer-neutral chord types (`KeyChord`,
4//! `KeyKind`, `SpecialKey`, `KeyMods`, `ChordParseError`,
5//! `parse_chord_sequence`, `last_chord_token_byte_len`, and the
6//! `FromStr` / `Display` impls) live in
7//! [`lattice_host::chord`]. This module is the TUI-side adapter
8//! that turns a `crossterm::KeyEvent` into the canonical
9//! [`KeyChord`] the keymap trie indexes by.
10//!
11//! `from_event` (the canonical crossterm-side entry) lives here
12//! as a **free function** rather than an `impl KeyChord` method
13//! because orphan rules forbid extending a foreign type from
14//! this crate. Existing call sites change from
15//! `KeyChord::from_event(&ev)` to `chord::from_event(&ev)`; the
16//! re-export below means `crate::chord::KeyChord` (and every
17//! other neutral type) still resolves unchanged.
18//!
19//! The future `lattice-ui-gpui` ships its own analogous
20//! `gpui_chord::from_event(&GpuiKeyEvent) -> Option<KeyChord>`
21//! adapter; both renderers feed the same `KeyChord` into
22//! `lattice_host`'s dispatch.
23
24// Re-export every neutral type from the host. Callers that
25// import `crate::chord::KeyChord` etc. continue to resolve
26// without source changes.
27pub use lattice_host::chord::*;
28
29use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
30
31/// Normalise a `crossterm::KeyEvent` into a canonical
32/// [`KeyChord`]. Returns `None` for events that have no chord
33/// representation (release events on terminals that emit them,
34/// modifier-only presses, key codes we don't recognise).
35///
36/// Normalisation rules (canonical form that the keymap trie
37/// indexes by):
38///
39/// - **Letters with Ctrl / Alt**: case is folded to lowercase
40///   so `Ctrl-c` and `Ctrl-C` map to the same chord.
41/// - **Letters without modifiers**: case is preserved (vim's
42///   `A` is uppercase a, distinct from `a`).
43/// - **Letters with shift only**: shift is folded into the
44///   case (the terminal already uppercased the letter; we
45///   strip the redundant `KeyMods::SHIFT`). `Shift-a` and `A`
46///   collapse.
47/// - **Non-letter chars**: shift is stripped (the terminal
48///   reports the shifted symbol, e.g. `$` for shift-4; the
49///   modifier would be redundant).
50/// - **Specials with shift**: shift is preserved (`<S-Tab>` is
51///   distinct from `<Tab>`).
52/// - **`KeyCode::BackTab`**: canonicalised to `Special(Tab) +
53///   KeyMods::SHIFT` so the keymap trie has one entry rather
54///   than two for "shift-tab".
55pub fn from_event(event: &KeyEvent) -> Option<KeyChord> {
56    let mut mods = KeyMods::NONE;
57    if event.modifiers.contains(KeyModifiers::CONTROL) {
58        mods = mods | KeyMods::CTRL;
59    }
60    if event.modifiers.contains(KeyModifiers::SHIFT) {
61        mods = mods | KeyMods::SHIFT;
62    }
63    if event.modifiers.contains(KeyModifiers::ALT) {
64        mods = mods | KeyMods::ALT;
65    }
66    if event.modifiers.contains(KeyModifiers::SUPER) {
67        mods = mods | KeyMods::SUPER;
68    }
69
70    let key = match event.code {
71        KeyCode::Esc => KeyKind::Special(SpecialKey::Esc),
72        KeyCode::Enter => KeyKind::Special(SpecialKey::Enter),
73        KeyCode::Tab => KeyKind::Special(SpecialKey::Tab),
74        KeyCode::BackTab => {
75            // BackTab IS shift-tab; canonicalise.
76            mods = mods | KeyMods::SHIFT;
77            KeyKind::Special(SpecialKey::Tab)
78        }
79        KeyCode::Backspace => KeyKind::Special(SpecialKey::Backspace),
80        KeyCode::Up => KeyKind::Special(SpecialKey::Up),
81        KeyCode::Down => KeyKind::Special(SpecialKey::Down),
82        KeyCode::Left => KeyKind::Special(SpecialKey::Left),
83        KeyCode::Right => KeyKind::Special(SpecialKey::Right),
84        KeyCode::Home => KeyKind::Special(SpecialKey::Home),
85        KeyCode::End => KeyKind::Special(SpecialKey::End),
86        KeyCode::PageUp => KeyKind::Special(SpecialKey::PageUp),
87        KeyCode::PageDown => KeyKind::Special(SpecialKey::PageDown),
88        KeyCode::Insert => KeyKind::Special(SpecialKey::Insert),
89        KeyCode::Delete => KeyKind::Special(SpecialKey::Delete),
90        KeyCode::F(n) if (1..=24).contains(&n) => KeyKind::Special(SpecialKey::F(n)),
91        // A space carrying ANY modifier is `Special::Space`; a bare one is a
92        // literal char.
93        //
94        // **This was described in a comment here and never implemented**, and
95        // the gap is why a documented `<C-Space>` binding did nothing:
96        // `parse_chord_sequence("<C-Space>")` yields `Special(Space) + CTRL`,
97        // a real Ctrl+Space arrived as `Char(' ') + CTRL`, and the trie lookup
98        // missed in silence. The GPUI peer has always had the rule right
99        // (`gpui_chord.rs`: it collapses to a char only when `mods.is_empty()`),
100        // so this was a renderer-parity break too.
101        //
102        // Matched before the general `Char` arm rather than inside it, because
103        // that arm's own first branch read as though it did this and did not —
104        // the shape that hid the bug in the first place.
105        KeyCode::Char(' ') if mods == KeyMods::NONE => KeyKind::Char(' '),
106        KeyCode::Char(' ') => KeyKind::Special(SpecialKey::Space),
107        KeyCode::Char(c) => {
108            let ctrl_or_alt = mods.ctrl() || mods.alt();
109            if ctrl_or_alt && c.is_ascii_alphabetic() {
110                // Ctrl / Alt + letter normalises to lowercase.
111                // Shift on a ctrl-letter is preserved (`<C-S-c>`
112                // stays distinct from `<C-c>`).
113                KeyKind::Char(c.to_ascii_lowercase())
114            } else if !ctrl_or_alt {
115                // Bare or shift-only printable. Strip shift --
116                // the terminal already encoded it in the case
117                // (for letters) or in the shifted symbol (for
118                // non-letters).
119                if mods.shift() {
120                    mods = mods.without(KeyMods::SHIFT);
121                }
122                KeyKind::Char(c)
123            } else {
124                KeyKind::Char(c)
125            }
126        }
127        _ => return None,
128    };
129
130    // Specials don't strip shift (it's meaningful for `<S-Tab>`,
131    // `<S-F1>`, etc.); the strip-on-bare-printable logic above
132    // handles only `KeyKind::Char`.
133    Some(KeyChord::new(key, mods))
134}
135
136/// Render a single crossterm key event as canonical chord
137/// notation. Returns `None` for events that have no chord
138/// representation. Thin shim over `from_event` + `to_string`.
139pub fn format_chord(event: &KeyEvent) -> Option<String> {
140    from_event(event).map(|c| c.to_string())
141}
142
143#[cfg(test)]
144mod tests {
145    #![allow(clippy::unwrap_used)]
146    use super::*;
147    use crossterm::event::{KeyEventKind, KeyEventState};
148
149    fn ev(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
150        KeyEvent {
151            code,
152            modifiers: mods,
153            kind: KeyEventKind::Press,
154            state: KeyEventState::NONE,
155        }
156    }
157
158    /// OS.1 guard, not driver. `DISAMBIGUATE_ESCAPE_CODES` changes how Esc and
159    /// the C0 controls ARRIVE — crossterm starts reporting `Kind::Press`
160    /// explicitly and may carry state bits — so the risk of pushing the flags
161    /// is that an existing chord decodes differently afterwards. The adapter
162    /// must ignore both fields; this pins that it does.
163    #[test]
164    fn disambiguated_events_decode_to_the_same_chords() {
165        for (code, mods) in [
166            (KeyCode::Esc, KeyModifiers::NONE),
167            (KeyCode::Char('c'), KeyModifiers::CONTROL),
168            (KeyCode::Enter, KeyModifiers::NONE),
169            (KeyCode::Tab, KeyModifiers::SHIFT),
170        ] {
171            let plain = KeyEvent::new(code, mods);
172            let disambiguated = KeyEvent {
173                kind: KeyEventKind::Press,
174                state: KeyEventState::NONE,
175                ..plain
176            };
177            assert_eq!(
178                from_event(&plain),
179                from_event(&disambiguated),
180                "{code:?}+{mods:?} must decode identically under disambiguation"
181            );
182        }
183    }
184
185    /// The adapter has ALWAYS been able to express this; it is the terminal
186    /// that could not send it. So this passes before OS.1 and after — it is
187    /// here to prove the chord OS.1 makes reachable is a distinct one, i.e.
188    /// that pushing the flags actually buys something.
189    #[test]
190    fn shift_enter_is_a_distinct_chord_from_enter() {
191        let enter = from_event(&ev(KeyCode::Enter, KeyModifiers::NONE));
192        let s_enter = from_event(&ev(KeyCode::Enter, KeyModifiers::SHIFT));
193        assert_ne!(enter, s_enter);
194        assert_eq!(s_enter.unwrap().to_string(), "<S-CR>");
195    }
196
197    /// **A modified space must promote to `SpecialKey::Space`.**
198    ///
199    /// This is the half of the round trip that decides whether a binding can
200    /// ever fire. `parse_chord_sequence("<C-Space>")` yields
201    /// `Special(Space) + CTRL`, so a real keypress that converts to
202    /// `Char(' ') + CTRL` cannot match it and the trie lookup silently misses —
203    /// which is exactly how org's documented `<C-Space>` checkbox toggle did
204    /// nothing while its own tests passed (they call `parse_chord_sequence` on
205    /// both sides and never reach this function).
206    ///
207    /// GPUI already gets this right (`gpui_chord.rs`, "if it carries a modifier
208    /// it stays a `SpecialKey::Space`"), so this was a renderer-parity break as
209    /// well as a bug.
210    #[test]
211    fn ctrl_space_promotes_to_the_special_key_the_parser_produces() {
212        let chord = from_event(&ev(KeyCode::Char(' '), KeyModifiers::CONTROL)).unwrap();
213        assert_eq!(chord.key, KeyKind::Special(SpecialKey::Space));
214        assert!(chord.mods.ctrl());
215    }
216
217    /// The round trip is what actually matters, so assert it directly against
218    /// the parser rather than against a hand-written expectation: whatever
219    /// `<C-Space>` means, a real Ctrl+Space must mean the same thing.
220    #[test]
221    fn a_real_ctrl_space_matches_what_the_binding_string_parses_to() {
222        let parsed = lattice_protocol::parse_chord_sequence("<C-Space>").expect("parses");
223        let pressed = from_event(&ev(KeyCode::Char(' '), KeyModifiers::CONTROL)).unwrap();
224        assert_eq!(parsed.as_slice(), &[pressed]);
225    }
226
227    /// **The same round trip for a BARE space, which is the leader key.**
228    ///
229    /// `keymap.leader` defaults to `<Space>` and `<leader>` is substituted at
230    /// BIND time, so every `<leader>x` binding in the editor lands under
231    /// whatever `<Space>` parses to. When the parser said `Special(Space)` and
232    /// the keyboard said `Char(' ')`, the entire leader keymap was unreachable
233    /// — `<Space>oa` did nothing, and so did every other leader chord.
234    ///
235    /// Asserted as an equivalence rather than against a literal, so the two
236    /// sides cannot drift apart again in either direction.
237    #[test]
238    fn a_real_bare_space_matches_what_the_leader_string_parses_to() {
239        let parsed = lattice_protocol::parse_chord_sequence("<Space>").expect("parses");
240        let pressed = from_event(&ev(KeyCode::Char(' '), KeyModifiers::NONE)).unwrap();
241        assert_eq!(parsed.as_slice(), &[pressed]);
242    }
243
244    /// And the whole leader sequence, since that is what a user actually types.
245    #[test]
246    fn a_leader_chord_matches_the_keys_that_produce_it() {
247        let parsed = lattice_protocol::parse_chord_sequence("<Space>oa").expect("parses");
248        let typed: Vec<_> = [
249            ev(KeyCode::Char(' '), KeyModifiers::NONE),
250            ev(KeyCode::Char('o'), KeyModifiers::NONE),
251            ev(KeyCode::Char('a'), KeyModifiers::NONE),
252        ]
253        .iter()
254        .map(|e| from_event(e).unwrap())
255        .collect();
256        assert_eq!(parsed, typed);
257    }
258
259    /// Alt too — the rule is "any modifier", not "control".
260    #[test]
261    fn alt_space_promotes_as_well() {
262        let chord = from_event(&ev(KeyCode::Char(' '), KeyModifiers::ALT)).unwrap();
263        assert_eq!(chord.key, KeyKind::Special(SpecialKey::Space));
264    }
265
266    /// And an UNMODIFIED space stays a literal char, because that is what
267    /// typing a space in Insert mode has to be. Promoting it would break every
268    /// space a user types, which is a far worse bug than the one being fixed.
269    #[test]
270    fn a_bare_space_stays_a_literal_char() {
271        let chord = from_event(&ev(KeyCode::Char(' '), KeyModifiers::NONE)).unwrap();
272        assert_eq!(chord.key, KeyKind::Char(' '));
273    }
274
275    #[test]
276    fn plain_char_renders_unwrapped() {
277        assert_eq!(
278            format_chord(&ev(KeyCode::Char('a'), KeyModifiers::NONE)),
279            Some("a".into())
280        );
281        assert_eq!(
282            format_chord(&ev(KeyCode::Char('$'), KeyModifiers::NONE)),
283            Some("$".into())
284        );
285    }
286
287    #[test]
288    fn ctrl_letter_renders_with_c_prefix_lowercase() {
289        // Ctrl-c -- key terminals may report 'c' or 'C'; either
290        // way the output normalises to lowercase.
291        assert_eq!(
292            format_chord(&ev(KeyCode::Char('c'), KeyModifiers::CONTROL)),
293            Some("<C-c>".into())
294        );
295        assert_eq!(
296            format_chord(&ev(KeyCode::Char('C'), KeyModifiers::CONTROL)),
297            Some("<C-c>".into())
298        );
299    }
300
301    #[test]
302    fn alt_letter_renders_with_m_prefix() {
303        assert_eq!(
304            format_chord(&ev(KeyCode::Char('x'), KeyModifiers::ALT)),
305            Some("<M-x>".into())
306        );
307    }
308
309    #[test]
310    fn ctrl_shift_letter_canonical_order() {
311        assert_eq!(
312            format_chord(&ev(
313                KeyCode::Char('c'),
314                KeyModifiers::CONTROL | KeyModifiers::SHIFT
315            )),
316            Some("<C-S-c>".into())
317        );
318    }
319
320    #[test]
321    fn special_keys_render_with_canonical_names() {
322        assert_eq!(
323            format_chord(&ev(KeyCode::Esc, KeyModifiers::NONE)),
324            Some("<Esc>".into())
325        );
326        assert_eq!(
327            format_chord(&ev(KeyCode::Tab, KeyModifiers::NONE)),
328            Some("<Tab>".into())
329        );
330        assert_eq!(
331            format_chord(&ev(KeyCode::Enter, KeyModifiers::NONE)),
332            Some("<CR>".into())
333        );
334        assert_eq!(
335            format_chord(&ev(KeyCode::Backspace, KeyModifiers::NONE)),
336            Some("<BS>".into())
337        );
338        assert_eq!(
339            format_chord(&ev(KeyCode::Left, KeyModifiers::NONE)),
340            Some("<Left>".into())
341        );
342    }
343
344    #[test]
345    fn ctrl_special_key_carries_modifier() {
346        assert_eq!(
347            format_chord(&ev(KeyCode::Up, KeyModifiers::CONTROL)),
348            Some("<C-Up>".into())
349        );
350    }
351
352    #[test]
353    fn back_tab_encodes_shift_without_double_prefix() {
354        assert_eq!(
355            format_chord(&ev(KeyCode::BackTab, KeyModifiers::SHIFT)),
356            Some("<S-Tab>".into())
357        );
358    }
359
360    #[test]
361    fn function_keys_render_as_fn() {
362        assert_eq!(
363            format_chord(&ev(KeyCode::F(1), KeyModifiers::NONE)),
364            Some("<F1>".into())
365        );
366        assert_eq!(
367            format_chord(&ev(KeyCode::F(12), KeyModifiers::NONE)),
368            Some("<F12>".into())
369        );
370    }
371
372    #[test]
373    fn literal_lt_escapes_as_lt_token() {
374        assert_eq!(
375            format_chord(&ev(KeyCode::Char('<'), KeyModifiers::NONE)),
376            Some("<lt>".into())
377        );
378    }
379
380    #[test]
381    fn from_event_normalises_ctrl_letter_lowercase() {
382        let lower = from_event(&ev(KeyCode::Char('c'), KeyModifiers::CONTROL)).expect("ctrl-c");
383        let upper = from_event(&ev(KeyCode::Char('C'), KeyModifiers::CONTROL)).expect("ctrl-C");
384        assert_eq!(lower, upper);
385        assert_eq!(lower, KeyChord::ctrl('c'));
386    }
387
388    #[test]
389    fn from_event_strips_redundant_shift_on_bare_letter() {
390        // Terminal reports `Char('A') + SHIFT`; canonical form
391        // is just `Char('A')` (case encodes shift).
392        let chord = from_event(&ev(KeyCode::Char('A'), KeyModifiers::SHIFT)).expect("shift-A");
393        assert_eq!(chord, KeyChord::char('A'));
394        assert!(!chord.mods.shift());
395    }
396
397    #[test]
398    fn from_event_keeps_shift_on_special_keys() {
399        let stab = from_event(&ev(KeyCode::Tab, KeyModifiers::SHIFT)).expect("shift-tab");
400        assert_eq!(stab.key, KeyKind::Special(SpecialKey::Tab));
401        assert!(stab.mods.shift());
402        let sf1 = from_event(&ev(KeyCode::F(1), KeyModifiers::SHIFT)).expect("shift-F1");
403        assert!(sf1.mods.shift());
404    }
405
406    #[test]
407    fn from_event_canonicalises_back_tab_to_tab_plus_shift() {
408        let chord = from_event(&ev(KeyCode::BackTab, KeyModifiers::NONE)).expect("back-tab");
409        assert_eq!(chord.key, KeyKind::Special(SpecialKey::Tab));
410        assert!(chord.mods.shift());
411    }
412
413    #[test]
414    fn keyevent_to_keychord_to_string_matches_format_chord() {
415        // The shim and the typed path produce the same string
416        // by construction; this test guards against regression
417        // if the shim diverges.
418        let cases = &[
419            ev(KeyCode::Char('a'), KeyModifiers::NONE),
420            ev(KeyCode::Char('A'), KeyModifiers::NONE),
421            ev(KeyCode::Char('c'), KeyModifiers::CONTROL),
422            ev(
423                KeyCode::Char('c'),
424                KeyModifiers::CONTROL | KeyModifiers::SHIFT,
425            ),
426            ev(KeyCode::Char('x'), KeyModifiers::ALT),
427            ev(KeyCode::Esc, KeyModifiers::NONE),
428            ev(KeyCode::Tab, KeyModifiers::NONE),
429            ev(KeyCode::BackTab, KeyModifiers::NONE),
430            ev(KeyCode::Tab, KeyModifiers::SHIFT),
431            ev(KeyCode::F(7), KeyModifiers::NONE),
432            ev(KeyCode::Char('<'), KeyModifiers::NONE),
433        ];
434        for e in cases {
435            let via_shim = format_chord(e);
436            let via_typed = from_event(e).map(|c| c.to_string());
437            assert_eq!(via_shim, via_typed, "mismatch for {e:?}");
438        }
439    }
440}