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//! When an application also keeps command metadata in [`CommandRegistry`],
9//! [`ShortcutManager::fill_shortcut_hints`] can copy the global shortcut map into
10//! each registered [`egui_command::CommandSpec::shortcut_hint`] field so menus,
11//! toolbars, and help overlays show the same display text as the active bindings.
12//!
13//! # Quick-start
14//! ```rust,ignore
15//! // Define your command type (typically a C: From<CommandId> enum).
16//! #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17//! enum AppCmd { ShowHelp, PrevProfile, NextProfile }
18//!
19//! // Build the global map once (e.g. in a lazy_static):
20//! let mut global: ShortcutMap<AppCmd> = ShortcutMap::new();
21//! global.insert(shortcut("F1"),  AppCmd::ShowHelp);
22//! global.insert(shortcut("F7"),  AppCmd::PrevProfile);
23//! global.insert(shortcut("F8"),  AppCmd::NextProfile);
24//!
25//! // Each frame, collect triggered commands:
26//! let triggered = manager.dispatch(ctx);
27//! for cmd in triggered { handle(cmd); }
28//!
29//! // Optional: populate display-only shortcut hints in a command registry.
30//! manager.fill_shortcut_hints(&mut registry);
31//! ```
32
33pub use egui_command;
34use {
35    egui::{Context, Key, KeyboardShortcut, Modifiers},
36    egui_command::{CommandId, CommandRegistry, CommandSource, CommandTriggered},
37    parking_lot::RwLock,
38    std::{collections::HashMap, sync::Arc},
39};
40
41/// A keyboard shortcut: a key plus zero-or-more modifier keys.
42#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
43pub struct Shortcut {
44    pub key: Key,
45    pub mods: Modifiers,
46}
47
48/// Maps `Shortcut → C` (an app-defined command value).
49pub type ShortcutMap<C> = HashMap<Shortcut, C>;
50
51/// A named, optionally-consuming scope of shortcuts.
52///
53/// Scopes are pushed/popped by context (e.g. while an editor view is active).
54/// When `consume = true`, a match in this scope stops propagation to lower scopes
55/// and to the global map.
56pub struct ShortcutScope<C> {
57    pub name: &'static str,
58    pub shortcuts: ShortcutMap<C>,
59    pub consume: bool,
60}
61
62impl<C> ShortcutScope<C> {
63    /// Creates a new scope with the given name, shortcut map, and consume flag.
64    pub fn new(name: &'static str, shortcuts: ShortcutMap<C>, consume: bool) -> Self {
65        Self {
66            name,
67            shortcuts,
68            consume,
69        }
70    }
71}
72
73/// Scans egui key events each frame and returns triggered commands.
74///
75/// Lookup order: extra scope → scoped stack (top → bottom) → global.
76/// Non-consuming scopes continue propagation; consuming scopes stop lower scopes
77/// and the global map. Within one scope/map, the most specific logical shortcut
78/// wins.
79pub struct ShortcutManager<C> {
80    global: Arc<RwLock<ShortcutMap<C>>>,
81    stack: Vec<ShortcutScope<C>>,
82}
83
84impl<C: Clone> ShortcutManager<C> {
85    pub fn new(global: Arc<RwLock<ShortcutMap<C>>>) -> Self {
86        Self {
87            global,
88            stack: Vec::new(),
89        }
90    }
91
92    /// Pushes a new scope onto the stack. Scopes are checked top-to-bottom during dispatch.
93    pub fn push_scope(&mut self, scope: ShortcutScope<C>) { self.stack.push(scope); }
94
95    /// Removes the top scope from the stack.
96    pub fn pop_scope(&mut self) { self.stack.pop(); }
97
98    /// Inserts or replaces a shortcut in the shared global map.
99    pub fn register_global(&mut self, sc: Shortcut, cmd: C) { self.global.write().insert(sc, cmd); }
100
101    /// Populates [`CommandRegistry`] shortcut hints from the global shortcut map.
102    ///
103    /// For each `(Shortcut, C)` entry in the global map, formats the shortcut as a
104    /// human-readable string (e.g. `"Ctrl+S"`, `"F1"`) and writes it into the
105    /// corresponding [`CommandSpec::shortcut_hint`] in `registry`.
106    ///
107    /// Commands that have a shortcut binding but are not registered in `registry`
108    /// are silently skipped.  Commands registered in `registry` that have no
109    /// shortcut binding are left unchanged.
110    pub fn fill_shortcut_hints<R>(&self, registry: &mut CommandRegistry<R>)
111    where
112        C: Into<CommandId> + Copy,
113        R: Copy + std::hash::Hash + Eq + Into<CommandId>,
114    {
115        let global = self.global.read();
116        for (shortcut, cmd) in global.iter() {
117            let id: CommandId = (*cmd).into();
118            if let Some(spec) = registry.spec_by_id_mut(id) {
119                spec.shortcut_hint = Some(format_shortcut(shortcut));
120            }
121        }
122    }
123
124    /// Scan egui key events and return all triggered commands this frame.
125    ///
126    /// Matched key events are consumed from the egui input queue so that
127    /// egui widgets don't double-handle them.
128    ///
129    /// Returns an empty `Vec` when [`Context::wants_keyboard_input`] is `true`
130    /// (i.e. a text-edit widget has focus) so that typing never fires shortcuts.
131    pub fn dispatch(&self, ctx: &Context) -> Vec<CommandTriggered>
132    where
133        C: Into<CommandId>,
134    {
135        if ctx.wants_keyboard_input() {
136            return Vec::new();
137        }
138
139        self.dispatch_raw_inner(ctx, None)
140            .into_iter()
141            .map(|cmd| CommandTriggered::new(cmd.into(), CommandSource::Keyboard))
142            .collect()
143    }
144
145    /// Dispatch with an optional extra scope checked before global shortcuts.
146    ///
147    /// Use this when a context-specific shortcut map (e.g. editor scope) should
148    /// take priority without needing `push_scope`/`pop_scope` on a mutable static.
149    /// The extra scope is always consuming: a match there skips the global map.
150    ///
151    /// Returns an empty `Vec` when [`Context::wants_keyboard_input`] is `true`.
152    pub fn dispatch_raw_with_extra(&self, ctx: &Context, extra: Option<&ShortcutMap<C>>) -> Vec<C> {
153        if ctx.wants_keyboard_input() {
154            return Vec::new();
155        }
156
157        self.dispatch_raw_inner(ctx, extra)
158    }
159
160    /// Dispatch without converting to `CommandTriggered` — returns raw `C` values.
161    ///
162    /// Returns an empty `Vec` when [`Context::wants_keyboard_input`] is `true`.
163    pub fn dispatch_raw(&self, ctx: &Context) -> Vec<C> {
164        if ctx.wants_keyboard_input() {
165            return Vec::new();
166        }
167
168        self.dispatch_raw_inner(ctx, None)
169    }
170
171    /// Check whether a specific shortcut was pressed this frame, consuming it if so.
172    ///
173    /// Returns `Some(cmd)` if `sc` appears in the global shortcut map and was
174    /// pressed this frame; `None` otherwise.
175    ///
176    /// Unlike [`dispatch`] / [`dispatch_raw`], this does **not** check
177    /// `wants_keyboard_input` — use it only when you intentionally want to
178    /// intercept a key even while a text field has focus.
179    pub fn try_shortcut(&self, ctx: &Context, sc: Shortcut) -> Option<C> {
180        let global = self.global.read();
181        let cmd = global.get(&sc)?.clone();
182        if ctx.input_mut(|i| i.consume_shortcut(&sc.to_keyboard_shortcut())) {
183            Some(cmd)
184        } else {
185            None
186        }
187    }
188
189    /// Shared implementation for all dispatch variants.
190    ///
191    /// Does **not** check `wants_keyboard_input`; callers are responsible for
192    /// that guard. `extra`, when provided, is checked first and always consumes
193    /// (a match there skips the scoped stack and global map for that key).
194    fn dispatch_raw_inner(&self, ctx: &Context, extra: Option<&ShortcutMap<C>>) -> Vec<C> {
195        let mut triggered: Vec<C> = Vec::new();
196        let global = self.global.read();
197
198        ctx.input_mut(|input| {
199            let mut consumed: Vec<KeyboardShortcut> = Vec::new();
200
201            for event in &input.events {
202                let egui::Event::Key {
203                    key,
204                    pressed: true,
205                    repeat: false,
206                    modifiers,
207                    ..
208                } = event
209                else {
210                    continue;
211                };
212
213                // Extra scope has highest priority and is always consuming.
214                if let Some(extra_map) = extra
215                    && let Some((shortcut, cmd)) = best_shortcut_match(extra_map, *key, *modifiers)
216                {
217                    triggered.push(cmd.clone());
218                    consumed.push(shortcut.to_keyboard_shortcut());
219                    continue;
220                }
221
222                let mut stop_propagation = false;
223                for scope in self.stack.iter().rev() {
224                    if let Some((shortcut, cmd)) = best_shortcut_match(&scope.shortcuts, *key, *modifiers) {
225                        triggered.push(cmd.clone());
226                        consumed.push(shortcut.to_keyboard_shortcut());
227                        if scope.consume {
228                            stop_propagation = true;
229                            break;
230                        }
231                    }
232                }
233                if stop_propagation {
234                    continue;
235                }
236
237                // Fall back to global map.
238                if let Some((shortcut, cmd)) = best_shortcut_match(&global, *key, *modifiers) {
239                    triggered.push(cmd.clone());
240                    consumed.push(shortcut.to_keyboard_shortcut());
241                }
242            }
243
244            for shortcut in consumed {
245                input.consume_shortcut(&shortcut);
246            }
247        });
248
249        triggered
250    }
251}
252
253fn best_shortcut_match<C>(
254    map: &ShortcutMap<C>,
255    key: Key,
256    pressed_modifiers: Modifiers,
257) -> Option<(Shortcut, &C)> {
258    map.iter()
259        .filter(|(shortcut, _)| shortcut.key == key && pressed_modifiers.matches_logically(shortcut.mods))
260        .max_by_key(|(shortcut, _)| shortcut.specificity())
261        .map(|(shortcut, command)| (*shortcut, command))
262}
263
264fn format_shortcut(sc: &Shortcut) -> String {
265    let mut parts: Vec<String> = Vec::new();
266    if sc.mods.ctrl { parts.push("Ctrl".into()); }
267    if sc.mods.alt { parts.push("Alt".into()); }
268    if sc.mods.shift { parts.push("Shift".into()); }
269    if sc.mods.command { parts.push("Cmd".into()); }
270    if sc.mods.mac_cmd { parts.push("Meta".into()); }
271    parts.push(format!("{:?}", sc.key));
272    parts.join("+")
273}
274
275impl Shortcut {
276    fn specificity(self) -> u8 {
277        self.mods.alt as u8
278            + self.mods.shift as u8
279            + self.mods.ctrl as u8
280            + self.mods.command as u8
281            + self.mods.mac_cmd as u8
282    }
283
284    fn to_keyboard_shortcut(self) -> KeyboardShortcut {
285        KeyboardShortcut::new(self.mods, self.key)
286    }
287}
288
289/// Parse a shortcut string like `"Ctrl+S"`, `"F2"`, `"Alt+Shift+X"`.
290///
291/// Token matching is case-insensitive.  Panics if the key token is unrecognised.
292pub fn shortcut(sc: &str) -> Shortcut {
293    let mut mods = Modifiers::default();
294    let mut key = None;
295
296    for part in sc.split('+') {
297        let part = part.trim();
298        match part.to_uppercase().as_str() {
299            "CTRL" | "CONTROL" => mods.ctrl = true,
300            "ALT" => mods.alt = true,
301            "SHIFT" => mods.shift = true,
302            "META" => mods.mac_cmd = true,
303            "CMD" | "COMMAND" => mods.command = true,
304            // Key::from_name is case-sensitive (egui uses PascalCase, e.g. "Escape", "F1", "A").
305            // Pass the original part (trimmed) so "Escape" stays "Escape", not "ESCAPE".
306            _ => key = Key::from_name(part),
307        }
308    }
309
310    Shortcut {
311        key: key.expect("Invalid key in shortcut string"),
312        mods,
313    }
314}
315
316/// Build a [`ShortcutMap`] from `shortcut_string => command` pairs.
317///
318/// # Example
319/// ```rust,ignore
320/// let map = shortcut_map![
321///     "F1" => AppCmd::ShowHelp,
322///     "F7" => AppCmd::PrevProfile,
323/// ];
324/// ```
325#[macro_export]
326macro_rules! shortcut_map {
327    ($($key:expr => $cmd:expr),* $(,)?) => {{
328        #[allow(unused_mut)]
329        let mut map = $crate::ShortcutMap::new();
330        $(map.insert($crate::shortcut($key), $cmd);)*
331        map
332    }};
333}
334
335#[cfg(test)]
336mod tests {
337    use {
338        super::*,
339        egui::{Event, Key, Modifiers, RawInput},
340    };
341
342    fn key_event(key: Key, modifiers: Modifiers) -> Event {
343        Event::Key {
344            key,
345            physical_key: None,
346            pressed: true,
347            repeat: false,
348            modifiers,
349        }
350    }
351
352    fn dispatch_raw_events(manager: &ShortcutManager<u32>, events: Vec<Event>) -> Vec<u32> {
353        let ctx = Context::default();
354        let mut triggered = None;
355
356        let _ = ctx.run(
357            RawInput {
358                events,
359                ..RawInput::default()
360            },
361            |ctx| {
362                triggered = Some(manager.dispatch_raw(ctx));
363            },
364        );
365
366        triggered.expect("dispatch should run exactly once")
367    }
368
369    #[test]
370    fn shortcut_single_key() {
371        let sc = shortcut("F1");
372        assert_eq!(sc.key, Key::F1);
373        assert_eq!(sc.mods, Modifiers::default());
374    }
375
376    #[test]
377    fn shortcut_ctrl_s() {
378        let sc = shortcut("Ctrl+S");
379        assert_eq!(sc.key, Key::S);
380        assert!(sc.mods.ctrl);
381        assert!(!sc.mods.alt);
382        assert!(!sc.mods.shift);
383    }
384
385    #[test]
386    fn shortcut_alt_shift_x() {
387        let sc = shortcut("Alt+Shift+X");
388        assert_eq!(sc.key, Key::X);
389        assert!(sc.mods.alt);
390        assert!(sc.mods.shift);
391        assert!(!sc.mods.ctrl);
392    }
393
394    #[test]
395    fn shortcut_control_alias() {
396        let sc = shortcut("Control+A");
397        assert!(sc.mods.ctrl);
398        assert_eq!(sc.key, Key::A);
399    }
400
401    #[test]
402    fn shortcut_command_sets_logical_command_modifier() {
403        let sc = shortcut("Cmd+S");
404        assert_eq!(sc.key, Key::S);
405        assert!(sc.mods.command);
406        assert!(!sc.mods.mac_cmd);
407    }
408
409    #[test]
410    #[should_panic]
411    fn shortcut_invalid_key_panics() { shortcut("Ctrl+NotAKey"); }
412
413    #[test]
414    fn shortcut_map_macro_builds_correctly() {
415        let map = shortcut_map![
416            "F1" => 1u32,
417            "F2" => 2u32,
418        ];
419        assert_eq!(map.get(&shortcut("F1")), Some(&1u32));
420        assert_eq!(map.get(&shortcut("F2")), Some(&2u32));
421        assert_eq!(map.get(&shortcut("F3")), None);
422    }
423
424    #[test]
425    fn shortcut_map_macro_empty() {
426        let map: ShortcutMap<u32> = shortcut_map![];
427        assert!(map.is_empty());
428    }
429
430    #[test]
431    fn shortcut_equality_and_hash() {
432        use std::collections::HashMap;
433        let mut m: HashMap<Shortcut, &str> = HashMap::new();
434        m.insert(shortcut("Ctrl+S"), "save");
435        assert_eq!(m[&shortcut("Ctrl+S")], "save");
436        assert!(!m.contains_key(&shortcut("Ctrl+Z")));
437    }
438
439    #[test]
440    fn non_consuming_scope_still_allows_global_fallback() {
441        let global = Arc::new(RwLock::new(shortcut_map!["Ctrl+S" => 1u32]));
442        let mut manager = ShortcutManager::new(global);
443        manager.push_scope(ShortcutScope::new(
444            "editor",
445            shortcut_map!["Ctrl+S" => 2u32],
446            false,
447        ));
448
449        let triggered = dispatch_raw_events(&manager, vec![key_event(Key::S, Modifiers::CTRL)]);
450        assert_eq!(triggered, vec![2, 1]);
451    }
452
453    #[test]
454    fn consuming_scope_blocks_global_fallback() {
455        let global = Arc::new(RwLock::new(shortcut_map!["Ctrl+S" => 1u32]));
456        let mut manager = ShortcutManager::new(global);
457        manager.push_scope(ShortcutScope::new(
458            "editor",
459            shortcut_map!["Ctrl+S" => 2u32],
460            true,
461        ));
462
463        let triggered = dispatch_raw_events(&manager, vec![key_event(Key::S, Modifiers::CTRL)]);
464        assert_eq!(triggered, vec![2]);
465    }
466
467    #[test]
468    fn logical_command_shortcut_matches_command_input() {
469        let global = Arc::new(RwLock::new(shortcut_map!["Cmd+S" => 7u32]));
470        let manager = ShortcutManager::new(global);
471
472        let triggered = dispatch_raw_events(&manager, vec![key_event(Key::S, Modifiers::COMMAND)]);
473        assert_eq!(triggered, vec![7]);
474    }
475
476    #[test]
477    fn more_specific_shortcut_wins_with_logical_matching() {
478        let global = Arc::new(RwLock::new(shortcut_map![
479            "Ctrl+S" => 1u32,
480            "Ctrl+Shift+S" => 2u32,
481        ]));
482        let manager = ShortcutManager::new(global);
483
484        let triggered = dispatch_raw_events(
485            &manager,
486            vec![key_event(Key::S, Modifiers::CTRL | Modifiers::SHIFT)],
487        );
488        assert_eq!(triggered, vec![2]);
489    }
490
491    #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
492    enum TestCmd { Save, Help, Quit }
493
494    impl From<TestCmd> for egui_command::CommandId {
495        fn from(c: TestCmd) -> Self { egui_command::CommandId::new(c) }
496    }
497
498    #[test]
499    fn fill_shortcut_hints_writes_to_registered_commands() {
500        let global = Arc::new(RwLock::new(shortcut_map![
501            "Ctrl+S" => TestCmd::Save,
502            "F1" => TestCmd::Help,
503        ]));
504        let manager = ShortcutManager::new(global);
505
506        let mut reg = egui_command::CommandRegistry::new()
507            .with(TestCmd::Save, egui_command::CommandSpec::new(TestCmd::Save.into(), "Save"))
508            .with(TestCmd::Help, egui_command::CommandSpec::new(TestCmd::Help.into(), "Help"))
509            .with(TestCmd::Quit, egui_command::CommandSpec::new(TestCmd::Quit.into(), "Quit"));
510
511        manager.fill_shortcut_hints(&mut reg);
512
513        let save_hint = reg.spec(TestCmd::Save).unwrap().shortcut_hint.as_deref();
514        let help_hint = reg.spec(TestCmd::Help).unwrap().shortcut_hint.as_deref();
515        let quit_hint = reg.spec(TestCmd::Quit).unwrap().shortcut_hint.as_deref();
516
517        assert!(save_hint.is_some(), "Save should have a shortcut hint");
518        assert!(save_hint.unwrap().contains("S"), "Save hint should mention S key");
519        assert!(help_hint.is_some(), "Help should have a shortcut hint");
520        assert!(help_hint.unwrap().contains("F1"), "Help hint should contain F1");
521        assert!(quit_hint.is_none(), "Quit has no binding, hint should be None");
522    }
523
524    #[test]
525    fn fill_shortcut_hints_unregistered_command_is_skipped() {
526        let global = Arc::new(RwLock::new(shortcut_map!["F9" => TestCmd::Quit]));
527        let manager = ShortcutManager::new(global);
528
529        let mut reg = egui_command::CommandRegistry::new()
530            .with(TestCmd::Save, egui_command::CommandSpec::new(TestCmd::Save.into(), "Save"));
531
532        manager.fill_shortcut_hints(&mut reg);
533
534        assert!(reg.spec(TestCmd::Save).unwrap().shortcut_hint.is_none());
535    }
536}