Skip to main content

retroglyph_widgets/interact/
shortcuts.rs

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