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        let mut parts = Vec::new();
234        if self.has_command() {
235            parts.push("Cmd".to_string());
236        }
237        if self.has_super() {
238            parts.push("Super".to_string());
239        }
240        if self.has_control() {
241            parts.push("Ctrl".to_string());
242        }
243        if self.has_option() {
244            parts.push("Alt".to_string());
245        }
246        if self.has_shift() {
247            parts.push("Shift".to_string());
248        }
249        parts.push(self.key.label());
250        parts.join("+")
251    }
252}
253
254/// Why a user-entered keyboard shortcut could not be parsed.
255#[derive(Clone, Debug, PartialEq, Eq, Error)]
256pub enum KeyComboParseError {
257    /// The shortcut field was blank.
258    #[error("keyboard shortcut must not be empty")]
259    Empty,
260    /// The shortcut contains modifiers but no ordinary key.
261    #[error("keyboard shortcut must contain a key")]
262    MissingKey,
263    /// More than one non-modifier key was entered.
264    #[error("keyboard shortcut must contain exactly one key")]
265    MultipleKeys,
266    /// A modifier or key name is not supported.
267    #[error("unsupported shortcut token: {0}")]
268    UnknownToken(String),
269    /// Serialized modifier bits contain an unknown flag.
270    #[error("unsupported shortcut modifier bits: {0:#04x}")]
271    InvalidModifiers(u8),
272}
273
274impl FromStr for KeyCombo {
275    type Err = KeyComboParseError;
276
277    fn from_str(input: &str) -> Result<Self, Self::Err> {
278        let input = input.trim();
279        if input.is_empty() {
280            return Err(KeyComboParseError::Empty);
281        }
282
283        let mut modifiers = 0;
284        let mut key = None;
285        for raw in input.split('+') {
286            let token = raw.trim();
287            if token.is_empty() {
288                return Err(KeyComboParseError::UnknownToken(raw.to_string()));
289            }
290            if let Some(modifier) = parse_modifier(token) {
291                modifiers |= modifier;
292                continue;
293            }
294            if key.is_some() {
295                return Err(KeyComboParseError::MultipleKeys);
296            }
297            key = Some(parse_key(token)?);
298        }
299        let Some(key) = key else {
300            return Err(KeyComboParseError::MissingKey);
301        };
302        Ok(Self { modifiers, key })
303    }
304}
305
306fn parse_modifier(token: &str) -> Option<u8> {
307    match token.to_ascii_lowercase().as_str() {
308        "cmd" | "command" => Some(MOD_COMMAND),
309        "super" | "win" | "meta" => Some(MOD_SUPER),
310        "shift" => Some(MOD_SHIFT),
311        "ctrl" | "control" => Some(MOD_CONTROL),
312        "alt" | "option" => Some(MOD_OPTION),
313        _ => None,
314    }
315}
316
317fn parse_key(token: &str) -> Result<KeyboardUsage, KeyComboParseError> {
318    let lowercase = token.to_ascii_lowercase();
319    let usage = if lowercase.len() == 1 {
320        let character = lowercase.chars().next().unwrap_or_default();
321        match character {
322            'a'..='z' => 0x04 + u8::try_from(character as u32 - 'a' as u32).unwrap_or_default(),
323            '1'..='9' => 0x1e + u8::try_from(character as u32 - '1' as u32).unwrap_or_default(),
324            '0' => 0x27,
325            '-' => 0x2d,
326            '=' => 0x2e,
327            '[' => 0x2f,
328            ']' => 0x30,
329            '\\' => 0x31,
330            ';' => 0x33,
331            '\'' => 0x34,
332            '`' => 0x35,
333            ',' => 0x36,
334            '.' => 0x37,
335            '/' => 0x38,
336            _ => return Err(KeyComboParseError::UnknownToken(token.to_string())),
337        }
338    } else if let Some(number) = lowercase
339        .strip_prefix('f')
340        .and_then(|number| number.parse::<u8>().ok())
341    {
342        match number {
343            1..=12 => 0x3a + number - 1,
344            13..=20 => 0x68 + number - 13,
345            _ => return Err(KeyComboParseError::UnknownToken(token.to_string())),
346        }
347    } else {
348        match lowercase.as_str() {
349            "enter" | "return" => 0x28,
350            "escape" | "esc" => 0x29,
351            "backspace" => 0x2a,
352            "tab" => 0x2b,
353            "space" => 0x2c,
354            "home" => 0x4a,
355            "pageup" | "page-up" => 0x4b,
356            "delete" => 0x4c,
357            "end" => 0x4d,
358            "pagedown" | "page-down" => 0x4e,
359            "right" => 0x4f,
360            "left" => 0x50,
361            "down" => 0x51,
362            "up" => 0x52,
363            _ => return Err(KeyComboParseError::UnknownToken(token.to_string())),
364        }
365    };
366    KeyboardUsage::try_from(usage).map_err(|_| KeyComboParseError::UnknownToken(token.to_string()))
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn ascii_char_names_the_character_a_layout_can_move() {
375        let key = |text: &str| text.parse::<KeyCombo>().expect("valid shortcut").key();
376        assert_eq!(key("Cmd+S").ascii_char(), Some('s'));
377        assert_eq!(key("Cmd+3").ascii_char(), Some('3'));
378        assert_eq!(key("Cmd+[").ascii_char(), Some('['));
379        for fixed in ["F5", "Left", "Enter", "Escape", "Tab"] {
380            assert_eq!(key(fixed).ascii_char(), None, "{fixed}");
381        }
382    }
383
384    #[test]
385    fn parses_super_as_its_own_modifier() {
386        let combo = "Super+End"
387            .parse::<KeyCombo>()
388            .expect("valid shortcut failed");
389        assert!(combo.has_super());
390        assert!(!combo.has_command());
391        assert_eq!(combo.key().code(), 0x4d);
392        assert_eq!(combo.rendered_label(), "Super+End");
393
394        // `Win` and `Meta` name the same logo key (#893), not Command.
395        for token in ["Win", "Meta"] {
396            let combo = format!("{token}+L")
397                .parse::<KeyCombo>()
398                .expect("valid shortcut failed");
399            assert!(combo.has_super(), "{token}");
400            assert!(!combo.has_command(), "{token}");
401        }
402
403        let combo = "Super+Shift+Right"
404            .parse::<KeyCombo>()
405            .expect("valid shortcut failed");
406        assert_eq!(combo.rendered_label(), "Super+Shift+Right");
407    }
408
409    #[test]
410    fn parses_modifiers_letters_and_navigation_keys() {
411        let combo = "Cmd+Shift+P"
412            .parse::<KeyCombo>()
413            .expect("valid shortcut failed");
414        assert!(combo.has_command());
415        assert!(combo.has_shift());
416        assert_eq!(combo.key().code(), 0x13);
417        assert_eq!(combo.rendered_label(), "Cmd+Shift+P");
418
419        let combo = "Ctrl+Alt+Left"
420            .parse::<KeyCombo>()
421            .expect("valid shortcut failed");
422        assert!(combo.has_control());
423        assert!(combo.has_option());
424        assert_eq!(combo.key().code(), 0x50);
425        assert_eq!(combo.rendered_label(), "Ctrl+Alt+Left");
426    }
427
428    #[test]
429    fn a_uses_its_platform_neutral_hid_usage() {
430        let combo = "Cmd+A".parse::<KeyCombo>().expect("valid shortcut failed");
431        assert_eq!(combo.key().code(), 0x04);
432        assert_eq!(combo.rendered_label(), "Cmd+A");
433    }
434
435    #[test]
436    fn rejects_missing_multiple_and_unknown_keys() {
437        assert_eq!(
438            "Cmd+Shift".parse::<KeyCombo>(),
439            Err(KeyComboParseError::MissingKey)
440        );
441        assert_eq!(
442            "Cmd+P+K".parse::<KeyCombo>(),
443            Err(KeyComboParseError::MultipleKeys)
444        );
445        assert!(matches!(
446            "Cmd+Hyper".parse::<KeyCombo>(),
447            Err(KeyComboParseError::UnknownToken(_))
448        ));
449    }
450
451    #[test]
452    fn rejects_unknown_serialized_usage_and_modifier_bits() {
453        // A bare `255` is not a TOML document, so the usage has to arrive in a
454        // wire field — otherwise the parse fails on syntax before reaching the
455        // usage guard.
456        let Err(error) = toml::from_str::<KeyComboWire>("modifiers = 0\nkey = 255") else {
457            panic!("usage 255 is not a supported HID keyboard usage and must be rejected")
458        };
459        assert!(
460            error
461                .to_string()
462                .contains(&KeyboardUsageError(255).to_string()),
463            "expected the usage guard to reject 255, got: {error}"
464        );
465        assert_eq!(
466            KeyCombo::try_from(KeyComboWire {
467                modifiers: 128,
468                key: KeyboardUsage::try_from(0x04).expect("0x04 is a valid keyboard usage"),
469            }),
470            Err(KeyComboParseError::InvalidModifiers(128))
471        );
472    }
473
474    #[test]
475    fn toml_uses_the_canonical_text_chord() {
476        #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
477        struct Wrapper {
478            shortcut: KeyCombo,
479        }
480
481        let combo = "Cmd+Shift+P"
482            .parse::<KeyCombo>()
483            .expect("valid shortcut failed");
484        let wrapper = Wrapper { shortcut: combo };
485        let encoded = toml::to_string(&wrapper).expect("shortcut serialization failed");
486        assert_eq!(encoded, "shortcut = \"Cmd+Shift+P\"\n");
487        assert_eq!(toml::from_str::<Wrapper>(&encoded), Ok(wrapper));
488    }
489}