Skip to main content

retroglyph_ui/interact/
shortcuts.rs

1//! [`Shortcuts`]: a small, focus-scoped keyboard shortcut registry.
2
3use alloc::vec::Vec;
4
5use retroglyph_core::event::{Event, KeyCode, KeyModifiers};
6
7/// One registered key combination and what it resolves to.
8#[derive(Debug, Clone, Copy)]
9struct Binding<Id, Action> {
10    /// `None` = fires regardless of focus. `Some(id)` = only fires while
11    /// `id` currently holds focus.
12    scope: Option<Id>,
13    code: KeyCode,
14    modifiers: KeyModifiers,
15    action: Action,
16}
17
18/// Maps key combinations to app-defined `Action`s, the same way
19/// [`HitTester`](crate::HitTester) maps a pointer position to a widget id.
20///
21/// A lookup table an app consults, not something that owns input handling.
22/// Bindings are either global (fire regardless of focus) or scoped to a
23/// single [`FocusRing`](crate::FocusRing) id (fire only while that id holds
24/// focus); [`resolve`](Self::resolve) checks the scoped binding first, so a
25/// widget can shadow a global shortcut for the same key while it's focused.
26///
27/// This does not replace ad hoc `match key.code { .. }` handling for
28/// widget-specific navigation (arrow keys meaning "move selection" only
29/// while a particular id is focused, say): that kind of binding usually
30/// carries extra context (list length, current offset) that doesn't fit a
31/// flat `Action` enum. `Shortcuts` is for the simple case: one key, always
32/// the same `Action`, wherever it's in scope. Bindings are a fixed table set
33/// up once (there's no per-frame `begin_frame`/registration step like
34/// [`FocusRing`](crate::FocusRing)'s, a key combination either exists or
35/// it doesn't, regardless of what happened to be drawn this frame).
36///
37/// # Examples
38///
39/// ```
40/// use retroglyph_core::event::{Event, KeyCode, KeyEvent, KeyModifiers};
41/// use retroglyph_ui::Shortcuts;
42///
43/// #[derive(Clone, Copy, PartialEq, Eq)]
44/// enum Id {
45///     SearchBox,
46/// }
47///
48/// #[derive(Debug, Clone, Copy, PartialEq, Eq)]
49/// enum Action {
50///     ToggleTheme,
51///     ClearSearch,
52/// }
53///
54/// let mut shortcuts = Shortcuts::new();
55/// shortcuts.bind_global(KeyCode::Char('t'), KeyModifiers::NONE, Action::ToggleTheme);
56/// shortcuts.bind_scoped(
57///     Id::SearchBox,
58///     KeyCode::Escape,
59///     KeyModifiers::NONE,
60///     Action::ClearSearch,
61/// );
62///
63/// let escape = Event::Key(KeyEvent::new(KeyCode::Escape, KeyModifiers::NONE));
64/// assert_eq!(shortcuts.resolve(&escape, Some(Id::SearchBox)), Some(Action::ClearSearch));
65/// assert_eq!(shortcuts.resolve(&escape, None), None); // scoped binding, nothing focused
66///
67/// let t = Event::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE));
68/// assert_eq!(shortcuts.resolve(&t, None), Some(Action::ToggleTheme)); // global, focus-independent
69/// ```
70#[derive(Debug, Clone)]
71pub struct Shortcuts<Id, Action> {
72    bindings: Vec<Binding<Id, Action>>,
73}
74
75impl<Id, Action> Shortcuts<Id, Action> {
76    /// An empty registry.
77    #[must_use]
78    pub const fn new() -> Self {
79        Self {
80            bindings: Vec::new(),
81        }
82    }
83}
84
85impl<Id: Copy + PartialEq, Action: Copy> Shortcuts<Id, Action> {
86    /// Registers a binding that fires regardless of what holds focus.
87    pub fn bind_global(&mut self, code: KeyCode, modifiers: KeyModifiers, action: Action) {
88        self.bindings.push(Binding {
89            scope: None,
90            code,
91            modifiers,
92            action,
93        });
94    }
95
96    /// Registers a binding that only fires while `id` holds focus.
97    pub fn bind_scoped(&mut self, id: Id, code: KeyCode, modifiers: KeyModifiers, action: Action) {
98        self.bindings.push(Binding {
99            scope: Some(id),
100            code,
101            modifiers,
102            action,
103        });
104    }
105
106    /// Resolves `event` against `focused` (typically
107    /// [`FocusRing::focused`](crate::FocusRing::focused)).
108    ///
109    /// `None` for anything but a key-down event. Otherwise: the first
110    /// registered binding scoped to `focused` with a matching
111    /// code/modifiers, or, failing that, the first matching global binding.
112    /// A scoped binding never fires for any id other than the one it named,
113    /// including when nothing is focused.
114    #[must_use]
115    pub fn resolve(&self, event: &Event, focused: Option<Id>) -> Option<Action> {
116        let Event::Key(key) = event else {
117            return None;
118        };
119        if !key.is_down() {
120            return None;
121        }
122        let matches = |b: &&Binding<Id, Action>| b.code == key.code && b.modifiers == key.modifiers;
123
124        if let Some(focused) = focused
125            && let Some(binding) = self
126                .bindings
127                .iter()
128                .find(|b| b.scope == Some(focused) && matches(b))
129        {
130            return Some(binding.action);
131        }
132        self.bindings
133            .iter()
134            .find(|b| b.scope.is_none() && matches(b))
135            .map(|b| b.action)
136    }
137}
138
139// Not `#[derive(Default)]`: that would add unnecessary `Id`/`Action` bounds
140// to the generated impl, even though an empty `Vec` never needs them (same
141// rationale as `FocusRing`'s manual `Default`).
142impl<Id, Action> Default for Shortcuts<Id, Action> {
143    fn default() -> Self {
144        Self::new()
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use retroglyph_core::event::KeyEvent;
151
152    use super::*;
153
154    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
155    enum Id {
156        List,
157        Search,
158    }
159
160    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
161    enum Action {
162        ToggleTheme,
163        ClearSearch,
164        DeleteSelected,
165    }
166
167    fn key(code: KeyCode) -> Event {
168        Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
169    }
170
171    #[test]
172    fn global_binding_fires_regardless_of_focus() {
173        let mut shortcuts = Shortcuts::new();
174        shortcuts.bind_global(KeyCode::Char('t'), KeyModifiers::NONE, Action::ToggleTheme);
175
176        assert_eq!(
177            shortcuts.resolve(&key(KeyCode::Char('t')), None),
178            Some(Action::ToggleTheme)
179        );
180        assert_eq!(
181            shortcuts.resolve(&key(KeyCode::Char('t')), Some(Id::List)),
182            Some(Action::ToggleTheme)
183        );
184    }
185
186    #[test]
187    fn scoped_binding_only_fires_while_its_id_is_focused() {
188        let mut shortcuts = Shortcuts::new();
189        shortcuts.bind_scoped(
190            Id::Search,
191            KeyCode::Escape,
192            KeyModifiers::NONE,
193            Action::ClearSearch,
194        );
195
196        assert_eq!(
197            shortcuts.resolve(&key(KeyCode::Escape), Some(Id::Search)),
198            Some(Action::ClearSearch)
199        );
200        assert_eq!(
201            shortcuts.resolve(&key(KeyCode::Escape), Some(Id::List)),
202            None
203        );
204        assert_eq!(shortcuts.resolve(&key(KeyCode::Escape), None), None);
205    }
206
207    #[test]
208    fn scoped_binding_takes_priority_over_a_global_one_for_the_same_key() {
209        let mut shortcuts = Shortcuts::new();
210        shortcuts.bind_global(KeyCode::Delete, KeyModifiers::NONE, Action::ToggleTheme);
211        shortcuts.bind_scoped(
212            Id::List,
213            KeyCode::Delete,
214            KeyModifiers::NONE,
215            Action::DeleteSelected,
216        );
217
218        assert_eq!(
219            shortcuts.resolve(&key(KeyCode::Delete), Some(Id::List)),
220            Some(Action::DeleteSelected)
221        );
222        // Different (or no) focus: falls through to the global binding.
223        assert_eq!(
224            shortcuts.resolve(&key(KeyCode::Delete), Some(Id::Search)),
225            Some(Action::ToggleTheme)
226        );
227        assert_eq!(
228            shortcuts.resolve(&key(KeyCode::Delete), None),
229            Some(Action::ToggleTheme)
230        );
231    }
232
233    #[test]
234    fn modifiers_must_match_exactly() {
235        let mut shortcuts = Shortcuts::<Id, Action>::new();
236        shortcuts.bind_global(
237            KeyCode::Char('s'),
238            KeyModifiers::CONTROL,
239            Action::ToggleTheme,
240        );
241
242        let ctrl_s = Event::Key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
243        assert_eq!(shortcuts.resolve(&ctrl_s, None), Some(Action::ToggleTheme));
244        assert_eq!(shortcuts.resolve(&key(KeyCode::Char('s')), None), None);
245    }
246
247    #[test]
248    fn ignores_non_key_and_key_up_events() {
249        let mut shortcuts = Shortcuts::<Id, Action>::new();
250        shortcuts.bind_global(KeyCode::Char('t'), KeyModifiers::NONE, Action::ToggleTheme);
251
252        assert_eq!(shortcuts.resolve(&Event::Close, None), None);
253
254        let released = Event::Key(KeyEvent::with_kind(
255            KeyCode::Char('t'),
256            KeyModifiers::NONE,
257            retroglyph_core::event::KeyEventKind::Release,
258        ));
259        assert_eq!(shortcuts.resolve(&released, None), None);
260    }
261
262    #[test]
263    fn empty_registry_resolves_nothing() {
264        let shortcuts = Shortcuts::<Id, Action>::new();
265        assert_eq!(shortcuts.resolve(&key(KeyCode::Char('t')), None), None);
266    }
267}