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::egui_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.egui_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::egui_wants_keyboard_input`] is `true`.
152    pub fn dispatch_raw_with_extra(&self, ctx: &Context, extra: Option<&ShortcutMap<C>>) -> Vec<C> {
153        if ctx.egui_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::egui_wants_keyboard_input`] is `true`.
163    pub fn dispatch_raw(&self, ctx: &Context) -> Vec<C> {
164        if ctx.egui_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    /// `egui_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 `egui_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)) =
225                        best_shortcut_match(&scope.shortcuts, *key, *modifiers)
226                    {
227                        triggered.push(cmd.clone());
228                        consumed.push(shortcut.to_keyboard_shortcut());
229                        if scope.consume {
230                            stop_propagation = true;
231                            break;
232                        }
233                    }
234                }
235                if stop_propagation {
236                    continue;
237                }
238
239                // Fall back to global map.
240                if let Some((shortcut, cmd)) = best_shortcut_match(&global, *key, *modifiers) {
241                    triggered.push(cmd.clone());
242                    consumed.push(shortcut.to_keyboard_shortcut());
243                }
244            }
245
246            for shortcut in consumed {
247                input.consume_shortcut(&shortcut);
248            }
249        });
250
251        triggered
252    }
253}
254
255fn best_shortcut_match<C>(
256    map: &ShortcutMap<C>,
257    key: Key,
258    pressed_modifiers: Modifiers,
259) -> Option<(Shortcut, &C)> {
260    map.iter()
261        .filter(|(shortcut, _)| {
262            shortcut.key == key && pressed_modifiers.matches_logically(shortcut.mods)
263        })
264        .max_by_key(|(shortcut, _)| shortcut.specificity())
265        .map(|(shortcut, command)| (*shortcut, command))
266}
267
268fn format_shortcut(sc: &Shortcut) -> String {
269    let mut parts: Vec<String> = Vec::new();
270    if sc.mods.ctrl {
271        parts.push("Ctrl".into());
272    }
273    if sc.mods.alt {
274        parts.push("Alt".into());
275    }
276    if sc.mods.shift {
277        parts.push("Shift".into());
278    }
279    if sc.mods.command {
280        parts.push("Cmd".into());
281    }
282    if sc.mods.mac_cmd {
283        parts.push("Meta".into());
284    }
285    parts.push(format!("{:?}", sc.key));
286    parts.join("+")
287}
288
289impl Shortcut {
290    fn specificity(self) -> u8 {
291        self.mods.alt as u8
292            + self.mods.shift as u8
293            + self.mods.ctrl as u8
294            + self.mods.command as u8
295            + self.mods.mac_cmd as u8
296    }
297
298    fn to_keyboard_shortcut(self) -> KeyboardShortcut { KeyboardShortcut::new(self.mods, self.key) }
299}
300
301/// Parse a shortcut string like `"Ctrl+S"`, `"F2"`, `"Alt+Shift+X"`.
302///
303/// Token matching is case-insensitive.  Panics if the key token is unrecognised.
304pub fn shortcut(sc: &str) -> Shortcut {
305    let mut mods = Modifiers::default();
306    let mut key = None;
307
308    for part in sc.split('+') {
309        let part = part.trim();
310        match part.to_uppercase().as_str() {
311            "CTRL" | "CONTROL" => mods.ctrl = true,
312            "ALT" => mods.alt = true,
313            "SHIFT" => mods.shift = true,
314            "META" => mods.mac_cmd = true,
315            "CMD" | "COMMAND" => mods.command = true,
316            // Key::from_name is case-sensitive (egui uses PascalCase, e.g. "Escape", "F1", "A").
317            // Pass the original part (trimmed) so "Escape" stays "Escape", not "ESCAPE".
318            _ => key = Key::from_name(part),
319        }
320    }
321
322    Shortcut {
323        key: key.expect("Invalid key in shortcut string"),
324        mods,
325    }
326}
327
328/// Build a [`ShortcutMap`] from `shortcut_string => command` pairs.
329///
330/// # Example
331/// ```rust,ignore
332/// let map = shortcut_map![
333///     "F1" => AppCmd::ShowHelp,
334///     "F7" => AppCmd::PrevProfile,
335/// ];
336/// ```
337#[macro_export]
338macro_rules! shortcut_map {
339    ($($key:expr => $cmd:expr),* $(,)?) => {{
340        #[allow(unused_mut)]
341        let mut map = $crate::ShortcutMap::new();
342        $(map.insert($crate::shortcut($key), $cmd);)*
343        map
344    }};
345}
346
347#[cfg(test)]
348mod tests {
349    use {
350        super::*,
351        egui::{Event, Key, Modifiers, RawInput},
352    };
353
354    fn key_event(key: Key, modifiers: Modifiers) -> Event {
355        Event::Key {
356            key,
357            physical_key: None,
358            pressed: true,
359            repeat: false,
360            modifiers,
361        }
362    }
363
364    fn dispatch_raw_events(manager: &ShortcutManager<u32>, events: Vec<Event>) -> Vec<u32> {
365        let ctx = Context::default();
366        let mut triggered = None;
367
368        let _ = ctx.run_ui(
369            RawInput {
370                events,
371                ..RawInput::default()
372            },
373            |ctx| {
374                triggered = Some(manager.dispatch_raw(ctx));
375            },
376        );
377
378        triggered.expect("dispatch should run exactly once")
379    }
380
381    #[test]
382    fn shortcut_single_key() {
383        let sc = shortcut("F1");
384        assert_eq!(sc.key, Key::F1);
385        assert_eq!(sc.mods, Modifiers::default());
386    }
387
388    #[test]
389    fn shortcut_ctrl_s() {
390        let sc = shortcut("Ctrl+S");
391        assert_eq!(sc.key, Key::S);
392        assert!(sc.mods.ctrl);
393        assert!(!sc.mods.alt);
394        assert!(!sc.mods.shift);
395    }
396
397    #[test]
398    fn shortcut_alt_shift_x() {
399        let sc = shortcut("Alt+Shift+X");
400        assert_eq!(sc.key, Key::X);
401        assert!(sc.mods.alt);
402        assert!(sc.mods.shift);
403        assert!(!sc.mods.ctrl);
404    }
405
406    #[test]
407    fn shortcut_control_alias() {
408        let sc = shortcut("Control+A");
409        assert!(sc.mods.ctrl);
410        assert_eq!(sc.key, Key::A);
411    }
412
413    #[test]
414    fn shortcut_command_sets_logical_command_modifier() {
415        let sc = shortcut("Cmd+S");
416        assert_eq!(sc.key, Key::S);
417        assert!(sc.mods.command);
418        assert!(!sc.mods.mac_cmd);
419    }
420
421    #[test]
422    #[should_panic]
423    fn shortcut_invalid_key_panics() { shortcut("Ctrl+NotAKey"); }
424
425    #[test]
426    fn shortcut_map_macro_builds_correctly() {
427        let map = shortcut_map![
428            "F1" => 1u32,
429            "F2" => 2u32,
430        ];
431        assert_eq!(map.get(&shortcut("F1")), Some(&1u32));
432        assert_eq!(map.get(&shortcut("F2")), Some(&2u32));
433        assert_eq!(map.get(&shortcut("F3")), None);
434    }
435
436    #[test]
437    fn shortcut_map_macro_empty() {
438        let map: ShortcutMap<u32> = shortcut_map![];
439        assert!(map.is_empty());
440    }
441
442    #[test]
443    fn shortcut_equality_and_hash() {
444        use std::collections::HashMap;
445        let mut m: HashMap<Shortcut, &str> = HashMap::new();
446        m.insert(shortcut("Ctrl+S"), "save");
447        assert_eq!(m[&shortcut("Ctrl+S")], "save");
448        assert!(!m.contains_key(&shortcut("Ctrl+Z")));
449    }
450
451    #[test]
452    fn non_consuming_scope_still_allows_global_fallback() {
453        let global = Arc::new(RwLock::new(shortcut_map!["Ctrl+S" => 1u32]));
454        let mut manager = ShortcutManager::new(global);
455        manager.push_scope(ShortcutScope::new(
456            "editor",
457            shortcut_map!["Ctrl+S" => 2u32],
458            false,
459        ));
460
461        let triggered = dispatch_raw_events(&manager, vec![key_event(Key::S, Modifiers::CTRL)]);
462        assert_eq!(triggered, vec![2, 1]);
463    }
464
465    #[test]
466    fn consuming_scope_blocks_global_fallback() {
467        let global = Arc::new(RwLock::new(shortcut_map!["Ctrl+S" => 1u32]));
468        let mut manager = ShortcutManager::new(global);
469        manager.push_scope(ShortcutScope::new(
470            "editor",
471            shortcut_map!["Ctrl+S" => 2u32],
472            true,
473        ));
474
475        let triggered = dispatch_raw_events(&manager, vec![key_event(Key::S, Modifiers::CTRL)]);
476        assert_eq!(triggered, vec![2]);
477    }
478
479    #[test]
480    fn logical_command_shortcut_matches_command_input() {
481        let global = Arc::new(RwLock::new(shortcut_map!["Cmd+S" => 7u32]));
482        let manager = ShortcutManager::new(global);
483
484        let triggered = dispatch_raw_events(&manager, vec![key_event(Key::S, Modifiers::COMMAND)]);
485        assert_eq!(triggered, vec![7]);
486    }
487
488    #[test]
489    fn more_specific_shortcut_wins_with_logical_matching() {
490        let global = Arc::new(RwLock::new(shortcut_map![
491            "Ctrl+S" => 1u32,
492            "Ctrl+Shift+S" => 2u32,
493        ]));
494        let manager = ShortcutManager::new(global);
495
496        let triggered = dispatch_raw_events(
497            &manager,
498            vec![key_event(Key::S, Modifiers::CTRL | Modifiers::SHIFT)],
499        );
500        assert_eq!(triggered, vec![2]);
501    }
502
503    #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
504    enum TestCmd {
505        Save,
506        Help,
507        Quit,
508    }
509
510    impl From<TestCmd> for egui_command::CommandId {
511        fn from(c: TestCmd) -> Self { egui_command::CommandId::new(c) }
512    }
513
514    #[test]
515    fn fill_shortcut_hints_writes_to_registered_commands() {
516        let global = Arc::new(RwLock::new(shortcut_map![
517            "Ctrl+S" => TestCmd::Save,
518            "F1" => TestCmd::Help,
519        ]));
520        let manager = ShortcutManager::new(global);
521
522        let mut reg = egui_command::CommandRegistry::new()
523            .with(
524                TestCmd::Save,
525                egui_command::CommandSpec::new(TestCmd::Save.into(), "Save"),
526            )
527            .with(
528                TestCmd::Help,
529                egui_command::CommandSpec::new(TestCmd::Help.into(), "Help"),
530            )
531            .with(
532                TestCmd::Quit,
533                egui_command::CommandSpec::new(TestCmd::Quit.into(), "Quit"),
534            );
535
536        manager.fill_shortcut_hints(&mut reg);
537
538        let save_hint = reg.spec(TestCmd::Save).unwrap().shortcut_hint.as_deref();
539        let help_hint = reg.spec(TestCmd::Help).unwrap().shortcut_hint.as_deref();
540        let quit_hint = reg.spec(TestCmd::Quit).unwrap().shortcut_hint.as_deref();
541
542        assert!(save_hint.is_some(), "Save should have a shortcut hint");
543        assert!(
544            save_hint.unwrap().contains("S"),
545            "Save hint should mention S key"
546        );
547        assert!(help_hint.is_some(), "Help should have a shortcut hint");
548        assert!(
549            help_hint.unwrap().contains("F1"),
550            "Help hint should contain F1"
551        );
552        assert!(
553            quit_hint.is_none(),
554            "Quit has no binding, hint should be None"
555        );
556    }
557
558    #[test]
559    fn fill_shortcut_hints_unregistered_command_is_skipped() {
560        let global = Arc::new(RwLock::new(shortcut_map!["F9" => TestCmd::Quit]));
561        let manager = ShortcutManager::new(global);
562
563        let mut reg = egui_command::CommandRegistry::new().with(
564            TestCmd::Save,
565            egui_command::CommandSpec::new(TestCmd::Save.into(), "Save"),
566        );
567
568        manager.fill_shortcut_hints(&mut reg);
569
570        assert!(reg.spec(TestCmd::Save).unwrap().shortcut_hint.is_none());
571    }
572}