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    enum TestAction {
444        Save,
445        Undo,
446        Redo,
447        Copy,
448    }
449
450    #[test]
451    fn test_dynamic_shortcut_creation() {
452        let shortcut = DynamicShortcut::new(Modifiers::COMMAND, Key::S);
453        assert_eq!(shortcut.modifiers, Modifiers::COMMAND);
454        assert_eq!(shortcut.key, Key::S);
455    }
456
457    #[test]
458    fn test_dynamic_shortcut_from_keyboard_shortcut() {
459        let ks = KeyboardShortcut::new(Modifiers::CTRL, Key::Z);
460        let ds = DynamicShortcut::from(ks);
461        assert_eq!(ds.modifiers, Modifiers::CTRL);
462        assert_eq!(ds.key, Key::Z);
463    }
464
465    #[test]
466    fn test_action_bindings_defaults() {
467        let bindings = ActionBindings::new()
468            .with_default(
469                TestAction::Save,
470                DynamicShortcut::new(Modifiers::COMMAND, Key::S),
471            )
472            .with_default(
473                TestAction::Undo,
474                DynamicShortcut::new(Modifiers::COMMAND, Key::Z),
475            );
476
477        assert_eq!(bindings.len(), 2);
478        assert_eq!(
479            bindings.get(&TestAction::Save),
480            Some(&DynamicShortcut::new(Modifiers::COMMAND, Key::S))
481        );
482    }
483
484    #[test]
485    fn test_action_bindings_rebind() {
486        let mut bindings = ActionBindings::new().with_default(
487            TestAction::Save,
488            DynamicShortcut::new(Modifiers::COMMAND, Key::S),
489        );
490
491        // Rebind to a different shortcut
492        let old = bindings.rebind(
493            &TestAction::Save,
494            DynamicShortcut::new(Modifiers::CTRL.plus(Modifiers::SHIFT), Key::S),
495        );
496
497        assert_eq!(old, Some(DynamicShortcut::new(Modifiers::COMMAND, Key::S)));
498        assert_eq!(
499            bindings.get(&TestAction::Save),
500            Some(&DynamicShortcut::new(
501                Modifiers::CTRL.plus(Modifiers::SHIFT),
502                Key::S
503            ))
504        );
505        assert!(bindings.is_modified(&TestAction::Save));
506    }
507
508    #[test]
509    fn test_action_bindings_reset() {
510        let mut bindings = ActionBindings::new().with_default(
511            TestAction::Save,
512            DynamicShortcut::new(Modifiers::COMMAND, Key::S),
513        );
514
515        // Modify and then reset
516        bindings.rebind(
517            &TestAction::Save,
518            DynamicShortcut::new(Modifiers::CTRL, Key::S),
519        );
520        assert!(bindings.is_modified(&TestAction::Save));
521
522        bindings.reset(&TestAction::Save);
523        assert!(!bindings.is_modified(&TestAction::Save));
524        assert_eq!(
525            bindings.get(&TestAction::Save),
526            Some(&DynamicShortcut::new(Modifiers::COMMAND, Key::S))
527        );
528    }
529
530    #[test]
531    fn test_find_action() {
532        let bindings = ActionBindings::new()
533            .with_default(
534                TestAction::Save,
535                DynamicShortcut::new(Modifiers::COMMAND, Key::S),
536            )
537            .with_default(
538                TestAction::Undo,
539                DynamicShortcut::new(Modifiers::COMMAND, Key::Z),
540            );
541
542        let found = bindings.find_action(&DynamicShortcut::new(Modifiers::COMMAND, Key::S));
543        assert_eq!(found, Some(&TestAction::Save));
544
545        let not_found = bindings.find_action(&DynamicShortcut::new(Modifiers::COMMAND, Key::X));
546        assert_eq!(not_found, None);
547    }
548
549    #[test]
550    fn test_find_conflicts() {
551        let mut bindings = ActionBindings::new()
552            .with_default(
553                TestAction::Save,
554                DynamicShortcut::new(Modifiers::COMMAND, Key::S),
555            )
556            .with_default(
557                TestAction::Undo,
558                DynamicShortcut::new(Modifiers::COMMAND, Key::Z),
559            );
560
561        // No conflicts initially
562        assert!(bindings.find_conflicts().is_empty());
563
564        // Create a conflict
565        bindings.rebind(
566            &TestAction::Undo,
567            DynamicShortcut::new(Modifiers::COMMAND, Key::S),
568        );
569
570        let conflicts = bindings.find_conflicts();
571        assert_eq!(conflicts.len(), 1);
572    }
573
574    #[test]
575    fn test_shortcut_group() {
576        let group = ShortcutGroup::new()
577            .with(DynamicShortcut::new(Modifiers::COMMAND, Key::Z))
578            .with(DynamicShortcut::new(Modifiers::CTRL, Key::Z));
579
580        // Display format is platform-dependent, just verify it contains the separator
581        let display = group.display();
582        assert!(
583            display.contains(" / "),
584            "Expected separator in: {}",
585            display
586        );
587        assert!(display.contains("Z"), "Expected key Z in: {}", display);
588    }
589}