Skip to main content

frust_material/
checkbox.rs

1//! The M3(E) `Checkbox`: a controlled component supporting the binary
2//! (`Some(bool)`) and, via [`tristate_checkbox`], the tristate
3//! (`Option<bool>`, `None` = indeterminate) shapes, plus an `error` flavor —
4//! an 18dp box with a 40dp state-layer halo, animated container fill/border
5//! and check-mark draw.
6//!
7//! Ported from `material_3_expressive` v1.0.8's `M3ECheckbox`/
8//! `M3ECheckboxTheme` (MIT, © 2026 Paa Developments;
9//! `tmp/material_3_expressive/lib/components/checkbox/m3e_checkbox.dart`,
10//! `styles/m3e_checkbox_theme.dart`, retrieved 2026-08-19).
11//!
12//! # Naming: plain `Checkbox`, mirroring `switch`'s cross-crate precedent
13//!
14//! Baseline `frust-widgets` already ships a labelled `Checkbox`/`checkbox`
15//! (`frust::Checkbox`) — but a plugin crate's flat re-export lives in its
16//! *own* namespace (`frust_material::Checkbox`), never the facade's, so the
17//! two coexist without collision the same way [`super::switch::Switch`] is
18//! named plainly despite `frust::Switch` existing in spirit (baseline ships
19//! no `Switch` at all, but the crate's stated policy — this module doc's own
20//! decision record — is the same regardless: a design-system catalog names
21//! its widgets after the thing they are, not after avoiding a same-named
22//! symbol one import-path over). `MaterialCheckbox` was considered and
23//! rejected as needless stutter (`frust_material::MaterialCheckbox` reads no
24//! more clearly than `frust_material::Checkbox` at any real call site, which
25//! always qualifies through the crate path or a `use` alias already).
26//!
27//! # Controlled component, tap-cycle order
28//!
29//! [`checkbox`] (binary) and [`tristate_checkbox`] both produce a
30//! [`CheckboxView`] that reports the *requested* next value through
31//! `on_changed` and never self-mutates — the app mutates its state and the
32//! next `rebuild` feeds the confirmed value back in, exactly like
33//! [`super::switch::switch`]/[`frust::Checkbox`]. The release-inside-bounds
34//! cycle order is [`next_value`], pinned exactly against the reference's
35//! `M3ECheckbox._handleTap`: `Some(false) -> Some(true)`; `Some(true) ->`
36//! `tristate ? None : Some(false)`; `None -> Some(false)`.
37//!
38//! # No disabled state
39//!
40//! The reference's `fillColor`/`borderColor` theme methods carry an
41//! `enabled` branch (an `onSurface`-at-`disabledOpacity` fill/border). This
42//! port carries only the `enabled: true` semantics — mirroring every sibling
43//! controlled toggle in this crate ([`super::switch`], `filter_chip`,
44//! baseline `frust::Checkbox`), none of which model a disabled state yet —
45//! so [`resolve_colors`] has no `enabled` parameter and the Dart source's
46//! `disabledOpacity` token has no counterpart here.
47//!
48//! # Check-mark draw: a deliberate enhancement over the reference
49//!
50//! The reference wraps its box in an `AnimatedContainer` (`duration:`
51//! [`MaterialMotion::SHORT_3`], `curve:` [`MaterialMotion::STANDARD`]) that
52//! tweens the container's own fill/border — but the mark *inside* it
53//! (`_buildMark`'s `Icon`/dash) is an instant, un-animated widget swap;
54//! Flutter's implicit-animation machinery only tweens a `Container`'s own
55//! decoration properties, never a child-identity change. This port instead
56//! path-draws the check mark as a two-segment stroke ([`check_mark_path`])
57//! and grows the indeterminate dash from its center, both revealed over the
58//! *same* `0.0..=1.0` progress already driving the box's fill/border lerp —
59//! a single [`frust::AnimationController`] paces all of it, advanced during
60//! [`Widget::paint`] and re-requested via [`PaintCtx::request_frame`] while
61//! in flight, per `docs/CODE_STANDARDS.md`'s Theming & Animation
62//! Conventions. Unlike [`super::switch`]'s spring (which resolves from
63//! `Theme::motion` and so must wait for a themed paint pass to start), this
64//! crate's [`MaterialMotion`] duration/curve tokens are plain constants
65//! needing no theme — but the transition is still started lazily in `paint`
66//! (comparing the live `is_active` value against `anim_target`) rather than
67//! in `rebuild`, for consistency with that established pattern. A freshly
68//! built already-checked/indeterminate checkbox settles instantly with no
69//! mount-time fade-in (see [`View::build`]'s doc), matching the reference's
70//! own `ImplicitlyAnimatedWidget` semantics (a first build's implicit tween
71//! has equal begin/end endpoints).
72//!
73//! # State-layer halo: the interaction core's precedence resolver
74//!
75//! The 40dp halo uses [`InteractionState::resolve_opacity`] (`dragged >`
76//! `pressed > focused > hovered`) rather than [`super::state_layer::StateLayer`]'s
77//! max-of-active resolver — this crate's new documented default (see
78//! [`crate::interaction`]'s module docs) — with hover claimed from the
79//! widget's own uncaptured `Move` arm and press captured on `Down`, mirroring
80//! [`super::list_item`]'s reference implementation of the hover-claim
81//! contract.
82
83use std::rc::Rc;
84
85use frust::authoring::{
86    Action, BoxConstraints, BuildCtx, ChangeFlags, EventCtx, EventResult, InputEvent, LayoutCtx,
87    PaintCtx, PaintScene, PointerPhase, Role, SemanticsCtx, Toggled, View, Widget,
88};
89use frust::{AnimationController, FrameTime, Theme, Tween};
90use kurbo::{BezPath, Point, RoundedRect, Shape, Size};
91use peniko::{Brush, Color};
92
93use super::press::presses;
94use crate::interaction::InteractionState;
95use crate::tokens::MaterialMotion;
96
97/// The full interactive hit target, in logical px (`M3ECheckboxTheme.hitSize`)
98/// — independent of the smaller [`BOX`] visual box.
99const HIT_SIZE: f64 = 40.0;
100/// Side length of the visual box, in logical px (`M3ECheckboxTheme.boxSize`).
101const BOX: f64 = 18.0;
102/// The box's local top-left offset within the [`HIT_SIZE`] hit target (the
103/// box sits centered).
104const BOX_OFFSET: f64 = (HIT_SIZE - BOX) / 2.0;
105/// Diameter of the state-layer halo — the same [`HIT_SIZE`] as the hit
106/// target (`M3ECheckboxTheme.hitSize` doubles as both).
107const STATE_LAYER_SIZE: f64 = HIT_SIZE;
108/// Side length of the check-mark glyph within the box, in logical px
109/// (`M3ECheckboxTheme.markSize`).
110const MARK_SIZE: f64 = 16.0;
111/// The mark's local top-left offset within the hit target (centered inside
112/// the box, which is itself centered inside the hit target).
113const MARK_OFFSET: f64 = BOX_OFFSET + (BOX - MARK_SIZE) / 2.0;
114/// Width of the indeterminate dash, in logical px
115/// (`M3ECheckboxTheme.indeterminateWidth`).
116const INDETERMINATE_WIDTH: f64 = 10.0;
117/// Height of the indeterminate dash, in logical px
118/// (`M3ECheckboxTheme.indeterminateHeight`).
119const INDETERMINATE_HEIGHT: f64 = 2.0;
120/// Box border stroke width, in logical px (`M3ECheckboxTheme.borderWidth`).
121const BORDER_WIDTH: f64 = 2.0;
122/// Check-mark stroke width, in logical px — an authored value: the
123/// reference paints the mark as a filled `Icon` glyph, not a stroke, so
124/// there is no upstream stroke width to carry over (see the module docs'
125/// Check-mark draw section).
126const CHECK_STROKE_WIDTH: f64 = 2.0;
127/// Unthemed-fallback box corner radius (a theme resolves this from
128/// `shape.extra_small`; the reference's `borderRadius` resolves
129/// `M3EShapes.radiusExtraSmall`, the same 4dp token).
130const RADIUS: f64 = 4.0;
131/// `kurbo::Shape::to_path` flattening tolerance for the box's stroked border
132/// outline (see [`super::card`]'s identical precedent).
133const PATH_TOLERANCE: f64 = 0.1;
134
135/// Unthemed-fallback active fill/border (a theme resolves this from
136/// `colors.primary`) — the same placeholder blue [`super::switch::SwitchWidget`]'s
137/// `TRACK_ON` uses.
138const FILL_ACTIVE: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
139/// Unthemed-fallback mark color on a non-error active fill (a theme resolves
140/// this from `colors.onPrimary`).
141const ON_PRIMARY: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
142/// Unthemed-fallback inactive border (a theme resolves this from
143/// `colors.onSurfaceVariant`).
144const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x6B, 0x72, 0x80);
145/// Unthemed-fallback state-layer base when neither active nor error (a theme
146/// resolves this from `colors.onSurface`) — the same placeholder gray-900
147/// [`super::chips`]'s `CHIP_LABEL` (`colors.onSurface`) uses.
148const ON_SURFACE: Color = Color::from_rgb8(0x11, 0x18, 0x27);
149/// Unthemed-fallback error fill/border (a theme resolves this from
150/// `colors.error`).
151const ERROR: Color = Color::from_rgb8(0xEF, 0x44, 0x44);
152/// Unthemed-fallback mark color on an error fill (a theme resolves this from
153/// `colors.onError`).
154const ON_ERROR: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
155
156/// Whether the box/state-layer render in their "on" tone — checked, or
157/// indeterminate. Mirrors the reference's `active = value == null || checked`
158/// (`value == null` being this port's `None`).
159fn is_active(value: Option<bool>) -> bool {
160    !matches!(value, Some(false))
161}
162
163/// The next value a release-inside-bounds reports, given the current `value`
164/// and whether `tristate` is enabled. Ports `M3ECheckbox._handleTap`'s exact
165/// switch — see the module docs.
166fn next_value(value: Option<bool>, tristate: bool) -> Option<bool> {
167    match value {
168        Some(false) => Some(true),
169        Some(true) => {
170            if tristate {
171                None
172            } else {
173                Some(false)
174            }
175        }
176        None => Some(false),
177    }
178}
179
180/// The theme-resolved colors [`Widget::paint`] lerps/reads from — ports
181/// `M3ECheckboxTheme.fillColor`/`borderColor`/`markColor`/`stateLayerColor`
182/// (`enabled: true` only; see the module docs).
183struct ResolvedColors {
184    /// `error ? colors.error : colors.primary` — the box fill/border's
185    /// `progress == 1.0` end, and the state-layer base whenever `active` or
186    /// `error`.
187    active: Color,
188    /// `error ? colors.error : colors.onSurfaceVariant` — the box border's
189    /// `progress == 0.0` end.
190    border_inactive: Color,
191    /// `colors.onSurface` — the state-layer base when neither `active` nor
192    /// `error`.
193    on_surface: Color,
194    /// `error ? colors.onError : colors.onPrimary` — the check-mark/dash
195    /// color (untweened; the reference's `markColor` doesn't key on
196    /// `active` at all, only `error`).
197    mark: Color,
198}
199
200/// Resolve [`ResolvedColors`]. Themed: `colors.primary`/`error`/
201/// `onSurfaceVariant`/`onSurface`/`onPrimary`/`onError`. Unthemed: this
202/// module's own fallback constants exactly.
203fn resolve_colors(theme: Option<&Theme>, error: bool) -> ResolvedColors {
204    match theme {
205        Some(theme) => {
206            let s = theme.scheme();
207            ResolvedColors {
208                active: if error { s.error } else { s.primary },
209                border_inactive: if error { s.error } else { s.on_surface_variant },
210                on_surface: s.on_surface,
211                mark: if error { s.on_error } else { s.on_primary },
212            }
213        }
214        None => ResolvedColors {
215            active: if error { ERROR } else { FILL_ACTIVE },
216            border_inactive: if error { ERROR } else { ON_SURFACE_VARIANT },
217            on_surface: ON_SURFACE,
218            mark: if error { ON_ERROR } else { ON_PRIMARY },
219        },
220    }
221}
222
223/// The state-layer halo's base color: ports `M3ECheckboxTheme.stateLayerColor`
224/// exactly (`error` wins outright; otherwise `active ? primary : onSurface`).
225fn state_layer_base(colors: &ResolvedColors, active: bool, error: bool) -> Color {
226    if error || active {
227        colors.active
228    } else {
229        colors.on_surface
230    }
231}
232
233/// The box corner radius. Themed: `shape.extra_small`. Unthemed: [`RADIUS`]
234/// exactly.
235fn resolve_radius(theme: Option<&Theme>) -> f64 {
236    theme.map_or(RADIUS, |t| t.shape.extra_small)
237}
238
239/// Interpolate from `begin` to `end` at `t`, snapping exactly to an endpoint
240/// when `t` is at (or past) `0.0`/`1.0` — see [`super::switch`]'s identical
241/// helper for why this matters (an at-rest box must paint pixel-identical to
242/// its resting color, never a few-ULPs-off `Tween::lerp` result).
243fn lerp_color_exact(begin: Color, end: Color, t: f64) -> Color {
244    if t <= 0.0 {
245        begin
246    } else if t >= 1.0 {
247        end
248    } else {
249        Tween::new(begin, end).lerp(t)
250    }
251}
252
253/// Return `color` with its alpha channel replaced by `alpha`.
254fn with_alpha(color: Color, alpha: f32) -> Color {
255    let c = color.components;
256    Color::new([c[0], c[1], c[2], alpha])
257}
258
259/// Paint the state-layer halo: a `diameter`-wide filled circle centered at
260/// `center`, in `base_color` at `state`'s
261/// [precedence-resolved](InteractionState::resolve_opacity) opacity. A
262/// no-op when no interaction state is active — see the module docs for why
263/// this uses the precedence resolver rather than
264/// [`super::state_layer::StateLayer`]'s max-of-active one.
265fn paint_state_layer(
266    scene: &mut dyn PaintScene,
267    center: Point,
268    diameter: f64,
269    state: InteractionState,
270    base_color: Color,
271) {
272    let opacity = state.resolve_opacity();
273    if opacity <= 0.0 {
274        return;
275    }
276    let r = diameter / 2.0;
277    scene.fill_rounded_rect(
278        Point::new(center.x - r, center.y - r),
279        Size::new(diameter, diameter),
280        r,
281        with_alpha(base_color, opacity),
282    );
283}
284
285/// The check mark's two-segment polyline within a `mark_size`×`mark_size`
286/// box anchored at local `origin` — proportions mirror the baseline
287/// `frust::Checkbox`'s hand-authored check mark
288/// (`crates/frust-widgets/src/checkbox.rs`), rescaled from its 20dp box to
289/// this component's 16dp `markSize`. `progress` (`0.0..=1.0`) reveals the
290/// stroke from its first point onward: `<= 0.0` yields an empty path, `1.0`
291/// the complete two-segment mark. See the module docs' Check-mark draw
292/// section for why this animated reveal is a deliberate enhancement over
293/// the reference's instant, un-animated mark swap.
294fn check_mark_path(origin: Point, mark_size: f64, progress: f64) -> BezPath {
295    let mut path = BezPath::new();
296    if progress <= 0.0 {
297        return path;
298    }
299    let p0 = Point::new(origin.x + mark_size * 0.24, origin.y + mark_size * 0.52);
300    let p1 = Point::new(origin.x + mark_size * 0.42, origin.y + mark_size * 0.70);
301    let p2 = Point::new(origin.x + mark_size * 0.76, origin.y + mark_size * 0.30);
302    let l1 = p0.distance(p1);
303    let l2 = p1.distance(p2);
304    let total = l1 + l2;
305    if total <= 0.0 {
306        return path;
307    }
308    path.move_to(p0);
309    let drawn = progress.min(1.0) * total;
310    if drawn <= l1 {
311        let t = if l1 > 0.0 { drawn / l1 } else { 1.0 };
312        path.line_to(p0.lerp(p1, t));
313    } else {
314        path.line_to(p1);
315        let t = if l2 > 0.0 {
316            ((drawn - l1) / l2).min(1.0)
317        } else {
318            1.0
319        };
320        path.line_to(p1.lerp(p2, t));
321    }
322    path
323}
324
325/// Whether local point `pos` (relative to the widget's own origin) lies
326/// inside `size`.
327fn inside(pos: Point, size: Size) -> bool {
328    pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
329}
330
331/// A view-held, typed change callback (erased on build) — always
332/// `Option<bool>` internally; [`checkbox`]'s binary wrapper unwraps it back
333/// to `bool` for its own callback signature (`None` never reaches a
334/// non-tristate caller — see [`next_value`]).
335type OnChanged<State> = Rc<dyn Fn(&mut State, Option<bool>)>;
336
337/// A declarative M3(E) checkbox. See the [module docs](self).
338pub struct CheckboxView<State: 'static> {
339    value: Option<bool>,
340    tristate: bool,
341    error: bool,
342    on_changed: OnChanged<State>,
343}
344
345/// Create a binary checkbox reflecting `checked`, that fires
346/// `on_changed(state, !checked)` on release inside its bounds. Mirrors
347/// [`super::switch::switch`]/[`frust::Checkbox`]: a controlled component
348/// that never flips its own value.
349pub fn checkbox<State: 'static, F: Fn(&mut State, bool) + 'static>(
350    checked: bool,
351    on_changed: F,
352) -> CheckboxView<State> {
353    CheckboxView {
354        value: Some(checked),
355        tristate: false,
356        error: false,
357        on_changed: Rc::new(move |state, next| on_changed(state, next.unwrap_or(false))),
358    }
359}
360
361/// PascalCase alias for [`checkbox`].
362#[allow(non_snake_case)]
363pub fn Checkbox<State: 'static, F: Fn(&mut State, bool) + 'static>(
364    checked: bool,
365    on_changed: F,
366) -> CheckboxView<State> {
367    checkbox(checked, on_changed)
368}
369
370/// Create a tristate checkbox reflecting `value` (`None` = indeterminate,
371/// rendered as a dash), that fires `on_changed` with the reference's exact
372/// tap-cycle order on release inside its bounds — see [`next_value`].
373pub fn tristate_checkbox<State: 'static, F: Fn(&mut State, Option<bool>) + 'static>(
374    value: Option<bool>,
375    on_changed: F,
376) -> CheckboxView<State> {
377    CheckboxView {
378        value,
379        tristate: true,
380        error: false,
381        on_changed: Rc::new(on_changed),
382    }
383}
384
385impl<State: 'static> CheckboxView<State> {
386    /// Recolor the box/border/mark into the error flavor (`colors.error`/
387    /// `colors.onError` in place of `colors.primary`/`colors.onPrimary`) —
388    /// mirrors the reference's `M3ECheckbox.error` flag
389    /// (`m3e_checkbox_theme.dart`'s `fillColor`/`borderColor`/`markColor`/
390    /// `stateLayerColor`, each keying on `error` first).
391    pub fn error(mut self, error: bool) -> Self {
392        self.error = error;
393        self
394    }
395}
396
397/// The retained widget for a [`CheckboxView`].
398pub struct CheckboxWidget {
399    /// The app-confirmed value (source of truth; adopted on `rebuild`).
400    /// `None` is the indeterminate state (only reachable when `tristate`).
401    value: Option<bool>,
402    tristate: bool,
403    error: bool,
404    /// Drives the box/mark `0.0` (inactive) .. `1.0` (active) reveal
405    /// fraction. See the module docs' Check-mark draw section.
406    anim: AnimationController,
407    /// The [`is_active`] value the animation is currently driving toward (or
408    /// has already settled at) — compared against the live value at paint
409    /// time to decide whether a fresh transition needs to start (mirrors
410    /// [`super::switch::SwitchWidget::anim_target`]).
411    anim_target: bool,
412    interaction: InteractionState,
413    /// The pressed *visual* state; follows the cursor in/out while captured.
414    pressed: bool,
415    /// Armed by a `Down` (alongside `capture_pointer`), cleared on `Up`/`Cancel`.
416    captured: bool,
417    on_changed: frust::authoring::ErasedArgCallback<Option<bool>>,
418}
419
420/// Build a duration+curve [`AnimationController`] already **settled** at
421/// `1.0` if `active`, `0.0` otherwise — no mount-time fade-in (see the
422/// module docs). A single `animate_to` call can't itself snap a non-zero-duration
423/// controller (only a zero-duration one settles instantly — see
424/// `AnimationController::animate_to`'s doc), and a lone `advance` call right
425/// after can't either: the first `advance` after starting a motion always
426/// reports a zero delta, since it only seeds the clock (see
427/// `AnimationController::advance`'s doc). Two throwaway `advance` calls
428/// settle it correctly instead: the first seeds `last_time`, the second —
429/// far enough ahead — reports an elapsed exceeding the duration.
430fn seeded_anim(active: bool) -> AnimationController {
431    let mut anim =
432        AnimationController::new(MaterialMotion::SHORT_3).with_curve(MaterialMotion::STANDARD);
433    if active {
434        anim.animate_to(1.0);
435        anim.advance(FrameTime::ZERO);
436        anim.advance(FrameTime::from_nanos(
437            MaterialMotion::SHORT_3.as_nanos() as u64 * 2,
438        ));
439    }
440    anim
441}
442
443impl<State: 'static> View<State> for CheckboxView<State> {
444    type Element = CheckboxWidget;
445
446    fn build(&self, _ctx: &mut BuildCtx<'_>) -> CheckboxWidget {
447        let active = is_active(self.value);
448        CheckboxWidget {
449            value: self.value,
450            tristate: self.tristate,
451            error: self.error,
452            anim: seeded_anim(active),
453            anim_target: active,
454            interaction: InteractionState::new(),
455            pressed: false,
456            captured: false,
457            on_changed: frust::authoring::erase_callback_arg(&self.on_changed),
458        }
459    }
460
461    fn rebuild(
462        &self,
463        prev: &Self,
464        element: &mut CheckboxWidget,
465        _ctx: &mut BuildCtx<'_>,
466    ) -> ChangeFlags {
467        element.on_changed = frust::authoring::erase_callback_arg(&self.on_changed);
468        let mut flags = ChangeFlags::NONE;
469        if prev.value != self.value || prev.tristate != self.tristate || prev.error != self.error {
470            // The app is the source of truth: adopt the new value on
471            // rebuild. The transition itself starts lazily in `paint` (see
472            // the module docs' Check-mark draw section).
473            element.value = self.value;
474            element.tristate = self.tristate;
475            element.error = self.error;
476            flags |= ChangeFlags::PAINT;
477        }
478        flags
479    }
480}
481
482impl Widget for CheckboxWidget {
483    fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
484        bc.constrain(Size::new(HIT_SIZE, HIT_SIZE))
485    }
486
487    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
488        // Resolved before the animation advance/request_frame block below —
489        // `theme` borrows `ctx` immutably, so every read through it must
490        // finish before `ctx.request_frame()`'s mutable borrow (mirrors
491        // `switch.rs`'s identical ordering).
492        let theme = Theme::from_paint_ctx(ctx);
493        let colors = resolve_colors(theme, self.error);
494        let radius = resolve_radius(theme);
495
496        let active = is_active(self.value);
497        if active != self.anim_target {
498            self.anim.animate_to(if active { 1.0 } else { 0.0 });
499            self.anim_target = active;
500        }
501        if self.anim.advance(ctx.frame_time()) {
502            ctx.request_frame();
503        }
504        let progress = self.anim.value_clamped();
505
506        // The pod's hover link is authoritative; re-synced every paint (see
507        // `list_item`'s identical pattern, cited in the module docs).
508        self.interaction.set_hovered(ctx.is_hovered());
509
510        let o = ctx.origin();
511        let center = Point::new(o.x + HIT_SIZE / 2.0, o.y + HIT_SIZE / 2.0);
512        let layer_base = state_layer_base(&colors, active, self.error);
513        paint_state_layer(
514            scene,
515            center,
516            STATE_LAYER_SIZE,
517            self.interaction,
518            layer_base,
519        );
520
521        let box_abs = Point::new(o.x + BOX_OFFSET, o.y + BOX_OFFSET);
522        let fill = lerp_color_exact(Color::TRANSPARENT, colors.active, progress);
523        scene.fill_rounded_rect(box_abs, Size::new(BOX, BOX), radius, fill);
524
525        let half = BORDER_WIDTH / 2.0;
526        let border = lerp_color_exact(colors.border_inactive, colors.active, progress);
527        let border_rect = RoundedRect::new(
528            BOX_OFFSET + half,
529            BOX_OFFSET + half,
530            BOX_OFFSET + BOX - half,
531            BOX_OFFSET + BOX - half,
532            (radius - half).max(0.0),
533        );
534        scene.stroke_path(
535            o,
536            &border_rect.to_path(PATH_TOLERANCE),
537            BORDER_WIDTH,
538            &Brush::Solid(border),
539        );
540
541        if progress > 0.0 {
542            match self.value {
543                Some(true) => {
544                    let mark_origin = Point::new(MARK_OFFSET, MARK_OFFSET);
545                    let path = check_mark_path(mark_origin, MARK_SIZE, progress);
546                    scene.stroke_path(o, &path, CHECK_STROKE_WIDTH, &Brush::Solid(colors.mark));
547                }
548                None if self.tristate => {
549                    let w = INDETERMINATE_WIDTH * progress.min(1.0);
550                    let rect_origin = Point::new(
551                        o.x + BOX_OFFSET + (BOX - w) / 2.0,
552                        o.y + BOX_OFFSET + (BOX - INDETERMINATE_HEIGHT) / 2.0,
553                    );
554                    scene.fill_rect(rect_origin, Size::new(w, INDETERMINATE_HEIGHT), colors.mark);
555                }
556                _ => {}
557            }
558        }
559    }
560
561    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
562        let InputEvent::Pointer(p) = event else {
563            return EventResult::Ignored;
564        };
565        match p.phase {
566            PointerPhase::Down => {
567                if !presses(p) {
568                    return EventResult::Ignored;
569                }
570                self.pressed = true;
571                self.captured = true;
572                self.interaction.set_pressed(true);
573                ctx.capture_pointer();
574                ctx.request_redraw();
575                EventResult::Handled
576            }
577            PointerPhase::Move => {
578                if !self.captured {
579                    // No capture: this is the hover pass — claim whenever
580                    // the pointer is inside, mirroring `list_item`'s
581                    // reference hover-claim implementation (see the module
582                    // docs).
583                    let over = inside(p.position, ctx.size());
584                    if over {
585                        ctx.claim_hover();
586                    }
587                    if self.interaction.set_hovered(over) {
588                        ctx.request_redraw();
589                    }
590                    return EventResult::Ignored;
591                }
592                let inside_now = inside(p.position, ctx.size());
593                self.pressed = inside_now;
594                self.interaction.set_pressed(inside_now);
595                ctx.request_redraw();
596                EventResult::Handled
597            }
598            PointerPhase::Up => {
599                if !self.captured {
600                    return EventResult::Ignored;
601                }
602                if inside(p.position, ctx.size()) {
603                    // Report the *requested* value; never self-toggle.
604                    let next = next_value(self.value, self.tristate);
605                    (self.on_changed)(ctx, next);
606                }
607                self.pressed = false;
608                self.captured = false;
609                self.interaction.set_pressed(false);
610                ctx.request_redraw();
611                EventResult::Handled
612            }
613            PointerPhase::Cancel => {
614                if !self.captured {
615                    return EventResult::Ignored;
616                }
617                self.pressed = false;
618                self.captured = false;
619                self.interaction.set_pressed(false);
620                ctx.request_redraw();
621                EventResult::Handled
622            }
623        }
624    }
625
626    fn semantics(&self, ctx: &mut SemanticsCtx) {
627        // A single CheckBox node; `Toggled::Mixed` reports the
628        // indeterminate state (mirrors baseline `frust::Checkbox`'s
629        // Role::CheckBox + Toggled convention, extended with the tristate
630        // third value accesskit's own `Toggled` enum already carries).
631        ctx.push_node(Role::CheckBox, |node| {
632            node.set_toggled(match self.value {
633                Some(true) => Toggled::True,
634                Some(false) => Toggled::False,
635                None => Toggled::Mixed,
636            });
637            node.add_action(Action::Click);
638        });
639    }
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645    use std::any::Any;
646
647    // ---- Pure logic: is_active / next_value ------------------------------
648
649    #[test]
650    fn is_active_matches_reference_active_formula() {
651        assert!(!is_active(Some(false)));
652        assert!(is_active(Some(true)));
653        assert!(is_active(None), "indeterminate is active");
654    }
655
656    #[test]
657    fn next_value_binary_cycle_matches_reference_handle_tap() {
658        assert_eq!(next_value(Some(false), false), Some(true));
659        assert_eq!(next_value(Some(true), false), Some(false));
660    }
661
662    #[test]
663    fn next_value_tristate_cycle_matches_reference_handle_tap() {
664        assert_eq!(next_value(Some(false), true), Some(true));
665        assert_eq!(next_value(Some(true), true), None);
666        assert_eq!(next_value(None, true), Some(false));
667    }
668
669    // ---- check_mark_path ---------------------------------------------------
670
671    #[test]
672    fn check_mark_path_is_empty_at_zero_progress() {
673        let path = check_mark_path(Point::ZERO, MARK_SIZE, 0.0);
674        assert!(path.elements().is_empty());
675    }
676
677    #[test]
678    fn check_mark_path_has_two_segments_at_full_progress() {
679        let path = check_mark_path(Point::ZERO, MARK_SIZE, 1.0);
680        // MoveTo + two LineTo.
681        assert_eq!(path.elements().len(), 3);
682    }
683
684    #[test]
685    fn check_mark_path_partial_progress_stops_along_the_first_segment() {
686        let path = check_mark_path(Point::ZERO, MARK_SIZE, 0.1);
687        // A very small progress hasn't reached the first vertex yet: one
688        // MoveTo + one partial LineTo, not the full two-segment path.
689        assert_eq!(path.elements().len(), 2);
690    }
691
692    // ---- View/Widget plumbing ---------------------------------------------
693
694    #[derive(Default)]
695    struct ChangeState {
696        last: Option<Option<bool>>,
697        changes: u32,
698    }
699
700    fn binary_widget(checked: bool) -> CheckboxWidget {
701        let view = checkbox::<ChangeState, _>(checked, |s: &mut ChangeState, v: bool| {
702            s.last = Some(Some(v));
703            s.changes += 1;
704        });
705        let mut counter = 0u64;
706        View::<ChangeState>::build(&view, &mut BuildCtx::new(&mut counter))
707    }
708
709    fn tristate_widget(value: Option<bool>) -> CheckboxWidget {
710        let view =
711            tristate_checkbox::<ChangeState, _>(value, |s: &mut ChangeState, v: Option<bool>| {
712                s.last = Some(v);
713                s.changes += 1;
714            });
715        let mut counter = 0u64;
716        View::<ChangeState>::build(&view, &mut BuildCtx::new(&mut counter))
717    }
718
719    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
720        InputEvent::Pointer(frust::authoring::PointerEvent {
721            phase,
722            position: Point::new(x, y),
723            button: frust::authoring::PointerButton::Primary,
724        })
725    }
726
727    fn dispatch(w: &mut CheckboxWidget, state: &mut ChangeState, event: &InputEvent) {
728        let state_any: &mut dyn Any = state;
729        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE));
730        w.event(&mut ctx, event);
731    }
732
733    #[test]
734    fn layout_is_always_the_forty_dp_hit_target_regardless_of_the_box() {
735        // HIT_SIZE (40dp) > BOX (18dp) is a compile-time fact of these two
736        // constants, asserted structurally here rather than at runtime: the
737        // hit target really is layout()'s returned size, strictly larger
738        // than the 18dp visual box painted inside it.
739        let mut w = binary_widget(false);
740        let mut lctx = LayoutCtx::new();
741        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
742        assert_eq!(size, Size::new(HIT_SIZE, HIT_SIZE));
743        assert!(size.width >= 40.0 && size.height >= 40.0);
744        assert!(size.width > BOX && size.height > BOX);
745    }
746
747    #[test]
748    fn binary_unchecked_fires_true_and_does_not_self_mutate() {
749        let mut w = binary_widget(false);
750        let mut state = ChangeState::default();
751        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 20.0, 20.0));
752        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 20.0, 20.0));
753        assert_eq!(state.last, Some(Some(true)));
754        assert_eq!(state.changes, 1);
755        assert_eq!(w.value, Some(false), "must not mutate its own value");
756    }
757
758    #[test]
759    fn binary_checked_fires_false() {
760        let mut w = binary_widget(true);
761        let mut state = ChangeState::default();
762        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 20.0, 20.0));
763        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 20.0, 20.0));
764        assert_eq!(state.last, Some(Some(false)));
765    }
766
767    #[test]
768    fn tristate_checked_fires_indeterminate() {
769        let mut w = tristate_widget(Some(true));
770        let mut state = ChangeState::default();
771        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 20.0, 20.0));
772        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 20.0, 20.0));
773        assert_eq!(state.last, Some(None));
774    }
775
776    #[test]
777    fn tristate_indeterminate_fires_false() {
778        let mut w = tristate_widget(None);
779        let mut state = ChangeState::default();
780        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 20.0, 20.0));
781        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 20.0, 20.0));
782        assert_eq!(state.last, Some(Some(false)));
783    }
784
785    #[test]
786    fn up_outside_does_not_fire() {
787        let mut w = binary_widget(false);
788        let mut state = ChangeState::default();
789        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 20.0, 20.0));
790        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 20.0));
791        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 20.0));
792        assert_eq!(state.changes, 0);
793    }
794
795    #[test]
796    fn hover_move_without_down_is_ignored_noop() {
797        let mut w = binary_widget(false);
798        let mut state = ChangeState::default();
799        let state_any: &mut dyn Any = &mut state;
800        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE));
801        let result = w.event(&mut ctx, &ev(PointerPhase::Move, 20.0, 20.0));
802        assert!(matches!(result, EventResult::Ignored));
803        assert!(!w.pressed);
804        assert_eq!(state.changes, 0);
805    }
806
807    #[test]
808    fn hover_move_inside_claims_hover_and_requests_redraw_once() {
809        let mut w = binary_widget(false);
810        let mut state = ChangeState::default();
811        let state_any: &mut dyn Any = &mut state;
812        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE));
813        w.event(&mut ctx, &ev(PointerPhase::Move, 20.0, 20.0));
814        assert!(ctx.needs_redraw(), "hover gain must request a redraw");
815        assert!(w.interaction.hovered);
816    }
817
818    #[test]
819    fn cancel_clears_armed_state() {
820        let mut w = binary_widget(false);
821        let mut state = ChangeState::default();
822        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 20.0, 20.0));
823        assert!(w.captured);
824        dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 20.0, 20.0));
825        assert!(!w.captured);
826        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 20.0, 20.0));
827        assert_eq!(state.changes, 0);
828    }
829
830    #[test]
831    fn rebuild_adopts_new_value_without_self_mutation() {
832        let mut counter = 0u64;
833        let prev = checkbox::<ChangeState, _>(false, |_s, _v| {});
834        let mut w = View::<ChangeState>::build(&prev, &mut BuildCtx::new(&mut counter));
835        assert_eq!(w.value, Some(false));
836        let next = checkbox::<ChangeState, _>(true, |_s, _v| {});
837        let flags =
838            View::<ChangeState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
839        assert_eq!(
840            w.value,
841            Some(true),
842            "the confirmed value adopts immediately"
843        );
844        assert!(
845            !w.anim_target,
846            "the animation target only updates lazily in paint, once this frame's active value is read"
847        );
848        assert!(flags.needs_paint());
849    }
850
851    // ---- Animation seeding / pacing ---------------------------------------
852
853    #[test]
854    fn build_already_checked_settles_instantly_with_no_mount_fade_in() {
855        let w = binary_widget(true);
856        assert!(!w.anim.is_animating());
857        assert_eq!(w.anim.value_clamped(), 1.0);
858        assert!(w.anim_target);
859    }
860
861    #[test]
862    fn build_unchecked_starts_settled_at_zero() {
863        let w = binary_widget(false);
864        assert!(!w.anim.is_animating());
865        assert_eq!(w.anim.value_clamped(), 0.0);
866        assert!(!w.anim_target);
867    }
868
869    #[test]
870    fn paint_starts_a_real_transition_when_the_value_disagrees_with_anim_target() {
871        let mut w = binary_widget(false);
872        w.value = Some(true); // simulate the confirmed value a rebuild adopts
873        assert!(!w.anim.is_animating());
874        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE));
875        let mut rec = RRectRecorder::default();
876        w.paint(&mut ctx, &mut rec);
877        assert!(w.anim_target, "paint syncs the animation target");
878        assert!(w.anim.is_animating(), "a real transition started");
879        assert!(
880            ctx.needs_frame(),
881            "an in-flight transition needs another frame"
882        );
883    }
884
885    // ---- Paint: colors per state ------------------------------------------
886
887    /// Records each rounded rect's `(origin, size, radius, color)` and each
888    /// stroked path's `(width, color)`, mirroring `switch.rs`'s
889    /// `RRectRecorder` pattern.
890    #[derive(Default)]
891    struct RRectRecorder {
892        rrects: Vec<(Point, Size, f64, Color)>,
893        strokes: Vec<(f64, Color)>,
894        fills: Vec<(Point, Size, Color)>,
895    }
896
897    impl PaintScene for RRectRecorder {
898        fn fill_rect(&mut self, o: Point, s: Size, c: Color) {
899            self.fills.push((o, s, c));
900        }
901        fn draw_text(&mut self, _o: Point, _t: &str) {}
902        fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
903            self.rrects.push((o, s, radius, color));
904        }
905        fn stroke_path(&mut self, _o: Point, _p: &BezPath, width: f64, brush: &Brush) {
906            let Brush::Solid(color) = brush else {
907                return;
908            };
909            self.strokes.push((width, *color));
910        }
911    }
912
913    fn paint_rec(w: &mut CheckboxWidget, theme: Option<&Theme>) -> RRectRecorder {
914        let mut rec = RRectRecorder::default();
915        let mut ctx = match theme {
916            Some(t) => PaintCtx::new(Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE)).with_theme(t),
917            None => PaintCtx::new(Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE)),
918        };
919        w.paint(&mut ctx, &mut rec);
920        rec
921    }
922
923    /// The box fill is always the second rounded rect (the state layer, when
924    /// active, is the first — but at rest with no interaction it's absent).
925    fn box_fill(rec: &RRectRecorder) -> Color {
926        rec.rrects.last().expect("box fill painted").3
927    }
928
929    fn border_color(rec: &RRectRecorder) -> Color {
930        rec.strokes.first().expect("border stroked").1
931    }
932
933    #[test]
934    fn unthemed_unchecked_paints_transparent_fill_and_outline_variant_border() {
935        let mut w = binary_widget(false);
936        let rec = paint_rec(&mut w, None);
937        assert_eq!(box_fill(&rec), Color::TRANSPARENT);
938        assert_eq!(border_color(&rec), ON_SURFACE_VARIANT);
939    }
940
941    #[test]
942    fn unthemed_checked_paints_active_fill_and_border() {
943        let mut w = binary_widget(true);
944        let rec = paint_rec(&mut w, None);
945        assert_eq!(box_fill(&rec), FILL_ACTIVE);
946        assert_eq!(border_color(&rec), FILL_ACTIVE);
947        // Two strokes: border + check mark.
948        assert_eq!(rec.strokes.len(), 2);
949        assert_eq!(rec.strokes[1].1, ON_PRIMARY);
950    }
951
952    #[test]
953    fn unthemed_error_recolors_fill_border_and_mark_regardless_of_active() {
954        let mut counter = 0u64;
955
956        let error_off = checkbox::<ChangeState, _>(false, |_s, _v| {}).error(true);
957        let mut w = View::<ChangeState>::build(&error_off, &mut BuildCtx::new(&mut counter));
958        let rec = paint_rec(&mut w, None);
959        assert_eq!(
960            box_fill(&rec),
961            Color::TRANSPARENT,
962            "inactive fill stays transparent even in error"
963        );
964        assert_eq!(border_color(&rec), ERROR);
965
966        let error_on = checkbox::<ChangeState, _>(true, |_s, _v| {}).error(true);
967        let mut w = View::<ChangeState>::build(&error_on, &mut BuildCtx::new(&mut counter));
968        let rec = paint_rec(&mut w, None);
969        assert_eq!(box_fill(&rec), ERROR);
970        assert_eq!(border_color(&rec), ERROR);
971        assert_eq!(rec.strokes[1].1, ON_ERROR);
972    }
973
974    #[test]
975    fn unthemed_indeterminate_paints_active_fill_and_a_centered_dash() {
976        let mut w = tristate_widget(None);
977        let rec = paint_rec(&mut w, None);
978        assert_eq!(box_fill(&rec), FILL_ACTIVE);
979        // Border stroke only (no check-mark stroke for the dash — it's a
980        // filled rect).
981        assert_eq!(rec.strokes.len(), 1);
982        let (origin, size, color) = rec.fills.first().expect("dash painted");
983        assert_eq!(*size, Size::new(INDETERMINATE_WIDTH, INDETERMINATE_HEIGHT));
984        assert_eq!(*color, ON_PRIMARY);
985        let expected_x = BOX_OFFSET + (BOX - INDETERMINATE_WIDTH) / 2.0;
986        assert!((origin.x - expected_x).abs() < 1e-9);
987    }
988
989    #[test]
990    fn themed_paint_resolves_color_scheme_roles() {
991        let theme = crate::baseline();
992        let scheme = theme.scheme();
993
994        let mut off = binary_widget(false);
995        let rec = paint_rec(&mut off, Some(&theme));
996        assert_eq!(box_fill(&rec), Color::TRANSPARENT);
997        assert_eq!(border_color(&rec), scheme.on_surface_variant);
998
999        let mut on = binary_widget(true);
1000        let rec = paint_rec(&mut on, Some(&theme));
1001        assert_eq!(box_fill(&rec), scheme.primary);
1002        assert_eq!(border_color(&rec), scheme.primary);
1003        assert_eq!(rec.strokes[1].1, scheme.on_primary);
1004    }
1005
1006    #[test]
1007    fn themed_paint_resolves_error_roles() {
1008        let theme = crate::baseline();
1009        let scheme = theme.scheme();
1010        let view = checkbox::<ChangeState, _>(true, |_s, _v| {}).error(true);
1011        let mut counter = 0u64;
1012        let mut w = View::<ChangeState>::build(&view, &mut BuildCtx::new(&mut counter));
1013        let rec = paint_rec(&mut w, Some(&theme));
1014        assert_eq!(box_fill(&rec), scheme.error);
1015        assert_eq!(border_color(&rec), scheme.error);
1016        assert_eq!(rec.strokes[1].1, scheme.on_error);
1017    }
1018
1019    // ---- Semantics ----------------------------------------------------------
1020
1021    #[test]
1022    fn semantics_reports_checkbox_role_and_toggled_state() {
1023        fn logic(_s: &mut ChangeState) -> CheckboxView<ChangeState> {
1024            checkbox::<ChangeState, _>(true, |_s, _v| {})
1025        }
1026        let mut root: frust_core::RenderRoot<ChangeState, CheckboxView<ChangeState>> =
1027            frust_core::RenderRoot::new();
1028        let mut state = ChangeState::default();
1029        root.rebuild(&mut logic, &mut state);
1030        root.layout(Size::new(200.0, 200.0));
1031        let update = root.semantics();
1032        let (_, node) = update
1033            .nodes
1034            .iter()
1035            .find(|(_, n)| n.role() == Role::CheckBox)
1036            .expect("checkbox contributes a Role::CheckBox node");
1037        assert_eq!(node.toggled(), Some(Toggled::True));
1038        assert!(node.supports_action(Action::Click));
1039    }
1040
1041    #[test]
1042    fn semantics_reports_mixed_toggled_for_indeterminate() {
1043        fn logic(_s: &mut ChangeState) -> CheckboxView<ChangeState> {
1044            tristate_checkbox::<ChangeState, _>(None, |_s, _v| {})
1045        }
1046        let mut root: frust_core::RenderRoot<ChangeState, CheckboxView<ChangeState>> =
1047            frust_core::RenderRoot::new();
1048        let mut state = ChangeState::default();
1049        root.rebuild(&mut logic, &mut state);
1050        root.layout(Size::new(200.0, 200.0));
1051        let update = root.semantics();
1052        let (_, node) = update
1053            .nodes
1054            .iter()
1055            .find(|(_, n)| n.role() == Role::CheckBox)
1056            .expect("checkbox contributes a Role::CheckBox node");
1057        assert_eq!(node.toggled(), Some(Toggled::Mixed));
1058    }
1059}