Skip to main content

frus_widgets/
checkbox.rs

1//! [`Checkbox`]: a **controlled** checkbox, its state coming from the application — the
2//! reference's `Checkbox` (`material/checkbox.dart`), with an optional label beside it.
3
4use frus_core::{
5    BorderSide, Color, Path, Point, Rect, ResolvedTextStyle, Scene, Stroke, TextStyle,
6};
7use frus_layout::{Dimension, Style};
8
9use crate::disabled::{disabled_content, disabled_mark};
10use crate::interaction::Status;
11use crate::theme::{TapTarget, Theme};
12use crate::widget::Widget;
13use crate::widgetstate::{WidgetState, WidgetStateProperty, WidgetStates};
14
15/// The box's side (`checkbox.dart:405`).
16const EDGE: f32 = 18.0;
17/// The outline's and the mark's stroke (`checkbox.dart:651`).
18const STROKE: f32 = 2.0;
19/// The box's corner (`checkbox.dart:1047`).
20const RADIUS: f32 = 2.0;
21/// How long a tick takes to draw itself (the reference's toggle animation).
22const TOGGLE_SECONDS: f32 = 0.2;
23
24/// A checkbox, with an optional label.
25///
26/// The box is the reference's: 18 px with a 2 px corner, centred in the room it reserves
27/// for a finger (48 px at the standard density), its outline `on_surface_variant` at rest
28/// and `on_surface` under a pointer, the keyboard or a finger, filled in `primary` with an
29/// `on_primary` tick drawn as a stroke, or a dash when partly ticked, and a halo behind it
30/// under a pointer, the keyboard or a finger. Ticking it fills the box and draws the tick
31/// over a fifth of a second.
32///
33/// The label is this framework's: the reference puts words beside a checkbox with a list
34/// tile. It follows the box's room.
35pub struct Checkbox<Msg = crate::callback::Callback> {
36    /// On, off, or **partly** on; see [`Checkbox::maybe`].
37    value: Option<bool>,
38    /// Whether a click may land on the partly-on answer.
39    tristate: bool,
40    label: Option<String>,
41    size: f32,
42    enabled: bool,
43    error: bool,
44    semantic_label: Option<String>,
45    fill_color: Option<Color>,
46    check_color: Option<Color>,
47    border_color: Option<Color>,
48    active_border_color: Option<Color>,
49    overlay_color: Option<WidgetStateProperty<Color>>,
50    splash_radius: Option<f32>,
51    tap_target: Option<TapTarget>,
52    visual_density: Option<crate::VisualDensity>,
53    radius: Option<f32>,
54    label_color: Option<Color>,
55    on_toggle: Option<Box<dyn Fn(bool) -> Msg>>,
56    on_change: Option<Box<dyn Fn(Option<bool>) -> Msg>>,
57}
58
59impl<Msg> Checkbox<Msg> {
60    /// Creates a checkbox whose checked state is supplied.
61    pub fn new(checked: bool) -> Self {
62        Self {
63            value: Some(checked),
64            tristate: false,
65            label: None,
66            size: 16.0,
67            enabled: true,
68            error: false,
69            semantic_label: None,
70            fill_color: None,
71            check_color: None,
72            border_color: None,
73            active_border_color: None,
74            overlay_color: None,
75            splash_radius: None,
76            tap_target: None,
77            visual_density: None,
78            radius: None,
79            label_color: None,
80            on_toggle: None,
81            on_change: None,
82        }
83    }
84
85    /// A checkbox with **three** answers: on, off, and partly on.
86    ///
87    /// `None` is the third, and it is an answer rather than a missing one. A "select
88    /// all" above five rows of which three are ticked is not unchecked — saying so tells
89    /// the reader something false, and a screen reader is told `mixed` for the same
90    /// reason.
91    ///
92    /// A click cycles off → on → partly on → off, which is the reference's order. Pair it
93    /// with [`on_change`](Checkbox::on_change), since [`on_toggle`](Checkbox::on_toggle)
94    /// has no way to say the third answer.
95    ///
96    /// ```
97    /// # use frus_widgets::Checkbox;
98    /// # #[derive(Clone)] enum Msg { All(Option<bool>) }
99    /// # let (done, total) = (3usize, 5usize);
100    /// let all = match done {
101    ///     0 => Some(false),
102    ///     n if n == total => Some(true),
103    ///     _ => None,
104    /// };
105    /// Checkbox::maybe(all).label("Select all").on_change(Msg::All);
106    /// ```
107    pub fn maybe(value: Option<bool>) -> Self {
108        let mut checkbox = Self::new(false);
109        checkbox.value = value;
110        checkbox.tristate = true;
111        checkbox
112    }
113
114    /// The box's fill when it is **ticked**; the theme's, then `primary`.
115    pub fn fill_color(mut self, color: Color) -> Self {
116        self.fill_color = Some(color);
117        self
118    }
119
120    /// The tick drawn on that fill; the theme's, then `on_primary`.
121    pub fn check_color(mut self, color: Color) -> Self {
122        self.check_color = Some(color);
123        self
124    }
125
126    /// The outline when the box is **not** ticked and at rest.
127    ///
128    /// Set on its own it also becomes the colour under a pointer or focus, unless
129    /// [`active_border_color`](Checkbox::active_border_color) says otherwise: a caller
130    /// who names one outline colour means the outline, not half of it.
131    pub fn border_color(mut self, color: Color) -> Self {
132        self.border_color = Some(color);
133        self
134    }
135
136    /// The outline under a pointer, a finger or focus.
137    pub fn active_border_color(mut self, color: Color) -> Self {
138        self.active_border_color = Some(color);
139        self
140    }
141
142    /// **The halo** under a pointer, the keyboard or a finger, state by state — the
143    /// reference's `overlayColor`. What it does not say, the theme's and then the
144    /// reference's answer.
145    #[must_use]
146    pub fn overlay_color(mut self, overlay: WidgetStateProperty<Color>) -> Self {
147        self.overlay_color = Some(overlay);
148        self
149    }
150
151    /// That halo's radius. Unset, the theme's, then 20.
152    #[must_use]
153    pub fn splash_radius(mut self, radius: f32) -> Self {
154        self.splash_radius = Some(radius);
155        self
156    }
157
158    /// **How compact it is.** Unset, the theme's checkbox density, then the standard one —
159    /// a checkbox keeps its 48 px on every platform unless told, as the reference's does
160    /// (`checkbox.dart:1043`).
161    #[must_use]
162    pub fn visual_density(mut self, density: crate::VisualDensity) -> Self {
163        self.visual_density = Some(density);
164        self
165    }
166
167    /// The box's corner radius. Unset, the theme's, then 2.
168    pub fn radius(mut self, radius: f32) -> Self {
169        self.radius = Some(radius);
170        self
171    }
172
173    /// The label's colour; the theme's `on_surface` otherwise.
174    pub fn label_color(mut self, color: Color) -> Self {
175        self.label_color = Some(color);
176        self
177    }
178
179    /// **In error**: the outline, the fill and the halo take the `error` colours, as the
180    /// reference's `isError` does — a required box left unticked in a form.
181    #[must_use]
182    pub fn error(mut self, error: bool) -> Self {
183        self.error = error;
184        self
185    }
186
187    /// **What a screen reader calls it** when it has no label to read.
188    #[must_use]
189    pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
190        self.semantic_label = Some(label.into());
191        self
192    }
193
194    /// The corner radius actually used.
195    fn corner(&self, theme: &Theme) -> f32 {
196        self.radius
197            .or(theme.widgets.checkbox.radius)
198            .unwrap_or(RADIUS)
199    }
200
201    /// Adds a label after it.
202    pub fn label(mut self, label: impl Into<String>) -> Self {
203        self.label = Some(label.into());
204        self
205    }
206
207    /// Whether the box can be ticked. Disabled it is **inert** — no message, out of the
208    /// tab order, announced as unavailable — and it still shows whether it is ticked,
209    /// because read-only is not invisible.
210    ///
211    /// See [`crate::disabled`] for the whole contract.
212    pub fn enabled(mut self, enabled: bool) -> Self {
213        self.enabled = enabled;
214        self
215    }
216
217    /// A closure producing a message from the new state, checked or not.
218    pub fn on_toggle<R: crate::callback::IntoMsg<Msg>>(
219        mut self,
220        on_toggle: impl Fn(bool) -> R + 'static,
221    ) -> Self {
222        self.on_toggle = Some(Box::new(crate::callback::handler1(on_toggle)));
223        self
224    }
225
226    /// A closure producing a message from the new state, **including** the partly-on
227    /// one. What [`Checkbox::maybe`] wants; it wins over
228    /// [`on_toggle`](Checkbox::on_toggle) when both are given.
229    pub fn on_change<R: crate::callback::IntoMsg<Msg>>(
230        mut self,
231        on_change: impl Fn(Option<bool>) -> R + 'static,
232    ) -> Self {
233        self.on_change = Some(Box::new(crate::callback::handler1(on_change)));
234        self
235    }
236
237    /// The state a click moves to. Three-way it is the reference's cycle.
238    fn next(&self) -> Option<bool> {
239        if self.tristate {
240            match self.value {
241                Some(false) => Some(true),
242                Some(true) => None,
243                None => Some(false),
244            }
245        } else {
246            Some(self.value != Some(true))
247        }
248    }
249
250    /// **How much room it reserves for a finger** ([`TapTarget`]). Unset, the theme's
251    /// answer, which is [`Padded`](TapTarget::Padded) — 48 pixels either way, whatever this
252    /// control paints in the middle of it.
253    pub fn tap_target(mut self, target: TapTarget) -> Self {
254        self.tap_target = Some(target);
255        self
256    }
257
258    /// The label's style, **resolved once** so that the number the box is measured with is
259    /// the number the glyphs are drawn at.
260    fn label_style(&self) -> ResolvedTextStyle {
261        TextStyle::new(self.size).resolved()
262    }
263
264    /// The square the box sits in: 48 px padded or 40 shrink-wrapped, moved by the density
265    /// (`checkbox.dart:516`).
266    fn square(&self, theme: &Theme) -> f32 {
267        let target = self
268            .tap_target
269            .or(theme.widgets.checkbox.tap_target)
270            .unwrap_or(theme.tap_target);
271        let side = match target {
272            TapTarget::Padded => crate::MIN_TAP_TARGET,
273            TapTarget::ShrinkWrap => crate::MIN_TAP_TARGET - 8.0,
274        };
275        let density = self
276            .visual_density
277            .or(theme.widgets.checkbox.visual_density)
278            .unwrap_or(crate::VisualDensity::STANDARD);
279        (side + density.base_size_adjustment().1).max(EDGE)
280    }
281
282    fn label_width(&self) -> f32 {
283        match &self.label {
284            Some(text) => frus_text::measure(text, self.size).width,
285            None => 0.0,
286        }
287    }
288
289    /// The states it is in: the interaction's, ticked, disabled and in error.
290    fn states(&self, status: &Status) -> WidgetStates {
291        let states = if self.enabled {
292            status.states()
293        } else {
294            WidgetStates::of(WidgetState::Disabled)
295        };
296        states
297            .set(WidgetState::Selected, self.value != Some(false))
298            .set(WidgetState::Error, self.error)
299    }
300
301    /// The fill for these states (`checkbox.dart:960`).
302    fn fill(&self, theme: &Theme, states: WidgetStates) -> Color {
303        let t = &theme.widgets.checkbox;
304        if states.contains(WidgetState::Disabled) {
305            return if states.contains(WidgetState::Selected) {
306                disabled_content(theme)
307            } else {
308                Color::TRANSPARENT
309            };
310        }
311        if !states.contains(WidgetState::Selected) {
312            return Color::TRANSPARENT;
313        }
314        if states.contains(WidgetState::Error) {
315            return theme.scheme.error;
316        }
317        self.fill_color
318            .or(t.fill_color)
319            .unwrap_or(theme.scheme.primary)
320    }
321
322    /// The mark's colour (`checkbox.dart:979`).
323    fn check(&self, theme: &Theme, states: WidgetStates) -> Color {
324        if states.contains(WidgetState::Disabled) {
325            return disabled_mark(theme);
326        }
327        if states.contains(WidgetState::Error) {
328            return theme.scheme.on_error;
329        }
330        self.check_color
331            .or(theme.widgets.checkbox.check_color)
332            .unwrap_or(theme.scheme.on_primary)
333    }
334
335    /// The outline of an unticked box for these states (`checkbox.dart:933`): none once
336    /// ticked, faint disabled, the error colour in error, `on_surface` under a pointer, the
337    /// keyboard or a finger, and `on_surface_variant` at rest.
338    fn side(&self, theme: &Theme, states: WidgetStates) -> BorderSide {
339        let t = &theme.widgets.checkbox;
340        if states.contains(WidgetState::Selected) {
341            return BorderSide::new(Color::TRANSPARENT, 0.0);
342        }
343        let color = if states.contains(WidgetState::Disabled) {
344            disabled_content(theme)
345        } else if states.contains(WidgetState::Error) {
346            theme.scheme.error
347        } else {
348            let resting = self.border_color.or(t.border_color);
349            let active = states.contains(WidgetState::Pressed)
350                || states.contains(WidgetState::Hovered)
351                || states.contains(WidgetState::Focused);
352            if active {
353                self.active_border_color
354                    .or(t.active_border_color)
355                    .or(resting)
356                    .unwrap_or(theme.scheme.on_surface)
357            } else {
358                resting.unwrap_or(theme.scheme.on_surface_variant)
359            }
360        };
361        BorderSide::new(color, STROKE)
362    }
363
364    /// The halo for these states (`checkbox.dart:998`): the caller's, the theme's, then the
365    /// reference's — 8 % under a pointer and 10 % focused or pressed, in `on_surface` or
366    /// `primary` depending on whether the box is ticked, and in `error` in error.
367    fn overlay(&self, theme: &Theme, states: WidgetStates) -> Color {
368        if let Some(c) = self
369            .overlay_color
370            .as_ref()
371            .and_then(|p| p.resolve(states))
372            .or_else(|| {
373                theme
374                    .widgets
375                    .checkbox
376                    .overlay_color
377                    .as_ref()
378                    .and_then(|p| p.resolve(states))
379            })
380        {
381            return *c;
382        }
383        if states.contains(WidgetState::Error) {
384            let c = theme.scheme.error;
385            // Pressed, then hovered, then focused, as the reference tries them.
386            if states.contains(WidgetState::Pressed) {
387                return c.with_alpha(0.1);
388            }
389            if states.contains(WidgetState::Hovered) {
390                return c.with_alpha(0.08);
391            }
392            if states.contains(WidgetState::Focused) {
393                return c.with_alpha(0.1);
394            }
395        }
396        crate::toggleable::overlay(theme, states)
397    }
398}
399
400/// The box at `t` of its animation, from its origin: full size at either end, a stroke
401/// smaller half way (`checkbox.dart:718`).
402fn outer_at(origin: Point, t: f32) -> Rect {
403    let inset = 1.0 - (t - 0.5).abs() * 2.0;
404    let size = EDGE - inset * STROKE;
405    Rect::new(origin.x + inset, origin.y + inset, size, size)
406}
407
408/// The tick at `t`: the short stroke drawn over the first half, the long one over the
409/// second (`checkbox.dart:750`).
410fn check_path(origin: Point, t: f32) -> Path {
411    let at = |x: f32, y: f32| Point::new(origin.x + EDGE * x, origin.y + EDGE * y);
412    let (start, mid, end) = (at(0.15, 0.45), at(0.4, 0.7), at(0.85, 0.25));
413    let lerp =
414        |a: Point, b: Point, k: f32| Point::new(a.x + (b.x - a.x) * k, a.y + (b.y - a.y) * k);
415    if t < 0.5 {
416        Path::new()
417            .move_to(start)
418            .line_to(lerp(start, mid, t * 2.0))
419    } else {
420        Path::new()
421            .move_to(start)
422            .line_to(mid)
423            .line_to(lerp(mid, end, (t - 0.5) * 2.0))
424    }
425}
426
427/// The dash at `t`, growing from the middle outwards (`checkbox.dart:773`).
428fn dash_path(origin: Point, t: f32) -> Path {
429    let y = origin.y + EDGE * 0.5;
430    let half = EDGE * 0.3 * t;
431    let mid = origin.x + EDGE * 0.5;
432    Path::new()
433        .move_to(Point::new(mid - half, y))
434        .line_to(Point::new(mid + half, y))
435}
436
437impl<Msg> Widget<Msg> for Checkbox<Msg> {
438    fn style(&self) -> Style {
439        Widget::<Msg>::style_themed(self, &Theme::default())
440    }
441
442    /// **A square a finger can hit**, 48 px at the standard density, with the 18 px box in
443    /// its middle (`checkbox.dart:516`), and the label after it.
444    fn style_themed(&self, theme: &Theme) -> Style {
445        let square = self.square(theme);
446        let line = self.label_style().line_height();
447        Style {
448            width: Dimension::Length((square + self.label_width()).ceil()),
449            height: Dimension::Length(line.max(square).ceil()),
450            ..Default::default()
451        }
452    }
453
454    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
455        &[]
456    }
457
458    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
459        let o = status.opacity;
460        let square = self.square(theme);
461        let centre = Point::new(bounds.x + square * 0.5, bounds.y + bounds.height * 0.5);
462        let states = self.states(&status);
463
464        // The halo: under a pointer and the keyboard it fades in, under a finger it grows
465        // (`toggleable.dart`'s radial reaction).
466        if self.enabled {
467            let radius = self
468                .splash_radius
469                .or(theme.widgets.checkbox.splash_radius)
470                .unwrap_or(crate::toggleable::SPLASH);
471            crate::toggleable::paint_halos(scene, centre, radius, &status, |state| {
472                self.overlay(theme, states.set(state, true))
473            });
474        }
475
476        // The box at `t` of its animation (`checkbox.dart:786`): 0 unticked, 1 filled.
477        let t = status.value.clamp(0.0, 1.0);
478        let origin = Point::new(centre.x - EDGE * 0.5, centre.y - EDGE * 0.5);
479        let off = states.set(WidgetState::Selected, false);
480        let on = states.set(WidgetState::Selected, true);
481        let fill = if t >= 0.25 {
482            self.fill(theme, on)
483        } else {
484            self.fill(theme, off).lerp(self.fill(theme, on), t * 4.0)
485        };
486        let outer = outer_at(origin, t);
487        let corner = self.corner(theme);
488        let side = if t <= 0.5 {
489            let (a, b) = (self.side(theme, off), self.side(theme, on));
490            BorderSide::new(a.color.lerp(b.color, t), a.width + (b.width - a.width) * t)
491        } else {
492            self.side(theme, on)
493        };
494        scene.draw_rect(outer, fill.fade(o), corner, side.width, side.color.fade(o));
495        if t > 0.5 {
496            let k = (t - 0.5) * 2.0;
497            let path = match self.value {
498                None => dash_path(origin, k),
499                _ => check_path(origin, k),
500            };
501            scene.paint_path(
502                &path,
503                None,
504                Some(Stroke::new(self.check(theme, on).fade(o), STROKE)),
505            );
506        }
507
508        if let Some(label) = &self.label {
509            let color = if self.enabled {
510                self.label_color
511                    .or(theme.widgets.checkbox.label_color)
512                    .unwrap_or(theme.on_surface)
513            } else {
514                disabled_content(theme)
515            };
516            let style = self.label_style();
517            scene.text(
518                Point::new(
519                    bounds.x + square,
520                    bounds.y + (bounds.height - style.line_height()) * 0.5,
521                ),
522                label.clone(),
523                &style,
524                color.fade(o),
525            );
526        }
527    }
528
529    fn on_click(&self) -> Option<Msg> {
530        if !self.enabled {
531            return None;
532        }
533        let next = self.next();
534        if let Some(make) = self.on_change.as_ref() {
535            return Some(make(next));
536        }
537        // `on_toggle` cannot say the third answer, so a tristate box wired only to it
538        // reports the two it can: partly on reads as on, which is what a click on it
539        // moves away from.
540        self.on_toggle
541            .as_ref()
542            .map(|make| make(next.unwrap_or(true)))
543    }
544
545    fn focusable(&self) -> bool {
546        self.enabled
547    }
548
549    /// Filled or not: ticking fills the box and draws the mark over a fifth of a second.
550    fn anim_target(&self) -> Option<f32> {
551        Some(if self.value == Some(false) { 0.0 } else { 1.0 })
552    }
553
554    fn anim_duration(&self) -> f32 {
555        TOGGLE_SECONDS
556    }
557
558    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
559        // Still ticked or not, still announced: a reader who cannot change the answer is
560        // still owed it.
561        let mut s = frus_core::SemanticsProperties::new(frus_core::Role::CheckBox)
562            .maybe_toggled(self.value);
563        s = if self.enabled {
564            s.clickable()
565        } else {
566            s.disabled(true)
567        };
568        if let Some(label) = self.label.as_ref().or(self.semantic_label.as_ref()) {
569            s = s.label(label.clone());
570        }
571        Some(s)
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use frus_core::{PathVerb, Primitive};
579
580    #[derive(Clone, Debug, PartialEq)]
581    enum Msg {
582        Set(bool),
583        Maybe(Option<bool>),
584    }
585
586    /// A settled status: the box's animation at its end for `value`.
587    fn settled(value: bool) -> Status {
588        Status {
589            opacity: 1.0,
590            value: if value { 1.0 } else { 0.0 },
591            ..Default::default()
592        }
593    }
594
595    fn painted(c: &Checkbox<Msg>, status: Status, theme: &Theme) -> Vec<Primitive> {
596        let side = c.square(theme);
597        let mut scene = Scene::new();
598        Widget::<Msg>::paint(
599            c,
600            Rect::new(0.0, 0.0, side, side),
601            status,
602            theme,
603            &mut scene,
604        );
605        scene.primitives().to_vec()
606    }
607
608    /// The box: the last rectangle, with its fill, outline and corner.
609    fn the_box(prims: &[Primitive]) -> (Rect, Color, f32, Color, f32) {
610        prims
611            .iter()
612            .rev()
613            .find_map(|p| match p {
614                Primitive::Rect {
615                    rect,
616                    color,
617                    border_width,
618                    border_color,
619                    radius,
620                    ..
621                } => Some((*rect, *color, *border_width, *border_color, radius.top_left)),
622                _ => None,
623            })
624            .expect("a box")
625    }
626
627    fn mark(prims: &[Primitive]) -> Option<(Vec<Point>, Color)> {
628        prims.iter().find_map(|p| match p {
629            Primitive::Path {
630                path,
631                stroke: Some(s),
632                ..
633            } => Some((
634                path.verbs()
635                    .iter()
636                    .filter_map(|v| match v {
637                        PathVerb::MoveTo(p) | PathVerb::LineTo(p) => Some(*p),
638                        _ => None,
639                    })
640                    .collect(),
641                s.color,
642            )),
643            _ => None,
644        })
645    }
646
647    /// **The reference's box**: 18 px with a 2 px corner, centred in a 48 px square at the
648    /// standard density, which a checkbox keeps on every platform unless told.
649    #[test]
650    fn the_box_is_the_reference_s() {
651        let theme = Theme::dark().with_platform(frus_core::TargetPlatform::Windows);
652        let c = Checkbox::<Msg>::new(false);
653        let style = Widget::<Msg>::style_themed(&c, &theme);
654        assert_eq!(
655            (style.width, style.height),
656            (Dimension::Length(48.0), Dimension::Length(48.0)),
657            "standard density even on a desktop"
658        );
659        let (rect, fill, border, outline, corner) = the_box(&painted(&c, settled(false), &theme));
660        assert_eq!(rect, Rect::new(15.0, 15.0, 18.0, 18.0));
661        assert_eq!((fill, border, corner), (Color::TRANSPARENT, 2.0, 2.0));
662        assert_eq!(outline, theme.scheme.on_surface_variant);
663        // Told to be compact, it is 40; shrink-wrapped, 40 too.
664        let compact =
665            c_style(Checkbox::<Msg>::new(false).visual_density(crate::VisualDensity::COMPACT));
666        assert_eq!(compact, 40.0);
667        let shrunk = c_style(Checkbox::<Msg>::new(false).tap_target(TapTarget::ShrinkWrap));
668        assert_eq!(shrunk, 40.0);
669    }
670
671    fn c_style(c: Checkbox<Msg>) -> f32 {
672        match Widget::<Msg>::style_themed(&c, &Theme::dark()).height {
673            Dimension::Length(v) => v,
674            _ => unreachable!(),
675        }
676    }
677
678    /// **Ticked, the box fills and the tick is a drawn stroke** through the reference's
679    /// three points (`checkbox.dart:755`); partly ticked, a dash across the middle.
680    #[test]
681    fn the_tick_is_drawn_through_the_reference_s_points() {
682        let theme = Theme::dark();
683        let prims = painted(&Checkbox::<Msg>::new(true), settled(true), &theme);
684        let (rect, fill, border, _, _) = the_box(&prims);
685        assert_eq!(
686            (rect, fill, border),
687            (Rect::new(15.0, 15.0, 18.0, 18.0), theme.scheme.primary, 0.0)
688        );
689        let (points, colour) = mark(&prims).expect("a tick");
690        assert_eq!(colour, theme.scheme.on_primary);
691        let at = |x: f32, y: f32| Point::new(15.0 + 18.0 * x, 15.0 + 18.0 * y);
692        assert_eq!(points, vec![at(0.15, 0.45), at(0.4, 0.7), at(0.85, 0.25)]);
693        let (dash, _) = mark(&painted(
694            &Checkbox::<Msg>::maybe(None),
695            settled(true),
696            &theme,
697        ))
698        .unwrap();
699        let near = |a: Point, b: Point| (a.x - b.x).abs() < 1e-3 && (a.y - b.y).abs() < 1e-3;
700        assert!(
701            near(dash[0], at(0.2, 0.5)) && near(dash[1], at(0.8, 0.5)),
702            "{dash:?}"
703        );
704    }
705
706    /// **Ticking animates**: half way the box is a stroke smaller and the tick not yet
707    /// drawn; at three quarters the short stroke is done and the long one half way.
708    #[test]
709    fn ticking_animates_the_box_and_the_tick() {
710        let theme = Theme::dark();
711        let c = Checkbox::<Msg>::new(true);
712        assert_eq!(Widget::<Msg>::anim_target(&c), Some(1.0));
713        assert_eq!(
714            Widget::<Msg>::anim_target(&Checkbox::<Msg>::new(false)),
715            Some(0.0)
716        );
717        // Partly ticked fills the box too: the dash is on a filled box, as the tick is.
718        assert_eq!(
719            Widget::<Msg>::anim_target(&Checkbox::<Msg>::maybe(None)),
720            Some(1.0)
721        );
722        assert_eq!(Widget::<Msg>::anim_duration(&c), 0.2);
723        let half = painted(
724            &c,
725            Status {
726                value: 0.5,
727                ..settled(true)
728            },
729            &theme,
730        );
731        assert_eq!(the_box(&half).0, Rect::new(16.0, 16.0, 16.0, 16.0));
732        assert!(mark(&half).is_none(), "no tick yet");
733        let three = painted(
734            &c,
735            Status {
736                value: 0.75,
737                ..settled(true)
738            },
739            &theme,
740        );
741        let (points, _) = mark(&three).unwrap();
742        let at = |x: f32, y: f32| Point::new(15.0 + 18.0 * x, 15.0 + 18.0 * y);
743        // The short stroke whole, and the long one not yet begun: a segment of no length at
744        // the turn, as the reference draws it at that instant (`checkbox.dart:763`).
745        assert_eq!(points, vec![at(0.15, 0.45), at(0.4, 0.7), at(0.4, 0.7)]);
746    }
747
748    /// **The halo is the reference's**: under a pointer `on_surface` at 8 % round an
749    /// unticked box and `primary` at 8 % round a ticked one, 20 px in radius.
750    #[test]
751    fn the_halo_is_the_reference_s() {
752        let theme = Theme::dark();
753        let hovered = |value: bool| Status {
754            hover_progress: 1.0,
755            interaction: crate::interaction::Interaction::Hovered,
756            ..settled(value)
757        };
758        let prims = painted(&Checkbox::<Msg>::new(false), hovered(false), &theme);
759        let halo = prims.iter().find_map(|p| match p {
760            Primitive::Rect { rect, color, .. } if rect.width == 40.0 => Some(*color),
761            _ => None,
762        });
763        assert_eq!(halo, Some(theme.scheme.on_surface.with_alpha(0.08)));
764        let prims = painted(&Checkbox::<Msg>::new(true), hovered(true), &theme);
765        let halo = prims.iter().find_map(|p| match p {
766            Primitive::Rect { rect, color, .. } if rect.width == 40.0 => Some(*color),
767            _ => None,
768        });
769        assert_eq!(halo, Some(theme.scheme.primary.with_alpha(0.08)));
770        // Under a pointer the outline darkens to `on_surface`.
771        let (_, _, _, outline, _) = the_box(&painted(
772            &Checkbox::<Msg>::new(false),
773            hovered(false),
774            &theme,
775        ));
776        assert_eq!(outline, theme.scheme.on_surface);
777    }
778
779    /// **In error** the outline, the fill and the tick take the error roles.
780    #[test]
781    fn an_error_takes_the_error_colours() {
782        let theme = Theme::dark();
783        let (_, _, _, outline, _) = the_box(&painted(
784            &Checkbox::<Msg>::new(false).error(true),
785            settled(false),
786            &theme,
787        ));
788        assert_eq!(outline, theme.scheme.error);
789        let prims = painted(
790            &Checkbox::<Msg>::new(true).error(true),
791            settled(true),
792            &theme,
793        );
794        assert_eq!(the_box(&prims).1, theme.scheme.error);
795        assert_eq!(mark(&prims).unwrap().1, theme.scheme.on_error);
796    }
797
798    /// **Disabled**: inert, but still showing whether it is ticked — the fill on_surface at
799    /// 38 % and the tick in `surface`, resolved opaque.
800    #[test]
801    fn a_disabled_box_is_inert_but_still_says_whether_it_is_ticked() {
802        let theme = Theme::dark();
803        let c = Checkbox::<Msg>::new(true)
804            .enabled(false)
805            .on_toggle(Msg::Set);
806        assert_eq!(Widget::<Msg>::on_click(&c), None);
807        assert!(!Widget::<Msg>::focusable(&c));
808        let prims = painted(&c, settled(true), &theme);
809        assert_eq!(the_box(&prims).1, disabled_content(&theme));
810        assert_eq!(mark(&prims).unwrap().1, disabled_mark(&theme));
811    }
812
813    #[test]
814    fn click_toggles() {
815        let c = Checkbox::<Msg>::new(false).on_toggle(Msg::Set);
816        assert_eq!(Widget::<Msg>::on_click(&c), Some(Msg::Set(true)));
817        let c = Checkbox::<Msg>::new(true).on_toggle(Msg::Set);
818        assert_eq!(Widget::<Msg>::on_click(&c), Some(Msg::Set(false)));
819    }
820
821    #[test]
822    fn a_tristate_box_cycles_through_the_third_answer() {
823        let next = |v: Option<bool>| {
824            Widget::<Msg>::on_click(&Checkbox::<Msg>::maybe(v).on_change(Msg::Maybe))
825        };
826        assert_eq!(next(Some(false)), Some(Msg::Maybe(Some(true))));
827        assert_eq!(next(Some(true)), Some(Msg::Maybe(None)));
828        assert_eq!(next(None), Some(Msg::Maybe(Some(false))));
829        // On the two-state callback, partly on reads as on.
830        let old = Checkbox::<Msg>::maybe(Some(true)).on_toggle(Msg::Set);
831        assert_eq!(Widget::<Msg>::on_click(&old), Some(Msg::Set(true)));
832    }
833
834    #[test]
835    fn partly_ticked_is_announced_as_mixed() {
836        let s = Widget::<Msg>::semantics(&Checkbox::<Msg>::maybe(None)).unwrap();
837        assert_eq!(s.toggled, frus_core::Toggled::Mixed);
838        let named =
839            Widget::<Msg>::semantics(&Checkbox::<Msg>::new(true).semantic_label("Agree")).unwrap();
840        assert_eq!(named.label.as_deref(), Some("Agree"));
841    }
842
843    /// **The caller outranks the theme, which outranks the reference.**
844    #[test]
845    fn the_theme_answers_and_the_instance_overrules_it() {
846        let mut theme = Theme::dark();
847        let green = Color::rgb8(0, 160, 80);
848        let blue = Color::rgb8(0, 0, 200);
849        theme.widgets.checkbox.fill_color = Some(green);
850        theme.widgets.checkbox.radius = Some(4.0);
851        let themed = the_box(&painted(&Checkbox::<Msg>::new(true), settled(true), &theme));
852        assert_eq!((themed.1, themed.4), (green, 4.0));
853        let mine = the_box(&painted(
854            &Checkbox::<Msg>::new(true).fill_color(blue).radius(1.0),
855            settled(true),
856            &theme,
857        ));
858        assert_eq!((mine.1, mine.4), (blue, 1.0));
859        // One outline colour covers rest and pointer alike.
860        let one = Checkbox::<Msg>::new(false).border_color(blue);
861        let hovered = Status {
862            interaction: crate::interaction::Interaction::Hovered,
863            ..settled(false)
864        };
865        assert_eq!(the_box(&painted(&one, hovered, &theme)).3, blue);
866    }
867
868    /// **The label follows the box's square** and takes its colour.
869    #[test]
870    fn the_label_follows_the_square() {
871        let theme = Theme::dark();
872        let c = Checkbox::<Msg>::new(false)
873            .label("Remember me")
874            .label_color(Color::rgb8(9, 9, 9));
875        let prims = painted(&c, settled(false), &theme);
876        let at = prims.iter().find_map(|p| match p {
877            Primitive::Text {
878                position, color, ..
879            } => Some((*position, *color)),
880            _ => None,
881        });
882        let (position, colour) = at.expect("a label");
883        assert_eq!(position.x, 48.0);
884        assert_eq!(colour, Color::rgb8(9, 9, 9));
885    }
886}