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