Skip to main content

computer_use/
keys.rs

1//! Platform-neutral key chords parsed from xdotool-style specifications.
2//!
3//! ```
4//! use computer_use::keys::{Key, KeyChord, Modifiers};
5//!
6//! let chord: KeyChord = "super+shift+t".parse()?;
7//! assert_eq!(chord.modifiers, Modifiers::META | Modifiers::SHIFT);
8//! assert_eq!(chord.key, Key::Char('t'));
9//! # Ok::<(), computer_use::Error>(())
10//! ```
11
12use std::fmt;
13use std::str::FromStr;
14
15use crate::error::Error;
16
17bitflags::bitflags! {
18    /// Modifier keys held during a key press.
19    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
20    pub struct Modifiers: u8 {
21        /// Shift.
22        const SHIFT = 1 << 0;
23        /// Control.
24        const CONTROL = 1 << 1;
25        /// Alt / Option.
26        const ALT = 1 << 2;
27        /// Command on macOS, the Windows key on Windows, Super on Linux.
28        const META = 1 << 3;
29        /// Fn (macOS only; ignored elsewhere).
30        const FUNCTION = 1 << 4;
31    }
32}
33
34/// A key on a numeric keypad.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36#[non_exhaustive]
37pub enum KeypadKey {
38    /// Digits 0–9.
39    Digit(u8),
40    /// Keypad Enter.
41    Enter,
42    /// `=`.
43    Equal,
44    /// `*`.
45    Multiply,
46    /// `+`.
47    Add,
48    /// `-`.
49    Subtract,
50    /// `.`.
51    Decimal,
52    /// `/`.
53    Divide,
54}
55
56/// A single physical or logical key.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58#[non_exhaustive]
59pub enum Key {
60    /// A printable character (letters are stored lowercase).
61    Char(char),
62    /// Return / Enter.
63    Return,
64    /// Tab.
65    Tab,
66    /// Space bar.
67    Space,
68    /// Escape.
69    Escape,
70    /// Backspace (macOS "delete").
71    Backspace,
72    /// Forward delete.
73    Delete,
74    /// Insert (Help on macOS).
75    Insert,
76    /// Arrow up.
77    Up,
78    /// Arrow down.
79    Down,
80    /// Arrow left.
81    Left,
82    /// Arrow right.
83    Right,
84    /// Home.
85    Home,
86    /// End.
87    End,
88    /// Page up.
89    PageUp,
90    /// Page down.
91    PageDown,
92    /// Caps lock.
93    CapsLock,
94    /// Context menu key.
95    Menu,
96    /// Function keys F1–F24.
97    F(u8),
98    /// Numeric keypad keys.
99    Keypad(KeypadKey),
100    /// A modifier pressed on its own.
101    Modifier(Modifiers),
102}
103
104/// A key plus the modifiers held while pressing it.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
106pub struct KeyChord {
107    /// Held modifiers.
108    pub modifiers: Modifiers,
109    /// The key pressed.
110    pub key: Key,
111}
112
113impl KeyChord {
114    /// A chord without modifiers.
115    #[must_use]
116    pub const fn new(key: Key) -> Self {
117        Self { modifiers: Modifiers::empty(), key }
118    }
119
120    /// Adds modifiers to the chord.
121    #[must_use]
122    pub const fn with(mut self, modifiers: Modifiers) -> Self {
123        self.modifiers = self.modifiers.union(modifiers);
124        self
125    }
126}
127
128impl FromStr for KeyChord {
129    type Err = Error;
130
131    fn from_str(spec: &str) -> Result<Self, Self::Err> {
132        parse_chord(spec)
133    }
134}
135
136impl fmt::Display for KeyChord {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        for (flag, name) in [
139            (Modifiers::CONTROL, "ctrl"),
140            (Modifiers::ALT, "alt"),
141            (Modifiers::SHIFT, "shift"),
142            (Modifiers::META, "super"),
143            (Modifiers::FUNCTION, "fn"),
144        ] {
145            if self.modifiers.contains(flag) {
146                write!(f, "{name}+")?;
147            }
148        }
149        match self.key {
150            Key::Char(c) => write!(f, "{c}"),
151            Key::F(n) => write!(f, "F{n}"),
152            Key::Keypad(KeypadKey::Digit(d)) => write!(f, "KP_{d}"),
153            Key::Keypad(k) => write!(f, "KP_{k:?}"),
154            Key::Modifier(m) => write!(f, "{m:?}"),
155            other => write!(f, "{other:?}"),
156        }
157    }
158}
159
160fn parse_chord(spec: &str) -> Result<KeyChord, Error> {
161    let spec = spec.trim();
162    if spec.is_empty() {
163        return Err(Error::invalid("key specification is empty"));
164    }
165    let (body, trailing_plus) = match spec.strip_suffix("++") {
166        Some(rest) => (rest, true),
167        None if spec == "+" => ("", true),
168        None => (spec, false),
169    };
170    let mut tokens: Vec<&str> = body.split('+').map(str::trim).filter(|t| !t.is_empty()).collect();
171    let key_token = if trailing_plus {
172        "+"
173    } else {
174        tokens.pop().ok_or_else(|| Error::invalid(format!("unsupported key '{spec}'")))?
175    };
176
177    let mut modifiers = Modifiers::empty();
178    for token in tokens {
179        modifiers |= parse_modifier(token)
180            .ok_or_else(|| Error::invalid(format!("unsupported modifier '{token}' in '{spec}'")))?;
181    }
182    let key = parse_key(key_token).ok_or_else(|| Error::invalid(format!("unsupported key '{spec}'")))?;
183    if let Key::Char(c) = key
184        && key_token.chars().next().is_some_and(char::is_uppercase)
185        && key_token.chars().count() == 1
186    {
187        return Ok(KeyChord { modifiers: modifiers | Modifiers::SHIFT, key: Key::Char(c) });
188    }
189    Ok(KeyChord { modifiers, key })
190}
191
192fn parse_modifier(token: &str) -> Option<Modifiers> {
193    Some(match token.to_ascii_lowercase().as_str() {
194        "shift" | "shift_l" | "shift_r" => Modifiers::SHIFT,
195        "ctrl" | "control" | "control_l" | "control_r" | "ctrl_l" | "ctrl_r" => Modifiers::CONTROL,
196        "alt" | "option" | "opt" | "alt_l" | "alt_r" | "mod1" => Modifiers::ALT,
197        "super" | "cmd" | "command" | "meta" | "win" | "windows" | "super_l" | "super_r" | "meta_l" | "meta_r"
198        | "mod4" => Modifiers::META,
199        "fn" | "function" => Modifiers::FUNCTION,
200        _ => return None,
201    })
202}
203
204fn parse_key(token: &str) -> Option<Key> {
205    let mut chars = token.chars();
206    if let (Some(c), None) = (chars.next(), chars.next()) {
207        return Some(match c {
208            ' ' => Key::Space,
209            '\n' | '\r' => Key::Return,
210            '\t' => Key::Tab,
211            c => Key::Char(c.to_lowercase().next().unwrap_or(c)),
212        });
213    }
214    if let Some(m) = parse_modifier(token) {
215        return Some(Key::Modifier(m));
216    }
217    let lower = token.to_ascii_lowercase().replace([' ', '-'], "_");
218    if let Some(n) = lower.strip_prefix('f').and_then(|n| n.parse::<u8>().ok())
219        && (1..=24).contains(&n)
220    {
221        return Some(Key::F(n));
222    }
223    if let Some(rest) = lower.strip_prefix("kp_") {
224        return parse_keypad(rest);
225    }
226    Some(match lower.as_str() {
227        "return" | "enter" => Key::Return,
228        "tab" | "iso_left_tab" => Key::Tab,
229        "space" | "spacebar" => Key::Space,
230        "escape" | "esc" => Key::Escape,
231        "backspace" => Key::Backspace,
232        "delete" | "del" | "forwarddelete" | "forward_delete" => Key::Delete,
233        "insert" | "ins" | "help" => Key::Insert,
234        "up" | "arrowup" | "arrow_up" | "uparrow" => Key::Up,
235        "down" | "arrowdown" | "arrow_down" | "downarrow" => Key::Down,
236        "left" | "arrowleft" | "arrow_left" | "leftarrow" => Key::Left,
237        "right" | "arrowright" | "arrow_right" | "rightarrow" => Key::Right,
238        "home" => Key::Home,
239        "end" => Key::End,
240        "page_up" | "pageup" | "prior" => Key::PageUp,
241        "page_down" | "pagedown" | "next" => Key::PageDown,
242        "caps_lock" | "capslock" => Key::CapsLock,
243        "menu" | "apps" | "contextmenu" => Key::Menu,
244        _ => Key::Char(keysym_char(&lower)?),
245    })
246}
247
248fn parse_keypad(rest: &str) -> Option<Key> {
249    if let Ok(d) = rest.parse::<u8>()
250        && d <= 9
251    {
252        return Some(Key::Keypad(KeypadKey::Digit(d)));
253    }
254    Some(match rest {
255        "enter" => Key::Keypad(KeypadKey::Enter),
256        "equal" => Key::Keypad(KeypadKey::Equal),
257        "multiply" => Key::Keypad(KeypadKey::Multiply),
258        "add" => Key::Keypad(KeypadKey::Add),
259        "subtract" => Key::Keypad(KeypadKey::Subtract),
260        "decimal" | "delete" => Key::Keypad(KeypadKey::Decimal),
261        "divide" => Key::Keypad(KeypadKey::Divide),
262        "home" => Key::Home,
263        "end" => Key::End,
264        "left" => Key::Left,
265        "right" => Key::Right,
266        "up" => Key::Up,
267        "down" => Key::Down,
268        "prior" | "page_up" => Key::PageUp,
269        "next" | "page_down" => Key::PageDown,
270        "insert" => Key::Insert,
271        _ => return None,
272    })
273}
274
275fn keysym_char(name: &str) -> Option<char> {
276    Some(match name {
277        "minus" => '-',
278        "equal" => '=',
279        "plus" => '+',
280        "bracketleft" => '[',
281        "bracketright" => ']',
282        "braceleft" => '{',
283        "braceright" => '}',
284        "backslash" => '\\',
285        "bar" => '|',
286        "semicolon" => ';',
287        "colon" => ':',
288        "apostrophe" | "quoteright" => '\'',
289        "quotedbl" => '"',
290        "grave" | "quoteleft" => '`',
291        "asciitilde" => '~',
292        "comma" => ',',
293        "period" => '.',
294        "slash" => '/',
295        "question" => '?',
296        "less" => '<',
297        "greater" => '>',
298        "exclam" => '!',
299        "at" => '@',
300        "numbersign" => '#',
301        "dollar" => '$',
302        "percent" => '%',
303        "asciicircum" => '^',
304        "ampersand" => '&',
305        "asterisk" => '*',
306        "parenleft" => '(',
307        "parenright" => ')',
308        "underscore" => '_',
309        _ => return None,
310    })
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    fn chord(spec: &str) -> KeyChord {
318        spec.parse().unwrap_or_else(|e| panic!("{spec}: {e}"))
319    }
320
321    #[test]
322    fn parses_xdotool_names() {
323        assert_eq!(chord("Return").key, Key::Return);
324        assert_eq!(chord("BackSpace").key, Key::Backspace);
325        assert_eq!(chord("KP_0").key, Key::Keypad(KeypadKey::Digit(0)));
326        assert_eq!(chord("Page_Up").key, Key::PageUp);
327        assert_eq!(chord("Next").key, Key::PageDown);
328        assert_eq!(chord("F12").key, Key::F(12));
329        assert_eq!(chord("bracketleft").key, Key::Char('['));
330        assert_eq!(chord("space").key, Key::Space);
331    }
332
333    #[test]
334    fn parses_modifiers() {
335        let c = chord("super+c");
336        assert_eq!(c.modifiers, Modifiers::META);
337        assert_eq!(c.key, Key::Char('c'));
338        let c = chord("ctrl + shift + Tab");
339        assert_eq!(c.modifiers, Modifiers::CONTROL | Modifiers::SHIFT);
340        assert_eq!(c.key, Key::Tab);
341        assert_eq!(chord("cmd+A").modifiers, Modifiers::META | Modifiers::SHIFT);
342        assert_eq!(chord("shift").key, Key::Modifier(Modifiers::SHIFT));
343    }
344
345    #[test]
346    fn parses_plus_key() {
347        let c = chord("ctrl++");
348        assert_eq!((c.modifiers, c.key), (Modifiers::CONTROL, Key::Char('+')));
349        assert_eq!(chord("+").key, Key::Char('+'));
350        assert_eq!(chord("ctrl+plus").key, Key::Char('+'));
351    }
352
353    #[test]
354    fn rejects_garbage() {
355        assert!("".parse::<KeyChord>().is_err());
356        assert!("hyper+x".parse::<KeyChord>().is_err());
357        assert!("NotAKey".parse::<KeyChord>().is_err());
358        assert!("F25".parse::<KeyChord>().is_err());
359    }
360
361    #[test]
362    fn display_round_trips() {
363        for spec in ["ctrl+shift+t", "super+F5", "alt+KP_3", "Return"] {
364            let c = chord(spec);
365            assert_eq!(chord(&c.to_string()), c, "{spec} -> {c}");
366        }
367    }
368}