Skip to main content

car_browser/
keymap.rs

1//! What a key press has to look like before Chromium will act on it.
2//!
3//! `Input.dispatchKeyEvent` with only `key` and `modifiers` set delivers a
4//! DOM event a page can observe — and nothing else happens. Chromium's
5//! editing layer (and its default form handling) keys off
6//! `windowsVirtualKeyCode`, and text entry keys off `text`; without them
7//! Backspace deletes nothing, the arrows move no caret, and Enter submits no
8//! form. That was live-verified: every editing key was delivered, none of
9//! them edited.
10//!
11//! This module is the missing description. It is pure — a key name in, the
12//! CDP fields out — which is what makes the behaviour testable without a live
13//! Chromium, the same discipline the rest of this crate's CDP glue follows.
14//!
15//! Not a full keyboard layout: the named keys a browser drawer's user
16//! actually presses, plus printable characters derived generically. A key it
17//! does not recognise still dispatches with its `key` name, exactly as
18//! before — unrecognised keys get no worse, recognised ones get correct.
19
20/// The CDP fields for one key press.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct KeyDescriptor {
23    /// DOM `key` value, echoed back as sent.
24    pub key: String,
25    /// DOM `code` (physical key). `None` when unknown.
26    pub code: Option<&'static str>,
27    /// `windowsVirtualKeyCode` — what Chromium's editing commands dispatch
28    /// on. `None` when unknown.
29    pub virtual_key_code: Option<i64>,
30    /// The character this press inserts, or `None` for a key that inserts
31    /// nothing (Backspace, the arrows, Escape…).
32    pub text: Option<String>,
33}
34
35/// Describe `key` for CDP.
36///
37/// `text` is what a press would INSERT, so it is suppressed while Control or
38/// Meta is held: ⌘A is "select all", not a request to type the letter a, and
39/// sending `text` there makes Chromium do both.
40pub fn describe_key(key: &str, control_or_meta_held: bool) -> KeyDescriptor {
41    let (canonical, code, vk, text) = named_key(key)
42        .or_else(|| printable_key(key))
43        .unwrap_or((None, None, None, None));
44    KeyDescriptor {
45        // The CANONICAL DOM name, not the spelling the caller used. This
46        // module accepts aliases — "Return", "Esc", "Left", "Spacebar" — and
47        // echoing them back put them on the one field pages actually match:
48        // `e.key === 'Enter'` is false for a press described as "Return", so
49        // the `code`/`keyCode` were right and every keyboard handler on the
50        // page still missed. An unrecognised name falls back to the raw
51        // input, which is the best available answer for a key this module
52        // does not know.
53        key: canonical.unwrap_or_else(|| key.to_string()),
54        code,
55        virtual_key_code: vk,
56        text: if control_or_meta_held { None } else { text },
57    }
58}
59
60/// Canonical DOM `key`, `code`, virtual key code, inserted text.
61type KeyFacts = (
62    Option<String>,
63    Option<&'static str>,
64    Option<i64>,
65    Option<String>,
66);
67
68/// The named keys a person actually presses in a browser. Virtual key codes
69/// are the Windows VK_* values Chromium expects — VK_BACK 8, VK_RETURN 13,
70/// VK_LEFT 37, and so on.
71fn dom(name: &str) -> Option<String> {
72    Some(name.to_string())
73}
74
75fn named_key(key: &str) -> Option<KeyFacts> {
76    let facts: KeyFacts = match key {
77        // Editing. `text` matters for Enter: Chromium's form handling looks
78        // for the carriage return, not just the key name.
79        "Backspace" => (dom("Backspace"), Some("Backspace"), Some(8), None),
80        // No `text`. Tab MOVES FOCUS; the US-layout reference this follows
81        // gives it none, and sending "\t" risks typing a literal tab into the
82        // field instead of (or as well as) advancing.
83        "Tab" => (dom("Tab"), Some("Tab"), Some(9), None),
84        "Enter" | "Return" => (
85            dom("Enter"),
86            Some("Enter"),
87            Some(13),
88            Some("\r".to_string()),
89        ),
90        "Escape" | "Esc" => (dom("Escape"), Some("Escape"), Some(27), None),
91        "Delete" => (dom("Delete"), Some("Delete"), Some(46), None),
92        " " | "Space" | "Spacebar" => (dom(" "), Some("Space"), Some(32), Some(" ".to_string())),
93        // Caret movement.
94        "ArrowLeft" | "Left" => (dom("ArrowLeft"), Some("ArrowLeft"), Some(37), None),
95        "ArrowUp" | "Up" => (dom("ArrowUp"), Some("ArrowUp"), Some(38), None),
96        "ArrowRight" | "Right" => (dom("ArrowRight"), Some("ArrowRight"), Some(39), None),
97        "ArrowDown" | "Down" => (dom("ArrowDown"), Some("ArrowDown"), Some(40), None),
98        "Home" => (dom("Home"), Some("Home"), Some(36), None),
99        "End" => (dom("End"), Some("End"), Some(35), None),
100        "PageUp" => (dom("PageUp"), Some("PageUp"), Some(33), None),
101        "PageDown" => (dom("PageDown"), Some("PageDown"), Some(34), None),
102        // Modifiers pressed on their own.
103        "Shift" => (dom("Shift"), Some("ShiftLeft"), Some(16), None),
104        "Control" => (dom("Control"), Some("ControlLeft"), Some(17), None),
105        "Alt" => (dom("Alt"), Some("AltLeft"), Some(18), None),
106        "Meta" => (dom("Meta"), Some("MetaLeft"), Some(91), None),
107        _ => return None,
108    };
109    Some(facts)
110}
111
112/// A single printable character: its own text, with the virtual key code and
113/// `code` Chromium expects for the US layout. Derived rather than tabulated —
114/// a table of 62 entries would say the same thing at more length.
115fn printable_key(key: &str) -> Option<KeyFacts> {
116    let mut chars = key.chars();
117    let ch = chars.next()?;
118    if chars.next().is_some() {
119        // A multi-character name this module does not know — not a
120        // printable character.
121        return None;
122    }
123    let text = Some(ch.to_string());
124    // Virtual key codes are layout-independent and defined on the UPPERCASE
125    // letter: pressing `a` and `A` are both VK 65, told apart by Shift.
126    let upper = ch.to_ascii_uppercase();
127    let (code, vk) = match upper {
128        'A'..='Z' => (
129            Some(LETTER_CODES[(upper as u8 - b'A') as usize]),
130            Some(upper as i64),
131        ),
132        '0'..='9' => (
133            Some(DIGIT_CODES[(upper as u8 - b'0') as usize]),
134            Some(upper as i64),
135        ),
136        // Punctuation and everything else: Chromium still inserts the
137        // character from `text`; only the physical-key description is
138        // unknown, and guessing it wrong is worse than omitting it.
139        _ => (None, None),
140    };
141    // A printable character IS its own DOM key name.
142    Some((Some(key.to_string()), code, vk, text))
143}
144
145const LETTER_CODES: [&str; 26] = [
146    "KeyA", "KeyB", "KeyC", "KeyD", "KeyE", "KeyF", "KeyG", "KeyH", "KeyI", "KeyJ", "KeyK", "KeyL",
147    "KeyM", "KeyN", "KeyO", "KeyP", "KeyQ", "KeyR", "KeyS", "KeyT", "KeyU", "KeyV", "KeyW", "KeyX",
148    "KeyY", "KeyZ",
149];
150
151const DIGIT_CODES: [&str; 10] = [
152    "Digit0", "Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8",
153    "Digit9",
154];
155
156#[cfg(test)]
157mod tests {
158    /// The aliases this module advertises must reach the page as the DOM
159    /// names pages match on. Echoing the caller's spelling put "Return" /
160    /// "Esc" / "Left" / "Spacebar" on `key`, so `e.key === 'Enter'` was false
161    /// for a press this crate had correctly identified as Enter — right
162    /// `code`, right `keyCode`, and every keyboard handler still missed.
163    #[test]
164    fn every_alias_reaches_the_page_under_its_canonical_dom_name() {
165        for (alias, canonical) in [
166            ("Return", "Enter"),
167            ("Enter", "Enter"),
168            ("Esc", "Escape"),
169            ("Escape", "Escape"),
170            ("Left", "ArrowLeft"),
171            ("Up", "ArrowUp"),
172            ("Right", "ArrowRight"),
173            ("Down", "ArrowDown"),
174            ("Space", " "),
175            ("Spacebar", " "),
176            (" ", " "),
177        ] {
178            assert_eq!(
179                super::describe_key(alias, false).key,
180                canonical,
181                "{alias} must be delivered as {canonical}"
182            );
183        }
184        // A printable character is its own name, and an unknown name falls
185        // back to the raw input rather than becoming nothing.
186        assert_eq!(super::describe_key("a", false).key, "a");
187        assert_eq!(super::describe_key("F13", false).key, "F13");
188    }
189
190    use super::*;
191
192    /// The exact regression: Backspace was delivered as an event and deleted
193    /// nothing, because Chromium's editing layer dispatches on
194    /// `windowsVirtualKeyCode` and the field was absent.
195    #[test]
196    fn backspace_carries_the_virtual_key_code_that_makes_it_delete() {
197        let d = describe_key("Backspace", false);
198        assert_eq!(d.virtual_key_code, Some(8));
199        assert_eq!(d.code, Some("Backspace"));
200        assert_eq!(d.text, None, "Backspace inserts nothing");
201    }
202
203    #[test]
204    fn the_arrows_carry_their_virtual_key_codes() {
205        for (key, vk) in [
206            ("ArrowLeft", 37),
207            ("ArrowUp", 38),
208            ("ArrowRight", 39),
209            ("ArrowDown", 40),
210        ] {
211            let d = describe_key(key, false);
212            assert_eq!(d.virtual_key_code, Some(vk), "{key}");
213            assert_eq!(d.text, None, "{key} moves the caret, it does not type");
214        }
215    }
216
217    /// Enter submitting a form needs the carriage return, not just the name.
218    #[test]
219    fn enter_carries_both_the_key_code_and_the_carriage_return() {
220        let d = describe_key("Enter", false);
221        assert_eq!(d.virtual_key_code, Some(13));
222        assert_eq!(d.text.as_deref(), Some("\r"));
223    }
224
225    /// Tab moves focus — it must not also type a literal tab character into
226    /// the field it is leaving.
227    #[test]
228    fn tab_moves_focus_without_typing_anything() {
229        let d = describe_key("Tab", false);
230        assert_eq!(d.code, Some("Tab"));
231        assert_eq!(d.virtual_key_code, Some(9));
232        assert_eq!(d.text, None);
233    }
234
235    #[test]
236    fn a_printable_letter_types_itself() {
237        let d = describe_key("a", false);
238        assert_eq!(d.text.as_deref(), Some("a"));
239        assert_eq!(d.code, Some("KeyA"));
240        assert_eq!(
241            d.virtual_key_code,
242            Some(65),
243            "virtual key codes are defined on the uppercase letter"
244        );
245        assert_eq!(describe_key("A", false).virtual_key_code, Some(65));
246    }
247
248    #[test]
249    fn a_digit_types_itself() {
250        let d = describe_key("7", false);
251        assert_eq!(d.text.as_deref(), Some("7"));
252        assert_eq!(d.code, Some("Digit7"));
253        assert_eq!(d.virtual_key_code, Some(b'7' as i64));
254    }
255
256    /// ⌘A is "select all", not a request to type the letter a. Sending
257    /// `text` alongside the modifier makes Chromium do both.
258    #[test]
259    fn a_shortcut_does_not_also_type_its_letter() {
260        let plain = describe_key("a", false);
261        let shortcut = describe_key("a", true);
262        assert_eq!(plain.text.as_deref(), Some("a"));
263        assert_eq!(shortcut.text, None);
264        assert_eq!(
265            shortcut.virtual_key_code, plain.virtual_key_code,
266            "the key itself is unchanged — only what it inserts"
267        );
268    }
269
270    #[test]
271    fn punctuation_still_types_even_without_a_physical_key_description() {
272        let d = describe_key("!", false);
273        assert_eq!(d.text.as_deref(), Some("!"));
274        assert_eq!(d.code, None, "guessing the physical key would be worse");
275    }
276
277    /// An unrecognised name is no worse than before: it still dispatches
278    /// under its own `key`, which is all the old code ever sent.
279    #[test]
280    fn an_unknown_key_name_still_dispatches_by_name() {
281        let d = describe_key("F13", false);
282        assert_eq!(d.key, "F13");
283        assert_eq!(d.code, None);
284        assert_eq!(d.virtual_key_code, None);
285        assert_eq!(d.text, None);
286    }
287
288    #[test]
289    fn space_is_recognised_by_every_spelling_a_client_might_send() {
290        for key in [" ", "Space", "Spacebar"] {
291            let d = describe_key(key, false);
292            assert_eq!(d.virtual_key_code, Some(32), "{key}");
293            assert_eq!(d.text.as_deref(), Some(" "), "{key}");
294        }
295    }
296}