Skip to main content

browser_control/session/
keys.rs

1//! Key definitions and chord parsing, shared by both engines.
2//!
3//! CDP and BiDi name keys in different namespaces: CDP wants
4//! `KeyboardEvent.key` (`"ArrowDown"`) plus a Windows virtual key code, while
5//! BiDi wants the WebDriver normalised key values (`\u{E015}` for ArrowDown).
6//! Neither is derivable from the other, so a [`KeyDef`] carries both rather
7//! than converting at the call site.
8//!
9//! Scope is the US layout: named keys, the modifiers, and printable ASCII.
10//! Anything beyond that belongs in `type`, which goes through
11//! `Input.insertText` and is layout-independent.
12
13use anyhow::{bail, Result};
14
15/// Everything both engines need to press one key.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct KeyDef {
18    /// `KeyboardEvent.key`.
19    pub key: &'static str,
20    /// `KeyboardEvent.code` (physical key).
21    pub code: &'static str,
22    /// Windows virtual key code, for CDP.
23    pub vk: u32,
24    /// Text this key inserts, or `None` when it inserts nothing.
25    ///
26    /// Load-bearing: Chromium synthesises `keypress` and inserts characters
27    /// **from this field**, so giving `ArrowDown` a `text` types a glyph
28    /// instead of moving the caret. Conversely `Enter` needs `"\r"` here or
29    /// forms do not submit.
30    pub text: Option<&'static str>,
31    /// WebDriver normalised key value, for BiDi.
32    pub bidi: &'static str,
33}
34
35/// CDP modifier bitmask values. The mask goes on **every** event in a chord,
36/// including the target key's — leaving it off there is the usual reason
37/// `Control+A` selects nothing.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Modifier {
40    Alt = 1,
41    Control = 2,
42    Meta = 4,
43    Shift = 8,
44}
45
46impl Modifier {
47    pub fn def(self) -> KeyDef {
48        match self {
49            Modifier::Alt => KeyDef {
50                key: "Alt",
51                code: "AltLeft",
52                vk: 0x12,
53                text: None,
54                bidi: "\u{E00A}",
55            },
56            Modifier::Control => KeyDef {
57                key: "Control",
58                code: "ControlLeft",
59                vk: 0x11,
60                text: None,
61                bidi: "\u{E009}",
62            },
63            Modifier::Meta => KeyDef {
64                key: "Meta",
65                code: "MetaLeft",
66                vk: 0x5B,
67                text: None,
68                bidi: "\u{E03D}",
69            },
70            Modifier::Shift => KeyDef {
71                key: "Shift",
72                code: "ShiftLeft",
73                vk: 0x10,
74                text: None,
75                bidi: "\u{E008}",
76            },
77        }
78    }
79
80    fn parse(name: &str) -> Option<Modifier> {
81        match name.to_ascii_lowercase().as_str() {
82            "alt" | "option" => Some(Modifier::Alt),
83            "control" | "ctrl" => Some(Modifier::Control),
84            "meta" | "cmd" | "command" | "super" => Some(Modifier::Meta),
85            "shift" => Some(Modifier::Shift),
86            _ => None,
87        }
88    }
89}
90
91/// A key plus the modifiers held while it is pressed.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct Chord {
94    pub modifiers: Vec<Modifier>,
95    pub key: KeyDef,
96}
97
98impl Chord {
99    pub fn plain(key: KeyDef) -> Chord {
100        Chord {
101            modifiers: Vec::new(),
102            key,
103        }
104    }
105
106    /// Combined CDP modifier bitmask.
107    pub fn mask(&self) -> u32 {
108        self.modifiers.iter().fold(0, |m, k| m | (*k as u32))
109    }
110}
111
112const NAMED: &[KeyDef] = &[
113    KeyDef {
114        key: "Enter",
115        code: "Enter",
116        vk: 0x0D,
117        text: Some("\r"),
118        bidi: "\u{E007}",
119    },
120    KeyDef {
121        key: "Tab",
122        code: "Tab",
123        vk: 0x09,
124        text: Some("\t"),
125        bidi: "\u{E004}",
126    },
127    KeyDef {
128        key: "Escape",
129        code: "Escape",
130        vk: 0x1B,
131        text: None,
132        bidi: "\u{E00C}",
133    },
134    KeyDef {
135        key: "Backspace",
136        code: "Backspace",
137        vk: 0x08,
138        text: None,
139        bidi: "\u{E003}",
140    },
141    KeyDef {
142        key: "Delete",
143        code: "Delete",
144        vk: 0x2E,
145        text: None,
146        bidi: "\u{E017}",
147    },
148    KeyDef {
149        key: "Insert",
150        code: "Insert",
151        vk: 0x2D,
152        text: None,
153        bidi: "\u{E016}",
154    },
155    KeyDef {
156        key: " ",
157        code: "Space",
158        vk: 0x20,
159        text: Some(" "),
160        bidi: "\u{E00D}",
161    },
162    KeyDef {
163        key: "Home",
164        code: "Home",
165        vk: 0x24,
166        text: None,
167        bidi: "\u{E011}",
168    },
169    KeyDef {
170        key: "End",
171        code: "End",
172        vk: 0x23,
173        text: None,
174        bidi: "\u{E010}",
175    },
176    KeyDef {
177        key: "PageUp",
178        code: "PageUp",
179        vk: 0x21,
180        text: None,
181        bidi: "\u{E00E}",
182    },
183    KeyDef {
184        key: "PageDown",
185        code: "PageDown",
186        vk: 0x22,
187        text: None,
188        bidi: "\u{E00F}",
189    },
190    KeyDef {
191        key: "ArrowUp",
192        code: "ArrowUp",
193        vk: 0x26,
194        text: None,
195        bidi: "\u{E013}",
196    },
197    KeyDef {
198        key: "ArrowDown",
199        code: "ArrowDown",
200        vk: 0x28,
201        text: None,
202        bidi: "\u{E015}",
203    },
204    KeyDef {
205        key: "ArrowLeft",
206        code: "ArrowLeft",
207        vk: 0x25,
208        text: None,
209        bidi: "\u{E012}",
210    },
211    KeyDef {
212        key: "ArrowRight",
213        code: "ArrowRight",
214        vk: 0x27,
215        text: None,
216        bidi: "\u{E014}",
217    },
218    KeyDef {
219        key: "F1",
220        code: "F1",
221        vk: 0x70,
222        text: None,
223        bidi: "\u{E031}",
224    },
225    KeyDef {
226        key: "F2",
227        code: "F2",
228        vk: 0x71,
229        text: None,
230        bidi: "\u{E032}",
231    },
232    KeyDef {
233        key: "F3",
234        code: "F3",
235        vk: 0x72,
236        text: None,
237        bidi: "\u{E033}",
238    },
239    KeyDef {
240        key: "F4",
241        code: "F4",
242        vk: 0x73,
243        text: None,
244        bidi: "\u{E034}",
245    },
246    KeyDef {
247        key: "F5",
248        code: "F5",
249        vk: 0x74,
250        text: None,
251        bidi: "\u{E035}",
252    },
253    KeyDef {
254        key: "F6",
255        code: "F6",
256        vk: 0x75,
257        text: None,
258        bidi: "\u{E036}",
259    },
260    KeyDef {
261        key: "F7",
262        code: "F7",
263        vk: 0x76,
264        text: None,
265        bidi: "\u{E037}",
266    },
267    KeyDef {
268        key: "F8",
269        code: "F8",
270        vk: 0x77,
271        text: None,
272        bidi: "\u{E038}",
273    },
274    KeyDef {
275        key: "F9",
276        code: "F9",
277        vk: 0x78,
278        text: None,
279        bidi: "\u{E039}",
280    },
281    KeyDef {
282        key: "F10",
283        code: "F10",
284        vk: 0x79,
285        text: None,
286        bidi: "\u{E03A}",
287    },
288    KeyDef {
289        key: "F11",
290        code: "F11",
291        vk: 0x7A,
292        text: None,
293        bidi: "\u{E03B}",
294    },
295    KeyDef {
296        key: "F12",
297        code: "F12",
298        vk: 0x7B,
299        text: None,
300        bidi: "\u{E03C}",
301    },
302];
303
304/// The `Enter` definition, so `press_enter` keeps its exact wire payload.
305pub const ENTER: KeyDef = NAMED[0];
306
307/// Aliases callers reasonably expect. `Space` is spelled `" "` in
308/// `KeyboardEvent.key`, so it cannot be found by name without this.
309fn alias(name: &str) -> Option<&'static str> {
310    match name.to_ascii_lowercase().as_str() {
311        "space" | "spacebar" => Some(" "),
312        "esc" => Some("Escape"),
313        "return" => Some("Enter"),
314        "del" => Some("Delete"),
315        "up" => Some("ArrowUp"),
316        "down" => Some("ArrowDown"),
317        "left" => Some("ArrowLeft"),
318        "right" => Some("ArrowRight"),
319        "pgup" => Some("PageUp"),
320        "pgdn" | "pgdown" => Some("PageDown"),
321        _ => None,
322    }
323}
324
325/// Look up one key by name. Named keys are case-insensitive; a single
326/// character is taken literally, so `"a"` and `"A"` differ.
327pub fn lookup(name: &str) -> Option<KeyDef> {
328    let wanted = alias(name).unwrap_or(name);
329    if let Some(def) = NAMED
330        .iter()
331        .find(|d| d.key.eq_ignore_ascii_case(wanted) && !wanted.is_empty())
332    {
333        return Some(*def);
334    }
335    let mut chars = wanted.chars();
336    match (chars.next(), chars.next()) {
337        (Some(c), None) if c.is_ascii_graphic() => Some(printable(c)),
338        _ => None,
339    }
340}
341
342/// Build a definition for a printable ASCII character.
343///
344/// `text` is leaked to `'static` because [`KeyDef`] is `Copy` and shared with
345/// the const table; one small allocation per distinct character pressed, which
346/// is bounded by the 95 printable ASCII characters.
347fn printable(c: char) -> KeyDef {
348    let upper = c.to_ascii_uppercase();
349    let code: &'static str = match upper {
350        'A'..='Z' => Box::leak(format!("Key{upper}").into_boxed_str()),
351        '0'..='9' => Box::leak(format!("Digit{upper}").into_boxed_str()),
352        _ => "",
353    };
354    KeyDef {
355        key: Box::leak(c.to_string().into_boxed_str()),
356        code,
357        // Virtual key codes are for the *unshifted* physical key.
358        vk: if upper.is_ascii_alphanumeric() {
359            upper as u32
360        } else {
361            0
362        },
363        text: Some(Box::leak(c.to_string().into_boxed_str())),
364        bidi: Box::leak(c.to_string().into_boxed_str()),
365    }
366}
367
368/// Parse `"Control+Shift+K"`. The last segment is the key; the rest are
369/// modifiers. A trailing `+` means the `+` key, as in `"Control++"`.
370pub fn parse_chord(spec: &str) -> Result<Chord> {
371    if spec.is_empty() {
372        bail!("empty key");
373    }
374    // `+` is both the separator and a pressable key, so a trailing `+` is
375    // peeled off before splitting rather than handled afterwards — splitting
376    // first leaves empty segments that look like unnamed modifiers.
377    let (mod_spec, key_name) = if let Some(head) = spec.strip_suffix('+') {
378        (head.trim_end_matches('+'), "+")
379    } else {
380        match spec.rsplit_once('+') {
381            Some((head, key)) => (head, key),
382            None => ("", spec),
383        }
384    };
385
386    let mut modifiers = Vec::new();
387    for m in mod_spec.split('+').filter(|s| !s.is_empty()) {
388        match Modifier::parse(m) {
389            Some(modifier) => modifiers.push(modifier),
390            None => bail!(
391                "unknown modifier `{m}` in `{spec}`. Known: Control/Ctrl, Shift, Alt/Option, Meta/Cmd"
392            ),
393        }
394    }
395
396    let key = lookup(key_name).ok_or_else(|| {
397        anyhow::anyhow!(
398            "unknown key `{key_name}` in `{spec}`.{}",
399            suggestion(key_name)
400        )
401    })?;
402    Ok(Chord { modifiers, key })
403}
404
405/// Name the closest known key, because a silently-wrong keystroke is worse
406/// than an error.
407fn suggestion(name: &str) -> String {
408    let lower = name.to_ascii_lowercase();
409    let near: Vec<&str> = NAMED
410        .iter()
411        .map(|d| d.key)
412        .filter(|k| {
413            let k = k.to_ascii_lowercase();
414            k.starts_with(&lower) || lower.starts_with(&k)
415        })
416        .take(3)
417        .collect();
418    if near.is_empty() {
419        " Named keys are e.g. Enter, Tab, Escape, ArrowDown, F5; \
420         a single character presses that character."
421            .to_string()
422    } else {
423        format!(" Did you mean {}?", near.join(", "))
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn plain_named_key() {
433        let c = parse_chord("Enter").unwrap();
434        assert!(c.modifiers.is_empty());
435        assert_eq!(c.key.key, "Enter");
436        assert_eq!(c.mask(), 0);
437    }
438
439    #[test]
440    fn named_keys_are_case_insensitive() {
441        assert_eq!(parse_chord("escape").unwrap().key.key, "Escape");
442        assert_eq!(parse_chord("ARROWDOWN").unwrap().key.key, "ArrowDown");
443    }
444
445    #[test]
446    fn single_characters_are_case_sensitive() {
447        assert_eq!(parse_chord("a").unwrap().key.key, "a");
448        assert_eq!(parse_chord("A").unwrap().key.key, "A");
449    }
450
451    #[test]
452    fn modifiers_combine_into_the_cdp_mask() {
453        let c = parse_chord("Control+A").unwrap();
454        assert_eq!(c.modifiers, vec![Modifier::Control]);
455        assert_eq!(c.mask(), 2);
456        assert_eq!(parse_chord("Control+Shift+K").unwrap().mask(), 2 | 8);
457        assert_eq!(parse_chord("Alt+Meta+x").unwrap().mask(), 1 | 4);
458    }
459
460    #[test]
461    fn modifier_aliases() {
462        assert_eq!(parse_chord("Ctrl+a").unwrap().mask(), 2);
463        assert_eq!(parse_chord("Cmd+a").unwrap().mask(), 4);
464        assert_eq!(parse_chord("Option+a").unwrap().mask(), 1);
465    }
466
467    #[test]
468    fn trailing_plus_is_the_plus_key() {
469        // `+` is both the separator and a pressable key.
470        let c = parse_chord("Control++").unwrap();
471        assert_eq!(c.modifiers, vec![Modifier::Control]);
472        assert_eq!(c.key.key, "+");
473
474        let bare = parse_chord("+").unwrap();
475        assert!(bare.modifiers.is_empty());
476        assert_eq!(bare.key.key, "+");
477
478        let two = parse_chord("Control+Shift++").unwrap();
479        assert_eq!(two.modifiers, vec![Modifier::Control, Modifier::Shift]);
480        assert_eq!(two.key.key, "+");
481    }
482
483    #[test]
484    fn space_is_reachable_by_name() {
485        // `KeyboardEvent.key` for space is " ", which no one types as a chord.
486        assert_eq!(parse_chord("Space").unwrap().key.code, "Space");
487    }
488
489    #[test]
490    fn named_keys_that_insert_nothing_carry_no_text() {
491        // Chromium inserts from `text`; giving ArrowDown one types a glyph.
492        for name in ["ArrowDown", "Escape", "F5", "Home", "Backspace"] {
493            assert_eq!(lookup(name).unwrap().text, None, "{name} must not insert");
494        }
495    }
496
497    #[test]
498    fn enter_still_carries_the_carriage_return() {
499        // This is what makes forms submit; press_enter depends on it.
500        assert_eq!(ENTER.text, Some("\r"));
501        assert_eq!(ENTER.vk, 13);
502    }
503
504    #[test]
505    fn printable_keys_insert_themselves() {
506        let a = lookup("a").unwrap();
507        assert_eq!(a.text, Some("a"));
508        assert_eq!(a.code, "KeyA");
509        assert_eq!(a.vk, 'A' as u32);
510    }
511
512    #[test]
513    fn unknown_key_names_the_closest_match() {
514        let err = parse_chord("Ente").unwrap_err().to_string();
515        assert!(err.contains("Enter"), "{err}");
516    }
517
518    #[test]
519    fn unknown_modifier_is_rejected_rather_than_ignored() {
520        let err = parse_chord("Hyper+a").unwrap_err().to_string();
521        assert!(err.contains("Hyper") && err.contains("Control"), "{err}");
522    }
523
524    #[test]
525    fn empty_input_is_an_error() {
526        assert!(parse_chord("").is_err());
527    }
528
529    #[test]
530    fn bidi_values_are_the_webdriver_namespace_not_the_key_name() {
531        assert_eq!(lookup("ArrowDown").unwrap().bidi, "\u{E015}");
532        assert_eq!(ENTER.bidi, "\u{E007}");
533    }
534}