Skip to main content

frust_widgets/
shortcuts.rs

1//! Keyboard shortcuts for app code: [`shortcuts`] wraps a child and binds chords to app-state
2//! handlers.
3//!
4//! The wrapper declares every binding on each paint through [`PaintCtx::register_shortcut`]; a
5//! key the focus path leaves unhandled that presses one comes back as [`InputEvent::Shortcut`]
6//! and runs its handler like a button press. Ids are allocated once, when the wrapper is built;
7//! an unmounted wrapper stops declaring at the next paint. The child is held inline, not in a
8//! pod, so the wrapper is transparent to layout, hit testing, focus traversal and slivers.
9//!
10//! Bindings are declared before the child paints, so a nested wrapper's binding for the same
11//! chord in the same tier is the newer declaration and wins (`frust_core::shortcuts`).
12
13use std::rc::Rc;
14
15use frust_core::event::PointerSource;
16use frust_core::{
17    BoxConstraints, BuildCtx, ChangeFlags, ChildPod, Chord, EventCtx, EventResult, InputEvent,
18    LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, ShortcutDecl, ShortcutId, ShortcutScope,
19    SliverWidget, View, Widget,
20};
21use kurbo::Size;
22
23use crate::authoring::{ErasedCallback, erase_callback};
24
25/// One binding as the view holds it: what to declare, and the typed handler.
26struct Binding<State> {
27    chord: Chord,
28    label: String,
29    scope: ShortcutScope,
30    handler: Rc<dyn Fn(&mut State)>,
31}
32
33/// A child plus the chords it binds. See the [module docs](self).
34pub struct ShortcutsView<State: 'static, V> {
35    child: V,
36    bindings: Vec<Binding<State>>,
37}
38
39/// Wrap `child` so it can bind keyboard shortcuts with [`ShortcutsView::on`] (local) and
40/// [`ShortcutsView::global`] (app-global).
41///
42/// ```
43/// use frust_widgets::{shortcuts, text};
44///
45/// struct App(bool);
46///
47/// let _ = shortcuts(text("Editor"))
48///     .global("Mod+K", "Open palette", |app: &mut App| app.0 = true)
49///     .on("Escape", "Close palette", |app: &mut App| app.0 = false);
50/// ```
51pub fn shortcuts<State: 'static, V: View<State>>(child: V) -> ShortcutsView<State, V> {
52    ShortcutsView {
53        child,
54        bindings: Vec::new(),
55    }
56}
57
58impl<State: 'static, V> ShortcutsView<State, V> {
59    /// Bind `chord` ([`Chord::parse`] syntax, e.g. `"Mod+K"`) in the
60    /// [`ShortcutScope::Local`] tier, listed under `label` in shortcut hints.
61    ///
62    /// # Panics
63    ///
64    /// When `chord` does not parse; build a [`Chord`] and call [`Self::bind`] for a spec
65    /// read at runtime.
66    pub fn on(
67        self,
68        chord: &str,
69        label: impl Into<String>,
70        handler: impl Fn(&mut State) + 'static,
71    ) -> Self {
72        self.bind(parse(chord), label, ShortcutScope::Local, handler)
73    }
74
75    /// Like [`Self::on`], in the [`ShortcutScope::AppGlobal`] tier: tried after every local
76    /// binding.
77    ///
78    /// # Panics
79    ///
80    /// When `chord` does not parse.
81    pub fn global(
82        self,
83        chord: &str,
84        label: impl Into<String>,
85        handler: impl Fn(&mut State) + 'static,
86    ) -> Self {
87        self.bind(parse(chord), label, ShortcutScope::AppGlobal, handler)
88    }
89
90    /// Bind an already-built `chord` in `scope`.
91    pub fn bind(
92        mut self,
93        chord: Chord,
94        label: impl Into<String>,
95        scope: ShortcutScope,
96        handler: impl Fn(&mut State) + 'static,
97    ) -> Self {
98        self.bindings.push(Binding {
99            chord,
100            label: label.into(),
101            scope,
102            handler: Rc::new(handler),
103        });
104        self
105    }
106}
107
108fn parse(spec: &str) -> Chord {
109    Chord::parse(spec).unwrap_or_else(|err| panic!("invalid shortcut chord `{spec}`: {err}"))
110}
111
112/// One binding as the widget keeps it: its declaration (stable id) and erased handler.
113struct Entry {
114    decl: ShortcutDecl,
115    handler: ErasedCallback,
116}
117
118/// The retained widget for a [`ShortcutsView`]: the child's widget plus its bindings.
119pub struct ShortcutsWidget<W> {
120    child: W,
121    entries: Vec<Entry>,
122}
123
124impl<State: 'static> Binding<State> {
125    fn entry(&self, id: ShortcutId) -> Entry {
126        Entry {
127            decl: ShortcutDecl {
128                id,
129                chord: self.chord.clone(),
130                label: self.label.clone(),
131                scope: self.scope,
132            },
133            handler: erase_callback(&self.handler),
134        }
135    }
136}
137
138impl<State: 'static, V: View<State>> View<State> for ShortcutsView<State, V> {
139    type Element = ShortcutsWidget<V::Element>;
140
141    fn build(&self, ctx: &mut BuildCtx<'_>) -> Self::Element {
142        let entries = self.bindings.iter();
143        ShortcutsWidget {
144            child: self.child.build(ctx),
145            entries: entries.map(|b| b.entry(ShortcutId::next())).collect(),
146        }
147    }
148
149    /// Bindings keep their ids by position; a changed declaration only needs a repaint.
150    fn rebuild(
151        &self,
152        prev: &Self,
153        element: &mut Self::Element,
154        ctx: &mut BuildCtx<'_>,
155    ) -> ChangeFlags {
156        let mut flags = self.child.rebuild(&prev.child, &mut element.child, ctx);
157        let before: Vec<ShortcutDecl> = element.entries.iter().map(|e| e.decl.clone()).collect();
158        element.entries.truncate(self.bindings.len());
159        for (i, binding) in self.bindings.iter().enumerate() {
160            match element.entries.get_mut(i) {
161                Some(entry) => *entry = binding.entry(entry.decl.id),
162                None => element.entries.push(binding.entry(ShortcutId::next())),
163            }
164        }
165        if element.entries.iter().map(|e| &e.decl).ne(before.iter()) {
166            flags |= ChangeFlags::PAINT;
167        }
168        flags
169    }
170
171    fn teardown(&self, element: &mut Self::Element, ctx: &mut BuildCtx<'_>) {
172        self.child.teardown(&mut element.child, ctx);
173    }
174
175    fn message(&self, element: &mut Self::Element, state: &mut State) {
176        self.child.message(&mut element.child, state);
177    }
178}
179
180impl<W: Widget> Widget for ShortcutsWidget<W> {
181    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
182        self.child.layout(ctx, bc)
183    }
184
185    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
186        for entry in &self.entries {
187            ctx.register_shortcut(entry.decl.clone());
188        }
189        self.child.paint(ctx, scene);
190    }
191
192    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
193        if let InputEvent::Shortcut(id) = event
194            && let Some(entry) = self.entries.iter_mut().find(|e| e.decl.id == *id)
195        {
196            (entry.handler)(ctx);
197            ctx.request_redraw();
198            return EventResult::Handled;
199        }
200        self.child.event(ctx, event)
201    }
202
203    fn semantics(&self, ctx: &mut SemanticsCtx) {
204        self.child.semantics(ctx);
205    }
206
207    /// The child's name: the wrapper adds bindings, never a tree element of its own.
208    fn type_name(&self) -> &'static str {
209        self.child.type_name()
210    }
211
212    fn visit_children(&self, visitor: &mut dyn FnMut(&ChildPod)) {
213        self.child.visit_children(visitor);
214    }
215
216    fn is_focusable(&self) -> bool {
217        self.child.is_focusable()
218    }
219
220    fn visit_focus_children(&self, visitor: &mut dyn FnMut(&ChildPod)) {
221        self.child.visit_focus_children(visitor);
222    }
223
224    fn as_sliver(&mut self) -> Option<&mut dyn SliverWidget> {
225        self.child.as_sliver()
226    }
227}
228
229/// Whether the widget holds focus and `event` is an activation key (see [`KeyEvent::is_activation`]).
230pub(crate) fn activation(ctx: &EventCtx, event: &InputEvent) -> bool {
231    ctx.has_focus() && matches!(event, InputEvent::Key(key) if key.is_activation())
232}
233
234/// Whether a primary press may claim focus: only a mouse contact does. A touch tap activates
235/// the control but leaves focus alone, so a focused text field keeps its session and keyboard.
236pub(crate) fn press_claims_focus(ctx: &EventCtx) -> bool {
237    ctx.pointer_id().source == PointerSource::Mouse
238}
239
240#[cfg(test)]
241mod tests {
242    use std::any::Any;
243    use std::cell::Cell;
244
245    use frust_core::{
246        DiscardScene, FrameTime, Key, KeyEvent, Modifiers, NamedKey, RenderRoot, ShortcutHint,
247    };
248    use frust_text::TextContext;
249
250    use super::*;
251    use crate::{FlexView, IndexedStack, button, column, either, text};
252
253    #[derive(Default)]
254    struct App {
255        opened: u32,
256        closed: u32,
257    }
258
259    fn key(key: Key, modifiers: Modifiers) -> InputEvent {
260        InputEvent::Key(KeyEvent {
261            key,
262            modifiers,
263            repeat: false,
264        })
265    }
266
267    fn ctrl(c: &str) -> InputEvent {
268        let modifiers = Modifiers {
269            ctrl: true,
270            ..Modifiers::default()
271        };
272        key(Key::Character(c.to_string()), modifiers)
273    }
274
275    fn frame<V: View<App>>(root: &mut RenderRoot<App, V>, logic: &mut impl FnMut(&mut App) -> V) {
276        let mut state = App::default();
277        root.rebuild(logic, &mut state);
278        root.layout_with_text(
279            Size::new(300.0, 300.0),
280            &mut TextContext::new() as &mut dyn Any,
281        );
282        root.paint(&mut DiscardScene, FrameTime::ZERO);
283    }
284
285    fn labels(root: &RenderRoot<App, impl View<App>>) -> Vec<String> {
286        root.shortcut_hints(|_| true)
287            .into_iter()
288            .map(|hint| hint.label)
289            .collect()
290    }
291
292    fn bound() -> ShortcutsView<App, crate::TextView> {
293        shortcuts(text("body"))
294            .on("Ctrl+K", "Open", |app: &mut App| app.opened += 1)
295            .global("Ctrl+J", "Close", |app: &mut App| app.closed += 1)
296    }
297
298    #[test]
299    fn a_shortcut_delivery_runs_only_its_own_handler() {
300        let mut counter = 0u64;
301        let view = bound();
302        let mut widget = view.build(&mut BuildCtx::new(&mut counter));
303        let (open, close) = (widget.entries[0].decl.id, widget.entries[1].decl.id);
304        let mut app = App::default();
305        let deliver = |widget: &mut ShortcutsWidget<_>, app: &mut App, id| {
306            let state: &mut dyn Any = app;
307            let mut ctx = EventCtx::new(state, kurbo::Point::ZERO, Size::new(10.0, 10.0));
308            widget.event(&mut ctx, &InputEvent::Shortcut(id))
309        };
310        assert_eq!(deliver(&mut widget, &mut app, open), EventResult::Handled);
311        assert_eq!(deliver(&mut widget, &mut app, close), EventResult::Handled);
312        assert_eq!((app.opened, app.closed), (1, 1));
313        let foreign = ShortcutId::next();
314        assert_eq!(
315            deliver(&mut widget, &mut app, foreign),
316            EventResult::Ignored
317        );
318        assert_eq!((app.opened, app.closed), (1, 1), "another id is not ours");
319    }
320
321    #[test]
322    fn a_pressed_chord_fires_its_handler_and_hints_list_the_bindings() {
323        let mut root: RenderRoot<App, FlexView<App>> = RenderRoot::new();
324        let mut logic = |_: &mut App| column().child(bound());
325        frame(&mut root, &mut logic);
326        let hints = root.shortcut_hints(|_| true);
327        assert_eq!(
328            hints[0],
329            ShortcutHint {
330                chord: Chord::parse("Ctrl+K").unwrap(),
331                label: "Open".to_string(),
332                scope: ShortcutScope::Local,
333            }
334        );
335        assert_eq!(hints[1].scope, ShortcutScope::AppGlobal);
336        assert_eq!(
337            hints[1].chord.display(),
338            Chord::parse("Ctrl+J").unwrap().display()
339        );
340
341        let mut app = App::default();
342        assert!(root.event(&mut app, &ctrl("k")).handled);
343        assert!(root.event(&mut app, &ctrl("j")).handled);
344        assert!(
345            !root.event(&mut app, &ctrl("x")).handled,
346            "an unbound chord"
347        );
348        assert_eq!((app.opened, app.closed), (1, 1));
349    }
350
351    #[test]
352    fn an_unmounted_wrapper_declares_nothing() {
353        let shown = Cell::new(true);
354        let mut root = RenderRoot::new();
355        let mut logic = |_: &mut App| either(shown.get(), bound, || text("gone"));
356        frame(&mut root, &mut logic);
357        assert_eq!(labels(&root), ["Open", "Close"]);
358
359        shown.set(false);
360        frame(&mut root, &mut logic);
361        assert!(labels(&root).is_empty(), "nothing declared after unmount");
362        let mut app = App::default();
363        root.event(&mut app, &ctrl("k"));
364        assert_eq!(app.opened, 0);
365    }
366
367    #[test]
368    fn a_nested_binding_wins_its_tier_and_rebuilds_keep_ids() {
369        let mut root: RenderRoot<App, ShortcutsView<App, ShortcutsView<App, crate::TextView>>> =
370            RenderRoot::new();
371        let inner_label = Cell::new("Inner");
372        let mut logic = |_: &mut App| {
373            let inner =
374                shortcuts(text("x")).on("Ctrl+K", inner_label.get(), |a: &mut App| a.closed += 1);
375            shortcuts(inner).on("Ctrl+K", "Outer", |a: &mut App| a.opened += 1)
376        };
377        frame(&mut root, &mut logic);
378        let mut app = App::default();
379        root.event(&mut app, &ctrl("k"));
380        assert_eq!(
381            (app.opened, app.closed),
382            (0, 1),
383            "the inner declaration is newer"
384        );
385
386        let id = |root: &RenderRoot<App, _>| {
387            let pod = root.tree().pod(root.root_id().unwrap()).unwrap();
388            let widget = (pod.widget() as &dyn Any)
389                .downcast_ref::<ShortcutsWidget<ShortcutsWidget<crate::TextWidget>>>();
390            widget.unwrap().child.entries[0].decl.id
391        };
392        let before = id(&root);
393        inner_label.set("Renamed");
394        frame(&mut root, &mut logic);
395        assert_eq!(id(&root), before, "a rebuild keeps the binding's id");
396        assert_eq!(labels(&root), ["Outer", "Renamed"]);
397    }
398
399    #[test]
400    fn rebuild_reports_paint_only_when_a_declaration_changes() {
401        let mut counter = 0u64;
402        let mut ctx = BuildCtx::new(&mut counter);
403        let prev = bound();
404        let mut widget = prev.build(&mut ctx);
405        let same = bound();
406        assert_eq!(
407            same.rebuild(&prev, &mut widget, &mut ctx),
408            ChangeFlags::NONE
409        );
410        let more = bound().on("Ctrl+L", "Third", |_: &mut App| {});
411        assert!(more.rebuild(&same, &mut widget, &mut ctx).needs_paint());
412        assert_eq!(widget.entries.len(), 3);
413        let fewer = shortcuts(text("body")).on("Ctrl+K", "Open", |_: &mut App| {});
414        assert!(fewer.rebuild(&more, &mut widget, &mut ctx).needs_paint());
415        assert_eq!(widget.entries.len(), 1);
416    }
417
418    #[test]
419    fn focus_queries_forward_to_the_child() {
420        let mut counter = 0u64;
421        let mut ctx = BuildCtx::new(&mut counter);
422        let wrapped = shortcuts(button::<App, _>("Go", |_| {})).on("Ctrl+K", "k", |_| {});
423        assert!(wrapped.build(&mut ctx).is_focusable());
424        assert!(!bound().build(&mut ctx).is_focusable());
425        let stack = IndexedStack(Some(0), (text("a"), text("b")));
426        let widget = shortcuts::<App, _>(stack).build(&mut ctx);
427        let (mut all, mut focus) = (0, 0);
428        widget.visit_children(&mut |_| all += 1);
429        widget.visit_focus_children(&mut |_| focus += 1);
430        assert_eq!(
431            (all, focus),
432            (2, 1),
433            "the stack's narrowing survives the wrapper"
434        );
435    }
436
437    #[test]
438    fn tab_reaches_a_wrapped_control_and_its_keys_pass_through() {
439        let mut root: RenderRoot<App, FlexView<App>> = RenderRoot::new();
440        let mut logic = |_: &mut App| {
441            let go = button::<App, _>("Go", |app: &mut App| app.opened += 1);
442            column().child(shortcuts(go).on("Ctrl+K", "k", |app: &mut App| app.closed += 1))
443        };
444        frame(&mut root, &mut logic);
445        let mut app = App::default();
446        root.event(
447            &mut app,
448            &key(Key::Named(NamedKey::Tab), Modifiers::default()),
449        );
450        assert!(
451            root.is_focus_active(),
452            "the wrapper's pod is the button's stop"
453        );
454        root.event(
455            &mut app,
456            &key(Key::Character(" ".into()), Modifiers::default()),
457        );
458        root.event(&mut app, &ctrl("k"));
459        assert_eq!((app.opened, app.closed), (1, 1));
460    }
461
462    #[test]
463    #[should_panic(expected = "invalid shortcut chord `F25`")]
464    fn an_unparsable_chord_panics_naming_it() {
465        let _ = shortcuts::<App, _>(text("x")).on("F25", "Rename", |_| {});
466    }
467}