Skip to main content

handy_keys/types/
modifiers.rs

1//! Modifier key definitions and parsing
2
3use bitflags::bitflags;
4use serde::{Deserialize, Serialize};
5use std::fmt;
6use std::str::FromStr;
7
8use crate::error::{Error, Result};
9
10bitflags! {
11    /// Modifier keys for hotkey combinations
12    ///
13    /// Individual flags track which side (left/right) was pressed.
14    /// Compound aliases (`CMD`, `SHIFT`, etc.) match either side.
15    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16    #[serde(transparent)]
17    pub struct Modifiers: u32 {
18        // Individual side-specific flags
19        const CMD_LEFT    = 1 << 0;
20        const SHIFT_LEFT  = 1 << 1;
21        const CTRL_LEFT   = 1 << 2;
22        const OPT_LEFT    = 1 << 3;
23        const FN          = 1 << 4;
24        const CMD_RIGHT   = 1 << 5;
25        const SHIFT_RIGHT = 1 << 6;
26        const CTRL_RIGHT  = 1 << 7;
27        const OPT_RIGHT   = 1 << 8;
28
29        // Compound aliases — "either side"
30        const CMD   = Self::CMD_LEFT.bits()   | Self::CMD_RIGHT.bits();
31        const SHIFT = Self::SHIFT_LEFT.bits() | Self::SHIFT_RIGHT.bits();
32        const CTRL  = Self::CTRL_LEFT.bits()  | Self::CTRL_RIGHT.bits();
33        const OPT   = Self::OPT_LEFT.bits()   | Self::OPT_RIGHT.bits();
34    }
35}
36
37/// Helper: all modifier groups as (left, right, compound) triples.
38const GROUPS: [(Modifiers, Modifiers, Modifiers); 4] = [
39    (Modifiers::CMD_LEFT, Modifiers::CMD_RIGHT, Modifiers::CMD),
40    (
41        Modifiers::SHIFT_LEFT,
42        Modifiers::SHIFT_RIGHT,
43        Modifiers::SHIFT,
44    ),
45    (Modifiers::CTRL_LEFT, Modifiers::CTRL_RIGHT, Modifiers::CTRL),
46    (Modifiers::OPT_LEFT, Modifiers::OPT_RIGHT, Modifiers::OPT),
47];
48
49impl fmt::Display for Modifiers {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        let mut parts = Vec::new();
52
53        // Ctrl group
54        if self.contains(Modifiers::CTRL) {
55            parts.push("Ctrl");
56        } else if self.contains(Modifiers::CTRL_LEFT) {
57            parts.push("CtrlLeft");
58        } else if self.contains(Modifiers::CTRL_RIGHT) {
59            parts.push("CtrlRight");
60        }
61
62        // Opt group
63        if self.contains(Modifiers::OPT) {
64            parts.push("Opt");
65        } else if self.contains(Modifiers::OPT_LEFT) {
66            parts.push("OptLeft");
67        } else if self.contains(Modifiers::OPT_RIGHT) {
68            parts.push("OptRight");
69        }
70
71        // Shift group
72        if self.contains(Modifiers::SHIFT) {
73            parts.push("Shift");
74        } else if self.contains(Modifiers::SHIFT_LEFT) {
75            parts.push("ShiftLeft");
76        } else if self.contains(Modifiers::SHIFT_RIGHT) {
77            parts.push("ShiftRight");
78        }
79
80        // Cmd group
81        if self.contains(Modifiers::CMD) {
82            parts.push("Cmd");
83        } else if self.contains(Modifiers::CMD_LEFT) {
84            parts.push("CmdLeft");
85        } else if self.contains(Modifiers::CMD_RIGHT) {
86            parts.push("CmdRight");
87        }
88
89        // Fn
90        if self.contains(Modifiers::FN) {
91            parts.push("Fn");
92        }
93
94        write!(f, "{}", parts.join("+"))
95    }
96}
97
98impl Modifiers {
99    /// Parse a single modifier name (case-insensitive)
100    pub(crate) fn parse_single(s: &str) -> Option<Modifiers> {
101        match s.to_lowercase().as_str() {
102            // Compound (either side)
103            "cmd" | "command" | "meta" | "super" | "win" | "windows" => Some(Modifiers::CMD),
104            "shift" => Some(Modifiers::SHIFT),
105            "ctrl" | "control" => Some(Modifiers::CTRL),
106            "opt" | "option" | "alt" => Some(Modifiers::OPT),
107            "fn" | "function" => Some(Modifiers::FN),
108
109            // Left-specific
110            "cmdleft" | "cmd_left" | "lcmd" | "commandleft" | "command_left" | "lcommand"
111            | "superleft" | "super_left" | "winleft" | "win_left" | "windowsleft"
112            | "windows_left" | "metaleft" | "meta_left" => Some(Modifiers::CMD_LEFT),
113            "shiftleft" | "shift_left" | "lshift" => Some(Modifiers::SHIFT_LEFT),
114            "ctrlleft" | "ctrl_left" | "lctrl" | "controlleft" | "control_left" | "lcontrol" => {
115                Some(Modifiers::CTRL_LEFT)
116            }
117            "optleft" | "opt_left" | "lopt" | "optionleft" | "option_left" | "loption"
118            | "altleft" | "alt_left" | "lalt" => Some(Modifiers::OPT_LEFT),
119
120            // Right-specific
121            "cmdright" | "cmd_right" | "rcmd" | "commandright" | "command_right" | "rcommand"
122            | "superright" | "super_right" | "winright" | "win_right" | "windowsright"
123            | "windows_right" | "metaright" | "meta_right" => Some(Modifiers::CMD_RIGHT),
124            "shiftright" | "shift_right" | "rshift" => Some(Modifiers::SHIFT_RIGHT),
125            "ctrlright" | "ctrl_right" | "rctrl" | "controlright" | "control_right"
126            | "rcontrol" => Some(Modifiers::CTRL_RIGHT),
127            "optright" | "opt_right" | "ropt" | "optionright" | "option_right" | "roption"
128            | "altright" | "alt_right" | "ralt" | "altgr" => Some(Modifiers::OPT_RIGHT),
129
130            _ => None,
131        }
132    }
133
134    /// Check whether `self` (as a hotkey pattern) matches `event` (the actual modifier state).
135    ///
136    /// For each modifier group (Cmd, Shift, Ctrl, Opt):
137    /// - Hotkey has both bits (compound): event must have at least one bit from the group
138    /// - Hotkey has a specific side: event must have that specific side (extra same-group bits OK)
139    /// - Hotkey has neither: event must not have either bit from the group
140    ///
141    /// FN is matched exactly.
142    pub fn matches(self, event: Modifiers) -> bool {
143        for &(left, right, _compound) in &GROUPS {
144            let hotkey_has_left = self.contains(left);
145            let hotkey_has_right = self.contains(right);
146            let event_has_left = event.contains(left);
147            let event_has_right = event.contains(right);
148            let event_has_any = event_has_left || event_has_right;
149
150            if hotkey_has_left && hotkey_has_right {
151                // Compound: event must have at least one
152                if !event_has_any {
153                    return false;
154                }
155            } else if hotkey_has_left {
156                // Specific left: event must have left
157                if !event_has_left {
158                    return false;
159                }
160            } else if hotkey_has_right {
161                // Specific right: event must have right
162                if !event_has_right {
163                    return false;
164                }
165            } else {
166                // Hotkey doesn't use this group: event must not have it
167                if event_has_any {
168                    return false;
169                }
170            }
171        }
172
173        // FN: exact match
174        self.contains(Modifiers::FN) == event.contains(Modifiers::FN)
175    }
176}
177
178impl FromStr for Modifiers {
179    type Err = Error;
180
181    /// Parse modifiers from a string like "Cmd+Shift" or "Ctrl+Alt"
182    fn from_str(s: &str) -> Result<Self> {
183        let s = s.trim();
184        if s.is_empty() {
185            return Ok(Modifiers::empty());
186        }
187
188        let mut modifiers = Modifiers::empty();
189        for part in s.split('+') {
190            let part = part.trim();
191            if part.is_empty() {
192                continue;
193            }
194            match Modifiers::parse_single(part) {
195                Some(m) => modifiers |= m,
196                None => return Err(Error::UnknownModifier(part.to_string())),
197            }
198        }
199        Ok(modifiers)
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn parse_single_modifiers() {
209        assert_eq!("Cmd".parse::<Modifiers>().unwrap(), Modifiers::CMD);
210        assert_eq!("command".parse::<Modifiers>().unwrap(), Modifiers::CMD);
211        assert_eq!("meta".parse::<Modifiers>().unwrap(), Modifiers::CMD);
212        assert_eq!("super".parse::<Modifiers>().unwrap(), Modifiers::CMD);
213        assert_eq!("win".parse::<Modifiers>().unwrap(), Modifiers::CMD);
214
215        assert_eq!("Shift".parse::<Modifiers>().unwrap(), Modifiers::SHIFT);
216        assert_eq!("SHIFT".parse::<Modifiers>().unwrap(), Modifiers::SHIFT);
217
218        assert_eq!("Ctrl".parse::<Modifiers>().unwrap(), Modifiers::CTRL);
219        assert_eq!("control".parse::<Modifiers>().unwrap(), Modifiers::CTRL);
220
221        assert_eq!("Opt".parse::<Modifiers>().unwrap(), Modifiers::OPT);
222        assert_eq!("option".parse::<Modifiers>().unwrap(), Modifiers::OPT);
223        assert_eq!("alt".parse::<Modifiers>().unwrap(), Modifiers::OPT);
224
225        assert_eq!("Fn".parse::<Modifiers>().unwrap(), Modifiers::FN);
226        assert_eq!("function".parse::<Modifiers>().unwrap(), Modifiers::FN);
227    }
228
229    #[test]
230    fn parse_side_specific_modifiers() {
231        assert_eq!("CmdLeft".parse::<Modifiers>().unwrap(), Modifiers::CMD_LEFT);
232        assert_eq!("LCmd".parse::<Modifiers>().unwrap(), Modifiers::CMD_LEFT);
233        assert_eq!(
234            "CmdRight".parse::<Modifiers>().unwrap(),
235            Modifiers::CMD_RIGHT
236        );
237        assert_eq!("RCmd".parse::<Modifiers>().unwrap(), Modifiers::CMD_RIGHT);
238
239        assert_eq!(
240            "ShiftLeft".parse::<Modifiers>().unwrap(),
241            Modifiers::SHIFT_LEFT
242        );
243        assert_eq!(
244            "ShiftRight".parse::<Modifiers>().unwrap(),
245            Modifiers::SHIFT_RIGHT
246        );
247
248        assert_eq!(
249            "CtrlLeft".parse::<Modifiers>().unwrap(),
250            Modifiers::CTRL_LEFT
251        );
252        assert_eq!(
253            "CtrlRight".parse::<Modifiers>().unwrap(),
254            Modifiers::CTRL_RIGHT
255        );
256
257        assert_eq!("OptLeft".parse::<Modifiers>().unwrap(), Modifiers::OPT_LEFT);
258        assert_eq!(
259            "AltRight".parse::<Modifiers>().unwrap(),
260            Modifiers::OPT_RIGHT
261        );
262        assert_eq!("AltGr".parse::<Modifiers>().unwrap(), Modifiers::OPT_RIGHT);
263    }
264
265    #[test]
266    fn parse_combined_modifiers() {
267        assert_eq!(
268            "Cmd+Shift".parse::<Modifiers>().unwrap(),
269            Modifiers::CMD | Modifiers::SHIFT
270        );
271        assert_eq!(
272            "Ctrl+Alt+Shift".parse::<Modifiers>().unwrap(),
273            Modifiers::CTRL | Modifiers::OPT | Modifiers::SHIFT
274        );
275    }
276
277    #[test]
278    fn parse_empty_modifiers() {
279        assert_eq!("".parse::<Modifiers>().unwrap(), Modifiers::empty());
280        assert_eq!("  ".parse::<Modifiers>().unwrap(), Modifiers::empty());
281    }
282
283    #[test]
284    fn parse_unknown_modifier_fails() {
285        assert!("Unknown".parse::<Modifiers>().is_err());
286        assert!("Cmd+Unknown".parse::<Modifiers>().is_err());
287    }
288
289    #[test]
290    fn modifiers_display() {
291        assert_eq!(format!("{}", Modifiers::CMD), "Cmd");
292        assert_eq!(format!("{}", Modifiers::SHIFT), "Shift");
293        assert_eq!(
294            format!("{}", Modifiers::CMD | Modifiers::SHIFT),
295            "Shift+Cmd"
296        );
297    }
298
299    #[test]
300    fn modifiers_display_side_specific() {
301        assert_eq!(format!("{}", Modifiers::CMD_LEFT), "CmdLeft");
302        assert_eq!(format!("{}", Modifiers::CMD_RIGHT), "CmdRight");
303        assert_eq!(format!("{}", Modifiers::SHIFT_LEFT), "ShiftLeft");
304        assert_eq!(format!("{}", Modifiers::CTRL_RIGHT), "CtrlRight");
305        assert_eq!(format!("{}", Modifiers::OPT_LEFT), "OptLeft");
306    }
307
308    #[test]
309    fn matches_compound_hotkey() {
310        // Compound "Cmd" matches either side
311        let hotkey = Modifiers::CMD;
312        assert!(hotkey.matches(Modifiers::CMD_LEFT));
313        assert!(hotkey.matches(Modifiers::CMD_RIGHT));
314        assert!(hotkey.matches(Modifiers::CMD_LEFT | Modifiers::CMD_RIGHT));
315        assert!(!hotkey.matches(Modifiers::empty()));
316        assert!(!hotkey.matches(Modifiers::SHIFT_LEFT));
317    }
318
319    #[test]
320    fn matches_side_specific_hotkey() {
321        // Specific "CmdLeft" requires left
322        let hotkey = Modifiers::CMD_LEFT;
323        assert!(hotkey.matches(Modifiers::CMD_LEFT));
324        assert!(!hotkey.matches(Modifiers::CMD_RIGHT));
325        // Both sides pressed: left is still present, so it matches
326        assert!(hotkey.matches(Modifiers::CMD_LEFT | Modifiers::CMD_RIGHT));
327        assert!(!hotkey.matches(Modifiers::empty()));
328    }
329
330    #[test]
331    fn matches_rejects_extra_groups() {
332        // Hotkey is just Cmd, event has Cmd+Shift — should fail (extra group)
333        let hotkey = Modifiers::CMD;
334        assert!(!hotkey.matches(Modifiers::CMD_LEFT | Modifiers::SHIFT_LEFT));
335
336        // Hotkey is CmdLeft+ShiftLeft, event is CmdLeft+ShiftLeft — OK
337        let hotkey = Modifiers::CMD_LEFT | Modifiers::SHIFT_LEFT;
338        assert!(hotkey.matches(Modifiers::CMD_LEFT | Modifiers::SHIFT_LEFT));
339    }
340
341    #[test]
342    fn matches_fn_exact() {
343        let hotkey = Modifiers::CMD | Modifiers::FN;
344        assert!(hotkey.matches(Modifiers::CMD_LEFT | Modifiers::FN));
345        assert!(!hotkey.matches(Modifiers::CMD_LEFT)); // missing FN
346
347        let hotkey = Modifiers::CMD;
348        assert!(!hotkey.matches(Modifiers::CMD_LEFT | Modifiers::FN)); // extra FN
349    }
350
351    #[test]
352    fn matches_empty() {
353        let hotkey = Modifiers::empty();
354        assert!(hotkey.matches(Modifiers::empty()));
355        assert!(!hotkey.matches(Modifiers::CMD_LEFT));
356    }
357
358    #[test]
359    fn compound_equals_both_sides() {
360        assert_eq!(Modifiers::CMD, Modifiers::CMD_LEFT | Modifiers::CMD_RIGHT);
361        assert_eq!(
362            Modifiers::SHIFT,
363            Modifiers::SHIFT_LEFT | Modifiers::SHIFT_RIGHT
364        );
365        assert_eq!(
366            Modifiers::CTRL,
367            Modifiers::CTRL_LEFT | Modifiers::CTRL_RIGHT
368        );
369        assert_eq!(Modifiers::OPT, Modifiers::OPT_LEFT | Modifiers::OPT_RIGHT);
370    }
371}