Skip to main content

frus_widgets/
shortcuts.rs

1//! **Shortcuts and actions**: a key combination, what it means, and who answers.
2//!
3//! The reference splits this in two on purpose, and the split is the whole design:
4//!
5//! - [`Shortcuts`] maps a **keystroke to an intent** — *Ctrl+S means «save»*. It knows
6//!   nothing about what saving does.
7//! - [`Actions`] maps an **intent to a message** — *«save» means `Msg::Save`*. It knows
8//!   nothing about which key got here.
9//!
10//! The point of the indirection is that the two can be replaced independently. A dialog
11//! can rebind the keys without touching the handlers; a subtree can answer «save»
12//! differently from the page around it, and the innermost answer wins — the same rule as
13//! focus, resolved the same way.
14//!
15//! When there is no reason to name an intent, [`CallbackShortcuts`] binds the keystroke
16//! straight to a message and skips the ceremony.
17//!
18//! ```ignore
19//! Actions::new(child)
20//!     .action(Intent("save"), Msg::Save)
21//!     .action(Intent("close"), Msg::Close)
22//! // …with, anywhere inside it:
23//! Shortcuts::new(form)
24//!     .bind(KeyStroke::new(ShortcutKey::Char('s')).ctrl(), Intent("save"))
25//! ```
26//!
27//! ## Which scope answers
28//!
29//! A binding applies when **focus is inside the subtree that declared it**, and the
30//! innermost such subtree wins. With nothing focused, every scope is a candidate and the
31//! innermost declared still wins.
32//!
33//! ## Typing beats a bare letter
34//!
35//! A keystroke with no Ctrl, Alt or Meta goes to the focused text field first, if there is
36//! one. Otherwise a `Shortcuts` binding on the letter `a` would make every field in the
37//! application impossible to type in — the reference reaches the same conclusion through
38//! its text-editing shortcuts taking priority in the focused scope.
39//!
40//! ## Not here: `ShortcutRegistrar`
41//!
42//! The reference has a registry a deep widget can *register into at runtime*, because its
43//! tree is retained and a widget's build is not re-run on every frame. Here the view is
44//! rebuilt from the state each frame, so declaring the binding where it applies is the
45//! same thing with none of the bookkeeping. That is an architectural difference, not a
46//! missing feature.
47
48use frus_core::{Rect, Scene};
49use frus_layout::Style;
50
51use crate::interaction::Status;
52use crate::theme::Theme;
53use crate::widget::{sizing_of, Widget};
54
55/// A key a shortcut can be bound to — a **general** vocabulary, unlike
56/// [`Key`](crate::Key), which is the one text editing needs.
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum ShortcutKey {
59    /// A printable key, matched **case-insensitively**: `Ctrl+S` and `Ctrl+s` are the
60    /// same shortcut, and the caller should not have to think about Caps Lock.
61    Char(char),
62    /// Enter / Return.
63    Enter,
64    /// Escape.
65    Escape,
66    /// Tab.
67    Tab,
68    /// Space.
69    Space,
70    /// Backspace.
71    Backspace,
72    /// Forward delete.
73    Delete,
74    /// Arrow up.
75    Up,
76    /// Arrow down.
77    Down,
78    /// Arrow left.
79    Left,
80    /// Arrow right.
81    Right,
82    /// Home.
83    Home,
84    /// End.
85    End,
86    /// Page up.
87    PageUp,
88    /// Page down.
89    PageDown,
90    /// A function key, `F(1)` through `F(24)`.
91    F(u8),
92}
93
94/// A key plus its modifiers — both the **pattern** a shortcut is bound to and the
95/// **description** of what was actually pressed. One type, because they are compared to
96/// each other and a second one would only be the first with the fields renamed.
97#[derive(Clone, Copy, Debug, PartialEq, Eq)]
98pub struct KeyStroke {
99    /// The key itself.
100    pub key: ShortcutKey,
101    /// Control (or Command on macOS, where the shell maps it here).
102    pub ctrl: bool,
103    /// Shift.
104    pub shift: bool,
105    /// Alt / Option.
106    pub alt: bool,
107    /// The platform's "logo" key, when it is not already folded into `ctrl`.
108    pub meta: bool,
109}
110
111impl KeyStroke {
112    /// A bare key, no modifiers.
113    pub const fn new(key: ShortcutKey) -> Self {
114        Self {
115            key,
116            ctrl: false,
117            shift: false,
118            alt: false,
119            meta: false,
120        }
121    }
122
123    /// With Control held.
124    pub const fn ctrl(mut self) -> Self {
125        self.ctrl = true;
126        self
127    }
128
129    /// With Shift held.
130    pub const fn shift(mut self) -> Self {
131        self.shift = true;
132        self
133    }
134
135    /// With Alt held.
136    pub const fn alt(mut self) -> Self {
137        self.alt = true;
138        self
139    }
140
141    /// With the logo key held.
142    pub const fn meta(mut self) -> Self {
143        self.meta = true;
144        self
145    }
146
147    /// Does this stroke carry a modifier that makes it a **command** rather than typing?
148    /// Shift alone does not: `Shift+A` is a capital A.
149    pub const fn is_command(&self) -> bool {
150        self.ctrl || self.alt || self.meta
151    }
152
153    /// Do a pressed stroke and a bound one match? Letters are compared without case, so a
154    /// binding written `Char('s')` answers a Shift-less `S` from a keyboard with Caps Lock
155    /// on.
156    pub fn matches(&self, bound: &KeyStroke) -> bool {
157        let same_key = match (self.key, bound.key) {
158            (ShortcutKey::Char(a), ShortcutKey::Char(b)) => {
159                a.eq_ignore_ascii_case(&b) || a.to_lowercase().eq(b.to_lowercase())
160            }
161            (a, b) => a == b,
162        };
163        same_key
164            && self.ctrl == bound.ctrl
165            && self.shift == bound.shift
166            && self.alt == bound.alt
167            && self.meta == bound.meta
168    }
169}
170
171/// What a keystroke **means**, named rather than implemented: `Intent("save")`.
172///
173/// A string rather than a type, because the whole point is that the widget binding the key
174/// and the widget answering it need not know about one another — and a name reads in a
175/// dump, which a type id does not.
176#[derive(Clone, Copy, Debug, PartialEq, Eq)]
177pub struct Intent(pub &'static str);
178
179/// The shared body: one child, its box, and one of the three tables.
180struct Wrapper<Msg> {
181    child: Vec<Box<dyn Widget<Msg>>>,
182    focusable: bool,
183    shortcuts: Vec<(KeyStroke, Intent)>,
184    callbacks: Vec<(KeyStroke, Msg)>,
185    actions: Vec<(Intent, Msg)>,
186    listeners: Vec<(Intent, Msg)>,
187}
188
189impl<Msg> Wrapper<Msg> {
190    fn new(child: Box<dyn Widget<Msg>>) -> Self {
191        Self {
192            child: vec![child],
193            focusable: false,
194            shortcuts: Vec::new(),
195            callbacks: Vec::new(),
196            actions: Vec::new(),
197            listeners: Vec::new(),
198        }
199    }
200}
201
202impl<Msg: Clone> Widget<Msg> for Wrapper<Msg> {
203    fn style(&self) -> Style {
204        sizing_of(self.child[0].style())
205    }
206
207    fn style_themed(&self, theme: &Theme) -> Style {
208        sizing_of(self.child[0].style_themed(theme))
209    }
210
211    fn build_themed(&self, theme: &Theme) {
212        self.child[0].build_themed(theme);
213    }
214
215    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
216        &self.child
217    }
218
219    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
220
221    fn on_click(&self) -> Option<Msg> {
222        None
223    }
224
225    fn focusable(&self) -> bool {
226        self.focusable
227    }
228
229    fn shortcut_bindings(&self) -> &[(KeyStroke, Intent)] {
230        &self.shortcuts
231    }
232
233    fn shortcut_callbacks(&self) -> &[(KeyStroke, Msg)] {
234        &self.callbacks
235    }
236
237    fn action_bindings(&self) -> &[(Intent, Msg)] {
238        &self.actions
239    }
240
241    fn action_listeners(&self) -> &[(Intent, Msg)] {
242        &self.listeners
243    }
244}
245
246/// Binds **keystrokes to intents** for its subtree. See the module's documentation for
247/// why that is two steps rather than one.
248pub struct Shortcuts<Msg = crate::callback::Callback>(Wrapper<Msg>);
249
250impl<Msg> Shortcuts<Msg> {
251    /// A shortcut scope around `child`.
252    pub fn new(child: impl Widget<Msg> + 'static) -> Self {
253        Self(Wrapper::new(Box::new(child)))
254    }
255
256    /// Binds a keystroke to an intent. A later binding for the same stroke replaces an
257    /// earlier one, so a caller can override a table it did not write.
258    pub fn bind(mut self, stroke: KeyStroke, intent: Intent) -> Self {
259        self.0.shortcuts.retain(|(s, _)| !s.matches(&stroke));
260        self.0.shortcuts.push((stroke, intent));
261        self
262    }
263}
264
265/// Binds **keystrokes straight to messages**, for when naming an intent would be
266/// ceremony — the reference's `CallbackShortcuts`.
267pub struct CallbackShortcuts<Msg = crate::callback::Callback>(Wrapper<Msg>);
268
269impl<Msg: Clone> CallbackShortcuts<Msg> {
270    /// A shortcut scope around `child`.
271    pub fn new(child: impl Widget<Msg> + 'static) -> Self {
272        Self(Wrapper::new(Box::new(child)))
273    }
274
275    /// Binds a keystroke to the message it sends.
276    pub fn bind(mut self, stroke: KeyStroke, msg: Msg) -> Self {
277        self.0.callbacks.retain(|(s, _)| !s.matches(&stroke));
278        self.0.callbacks.push((stroke, msg));
279        self
280    }
281}
282
283/// Answers **intents** for its subtree: the innermost `Actions` that has the intent is
284/// the one that answers it.
285pub struct Actions<Msg = crate::callback::Callback>(Wrapper<Msg>);
286
287impl<Msg> Actions<Msg> {
288    /// An action scope around `child`.
289    pub fn new(child: impl Widget<Msg> + 'static) -> Self {
290        Self(Wrapper::new(Box::new(child)))
291    }
292
293    /// What this scope does about `intent`.
294    pub fn action(mut self, intent: Intent, msg: Msg) -> Self {
295        self.0.actions.push((intent, msg));
296        self
297    }
298}
299
300/// **Watches** intents without answering them: its message is sent as well as, not
301/// instead of, whatever the answering [`Actions`] sends.
302///
303/// For the things that want to know an action happened — an undo stack, a telemetry
304/// counter, a status line — without becoming the one that performs it.
305pub struct ActionListener<Msg = crate::callback::Callback>(Wrapper<Msg>);
306
307impl<Msg> ActionListener<Msg> {
308    /// A listener around `child`.
309    pub fn new(child: impl Widget<Msg> + 'static) -> Self {
310        Self(Wrapper::new(Box::new(child)))
311    }
312
313    /// Also send `msg` whenever `intent` is invoked inside this subtree.
314    pub fn on(mut self, intent: Intent, msg: Msg) -> Self {
315        self.0.listeners.push((intent, msg));
316        self
317    }
318}
319
320/// A **focus stop that carries its own keys**: focusable, with its own shortcut and action
321/// tables, so a control can answer the keyboard for itself while it has focus.
322///
323/// The reference's `FocusableActionDetector`, and the sixth of the focus widgets — it
324/// waited for this milestone because it is made of shortcuts as much as of focus. It is
325/// the composition, given one name because the three are always wanted together: a menu
326/// item that answers Enter, a card that answers Delete, a canvas that answers arrows.
327pub struct FocusableActionDetector<Msg = crate::callback::Callback>(Wrapper<Msg>);
328
329impl<Msg: Clone> FocusableActionDetector<Msg> {
330    /// A focusable, key-answering wrapper around `child`.
331    pub fn new(child: impl Widget<Msg> + 'static) -> Self {
332        let mut w = Wrapper::new(Box::new(child));
333        w.focusable = true;
334        Self(w)
335    }
336
337    /// Whether it takes focus at all. A disabled control should say `false` here rather
338    /// than keep an inert focus stop that Tab still lands on.
339    pub fn enabled(mut self, enabled: bool) -> Self {
340        self.0.focusable = enabled;
341        self
342    }
343
344    /// Binds a keystroke to an intent, for while focus is inside.
345    pub fn bind(mut self, stroke: KeyStroke, intent: Intent) -> Self {
346        self.0.shortcuts.retain(|(s, _)| !s.matches(&stroke));
347        self.0.shortcuts.push((stroke, intent));
348        self
349    }
350
351    /// Binds a keystroke straight to a message.
352    pub fn on_key(mut self, stroke: KeyStroke, msg: Msg) -> Self {
353        self.0.callbacks.retain(|(s, _)| !s.matches(&stroke));
354        self.0.callbacks.push((stroke, msg));
355        self
356    }
357
358    /// What this control does about `intent`.
359    pub fn action(mut self, intent: Intent, msg: Msg) -> Self {
360        self.0.actions.push((intent, msg));
361        self
362    }
363}
364
365/// Hands **every keystroke** in its subtree to a closure — the reference's
366/// `KeyboardListener`, and the escape hatch for anything the tables above cannot say.
367pub struct KeyboardListener<Msg = crate::callback::Callback> {
368    child: Vec<Box<dyn Widget<Msg>>>,
369    /// Shared rather than borrowed: the interface the walk produces outlives the tree it
370    /// was built from, so a closure it carries has to be owned.
371    #[allow(clippy::type_complexity)]
372    on_key: std::rc::Rc<dyn Fn(KeyStroke) -> Option<Msg>>,
373}
374
375impl<Msg> KeyboardListener<Msg> {
376    /// Wraps `child`, calling `on_key` for each keystroke while focus is inside it.
377    /// Returning `None` lets the keystroke carry on to the scopes around it.
378    pub fn new(
379        child: impl Widget<Msg> + 'static,
380        on_key: impl Fn(KeyStroke) -> Option<Msg> + 'static,
381    ) -> Self {
382        Self {
383            child: vec![Box::new(child)],
384            on_key: std::rc::Rc::new(on_key),
385        }
386    }
387}
388
389impl<Msg: Clone> Widget<Msg> for KeyboardListener<Msg> {
390    fn style(&self) -> Style {
391        sizing_of(self.child[0].style())
392    }
393
394    fn style_themed(&self, theme: &Theme) -> Style {
395        sizing_of(self.child[0].style_themed(theme))
396    }
397
398    fn build_themed(&self, theme: &Theme) {
399        self.child[0].build_themed(theme);
400    }
401
402    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
403        &self.child
404    }
405
406    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
407
408    fn on_click(&self) -> Option<Msg> {
409        None
410    }
411
412    fn on_keystroke(&self) -> Option<std::rc::Rc<dyn Fn(KeyStroke) -> Option<Msg>>> {
413        Some(std::rc::Rc::clone(&self.on_key))
414    }
415}
416
417/// The four table-carrying wrappers are the same box with a different table filled in.
418macro_rules! delegate {
419    ($($ty:ident),* $(,)?) => {$(
420        impl<Msg: Clone> Widget<Msg> for $ty<Msg> {
421            fn style(&self) -> Style {
422                Widget::<Msg>::style(&self.0)
423            }
424            fn style_themed(&self, theme: &Theme) -> Style {
425                Widget::<Msg>::style_themed(&self.0, theme)
426            }
427            fn build_themed(&self, theme: &Theme) {
428                Widget::<Msg>::build_themed(&self.0, theme)
429            }
430            fn children(&self) -> &[Box<dyn Widget<Msg>>] {
431                Widget::<Msg>::children(&self.0)
432            }
433            fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
434                Widget::<Msg>::paint(&self.0, bounds, status, theme, scene)
435            }
436            fn on_click(&self) -> Option<Msg> {
437                Widget::<Msg>::on_click(&self.0)
438            }
439            fn focusable(&self) -> bool {
440                Widget::<Msg>::focusable(&self.0)
441            }
442            fn shortcut_bindings(&self) -> &[(KeyStroke, Intent)] {
443                Widget::<Msg>::shortcut_bindings(&self.0)
444            }
445            fn shortcut_callbacks(&self) -> &[(KeyStroke, Msg)] {
446                Widget::<Msg>::shortcut_callbacks(&self.0)
447            }
448            fn action_bindings(&self) -> &[(Intent, Msg)] {
449                Widget::<Msg>::action_bindings(&self.0)
450            }
451            fn action_listeners(&self) -> &[(Intent, Msg)] {
452                Widget::<Msg>::action_listeners(&self.0)
453            }
454        }
455    )*};
456}
457
458delegate!(
459    Shortcuts,
460    CallbackShortcuts,
461    Actions,
462    ActionListener,
463    FocusableActionDetector,
464);
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    #[derive(Clone, Debug, PartialEq)]
471    enum Msg {
472        SavePage,
473        SaveDialog,
474        Edited(String),
475    }
476
477    #[test]
478    fn a_letter_matches_without_case_and_a_modifier_does_not() {
479        let bound = KeyStroke::new(ShortcutKey::Char('s')).ctrl();
480        assert!(KeyStroke::new(ShortcutKey::Char('S'))
481            .ctrl()
482            .matches(&bound));
483        assert!(KeyStroke::new(ShortcutKey::Char('s'))
484            .ctrl()
485            .matches(&bound));
486        assert!(
487            !KeyStroke::new(ShortcutKey::Char('s')).matches(&bound),
488            "no Ctrl"
489        );
490        assert!(
491            !KeyStroke::new(ShortcutKey::Char('s'))
492                .ctrl()
493                .shift()
494                .matches(&bound),
495            "Shift is part of the pattern"
496        );
497    }
498
499    /// Shift alone is typing, not a command — otherwise a capital letter could not be
500    /// typed into a field that sits under a shortcut scope.
501    #[test]
502    fn shift_alone_is_not_a_command() {
503        assert!(!KeyStroke::new(ShortcutKey::Char('a')).shift().is_command());
504        assert!(KeyStroke::new(ShortcutKey::Char('a')).ctrl().is_command());
505        assert!(KeyStroke::new(ShortcutKey::Char('a')).alt().is_command());
506        assert!(KeyStroke::new(ShortcutKey::Char('a')).meta().is_command());
507    }
508
509    /// The two-step resolution end to end: a key names an intent, and the innermost
510    /// `Actions` that answers it supplies the message.
511    #[test]
512    fn a_key_names_an_intent_and_the_nearest_answer_wins() {
513        use crate::runtime::Runtime;
514        use crate::ui::build_ui;
515        use frus_core::Size;
516
517        let save = KeyStroke::new(ShortcutKey::Char('s')).ctrl();
518        let field = |name: &str| {
519            crate::TextField::new(name.to_string())
520                .label(name.to_string())
521                .on_input(Msg::Edited)
522        };
523
524        // A page that saves, holding a dialog that saves differently.
525        let tree = Actions::new(
526            Shortcuts::new(crate::column![
527                field("page"),
528                Actions::new(field("dialog")).action(Intent("save"), Msg::SaveDialog),
529            ])
530            .bind(save, Intent("save")),
531        )
532        .action(Intent("save"), Msg::SavePage);
533
534        let ui = build_ui(
535            &tree,
536            Size::new(400.0, 400.0),
537            &Runtime::default(),
538            &Theme::default(),
539        );
540        let stops: Vec<_> = ui.focusable_ids().collect();
541        assert_eq!(stops.len(), 2);
542
543        assert_eq!(ui.keystroke(save, Some(stops[0])), vec![Msg::SavePage]);
544        assert_eq!(
545            ui.keystroke(save, Some(stops[1])),
546            vec![Msg::SaveDialog],
547            "the innermost answer wins"
548        );
549        assert!(
550            ui.keystroke(KeyStroke::new(ShortcutKey::Char('s')), Some(stops[0]))
551                .is_empty(),
552            "without Ctrl it is typing, not a command"
553        );
554    }
555
556    /// An intent nobody answers is inert, not an error: a key bound to a meaning the
557    /// current screen has no answer for should do nothing.
558    #[test]
559    fn an_unanswered_intent_does_nothing() {
560        use crate::runtime::Runtime;
561        use crate::ui::build_ui;
562        use frus_core::Size;
563
564        let save = KeyStroke::new(ShortcutKey::Char('s')).ctrl();
565        let tree: Shortcuts<Msg> =
566            Shortcuts::new(crate::Container::new().width(10.0)).bind(save, Intent("save"));
567        let ui = build_ui(
568            &tree,
569            Size::new(100.0, 100.0),
570            &Runtime::default(),
571            &Theme::default(),
572        );
573        assert!(ui.keystroke(save, None).is_empty());
574    }
575
576    /// The sixth focus widget: a stop that answers its own keys while it has them.
577    #[test]
578    fn a_detector_is_a_focus_stop_that_answers_its_own_keys() {
579        use crate::runtime::Runtime;
580        use crate::ui::build_ui;
581        use frus_core::Size;
582
583        let del = KeyStroke::new(ShortcutKey::Delete);
584        let card = FocusableActionDetector::new(crate::Container::new().width(40.0).height(40.0))
585            .on_key(del, Msg::SaveDialog);
586        assert!(Widget::<Msg>::focusable(&card));
587
588        let ui = build_ui(
589            &card,
590            Size::new(100.0, 100.0),
591            &Runtime::default(),
592            &Theme::default(),
593        );
594        let stop = ui.focusable_ids().next().expect("its own stop");
595        assert_eq!(ui.keystroke(del, Some(stop)), vec![Msg::SaveDialog]);
596
597        // Disabled, it is not a stop at all — rather than an inert one Tab still lands on.
598        let off: FocusableActionDetector<Msg> =
599            FocusableActionDetector::new(crate::Container::new()).enabled(false);
600        assert!(!Widget::<Msg>::focusable(&off));
601    }
602
603    /// A later binding for the same stroke replaces the earlier one, so a caller can
604    /// override a table it did not write rather than fighting it.
605    #[test]
606    fn rebinding_a_stroke_replaces_it() {
607        let s: Shortcuts<()> = Shortcuts::new(crate::Container::new())
608            .bind(
609                KeyStroke::new(ShortcutKey::Char('s')).ctrl(),
610                Intent("save"),
611            )
612            .bind(
613                KeyStroke::new(ShortcutKey::Char('s')).ctrl(),
614                Intent("save-as"),
615            );
616        let table = Widget::<()>::shortcut_bindings(&s);
617        assert_eq!(table.len(), 1);
618        assert_eq!(table[0].1, Intent("save-as"));
619    }
620}