egui_desktop/menu/
shortcuts.rs

1use egui::{Key, Modifiers};
2use std::collections::HashMap;
3use std::sync::Mutex;
4
5// Global state to track shortcut states across frames
6lazy_static::lazy_static! {
7    static ref SHORTCUT_STATES: Mutex<HashMap<String, bool>> = Mutex::new(HashMap::new());
8}
9
10/// Keyboard shortcut for menu items.
11///
12/// Combines a primary `egui::Key` with optional modifier keys. Use
13/// [`KeyboardShortcut::from_string`] to parse user-friendly strings like
14/// "ctrl+shift+p" or create it programmatically via [`KeyboardShortcut::new`].
15#[derive(Debug, Clone)]
16pub struct KeyboardShortcut {
17    /// Primary key that triggers the shortcut (e.g. `Key::S`).
18    pub key: Key,
19    /// Modifier state required for the shortcut (Ctrl/Cmd, Alt, Shift).
20    pub modifiers: Modifiers,
21}
22
23/// Parse error for shortcut strings.
24///
25/// Returned by [`KeyboardShortcut::from_string`] when the provided string
26/// contains an unknown key, unsupported modifier, or has the wrong format.
27#[derive(Debug, Clone)]
28pub enum ShortcutParseError {
29    /// Unknown or unsupported key token, e.g. "foobar".
30    InvalidKey(String),
31    /// Unknown or unsupported modifier token, e.g. "hyper".
32    InvalidModifier(String),
33    /// General formatting issue (e.g. empty string).
34    InvalidFormat(String),
35}
36
37impl KeyboardShortcut {
38    /// Create a shortcut with a primary key and no modifiers.
39    pub fn new(key: Key) -> Self {
40        Self {
41            key,
42            modifiers: Modifiers::default(),
43        }
44    }
45
46    /// Create a shortcut from a simple string like "t", "ctrl+t", "ctrl+shift+t", etc.
47    ///
48    /// # Examples
49    /// ```
50    /// KeyboardShortcut::from_string("t").unwrap()
51    /// KeyboardShortcut::from_string("ctrl+t").unwrap()
52    /// KeyboardShortcut::from_string("ctrl+shift+t").unwrap()
53    /// KeyboardShortcut::from_string("alt+f4").unwrap()
54    /// ```
55    pub fn from_string(shortcut: &str) -> Result<Self, ShortcutParseError> {
56        let parts: Vec<&str> = shortcut.split('+').collect();
57
58        if parts.is_empty() {
59            return Err(ShortcutParseError::InvalidFormat(shortcut.to_string()));
60        }
61
62        let mut modifiers = Modifiers::default();
63        let key_str = parts.last().unwrap().to_lowercase();
64
65        // Parse modifiers
66        for part in &parts[..parts.len() - 1] {
67            let modifier = part.to_lowercase();
68            match modifier.as_str() {
69                "ctrl" | "control" => modifiers.ctrl = true,
70                "alt" => modifiers.alt = true,
71                "shift" => modifiers.shift = true,
72                "cmd" | "meta" | "super" => modifiers.command = true,
73                _ => return Err(ShortcutParseError::InvalidModifier(part.to_string())),
74            }
75        }
76
77        // Parse key
78        let key = match key_str.as_str() {
79            // Letters
80            "a" => Key::A,
81            "b" => Key::B,
82            "c" => Key::C,
83            "d" => Key::D,
84            "e" => Key::E,
85            "f" => Key::F,
86            "g" => Key::G,
87            "h" => Key::H,
88            "i" => Key::I,
89            "j" => Key::J,
90            "k" => Key::K,
91            "l" => Key::L,
92            "m" => Key::M,
93            "n" => Key::N,
94            "o" => Key::O,
95            "p" => Key::P,
96            "q" => Key::Q,
97            "r" => Key::R,
98            "s" => Key::S,
99            "t" => Key::T,
100            "u" => Key::U,
101            "v" => Key::V,
102            "w" => Key::W,
103            "x" => Key::X,
104            "y" => Key::Y,
105            "z" => Key::Z,
106
107            // Numbers
108            "0" => Key::Num0,
109            "1" => Key::Num1,
110            "2" => Key::Num2,
111            "3" => Key::Num3,
112            "4" => Key::Num4,
113            "5" => Key::Num5,
114            "6" => Key::Num6,
115            "7" => Key::Num7,
116            "8" => Key::Num8,
117            "9" => Key::Num9,
118
119            // Function keys
120            "f1" => Key::F1,
121            "f2" => Key::F2,
122            "f3" => Key::F3,
123            "f4" => Key::F4,
124            "f5" => Key::F5,
125            "f6" => Key::F6,
126            "f7" => Key::F7,
127            "f8" => Key::F8,
128            "f9" => Key::F9,
129            "f10" => Key::F10,
130            "f11" => Key::F11,
131            "f12" => Key::F12,
132
133            // Special keys
134            "enter" | "return" => Key::Enter,
135            "space" => Key::Space,
136            "tab" => Key::Tab,
137            "escape" | "esc" => Key::Escape,
138            "backspace" => Key::Backspace,
139            "delete" | "del" => Key::Delete,
140            "home" => Key::Home,
141            "end" => Key::End,
142            "pageup" | "pgup" => Key::PageUp,
143            "pagedown" | "pgdown" => Key::PageDown,
144            "up" => Key::ArrowUp,
145            "down" => Key::ArrowDown,
146            "left" => Key::ArrowLeft,
147            "right" => Key::ArrowRight,
148
149            // Punctuation
150            "-" | "minus" => Key::Minus,
151            "=" | "plus" => Key::Equals,
152            "[" => Key::OpenBracket,
153            "]" => Key::CloseBracket,
154            ";" => Key::Semicolon,
155            "'" => Key::Quote,
156            "`" => Key::Backtick,
157            "\\" => Key::Backslash,
158            "," => Key::Comma,
159            "." => Key::Period,
160            "/" => Key::Slash,
161
162            _ => return Err(ShortcutParseError::InvalidKey(key_str)),
163        };
164
165        Ok(Self { key, modifiers })
166    }
167
168    /// Create a shortcut from a string, panicking on invalid input.
169    /// Use this when you know the shortcut string is valid.
170    ///
171    /// # Examples
172    /// ```
173    /// KeyboardShortcut::parse("ctrl+t")
174    /// KeyboardShortcut::parse("alt+f4")
175    /// ```
176    pub fn parse(shortcut: &str) -> Self {
177        Self::from_string(shortcut).expect(&format!("Invalid shortcut: {}", shortcut))
178    }
179
180    /// Check if this shortcut matches the current input
181    pub fn matches(&self, key: Key, modifiers: Modifiers) -> bool {
182        self.key == key && self.modifiers == modifiers
183    }
184
185    /// Check if this shortcut was just pressed
186    pub fn just_pressed(&self, ctx: &egui::Context) -> bool {
187        // Create a unique key for this shortcut
188        let shortcut_key = format!(
189            "{:?}_{}_{}_{}_{}",
190            self.key,
191            self.modifiers.ctrl,
192            self.modifiers.alt,
193            self.modifiers.shift,
194            self.modifiers.command
195        );
196
197        // Check if this frame the key was pressed and modifiers match
198        let current_frame_pressed = ctx.input(|i| {
199            let key_pressed = i.key_pressed(self.key);
200
201            // For Ctrl shortcuts, accept either ctrl OR cmd (Windows compatibility)
202            let ctrl_held = i.modifiers.ctrl || i.modifiers.command;
203            let ctrl_match = if self.modifiers.ctrl {
204                ctrl_held // We want Ctrl, accept either ctrl or cmd
205            } else {
206                !ctrl_held // We don't want Ctrl, make sure neither is held
207            };
208
209            let alt_match = i.modifiers.alt == self.modifiers.alt;
210            let shift_match = i.modifiers.shift == self.modifiers.shift;
211
212            key_pressed && ctrl_match && alt_match && shift_match
213        });
214
215        // Get previous state
216        let mut states = SHORTCUT_STATES.lock().unwrap();
217        let was_pressed = states.get(&shortcut_key).copied().unwrap_or(false);
218
219        // Update state
220        states.insert(shortcut_key.clone(), current_frame_pressed);
221
222        // Return true only on transition from not pressed to pressed (just pressed)
223        current_frame_pressed && !was_pressed
224    }
225
226    /// Human-readable representation like "Ctrl+Shift+P".
227    pub fn display_string(&self) -> String {
228        let mut result = String::new();
229
230        if self.modifiers.ctrl {
231            result.push_str("Ctrl+");
232        }
233        if self.modifiers.alt {
234            result.push_str("Alt+");
235        }
236        if self.modifiers.shift {
237            result.push_str("Shift+");
238        }
239        if self.modifiers.command {
240            result.push_str("Cmd+");
241        }
242
243        result.push_str(&self.key.name());
244        result
245    }
246}