1use 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
60pub struct CheckboxDefaults;
62
63impl CheckboxDefaults {
64 pub const GLYPH_SIZE: Dp = Dp(18.0);
66 pub const STATE_LAYER_SIZE: Dp = Dp(40.0);
68 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 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#[derive(Clone, PartialEq, Default)]
97pub struct CheckboxController {
98 checkmark: CheckmarkState,
99}
100
101impl CheckboxController {
102 pub fn new(initial_state: bool) -> CheckboxController {
104 Self {
105 checkmark: CheckmarkState::new(initial_state),
106 }
107 }
108
109 pub fn is_checked(&self) -> bool {
111 self.checkmark.checked && !self.checkmark.indeterminate
112 }
113
114 pub fn is_indeterminate(&self) -> bool {
116 self.checkmark.indeterminate
117 }
118
119 pub fn set_checked(&mut self, checked: bool) {
121 self.set_value(checked, false);
122 }
123
124 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 pub fn toggle(&mut self) {
143 self.checkmark.toggle();
144 }
145
146 fn update_progress(&mut self, frame_nanos: u64) {
148 self.checkmark.update_progress(frame_nanos);
149 }
150
151 fn progress(&self) -> f32 {
153 self.checkmark.progress()
154 }
155
156 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#[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}