Skip to main content

egui_command_binding/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! `egui-command-binding` — keyboard shortcut → [`CommandId`] dispatch for egui apps.
4//!
5//! Wraps `egui-command` types with egui-specific input handling.
6//! `ShortcutManager<C>` scans egui `Key` events and returns a `Vec<C>` of
7//! triggered commands — it never executes business logic directly.
8//!
9//! # Quick-start
10//! ```rust,ignore
11//! // Define your command type (typically a C: From<CommandId> enum).
12//! #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13//! enum AppCmd { ShowHelp, PrevProfile, NextProfile }
14//!
15//! // Build the global map once (e.g. in a lazy_static):
16//! let mut global: ShortcutMap<AppCmd> = ShortcutMap::new();
17//! global.insert(shortcut("F1"),  AppCmd::ShowHelp);
18//! global.insert(shortcut("F7"),  AppCmd::PrevProfile);
19//! global.insert(shortcut("F8"),  AppCmd::NextProfile);
20//!
21//! // Each frame, collect triggered commands:
22//! let triggered = manager.dispatch(ctx);
23//! for cmd in triggered { handle(cmd); }
24//! ```
25
26pub use egui_command;
27use {
28    egui::{Context, Key, KeyboardShortcut, Modifiers},
29    egui_command::{CommandId, CommandSource, CommandTriggered},
30    parking_lot::RwLock,
31    std::{collections::HashMap, sync::Arc},
32};
33
34/// A keyboard shortcut: a key plus zero-or-more modifier keys.
35#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
36pub struct Shortcut {
37    pub key: Key,
38    pub mods: Modifiers,
39}
40
41/// Maps `Shortcut → C` (an app-defined command value).
42pub type ShortcutMap<C> = HashMap<Shortcut, C>;
43
44/// A named, optionally-consuming scope of shortcuts.
45///
46/// Scopes are pushed/popped by context (e.g. while an editor view is active).
47/// When `consume = true`, a match in this scope stops propagation to lower scopes
48/// and to the global map.
49pub struct ShortcutScope<C> {
50    pub name: &'static str,
51    pub shortcuts: ShortcutMap<C>,
52    pub consume: bool,
53}
54
55impl<C> ShortcutScope<C> {
56    /// Creates a new scope with the given name, shortcut map, and consume flag.
57    pub fn new(name: &'static str, shortcuts: ShortcutMap<C>, consume: bool) -> Self {
58        Self {
59            name,
60            shortcuts,
61            consume,
62        }
63    }
64}
65
66/// Scans egui key events each frame and returns triggered commands.
67///
68/// Lookup order: extra scope → scoped stack (top → bottom) → global.
69/// Non-consuming scopes continue propagation; consuming scopes stop lower scopes
70/// and the global map. Within one scope/map, the most specific logical shortcut
71/// wins.
72pub struct ShortcutManager<C> {
73    global: Arc<RwLock<ShortcutMap<C>>>,
74    stack: Vec<ShortcutScope<C>>,
75}
76
77impl<C: Clone> ShortcutManager<C> {
78    pub fn new(global: Arc<RwLock<ShortcutMap<C>>>) -> Self {
79        Self {
80            global,
81            stack: Vec::new(),
82        }
83    }
84
85    /// Pushes a new scope onto the stack. Scopes are checked top-to-bottom during dispatch.
86    pub fn push_scope(&mut self, scope: ShortcutScope<C>) { self.stack.push(scope); }
87
88    /// Removes the top scope from the stack.
89    pub fn pop_scope(&mut self) { self.stack.pop(); }
90
91    /// Inserts or replaces a shortcut in the shared global map.
92    pub fn register_global(&mut self, sc: Shortcut, cmd: C) { self.global.write().insert(sc, cmd); }
93
94    /// Scan egui key events and return all triggered commands this frame.
95    ///
96    /// Matched key events are consumed from the egui input queue so that
97    /// egui widgets don't double-handle them.
98    ///
99    /// Returns an empty `Vec` when [`Context::wants_keyboard_input`] is `true`
100    /// (i.e. a text-edit widget has focus) so that typing never fires shortcuts.
101    pub fn dispatch(&self, ctx: &Context) -> Vec<CommandTriggered>
102    where
103        C: Into<CommandId>,
104    {
105        if ctx.wants_keyboard_input() {
106            return Vec::new();
107        }
108
109        self.dispatch_raw_inner(ctx, None)
110            .into_iter()
111            .map(|cmd| CommandTriggered::new(cmd.into(), CommandSource::Keyboard))
112            .collect()
113    }
114
115    /// Dispatch with an optional extra scope checked before global shortcuts.
116    ///
117    /// Use this when a context-specific shortcut map (e.g. editor scope) should
118    /// take priority without needing `push_scope`/`pop_scope` on a mutable static.
119    /// The extra scope is always consuming: a match there skips the global map.
120    ///
121    /// Returns an empty `Vec` when [`Context::wants_keyboard_input`] is `true`.
122    pub fn dispatch_raw_with_extra(&self, ctx: &Context, extra: Option<&ShortcutMap<C>>) -> Vec<C> {
123        if ctx.wants_keyboard_input() {
124            return Vec::new();
125        }
126
127        self.dispatch_raw_inner(ctx, extra)
128    }
129
130    /// Dispatch without converting to `CommandTriggered` — returns raw `C` values.
131    ///
132    /// Returns an empty `Vec` when [`Context::wants_keyboard_input`] is `true`.
133    pub fn dispatch_raw(&self, ctx: &Context) -> Vec<C> {
134        if ctx.wants_keyboard_input() {
135            return Vec::new();
136        }
137
138        self.dispatch_raw_inner(ctx, None)
139    }
140
141    /// Shared implementation for all dispatch variants.
142    ///
143    /// Does **not** check `wants_keyboard_input`; callers are responsible for
144    /// that guard. `extra`, when provided, is checked first and always consumes
145    /// (a match there skips the scoped stack and global map for that key).
146    fn dispatch_raw_inner(&self, ctx: &Context, extra: Option<&ShortcutMap<C>>) -> Vec<C> {
147        let mut triggered: Vec<C> = Vec::new();
148
149        ctx.input_mut(|input| {
150            let mut consumed: Vec<KeyboardShortcut> = Vec::new();
151
152            for event in &input.events {
153                let egui::Event::Key {
154                    key,
155                    pressed: true,
156                    repeat: false,
157                    modifiers,
158                    ..
159                } = event
160                else {
161                    continue;
162                };
163
164                // Extra scope has highest priority and is always consuming.
165                if let Some(extra_map) = extra
166                    && let Some((shortcut, cmd)) = best_shortcut_match(extra_map, *key, *modifiers)
167                {
168                    triggered.push(cmd.clone());
169                    consumed.push(shortcut.to_keyboard_shortcut());
170                    continue;
171                }
172
173                let mut stop_propagation = false;
174                for scope in self.stack.iter().rev() {
175                    if let Some((shortcut, cmd)) = best_shortcut_match(&scope.shortcuts, *key, *modifiers) {
176                        triggered.push(cmd.clone());
177                        consumed.push(shortcut.to_keyboard_shortcut());
178                        if scope.consume {
179                            stop_propagation = true;
180                            break;
181                        }
182                    }
183                }
184                if stop_propagation {
185                    continue;
186                }
187
188                // Fall back to global map.
189                let global = self.global.read();
190                if let Some((shortcut, cmd)) = best_shortcut_match(&global, *key, *modifiers) {
191                    triggered.push(cmd.clone());
192                    consumed.push(shortcut.to_keyboard_shortcut());
193                }
194            }
195
196            for shortcut in consumed {
197                input.consume_shortcut(&shortcut);
198            }
199        });
200
201        triggered
202    }
203}
204
205fn best_shortcut_match<C>(
206    map: &ShortcutMap<C>,
207    key: Key,
208    pressed_modifiers: Modifiers,
209) -> Option<(Shortcut, &C)> {
210    map.iter()
211        .filter(|(shortcut, _)| shortcut.key == key && pressed_modifiers.matches_logically(shortcut.mods))
212        .max_by_key(|(shortcut, _)| shortcut.specificity())
213        .map(|(shortcut, command)| (*shortcut, command))
214}
215
216impl Shortcut {
217    fn specificity(self) -> u8 {
218        self.mods.alt as u8
219            + self.mods.shift as u8
220            + self.mods.ctrl as u8
221            + self.mods.command as u8
222            + self.mods.mac_cmd as u8
223    }
224
225    fn to_keyboard_shortcut(self) -> KeyboardShortcut {
226        KeyboardShortcut::new(self.mods, self.key)
227    }
228}
229
230/// Parse a shortcut string like `"Ctrl+S"`, `"F2"`, `"Alt+Shift+X"`.
231///
232/// Token matching is case-insensitive.  Panics if the key token is unrecognised.
233pub fn shortcut(sc: &str) -> Shortcut {
234    let mut mods = Modifiers::default();
235    let mut key = None;
236
237    for part in sc.split('+') {
238        let part = part.trim();
239        match part.to_uppercase().as_str() {
240            "CTRL" | "CONTROL" => mods.ctrl = true,
241            "ALT" => mods.alt = true,
242            "SHIFT" => mods.shift = true,
243            "META" => mods.mac_cmd = true,
244            "CMD" | "COMMAND" => mods.command = true,
245            // Key::from_name is case-sensitive (egui uses PascalCase, e.g. "Escape", "F1", "A").
246            // Pass the original part (trimmed) so "Escape" stays "Escape", not "ESCAPE".
247            _ => key = Key::from_name(part),
248        }
249    }
250
251    Shortcut {
252        key: key.expect("Invalid key in shortcut string"),
253        mods,
254    }
255}
256
257/// Build a [`ShortcutMap`] from `shortcut_string => command` pairs.
258///
259/// # Example
260/// ```rust,ignore
261/// let map = shortcut_map![
262///     "F1" => AppCmd::ShowHelp,
263///     "F7" => AppCmd::PrevProfile,
264/// ];
265/// ```
266#[macro_export]
267macro_rules! shortcut_map {
268    ($($key:expr => $cmd:expr),* $(,)?) => {{
269        #[allow(unused_mut)]
270        let mut map = $crate::ShortcutMap::new();
271        $(map.insert($crate::shortcut($key), $cmd);)*
272        map
273    }};
274}
275
276#[cfg(test)]
277mod tests {
278    use {
279        super::*,
280        egui::{Event, Key, Modifiers, RawInput},
281    };
282
283    fn key_event(key: Key, modifiers: Modifiers) -> Event {
284        Event::Key {
285            key,
286            physical_key: None,
287            pressed: true,
288            repeat: false,
289            modifiers,
290        }
291    }
292
293    fn dispatch_raw_events(manager: &ShortcutManager<u32>, events: Vec<Event>) -> Vec<u32> {
294        let ctx = Context::default();
295        let mut triggered = None;
296
297        let _ = ctx.run(
298            RawInput {
299                events,
300                ..RawInput::default()
301            },
302            |ctx| {
303                triggered = Some(manager.dispatch_raw(ctx));
304            },
305        );
306
307        triggered.expect("dispatch should run exactly once")
308    }
309
310    #[test]
311    fn shortcut_single_key() {
312        let sc = shortcut("F1");
313        assert_eq!(sc.key, Key::F1);
314        assert_eq!(sc.mods, Modifiers::default());
315    }
316
317    #[test]
318    fn shortcut_ctrl_s() {
319        let sc = shortcut("Ctrl+S");
320        assert_eq!(sc.key, Key::S);
321        assert!(sc.mods.ctrl);
322        assert!(!sc.mods.alt);
323        assert!(!sc.mods.shift);
324    }
325
326    #[test]
327    fn shortcut_alt_shift_x() {
328        let sc = shortcut("Alt+Shift+X");
329        assert_eq!(sc.key, Key::X);
330        assert!(sc.mods.alt);
331        assert!(sc.mods.shift);
332        assert!(!sc.mods.ctrl);
333    }
334
335    #[test]
336    fn shortcut_control_alias() {
337        let sc = shortcut("Control+A");
338        assert!(sc.mods.ctrl);
339        assert_eq!(sc.key, Key::A);
340    }
341
342    #[test]
343    fn shortcut_command_sets_logical_command_modifier() {
344        let sc = shortcut("Cmd+S");
345        assert_eq!(sc.key, Key::S);
346        assert!(sc.mods.command);
347        assert!(!sc.mods.mac_cmd);
348    }
349
350    #[test]
351    #[should_panic]
352    fn shortcut_invalid_key_panics() { shortcut("Ctrl+NotAKey"); }
353
354    #[test]
355    fn shortcut_map_macro_builds_correctly() {
356        let map = shortcut_map![
357            "F1" => 1u32,
358            "F2" => 2u32,
359        ];
360        assert_eq!(map.get(&shortcut("F1")), Some(&1u32));
361        assert_eq!(map.get(&shortcut("F2")), Some(&2u32));
362        assert_eq!(map.get(&shortcut("F3")), None);
363    }
364
365    #[test]
366    fn shortcut_map_macro_empty() {
367        let map: ShortcutMap<u32> = shortcut_map![];
368        assert!(map.is_empty());
369    }
370
371    #[test]
372    fn shortcut_equality_and_hash() {
373        use std::collections::HashMap;
374        let mut m: HashMap<Shortcut, &str> = HashMap::new();
375        m.insert(shortcut("Ctrl+S"), "save");
376        assert_eq!(m[&shortcut("Ctrl+S")], "save");
377        assert!(!m.contains_key(&shortcut("Ctrl+Z")));
378    }
379
380    #[test]
381    fn non_consuming_scope_still_allows_global_fallback() {
382        let global = Arc::new(RwLock::new(shortcut_map!["Ctrl+S" => 1u32]));
383        let mut manager = ShortcutManager::new(global);
384        manager.push_scope(ShortcutScope::new(
385            "editor",
386            shortcut_map!["Ctrl+S" => 2u32],
387            false,
388        ));
389
390        let triggered = dispatch_raw_events(&manager, vec![key_event(Key::S, Modifiers::CTRL)]);
391        assert_eq!(triggered, vec![2, 1]);
392    }
393
394    #[test]
395    fn consuming_scope_blocks_global_fallback() {
396        let global = Arc::new(RwLock::new(shortcut_map!["Ctrl+S" => 1u32]));
397        let mut manager = ShortcutManager::new(global);
398        manager.push_scope(ShortcutScope::new(
399            "editor",
400            shortcut_map!["Ctrl+S" => 2u32],
401            true,
402        ));
403
404        let triggered = dispatch_raw_events(&manager, vec![key_event(Key::S, Modifiers::CTRL)]);
405        assert_eq!(triggered, vec![2]);
406    }
407
408    #[test]
409    fn logical_command_shortcut_matches_command_input() {
410        let global = Arc::new(RwLock::new(shortcut_map!["Cmd+S" => 7u32]));
411        let manager = ShortcutManager::new(global);
412
413        let triggered = dispatch_raw_events(&manager, vec![key_event(Key::S, Modifiers::COMMAND)]);
414        assert_eq!(triggered, vec![7]);
415    }
416
417    #[test]
418    fn more_specific_shortcut_wins_with_logical_matching() {
419        let global = Arc::new(RwLock::new(shortcut_map![
420            "Ctrl+S" => 1u32,
421            "Ctrl+Shift+S" => 2u32,
422        ]));
423        let manager = ShortcutManager::new(global);
424
425        let triggered = dispatch_raw_events(
426            &manager,
427            vec![key_event(Key::S, Modifiers::CTRL | Modifiers::SHIFT)],
428        );
429        assert_eq!(triggered, vec![2]);
430    }
431}