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