Skip to main content

openlogi_core/binding/
key_combo.rs

1//! Platform-neutral keyboard shortcut vocabulary and parser.
2
3use std::str::FromStr;
4
5use nutype::nutype;
6use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
7use thiserror::Error;
8
9const MOD_COMMAND: u8 = 1 << 0;
10const MOD_SHIFT: u8 = 1 << 1;
11const MOD_CONTROL: u8 = 1 << 2;
12const MOD_OPTION: u8 = 1 << 3;
13const MOD_SUPER: u8 = 1 << 4;
14const ALL_MODIFIERS: u8 = MOD_COMMAND | MOD_SHIFT | MOD_CONTROL | MOD_OPTION | MOD_SUPER;
15
16/// USB HID keyboard usage supported by custom shortcuts.
17///
18/// Persisting a standard HID usage keeps the config independent of macOS
19/// virtual keys, Linux evdev codes, and Windows virtual-key codes. Unknown
20/// values are rejected during deserialization rather than silently ignored.
21#[nutype(
22    const_fn,
23    validate(with = validate_keyboard_usage, error = KeyboardUsageError),
24    derive(Clone, Copy, Debug, PartialEq, Eq, Hash, TryFrom, Into, Serialize, Deserialize),
25)]
26pub struct KeyboardUsage(u8);
27
28impl KeyboardUsage {
29    /// Raw USB HID usage ID for platform injection backends.
30    #[must_use]
31    pub const fn code(self) -> u8 {
32        self.into_inner()
33    }
34
35    /// The character this key types without modifiers on a US layout, for
36    /// backends that must find the key typing it under the user's layout.
37    /// `None` for keys a layout never moves: function, arrow, and editing
38    /// keys keep their position.
39    #[must_use]
40    pub fn ascii_char(self) -> Option<char> {
41        let code = self.into_inner();
42        match code {
43            0x04..=0x1d => Some(char::from(b'a' + code - 0x04)),
44            0x1e..=0x26 => Some(char::from(b'1' + code - 0x1e)),
45            0x27 => Some('0'),
46            0x2d => Some('-'),
47            0x2e => Some('='),
48            0x2f => Some('['),
49            0x30 => Some(']'),
50            0x31 => Some('\\'),
51            0x33 => Some(';'),
52            0x34 => Some('\''),
53            0x35 => Some('`'),
54            0x36 => Some(','),
55            0x37 => Some('.'),
56            0x38 => Some('/'),
57            _ => None,
58        }
59    }
60
61    fn label(self) -> String {
62        let code = self.into_inner();
63        match code {
64            0x04..=0x1d => char::from(b'A' + code - 0x04).to_string(),
65            0x1e..=0x26 => char::from(b'1' + code - 0x1e).to_string(),
66            0x27 => "0".to_string(),
67            0x28 => "Enter".to_string(),
68            0x29 => "Escape".to_string(),
69            0x2a => "Backspace".to_string(),
70            0x2b => "Tab".to_string(),
71            0x2c => "Space".to_string(),
72            0x2d => "-".to_string(),
73            0x2e => "=".to_string(),
74            0x2f => "[".to_string(),
75            0x30 => "]".to_string(),
76            0x31 => "\\".to_string(),
77            0x33 => ";".to_string(),
78            0x34 => "'".to_string(),
79            0x35 => "`".to_string(),
80            0x36 => ",".to_string(),
81            0x37 => ".".to_string(),
82            0x38 => "/".to_string(),
83            0x3a..=0x45 => format!("F{}", code - 0x3a + 1),
84            0x4a => "Home".to_string(),
85            0x4b => "PageUp".to_string(),
86            0x4c => "Delete".to_string(),
87            0x4d => "End".to_string(),
88            0x4e => "PageDown".to_string(),
89            0x4f => "Right".to_string(),
90            0x50 => "Left".to_string(),
91            0x51 => "Down".to_string(),
92            0x52 => "Up".to_string(),
93            0x68..=0x6f => format!("F{}", code - 0x68 + 13),
94            _ => format!("Usage 0x{code:02X}"),
95        }
96    }
97}
98
99/// Unsupported USB HID usage found in a shortcut payload.
100#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
101#[error("unsupported keyboard usage: {0:#04x}")]
102pub struct KeyboardUsageError(pub u8);
103
104#[expect(
105    clippy::trivially_copy_pass_by_ref,
106    reason = "nutype custom validators receive a reference to the wrapped value"
107)]
108const fn validate_keyboard_usage(value: &u8) -> Result<(), KeyboardUsageError> {
109    if matches!(
110        value,
111        0x04..=0x31 | 0x33..=0x38 | 0x3a..=0x45 | 0x4a..=0x52 | 0x68..=0x6f
112    ) {
113        Ok(())
114    } else {
115        Err(KeyboardUsageError(*value))
116    }
117}
118
119/// A platform-neutral keyboard chord.
120///
121/// Human-readable formats store the canonical text chord; binary IPC stores
122/// validated modifier bits and a USB HID usage.
123#[derive(Clone, Debug, PartialEq, Eq, Hash)]
124pub struct KeyCombo {
125    modifiers: u8,
126    key: KeyboardUsage,
127}
128
129#[derive(Serialize, Deserialize)]
130struct KeyComboWire {
131    modifiers: u8,
132    key: KeyboardUsage,
133}
134
135impl TryFrom<KeyComboWire> for KeyCombo {
136    type Error = KeyComboParseError;
137
138    fn try_from(value: KeyComboWire) -> Result<Self, Self::Error> {
139        if value.modifiers & !ALL_MODIFIERS != 0 {
140            return Err(KeyComboParseError::InvalidModifiers(value.modifiers));
141        }
142        Ok(Self {
143            modifiers: value.modifiers,
144            key: value.key,
145        })
146    }
147}
148
149impl From<KeyCombo> for KeyComboWire {
150    fn from(value: KeyCombo) -> Self {
151        Self {
152            modifiers: value.modifiers,
153            key: value.key,
154        }
155    }
156}
157
158impl Serialize for KeyCombo {
159    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
160    where
161        S: Serializer,
162    {
163        if serializer.is_human_readable() {
164            serializer.serialize_str(&self.rendered_label())
165        } else {
166            KeyComboWire {
167                modifiers: self.modifiers,
168                key: self.key,
169            }
170            .serialize(serializer)
171        }
172    }
173}
174
175impl<'de> Deserialize<'de> for KeyCombo {
176    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
177    where
178        D: Deserializer<'de>,
179    {
180        if deserializer.is_human_readable() {
181            String::deserialize(deserializer)?
182                .parse()
183                .map_err(de::Error::custom)
184        } else {
185            Self::try_from(KeyComboWire::deserialize(deserializer)?).map_err(de::Error::custom)
186        }
187    }
188}
189
190impl KeyCombo {
191    /// USB HID keyboard usage for the ordinary key.
192    #[must_use]
193    pub const fn key(&self) -> KeyboardUsage {
194        self.key
195    }
196
197    /// Whether the chord includes Command, the cross-platform primary modifier:
198    /// Command on macOS, Control on Linux and Windows.
199    #[must_use]
200    pub const fn has_command(&self) -> bool {
201        self.modifiers & MOD_COMMAND != 0
202    }
203
204    /// Whether the chord includes Shift.
205    #[must_use]
206    pub const fn has_shift(&self) -> bool {
207        self.modifiers & MOD_SHIFT != 0
208    }
209
210    /// Whether the chord includes Control.
211    #[must_use]
212    pub const fn has_control(&self) -> bool {
213        self.modifiers & MOD_CONTROL != 0
214    }
215
216    /// Whether the chord includes Option/Alt.
217    #[must_use]
218    pub const fn has_option(&self) -> bool {
219        self.modifiers & MOD_OPTION != 0
220    }
221
222    /// Whether the chord includes the logo key, written `Super`, `Win`, or
223    /// `Meta`: Command on macOS, the Windows key, `KEY_LEFTMETA` on Linux.
224    /// Unlike [`Self::has_command`], it never becomes Control.
225    #[must_use]
226    pub const fn has_super(&self) -> bool {
227        self.modifiers & MOD_SUPER != 0
228    }
229
230    /// Canonical user-facing chord label.
231    #[must_use]
232    pub fn rendered_label(&self) -> String {
233        match self.rendered_modifiers() {
234            Some(modifiers) => format!("{modifiers}+{}", self.rendered_key()),
235            None => self.rendered_key(),
236        }
237    }
238
239    /// Canonical label for the chord's modifiers alone, or `None` when it
240    /// carries none. An action that holds the modifiers while tapping the
241    /// ordinary key separately labels the two halves separately too.
242    #[must_use]
243    pub fn rendered_modifiers(&self) -> Option<String> {
244        let mut parts = Vec::new();
245        if self.has_command() {
246            parts.push("Cmd");
247        }
248        if self.has_super() {
249            parts.push("Super");
250        }
251        if self.has_control() {
252            parts.push("Ctrl");
253        }
254        if self.has_option() {
255            parts.push("Alt");
256        }
257        if self.has_shift() {
258            parts.push("Shift");
259        }
260        (!parts.is_empty()).then(|| parts.join("+"))
261    }
262
263    /// Canonical label for the chord's ordinary key alone.
264    #[must_use]
265    pub fn rendered_key(&self) -> String {
266        self.key.label()
267    }
268}
269
270/// Why a user-entered keyboard shortcut could not be parsed.
271#[derive(Clone, Debug, PartialEq, Eq, Error)]
272pub enum KeyComboParseError {
273    /// The shortcut field was blank.
274    #[error("keyboard shortcut must not be empty")]
275    Empty,
276    /// The shortcut contains modifiers but no ordinary key.
277    #[error("keyboard shortcut must contain a key")]
278    MissingKey,
279    /// More than one non-modifier key was entered.
280    #[error("keyboard shortcut must contain exactly one key")]
281    MultipleKeys,
282    /// A modifier or key name is not supported.
283    #[error("unsupported shortcut token: {0}")]
284    UnknownToken(String),
285    /// Serialized modifier bits contain an unknown flag.
286    #[error("unsupported shortcut modifier bits: {0:#04x}")]
287    InvalidModifiers(u8),
288}
289
290impl FromStr for KeyCombo {
291    type Err = KeyComboParseError;
292
293    fn from_str(input: &str) -> Result<Self, Self::Err> {
294        let input = input.trim();
295        if input.is_empty() {
296            return Err(KeyComboParseError::Empty);
297        }
298
299        let mut modifiers = 0;
300        let mut key = None;
301        for raw in input.split('+') {
302            let token = raw.trim();
303            if token.is_empty() {
304                return Err(KeyComboParseError::UnknownToken(raw.to_string()));
305            }
306            if let Some(modifier) = parse_modifier(token) {
307                modifiers |= modifier;
308                continue;
309            }
310            if key.is_some() {
311                return Err(KeyComboParseError::MultipleKeys);
312            }
313            key = Some(parse_key(token)?);
314        }
315        let Some(key) = key else {
316            return Err(KeyComboParseError::MissingKey);
317        };
318        Ok(Self { modifiers, key })
319    }
320}
321
322fn parse_modifier(token: &str) -> Option<u8> {
323    match token.to_ascii_lowercase().as_str() {
324        "cmd" | "command" => Some(MOD_COMMAND),
325        "super" | "win" | "meta" => Some(MOD_SUPER),
326        "shift" => Some(MOD_SHIFT),
327        "ctrl" | "control" => Some(MOD_CONTROL),
328        "alt" | "option" => Some(MOD_OPTION),
329        _ => None,
330    }
331}
332
333fn parse_key(token: &str) -> Result<KeyboardUsage, KeyComboParseError> {
334    let lowercase = token.to_ascii_lowercase();
335    let usage = if lowercase.len() == 1 {
336        let character = lowercase.chars().next().unwrap_or_default();
337        match character {
338            'a'..='z' => 0x04 + u8::try_from(character as u32 - 'a' as u32).unwrap_or_default(),
339            '1'..='9' => 0x1e + u8::try_from(character as u32 - '1' as u32).unwrap_or_default(),
340            '0' => 0x27,
341            '-' => 0x2d,
342            '=' => 0x2e,
343            '[' => 0x2f,
344            ']' => 0x30,
345            '\\' => 0x31,
346            ';' => 0x33,
347            '\'' => 0x34,
348            '`' => 0x35,
349            ',' => 0x36,
350            '.' => 0x37,
351            '/' => 0x38,
352            _ => return Err(KeyComboParseError::UnknownToken(token.to_string())),
353        }
354    } else if let Some(number) = lowercase
355        .strip_prefix('f')
356        .and_then(|number| number.parse::<u8>().ok())
357    {
358        match number {
359            1..=12 => 0x3a + number - 1,
360            13..=20 => 0x68 + number - 13,
361            _ => return Err(KeyComboParseError::UnknownToken(token.to_string())),
362        }
363    } else {
364        match lowercase.as_str() {
365            "enter" | "return" => 0x28,
366            "escape" | "esc" => 0x29,
367            "backspace" => 0x2a,
368            "tab" => 0x2b,
369            "space" => 0x2c,
370            "home" => 0x4a,
371            "pageup" | "page-up" => 0x4b,
372            "delete" => 0x4c,
373            "end" => 0x4d,
374            "pagedown" | "page-down" => 0x4e,
375            "right" => 0x4f,
376            "left" => 0x50,
377            "down" => 0x51,
378            "up" => 0x52,
379            _ => return Err(KeyComboParseError::UnknownToken(token.to_string())),
380        }
381    };
382    KeyboardUsage::try_from(usage).map_err(|_| KeyComboParseError::UnknownToken(token.to_string()))
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn ascii_char_names_the_character_a_layout_can_move() {
391        let key = |text: &str| text.parse::<KeyCombo>().expect("valid shortcut").key();
392        assert_eq!(key("Cmd+S").ascii_char(), Some('s'));
393        assert_eq!(key("Cmd+3").ascii_char(), Some('3'));
394        assert_eq!(key("Cmd+[").ascii_char(), Some('['));
395        for fixed in ["F5", "Left", "Enter", "Escape", "Tab"] {
396            assert_eq!(key(fixed).ascii_char(), None, "{fixed}");
397        }
398    }
399
400    #[test]
401    fn parses_super_as_its_own_modifier() {
402        let combo = "Super+End"
403            .parse::<KeyCombo>()
404            .expect("valid shortcut failed");
405        assert!(combo.has_super());
406        assert!(!combo.has_command());
407        assert_eq!(combo.key().code(), 0x4d);
408        assert_eq!(combo.rendered_label(), "Super+End");
409
410        // `Win` and `Meta` name the same logo key (#893), not Command.
411        for token in ["Win", "Meta"] {
412            let combo = format!("{token}+L")
413                .parse::<KeyCombo>()
414                .expect("valid shortcut failed");
415            assert!(combo.has_super(), "{token}");
416            assert!(!combo.has_command(), "{token}");
417        }
418
419        let combo = "Super+Shift+Right"
420            .parse::<KeyCombo>()
421            .expect("valid shortcut failed");
422        assert_eq!(combo.rendered_label(), "Super+Shift+Right");
423    }
424
425    #[test]
426    fn parses_modifiers_letters_and_navigation_keys() {
427        let combo = "Cmd+Shift+P"
428            .parse::<KeyCombo>()
429            .expect("valid shortcut failed");
430        assert!(combo.has_command());
431        assert!(combo.has_shift());
432        assert_eq!(combo.key().code(), 0x13);
433        assert_eq!(combo.rendered_label(), "Cmd+Shift+P");
434
435        let combo = "Ctrl+Alt+Left"
436            .parse::<KeyCombo>()
437            .expect("valid shortcut failed");
438        assert!(combo.has_control());
439        assert!(combo.has_option());
440        assert_eq!(combo.key().code(), 0x50);
441        assert_eq!(combo.rendered_label(), "Ctrl+Alt+Left");
442    }
443
444    #[test]
445    fn a_uses_its_platform_neutral_hid_usage() {
446        let combo = "Cmd+A".parse::<KeyCombo>().expect("valid shortcut failed");
447        assert_eq!(combo.key().code(), 0x04);
448        assert_eq!(combo.rendered_label(), "Cmd+A");
449    }
450
451    #[test]
452    fn rejects_missing_multiple_and_unknown_keys() {
453        assert_eq!(
454            "Cmd+Shift".parse::<KeyCombo>(),
455            Err(KeyComboParseError::MissingKey)
456        );
457        assert_eq!(
458            "Cmd+P+K".parse::<KeyCombo>(),
459            Err(KeyComboParseError::MultipleKeys)
460        );
461        assert!(matches!(
462            "Cmd+Hyper".parse::<KeyCombo>(),
463            Err(KeyComboParseError::UnknownToken(_))
464        ));
465    }
466
467    #[test]
468    fn rejects_unknown_serialized_usage_and_modifier_bits() {
469        // A bare `255` is not a TOML document, so the usage has to arrive in a
470        // wire field — otherwise the parse fails on syntax before reaching the
471        // usage guard.
472        let Err(error) = toml::from_str::<KeyComboWire>("modifiers = 0\nkey = 255") else {
473            panic!("usage 255 is not a supported HID keyboard usage and must be rejected")
474        };
475        assert!(
476            error
477                .to_string()
478                .contains(&KeyboardUsageError(255).to_string()),
479            "expected the usage guard to reject 255, got: {error}"
480        );
481        assert_eq!(
482            KeyCombo::try_from(KeyComboWire {
483                modifiers: 128,
484                key: KeyboardUsage::try_from(0x04).expect("0x04 is a valid keyboard usage"),
485            }),
486            Err(KeyComboParseError::InvalidModifiers(128))
487        );
488    }
489
490    #[test]
491    fn toml_uses_the_canonical_text_chord() {
492        #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
493        struct Wrapper {
494            shortcut: KeyCombo,
495        }
496
497        let combo = "Cmd+Shift+P"
498            .parse::<KeyCombo>()
499            .expect("valid shortcut failed");
500        let wrapper = Wrapper { shortcut: combo };
501        let encoded = toml::to_string(&wrapper).expect("shortcut serialization failed");
502        assert_eq!(encoded, "shortcut = \"Cmd+Shift+P\"\n");
503        assert_eq!(toml::from_str::<Wrapper>(&encoded), Ok(wrapper));
504    }
505}