Skip to main content

twrite_gpui/
input.rs

1use gpui::KeyDownEvent;
2use twrite_core::{KeyCode, KeyEvent, Modifiers};
3
4/// Translates a GPUI [`KeyDownEvent`] into a platform-agnostic twrite [`KeyEvent`].
5///
6/// This is the single boundary where GPUI key *strings* are interpreted;
7/// everything downstream matches on [`KeyCode`]. Arrow keys arrive under
8/// different names per platform/backend (`left` vs `arrowleft`) and converge
9/// on one variant here.
10///
11/// Printable characters come from [`Keystroke::key_char`] โ€” the character
12/// that would actually be typed (`"A"` with Shift held, layout-aware) โ€”
13///
14/// rather than [`Keystroke::key`], which only names the physical key (`"a"`).
15/// Using `key` would make capitals, shifted symbols, and non-US layouts
16/// untypeable. `key_char` is honored only for genuine text input: command
17/// combos (Ctrl/Cmd held) keep physical key names so `Ctrl+B`-style bindings
18/// keep matching, and Option/Alt-modified keys keep theirs too.
19///
20/// NOTE (gpui 0.2.2): the git-era `KeyDownEvent::prefer_character_input`
21/// signal (AltGr, macOS Option accents) does not exist in the 0.2.2 API, so
22/// Alt-modified keys always keep physical names here. Revisit when upgrading
23/// past 0.2.2 if that signal returns.
24pub fn translate_key_down(event: &KeyDownEvent) -> Option<KeyEvent> {
25    let keystroke = &event.keystroke;
26    Some(KeyEvent {
27        code: code_for(keystroke),
28        modifiers: Modifiers {
29            ctrl: keystroke.modifiers.control,
30            alt: keystroke.modifiers.alt,
31            shift: keystroke.modifiers.shift,
32            meta: keystroke.modifiers.platform,
33        },
34    })
35}
36
37/// Maps one GPUI keystroke to a [`KeyCode`], preserving the historical
38/// precedence: named physical keys first, then produced characters for
39/// genuine text input, then single-char physical names (Ctrl/Cmd combos),
40/// then the named table, else [`KeyCode::Unidentified`].
41fn code_for(keystroke: &gpui::Keystroke) -> KeyCode {
42    let mods = &keystroke.modifiers;
43    match keystroke.key.as_str() {
44        "space" => return KeyCode::Char(' '),
45        "left" | "arrowleft" => return KeyCode::Left,
46        "right" | "arrowright" => return KeyCode::Right,
47        "up" | "arrowup" => return KeyCode::Up,
48        "down" | "arrowdown" => return KeyCode::Down,
49        _ => {}
50    }
51    if let Some(text) = &keystroke.key_char
52        && text.chars().count() == 1
53        && !mods.control
54        && !mods.platform
55        && !mods.alt
56    {
57        return KeyCode::Char(text.chars().next().unwrap());
58    }
59    if keystroke.key.chars().count() == 1 {
60        return KeyCode::Char(keystroke.key.chars().next().unwrap().to_ascii_lowercase());
61    }
62    match keystroke.key.as_str() {
63        "enter" => KeyCode::Enter,
64        "tab" => KeyCode::Tab,
65        "escape" => KeyCode::Escape,
66        "backspace" => KeyCode::Backspace,
67        "delete" => KeyCode::Delete,
68        "home" => KeyCode::Home,
69        "end" => KeyCode::End,
70        "pageup" => KeyCode::PageUp,
71        "pagedown" => KeyCode::PageDown,
72        "insert" => KeyCode::Insert,
73        name => parse_function_key(name).unwrap_or(KeyCode::Unidentified),
74    }
75}
76
77/// Parses `F1`โ€“`F24` in any case (`"f3"` is what GPUI emits on Linux).
78fn parse_function_key(name: &str) -> Option<KeyCode> {
79    let lower = name.to_ascii_lowercase();
80    let digits = lower.strip_prefix('f')?;
81    if !(1..=2).contains(&digits.len()) || !digits.chars().all(|c| c.is_ascii_digit()) {
82        return None;
83    }
84    let n: u8 = digits.parse().ok()?;
85    (1..=24).contains(&n).then_some(KeyCode::F(n))
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    fn keystroke(key: &str, key_char: Option<&str>, modifiers: gpui::Modifiers) -> KeyDownEvent {
93        KeyDownEvent {
94            keystroke: gpui::Keystroke {
95                key: key.to_string(),
96                key_char: key_char.map(|s| s.to_string()),
97                modifiers,
98            },
99            is_held: false,
100        }
101    }
102
103    fn plain(key: &str, key_char: Option<&str>) -> KeyDownEvent {
104        keystroke(key, key_char, gpui::Modifiers::default())
105    }
106
107    #[test]
108    fn shift_letter_yields_capital_with_shift_held() {
109        let event = keystroke(
110            "a",
111            Some("A"),
112            gpui::Modifiers {
113                shift: true,
114                ..Default::default()
115            },
116        );
117        let translated = translate_key_down(&event).unwrap();
118        assert_eq!(translated.code, KeyCode::Char('A'));
119        assert!(translated.modifiers.shift);
120    }
121
122    #[test]
123    fn plain_letter_passes_through() {
124        let translated = translate_key_down(&plain("a", Some("a"))).unwrap();
125        assert_eq!(translated.code, KeyCode::Char('a'));
126        assert!(!translated.modifiers.shift);
127    }
128
129    #[test]
130    fn shifted_symbol_uses_typed_character() {
131        let event = keystroke(
132            "/",
133            Some("?"),
134            gpui::Modifiers {
135                shift: true,
136                ..Default::default()
137            },
138        );
139        assert_eq!(translate_key_down(&event).unwrap().code, KeyCode::Char('?'));
140    }
141
142    #[test]
143    fn command_combos_keep_physical_key_names() {
144        let event = keystroke(
145            "b",
146            Some("b"),
147            gpui::Modifiers {
148                control: true,
149                ..Default::default()
150            },
151        );
152        let translated = translate_key_down(&event).unwrap();
153        assert_eq!(translated.code, KeyCode::Char('b'));
154        assert!(translated.modifiers.ctrl);
155    }
156
157    #[test]
158    fn alt_combos_keep_key_names() {
159        // gpui 0.2.2 has no prefer_character_input signal, so Alt+C stays
160        // a toggle binding (physical key name).
161        let alt = gpui::Modifiers {
162            alt: true,
163            ..Default::default()
164        };
165        let translated = translate_key_down(&keystroke("c", Some("รง"), alt)).unwrap();
166        assert_eq!(translated.code, KeyCode::Char('c'));
167        assert!(translated.modifiers.alt);
168    }
169
170    #[test]
171    fn multichar_or_missing_key_char_falls_back_to_key() {
172        // IME composition sequences are not text input yet.
173        let translated = translate_key_down(&plain("a", Some("aeiou"))).unwrap();
174        assert_eq!(translated.code, KeyCode::Char('a'));
175        // Modifier-only combos carry no character.
176        let translated = translate_key_down(&plain("s", None)).unwrap();
177        assert_eq!(translated.code, KeyCode::Char('s'));
178    }
179
180    #[test]
181    fn named_keys_and_aliases_map_to_variants() {
182        assert_eq!(
183            translate_key_down(&plain("enter", None)).unwrap().code,
184            KeyCode::Enter
185        );
186        assert_eq!(
187            translate_key_down(&plain("left", None)).unwrap().code,
188            KeyCode::Left
189        );
190        assert_eq!(
191            translate_key_down(&plain("space", None)).unwrap().code,
192            KeyCode::Char(' ')
193        );
194        assert_eq!(
195            translate_key_down(&plain("f3", None)).unwrap().code,
196            KeyCode::F(3)
197        );
198        assert_eq!(
199            translate_key_down(&plain("back", None)).unwrap().code,
200            KeyCode::Unidentified
201        );
202    }
203}