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}