Skip to main content

computer_use/
keys.rs

1//! Parse key combinations such as `cmd+shift+4`.
2
3use crate::error::ComputerError;
4use crate::types::Modifier;
5
6/// Modifiers plus the final key from a combo string.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct KeyCombo {
9    /// Modifiers, in the order they appeared, without duplicates.
10    pub modifiers: Vec<Modifier>,
11    /// Canonical key name. Single characters keep their case.
12    pub key: String,
13}
14
15/// Parse `cmd+shift+4`, `ctrl+alt+delete`, or a bare key such as `enter`.
16///
17/// Modifier names: `cmd`, `command`, `meta`, `super`, `ctrl`, `control`,
18/// `alt`, `option`, and `shift`. The last segment is the key.
19pub fn parse_key_combo(combo: &str) -> Result<KeyCombo, ComputerError> {
20    let trimmed = combo.trim();
21    if trimmed.is_empty() {
22        return Err(ComputerError::InvalidArguments("key combo is empty".into()));
23    }
24    let parts: Vec<&str> = trimmed.split('+').map(str::trim).collect();
25    if parts.iter().any(|part| part.is_empty()) {
26        return Err(ComputerError::InvalidArguments(format!(
27            "key combo has an empty segment: {combo:?}"
28        )));
29    }
30    let (modifier_parts, key_part) = parts.split_at(parts.len() - 1);
31    let mut modifiers = Vec::new();
32    for part in modifier_parts {
33        let modifier = parse_modifier(part).ok_or_else(|| {
34            ComputerError::InvalidArguments(format!("unknown modifier {part:?} in combo {combo:?}"))
35        })?;
36        if !modifiers.contains(&modifier) {
37            modifiers.push(modifier);
38        }
39    }
40    let key = canonical_key(key_part[0]);
41    if key.is_empty() {
42        return Err(ComputerError::InvalidArguments(
43            "key combo is missing a key".into(),
44        ));
45    }
46    if !is_known_key(&key) {
47        return Err(ComputerError::InvalidArguments(format!(
48            "unknown key {key:?}"
49        )));
50    }
51    Ok(KeyCombo { modifiers, key })
52}
53
54/// Split a space-separated sequence such as `ctrl+a Delete` into combos.
55pub fn parse_key_sequence(text: &str) -> Result<Vec<KeyCombo>, ComputerError> {
56    let trimmed = text.trim();
57    if trimmed.is_empty() {
58        return Err(ComputerError::InvalidArguments("key combo is empty".into()));
59    }
60    let mut combos = Vec::new();
61    for token in trimmed.split_whitespace() {
62        combos.push(parse_key_combo(token)?);
63    }
64    if combos.is_empty() {
65        return Err(ComputerError::InvalidArguments("key combo is empty".into()));
66    }
67    Ok(combos)
68}
69
70/// A canonical name or a single character. Unknown words are not keys.
71pub fn is_known_key(key: &str) -> bool {
72    if key.chars().count() == 1 {
73        return true;
74    }
75    let lower = key.trim().to_ascii_lowercase().replace('-', "_");
76    canonical_named(&lower).is_some()
77}
78
79/// macOS has virtual keycodes for F13–F20. F21–F24 are known names and still unsupported there.
80pub fn macos_function_gap(key: &str) -> Result<(), ComputerError> {
81    #[cfg(target_os = "macos")]
82    if matches!(key, "f21" | "f22" | "f23" | "f24") {
83        return Err(ComputerError::Unsupported(format!(
84            "{key} has no macOS virtual keycode; F13 through F20 are available"
85        )));
86    }
87    #[cfg(not(target_os = "macos"))]
88    let _ = key;
89    Ok(())
90}
91
92fn parse_modifier(text: &str) -> Option<Modifier> {
93    match text.to_ascii_lowercase().as_str() {
94        "cmd" | "command" => Some(Modifier::Command),
95        "meta" | "super" => Some(Modifier::Meta),
96        "ctrl" | "control" => Some(Modifier::Control),
97        "alt" | "option" => Some(Modifier::Alt),
98        "shift" => Some(Modifier::Shift),
99        _ => None,
100    }
101}
102
103/// Normalize aliases (`esc`, `return`, `arrow_left`) to one spelling.
104pub fn canonical_key(text: &str) -> String {
105    let trimmed = text.trim();
106    let lower = trimmed.to_ascii_lowercase().replace('-', "_");
107    if let Some(name) = canonical_named(&lower) {
108        return name.to_string();
109    }
110    if trimmed.chars().count() == 1 {
111        return trimmed.to_string();
112    }
113    lower
114}
115
116fn canonical_named(lower: &str) -> Option<&'static str> {
117    Some(match lower {
118        "enter" | "return" => "enter",
119        "tab" => "tab",
120        "esc" | "escape" => "esc",
121        "space" => "space",
122        "backspace" | "bksp" => "backspace",
123        "delete" | "del" => "delete",
124        "left" | "leftarrow" | "left_arrow" | "arrowleft" | "arrow_left" => "left",
125        "right" | "rightarrow" | "right_arrow" | "arrowright" | "arrow_right" => "right",
126        "up" | "uparrow" | "up_arrow" | "arrowup" | "arrow_up" => "up",
127        "down" | "downarrow" | "down_arrow" | "arrowdown" | "arrow_down" => "down",
128        "home" => "home",
129        "end" => "end",
130        "pageup" | "page_up" | "prior" => "pageup",
131        "pagedown" | "page_down" | "next" => "pagedown",
132        "insert" => "insert",
133        "plus" => "plus",
134        "minus" => "minus",
135        "f1" => "f1",
136        "f2" => "f2",
137        "f3" => "f3",
138        "f4" => "f4",
139        "f5" => "f5",
140        "f6" => "f6",
141        "f7" => "f7",
142        "f8" => "f8",
143        "f9" => "f9",
144        "f10" => "f10",
145        "f11" => "f11",
146        "f12" => "f12",
147        "f13" => "f13",
148        "f14" => "f14",
149        "f15" => "f15",
150        "f16" => "f16",
151        "f17" => "f17",
152        "f18" => "f18",
153        "f19" => "f19",
154        "f20" => "f20",
155        "f21" => "f21",
156        "f22" => "f22",
157        "f23" => "f23",
158        "f24" => "f24",
159        "shift" => "shift",
160        "ctrl" | "control" => "control",
161        "alt" | "option" => "alt",
162        "cmd" | "command" | "meta" | "super" => "meta",
163        _ => return None,
164    })
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    fn modifiers(combo: &str) -> (Vec<Modifier>, String) {
172        let parsed = parse_key_combo(combo).unwrap();
173        (parsed.modifiers, parsed.key)
174    }
175
176    #[test]
177    fn parses_modifiers_and_named_keys() {
178        assert_eq!(modifiers("space"), (vec![], "space".into()));
179        assert_eq!(modifiers("Enter"), (vec![], "enter".into()));
180        assert_eq!(modifiers("ESC"), (vec![], "esc".into()));
181        assert_eq!(modifiers("F12"), (vec![], "f12".into()));
182        assert_eq!(modifiers("arrow_left"), (vec![], "left".into()));
183        assert_eq!(modifiers("page_up"), (vec![], "pageup".into()));
184
185        assert_eq!(modifiers("cmd+c"), (vec![Modifier::Command], "c".into()));
186        assert_eq!(
187            modifiers("command+v"),
188            (vec![Modifier::Command], "v".into())
189        );
190        assert_eq!(modifiers("meta+a"), (vec![Modifier::Meta], "a".into()));
191        assert_eq!(modifiers("super+tab"), (vec![Modifier::Meta], "tab".into()));
192        assert_eq!(
193            modifiers("ctrl+shift+tab"),
194            (vec![Modifier::Control, Modifier::Shift], "tab".into())
195        );
196        assert_eq!(
197            modifiers("control+z"),
198            (vec![Modifier::Control], "z".into())
199        );
200        assert_eq!(modifiers("alt+f4"), (vec![Modifier::Alt], "f4".into()));
201        assert_eq!(modifiers("option+q"), (vec![Modifier::Alt], "q".into()));
202        assert_eq!(modifiers("shift+A"), (vec![Modifier::Shift], "A".into()));
203        assert_eq!(
204            modifiers(" cmd + shift + 4 "),
205            (vec![Modifier::Command, Modifier::Shift], "4".into())
206        );
207        assert_eq!(
208            modifiers("Ctrl+Alt+Delete"),
209            (vec![Modifier::Control, Modifier::Alt], "delete".into())
210        );
211    }
212
213    #[test]
214    fn dedups_repeated_modifiers() {
215        let (mods, key) = modifiers("shift+shift+a");
216        assert_eq!(mods, vec![Modifier::Shift]);
217        assert_eq!(key, "a");
218    }
219
220    #[test]
221    fn rejects_empty_unknown_and_dangling_plus() {
222        assert!(parse_key_combo("").is_err());
223        assert!(parse_key_combo("   ").is_err());
224        assert!(parse_key_combo("cmd+").is_err());
225        assert!(parse_key_combo("+c").is_err());
226        assert!(parse_key_combo("foo+c").is_err());
227        assert!(parse_key_combo("cmd++c").is_err());
228        assert!(parse_key_combo("bogus").is_err());
229    }
230
231    #[test]
232    fn xdotool_names_and_sequences() {
233        assert_eq!(modifiers("super+c"), (vec![Modifier::Meta], "c".into()));
234        assert_eq!(modifiers("Return"), (vec![], "enter".into()));
235        assert_eq!(
236            modifiers("ctrl+alt+Delete"),
237            (vec![Modifier::Control, Modifier::Alt], "delete".into())
238        );
239        assert_eq!(modifiers("plus"), (vec![], "plus".into()));
240        assert_eq!(
241            modifiers("shift+plus"),
242            (vec![Modifier::Shift], "plus".into())
243        );
244        assert_eq!(modifiers("minus"), (vec![], "minus".into()));
245        assert_eq!(modifiers("F24"), (vec![], "f24".into()));
246        assert_eq!(modifiers("Prior"), (vec![], "pageup".into()));
247        assert_eq!(modifiers("Next"), (vec![], "pagedown".into()));
248        assert_eq!(modifiers("Insert"), (vec![], "insert".into()));
249        assert_eq!(modifiers("BackSpace"), (vec![], "backspace".into()));
250        let sequence = parse_key_sequence("ctrl+a Delete").unwrap();
251        assert_eq!(sequence.len(), 2);
252        assert_eq!(sequence[0].key, "a");
253        assert_eq!(sequence[1].key, "delete");
254        assert!(parse_key_sequence("bogus").is_err());
255        #[cfg(target_os = "macos")]
256        assert_eq!(macos_function_gap("f24").unwrap_err().code(), "unsupported");
257        #[cfg(not(target_os = "macos"))]
258        assert!(macos_function_gap("f24").is_ok());
259    }
260}