Skip to main content

frust_widgets/
checkbox.rs

1//! The `Checkbox` interactive widget: a labelled box that reports a
2//! requested toggle but is **not** its own source of truth.
3//!
4//! [`checkbox`] produces a [`CheckboxView`] carrying the current `checked` value,
5//! a label, and an `on_toggle` closure. On release inside its bounds it fires
6//! `on_toggle(state, !checked)` — it does **not** flip its own `checked` field.
7//! The app mutates its state in the callback and the next rebuild feeds the new
8//! value back in (the masonry rule: application state is the source of truth).
9//!
10//! A checkbox is a Tab stop; while focused, a bare Space or Enter requests the toggle exactly as
11//! a click does. A primary mouse press also takes focus, so the next Tab continues from here; a touch tap never
12//! claims it.
13
14use std::rc::Rc;
15
16use frust_core::accesskit::{Action, Role, Toggled};
17use frust_core::{
18    BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent, LayoutCtx,
19    PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
20};
21use frust_theme::Theme;
22use kurbo::{Point, Size};
23use peniko::Color;
24
25use crate::authoring::presses;
26use crate::shortcuts::{activation, press_claims_focus};
27use crate::text;
28
29/// Side length of the check box, in logical px.
30const BOX: f64 = 20.0;
31/// Gap between the box and its label, in logical px.
32const GAP: f64 = 8.0;
33/// Corner radius of the box.
34const RADIUS: f64 = 4.0;
35/// Stroke width of the check mark.
36const CHECK_WIDTH: f64 = 2.5;
37/// Unchecked box fill (unthemed fallback; a theme resolves this from
38/// `colors.outline`).
39const FILL_OFF: Color = Color::from_rgb8(0xE5, 0xE7, 0xEB);
40/// Checked box fill (unthemed fallback; a theme resolves this from
41/// `colors.primary`).
42const FILL_ON: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
43/// Check-mark stroke color (unthemed fallback; a theme resolves this from
44/// `colors.on_primary`).
45const CHECK: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
46
47/// The resolved checkbox paint colors: `(off_fill, on_fill, check)`. Themed:
48/// `outline`/`primary`/`on_primary`. Unthemed: the [`FILL_OFF`]/[`FILL_ON`]/
49/// [`CHECK`] constants exactly, so a pre-theme app renders unchanged.
50fn resolve_colors(theme: Option<&Theme>) -> (Color, Color, Color) {
51    match theme {
52        Some(theme) => {
53            let scheme = theme.scheme();
54            (scheme.outline, scheme.primary, scheme.on_primary)
55        }
56        None => (FILL_OFF, FILL_ON, CHECK),
57    }
58}
59
60/// A view-held, typed toggle callback (erased on build).
61type OnToggle<State> = Rc<dyn Fn(&mut State, bool)>;
62
63/// A declarative checkbox. See the [module docs](self).
64pub struct CheckboxView<State: 'static> {
65    checked: bool,
66    label: String,
67    on_toggle: OnToggle<State>,
68}
69
70/// Create a checkbox reflecting `checked`, labelled `label`, that fires
71/// `on_toggle(state, !checked)` on release inside its bounds.
72pub fn checkbox<State: 'static, F: Fn(&mut State, bool) + 'static>(
73    checked: bool,
74    label: impl Into<String>,
75    on_toggle: F,
76) -> CheckboxView<State> {
77    CheckboxView {
78        checked,
79        label: label.into(),
80        on_toggle: Rc::new(on_toggle),
81    }
82}
83
84/// PascalCase alias for [`checkbox`].
85#[allow(non_snake_case)]
86pub fn Checkbox<State: 'static, F: Fn(&mut State, bool) + 'static>(
87    checked: bool,
88    label: impl Into<String>,
89    on_toggle: F,
90) -> CheckboxView<State> {
91    checkbox(checked, label, on_toggle)
92}
93
94/// The retained widget for a [`CheckboxView`].
95pub struct CheckboxWidget {
96    checked: bool,
97    label: ChildPod,
98    /// The label text, retained for the semantics node's accessible name (a
99    /// checkbox is a single a11y node reading its name from here rather than
100    /// recursing into the inner `TextWidget`).
101    label_text: String,
102    /// The pressed *visual* state; follows the cursor in/out while captured.
103    pressed: bool,
104    /// Armed by a `Down` (alongside `capture_pointer`), cleared on `Up`/`Cancel`.
105    /// Gates all `Move`/`Up` handling so a hover `Move` never latches `pressed`
106    /// or fires `on_toggle` without a preceding press.
107    captured: bool,
108    on_toggle: crate::authoring::ErasedArgCallback<bool>,
109}
110
111fn inside(pos: Point, size: Size) -> bool {
112    pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
113}
114
115impl<State: 'static> View<State> for CheckboxView<State> {
116    type Element = CheckboxWidget;
117
118    fn build(&self, ctx: &mut BuildCtx<'_>) -> CheckboxWidget {
119        let label_view = any::<State, _>(text(self.label.clone()));
120        CheckboxWidget {
121            checked: self.checked,
122            label: crate::authoring::build_child(&label_view, ctx),
123            label_text: self.label.clone(),
124            pressed: false,
125            captured: false,
126            on_toggle: crate::authoring::erase_callback_arg(&self.on_toggle),
127        }
128    }
129
130    fn rebuild(
131        &self,
132        prev: &Self,
133        element: &mut CheckboxWidget,
134        ctx: &mut BuildCtx<'_>,
135    ) -> ChangeFlags {
136        element.on_toggle = crate::authoring::erase_callback_arg(&self.on_toggle);
137        let mut flags = ChangeFlags::NONE;
138        if prev.checked != self.checked {
139            // The app is the source of truth: adopt the new value on rebuild.
140            element.checked = self.checked;
141            flags |= ChangeFlags::PAINT;
142        }
143        if prev.label != self.label {
144            element.label_text = self.label.clone();
145            let prev_view = any::<State, _>(text(prev.label.clone()));
146            let next_view = any::<State, _>(text(self.label.clone()));
147            flags |=
148                crate::authoring::rebuild_child(&prev_view, &next_view, &mut element.label, ctx);
149        }
150        flags
151    }
152
153    fn teardown(&self, element: &mut CheckboxWidget, ctx: &mut BuildCtx<'_>) {
154        let label_view = any::<State, _>(text(self.label.clone()));
155        crate::authoring::teardown_child(&label_view, &mut element.label, ctx);
156    }
157}
158
159impl Widget for CheckboxWidget {
160    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
161        let label_max = Size::new((bc.max().width - BOX - GAP).max(0.0), bc.max().height);
162        let label_size = self
163            .label
164            .layout_child(ctx, &BoxConstraints::loose(label_max));
165        let height = label_size.height.max(BOX);
166        // Vertically centre the label against the box.
167        self.label
168            .set_origin(Point::new(BOX + GAP, (height - label_size.height) / 2.0));
169        bc.constrain(Size::new(BOX + GAP + label_size.width, height))
170    }
171
172    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
173        let (off_fill, on_fill, check) = resolve_colors(Theme::from_paint_ctx(ctx));
174        let box_y = ctx.origin().y + (ctx.size().height - BOX) / 2.0;
175        let box_origin = Point::new(ctx.origin().x, box_y);
176        let fill = if self.checked { on_fill } else { off_fill };
177        scene.fill_rounded_rect(box_origin, Size::new(BOX, BOX), RADIUS, fill);
178        if self.checked {
179            // A two-segment check mark within the box.
180            let p0 = Point::new(box_origin.x + BOX * 0.24, box_origin.y + BOX * 0.52);
181            let p1 = Point::new(box_origin.x + BOX * 0.42, box_origin.y + BOX * 0.70);
182            let p2 = Point::new(box_origin.x + BOX * 0.76, box_origin.y + BOX * 0.30);
183            scene.stroke_line(p0, p1, CHECK_WIDTH, check);
184            scene.stroke_line(p1, p2, CHECK_WIDTH, check);
185        }
186        self.label.paint_child(ctx, scene);
187    }
188
189    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
190        if activation(ctx, event) {
191            (self.on_toggle)(ctx, !self.checked);
192            ctx.request_redraw();
193            return EventResult::Handled;
194        }
195        let InputEvent::Pointer(p) = event else {
196            return EventResult::Ignored;
197        };
198        match p.phase {
199            PointerPhase::Down => {
200                if !presses(p) {
201                    return EventResult::Ignored;
202                }
203                if press_claims_focus(ctx) {
204                    ctx.request_focus();
205                }
206                self.pressed = true;
207                self.captured = true;
208                ctx.capture_pointer();
209                ctx.request_redraw();
210                EventResult::Handled
211            }
212            PointerPhase::Move => {
213                // Only a press we armed on `Down` tracks the cursor; a hover
214                // `Move` (no prior press) is not ours.
215                if !self.captured {
216                    return EventResult::Ignored;
217                }
218                self.pressed = inside(p.position, ctx.size());
219                ctx.request_redraw();
220                EventResult::Handled
221            }
222            PointerPhase::Up => {
223                if !self.captured {
224                    return EventResult::Ignored;
225                }
226                if inside(p.position, ctx.size()) {
227                    // Report the *requested* value; never self-toggle.
228                    let requested = !self.checked;
229                    (self.on_toggle)(ctx, requested);
230                }
231                self.pressed = false;
232                self.captured = false;
233                ctx.request_redraw();
234                EventResult::Handled
235            }
236            PointerPhase::Cancel => {
237                if !self.captured {
238                    return EventResult::Ignored;
239                }
240                self.pressed = false;
241                self.captured = false;
242                ctx.request_redraw();
243                EventResult::Handled
244            }
245        }
246    }
247
248    fn semantics(&self, ctx: &mut SemanticsCtx) {
249        // A single CheckBox node carrying its toggle state and label; it fires a
250        // Click to toggle.
251        ctx.push_node(Role::CheckBox, |node| {
252            node.set_label(self.label_text.as_str());
253            node.set_toggled(Toggled::from(self.checked));
254            node.add_action(Action::Click);
255        });
256    }
257
258    /// Always a Tab stop; a primary mouse press also takes focus (see the module docs).
259    fn is_focusable(&self) -> bool {
260        true
261    }
262
263    crate::authoring::visit_children!(label);
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use std::any::Any;
270
271    #[derive(Default)]
272    struct ToggleState {
273        last: Option<bool>,
274        toggles: u32,
275    }
276
277    fn widget(checked: bool) -> CheckboxWidget {
278        let view = checkbox::<ToggleState, _>(checked, "on", |s: &mut ToggleState, v: bool| {
279            s.last = Some(v);
280            s.toggles += 1;
281        });
282        let mut counter = 0u64;
283        View::<ToggleState>::build(&view, &mut BuildCtx::new(&mut counter))
284    }
285
286    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
287        InputEvent::Pointer(frust_core::PointerEvent {
288            phase,
289            position: Point::new(x, y),
290            button: frust_core::PointerButton::Primary,
291        })
292    }
293
294    /// The same event on the secondary (right) button.
295    fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
296        InputEvent::Pointer(frust_core::PointerEvent {
297            phase,
298            position: Point::new(x, y),
299            button: frust_core::PointerButton::Secondary,
300        })
301    }
302
303    fn dispatch(w: &mut CheckboxWidget, state: &mut ToggleState, event: &InputEvent) {
304        let state_any: &mut dyn Any = state;
305        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(120.0, 24.0));
306        w.event(&mut ctx, event);
307    }
308
309    #[test]
310    fn a_secondary_press_never_captures_or_toggles() {
311        let mut w = widget(false);
312        let mut state = ToggleState::default();
313        dispatch(
314            &mut w,
315            &mut state,
316            &secondary_ev(PointerPhase::Down, 5.0, 12.0),
317        );
318        assert!(!w.pressed);
319        assert!(!w.captured, "no capture for the shell to get stuck on");
320        dispatch(
321            &mut w,
322            &mut state,
323            &secondary_ev(PointerPhase::Up, 5.0, 12.0),
324        );
325        assert_eq!(state.toggles, 0);
326
327        // The primary gesture still toggles.
328        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
329        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
330        assert_eq!(state.toggles, 1);
331    }
332
333    #[test]
334    fn unchecked_fires_true_and_does_not_self_toggle() {
335        let mut w = widget(false);
336        let mut state = ToggleState::default();
337        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
338        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
339        assert_eq!(state.last, Some(true));
340        assert_eq!(state.toggles, 1);
341        assert!(!w.checked, "checkbox must not mutate its own checked flag");
342    }
343
344    #[test]
345    fn checked_fires_false() {
346        let mut w = widget(true);
347        let mut state = ToggleState::default();
348        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
349        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
350        assert_eq!(state.last, Some(false));
351        assert!(w.checked, "still checked until the app rebuilds it");
352    }
353
354    #[test]
355    fn up_outside_does_not_fire() {
356        let mut w = widget(false);
357        let mut state = ToggleState::default();
358        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
359        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 12.0));
360        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 12.0));
361        assert_eq!(state.toggles, 0);
362    }
363
364    #[test]
365    fn hover_move_without_down_is_ignored_noop() {
366        let mut w = widget(false);
367        let mut state = ToggleState::default();
368        let state_any: &mut dyn Any = &mut state;
369        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(120.0, 24.0));
370        let result = w.event(&mut ctx, &ev(PointerPhase::Move, 5.0, 12.0));
371        assert!(matches!(result, EventResult::Ignored));
372        assert!(!w.pressed, "hover must not press");
373        assert!(!ctx.needs_redraw(), "hover must not request a redraw");
374        assert_eq!(state.toggles, 0);
375    }
376
377    #[test]
378    fn up_without_down_does_not_fire() {
379        let mut w = widget(false);
380        let mut state = ToggleState::default();
381        let state_any: &mut dyn Any = &mut state;
382        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(120.0, 24.0));
383        let result = w.event(&mut ctx, &ev(PointerPhase::Up, 5.0, 12.0));
384        assert!(matches!(result, EventResult::Ignored));
385        assert_eq!(state.toggles, 0);
386    }
387
388    #[test]
389    fn cancel_clears_armed_state() {
390        let mut w = widget(false);
391        let mut state = ToggleState::default();
392        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
393        assert!(w.captured);
394        dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 5.0, 12.0));
395        assert!(!w.captured, "Cancel disarms the press");
396        // A follow-up hover Up must not fire.
397        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
398        assert_eq!(state.toggles, 0);
399    }
400
401    /// Records the box fill (rounded rect) and check-stroke colors.
402    #[derive(Default)]
403    struct BoxRecorder {
404        rrects: Vec<Color>,
405        strokes: Vec<Color>,
406    }
407
408    impl PaintScene for BoxRecorder {
409        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
410        fn draw_text(&mut self, _o: Point, _t: &str) {}
411        fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, color: Color) {
412            self.rrects.push(color);
413        }
414        fn stroke_line(&mut self, _a: Point, _b: Point, _w: f64, color: Color) {
415            self.strokes.push(color);
416        }
417    }
418
419    fn paint_rec(w: &mut CheckboxWidget, theme: Option<&Theme>) -> BoxRecorder {
420        let mut rec = BoxRecorder::default();
421        let mut ctx = match theme {
422            Some(t) => PaintCtx::new(Point::ZERO, Size::new(120.0, 24.0)).with_theme(t),
423            None => PaintCtx::new(Point::ZERO, Size::new(120.0, 24.0)),
424        };
425        w.paint(&mut ctx, &mut rec);
426        rec
427    }
428
429    #[test]
430    fn unthemed_paint_uses_fallback_constants() {
431        let mut off = widget(false);
432        assert_eq!(paint_rec(&mut off, None).rrects, vec![FILL_OFF]);
433        let mut on = widget(true);
434        let rec = paint_rec(&mut on, None);
435        assert_eq!(rec.rrects, vec![FILL_ON]);
436        assert_eq!(rec.strokes, vec![CHECK, CHECK]);
437    }
438
439    #[test]
440    fn themed_paint_resolves_roles() {
441        let theme = Theme::neutral();
442        let scheme = theme.scheme();
443        let mut off = widget(false);
444        assert_eq!(
445            paint_rec(&mut off, Some(&theme)).rrects,
446            vec![scheme.outline],
447            "unchecked box uses the outline role"
448        );
449        let mut on = widget(true);
450        let rec = paint_rec(&mut on, Some(&theme));
451        assert_eq!(rec.rrects, vec![scheme.primary], "checked box uses primary");
452        assert_eq!(
453            rec.strokes,
454            vec![scheme.on_primary, scheme.on_primary],
455            "check mark uses on_primary"
456        );
457    }
458
459    #[test]
460    fn rebuild_adopts_new_checked_value() {
461        let mut counter = 0u64;
462        let prev = checkbox::<ToggleState, _>(false, "on", |_s, _v| {});
463        let mut w = View::<ToggleState>::build(&prev, &mut BuildCtx::new(&mut counter));
464        assert!(!w.checked);
465        let next = checkbox::<ToggleState, _>(true, "on", |_s, _v| {});
466        let flags =
467            View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
468        assert!(w.checked);
469        assert!(flags.needs_paint());
470    }
471
472    mod keyboard {
473        use super::*;
474        use frust_core::{DiscardScene, FrameTime, Key, KeyEvent, Modifiers, NamedKey, RenderRoot};
475
476        fn key(key: Key) -> InputEvent {
477            InputEvent::Key(KeyEvent {
478                key,
479                modifiers: Modifiers::default(),
480                repeat: false,
481            })
482        }
483
484        #[test]
485        fn a_press_keeps_focus_so_the_next_tab_moves_to_the_next_stop() {
486            let mut logic = |_: &mut ToggleState| {
487                crate::column()
488                    .child(checkbox(false, "a", |s: &mut ToggleState, _| {
489                        s.toggles += 1
490                    }))
491                    .child(checkbox(false, "b", |s: &mut ToggleState, _| {
492                        s.toggles += 10
493                    }))
494            };
495            let mut root: RenderRoot<ToggleState, crate::FlexView<ToggleState>> = RenderRoot::new();
496            let mut state = ToggleState::default();
497            let mut tcx = frust_text::TextContext::new();
498            root.rebuild(&mut logic, &mut state);
499            root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
500            root.paint(&mut DiscardScene, FrameTime::ZERO);
501            root.event(&mut state, &ev(PointerPhase::Down, 5.0, 10.0));
502            root.event(&mut state, &ev(PointerPhase::Up, 5.0, 10.0));
503            root.event(&mut state, &key(Key::Named(NamedKey::Tab)));
504            root.event(&mut state, &key(Key::Character(" ".to_string())));
505            assert_eq!(state.toggles, 11, "Tab went to the second checkbox");
506        }
507
508        #[test]
509        fn a_touch_tap_toggles_without_claiming_focus() {
510            let mut logic = |_: &mut ToggleState| {
511                crate::column().child(checkbox(false, "a", |s: &mut ToggleState, _| {
512                    s.toggles += 1
513                }))
514            };
515            let mut root: RenderRoot<ToggleState, crate::FlexView<ToggleState>> = RenderRoot::new();
516            let mut state = ToggleState::default();
517            let mut tcx = frust_text::TextContext::new();
518            root.rebuild(&mut logic, &mut state);
519            root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
520            root.paint(&mut DiscardScene, FrameTime::ZERO);
521            for phase in [PointerPhase::Down, PointerPhase::Up] {
522                let InputEvent::Pointer(event) = ev(phase, 5.0, 10.0) else {
523                    unreachable!()
524                };
525                let contact = InputEvent::PointerContact {
526                    pointer_id: frust_core::event::PointerId::touch(0),
527                    event,
528                };
529                root.event(&mut state, &contact);
530            }
531            assert_eq!(state.toggles, 1);
532            assert!(!root.is_focus_active(), "a tap leaves focus alone");
533        }
534
535        #[test]
536        fn tab_focuses_the_checkbox_then_space_or_enter_requests_the_toggle() {
537            assert!(widget(false).is_focusable());
538            let mut logic = |s: &mut ToggleState| {
539                let checked = s.last.unwrap_or(false);
540                crate::column().child(checkbox(checked, "on", |s: &mut ToggleState, v| {
541                    s.last = Some(v);
542                    s.toggles += 1;
543                }))
544            };
545            let mut root: RenderRoot<ToggleState, crate::FlexView<ToggleState>> = RenderRoot::new();
546            let mut state = ToggleState::default();
547            let mut tcx = frust_text::TextContext::new();
548            root.rebuild(&mut logic, &mut state);
549            root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
550            root.paint(&mut DiscardScene, FrameTime::ZERO);
551            root.event(&mut state, &key(Key::Character(" ".to_string())));
552            assert_eq!(state.toggles, 0, "an unfocused checkbox takes no keys");
553            root.event(&mut state, &ev(PointerPhase::Down, 5.0, 10.0));
554            root.event(&mut state, &ev(PointerPhase::Up, 5.0, 10.0));
555            assert_eq!(state.toggles, 1);
556            assert!(root.is_focus_active(), "a click claims focus");
557            root.rebuild(&mut logic, &mut state);
558
559            root.event(&mut state, &key(Key::Named(NamedKey::Tab)));
560            assert!(root.is_focus_active());
561            assert!(
562                root.event(&mut state, &key(Key::Character(" ".to_string())))
563                    .handled
564            );
565            assert_eq!(
566                (state.last, state.toggles),
567                (Some(false), 2),
568                "requests !checked"
569            );
570            root.rebuild(&mut logic, &mut state);
571            root.event(&mut state, &key(Key::Named(NamedKey::Enter)));
572            assert_eq!((state.last, state.toggles), (Some(true), 3));
573        }
574    }
575}