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