Skip to main content

frust_widgets/
radio.rs

1//! The `Radio` interactive widget: a labelled circular indicator
2//! that reports a requested selection but is **not** its own source of truth.
3//!
4//! [`radio`] produces a [`RadioView`] carrying the current `selected` value
5//! and a label; [`RadioView::on_select`] attaches an `on_select(state)`
6//! closure fired on release inside its bounds. Unlike [`crate::checkbox`]'s
7//! `on_toggle`, `on_select` carries no value argument — a radio's release
8//! always means "select me", and the app-level callback is the one that knows
9//! *which* radio it is (e.g. `radio(freq == Daily, "Daily")
10//! .on_select(|s| s.freq = Freq::Daily)`). The widget never flips its own
11//! `selected` field; the app mutates its state in the callback and the next
12//! rebuild feeds the confirmed selection back in (the masonry rule:
13//! application state is the source of truth).
14//!
15//! A radio is a Tab stop; while focused, a bare Space or Enter fires `on_select` exactly as a
16//! click does. A primary mouse press also takes focus, so the next Tab continues from here; a touch tap
17//! never claims it. There
18//! is no arrow-key movement within a group (there is no group widget, below).
19//!
20//! **No group container in v1.** A radio group is app-level: a column of
21//! [`radio`] views sharing one piece of app state that records which is
22//! selected (see the module doc example above).
23//! There is no `RadioGroup` widget here — compare
24//! [`crate::material::button_group`], whose `Role::RadioGroup` semantics
25//! container is a *different*, already-shipped single-select control.
26
27use std::rc::Rc;
28
29use frust_core::accesskit::{Action, Role};
30use frust_core::{
31    BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent, LayoutCtx,
32    PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
33};
34use frust_scene::arc_path;
35use frust_theme::Theme;
36use kurbo::{Point, Size};
37use peniko::{Brush, Color};
38
39use crate::authoring::presses;
40use crate::shortcuts::{activation, press_claims_focus};
41use crate::text;
42
43/// Outer diameter of the radio ring, in logical px (matches
44/// [`crate::checkbox`]'s box side so a mixed column of checkboxes/radios
45/// lines up).
46const DIAMETER: f64 = 20.0;
47/// Gap between the ring and its label, in logical px.
48const GAP: f64 = 8.0;
49/// Stroke width of the outer ring.
50const RING_STROKE: f64 = 2.0;
51/// Radius of the filled inner dot, painted only when selected.
52const DOT_RADIUS: f64 = 5.0;
53/// Unselected ring stroke color (unthemed fallback; a theme resolves this
54/// from `colors.outline`).
55const RING_OFF: Color = Color::from_rgb8(0xE5, 0xE7, 0xEB);
56/// Selected ring stroke + inner dot fill color (unthemed fallback; a theme
57/// resolves this from `colors.primary`). A filled dot inside a same-colored
58/// ring is the correct M3 shape — the ring is stroked over a transparent
59/// interior, not filled, so there is no primary-colored surface underneath
60/// the dot that would call for an `on_primary`-contrast fill the way
61/// [`crate::checkbox`]'s check mark needs one.
62const RING_ON: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
63
64/// The resolved radio ring/dot color for the given `selected` state. Themed:
65/// `outline`/`primary`. Unthemed: [`RING_OFF`]/[`RING_ON`] exactly, so a
66/// pre-theme app renders unchanged.
67fn resolve_color(theme: Option<&Theme>, selected: bool) -> Color {
68    match theme {
69        Some(theme) => {
70            let scheme = theme.scheme();
71            if selected {
72                scheme.primary
73            } else {
74                scheme.outline
75            }
76        }
77        None => {
78            if selected {
79                RING_ON
80            } else {
81                RING_OFF
82            }
83        }
84    }
85}
86
87/// A view-held, typed select callback (erased on build).
88type OnSelect<State> = Rc<dyn Fn(&mut State)>;
89
90/// A declarative radio button. See the [module docs](self).
91pub struct RadioView<State: 'static> {
92    selected: bool,
93    label: String,
94    on_select: Option<OnSelect<State>>,
95}
96
97/// Create a radio reflecting `selected`, labelled `label`. Attach a callback
98/// with [`RadioView::on_select`]; a radio with none is inert (still paints
99/// and captures the press, but never fires) — the same optional-callback
100/// shape a design system's own list-item row uses.
101pub fn radio<State: 'static>(selected: bool, label: impl Into<String>) -> RadioView<State> {
102    RadioView {
103        selected,
104        label: label.into(),
105        on_select: None,
106    }
107}
108
109/// PascalCase alias for [`radio`].
110#[allow(non_snake_case)]
111pub fn Radio<State: 'static>(selected: bool, label: impl Into<String>) -> RadioView<State> {
112    radio(selected, label)
113}
114
115impl<State: 'static> RadioView<State> {
116    /// Fire `on_select(state)` on release inside this radio's bounds. Never
117    /// self-mutates `selected` — see the [module docs](self).
118    pub fn on_select<F: Fn(&mut State) + 'static>(mut self, on_select: F) -> Self {
119        self.on_select = Some(Rc::new(on_select));
120        self
121    }
122}
123
124/// The retained widget for a [`RadioView`].
125pub struct RadioWidget {
126    selected: bool,
127    label: ChildPod,
128    /// The label text, retained for the semantics node's accessible name (a
129    /// radio is a single a11y node reading its name from here rather than
130    /// recursing into the inner `TextWidget`).
131    label_text: String,
132    /// The pressed *visual* state; follows the cursor in/out while captured.
133    pressed: bool,
134    /// Armed by a `Down` (alongside `capture_pointer`), cleared on `Up`/`Cancel`.
135    /// Gates all `Move`/`Up` handling so a hover `Move` never latches `pressed`
136    /// or fires `on_select` without a preceding press.
137    captured: bool,
138    on_select: Option<crate::authoring::ErasedCallback>,
139}
140
141fn inside(pos: Point, size: Size) -> bool {
142    pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
143}
144
145impl<State: 'static> View<State> for RadioView<State> {
146    type Element = RadioWidget;
147
148    fn build(&self, ctx: &mut BuildCtx<'_>) -> RadioWidget {
149        let label_view = any::<State, _>(text(self.label.clone()));
150        RadioWidget {
151            selected: self.selected,
152            label: crate::authoring::build_child(&label_view, ctx),
153            label_text: self.label.clone(),
154            pressed: false,
155            captured: false,
156            on_select: self
157                .on_select
158                .as_ref()
159                .map(crate::authoring::erase_callback),
160        }
161    }
162
163    fn rebuild(
164        &self,
165        prev: &Self,
166        element: &mut RadioWidget,
167        ctx: &mut BuildCtx<'_>,
168    ) -> ChangeFlags {
169        // Closures are not comparable — always reinstall the adapter.
170        element.on_select = self
171            .on_select
172            .as_ref()
173            .map(crate::authoring::erase_callback);
174        let mut flags = ChangeFlags::NONE;
175        if prev.selected != self.selected {
176            // The app is the source of truth: adopt the new value on rebuild.
177            element.selected = self.selected;
178            flags |= ChangeFlags::PAINT;
179        }
180        if prev.label != self.label {
181            element.label_text = self.label.clone();
182            let prev_view = any::<State, _>(text(prev.label.clone()));
183            let next_view = any::<State, _>(text(self.label.clone()));
184            flags |=
185                crate::authoring::rebuild_child(&prev_view, &next_view, &mut element.label, ctx);
186        }
187        flags
188    }
189
190    fn teardown(&self, element: &mut RadioWidget, ctx: &mut BuildCtx<'_>) {
191        let label_view = any::<State, _>(text(self.label.clone()));
192        crate::authoring::teardown_child(&label_view, &mut element.label, ctx);
193    }
194}
195
196impl Widget for RadioWidget {
197    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
198        let label_max = Size::new((bc.max().width - DIAMETER - GAP).max(0.0), bc.max().height);
199        let label_size = self
200            .label
201            .layout_child(ctx, &BoxConstraints::loose(label_max));
202        let height = label_size.height.max(DIAMETER);
203        // Vertically centre the label against the ring.
204        self.label.set_origin(Point::new(
205            DIAMETER + GAP,
206            (height - label_size.height) / 2.0,
207        ));
208        bc.constrain(Size::new(DIAMETER + GAP + label_size.width, height))
209    }
210
211    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
212        let color = resolve_color(Theme::from_paint_ctx(ctx), self.selected);
213        let brush = Brush::Solid(color);
214        let center = Point::new(
215            ctx.origin().x + DIAMETER / 2.0,
216            ctx.origin().y + ctx.size().height / 2.0,
217        );
218        let ring_radius = (DIAMETER - RING_STROKE) / 2.0;
219        let ring_path = arc_path(center, ring_radius, 0.0, std::f64::consts::TAU);
220        scene.stroke_path(Point::ZERO, &ring_path, RING_STROKE, &brush);
221        if self.selected {
222            let dot_path = arc_path(center, DOT_RADIUS, 0.0, std::f64::consts::TAU);
223            scene.fill_path(Point::ZERO, &dot_path, &brush);
224        }
225        self.label.paint_child(ctx, scene);
226    }
227
228    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
229        if activation(ctx, event) {
230            if let Some(on_select) = self.on_select.as_mut() {
231                (on_select)(ctx);
232            }
233            ctx.request_redraw();
234            return EventResult::Handled;
235        }
236        let InputEvent::Pointer(p) = event else {
237            return EventResult::Ignored;
238        };
239        match p.phase {
240            PointerPhase::Down => {
241                if !presses(p) {
242                    return EventResult::Ignored;
243                }
244                if press_claims_focus(ctx) {
245                    ctx.request_focus();
246                }
247                self.pressed = true;
248                self.captured = true;
249                ctx.capture_pointer();
250                ctx.request_redraw();
251                EventResult::Handled
252            }
253            PointerPhase::Move => {
254                // Only a press we armed on `Down` tracks the cursor; a hover
255                // `Move` (no prior press) is not ours.
256                if !self.captured {
257                    return EventResult::Ignored;
258                }
259                self.pressed = inside(p.position, ctx.size());
260                ctx.request_redraw();
261                EventResult::Handled
262            }
263            PointerPhase::Up => {
264                if !self.captured {
265                    return EventResult::Ignored;
266                }
267                if inside(p.position, ctx.size())
268                    && let Some(on_select) = self.on_select.as_mut()
269                {
270                    // Fire unconditionally: a release always requests
271                    // "select me", never self-mutating `selected`.
272                    (on_select)(ctx);
273                }
274                self.pressed = false;
275                self.captured = false;
276                ctx.request_redraw();
277                EventResult::Handled
278            }
279            PointerPhase::Cancel => {
280                if !self.captured {
281                    return EventResult::Ignored;
282                }
283                self.pressed = false;
284                self.captured = false;
285                ctx.request_redraw();
286                EventResult::Handled
287            }
288        }
289    }
290
291    fn semantics(&self, ctx: &mut SemanticsCtx) {
292        // A single RadioButton node carrying its selected state and label; it
293        // fires a Click to select.
294        ctx.push_node(Role::RadioButton, |node| {
295            node.set_label(self.label_text.as_str());
296            node.set_selected(self.selected);
297            node.add_action(Action::Click);
298        });
299    }
300
301    /// Always a Tab stop; a primary mouse press also takes focus (see the module docs).
302    fn is_focusable(&self) -> bool {
303        true
304    }
305
306    crate::authoring::visit_children!(label);
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use std::any::Any;
313
314    #[derive(Default)]
315    struct SelectState {
316        selects: u32,
317    }
318
319    fn widget(selected: bool) -> RadioWidget {
320        let view = radio::<SelectState>(selected, "daily").on_select(|s: &mut SelectState| {
321            s.selects += 1;
322        });
323        let mut counter = 0u64;
324        View::<SelectState>::build(&view, &mut BuildCtx::new(&mut counter))
325    }
326
327    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
328        InputEvent::Pointer(frust_core::PointerEvent {
329            phase,
330            position: Point::new(x, y),
331            button: frust_core::PointerButton::Primary,
332        })
333    }
334
335    fn dispatch(w: &mut RadioWidget, state: &mut SelectState, event: &InputEvent) {
336        let state_any: &mut dyn Any = state;
337        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(120.0, 24.0));
338        w.event(&mut ctx, event);
339    }
340
341    #[test]
342    fn unselected_fires_on_select_and_does_not_self_mutate() {
343        let mut w = widget(false);
344        let mut state = SelectState::default();
345        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
346        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
347        assert_eq!(state.selects, 1);
348        assert!(!w.selected, "radio must not mutate its own selected flag");
349    }
350
351    #[test]
352    fn already_selected_still_fires() {
353        let mut w = widget(true);
354        let mut state = SelectState::default();
355        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
356        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
357        assert_eq!(state.selects, 1);
358        assert!(w.selected, "still selected until the app rebuilds it");
359    }
360
361    #[test]
362    fn up_outside_does_not_fire() {
363        let mut w = widget(false);
364        let mut state = SelectState::default();
365        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
366        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 12.0));
367        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 12.0));
368        assert_eq!(state.selects, 0);
369    }
370
371    #[test]
372    fn hover_move_without_down_is_ignored_noop() {
373        let mut w = widget(false);
374        let mut state = SelectState::default();
375        let state_any: &mut dyn Any = &mut state;
376        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(120.0, 24.0));
377        let result = w.event(&mut ctx, &ev(PointerPhase::Move, 5.0, 12.0));
378        assert!(matches!(result, EventResult::Ignored));
379        assert!(!w.pressed, "hover must not press");
380        assert!(!ctx.needs_redraw(), "hover must not request a redraw");
381        assert_eq!(state.selects, 0);
382    }
383
384    #[test]
385    fn up_without_down_does_not_fire() {
386        let mut w = widget(false);
387        let mut state = SelectState::default();
388        let state_any: &mut dyn Any = &mut state;
389        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(120.0, 24.0));
390        let result = w.event(&mut ctx, &ev(PointerPhase::Up, 5.0, 12.0));
391        assert!(matches!(result, EventResult::Ignored));
392        assert_eq!(state.selects, 0);
393    }
394
395    #[test]
396    fn cancel_clears_armed_state() {
397        let mut w = widget(false);
398        let mut state = SelectState::default();
399        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
400        assert!(w.captured);
401        dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 5.0, 12.0));
402        assert!(!w.captured, "Cancel disarms the press");
403        // A follow-up hover Up must not fire.
404        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
405        assert_eq!(state.selects, 0);
406    }
407
408    /// Records the ring stroke and dot fill colors.
409    #[derive(Default)]
410    struct RingRecorder {
411        strokes: Vec<Color>,
412        fills: Vec<Color>,
413    }
414
415    impl PaintScene for RingRecorder {
416        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
417        fn draw_text(&mut self, _o: Point, _t: &str) {}
418        fn stroke_path(&mut self, _o: Point, _path: &kurbo::BezPath, _width: f64, brush: &Brush) {
419            if let Brush::Solid(c) = brush {
420                self.strokes.push(*c);
421            }
422        }
423        fn fill_path(&mut self, _o: Point, _path: &kurbo::BezPath, brush: &Brush) {
424            if let Brush::Solid(c) = brush {
425                self.fills.push(*c);
426            }
427        }
428    }
429
430    fn paint_rec(w: &mut RadioWidget, theme: Option<&Theme>) -> RingRecorder {
431        let mut rec = RingRecorder::default();
432        let mut ctx = match theme {
433            Some(t) => PaintCtx::new(Point::ZERO, Size::new(120.0, 24.0)).with_theme(t),
434            None => PaintCtx::new(Point::ZERO, Size::new(120.0, 24.0)),
435        };
436        w.paint(&mut ctx, &mut rec);
437        rec
438    }
439
440    #[test]
441    fn unthemed_paint_uses_fallback_constants() {
442        let mut off = widget(false);
443        let rec = paint_rec(&mut off, None);
444        assert_eq!(rec.strokes, vec![RING_OFF]);
445        assert!(rec.fills.is_empty(), "unselected radio paints no dot");
446        let mut on = widget(true);
447        let rec = paint_rec(&mut on, None);
448        assert_eq!(rec.strokes, vec![RING_ON]);
449        assert_eq!(rec.fills, vec![RING_ON]);
450    }
451
452    #[test]
453    fn themed_paint_resolves_roles() {
454        let theme = Theme::neutral();
455        let scheme = theme.scheme();
456        let mut off = widget(false);
457        assert_eq!(
458            paint_rec(&mut off, Some(&theme)).strokes,
459            vec![scheme.outline],
460            "unselected ring uses the outline role"
461        );
462        let mut on = widget(true);
463        let rec = paint_rec(&mut on, Some(&theme));
464        assert_eq!(
465            rec.strokes,
466            vec![scheme.primary],
467            "selected ring uses primary"
468        );
469        assert_eq!(rec.fills, vec![scheme.primary], "dot uses primary");
470    }
471
472    #[test]
473    fn rebuild_adopts_new_selected_value() {
474        let mut counter = 0u64;
475        let prev = radio::<SelectState>(false, "daily");
476        let mut w = View::<SelectState>::build(&prev, &mut BuildCtx::new(&mut counter));
477        assert!(!w.selected);
478        let next = radio::<SelectState>(true, "daily");
479        let flags =
480            View::<SelectState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
481        assert!(w.selected);
482        assert!(flags.needs_paint());
483    }
484
485    #[test]
486    fn semantics_reports_role_label_selected_and_bounds() {
487        fn logic(_s: &mut ()) -> RadioView<()> {
488            radio::<()>(true, "daily")
489        }
490        let mut root: frust_core::RenderRoot<(), RadioView<()>> = frust_core::RenderRoot::new();
491        let mut state = ();
492        root.rebuild(&mut logic, &mut state);
493        let mut tcx = frust_text::TextContext::new();
494        root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
495        let update = root.semantics();
496        let (_, node) = update
497            .nodes
498            .iter()
499            .find(|(_, n)| n.role() == Role::RadioButton)
500            .expect("radio contributes a Role::RadioButton node");
501        assert_eq!(node.label(), Some("daily"));
502        assert_eq!(node.is_selected(), Some(true));
503        let bounds = node.bounds().expect("radio node has bounds");
504        assert_eq!((bounds.x0, bounds.y0), (0.0, 0.0));
505    }
506
507    mod keyboard {
508        use super::*;
509        use frust_core::{
510            DiscardScene, FrameTime, Key, KeyEvent, Modifiers, NamedKey, PointerButton,
511            PointerEvent, RenderRoot,
512        };
513
514        fn key(key: Key, repeat: bool) -> InputEvent {
515            InputEvent::Key(KeyEvent {
516                key,
517                modifiers: Modifiers::default(),
518                repeat,
519            })
520        }
521
522        fn pointer(phase: PointerPhase) -> InputEvent {
523            InputEvent::Pointer(PointerEvent {
524                phase,
525                position: Point::new(5.0, 10.0),
526                button: PointerButton::Primary,
527            })
528        }
529
530        #[test]
531        fn a_press_keeps_focus_so_the_next_tab_moves_to_the_next_stop() {
532            let mut logic = |_: &mut SelectState| {
533                crate::column()
534                    .child(radio(false, "a").on_select(|s: &mut SelectState| s.selects += 1))
535                    .child(radio(false, "b").on_select(|s: &mut SelectState| s.selects += 10))
536            };
537            let mut root: RenderRoot<SelectState, crate::FlexView<SelectState>> = RenderRoot::new();
538            let mut state = SelectState::default();
539            let mut tcx = frust_text::TextContext::new();
540            root.rebuild(&mut logic, &mut state);
541            root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
542            root.paint(&mut DiscardScene, FrameTime::ZERO);
543            root.event(&mut state, &pointer(PointerPhase::Down));
544            root.event(&mut state, &pointer(PointerPhase::Up));
545            root.event(&mut state, &key(Key::Named(NamedKey::Tab), false));
546            root.event(&mut state, &key(Key::Character(" ".into()), false));
547            assert_eq!(state.selects, 11, "Tab went to the second radio");
548        }
549
550        #[test]
551        fn a_touch_tap_selects_without_claiming_focus() {
552            let mut logic = |_: &mut SelectState| {
553                crate::column()
554                    .child(radio(false, "a").on_select(|s: &mut SelectState| s.selects += 1))
555            };
556            let mut root: RenderRoot<SelectState, crate::FlexView<SelectState>> = RenderRoot::new();
557            let mut state = SelectState::default();
558            let mut tcx = frust_text::TextContext::new();
559            root.rebuild(&mut logic, &mut state);
560            root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
561            root.paint(&mut DiscardScene, FrameTime::ZERO);
562            for phase in [PointerPhase::Down, PointerPhase::Up] {
563                let InputEvent::Pointer(event) = pointer(phase) else {
564                    unreachable!()
565                };
566                let contact = InputEvent::PointerContact {
567                    pointer_id: frust_core::event::PointerId::touch(0),
568                    event,
569                };
570                root.event(&mut state, &contact);
571            }
572            assert_eq!(state.selects, 1);
573            assert!(!root.is_focus_active(), "a tap leaves focus alone");
574        }
575
576        #[test]
577        fn tab_focuses_the_radio_then_space_or_enter_selects_it() {
578            let mut logic = |_: &mut SelectState| {
579                let daily = radio(false, "daily").on_select(|s: &mut SelectState| s.selects += 1);
580                crate::column().child(daily)
581            };
582            let mut root: RenderRoot<SelectState, crate::FlexView<SelectState>> = RenderRoot::new();
583            let mut state = SelectState::default();
584            let mut tcx = frust_text::TextContext::new();
585            root.rebuild(&mut logic, &mut state);
586            root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
587            root.paint(&mut DiscardScene, FrameTime::ZERO);
588            root.event(&mut state, &key(Key::Named(NamedKey::Enter), false));
589            assert_eq!(state.selects, 0, "an unfocused radio takes no keys");
590            root.event(&mut state, &pointer(PointerPhase::Down));
591            root.event(&mut state, &pointer(PointerPhase::Up));
592            assert_eq!(state.selects, 1);
593            assert!(root.is_focus_active(), "a click claims focus");
594
595            root.event(&mut state, &key(Key::Named(NamedKey::Tab), false));
596            assert!(root.is_focus_active());
597            assert!(
598                root.event(&mut state, &key(Key::Character(" ".into()), false))
599                    .handled
600            );
601            root.event(&mut state, &key(Key::Named(NamedKey::Enter), false));
602            assert_eq!(state.selects, 3);
603            root.event(&mut state, &key(Key::Named(NamedKey::Enter), true));
604            assert_eq!(state.selects, 3, "an auto-repeat does not select again");
605            assert!(widget(true).is_focusable());
606        }
607    }
608}