Skip to main content

bern_protocol/
shortcut.rs

1//! Keyboard shortcuts as Bern writes them: the modifiers `cmd` (the
2//! primary modifier: Command on Apple systems, Control elsewhere), `ctrl`,
3//! `alt` and `shift`, then one key, joined by `+`, such as `"cmd+k"` or
4//! `"space"`. Declarations write them as literals, which the compiler
5//! normalizes; a shortcut that follows state is a text in the same form,
6//! which engines normalize with [`parse_shortcut`] before they keep it, and
7//! every runtime reads by the same rules.
8
9/// Keys a shortcut may name besides a single printable character.
10pub const SHORTCUT_KEYS: &[&str] = &[
11    "space", "escape", "return", "tab", "delete", "up", "down", "left", "right", "home", "end",
12    "pageup", "pagedown",
13];
14
15/// Normalize a keyboard shortcut: modifiers `cmd`, `ctrl`, `alt` and
16/// `shift` in that order, then one key: a printable character in lower
17/// case or one of [`SHORTCUT_KEYS`]. Case and spaces around the parts do
18/// not matter, so `"Shift + Cmd + K"` is `"cmd+shift+k"`.
19pub fn parse_shortcut(text: &str) -> Result<String, String> {
20    let parts: Vec<String> = text
21        .split('+')
22        .map(|part| part.trim().to_lowercase())
23        .collect();
24    let (key, modifiers) = parts.split_last().ok_or("an empty shortcut")?;
25    let mut ordered = Vec::new();
26    for wanted in ["cmd", "ctrl", "alt", "shift"] {
27        match modifiers.iter().filter(|m| *m == wanted).count() {
28            0 => {}
29            1 => ordered.push(wanted.to_string()),
30            _ => return Err(format!("`{wanted}` is written twice")),
31        }
32    }
33    if let Some(other) = modifiers
34        .iter()
35        .find(|m| !["cmd", "ctrl", "alt", "shift"].contains(&m.as_str()))
36    {
37        return Err(format!(
38            "`{other}` is not a modifier; use cmd, ctrl, alt or shift"
39        ));
40    }
41    let printable = key.chars().count() == 1
42        && key
43            .chars()
44            .all(|c| c.is_ascii_graphic() && !c.is_ascii_uppercase());
45    if !printable && !SHORTCUT_KEYS.contains(&key.as_str()) {
46        return Err(format!(
47            "`{key}` is not a key; use one character or one of {}",
48            SHORTCUT_KEYS.join(", ")
49        ));
50    }
51    ordered.push(key.clone());
52    Ok(ordered.join("+"))
53}
54
55/// Whether two shortcuts are pressed with the same keys on a system that is
56/// (`apple`) or is not an Apple one: elsewhere `cmd` is Control, so
57/// `cmd+k` and `ctrl+k` are one shortcut there. Texts that are no shortcut
58/// are the same as nothing else.
59pub fn same_keys(a: &str, b: &str, apple: bool) -> bool {
60    let pressed = |text: &str| -> Option<String> {
61        let normal = parse_shortcut(text).ok()?;
62        if apple {
63            return Some(normal);
64        }
65        let mut parts: Vec<&str> = normal.split('+').collect();
66        let key = parts.pop()?;
67        let control = parts.iter().any(|part| *part == "cmd" || *part == "ctrl");
68        let mut keys: Vec<&str> = Vec::new();
69        if control {
70            keys.push("cmd");
71        }
72        keys.extend(
73            parts
74                .iter()
75                .filter(|part| **part == "alt" || **part == "shift"),
76        );
77        keys.push(key);
78        Some(keys.join("+"))
79    };
80    matches!((pressed(a), pressed(b)), (Some(a), Some(b)) if a == b)
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn shortcuts_normalize_their_modifiers_and_key() {
89        assert_eq!(parse_shortcut("Shift + Cmd + K").unwrap(), "cmd+shift+k");
90        assert_eq!(parse_shortcut("space").unwrap(), "space");
91        assert_eq!(parse_shortcut("alt+ctrl+left").unwrap(), "ctrl+alt+left");
92        assert_eq!(parse_shortcut("cmd+,").unwrap(), "cmd+,");
93        // `+` joins the parts: it is no key of its own.
94        assert!(parse_shortcut("cmd++").is_err());
95        assert!(parse_shortcut("").is_err());
96        assert!(parse_shortcut("cmd+cmd+k").is_err());
97        assert!(parse_shortcut("hyper+k").is_err());
98        assert!(parse_shortcut("cmd+f13").is_err());
99    }
100
101    #[test]
102    fn control_and_command_are_one_key_away_from_apple() {
103        assert!(same_keys("cmd+k", "ctrl+k", false));
104        assert!(!same_keys("cmd+k", "ctrl+k", true));
105        assert!(same_keys("shift+cmd+k", "cmd+shift+k", true));
106        assert!(!same_keys("", "", true));
107        assert!(!same_keys("cmd+k", "cmd+j", false));
108    }
109}