Skip to main content

handy_keys/types/
hotkey.rs

1//! Hotkey definitions and related types
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5use std::str::FromStr;
6
7use crate::error::{Error, Result};
8
9use super::key::Key;
10use super::modifiers::Modifiers;
11
12/// A unique identifier for a registered hotkey
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(transparent)]
15pub struct HotkeyId(pub(crate) u32);
16
17impl HotkeyId {
18    pub fn as_u32(&self) -> u32 {
19        self.0
20    }
21}
22
23/// A hotkey definition - either a key with modifiers, or modifiers only
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25pub struct Hotkey {
26    pub modifiers: Modifiers,
27    pub key: Option<Key>,
28}
29
30impl Hotkey {
31    /// Create a hotkey with modifiers and/or a key
32    ///
33    /// At least one of modifiers or key must be provided.
34    /// Returns an error if both are empty/None.
35    ///
36    /// # Examples
37    /// ```
38    /// use handy_keys::{Hotkey, Modifiers, Key};
39    ///
40    /// // With modifiers and key
41    /// let hotkey = Hotkey::new(Modifiers::CMD | Modifiers::SHIFT, Key::K).unwrap();
42    ///
43    /// // Modifier-only
44    /// let hotkey = Hotkey::new(Modifiers::CMD | Modifiers::SHIFT, None).unwrap();
45    ///
46    /// // Key-only
47    /// let hotkey = Hotkey::new(Modifiers::empty(), Key::F1).unwrap();
48    /// ```
49    pub fn new(modifiers: Modifiers, key: impl Into<Option<Key>>) -> Result<Self> {
50        let key = key.into();
51        if modifiers.is_empty() && key.is_none() {
52            return Err(Error::EmptyHotkey);
53        }
54        Ok(Self { modifiers, key })
55    }
56
57    /// Format hotkey as lowercase string (e.g., "cmd+shift+k")
58    ///
59    /// This is useful for compatibility with systems that expect lowercase
60    /// key names.
61    pub fn to_lowercase_string(&self) -> String {
62        self.to_string().to_lowercase()
63    }
64
65    /// Format hotkey using Handy-compatible key names (lowercase with full modifier names)
66    ///
67    /// Uses platform-appropriate naming:
68    /// - macOS: "command", "option", "ctrl", "shift"
69    /// - Windows/Linux: "ctrl", "alt", "super", "shift"
70    ///
71    /// Side-specific modifiers use `_left`/`_right` suffixes.
72    pub fn to_handy_string(&self) -> String {
73        #[cfg(target_os = "macos")]
74        fn mod_names(
75            mods: Modifiers,
76            left: Modifiers,
77            right: Modifiers,
78            compound: Modifiers,
79            name: &str,
80        ) -> Option<String> {
81            if mods.contains(compound) {
82                Some(name.to_string())
83            } else if mods.contains(left) {
84                Some(format!("{}_left", name))
85            } else if mods.contains(right) {
86                Some(format!("{}_right", name))
87            } else {
88                None
89            }
90        }
91
92        #[cfg(not(target_os = "macos"))]
93        fn mod_names(
94            mods: Modifiers,
95            left: Modifiers,
96            right: Modifiers,
97            compound: Modifiers,
98            name: &str,
99        ) -> Option<String> {
100            if mods.contains(compound) {
101                Some(name.to_string())
102            } else if mods.contains(left) {
103                Some(format!("{}_left", name))
104            } else if mods.contains(right) {
105                Some(format!("{}_right", name))
106            } else {
107                None
108            }
109        }
110
111        let mut parts = Vec::new();
112
113        // Ctrl
114        if let Some(s) = mod_names(
115            self.modifiers,
116            Modifiers::CTRL_LEFT,
117            Modifiers::CTRL_RIGHT,
118            Modifiers::CTRL,
119            "ctrl",
120        ) {
121            parts.push(s);
122        }
123
124        // Opt/Alt
125        #[cfg(target_os = "macos")]
126        let opt_name = "option";
127        #[cfg(not(target_os = "macos"))]
128        let opt_name = "alt";
129        if let Some(s) = mod_names(
130            self.modifiers,
131            Modifiers::OPT_LEFT,
132            Modifiers::OPT_RIGHT,
133            Modifiers::OPT,
134            opt_name,
135        ) {
136            parts.push(s);
137        }
138
139        // Shift
140        if let Some(s) = mod_names(
141            self.modifiers,
142            Modifiers::SHIFT_LEFT,
143            Modifiers::SHIFT_RIGHT,
144            Modifiers::SHIFT,
145            "shift",
146        ) {
147            parts.push(s);
148        }
149
150        // Cmd/Super
151        #[cfg(target_os = "macos")]
152        let cmd_name = "command";
153        #[cfg(not(target_os = "macos"))]
154        let cmd_name = "super";
155        if let Some(s) = mod_names(
156            self.modifiers,
157            Modifiers::CMD_LEFT,
158            Modifiers::CMD_RIGHT,
159            Modifiers::CMD,
160            cmd_name,
161        ) {
162            parts.push(s);
163        }
164
165        // Fn (macOS only)
166        #[cfg(target_os = "macos")]
167        if self.modifiers.contains(Modifiers::FN) {
168            parts.push("fn".to_string());
169        }
170
171        if let Some(key) = &self.key {
172            let key_str = key.to_string().to_lowercase();
173            let mut result = parts.join("+");
174            if !result.is_empty() {
175                result.push('+');
176            }
177            result.push_str(&key_str);
178            result
179        } else {
180            parts.join("+")
181        }
182    }
183}
184
185impl fmt::Display for Hotkey {
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        if self.modifiers.is_empty() {
188            if let Some(key) = &self.key {
189                write!(f, "{}", key)
190            } else {
191                write!(f, "(none)")
192            }
193        } else if let Some(key) = &self.key {
194            write!(f, "{}+{}", self.modifiers, key)
195        } else {
196            write!(f, "{}", self.modifiers)
197        }
198    }
199}
200
201impl FromStr for Hotkey {
202    type Err = Error;
203
204    /// Parse a hotkey from a string like "Cmd+Shift+K" or "Ctrl+Space"
205    ///
206    /// # Examples
207    /// ```
208    /// use handy_keys::Hotkey;
209    ///
210    /// let hotkey: Hotkey = "Cmd+Shift+K".parse().unwrap();
211    /// let hotkey: Hotkey = "Ctrl+Alt+Delete".parse().unwrap();
212    /// let hotkey: Hotkey = "KeypadPlus".parse().unwrap();
213    /// let hotkey: Hotkey = "F1".parse().unwrap();  // Key only
214    /// let hotkey: Hotkey = "Cmd+Shift".parse().unwrap();  // Modifiers only
215    /// ```
216    fn from_str(s: &str) -> Result<Self> {
217        let s = s.trim();
218        if s.is_empty() {
219            return Err(Error::EmptyHotkey);
220        }
221
222        let parts: Vec<&str> = s.split('+').map(|p| p.trim()).collect();
223
224        let mut modifiers = Modifiers::empty();
225        let mut key: Option<Key> = None;
226
227        for part in parts {
228            if part.is_empty() {
229                continue;
230            }
231
232            // Try to parse as modifier first
233            if let Some(m) = Modifiers::parse_single(part) {
234                modifiers |= m;
235            } else {
236                // Not a modifier, must be a key
237                if key.is_some() {
238                    return Err(Error::InvalidHotkeyFormat(format!(
239                        "Multiple keys specified: already have a key, found '{}'",
240                        part
241                    )));
242                }
243                key = Some(Key::from_str(part)?);
244            }
245        }
246
247        Hotkey::new(modifiers, key)
248    }
249}
250
251/// The state of a hotkey (pressed or released)
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
253pub enum HotkeyState {
254    /// The hotkey was just pressed
255    Pressed,
256    /// The hotkey was just released
257    Released,
258}
259
260/// Event emitted when a hotkey is pressed or released
261#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
262pub struct HotkeyEvent {
263    pub id: HotkeyId,
264    pub state: HotkeyState,
265}
266
267/// Event emitted during key recording
268#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
269pub struct KeyEvent {
270    pub modifiers: Modifiers,
271    pub key: Option<Key>,
272    pub is_key_down: bool,
273    /// For modifier-only events (FlagsChanged), indicates which modifier changed.
274    /// `None` for regular key events.
275    pub changed_modifier: Option<Modifiers>,
276}
277
278impl KeyEvent {
279    /// Convert this key event to a hotkey definition
280    pub fn as_hotkey(&self) -> Result<Hotkey> {
281        Hotkey::new(self.modifiers, self.key)
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn parse_modifier_plus_key() {
291        let hotkey: Hotkey = "Cmd+K".parse().unwrap();
292        assert_eq!(hotkey.modifiers, Modifiers::CMD);
293        assert_eq!(hotkey.key, Some(Key::K));
294    }
295
296    #[test]
297    fn parse_multiple_modifiers_plus_key() {
298        let hotkey: Hotkey = "Cmd+Shift+K".parse().unwrap();
299        assert_eq!(hotkey.modifiers, Modifiers::CMD | Modifiers::SHIFT);
300        assert_eq!(hotkey.key, Some(Key::K));
301
302        let hotkey: Hotkey = "Ctrl+Alt+Delete".parse().unwrap();
303        assert_eq!(hotkey.modifiers, Modifiers::CTRL | Modifiers::OPT);
304        assert_eq!(hotkey.key, Some(Key::Delete));
305    }
306
307    #[test]
308    fn parse_key_only() {
309        let hotkey: Hotkey = "F1".parse().unwrap();
310        assert_eq!(hotkey.modifiers, Modifiers::empty());
311        assert_eq!(hotkey.key, Some(Key::F1));
312
313        let hotkey: Hotkey = "Space".parse().unwrap();
314        assert_eq!(hotkey.modifiers, Modifiers::empty());
315        assert_eq!(hotkey.key, Some(Key::Space));
316    }
317
318    #[test]
319    fn parse_modifiers_only() {
320        let hotkey: Hotkey = "Cmd+Shift".parse().unwrap();
321        assert_eq!(hotkey.modifiers, Modifiers::CMD | Modifiers::SHIFT);
322        assert_eq!(hotkey.key, None);
323    }
324
325    #[test]
326    fn parse_side_specific_hotkey() {
327        let hotkey: Hotkey = "CtrlRight+Space".parse().unwrap();
328        assert_eq!(hotkey.modifiers, Modifiers::CTRL_RIGHT);
329        assert_eq!(hotkey.key, Some(Key::Space));
330
331        let hotkey: Hotkey = "CmdLeft+ShiftRight+K".parse().unwrap();
332        assert_eq!(
333            hotkey.modifiers,
334            Modifiers::CMD_LEFT | Modifiers::SHIFT_RIGHT
335        );
336        assert_eq!(hotkey.key, Some(Key::K));
337    }
338
339    #[test]
340    fn parse_empty_fails() {
341        assert!("".parse::<Hotkey>().is_err());
342    }
343
344    #[test]
345    fn parse_multiple_keys_fails() {
346        assert!("A+B".parse::<Hotkey>().is_err());
347        assert!("Cmd+A+B".parse::<Hotkey>().is_err());
348    }
349
350    #[test]
351    fn parse_case_insensitive() {
352        let h1: Hotkey = "CMD+SHIFT+K".parse().unwrap();
353        let h2: Hotkey = "cmd+shift+k".parse().unwrap();
354        let h3: Hotkey = "Cmd+Shift+K".parse().unwrap();
355        assert_eq!(h1, h2);
356        assert_eq!(h2, h3);
357    }
358
359    #[test]
360    fn hotkey_display() {
361        let hotkey = Hotkey::new(Modifiers::CMD | Modifiers::SHIFT, Key::K).unwrap();
362        let displayed = format!("{}", hotkey);
363        assert!(displayed.contains("Cmd"));
364        assert!(displayed.contains("Shift"));
365        assert!(displayed.contains("K"));
366    }
367
368    #[test]
369    fn hotkey_display_roundtrip_keypad() {
370        // Ensure all keypad keys roundtrip through Hotkey Display → FromStr
371        let keypad_keys = [
372            Key::KeypadPlus,
373            Key::KeypadMinus,
374            Key::KeypadMultiply,
375            Key::KeypadDivide,
376            Key::KeypadDecimal,
377            Key::KeypadEquals,
378            Key::KeypadEnter,
379            Key::KeypadClear,
380        ];
381        for key in keypad_keys {
382            // Key-only hotkey
383            let hotkey = Hotkey::new(Modifiers::empty(), key).unwrap();
384            let displayed = format!("{}", hotkey);
385            let parsed: Hotkey = displayed.parse().unwrap_or_else(|e| {
386                panic!("Failed to parse '{}' (from {:?}): {}", displayed, key, e)
387            });
388            assert_eq!(parsed, hotkey, "Key-only roundtrip failed for {:?}", key);
389
390            // With modifier
391            let hotkey = Hotkey::new(Modifiers::CMD, key).unwrap();
392            let displayed = format!("{}", hotkey);
393            let parsed: Hotkey = displayed.parse().unwrap_or_else(|e| {
394                panic!("Failed to parse '{}' (from Cmd+{:?}): {}", displayed, key, e)
395            });
396            assert_eq!(parsed, hotkey, "Cmd+{:?} roundtrip failed", key);
397        }
398    }
399
400    #[test]
401    fn hotkey_new_validates() {
402        // Valid combinations
403        assert!(Hotkey::new(Modifiers::CMD, Key::K).is_ok());
404        assert!(Hotkey::new(Modifiers::CMD | Modifiers::SHIFT, None).is_ok());
405        assert!(Hotkey::new(Modifiers::empty(), Key::F1).is_ok());
406
407        // Invalid: no modifiers and no key
408        assert!(Hotkey::new(Modifiers::empty(), None).is_err());
409    }
410}