Skip to main content

tessera_components/
checkbox.rs

1//! A customizable, animated checkbox component.
2//!
3//! ## Usage
4//!
5//! Use in forms, settings, or lists to enable boolean selections.
6use tessera_foundation::gesture::TapRecognizer;
7use tessera_ui::{
8    AccessibilityActionHandler, AccessibilityNode, CallbackWith, Color, Dp, FocusRequester,
9    Modifier, PxSize, RenderSlot, SemanticsModifierNode, State,
10    accesskit::{Role, Toggled},
11    current_frame_nanos,
12    modifier::ModifierCapabilityExt as _,
13    receive_frame_nanos, remember, tessera, use_context,
14};
15
16use crate::{
17    alignment::Alignment,
18    boxed::boxed,
19    checkmark::checkmark,
20    modifier::{
21        DisabledToggleSemantics, InteractionState, InteractionVisualState, ModifierExt,
22        PointerEventContext, ToggleableArgs,
23    },
24    ripple_state::{RippleSpec, RippleState},
25    shape_def::{RoundedCorner, Shape},
26    surface::{SurfaceStyle, surface},
27    theme::{MaterialAlpha, MaterialColorScheme, MaterialTheme},
28};
29
30use motion::{Colors, ColorsMotion, Scalar, Spec};
31
32mod motion;
33
34#[cfg(all(test, not(target_family = "wasm")))]
35mod accessibility_tests;
36
37#[cfg(all(test, not(target_family = "wasm")))]
38mod appearance_tests;
39
40#[cfg(all(test, not(target_family = "wasm")))]
41mod motion_tests;
42
43#[cfg(all(test, not(target_family = "wasm")))]
44mod state_tests;
45
46struct CheckboxValueSemantics {
47    toggled: Toggled,
48    is_error: bool,
49}
50
51impl SemanticsModifierNode for CheckboxValueSemantics {
52    fn apply(&self, node: &mut AccessibilityNode, _: &mut Option<AccessibilityActionHandler>) {
53        node.toggled = Some(self.toggled);
54        if self.is_error {
55            node.invalid = Some(tessera_ui::accesskit::Invalid::True);
56        }
57    }
58}
59
60/// Material Design 3 defaults for [`checkbox`].
61pub struct CheckboxDefaults;
62
63impl CheckboxDefaults {
64    /// Visual checkbox glyph size (not including touch target).
65    pub const GLYPH_SIZE: Dp = Dp(18.0);
66    /// State-layer size used for hover/press feedback.
67    pub const STATE_LAYER_SIZE: Dp = Dp(40.0);
68    /// Minimum recommended touch target size.
69    pub const TOUCH_TARGET_SIZE: Dp = Dp(48.0);
70
71    fn default_shape() -> Shape {
72        Shape::RoundedRectangle {
73            top_left: RoundedCorner::manual(Dp(2.0), 2.0),
74            top_right: RoundedCorner::manual(Dp(2.0), 2.0),
75            bottom_right: RoundedCorner::manual(Dp(2.0), 2.0),
76            bottom_left: RoundedCorner::manual(Dp(2.0), 2.0),
77        }
78    }
79
80    /// Computes the default state-layer base color for the current checked
81    /// state.
82    pub fn state_layer_base_color(
83        is_checked: bool,
84        checked_color: Color,
85        scheme: &MaterialColorScheme,
86    ) -> Color {
87        if is_checked {
88            checked_color
89        } else {
90            scheme.on_surface
91        }
92    }
93}
94
95/// Controller for [`checkbox`] state.
96#[derive(Clone, PartialEq, Default)]
97pub struct CheckboxController {
98    checkmark: CheckmarkState,
99}
100
101impl CheckboxController {
102    /// Creates a new controller with the provided initial checked state.
103    pub fn new(initial_state: bool) -> CheckboxController {
104        Self {
105            checkmark: CheckmarkState::new(initial_state),
106        }
107    }
108
109    /// Returns whether the checkbox is currently checked.
110    pub fn is_checked(&self) -> bool {
111        self.checkmark.checked && !self.checkmark.indeterminate
112    }
113
114    /// Returns whether only part of the represented selection is checked.
115    pub fn is_indeterminate(&self) -> bool {
116        self.checkmark.indeterminate
117    }
118
119    /// Sets the checked state directly and resets animation progress.
120    pub fn set_checked(&mut self, checked: bool) {
121        self.set_value(checked, false);
122    }
123
124    /// Sets partial selection without animation. Clearing a partial selection
125    /// leaves the checkbox unchecked; an already checked checkbox stays
126    /// checked.
127    pub fn set_indeterminate(&mut self, indeterminate: bool) {
128        self.set_value(self.is_checked(), indeterminate);
129    }
130
131    fn set_value(&mut self, checked: bool, indeterminate: bool) {
132        let active = checked || indeterminate;
133        if self.checkmark.checked != active
134            || self.checkmark.indeterminate != indeterminate
135            || self.is_animating()
136        {
137            self.checkmark.snap_to(active, indeterminate);
138        }
139    }
140
141    /// Toggles the checked state and starts the animation timeline.
142    pub fn toggle(&mut self) {
143        self.checkmark.toggle();
144    }
145
146    /// Advances the checkmark animation progress based on elapsed time.
147    fn update_progress(&mut self, frame_nanos: u64) {
148        self.checkmark.update_progress(frame_nanos);
149    }
150
151    /// Returns current animation progress (0.0..1.0).
152    fn progress(&self) -> f32 {
153        self.checkmark.progress()
154    }
155
156    /// Returns whether the checkmark animation is currently running.
157    fn is_animating(&self) -> bool {
158        self.checkmark.progress.is_animating() || self.checkmark.morph.is_animating()
159    }
160}
161
162#[derive(Clone, PartialEq)]
163struct CheckmarkState {
164    checked: bool,
165    indeterminate: bool,
166    progress: Scalar,
167    morph: Scalar,
168    snap_revision: u64,
169}
170
171impl Default for CheckmarkState {
172    fn default() -> Self {
173        Self::new(false)
174    }
175}
176
177impl CheckmarkState {
178    fn new(initial_state: bool) -> Self {
179        Self {
180            checked: initial_state,
181            indeterminate: false,
182            progress: Scalar::new(if initial_state { 1.0 } else { 0.0 }),
183            morph: Scalar::new(0.0),
184            snap_revision: 0,
185        }
186    }
187
188    fn snap_to(&mut self, active: bool, indeterminate: bool) {
189        self.checked = active;
190        self.indeterminate = indeterminate;
191        self.progress.snap(if active { 1.0 } else { 0.0 });
192        self.morph.snap(if indeterminate { 1.0 } else { 0.0 });
193        self.snap_revision = self.snap_revision.wrapping_add(1);
194    }
195
196    fn toggle(&mut self) {
197        self.animate_to(
198            !self.checked || self.indeterminate,
199            false,
200            current_frame_nanos(),
201        );
202    }
203
204    fn animate_to(&mut self, checked: bool, indeterminate: bool, frame_nanos: u64) {
205        let active = checked || indeterminate;
206        if self.checked == active && self.indeterminate == indeterminate {
207            return;
208        }
209        let spec = if active { Spec::Spatial } else { Spec::Removal };
210        self.progress
211            .retarget(if active { 1.0 } else { 0.0 }, spec, frame_nanos);
212        let morph = if indeterminate { 1.0 } else { 0.0 };
213        if !self.checked {
214            self.morph.snap(morph);
215        } else {
216            self.morph.retarget(morph, spec, frame_nanos);
217        }
218        self.checked = active;
219        self.indeterminate = indeterminate;
220    }
221
222    fn update_progress(&mut self, frame_nanos: u64) {
223        self.progress.advance(frame_nanos);
224        self.morph.advance(frame_nanos);
225    }
226
227    fn progress(&self) -> f32 {
228        self.progress.value.clamp(0.0, 1.0) as f32
229    }
230}
231
232/// # checkbox
233///
234/// Renders an interactive checkbox with an animated checkmark.
235///
236/// ## Usage
237///
238/// Capture a boolean choice or represent partial selection for a group of
239/// choices.
240///
241/// ## Parameters
242///
243/// - `modifier` — modifier chain applied to the checkbox subtree.
244/// - `focus_requester` — optional handle targeting the checkbox for focus
245///   requests.
246/// - `on_toggle` — toggle callback invoked with the new checked state.
247/// - `checked` — externally controlled checked state; omit to use the
248///   controller.
249/// - `indeterminate` — partial selection, taking precedence over `checked`;
250///   activation clears this state and requests `true` through `on_toggle`.
251/// - `presentation_state` — optional visual interaction; live input and
252///   disabled state take precedence.
253/// - `is_error` — validation error state; publishes invalid semantics and uses
254///   error colors unless explicitly overridden. Supply validation details with
255///   `accessibility_description`. Disabled colors take precedence.
256/// - `size` — optional checkbox glyph size.
257/// - `color` — optional unchecked outline color.
258/// - `checked_color` — optional checked container color.
259/// - `checkmark_color` — optional checkmark icon color.
260/// - `checkmark_stroke_width` — optional stroke width in physical pixels;
261///   defaults to 2dp rounded down to a whole pixel.
262/// - `shape` — optional outer checkbox shape.
263/// - `disabled` — whether the checkbox is disabled.
264/// - `disabled_color` — optional disabled border/container color.
265/// - `disabled_checkmark_color` — optional disabled checkmark color.
266/// - `accessibility_label` — optional accessibility label.
267/// - `accessibility_description` — optional accessibility description.
268/// - `controller` — optional external checkbox controller.
269///
270/// ## Examples
271///
272/// ```
273/// use tessera_components::checkbox::{CheckboxController, checkbox};
274/// use tessera_ui::{remember, tessera};
275///
276/// #[tessera]
277/// fn checkbox_demo() {
278///     let controller = remember(|| {
279///         let mut controller = CheckboxController::new(false);
280///         controller.set_indeterminate(true);
281///         assert!(controller.is_indeterminate());
282///         controller.toggle();
283///         assert!(controller.is_checked());
284///         assert!(!controller.is_indeterminate());
285///         controller
286///     });
287///     checkbox().controller(controller);
288///     let choices = remember(|| [true, false]);
289///     let all = choices.with(|values| values.iter().all(|value| *value));
290///     let some = choices.with(|values| values.iter().any(|value| *value));
291///     checkbox()
292///         .checked(all)
293///         .indeterminate(some && !all)
294///         .is_error(!some)
295///         .accessibility_description("Choose at least one option")
296///         .on_toggle(move |value| choices.set([value; 2]));
297/// }
298/// # #[tessera]
299/// # fn demo() {
300/// #     tessera_components::theme::material_theme().child(|| { checkbox_demo(); });
301/// # }
302/// # tessera_ui::testing::layout_test(demo).run();
303/// ```
304#[tessera]
305pub fn checkbox(
306    modifier: Option<Modifier>,
307    focus_requester: Option<FocusRequester>,
308    on_toggle: Option<CallbackWith<bool, ()>>,
309    checked: Option<bool>,
310    indeterminate: Option<bool>,
311    is_error: Option<bool>,
312    presentation_state: Option<InteractionVisualState>,
313    size: Option<Dp>,
314    color: Option<Color>,
315    checked_color: Option<Color>,
316    checkmark_color: Option<Color>,
317    checkmark_stroke_width: Option<f32>,
318    shape: Option<Shape>,
319    disabled: Option<bool>,
320    disabled_color: Option<Color>,
321    disabled_checkmark_color: Option<Color>,
322    #[prop(into)] accessibility_label: Option<String>,
323    #[prop(into)] accessibility_description: Option<String>,
324    controller: Option<State<CheckboxController>>,
325) {
326    let focus_requester = if let Some(focus_requester) = focus_requester {
327        focus_requester
328    } else {
329        remember(FocusRequester::new).get()
330    };
331    let modifier = modifier.unwrap_or_default();
332    let disabled = disabled.unwrap_or(false);
333    let is_error = is_error.unwrap_or(false);
334    let scheme = use_context::<MaterialTheme>()
335        .expect("MaterialTheme must be provided")
336        .get()
337        .color_scheme;
338    let size = size.unwrap_or(CheckboxDefaults::GLYPH_SIZE);
339    let unchecked_interaction_color = color.unwrap_or(if is_error {
340        scheme.error
341    } else {
342        scheme.on_surface
343    });
344    let color = color.unwrap_or(if is_error {
345        scheme.error
346    } else {
347        scheme.on_surface_variant
348    });
349    let checked_color = checked_color.unwrap_or(if is_error {
350        scheme.error
351    } else {
352        scheme.primary
353    });
354    let checkmark_color = checkmark_color.unwrap_or(if is_error {
355        scheme.on_error
356    } else {
357        scheme.on_primary
358    });
359    let checkmark_stroke_width =
360        checkmark_stroke_width.unwrap_or_else(|| Dp(2.0).to_pixels_f32().floor());
361    let shape = shape.unwrap_or_else(CheckboxDefaults::default_shape);
362    let disabled_color = disabled_color.unwrap_or(
363        scheme
364            .on_surface
365            .with_alpha(MaterialAlpha::DISABLED_CONTENT),
366    );
367    let disabled_checkmark_color = disabled_checkmark_color.unwrap_or(scheme.surface);
368    let controller = if let Some(controller) = controller {
369        controller
370    } else {
371        remember(|| {
372            let mut controller = CheckboxController::new(checked.unwrap_or(false));
373            controller.set_indeterminate(indeterminate.unwrap_or(false));
374            controller
375        })
376    };
377    if checked.is_some() || indeterminate.is_some() {
378        let checked =
379            checked.unwrap_or_else(|| controller.with(|controller| controller.is_checked()));
380        let indeterminate = indeterminate.unwrap_or(false);
381        if controller.with(|controller| {
382            controller.checkmark.checked != (checked || indeterminate)
383                || controller.is_indeterminate() != indeterminate
384        }) {
385            controller.with_mut(|controller| {
386                controller
387                    .checkmark
388                    .animate_to(checked, indeterminate, current_frame_nanos())
389            });
390        }
391    }
392
393    checkbox_inner()
394        .focus_requester(focus_requester)
395        .modifier(modifier)
396        .size(size)
397        .color(color)
398        .unchecked_interaction_color(unchecked_interaction_color)
399        .checked_color(checked_color)
400        .checkmark_color(checkmark_color)
401        .checkmark_stroke_width(checkmark_stroke_width)
402        .shape(shape)
403        .disabled(disabled)
404        .is_error(is_error)
405        .presentation_state_optional(presentation_state)
406        .disabled_color(disabled_color)
407        .disabled_checkmark_color(disabled_checkmark_color)
408        .controller(controller)
409        .on_toggle_optional(on_toggle)
410        .accessibility_label_optional(accessibility_label)
411        .accessibility_description_optional(accessibility_description);
412}
413
414#[tessera]
415fn checkbox_inner(
416    modifier: Option<Modifier>,
417    focus_requester: Option<FocusRequester>,
418    on_toggle: Option<CallbackWith<bool, ()>>,
419    size: Option<Dp>,
420    color: Option<Color>,
421    unchecked_interaction_color: Option<Color>,
422    checked_color: Option<Color>,
423    checkmark_color: Option<Color>,
424    checkmark_stroke_width: Option<f32>,
425    shape: Option<Shape>,
426    disabled: Option<bool>,
427    is_error: Option<bool>,
428    presentation_state: Option<InteractionVisualState>,
429    disabled_color: Option<Color>,
430    disabled_checkmark_color: Option<Color>,
431    accessibility_label: Option<String>,
432    accessibility_description: Option<String>,
433    controller: Option<State<CheckboxController>>,
434) {
435    let modifier = modifier.unwrap_or_default();
436    let size = size.unwrap_or(CheckboxDefaults::GLYPH_SIZE);
437    let color = color.unwrap_or(Color::TRANSPARENT);
438    let checked_color = checked_color.unwrap_or(Color::TRANSPARENT);
439    let checkmark_color = checkmark_color.unwrap_or(Color::TRANSPARENT);
440    let checkmark_stroke_width =
441        checkmark_stroke_width.unwrap_or_else(|| Dp(2.0).to_pixels_f32().floor());
442    let shape = shape.unwrap_or(Shape::CAPSULE);
443    let disabled = disabled.unwrap_or(false);
444    let is_error = is_error.unwrap_or(false);
445    let disabled_color = disabled_color.unwrap_or(Color::TRANSPARENT);
446    let disabled_checkmark_color = disabled_checkmark_color.unwrap_or(Color::TRANSPARENT);
447    let controller = controller.expect("checkbox_inner requires controller to be set");
448    let is_checked = controller.with(|c| c.is_checked());
449    let is_indeterminate = controller.with(|controller| controller.is_indeterminate());
450    let is_active = is_checked || is_indeterminate;
451    let enabled = !disabled;
452    let interaction_state = if enabled {
453        Some(remember(InteractionState::new))
454    } else {
455        None
456    };
457    let ripple_state = if enabled {
458        Some(remember(RippleState::new))
459    } else {
460        None
461    };
462    let tap_recognizer = if enabled {
463        Some(remember(TapRecognizer::default))
464    } else {
465        None
466    };
467
468    let on_value_change = {
469        let on_toggle = on_toggle.unwrap_or_else(CallbackWith::default_value);
470        CallbackWith::new(move |next_checked| {
471            controller.with_mut(|controller| {
472                controller
473                    .checkmark
474                    .animate_to(next_checked, false, current_frame_nanos())
475            });
476            on_toggle.call(next_checked);
477        })
478    };
479
480    let scheme = use_context::<MaterialTheme>()
481        .expect("MaterialTheme must be provided")
482        .get()
483        .color_scheme;
484    let visual = interaction_state
485        .map(|state| state.with(|state| state.with_presentation(presentation_state, enabled)))
486        .unwrap_or_else(|| InteractionState::new().with_presentation(presentation_state, enabled));
487    let color = if visual.is_hovered() || visual.is_focused() || visual.is_pressed() {
488        unchecked_interaction_color.unwrap_or(scheme.on_surface)
489    } else {
490        color
491    };
492    let border = if disabled {
493        disabled_color
494    } else if is_active {
495        checked_color
496    } else {
497        color
498    };
499    let target = Colors {
500        fill: if is_active {
501            border
502        } else {
503            Color::TRANSPARENT
504        },
505        border,
506        icon: if disabled {
507            disabled_checkmark_color
508        } else if is_active {
509            checkmark_color
510        } else {
511            Color::TRANSPARENT
512        },
513        enabled,
514        active: is_active,
515        snap_revision: controller.with(|controller| controller.checkmark.snap_revision),
516    };
517    let frame = current_frame_nanos();
518    let colors = remember(|| ColorsMotion::new(target));
519    if colors.with(|colors| colors.target != target) {
520        colors.with_mut(|colors| colors.retarget(target, frame));
521    }
522    if controller.with(CheckboxController::is_animating) || colors.with(ColorsMotion::is_animating)
523    {
524        receive_frame_nanos(move |frame| {
525            let stroke_active = controller.with_mut(|controller| {
526                controller.update_progress(frame);
527                controller.is_animating()
528            });
529            let color_active = colors.with_mut(|colors| {
530                colors.advance(frame);
531                colors.is_animating()
532            });
533            if stroke_active || color_active {
534                tessera_ui::FrameNanosControl::Continue
535            } else {
536                tessera_ui::FrameNanosControl::Stop
537            }
538        });
539    }
540    let (fill_color, border_color, icon_color) = colors.with(ColorsMotion::value);
541    let outline_width = Dp::from_pixels_f32(Dp(2.0).to_pixels_f32().floor());
542    let checkbox_style = if fill_color == border_color {
543        SurfaceStyle::Filled { color: fill_color }
544    } else if fill_color.a == 0.0 {
545        SurfaceStyle::Outlined {
546            color: border_color,
547            width: outline_width,
548        }
549    } else {
550        SurfaceStyle::FilledOutlined {
551            fill_color,
552            border_color,
553            border_width: outline_width,
554        }
555    };
556
557    let state_layer_base = if is_error {
558        if is_active { checked_color } else { color }
559    } else {
560        CheckboxDefaults::state_layer_base_color(is_active, checked_color, &scheme)
561    };
562
563    let checkmark_slot = RenderSlot::new(move || {
564        let progress = controller.with(|c| c.progress());
565        let morph = controller.with(|controller| controller.checkmark.morph.value as f32);
566        if progress > 0.0 {
567            boxed()
568                .alignment(Alignment::Center)
569                .modifier(Modifier::new().fill_max_size())
570                .children(move || {
571                    checkmark()
572                        .color(icon_color)
573                        .stroke_width(checkmark_stroke_width)
574                        .progress(progress)
575                        .indeterminate_fraction(morph)
576                        .size(size)
577                        .padding([0.0, 0.0])
578                        .checkbox_geometry(true);
579                });
580        }
581    });
582
583    let checkbox_surface_slot = {
584        RenderSlot::new(move || {
585            let checkbox_style = checkbox_style.clone();
586            surface()
587                .modifier(Modifier::new().size(size, size))
588                .shape(shape)
589                .style(checkbox_style)
590                .child(move || {
591                    checkmark_slot.render();
592                });
593        })
594    };
595
596    let checkbox_container_slot = {
597        RenderSlot::new(move || {
598            boxed()
599                .alignment(Alignment::Center)
600                .modifier(Modifier::new().fill_max_size())
601                .children(move || {
602                    checkbox_surface_slot.render();
603                });
604        })
605    };
606
607    let state_layer_slot = {
608        RenderSlot::new(move || {
609            surface()
610                .modifier(Modifier::new().size(
611                    CheckboxDefaults::STATE_LAYER_SIZE,
612                    CheckboxDefaults::STATE_LAYER_SIZE,
613                ))
614                .shape(Shape::Ellipse)
615                .enabled(enabled)
616                .style(SurfaceStyle::Filled {
617                    color: Color::TRANSPARENT,
618                })
619                .ripple_bounded(false)
620                .ripple_radius(Dp(CheckboxDefaults::STATE_LAYER_SIZE.0 / 2.0))
621                .ripple_color(state_layer_base)
622                .interaction_state_optional(interaction_state)
623                .presentation_state_optional(presentation_state)
624                .focus_requester_optional(focus_requester)
625                .show_focus_indicator(true)
626                .ripple_state_optional(ripple_state);
627        })
628    };
629
630    let mut modifier = modifier.size(
631        CheckboxDefaults::TOUCH_TARGET_SIZE,
632        CheckboxDefaults::TOUCH_TARGET_SIZE,
633    );
634    if enabled {
635        let ripple_spec = RippleSpec {
636            bounded: false,
637            radius: Some(Dp(CheckboxDefaults::STATE_LAYER_SIZE.0 / 2.0)),
638        };
639        let ripple_size = PxSize::new(
640            CheckboxDefaults::STATE_LAYER_SIZE.to_px(),
641            CheckboxDefaults::STATE_LAYER_SIZE.to_px(),
642        );
643        let press_handler = ripple_state.map(|state| {
644            let spec = ripple_spec;
645            let size = ripple_size;
646            move |ctx: PointerEventContext| {
647                state.with_mut(|s| s.start_animation_with_spec(ctx.normalized_pos, size, spec));
648            }
649        });
650        let release_handler = ripple_state
651            .map(|state| move |_ctx: PointerEventContext| state.with_mut(|s| s.release()));
652        let toggle_args = ToggleableArgs {
653            focus_requester,
654            value: is_checked,
655            on_value_change,
656            enabled: true,
657            role: Some(Role::CheckBox),
658            label: accessibility_label.clone(),
659            description: accessibility_description.clone(),
660            interaction_state,
661            on_press: press_handler.map(Into::into),
662            on_release: release_handler.map(Into::into),
663            tap_recognizer,
664            ..Default::default()
665        };
666        modifier = modifier.toggleable_with(toggle_args);
667    } else {
668        modifier = modifier.push_semantics(DisabledToggleSemantics {
669            role: Role::CheckBox,
670            value: is_checked,
671            label: accessibility_label,
672            description: accessibility_description,
673        });
674    }
675    let modifier = modifier.push_semantics(CheckboxValueSemantics {
676        toggled: if is_indeterminate {
677            Toggled::Mixed
678        } else if is_checked {
679            Toggled::True
680        } else {
681            Toggled::False
682        },
683        is_error,
684    });
685    boxed()
686        .modifier(modifier)
687        .alignment(Alignment::Center)
688        .children(move || {
689            state_layer_slot.render();
690            checkbox_container_slot.render();
691        });
692}
693
694#[cfg(test)]
695mod tests {
696    use super::CheckboxController;
697
698    #[test]
699    fn checkbox_controller_animates_to_checked_state() {
700        let mut controller = CheckboxController::new(false);
701
702        controller.checkmark.animate_to(true, false, 1_000_000_000);
703
704        assert!(controller.is_checked());
705        assert!(controller.is_animating());
706        assert_eq!(controller.progress(), 0.0);
707
708        controller.update_progress(1_100_000_000);
709        assert!((controller.progress() - 0.65983105).abs() < 0.00001);
710        assert!(controller.is_animating());
711
712        controller.set_checked(true);
713        assert_eq!(controller.progress(), 1.0);
714        assert!(!controller.is_animating());
715    }
716
717    #[test]
718    fn checkbox_controller_animates_to_unchecked_state() {
719        let mut controller = CheckboxController::new(true);
720
721        controller.checkmark.animate_to(false, false, 1_000_000_000);
722
723        assert!(!controller.is_checked());
724        assert!(controller.is_animating());
725        assert_eq!(controller.progress(), 1.0);
726
727        controller.update_progress(1_099_000_000);
728        assert_eq!(controller.progress(), 1.0);
729        assert!(controller.is_animating());
730
731        controller.update_progress(1_100_000_000);
732        assert_eq!(controller.progress(), 0.0);
733        assert!(!controller.is_animating());
734    }
735
736    #[test]
737    fn mixed_activation_selects_and_morph_retargeting_preserves_the_stroke() {
738        let runtime = tessera_ui::RuntimeSession::new();
739        let _scope = runtime.enter();
740        let mut controller = CheckboxController::new(false);
741        controller.set_indeterminate(true);
742        assert!(controller.is_indeterminate());
743        assert!(!controller.is_checked());
744        let start = tessera_ui::current_frame_nanos();
745        controller.toggle();
746        assert!(controller.is_checked());
747        assert!(!controller.is_indeterminate());
748        controller.update_progress(start + 100_000_000);
749        assert_eq!(controller.progress(), 1.0);
750        let morph = controller.checkmark.morph.value;
751        let velocity = controller.checkmark.morph.velocity;
752        assert!((morph - 0.34016894).abs() < 0.00001);
753        controller
754            .checkmark
755            .animate_to(false, true, start + 100_000_000);
756        controller.update_progress(start + 100_000_000);
757        assert!((controller.checkmark.morph.value - morph).abs() < 0.000000000001);
758        assert!((controller.checkmark.morph.velocity - velocity).abs() < 0.000000000001);
759        controller.update_progress(start + 120_000_000);
760        assert!(
761            controller.checkmark.morph.value < morph,
762            "reversal retains incoming velocity"
763        );
764        controller.update_progress(start + 600_000_000);
765        assert_eq!(controller.checkmark.morph.value, 1.0);
766        assert!(controller.is_indeterminate());
767        assert!(!controller.is_animating());
768        controller
769            .checkmark
770            .animate_to(true, false, start + 600_000_000);
771        controller.update_progress(start + 700_000_000);
772        let held_morph = controller.checkmark.morph.value;
773        controller
774            .checkmark
775            .animate_to(false, false, start + 700_000_000);
776        controller.update_progress(start + 799_000_000);
777        assert!((controller.checkmark.morph.value - held_morph).abs() < 0.000000000001);
778        controller.update_progress(start + 800_000_000);
779        assert_eq!(controller.checkmark.morph.value, 0.0);
780        assert_eq!(controller.progress(), 0.0);
781        assert!(!controller.is_animating());
782    }
783}