Skip to main content

lattice_ui_gpui/
gpui_chord.rs

1//! GPUI `Keystroke` → [`KeyChord`] adapter.
2//!
3//! Phase 5.7.B.3: mirrors the role of `lattice-ui-tui::chord`
4//! for the crossterm side — turns the renderer's native key
5//! event shape into the canonical, renderer-neutral
6//! [`KeyChord`] that the host's keymap trie + translate path
7//! both consume.
8//!
9//! ## Why a string-typed adapter (no gpui dep)
10//!
11//! The lib of this crate must build (and its tests must run)
12//! in headless CI without the `window` Cargo feature so the
13//! host-substrate-reusable claim from 5.7's scaffold slice
14//! remains provable on every host. Taking a `&gpui::Keystroke`
15//! would link the whole `lattice-ui-gpui` lib against
16//! `gpui = "0.2.2"` and the X11 / Wayland / Cocoa / Windows
17//! display libs it pulls in transitively.
18//!
19//! Instead [`from_keystroke`] takes the **shape** of GPUI's
20//! `Keystroke` as primitives: `key: &str` for the key id,
21//! plus four `bool`s for the modifier set. The binary's GPUI
22//! event handler is the thin glue that destructures
23//! `KeyDownEvent.keystroke` into those primitives and calls
24//! this adapter; the adapter itself is pure data.
25//!
26//! ## Normalisation rules
27//!
28//! Match what `lattice-ui-tui::chord::from_event` produces so
29//! the keymap trie sees identical [`KeyChord`]s regardless of
30//! which renderer originated the keystroke:
31//!
32//! - **Letters with Ctrl / Alt**: case folded to lowercase
33//!   (`Ctrl-C` and `Ctrl-c` collapse).
34//! - **Letters without modifiers**: case preserved (`a` and
35//!   `A` are distinct chords by vim convention).
36//! - **Letters with shift only**: shift folded into the case
37//!   when GPUI reports the lowercase letter (some backends
38//!   do); the redundant `KeyMods::SHIFT` is stripped.
39//! - **Non-letter chars with shift only**: shift stripped (the
40//!   key string already encodes the shifted symbol —
41//!   `Shift-4` arrives as `"$"`).
42//! - **Specials with shift**: shift preserved (`<S-Tab>` is
43//!   distinct from `<Tab>`).
44//! - **Space**: bare space becomes `KeyKind::Char(' ')`; with
45//!   a modifier (`<C-Space>`) it stays a [`SpecialKey::Space`]
46//!   so the trie has a distinct entry. GPUI's `"space"` key
47//!   string is accepted alongside the literal `" "` char.
48//!
49//! Key-string vocabulary (case-insensitive, follows GPUI's
50//! lowercase convention):
51//!
52//! - Specials: `"escape" | "esc"`, `"enter" | "return"`,
53//!   `"tab"`, `"backspace"`, `"space"`, `"up"`, `"down"`,
54//!   `"left"`, `"right"`, `"home"`, `"end"`, `"pageup"`,
55//!   `"pagedown"`, `"insert"`, `"delete"`.
56//! - Function keys: `"f1"`..`"f24"` (out-of-range returns
57//!   `None`).
58//! - Anything else of length 1 is treated as a printable
59//!   character; longer strings that don't match a special key
60//!   id return `None` (GPUI may report compound names this
61//!   adapter doesn't recognise yet).
62
63use lattice_host::chord::{KeyChord, KeyKind, KeyMods, SpecialKey};
64
65/// Normalise a GPUI [`Keystroke`]-shaped input into a canonical
66/// [`KeyChord`].
67///
68/// `key` is GPUI's `Keystroke::key` string id (lowercase by
69/// convention, but matched case-insensitively for robustness).
70/// `control` / `alt` / `shift` / `platform` are the four
71/// modifier bits of `Keystroke::modifiers`; `platform` maps to
72/// [`KeyMods::SUPER`] so it joins the same modifier bitfield
73/// the rest of the host uses.
74///
75/// Returns `None` for inputs with no chord representation:
76/// empty key string, multi-character key strings that aren't
77/// recognised special names, or `"f"`-prefixed function keys
78/// outside the `1..=24` range.
79///
80/// [`Keystroke`]: https://docs.rs/gpui/latest/gpui/struct.Keystroke.html
81pub fn from_keystroke(
82    key: &str,
83    control: bool,
84    alt: bool,
85    shift: bool,
86    platform: bool,
87) -> Option<KeyChord> {
88    let mut mods = KeyMods::NONE;
89    if control {
90        mods = mods | KeyMods::CTRL;
91    }
92    if shift {
93        mods = mods | KeyMods::SHIFT;
94    }
95    if alt {
96        mods = mods | KeyMods::ALT;
97    }
98    if platform {
99        mods = mods | KeyMods::SUPER;
100    }
101
102    if key.is_empty() {
103        return None;
104    }
105
106    // Special-key id match first (case-insensitive). GPUI uses
107    // lowercase by convention but the adapter is tolerant.
108    let lower = key.to_ascii_lowercase();
109    let special = match lower.as_str() {
110        "escape" | "esc" => Some(SpecialKey::Esc),
111        "enter" | "return" => Some(SpecialKey::Enter),
112        "tab" => Some(SpecialKey::Tab),
113        "backspace" => Some(SpecialKey::Backspace),
114        "space" => Some(SpecialKey::Space),
115        "up" => Some(SpecialKey::Up),
116        "down" => Some(SpecialKey::Down),
117        "left" => Some(SpecialKey::Left),
118        "right" => Some(SpecialKey::Right),
119        "home" => Some(SpecialKey::Home),
120        "end" => Some(SpecialKey::End),
121        "pageup" => Some(SpecialKey::PageUp),
122        "pagedown" => Some(SpecialKey::PageDown),
123        "insert" => Some(SpecialKey::Insert),
124        "delete" => Some(SpecialKey::Delete),
125        s if s.starts_with('f') && s.len() >= 2 && s.len() <= 3 => s[1..]
126            .parse::<u8>()
127            .ok()
128            .filter(|&n| (1..=24).contains(&n))
129            .map(SpecialKey::F),
130        _ => None,
131    };
132
133    if let Some(sk) = special {
134        // Specials preserve shift -- `<S-Tab>` is distinct from
135        // `<Tab>`. Other modifiers ride through unchanged.
136        // Bare space collapses to `Char(' ')` so the keymap
137        // trie sees a single canonical entry shared with TUI's
138        // adapter; with any modifier it stays the Special form.
139        if matches!(sk, SpecialKey::Space) && mods.is_empty() {
140            return Some(KeyChord::new(KeyKind::Char(' '), mods));
141        }
142        return Some(KeyChord::new(KeyKind::Special(sk), mods));
143    }
144
145    // Issue (2026-05-22): GPUI reports symbolic keys by NAME,
146    // not by literal character. `<C-w>=` arrived as
147    // `key="equal"` which previously fell into the
148    // "multi-char unrecognised" early-return below, dropping
149    // the entire chord. The same affected `+`/`-`/`>`/`<` and
150    // every other symbolic chord (`:` etc.). Map common names
151    // to their literal char form so the keymap trie (which
152    // binds `lit_char('=')`) finds them.
153    let symbol_char: Option<char> = match lower.as_str() {
154        "equal" => Some('='),
155        "plus" => Some('+'),
156        "minus" | "hyphen" => Some('-'),
157        "greater" | "greaterthan" => Some('>'),
158        "less" | "lessthan" => Some('<'),
159        "comma" => Some(','),
160        "period" | "dot" => Some('.'),
161        "slash" => Some('/'),
162        "backslash" => Some('\\'),
163        "semicolon" => Some(';'),
164        "colon" => Some(':'),
165        "apostrophe" | "quote" => Some('\''),
166        "doublequote" | "quotedbl" => Some('"'),
167        "grave" | "backtick" => Some('`'),
168        "tilde" => Some('~'),
169        "bracketleft" | "leftbracket" => Some('['),
170        "bracketright" | "rightbracket" => Some(']'),
171        "braceleft" | "leftbrace" => Some('{'),
172        "braceright" | "rightbrace" => Some('}'),
173        "parenleft" | "leftparen" => Some('('),
174        "parenright" | "rightparen" => Some(')'),
175        "exclam" | "exclamation" => Some('!'),
176        "at" => Some('@'),
177        "numbersign" | "hash" => Some('#'),
178        "dollar" => Some('$'),
179        "percent" => Some('%'),
180        "asciicircum" | "caret" => Some('^'),
181        "ampersand" => Some('&'),
182        "asterisk" | "star" => Some('*'),
183        "underscore" => Some('_'),
184        "bar" | "pipe" => Some('|'),
185        "question" => Some('?'),
186        _ => None,
187    };
188    if let Some(c) = symbol_char {
189        // Apply the same shift-stripping rule as literal chars
190        // — the name already encodes the shifted form
191        // (`plus` = shift+equal, `greater` = shift+period).
192        if !mods.ctrl() && !mods.alt() && mods.shift() {
193            mods = mods.without(KeyMods::SHIFT);
194        }
195        return Some(KeyChord::new(KeyKind::Char(c), mods));
196    }
197
198    // Printable character. Must be exactly one char.
199    let mut chars = key.chars();
200    let c = chars.next()?;
201    if chars.next().is_some() {
202        // Multi-char key string we don't recognise as special.
203        return None;
204    }
205
206    let ctrl_or_alt = mods.ctrl() || mods.alt();
207    let key_kind = if c == ' ' && !ctrl_or_alt {
208        KeyKind::Char(' ')
209    } else if ctrl_or_alt && c.is_ascii_alphabetic() {
210        // Ctrl / Alt + letter normalises to lowercase.
211        // Shift on a ctrl-letter is preserved.
212        KeyKind::Char(c.to_ascii_lowercase())
213    } else if !ctrl_or_alt && mods.shift() && c.is_ascii_lowercase() {
214        // Shift + bare lowercase letter: GPUI may report the
215        // unshifted letter when shift is held; fold to
216        // uppercase + strip the redundant SHIFT bit (vim
217        // convention: `A` is shift-a; no `<S-a>` chord).
218        mods = mods.without(KeyMods::SHIFT);
219        KeyKind::Char(c.to_ascii_uppercase())
220    } else if !ctrl_or_alt && mods.shift() {
221        // Shift on a non-letter or already-uppercase char.
222        // The key string already encodes the shifted form
223        // (terminal-like backends do this); strip the bit.
224        mods = mods.without(KeyMods::SHIFT);
225        KeyKind::Char(c)
226    } else if !ctrl_or_alt {
227        // Bare printable, no modifier work needed.
228        KeyKind::Char(c)
229    } else {
230        // Ctrl / Alt + non-letter char: keep verbatim.
231        KeyKind::Char(c)
232    };
233
234    Some(KeyChord::new(key_kind, mods))
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn empty_key_returns_none() {
243        assert_eq!(from_keystroke("", false, false, false, false), None);
244    }
245
246    #[test]
247    fn plain_lowercase_letter_no_mods() {
248        let chord = from_keystroke("a", false, false, false, false).unwrap();
249        assert_eq!(chord.key, KeyKind::Char('a'));
250        assert!(chord.mods.is_empty());
251    }
252
253    #[test]
254    fn uppercase_letter_no_mods_preserves_case() {
255        // Some backends report the post-shift uppercase letter
256        // with the shift modifier already consumed. The case is
257        // load-bearing (vim distinguishes `a` and `A`); leave it.
258        let chord = from_keystroke("A", false, false, false, false).unwrap();
259        assert_eq!(chord.key, KeyKind::Char('A'));
260        assert!(chord.mods.is_empty());
261    }
262
263    #[test]
264    fn shift_lowercase_letter_folds_into_uppercase_and_strips_shift() {
265        // Backends that report the unshifted letter + shift
266        // modifier: fold so the trie key matches `A` above.
267        let chord = from_keystroke("a", false, false, true, false).unwrap();
268        assert_eq!(chord.key, KeyKind::Char('A'));
269        assert!(chord.mods.is_empty());
270    }
271
272    #[test]
273    fn ctrl_letter_normalises_to_lowercase() {
274        // Whether the backend reports `c` or `C`, ctrl-c should
275        // produce the same chord (`<C-c>`).
276        let a = from_keystroke("c", true, false, false, false).unwrap();
277        let b = from_keystroke("C", true, false, false, false).unwrap();
278        assert_eq!(a, b);
279        assert_eq!(a.key, KeyKind::Char('c'));
280        assert!(a.mods.ctrl());
281        assert!(!a.mods.shift());
282    }
283
284    #[test]
285    fn ctrl_shift_letter_preserves_shift() {
286        // `<C-S-c>` must stay distinct from `<C-c>`.
287        let chord = from_keystroke("c", true, false, true, false).unwrap();
288        assert_eq!(chord.key, KeyKind::Char('c'));
289        assert!(chord.mods.ctrl());
290        assert!(chord.mods.shift());
291    }
292
293    #[test]
294    fn alt_letter_records_alt_modifier() {
295        let chord = from_keystroke("x", false, true, false, false).unwrap();
296        assert_eq!(chord.key, KeyKind::Char('x'));
297        assert!(chord.mods.alt());
298        assert!(!chord.mods.ctrl());
299    }
300
301    #[test]
302    fn platform_maps_to_super() {
303        let chord = from_keystroke("a", false, false, false, true).unwrap();
304        assert!(chord.mods.super_());
305    }
306
307    #[test]
308    fn shift_on_non_letter_stripped() {
309        // `Shift-4` arrives as `"$"` — the key string already
310        // encodes the shifted symbol; shift modifier is redundant.
311        let chord = from_keystroke("$", false, false, true, false).unwrap();
312        assert_eq!(chord.key, KeyKind::Char('$'));
313        assert!(!chord.mods.shift());
314    }
315
316    #[test]
317    fn escape_special_accepts_aliases_and_case() {
318        for name in ["escape", "esc", "Escape", "ESC"] {
319            let chord = from_keystroke(name, false, false, false, false).unwrap();
320            assert_eq!(chord.key, KeyKind::Special(SpecialKey::Esc));
321        }
322    }
323
324    #[test]
325    fn enter_special_accepts_return_alias() {
326        let a = from_keystroke("enter", false, false, false, false).unwrap();
327        let b = from_keystroke("return", false, false, false, false).unwrap();
328        assert_eq!(a, b);
329        assert_eq!(a.key, KeyKind::Special(SpecialKey::Enter));
330    }
331
332    #[test]
333    fn tab_with_shift_preserves_shift() {
334        // `<S-Tab>` is a distinct chord; shift survives on specials.
335        let chord = from_keystroke("tab", false, false, true, false).unwrap();
336        assert_eq!(chord.key, KeyKind::Special(SpecialKey::Tab));
337        assert!(chord.mods.shift());
338    }
339
340    #[test]
341    fn function_keys_in_range() {
342        for n in 1u8..=12 {
343            let s = format!("f{n}");
344            let chord = from_keystroke(&s, false, false, false, false).unwrap();
345            assert_eq!(chord.key, KeyKind::Special(SpecialKey::F(n)));
346        }
347    }
348
349    #[test]
350    fn function_keys_out_of_range_return_none() {
351        assert_eq!(from_keystroke("f0", false, false, false, false), None);
352        assert_eq!(from_keystroke("f25", false, false, false, false), None);
353        assert_eq!(from_keystroke("f99", false, false, false, false), None);
354    }
355
356    #[test]
357    fn space_bare_renders_as_char_space() {
358        // `"space"` un-modified collapses to `Char(' ')` so the
359        // trie has a single entry shared with TUI's `from_event`.
360        let chord = from_keystroke("space", false, false, false, false).unwrap();
361        assert_eq!(chord.key, KeyKind::Char(' '));
362    }
363
364    #[test]
365    fn space_with_ctrl_keeps_special_form() {
366        // With a modifier, the `Space` special variant carries
367        // the chord — `<C-Space>` is unambiguous either way, but
368        // matching trie shape with TUI is the goal.
369        let chord = from_keystroke("space", true, false, false, false).unwrap();
370        assert_eq!(chord.key, KeyKind::Special(SpecialKey::Space));
371        assert!(chord.mods.ctrl());
372    }
373
374    #[test]
375    fn unrecognised_multichar_key_returns_none() {
376        // Anything multi-char that isn't a known special id is a
377        // chord we can't represent yet — better to return None
378        // than fabricate a bogus chord.
379        assert_eq!(from_keystroke("hyper", false, false, false, false), None);
380        assert_eq!(from_keystroke("xyz", false, false, false, false), None);
381    }
382
383    #[test]
384    fn lt_char_passes_through_unchanged() {
385        // Renderer-neutral storage; `<lt>` notation is a
386        // *display* concern handled by `KeyChord::Display`.
387        let chord = from_keystroke("<", false, false, false, false).unwrap();
388        assert_eq!(chord.key, KeyKind::Char('<'));
389    }
390    /// PBH.6 cross-renderer parity: `<C-6>` / `<C-7>` (the pane
391    /// buffer-history walk) must produce the SAME `KeyChord` in GPUI as
392    /// in the TUI.
393    ///
394    /// The two peers arrive at it by different routes. The terminal
395    /// sends control bytes 0x1E / 0x1F, which crossterm maps back to
396    /// `Char('6'..'7') + CONTROL`; GPUI reports the key string `"6"`
397    /// with a control modifier. Only the TUI path was exercised when
398    /// the chords landed, so this pins the GPUI half rather than
399    /// assuming the printable-char fallback happens to agree.
400    #[test]
401    fn ctrl_digits_match_the_tui_chord() {
402        for (key, ch) in [("6", '6'), ("7", '7')] {
403            let chord = from_keystroke(key, true, false, false, false)
404                .unwrap_or_else(|| panic!("<C-{ch}> must translate"));
405            assert_eq!(chord.key, KeyKind::Char(ch));
406            assert!(chord.mods.ctrl(), "<C-{ch}> must carry CTRL");
407            assert!(
408                !chord.mods.shift() && !chord.mods.alt() && !chord.mods.super_(),
409                "<C-{ch}> must carry CTRL only, got {:?}",
410                chord.mods,
411            );
412        }
413    }
414
415    /// The bare digits stay counts in GPUI too — the peer of the
416    /// `input.rs` guard that stops `<C-6>` being eaten as a count.
417    #[test]
418    fn bare_digits_carry_no_modifiers() {
419        let chord = from_keystroke("6", false, false, false, false).expect("digit translates");
420        assert_eq!(chord.key, KeyKind::Char('6'));
421        assert!(chord.mods.is_empty(), "a bare digit must be modifier-free");
422    }
423}