Skip to main content

egui_cha/
bindings.rs

1//! Dynamic input binding system
2//!
3//! Provides a flexible way to manage keyboard shortcuts that can be
4//! rebound at runtime. This is Phase 2 of the keyboard shortcuts system,
5//! building on top of the static shortcuts in the `shortcuts` module.
6//!
7//! # Architecture
8//!
9//! ```text
10//! ┌─────────────────────────────────────────────────────────┐
11//! │  ActionBindings<A>                                      │
12//! │  Maps application actions to shortcuts                  │
13//! │  - rebind(), reset(), find_conflicts()                  │
14//! ├─────────────────────────────────────────────────────────┤
15//! │  DynamicShortcut                                        │
16//! │  Runtime-modifiable keyboard shortcut                   │
17//! │  - Modifiers + Key, serde support                       │
18//! ├─────────────────────────────────────────────────────────┤
19//! │  InputBinding trait                                     │
20//! │  Abstraction over KeyboardShortcut, DynamicShortcut     │
21//! └─────────────────────────────────────────────────────────┘
22//! ```
23//!
24//! # Example
25//!
26//! ```ignore
27//! use egui_cha::bindings::{ActionBindings, DynamicShortcut};
28//! use egui_cha::shortcuts;
29//!
30//! #[derive(Clone, PartialEq, Eq, Hash)]
31//! enum Action {
32//!     Save,
33//!     Undo,
34//!     Redo,
35//! }
36//!
37//! // Create bindings with defaults
38//! let mut bindings = ActionBindings::new()
39//!     .with_default(Action::Save, shortcuts::SAVE)
40//!     .with_default(Action::Undo, shortcuts::UNDO)
41//!     .with_default(Action::Redo, shortcuts::REDO);
42//!
43//! // User rebinds Save to Ctrl+Shift+S
44//! bindings.rebind(
45//!     &Action::Save,
46//!     DynamicShortcut::new(Modifiers::CTRL | Modifiers::SHIFT, Key::S),
47//! );
48//!
49//! // In view function
50//! fn view(model: &Model, ctx: &mut ViewCtx<Msg>) {
51//!     ctx.on_action(&bindings, &Action::Save, Msg::Save);
52//! }
53//! ```
54
55use egui::{Context, Key, KeyboardShortcut, Modifiers};
56use std::collections::HashMap;
57use std::hash::Hash;
58
59/// Abstraction over different types of input bindings.
60///
61/// This trait allows treating static `KeyboardShortcut` constants and
62/// dynamic `DynamicShortcut` values uniformly.
63pub trait InputBinding {
64    /// Check if this binding was triggered (does not consume the input).
65    fn matches(&self, ctx: &Context) -> bool;
66
67    /// Consume the input and return whether it was triggered.
68    ///
69    /// Once consumed, the shortcut won't trigger other handlers.
70    fn consume(&self, ctx: &Context) -> bool;
71
72    /// Get a human-readable representation of this binding.
73    ///
74    /// Useful for displaying in menus or help screens.
75    /// Example: "⌘S" or "Ctrl+S"
76    fn display(&self) -> String;
77
78    /// Convert to KeyboardShortcut if possible.
79    fn as_keyboard_shortcut(&self) -> Option<KeyboardShortcut>;
80}
81
82impl InputBinding for KeyboardShortcut {
83    fn matches(&self, ctx: &Context) -> bool {
84        ctx.input(|i| i.modifiers == self.modifiers && i.key_pressed(self.logical_key))
85    }
86
87    fn consume(&self, ctx: &Context) -> bool {
88        ctx.input_mut(|i| i.consume_shortcut(self))
89    }
90
91    fn display(&self) -> String {
92        self.format(&modifier_names(), self.logical_key == Key::Plus)
93    }
94
95    fn as_keyboard_shortcut(&self) -> Option<KeyboardShortcut> {
96        Some(*self)
97    }
98}
99
100/// A keyboard shortcut that can be modified at runtime.
101///
102/// Unlike `KeyboardShortcut` which is typically a `const`, `DynamicShortcut`
103/// is designed for user-configurable keybindings.
104///
105/// # Serialization
106///
107/// When the `serde` feature is enabled, this type can be serialized/deserialized
108/// for saving user preferences.
109#[derive(Clone, Debug, PartialEq, Eq, Hash)]
110pub struct DynamicShortcut {
111    /// The modifier keys (Ctrl, Shift, Alt, etc.)
112    pub modifiers: Modifiers,
113    /// The main key
114    pub key: Key,
115}
116
117impl DynamicShortcut {
118    /// Create a new dynamic shortcut.
119    pub const fn new(modifiers: Modifiers, key: Key) -> Self {
120        Self { modifiers, key }
121    }
122
123    /// Create a shortcut with no modifiers.
124    pub const fn key_only(key: Key) -> Self {
125        Self::new(Modifiers::NONE, key)
126    }
127
128    /// Convert to egui's KeyboardShortcut.
129    pub const fn to_keyboard_shortcut(&self) -> KeyboardShortcut {
130        KeyboardShortcut::new(self.modifiers, self.key)
131    }
132}
133
134impl From<KeyboardShortcut> for DynamicShortcut {
135    fn from(shortcut: KeyboardShortcut) -> Self {
136        Self {
137            modifiers: shortcut.modifiers,
138            key: shortcut.logical_key,
139        }
140    }
141}
142
143impl From<DynamicShortcut> for KeyboardShortcut {
144    fn from(shortcut: DynamicShortcut) -> Self {
145        KeyboardShortcut::new(shortcut.modifiers, shortcut.key)
146    }
147}
148
149impl InputBinding for DynamicShortcut {
150    fn matches(&self, ctx: &Context) -> bool {
151        self.to_keyboard_shortcut().matches(ctx)
152    }
153
154    fn consume(&self, ctx: &Context) -> bool {
155        self.to_keyboard_shortcut().consume(ctx)
156    }
157
158    fn display(&self) -> String {
159        self.to_keyboard_shortcut().display()
160    }
161
162    fn as_keyboard_shortcut(&self) -> Option<KeyboardShortcut> {
163        Some(self.to_keyboard_shortcut())
164    }
165}
166
167/// Manages the mapping between application actions and keyboard shortcuts.
168///
169/// This struct maintains both the current bindings and the defaults,
170/// allowing users to customize shortcuts while being able to reset to defaults.
171///
172/// # Type Parameter
173///
174/// `A` - The action type. Typically an enum representing all possible
175/// keyboard-triggered actions in your application.
176///
177/// # Example
178///
179/// ```ignore
180/// #[derive(Clone, PartialEq, Eq, Hash)]
181/// enum Action {
182///     NewFile,
183///     Open,
184///     Save,
185///     Undo,
186///     Redo,
187/// }
188///
189/// let bindings = ActionBindings::new()
190///     .with_default(Action::NewFile, shortcuts::NEW)
191///     .with_default(Action::Open, shortcuts::OPEN)
192///     .with_default(Action::Save, shortcuts::SAVE)
193///     .with_default(Action::Undo, shortcuts::UNDO)
194///     .with_default(Action::Redo, shortcuts::REDO);
195/// ```
196#[derive(Clone, Debug)]
197pub struct ActionBindings<A> {
198    /// Current bindings (may differ from defaults after user customization)
199    bindings: HashMap<A, DynamicShortcut>,
200    /// Default bindings (used for reset)
201    defaults: HashMap<A, DynamicShortcut>,
202}
203
204impl<A> Default for ActionBindings<A> {
205    fn default() -> Self {
206        Self::new()
207    }
208}
209
210impl<A> ActionBindings<A> {
211    /// Create a new empty ActionBindings.
212    pub fn new() -> Self {
213        Self {
214            bindings: HashMap::new(),
215            defaults: HashMap::new(),
216        }
217    }
218}
219
220impl<A: Eq + Hash + Clone> ActionBindings<A> {
221    /// Register a default binding for an action (builder pattern).
222    ///
223    /// This sets both the default and the current binding.
224    pub fn with_default(mut self, action: A, shortcut: impl Into<DynamicShortcut>) -> Self {
225        self.register_default(action, shortcut);
226        self
227    }
228
229    /// Register a default binding for an action.
230    ///
231    /// This sets both the default and the current binding.
232    pub fn register_default(&mut self, action: A, shortcut: impl Into<DynamicShortcut>) {
233        let shortcut = shortcut.into();
234        self.defaults.insert(action.clone(), shortcut.clone());
235        self.bindings.insert(action, shortcut);
236    }
237
238    /// Register multiple defaults at once.
239    ///
240    /// # Example
241    /// ```ignore
242    /// bindings.register_defaults([
243    ///     (Action::Save, shortcuts::SAVE),
244    ///     (Action::Undo, shortcuts::UNDO),
245    ///     (Action::Redo, shortcuts::REDO),
246    /// ]);
247    /// ```
248    pub fn register_defaults<I, S>(&mut self, iter: I)
249    where
250        I: IntoIterator<Item = (A, S)>,
251        S: Into<DynamicShortcut>,
252    {
253        for (action, shortcut) in iter {
254            self.register_default(action, shortcut);
255        }
256    }
257
258    /// Rebind an action to a new shortcut.
259    ///
260    /// Returns the previous binding, if any.
261    pub fn rebind(&mut self, action: &A, shortcut: DynamicShortcut) -> Option<DynamicShortcut> {
262        self.bindings.insert(action.clone(), shortcut)
263    }
264
265    /// Reset an action to its default binding.
266    ///
267    /// Returns true if the action had a default to reset to.
268    pub fn reset(&mut self, action: &A) -> bool {
269        if let Some(default) = self.defaults.get(action) {
270            self.bindings.insert(action.clone(), default.clone());
271            true
272        } else {
273            false
274        }
275    }
276
277    /// Reset all actions to their default bindings.
278    pub fn reset_all(&mut self) {
279        self.bindings = self.defaults.clone();
280    }
281
282    /// Get the current binding for an action.
283    pub fn get(&self, action: &A) -> Option<&DynamicShortcut> {
284        self.bindings.get(action)
285    }
286
287    /// Get the default binding for an action.
288    pub fn get_default(&self, action: &A) -> Option<&DynamicShortcut> {
289        self.defaults.get(action)
290    }
291
292    /// Check if an action's binding has been modified from its default.
293    pub fn is_modified(&self, action: &A) -> bool {
294        match (self.bindings.get(action), self.defaults.get(action)) {
295            (Some(current), Some(default)) => current != default,
296            _ => false,
297        }
298    }
299
300    /// Find the action bound to a given shortcut.
301    ///
302    /// Useful for displaying "already bound to X" messages in a keybinding UI.
303    pub fn find_action(&self, shortcut: &DynamicShortcut) -> Option<&A> {
304        self.bindings
305            .iter()
306            .find(|(_, s)| *s == shortcut)
307            .map(|(a, _)| a)
308    }
309
310    /// Find all pairs of actions that share the same shortcut.
311    ///
312    /// Returns an empty Vec if there are no conflicts.
313    pub fn find_conflicts(&self) -> Vec<(&A, &A)> {
314        let mut conflicts = Vec::new();
315        let actions: Vec<_> = self.bindings.keys().collect();
316
317        for i in 0..actions.len() {
318            for j in (i + 1)..actions.len() {
319                if self.bindings.get(actions[i]) == self.bindings.get(actions[j]) {
320                    conflicts.push((actions[i], actions[j]));
321                }
322            }
323        }
324
325        conflicts
326    }
327
328    /// Get an iterator over all (action, shortcut) pairs.
329    pub fn iter(&self) -> impl Iterator<Item = (&A, &DynamicShortcut)> {
330        self.bindings.iter()
331    }
332
333    /// Get the number of registered bindings.
334    pub fn len(&self) -> usize {
335        self.bindings.len()
336    }
337
338    /// Check if there are no bindings.
339    pub fn is_empty(&self) -> bool {
340        self.bindings.is_empty()
341    }
342
343    /// Remove a binding entirely.
344    ///
345    /// This removes both the current binding and the default.
346    pub fn remove(&mut self, action: &A) -> Option<DynamicShortcut> {
347        self.defaults.remove(action);
348        self.bindings.remove(action)
349    }
350
351    /// Check if the given shortcut was triggered and consume it.
352    ///
353    /// Returns Some(action) if a bound action was triggered, None otherwise.
354    pub fn check_triggered(&self, ctx: &Context) -> Option<&A> {
355        for (action, shortcut) in &self.bindings {
356            if shortcut.consume(ctx) {
357                return Some(action);
358            }
359        }
360        None
361    }
362}
363
364/// Helper to get modifier key names.
365/// Always uses text names (Cmd, Ctrl, etc.) instead of symbols (⌘, ⌃)
366/// to avoid font rendering issues.
367fn modifier_names() -> egui::ModifierNames<'static> {
368    // Always use NAMES to avoid symbol rendering issues with icon fonts
369    egui::ModifierNames::NAMES
370}
371
372/// A group of shortcuts where any one can trigger the action.
373///
374/// Useful for supporting multiple shortcuts for the same action,
375/// like both "Cmd+Z" and "Ctrl+Z" for undo on different platforms.
376#[derive(Clone, Debug, Default)]
377pub struct ShortcutGroup {
378    shortcuts: Vec<DynamicShortcut>,
379}
380
381impl ShortcutGroup {
382    /// Create a new empty shortcut group.
383    pub fn new() -> Self {
384        Self::default()
385    }
386
387    /// Add a shortcut to the group.
388    pub fn with(mut self, shortcut: impl Into<DynamicShortcut>) -> Self {
389        self.shortcuts.push(shortcut.into());
390        self
391    }
392
393    /// Add a shortcut to the group.
394    pub fn add(&mut self, shortcut: impl Into<DynamicShortcut>) {
395        self.shortcuts.push(shortcut.into());
396    }
397
398    /// Check if any shortcut in the group matches.
399    pub fn matches(&self, ctx: &Context) -> bool {
400        self.shortcuts.iter().any(|s| s.matches(ctx))
401    }
402
403    /// Consume the first matching shortcut and return whether any matched.
404    pub fn consume(&self, ctx: &Context) -> bool {
405        for shortcut in &self.shortcuts {
406            if shortcut.consume(ctx) {
407                return true;
408            }
409        }
410        false
411    }
412}
413
414impl InputBinding for ShortcutGroup {
415    fn matches(&self, ctx: &Context) -> bool {
416        ShortcutGroup::matches(self, ctx)
417    }
418
419    fn consume(&self, ctx: &Context) -> bool {
420        ShortcutGroup::consume(self, ctx)
421    }
422
423    fn display(&self) -> String {
424        self.shortcuts
425            .iter()
426            .map(|s| s.display())
427            .collect::<Vec<_>>()
428            .join(" / ")
429    }
430
431    fn as_keyboard_shortcut(&self) -> Option<KeyboardShortcut> {
432        self.shortcuts
433            .first()
434            .and_then(|s| s.as_keyboard_shortcut())
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[derive(Clone, Debug, PartialEq, Eq, Hash)]
443    #[allow(dead_code)]
444    enum TestAction {
445        Save,
446        Undo,
447        Redo,
448        Copy,
449    }
450
451    #[test]
452    fn test_dynamic_shortcut_creation() {
453        let shortcut = DynamicShortcut::new(Modifiers::COMMAND, Key::S);
454        assert_eq!(shortcut.modifiers, Modifiers::COMMAND);
455        assert_eq!(shortcut.key, Key::S);
456    }
457
458    #[test]
459    fn test_dynamic_shortcut_from_keyboard_shortcut() {
460        let ks = KeyboardShortcut::new(Modifiers::CTRL, Key::Z);
461        let ds = DynamicShortcut::from(ks);
462        assert_eq!(ds.modifiers, Modifiers::CTRL);
463        assert_eq!(ds.key, Key::Z);
464    }
465
466    #[test]
467    fn test_action_bindings_defaults() {
468        let bindings = ActionBindings::new()
469            .with_default(
470                TestAction::Save,
471                DynamicShortcut::new(Modifiers::COMMAND, Key::S),
472            )
473            .with_default(
474                TestAction::Undo,
475                DynamicShortcut::new(Modifiers::COMMAND, Key::Z),
476            );
477
478        assert_eq!(bindings.len(), 2);
479        assert_eq!(
480            bindings.get(&TestAction::Save),
481            Some(&DynamicShortcut::new(Modifiers::COMMAND, Key::S))
482        );
483    }
484
485    #[test]
486    fn test_action_bindings_rebind() {
487        let mut bindings = ActionBindings::new().with_default(
488            TestAction::Save,
489            DynamicShortcut::new(Modifiers::COMMAND, Key::S),
490        );
491
492        // Rebind to a different shortcut
493        let old = bindings.rebind(
494            &TestAction::Save,
495            DynamicShortcut::new(Modifiers::CTRL.plus(Modifiers::SHIFT), Key::S),
496        );
497
498        assert_eq!(old, Some(DynamicShortcut::new(Modifiers::COMMAND, Key::S)));
499        assert_eq!(
500            bindings.get(&TestAction::Save),
501            Some(&DynamicShortcut::new(
502                Modifiers::CTRL.plus(Modifiers::SHIFT),
503                Key::S
504            ))
505        );
506        assert!(bindings.is_modified(&TestAction::Save));
507    }
508
509    #[test]
510    fn test_action_bindings_reset() {
511        let mut bindings = ActionBindings::new().with_default(
512            TestAction::Save,
513            DynamicShortcut::new(Modifiers::COMMAND, Key::S),
514        );
515
516        // Modify and then reset
517        bindings.rebind(
518            &TestAction::Save,
519            DynamicShortcut::new(Modifiers::CTRL, Key::S),
520        );
521        assert!(bindings.is_modified(&TestAction::Save));
522
523        bindings.reset(&TestAction::Save);
524        assert!(!bindings.is_modified(&TestAction::Save));
525        assert_eq!(
526            bindings.get(&TestAction::Save),
527            Some(&DynamicShortcut::new(Modifiers::COMMAND, Key::S))
528        );
529    }
530
531    #[test]
532    fn test_find_action() {
533        let bindings = ActionBindings::new()
534            .with_default(
535                TestAction::Save,
536                DynamicShortcut::new(Modifiers::COMMAND, Key::S),
537            )
538            .with_default(
539                TestAction::Undo,
540                DynamicShortcut::new(Modifiers::COMMAND, Key::Z),
541            );
542
543        let found = bindings.find_action(&DynamicShortcut::new(Modifiers::COMMAND, Key::S));
544        assert_eq!(found, Some(&TestAction::Save));
545
546        let not_found = bindings.find_action(&DynamicShortcut::new(Modifiers::COMMAND, Key::X));
547        assert_eq!(not_found, None);
548    }
549
550    #[test]
551    fn test_find_conflicts() {
552        let mut bindings = ActionBindings::new()
553            .with_default(
554                TestAction::Save,
555                DynamicShortcut::new(Modifiers::COMMAND, Key::S),
556            )
557            .with_default(
558                TestAction::Undo,
559                DynamicShortcut::new(Modifiers::COMMAND, Key::Z),
560            );
561
562        // No conflicts initially
563        assert!(bindings.find_conflicts().is_empty());
564
565        // Create a conflict
566        bindings.rebind(
567            &TestAction::Undo,
568            DynamicShortcut::new(Modifiers::COMMAND, Key::S),
569        );
570
571        let conflicts = bindings.find_conflicts();
572        assert_eq!(conflicts.len(), 1);
573    }
574
575    #[test]
576    fn test_shortcut_group() {
577        let group = ShortcutGroup::new()
578            .with(DynamicShortcut::new(Modifiers::COMMAND, Key::Z))
579            .with(DynamicShortcut::new(Modifiers::CTRL, Key::Z));
580
581        // Display format is platform-dependent, just verify it contains the separator
582        let display = group.display();
583        assert!(
584            display.contains(" / "),
585            "Expected separator in: {}",
586            display
587        );
588        assert!(display.contains("Z"), "Expected key Z in: {}", display);
589    }
590}