Skip to main content

frus_widgets/
checkbox.rs

1//! [`Checkbox`]: a **controlled** checkbox, its state coming from the application.
2
3use frus_core::{Color, Point, Rect, ResolvedTextStyle, Scene, TextStyle};
4use frus_layout::{Dimension, Style};
5
6use crate::disabled::{disabled_content, disabled_mark};
7use crate::interaction::{Interaction, Status};
8use crate::theme::{TapTarget, Theme};
9use crate::widget::Widget;
10
11const BOX: f32 = 20.0;
12/// The partly-ticked bar: its inset from either side of the box, and its thickness.
13const MIXED_INSET: f32 = 4.0;
14const MIXED_THICKNESS: f32 = 2.0;
15const GAP: f32 = 10.0;
16
17/// A checkbox, with an optional label.
18pub struct Checkbox<Msg = crate::callback::Callback> {
19    /// On, off, or **partly** on; see [`Checkbox::maybe`].
20    value: Option<bool>,
21    /// Whether a click may land on the partly-on answer.
22    tristate: bool,
23    label: Option<String>,
24    size: f32,
25    enabled: bool,
26    fill_color: Option<Color>,
27    check_color: Option<Color>,
28    border_color: Option<Color>,
29    active_border_color: Option<Color>,
30    tap_target: Option<TapTarget>,
31    radius: Option<f32>,
32    label_color: Option<Color>,
33    on_toggle: Option<Box<dyn Fn(bool) -> Msg>>,
34    on_change: Option<Box<dyn Fn(Option<bool>) -> Msg>>,
35}
36
37impl<Msg> Checkbox<Msg> {
38    /// Creates a checkbox whose checked state is supplied.
39    pub fn new(checked: bool) -> Self {
40        Self {
41            value: Some(checked),
42            tristate: false,
43            label: None,
44            size: 18.0,
45            enabled: true,
46            fill_color: None,
47            check_color: None,
48            border_color: None,
49            active_border_color: None,
50            tap_target: None,
51            radius: None,
52            label_color: None,
53            on_toggle: None,
54            on_change: None,
55        }
56    }
57
58    /// A checkbox with **three** answers: on, off, and partly on.
59    ///
60    /// `None` is the third, and it is an answer rather than a missing one. A "select
61    /// all" above five rows of which three are ticked is not unchecked — saying so tells
62    /// the reader something false, and a screen reader is told `mixed` for the same
63    /// reason.
64    ///
65    /// A click cycles off → on → partly on → off, which is the reference's order. Pair it
66    /// with [`on_change`](Checkbox::on_change), since [`on_toggle`](Checkbox::on_toggle)
67    /// has no way to say the third answer.
68    ///
69    /// ```
70    /// # use frus_widgets::Checkbox;
71    /// # #[derive(Clone)] enum Msg { All(Option<bool>) }
72    /// # let (done, total) = (3usize, 5usize);
73    /// let all = match done {
74    ///     0 => Some(false),
75    ///     n if n == total => Some(true),
76    ///     _ => None,
77    /// };
78    /// Checkbox::maybe(all).label("Select all").on_change(Msg::All);
79    /// ```
80    pub fn maybe(value: Option<bool>) -> Self {
81        let mut checkbox = Self::new(false);
82        checkbox.value = value;
83        checkbox.tristate = true;
84        checkbox
85    }
86
87    /// The box's fill when it is **ticked**; the theme's `primary` otherwise.
88    pub fn fill_color(mut self, color: Color) -> Self {
89        self.fill_color = Some(color);
90        self
91    }
92
93    /// The tick drawn on that fill; the theme's `on_primary` otherwise.
94    pub fn check_color(mut self, color: Color) -> Self {
95        self.check_color = Some(color);
96        self
97    }
98
99    /// The outline when the box is **not** ticked and at rest.
100    ///
101    /// Set on its own it also becomes the colour under a pointer or focus, unless
102    /// [`active_border_color`](Checkbox::active_border_color) says otherwise: a caller
103    /// who names one outline colour means the outline, not half of it.
104    pub fn border_color(mut self, color: Color) -> Self {
105        self.border_color = Some(color);
106        self
107    }
108
109    /// The outline under a pointer, a finger or focus. The reference resolves this side
110    /// per state, and an outline that did not answer at all would look inert.
111    pub fn active_border_color(mut self, color: Color) -> Self {
112        self.active_border_color = Some(color);
113        self
114    }
115
116    /// The box's corner radius.
117    pub fn radius(mut self, radius: f32) -> Self {
118        self.radius = Some(radius);
119        self
120    }
121
122    /// The label's colour; the theme's `on_surface` otherwise.
123    pub fn label_color(mut self, color: Color) -> Self {
124        self.label_color = Some(color);
125        self
126    }
127
128    /// The corner radius actually used.
129    fn corner(&self, theme: &Theme) -> f32 {
130        self.radius.or(theme.widgets.checkbox.radius).unwrap_or(5.0)
131    }
132
133    /// Adds a label on the right.
134    pub fn label(mut self, label: impl Into<String>) -> Self {
135        self.label = Some(label.into());
136        self
137    }
138
139    /// Whether the box can be ticked. Disabled it is **inert** — no message, out of the
140    /// tab order, announced as unavailable — and it still shows whether it is ticked,
141    /// because read-only is not invisible.
142    ///
143    /// See [`crate::disabled`] for the whole contract.
144    pub fn enabled(mut self, enabled: bool) -> Self {
145        self.enabled = enabled;
146        self
147    }
148
149    /// A closure producing a message from the new state, checked or not.
150    pub fn on_toggle<R: crate::callback::IntoMsg<Msg>>(
151        mut self,
152        on_toggle: impl Fn(bool) -> R + 'static,
153    ) -> Self {
154        self.on_toggle = Some(Box::new(crate::callback::handler1(on_toggle)));
155        self
156    }
157
158    /// A closure producing a message from the new state, **including** the partly-on
159    /// one. What [`Checkbox::maybe`] wants; it wins over
160    /// [`on_toggle`](Checkbox::on_toggle) when both are given.
161    pub fn on_change<R: crate::callback::IntoMsg<Msg>>(
162        mut self,
163        on_change: impl Fn(Option<bool>) -> R + 'static,
164    ) -> Self {
165        self.on_change = Some(Box::new(crate::callback::handler1(on_change)));
166        self
167    }
168
169    /// Is the box filled? Both **on** and **partly on** are: the mark differs, the
170    /// surface under it does not, which is what says "this is not simply off".
171    fn filled(&self) -> bool {
172        self.value != Some(false)
173    }
174
175    /// The state a click moves to. Three-way it is the reference's cycle; otherwise the
176    /// old one, unchanged.
177    fn next(&self) -> Option<bool> {
178        if self.tristate {
179            match self.value {
180                Some(false) => Some(true),
181                Some(true) => None,
182                None => Some(false),
183            }
184        } else {
185            Some(self.value != Some(true))
186        }
187    }
188
189    fn label_width(&self) -> f32 {
190        match &self.label {
191            Some(text) => GAP + frus_text::measure(text, self.size).width,
192            None => 0.0,
193        }
194    }
195}
196
197impl<Msg> Checkbox<Msg> {
198    /// **How much room it reserves for a finger** ([`TapTarget`]). Unset, the theme's
199    /// answer, which is [`Padded`](TapTarget::Padded) — at least 48 pixels either way,
200    /// whatever this control paints in the middle of it.
201    pub fn tap_target(mut self, target: TapTarget) -> Self {
202        self.tap_target = Some(target);
203        self
204    }
205
206    /// The label's style, **resolved once** so that the number the box is measured with is
207    /// the number the glyphs are drawn at. Resolving is the single place the reader's font
208    /// setting is applied (milestone 403).
209    fn label_style(&self) -> ResolvedTextStyle {
210        TextStyle::new(self.size).resolved()
211    }
212
213    /// The room this checkbox reserves, resolved as `caller ?? theme ?? framework`
214    /// (`checkbox.dart:510`).
215    fn reserved(&self, theme: &Theme) -> f32 {
216        self.tap_target
217            .or(theme.widgets.checkbox.tap_target)
218            .unwrap_or(theme.tap_target)
219            .min_side()
220    }
221}
222
223impl<Msg> Widget<Msg> for Checkbox<Msg> {
224    fn style(&self) -> Style {
225        Widget::<Msg>::style_themed(self, &Theme::default())
226    }
227
228    /// **A 20-pixel box is not something a finger can be asked to hit** — the reference
229    /// lays a checkbox out inside a 48-pixel square whatever it paints in the middle
230    /// (`checkbox.dart:516`).
231    ///
232    /// The target here is the **whole control**, label included, rather than a square
233    /// around the box: the reference's checkbox carries no label, so it reaches the same
234    /// guarantee by being a square with the words outside it. A labelled one is already
235    /// wider than the minimum, so the width floor only ever binds on a bare box — and
236    /// then the box is centred in what it was given.
237    fn style_themed(&self, theme: &Theme) -> Style {
238        let least = self.reserved(theme);
239        let line = self.label_style().line_height().max(BOX);
240        Style {
241            width: Dimension::Length((BOX + self.label_width()).max(least).ceil()),
242            height: Dimension::Length(line.max(least).ceil()),
243            ..Default::default()
244        }
245    }
246
247    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
248        &[]
249    }
250
251    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
252        let o = status.opacity;
253        let box_y = bounds.y + (bounds.height - BOX) * 0.5;
254        // Centred across as well when there is nothing beside it, since the box is then
255        // the only thing in a square wider than itself. With a label it stays at the
256        // leading edge and the words follow it.
257        let box_x = bounds.x
258            + if self.label.is_none() {
259                (bounds.width - BOX) * 0.5
260            } else {
261                0.0
262            };
263        let box_rect = Rect::new(box_x, box_y, BOX, BOX);
264
265        if self.filled() {
266            // Disabled and ticked: the box flattens to `on_surface` at 38 % and the tick
267            // punches through in `surface`. A translucent tick on a translucent box would
268            // land within a few percent of it and vanish.
269            let (fill, tick) = if self.enabled {
270                (
271                    self.fill_color
272                        .or(theme.widgets.checkbox.fill_color)
273                        .unwrap_or(theme.primary),
274                    self.check_color
275                        .or(theme.widgets.checkbox.check_color)
276                        .unwrap_or(theme.on_primary),
277                )
278            } else {
279                (disabled_content(theme), disabled_mark(theme))
280            };
281            scene.draw_rect(
282                box_rect,
283                fill.fade(o),
284                self.corner(theme),
285                0.0,
286                Color::TRANSPARENT,
287            );
288            match self.value {
289                // Ticked.
290                // `exact`: the tick is an icon drawn as a glyph and it lives inside a
291                // `BOX` that does not move, so it must not follow the reader's type.
292                Some(_) => scene.text(
293                    Point::new(box_rect.x + 3.0, box_rect.y + 1.0),
294                    "✓".to_string(),
295                    &ResolvedTextStyle::exact(self.size),
296                    tick.fade(o),
297                ),
298                // Partly ticked: a bar, and a **drawn** one rather than a dash of text.
299                // The tick above is a glyph and pays for it — a font's own width and
300                // weight — but the reference draws this mark, and a bar is two numbers
301                // rather than a code point some font may not carry.
302                None => scene.draw_rect(
303                    Rect::new(
304                        box_rect.x + MIXED_INSET,
305                        box_rect.y + (BOX - MIXED_THICKNESS) * 0.5,
306                        BOX - MIXED_INSET * 2.0,
307                        MIXED_THICKNESS,
308                    ),
309                    tick.fade(o),
310                    MIXED_THICKNESS * 0.5,
311                    0.0,
312                    Color::TRANSPARENT,
313                ),
314            }
315        } else {
316            // Unticked, the outline *is* the control — the mark rather than a container —
317            // so it takes the content opacity, as the reference's does.
318            //
319            // And it is **not** `outline`. The reference resolves this side per state:
320            // `on_surface_variant` at rest, the full `on_surface` under a finger, a
321            // pointer or focus, and `on_surface` at 38 % when disabled. An unselected
322            // checkbox is a mark, and a mark is drawn in an *on* colour; `outline` is for
323            // the edge of a container, which this is not. Milestone 332.
324            //
325            // A caller who names one outline colour means the outline, so the pointer
326            // state falls back to the resting override before it falls back to the
327            // scheme -- otherwise a green checkbox would turn grey under a finger.
328            let resting = self.border_color.or(theme.widgets.checkbox.border_color);
329            let border = if !self.enabled {
330                disabled_content(theme)
331            } else if status.interaction != Interaction::None || status.focused {
332                self.active_border_color
333                    .or(theme.widgets.checkbox.active_border_color)
334                    .or(resting)
335                    .unwrap_or(theme.scheme.on_surface)
336            } else {
337                resting.unwrap_or(theme.scheme.on_surface_variant)
338            };
339            scene.draw_rect(
340                box_rect,
341                theme.surface.fade(o),
342                self.corner(theme),
343                2.0,
344                border.fade(o),
345            );
346        }
347
348        if let Some(label) = &self.label {
349            let color = if self.enabled {
350                self.label_color
351                    .or(theme.widgets.checkbox.label_color)
352                    .unwrap_or(theme.on_surface)
353            } else {
354                disabled_content(theme)
355            };
356            // Centred down the box's own height. It used to be drawn at the top of the
357            // bounds, which was the same sentence while the bounds *were* the line; a
358            // floor under the height makes the two different.
359            let style = self.label_style();
360            scene.text(
361                Point::new(
362                    bounds.x + BOX + GAP,
363                    bounds.y + (bounds.height - style.line_height()) * 0.5,
364                ),
365                label.clone(),
366                &style,
367                color.fade(o),
368            );
369        }
370    }
371
372    fn on_click(&self) -> Option<Msg> {
373        if !self.enabled {
374            return None;
375        }
376        let next = self.next();
377        if let Some(make) = self.on_change.as_ref() {
378            return Some(make(next));
379        }
380        // `on_toggle` cannot say the third answer, so a tristate box wired only to it
381        // reports the two it can: partly on reads as on, which is what a click on it
382        // moves away from.
383        self.on_toggle
384            .as_ref()
385            .map(|make| make(next.unwrap_or(true)))
386    }
387
388    fn focusable(&self) -> bool {
389        self.enabled
390    }
391
392    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
393        // Still ticked or not, still announced: a reader who cannot change the answer is
394        // still owed it.
395        let mut s = frus_core::SemanticsProperties::new(frus_core::Role::CheckBox)
396            .maybe_toggled(self.value);
397        s = if self.enabled {
398            s.clickable()
399        } else {
400            s.disabled(true)
401        };
402        if let Some(label) = &self.label {
403            s = s.label(label.clone());
404        }
405        Some(s)
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use crate::theme::{MIN_TAP_TARGET, SHRUNK_TAP_TARGET};
413
414    #[derive(Clone, Debug, PartialEq)]
415    enum Msg {
416        Set(bool),
417        Maybe(Option<bool>),
418    }
419
420    /// **A 20-pixel box is not something a finger can be asked to hit** (milestone 442).
421    ///
422    /// The reference lays a checkbox out inside a 48-pixel square whatever it paints in
423    /// the middle (`checkbox.dart:516`). This asked for the box and the label's line, so
424    /// a bare checkbox was a 20-pixel target and a labelled one was as tall as its type.
425    #[test]
426    fn a_checkbox_reserves_room_for_a_finger() {
427        let theme = Theme::dark();
428        let bare: Checkbox<Msg> = Checkbox::new(false);
429        let square = Widget::<Msg>::style_themed(&bare, &theme);
430        assert_eq!(square.width, Dimension::Length(MIN_TAP_TARGET));
431        assert_eq!(square.height, Dimension::Length(MIN_TAP_TARGET));
432
433        // With a label the control is already wider than the minimum, so only the height
434        // floor binds — the target is the whole control, words included.
435        let labelled: Checkbox<Msg> = Checkbox::new(false).label("Remember me");
436        let row = Widget::<Msg>::style_themed(&labelled, &theme);
437        assert_eq!(row.height, Dimension::Length(MIN_TAP_TARGET));
438        match row.width {
439            Dimension::Length(w) => assert!(w > MIN_TAP_TARGET, "width = {w}"),
440            other => panic!("{other:?}"),
441        }
442
443        let dense: Checkbox<Msg> = Checkbox::new(false).tap_target(TapTarget::ShrinkWrap);
444        assert_eq!(
445            Widget::<Msg>::style_themed(&dense, &theme).height,
446            Dimension::Length(SHRUNK_TAP_TARGET)
447        );
448    }
449
450    /// And **the click lands in the room**, which is the whole point of reserving it: a
451    /// point below the box, inside the square, reaches the checkbox.
452    #[test]
453    fn a_click_below_the_box_still_lands() {
454        use frus_core::Size;
455        let ui = crate::ui::build_ui(
456            &Checkbox::new(false).on_toggle(Msg::Set),
457            Size::new(200.0, 200.0),
458            &crate::runtime::Runtime::default(),
459            &Theme::dark(),
460        );
461        // The box occupies 14..34 in both axes of the 48-pixel square; this is under it.
462        let below = Point::new(24.0, 42.0);
463        let landed = ui.hit(below).and_then(|id| ui.msg_for(id));
464        assert_eq!(landed, Some(Msg::Set(true)), "a finger just low of the box");
465    }
466
467    /// The box is centred in that square when nothing stands beside it, and stays at the
468    /// leading edge when a label does.
469    #[test]
470    fn a_bare_box_is_centred_in_its_room() {
471        let theme = Theme::dark();
472        let box_of = |checkbox: &Checkbox<Msg>, width: f32| {
473            let mut scene = Scene::new();
474            Widget::<Msg>::paint(
475                checkbox,
476                Rect::new(0.0, 0.0, width, MIN_TAP_TARGET),
477                Status::default(),
478                &theme,
479                &mut scene,
480            );
481            scene
482                .primitives()
483                .iter()
484                .find_map(|p| match p {
485                    frus_core::Primitive::Rect { rect, .. } => Some(*rect),
486                    _ => None,
487                })
488                .expect("a checkbox paints its box")
489        };
490
491        let bare = box_of(&Checkbox::new(true), MIN_TAP_TARGET);
492        assert!(
493            (bare.x - (MIN_TAP_TARGET - BOX) * 0.5).abs() < 0.01,
494            "centred across: {bare:?}"
495        );
496        assert!(
497            (bare.y - (MIN_TAP_TARGET - BOX) * 0.5).abs() < 0.01,
498            "and down: {bare:?}"
499        );
500
501        let labelled = box_of(&Checkbox::new(true).label("Remember me"), 200.0);
502        assert_eq!(
503            labelled.x, 0.0,
504            "the words follow the box, not the other way"
505        );
506    }
507
508    #[test]
509    fn click_toggles() {
510        let unchecked = Checkbox::new(false).on_toggle(Msg::Set);
511        assert_eq!(Widget::on_click(&unchecked), Some(Msg::Set(true)));
512        let checked = Checkbox::new(true).on_toggle(Msg::Set);
513        assert_eq!(Widget::on_click(&checked), Some(Msg::Set(false)));
514    }
515
516    #[test]
517    fn a_disabled_box_is_inert_but_still_says_whether_it_is_ticked() {
518        let dead = Checkbox::new(true).on_toggle(Msg::Set).enabled(false);
519        assert_eq!(Widget::on_click(&dead), None, "the press goes nowhere");
520        assert!(!Widget::<Msg>::focusable(&dead), "out of the tab order");
521        let semantics = Widget::<Msg>::semantics(&dead).expect("still announced");
522        assert!(semantics.disabled, "and announced as unavailable");
523        // The answer survives: read-only is not invisible.
524        assert_eq!(semantics.toggled, frus_core::Toggled::True);
525    }
526
527    /// An unselected box's side, state by state. The reference resolves it as
528    /// `on_surface_variant` at rest and the full `on_surface` under a finger, a pointer or
529    /// focus — an unselected checkbox is a **mark**, so it takes an *on* colour. Ours was
530    /// `outline`, the role for the edge of a container, since it was written.
531    #[test]
532    fn an_unticked_box_is_a_mark_not_a_container_edge() {
533        let theme = Theme::dark();
534        let side = |status: Status| {
535            let mut scene = Scene::new();
536            Widget::<Msg>::paint(
537                &Checkbox::<Msg>::new(false),
538                Rect::new(0.0, 0.0, 20.0, 20.0),
539                status,
540                &theme,
541                &mut scene,
542            );
543            scene
544                .primitives()
545                .iter()
546                .find_map(|p| match p {
547                    frus_core::Primitive::Rect { border_color, .. } => Some(*border_color),
548                    _ => None,
549                })
550                .expect("the box")
551        };
552        let at = |interaction, focused| Status {
553            opacity: 1.0,
554            interaction,
555            focused,
556            ..Default::default()
557        };
558        assert_eq!(
559            side(at(Interaction::None, false)),
560            theme.scheme.on_surface_variant,
561            "at rest"
562        );
563        for (name, status) in [
564            ("hovered", at(Interaction::Hovered, false)),
565            ("pressed", at(Interaction::Pressed, false)),
566            ("focused", at(Interaction::None, true)),
567        ] {
568            assert_eq!(side(status), theme.scheme.on_surface, "{name}");
569        }
570        // And it is no longer the container-edge role, which is what it used to be.
571        assert_ne!(
572            side(at(Interaction::None, false)),
573            theme.scheme.outline,
574            "an unselected box is a mark, not a container's edge"
575        );
576    }
577
578    #[test]
579    fn a_disabled_tick_does_not_disappear_into_its_box() {
580        // Both are drawn from `on_surface`; if the tick took the content opacity too it
581        // would land within a few percent of the 38 % box behind it and vanish. It punches
582        // through in `surface` instead, and this is the assertion that says so.
583        for theme in [Theme::dark(), Theme::light()] {
584            let mut scene = Scene::new();
585            Widget::<Msg>::paint(
586                &Checkbox::<Msg>::new(true).enabled(false),
587                Rect::new(0.0, 0.0, 20.0, 20.0),
588                Status {
589                    opacity: 1.0,
590                    ..Default::default()
591                },
592                &theme,
593                &mut scene,
594            );
595            let box_fill = scene
596                .primitives()
597                .iter()
598                .find_map(|p| match p {
599                    frus_core::Primitive::Rect { color, .. } => Some(*color),
600                    _ => None,
601                })
602                .expect("the box");
603            let tick = scene
604                .primitives()
605                .iter()
606                .find_map(|p| match p {
607                    frus_core::Primitive::Text { color, .. } => Some(*color),
608                    _ => None,
609                })
610                .expect("the tick");
611            let against = |c: frus_core::Color| {
612                (c.r - theme.scheme.surface.r).abs()
613                    + (c.g - theme.scheme.surface.g).abs()
614                    + (c.b - theme.scheme.surface.b).abs()
615            };
616            // The tick is the surface punching through, so it is *at* the surface while
617            // the fill it sits on is a measurable way off it. Since milestone 329 the
618            // disabled tokens resolve to opaque colours, so the two are told apart by
619            // where they sit rather than by an alpha.
620            assert!(
621                against(tick) < 0.01,
622                "the tick is the surface punching through: {tick:?}"
623            );
624            assert!(
625                against(box_fill) > 0.1,
626                "and the box it is inside is not: {box_fill:?}"
627            );
628        }
629    }
630
631    /// The third answer is an answer, and the cycle is the reference's.
632    ///
633    /// A "select all" above five rows of which three are ticked is not unchecked. Drawn
634    /// that way it says *nothing here is selected*, which is false; drawn ticked it says
635    /// *everything is*, which is also false. So the control has a third state, and a
636    /// click walks off → on → partly on → off.
637    #[test]
638    fn a_tristate_box_cycles_through_the_third_answer() {
639        let seen = |value| Widget::on_click(&Checkbox::maybe(value).on_change(Msg::Maybe));
640        assert_eq!(seen(Some(false)), Some(Msg::Maybe(Some(true))));
641        assert_eq!(seen(Some(true)), Some(Msg::Maybe(None)));
642        assert_eq!(seen(None), Some(Msg::Maybe(Some(false))));
643    }
644
645    /// A two-state box is untouched by any of it: `new` is not `maybe`, so the cycle it
646    /// walks is still the old one and `on_toggle` still says what it always said.
647    #[test]
648    fn a_two_state_box_never_reaches_the_third() {
649        assert_eq!(
650            Widget::on_click(&Checkbox::new(true).on_change(Msg::Maybe)),
651            Some(Msg::Maybe(Some(false))),
652            "no `None` in the middle"
653        );
654    }
655
656    /// `on_toggle` takes a `bool` and there is no value of `bool` that means *partly*, so
657    /// a tristate box wired only to it reports the two answers that type can carry —
658    /// partly-on reading as on, which is what a click on it moves away from. Making the
659    /// case emit nothing would be a widget that looks live and is not.
660    #[test]
661    fn a_tristate_box_on_the_old_callback_is_not_left_silent() {
662        assert_eq!(
663            Widget::on_click(&Checkbox::maybe(None).on_toggle(Msg::Set)),
664            Some(Msg::Set(false)),
665            "partly on moves to off, and `bool` can say that"
666        );
667        assert_eq!(
668            Widget::on_click(&Checkbox::maybe(Some(true)).on_toggle(Msg::Set)),
669            Some(Msg::Set(true)),
670            "on moves to partly on, which reads as on"
671        );
672    }
673
674    /// `on_change` wins when both are given: it is the one that can say all three.
675    #[test]
676    fn the_three_state_callback_wins_over_the_two_state_one() {
677        let both = Checkbox::maybe(Some(true))
678            .on_toggle(|_| Msg::Set(false))
679            .on_change(Msg::Maybe);
680        assert_eq!(Widget::on_click(&both), Some(Msg::Maybe(None)));
681    }
682
683    /// The screen reader is told `mixed` rather than handed a lie in one of the two
684    /// directions.
685    #[test]
686    fn partly_ticked_is_announced_as_mixed() {
687        let announced = |value| {
688            Widget::<Msg>::semantics(&Checkbox::<Msg>::maybe(value))
689                .expect("announced")
690                .toggled
691        };
692        assert_eq!(announced(None), frus_core::Toggled::Mixed);
693        assert_eq!(announced(Some(true)), frus_core::Toggled::True);
694        assert_eq!(announced(Some(false)), frus_core::Toggled::False);
695    }
696
697    /// Both **on** and **partly on** fill the box; only the mark differs. The filled
698    /// surface is what says *this is not simply off*, and the mark says which of the two
699    /// it is — which is the reference's drawing.
700    #[test]
701    fn both_answers_that_are_not_off_fill_the_box() {
702        let theme = Theme::default();
703        let painted = |value| {
704            let mut scene = Scene::new();
705            Widget::<Msg>::paint(
706                &Checkbox::<Msg>::maybe(value),
707                Rect::new(0.0, 0.0, 20.0, 20.0),
708                Status {
709                    opacity: 1.0,
710                    ..Default::default()
711                },
712                &theme,
713                &mut scene,
714            );
715            scene.primitives().to_vec()
716        };
717        let filled = |primitives: &[frus_core::Primitive]| {
718            primitives.iter().any(|p| {
719                matches!(p, frus_core::Primitive::Rect { color, .. } if *color == theme.primary)
720            })
721        };
722        assert!(filled(&painted(Some(true))), "ticked fills");
723        assert!(filled(&painted(None)), "partly ticked fills too");
724        assert!(!filled(&painted(Some(false))), "off does not");
725    }
726
727    /// The partly-on mark is **drawn** rather than a glyph — a bar, two numbers — so no
728    /// font gets to decide whether it exists. The tick above it is text and pays for it;
729    /// there was no reason to add a second one.
730    #[test]
731    fn the_partly_ticked_mark_is_drawn_and_the_tick_is_not() {
732        let has_text = |value| {
733            let mut scene = Scene::new();
734            Widget::<Msg>::paint(
735                &Checkbox::<Msg>::maybe(value),
736                Rect::new(0.0, 0.0, 20.0, 20.0),
737                Status {
738                    opacity: 1.0,
739                    ..Default::default()
740                },
741                &Theme::default(),
742                &mut scene,
743            );
744            scene
745                .primitives()
746                .iter()
747                .any(|p| matches!(p, frus_core::Primitive::Text { .. }))
748        };
749        assert!(has_text(Some(true)), "the tick is a glyph");
750        assert!(!has_text(None), "the bar is not");
751    }
752}
753
754#[cfg(test)]
755mod color_tests {
756    use super::*;
757    use crate::widget::Widget;
758    use frus_core::Primitive;
759
760    const BRAND: Color = Color::rgb(0.0, 0.6, 0.3);
761    const MARK: Color = Color::rgb(0.9, 0.9, 0.2);
762
763    /// The box: its fill, its border colour and its radius.
764    fn box_of(cb: &Checkbox<()>, status: Status, theme: &Theme) -> (Color, Color, f32) {
765        let mut scene = Scene::new();
766        Widget::<()>::paint(
767            cb,
768            Rect::new(0.0, 0.0, 200.0, BOX),
769            status,
770            theme,
771            &mut scene,
772        );
773        scene
774            .primitives()
775            .iter()
776            .find_map(|p| match p {
777                Primitive::Rect {
778                    color,
779                    border_color,
780                    radius,
781                    ..
782                } => Some((*color, *border_color, radius.top_left)),
783                _ => None,
784            })
785            .expect("the box is painted")
786    }
787
788    /// The tick's colour, or the label's — whichever text came out.
789    fn text_color(cb: &Checkbox<()>, theme: &Theme) -> Vec<Color> {
790        let mut scene = Scene::new();
791        Widget::<()>::paint(
792            cb,
793            Rect::new(0.0, 0.0, 200.0, BOX),
794            Status {
795                opacity: 1.0,
796                ..Default::default()
797            },
798            theme,
799            &mut scene,
800        );
801        scene
802            .primitives()
803            .iter()
804            .filter_map(|p| match p {
805                Primitive::Text { color, .. } => Some(*color),
806                _ => None,
807            })
808            .collect()
809    }
810
811    fn rest() -> Status {
812        Status {
813            opacity: 1.0,
814            ..Default::default()
815        }
816    }
817
818    /// Nothing said: what it always painted.
819    #[test]
820    fn the_defaults_are_what_they_were() {
821        let theme = Theme::default();
822        let (fill, _, radius) = box_of(&Checkbox::<()>::new(true), rest(), &theme);
823        assert_eq!(fill, theme.primary);
824        assert_eq!(radius, 5.0);
825        let (_, border, _) = box_of(&Checkbox::<()>::new(false), rest(), &theme);
826        assert_eq!(border, theme.scheme.on_surface_variant);
827    }
828
829    /// A ticked box takes its fill and its tick; the corner takes its radius.
830    #[test]
831    fn a_ticked_box_takes_its_colours() {
832        let theme = Theme::default();
833        let cb = Checkbox::<()>::new(true)
834            .fill_color(BRAND)
835            .check_color(MARK)
836            .radius(2.0);
837        let (fill, _, radius) = box_of(&cb, rest(), &theme);
838        assert_eq!((fill, radius), (BRAND, 2.0));
839        assert_eq!(text_color(&cb, &theme), vec![MARK]);
840    }
841
842    /// A caller who names one outline colour means the outline: the pointer state falls
843    /// back to it before it falls back to the scheme. Otherwise a green checkbox would
844    /// turn grey the moment a finger came near it.
845    #[test]
846    fn one_outline_colour_covers_both_states() {
847        let theme = Theme::default();
848        let hovered = Status {
849            opacity: 1.0,
850            interaction: Interaction::Hovered,
851            ..Default::default()
852        };
853        let cb = Checkbox::<()>::new(false).border_color(BRAND);
854        assert_eq!(box_of(&cb, rest(), &theme).1, BRAND);
855        assert_eq!(box_of(&cb, hovered, &theme).1, BRAND, "and under a finger");
856
857        let both = Checkbox::<()>::new(false)
858            .border_color(BRAND)
859            .active_border_color(MARK);
860        assert_eq!(box_of(&both, rest(), &theme).1, BRAND);
861        assert_eq!(box_of(&both, hovered, &theme).1, MARK, "unless it is named");
862    }
863
864    /// The theme answers when the instance does not, and loses when it does.
865    #[test]
866    fn the_theme_answers_and_the_instance_overrules_it() {
867        let mut theme = Theme::default();
868        theme.widgets.checkbox.fill_color = Some(MARK);
869        assert_eq!(box_of(&Checkbox::<()>::new(true), rest(), &theme).0, MARK);
870        assert_eq!(
871            box_of(&Checkbox::<()>::new(true).fill_color(BRAND), rest(), &theme).0,
872            BRAND
873        );
874    }
875
876    /// The label answers too.
877    #[test]
878    fn the_label_takes_its_colour() {
879        let theme = Theme::default();
880        let cb = Checkbox::<()>::new(false).label("Ready").label_color(BRAND);
881        assert_eq!(text_color(&cb, &theme), vec![BRAND]);
882    }
883}