Skip to main content

frus_widgets/
focus.rs

1//! The **focus** wrappers: which parts of a tree the keyboard can reach, and in what
2//! order.
3//!
4//! Focus already existed here as a property of a widget — a text field is focusable, a
5//! label is not — and Tab already walked the tree. What was missing is the declarative
6//! surface the reference puts over it: the five wrappers that let a caller say *this
7//! subtree is out of reach*, *skip this one with Tab but let a click land on it*, *these
8//! come in this order*, and *resolve that order among these and nowhere else*.
9//!
10//! ```ignore
11//! FocusTraversalGroup::new(
12//!     column![
13//!         FocusTraversalOrder::new(2.0, name_field),   // second, whatever the tree says
14//!         FocusTraversalOrder::new(1.0, email_field),  // first
15//!         ExcludeFocusTraversal::new(help_button),     // clickable, not in the order
16//!     ],
17//! )
18//! ```
19//!
20//! All five are **single-child boxes** that take their child's sizing and add nothing to
21//! the picture. The flags they set are *subtree-scoped*: the walk pushes them on the way
22//! in and pops them on the way out, so a sibling never inherits one.
23//!
24//! ## The two questions Tab and a click ask separately
25//!
26//! [`ExcludeFocus`] removes a subtree from focus entirely — nothing in it registers a stop
27//! at all. [`ExcludeFocusTraversal`] keeps the stops and takes them out of *Tab's order*:
28//! a click still lands, the keyboard passes by. They are separate because the reasons are
29//! separate — a panel behind a sheet is unreachable, a toolbar button is reachable but
30//! does not belong in a form's keyboard order.
31
32use frus_core::{Rect, Scene};
33use frus_layout::Style;
34
35use crate::interaction::Status;
36use crate::theme::Theme;
37use crate::widget::{sizing_of, Widget};
38
39/// The shared body of all five: one child, its box, and a set of focus flags.
40struct Wrapper<Msg> {
41    child: Vec<Box<dyn Widget<Msg>>>,
42    focusable: bool,
43    descendants_focusable: bool,
44    skip_traversal: bool,
45    order: Option<f32>,
46    group: bool,
47    /// Told when the focus arrives in or leaves the subtree; see [`Focus::on_focus_change`].
48    on_focus_change: Option<Box<dyn Fn(bool) -> Msg>>,
49}
50
51impl<Msg> Wrapper<Msg> {
52    fn new(child: Box<dyn Widget<Msg>>) -> Self {
53        Self {
54            child: vec![child],
55            focusable: false,
56            descendants_focusable: true,
57            skip_traversal: false,
58            order: None,
59            group: false,
60            on_focus_change: None,
61        }
62    }
63}
64
65impl<Msg: Clone> Widget<Msg> for Wrapper<Msg> {
66    fn style(&self) -> Style {
67        sizing_of(self.child[0].style())
68    }
69
70    fn style_themed(&self, theme: &Theme) -> Style {
71        sizing_of(self.child[0].style_themed(theme))
72    }
73
74    fn build_themed(&self, theme: &Theme) {
75        self.child[0].build_themed(theme);
76    }
77
78    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
79        &self.child
80    }
81
82    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
83
84    fn on_click(&self) -> Option<Msg> {
85        None
86    }
87
88    fn focusable(&self) -> bool {
89        self.focusable
90    }
91
92    fn descendants_focusable(&self) -> bool {
93        self.descendants_focusable
94    }
95
96    fn focus_skip_traversal(&self) -> bool {
97        self.skip_traversal
98    }
99
100    fn focus_order(&self) -> Option<f32> {
101        self.order
102    }
103
104    fn focus_group(&self) -> bool {
105        self.group
106    }
107
108    fn on_focus_change(&self, focused: bool) -> Option<Msg> {
109        self.on_focus_change.as_ref().map(|make| make(focused))
110    }
111}
112
113/// Makes its child a **focus stop**: somewhere Tab can land and a click can focus, even
114/// when the child itself has no opinion about focus.
115pub struct Focus<Msg = crate::callback::Callback>(Wrapper<Msg>);
116
117impl<Msg> Focus<Msg> {
118    /// A focus stop around `child`.
119    pub fn new(child: impl Widget<Msg> + 'static) -> Self {
120        let mut w = Wrapper::new(Box::new(child));
121        w.focusable = true;
122        Self(w)
123    }
124
125    /// Whether it can take focus at all — the reference's `canRequestFocus`. `false`
126    /// leaves the subtree in place and simply stops being a stop.
127    pub fn can_request_focus(mut self, can: bool) -> Self {
128        self.0.focusable = can;
129        self
130    }
131
132    /// Focusable by a click, passed over by Tab.
133    pub fn skip_traversal(mut self) -> Self {
134        self.0.skip_traversal = true;
135        self
136    }
137
138    /// A message for the focus **arriving in or leaving** this subtree — the reference's
139    /// `onFocusChange` (milestone 660): `true` when it, or anything inside it, now has the
140    /// focus and did not, `false` the other way. A move between two widgets inside it says
141    /// nothing.
142    pub fn on_focus_change<R: crate::callback::IntoMsg<Msg>>(
143        mut self,
144        on_change: impl Fn(bool) -> R + 'static,
145    ) -> Self {
146        self.0.on_focus_change = Some(Box::new(crate::callback::handler1(on_change)));
147        self
148    }
149}
150
151/// **Nothing inside can take focus.** The subtree is still drawn and still measured; it
152/// is simply not somewhere the keyboard can go.
153pub struct ExcludeFocus<Msg = crate::callback::Callback>(Wrapper<Msg>);
154
155impl<Msg> ExcludeFocus<Msg> {
156    /// Puts `child` out of the keyboard's reach.
157    pub fn new(child: impl Widget<Msg> + 'static) -> Self {
158        let mut w = Wrapper::new(Box::new(child));
159        w.descendants_focusable = false;
160        Self(w)
161    }
162}
163
164/// **Skipped by Tab, reachable by a click** — a different question from
165/// [`ExcludeFocus`], which takes the subtree out of the keyboard's reach altogether. A
166/// panel behind a sheet is unreachable; a toolbar button is reachable and simply does not
167/// belong in a form's keyboard order.
168pub struct ExcludeFocusTraversal<Msg = crate::callback::Callback>(Wrapper<Msg>);
169
170impl<Msg> ExcludeFocusTraversal<Msg> {
171    /// Takes `child`'s focus stops out of Tab's order.
172    pub fn new(child: impl Widget<Msg> + 'static) -> Self {
173        let mut w = Wrapper::new(Box::new(child));
174        w.skip_traversal = true;
175        Self(w)
176    }
177}
178
179/// An explicit **traversal position** for a subtree, smallest first.
180///
181/// Ordered stops come before unordered ones, and everything without an order keeps tree
182/// order — so this is a local statement rather than a rearrangement of the frame.
183pub struct FocusTraversalOrder<Msg = crate::callback::Callback>(Wrapper<Msg>);
184
185impl<Msg> FocusTraversalOrder<Msg> {
186    /// Gives `child`'s focus stops the position `order`.
187    pub fn new(order: f32, child: impl Widget<Msg> + 'static) -> Self {
188        let mut w = Wrapper::new(Box::new(child));
189        w.order = Some(order);
190        Self(w)
191    }
192}
193
194/// A **traversal group**: an order set inside it is resolved among its own members and
195/// nowhere else, so a reordered dialog does not reshuffle the page behind it.
196pub struct FocusTraversalGroup<Msg = crate::callback::Callback>(Wrapper<Msg>);
197
198impl<Msg> FocusTraversalGroup<Msg> {
199    /// Scopes `child`'s traversal order to `child`.
200    pub fn new(child: impl Widget<Msg> + 'static) -> Self {
201        let mut w = Wrapper::new(Box::new(child));
202        w.group = true;
203        Self(w)
204    }
205}
206
207/// Each of the five is the same box with different flags; the delegation is mechanical
208/// and identical, so it is written once.
209macro_rules! delegate {
210    ($($ty:ident),* $(,)?) => {$(
211        impl<Msg: Clone> Widget<Msg> for $ty<Msg> {
212            fn style(&self) -> Style {
213                Widget::<Msg>::style(&self.0)
214            }
215            fn style_themed(&self, theme: &Theme) -> Style {
216                Widget::<Msg>::style_themed(&self.0, theme)
217            }
218            fn build_themed(&self, theme: &Theme) {
219                Widget::<Msg>::build_themed(&self.0, theme)
220            }
221            fn children(&self) -> &[Box<dyn Widget<Msg>>] {
222                Widget::<Msg>::children(&self.0)
223            }
224            fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
225                Widget::<Msg>::paint(&self.0, bounds, status, theme, scene)
226            }
227            fn on_click(&self) -> Option<Msg> {
228                Widget::<Msg>::on_click(&self.0)
229            }
230            fn focusable(&self) -> bool {
231                Widget::<Msg>::focusable(&self.0)
232            }
233            fn descendants_focusable(&self) -> bool {
234                Widget::<Msg>::descendants_focusable(&self.0)
235            }
236            fn focus_skip_traversal(&self) -> bool {
237                Widget::<Msg>::focus_skip_traversal(&self.0)
238            }
239            fn focus_order(&self) -> Option<f32> {
240                Widget::<Msg>::focus_order(&self.0)
241            }
242            fn focus_group(&self) -> bool {
243                Widget::<Msg>::focus_group(&self.0)
244            }
245            fn on_focus_change(&self, focused: bool) -> Option<Msg> {
246                Widget::<Msg>::on_focus_change(&self.0, focused)
247            }
248        }
249    )*};
250}
251
252delegate!(
253    Focus,
254    ExcludeFocus,
255    ExcludeFocusTraversal,
256    FocusTraversalOrder,
257    FocusTraversalGroup,
258);
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::runtime::Runtime;
264    use crate::ui::build_ui;
265    use crate::{Container, TextField};
266    use frus_core::Size;
267
268    #[derive(Clone, Debug, PartialEq)]
269    enum Msg {
270        Edited(String),
271    }
272
273    /// Three fields in a column, so there is a tree order to disturb.
274    fn field(label: &str) -> TextField<Msg> {
275        TextField::new(label.to_string())
276            .label(label.to_string())
277            .on_input(Msg::Edited)
278    }
279
280    fn order_of(root: &dyn Widget<Msg>) -> Vec<usize> {
281        let ui = build_ui(
282            root,
283            Size::new(400.0, 400.0),
284            &Runtime::default(),
285            &Theme::default(),
286        );
287        let ids: Vec<_> = ui.focusable_ids().collect();
288        ui.traversal_order()
289            .iter()
290            .map(|id| ids.iter().position(|x| x == id).expect("a known stop"))
291            .collect()
292    }
293
294    /// Untouched, Tab follows the tree.
295    #[test]
296    fn tab_follows_the_tree_by_default() {
297        let root = crate::column![field("a"), field("b"), field("c")];
298        assert_eq!(order_of(&root), vec![0, 1, 2]);
299    }
300
301    /// An order moves a subtree without moving anything else.
302    #[test]
303    fn an_order_moves_only_what_it_names() {
304        let root = crate::column![
305            FocusTraversalOrder::new(2.0, field("a")),
306            FocusTraversalOrder::new(1.0, field("b")),
307            field("c"),
308        ];
309        // b then a, both ordered; c has no order, so it comes after them.
310        assert_eq!(order_of(&root), vec![1, 0, 2]);
311    }
312
313    /// Skipped by Tab, and still a focus stop for a click.
314    #[test]
315    fn skipping_traversal_leaves_the_stop_where_it_was() {
316        let root = crate::column![
317            field("a"),
318            ExcludeFocusTraversal::new(field("b")),
319            field("c"),
320        ];
321        assert_eq!(order_of(&root), vec![0, 2], "Tab passes b by");
322
323        let ui = build_ui(
324            &root,
325            Size::new(400.0, 400.0),
326            &Runtime::default(),
327            &Theme::default(),
328        );
329        assert_eq!(
330            ui.focusable_ids().count(),
331            3,
332            "but b is still somewhere a click can land"
333        );
334    }
335
336    /// Excluded outright: not a stop at all.
337    #[test]
338    fn an_excluded_subtree_registers_nothing() {
339        let root = crate::column![field("a"), ExcludeFocus::new(field("b")), field("c")];
340        let ui = build_ui(
341            &root,
342            Size::new(400.0, 400.0),
343            &Runtime::default(),
344            &Theme::default(),
345        );
346        assert_eq!(ui.focusable_ids().count(), 2);
347        assert_eq!(order_of(&root), vec![0, 1]);
348    }
349
350    /// The flags are **subtree-scoped**: what follows an excluded subtree is not itself
351    /// excluded. This is the bug the scope exists to prevent, and the one a flag set on
352    /// the way in and never cleared would produce.
353    #[test]
354    fn the_flags_do_not_leak_to_what_comes_after() {
355        let root = crate::column![
356            ExcludeFocus::new(crate::column![field("a"), field("b")]),
357            field("c"),
358            field("d"),
359        ];
360        let ui = build_ui(
361            &root,
362            Size::new(400.0, 400.0),
363            &Runtime::default(),
364            &Theme::default(),
365        );
366        assert_eq!(ui.focusable_ids().count(), 2, "only c and d");
367    }
368
369    /// A group keeps an order to itself: the dialog reorders, the page behind it does not.
370    #[test]
371    fn a_group_resolves_its_order_among_its_own_members() {
372        let root = crate::column![
373            field("page-1"),
374            FocusTraversalGroup::new(crate::column![
375                FocusTraversalOrder::new(2.0, field("dialog-1")),
376                FocusTraversalOrder::new(1.0, field("dialog-2")),
377            ]),
378            field("page-2"),
379        ];
380        // The page keeps tree order around a dialog that swapped its two fields.
381        assert_eq!(order_of(&root), vec![0, 2, 1, 3]);
382    }
383
384    /// A `Focus` makes something focusable that had no opinion about it.
385    #[test]
386    fn it_makes_an_ordinary_box_a_focus_stop() {
387        let plain = Container::<Msg>::new().width(40.0).height(40.0);
388        assert!(!Widget::<Msg>::focusable(&plain));
389        let wrapped = Focus::new(Container::<Msg>::new().width(40.0).height(40.0));
390        assert!(Widget::<Msg>::focusable(&wrapped));
391        assert!(!Widget::<Msg>::focusable(
392            &Focus::new(Container::<Msg>::new()).can_request_focus(false)
393        ));
394    }
395}
396
397/// The focus arriving in and leaving a subtree (milestone 660).
398#[cfg(test)]
399mod focus_change_tests {
400    use super::*;
401    use crate::WidgetId;
402    use crate::{build_ui, focus_changes, Flex, Runtime, Size, TextField};
403
404    #[derive(Clone, Debug, PartialEq)]
405    enum Msg {
406        Group(bool),
407        Typed,
408    }
409
410    /// A group of two fields under a `Focus`, and a third field beside it.
411    fn page() -> Flex<Msg> {
412        Flex::column()
413            .width(300.0)
414            .height(300.0)
415            .child(
416                Focus::new(
417                    Flex::column()
418                        .child(TextField::new("a").on_input(|_| Msg::Typed))
419                        .child(TextField::new("b").on_input(|_| Msg::Typed)),
420                )
421                .skip_traversal()
422                .on_focus_change(Msg::Group),
423            )
424            .child(TextField::new("c").on_input(|_| Msg::Typed))
425    }
426
427    /// The three fields' ids, in order: `a`, `b`, `c`.
428    fn fields(page: &Flex<Msg>) -> Vec<WidgetId> {
429        let ui = build_ui(
430            page,
431            Size::new(300.0, 300.0),
432            &Runtime::default(),
433            &crate::Theme::default(),
434        );
435        let ids: Vec<WidgetId> = ui.focusable_ids().collect();
436        // The group itself is a stop too, ahead of its fields.
437        assert_eq!(ids.len(), 4, "{ids:?}");
438        ids[1..].to_vec()
439    }
440
441    /// **The group hears the focus arrive and leave**, and nothing for a move between its
442    /// own fields.
443    #[test]
444    fn a_group_hears_the_focus_arrive_and_leave() {
445        let page = page();
446        let ids = fields(&page);
447        let (a, b, c) = (Some(ids[0]), Some(ids[1]), Some(ids[2]));
448        assert_eq!(
449            focus_changes(&page, None, a),
450            vec![Msg::Group(true)],
451            "arrived"
452        );
453        assert_eq!(focus_changes(&page, a, b), vec![], "moved inside");
454        assert_eq!(focus_changes(&page, b, c), vec![Msg::Group(false)], "left");
455        assert_eq!(focus_changes(&page, c, a), vec![Msg::Group(true)]);
456        assert_eq!(
457            focus_changes(&page, a, None),
458            vec![Msg::Group(false)],
459            "cleared"
460        );
461        assert_eq!(focus_changes(&page, a, a), vec![], "no move");
462        let gone = Some(WidgetId::ROOT.child(9));
463        assert_eq!(
464            focus_changes(&page, gone, a),
465            vec![Msg::Group(true)],
466            "a widget no longer there is not heard from"
467        );
468    }
469
470    /// **The one that lost the focus hears first**, deepest first.
471    #[test]
472    fn the_one_that_lost_it_hears_first() {
473        let nested = Flex::column()
474            .width(300.0)
475            .height(300.0)
476            .child(
477                Focus::new(TextField::new("a").on_input(|_| Msg::Typed))
478                    .skip_traversal()
479                    .on_focus_change(|f| if f { Msg::Group(true) } else { Msg::Typed }),
480            )
481            .child(
482                Focus::new(TextField::new("b").on_input(|_| Msg::Typed))
483                    .skip_traversal()
484                    .on_focus_change(Msg::Group),
485            );
486        let ui = build_ui(
487            &nested,
488            Size::new(300.0, 300.0),
489            &Runtime::default(),
490            &crate::Theme::default(),
491        );
492        let ids: Vec<WidgetId> = ui.focusable_ids().collect();
493        // [group a, a, group b, b]
494        let (a, b) = (Some(ids[1]), Some(ids[3]));
495        assert_eq!(
496            focus_changes(&nested, a, b),
497            vec![Msg::Typed, Msg::Group(true)],
498            "the first group's `false`, then the second's `true`"
499        );
500    }
501
502    /// **Nested groups the focus leaves hear it deepest first**, as do those it enters.
503    #[test]
504    fn nested_groups_hear_it_deepest_first() {
505        let nested = Flex::column()
506            .width(300.0)
507            .height(300.0)
508            .child(
509                Focus::new(
510                    Focus::new(TextField::new("a").on_input(|_| Msg::Typed))
511                        .skip_traversal()
512                        .on_focus_change(|f| Msg::Group(!f)),
513                )
514                .skip_traversal()
515                .on_focus_change(Msg::Group),
516            )
517            .child(TextField::new("b").on_input(|_| Msg::Typed));
518        let ui = build_ui(
519            &nested,
520            Size::new(300.0, 300.0),
521            &Runtime::default(),
522            &crate::Theme::default(),
523        );
524        let ids: Vec<WidgetId> = ui.focusable_ids().collect();
525        // [outer, inner, a, b]
526        let (a, b) = (Some(ids[2]), Some(ids[3]));
527        assert_eq!(
528            focus_changes(&nested, a, b),
529            vec![Msg::Group(true), Msg::Group(false)],
530            "the inner group (which says the opposite) first, then the outer"
531        );
532        assert_eq!(
533            focus_changes(&nested, b, a),
534            vec![Msg::Group(false), Msg::Group(true)],
535            "entering: the inner first, then the outer"
536        );
537    }
538}