1use 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
97const HIT_SIZE: f64 = 40.0;
100const BOX: f64 = 18.0;
102const BOX_OFFSET: f64 = (HIT_SIZE - BOX) / 2.0;
105const STATE_LAYER_SIZE: f64 = HIT_SIZE;
108const MARK_SIZE: f64 = 16.0;
111const MARK_OFFSET: f64 = BOX_OFFSET + (BOX - MARK_SIZE) / 2.0;
114const INDETERMINATE_WIDTH: f64 = 10.0;
117const INDETERMINATE_HEIGHT: f64 = 2.0;
120const BORDER_WIDTH: f64 = 2.0;
122const CHECK_STROKE_WIDTH: f64 = 2.0;
127const RADIUS: f64 = 4.0;
131const PATH_TOLERANCE: f64 = 0.1;
134
135const FILL_ACTIVE: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
139const ON_PRIMARY: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
142const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x6B, 0x72, 0x80);
145const ON_SURFACE: Color = Color::from_rgb8(0x11, 0x18, 0x27);
149const ERROR: Color = Color::from_rgb8(0xEF, 0x44, 0x44);
152const ON_ERROR: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
155
156fn is_active(value: Option<bool>) -> bool {
160 !matches!(value, Some(false))
161}
162
163fn 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
180struct ResolvedColors {
184 active: Color,
188 border_inactive: Color,
191 on_surface: Color,
194 mark: Color,
198}
199
200fn 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
223fn 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
233fn resolve_radius(theme: Option<&Theme>) -> f64 {
236 theme.map_or(RADIUS, |t| t.shape.extra_small)
237}
238
239fn 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
253fn with_alpha(color: Color, alpha: f32) -> Color {
255 let c = color.components;
256 Color::new([c[0], c[1], c[2], alpha])
257}
258
259fn 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
285fn 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
325fn 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
331type OnChanged<State> = Rc<dyn Fn(&mut State, Option<bool>)>;
336
337pub struct CheckboxView<State: 'static> {
339 value: Option<bool>,
340 tristate: bool,
341 error: bool,
342 on_changed: OnChanged<State>,
343}
344
345pub 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#[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
370pub 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 pub fn error(mut self, error: bool) -> Self {
392 self.error = error;
393 self
394 }
395}
396
397pub struct CheckboxWidget {
399 value: Option<bool>,
402 tristate: bool,
403 error: bool,
404 anim: AnimationController,
407 anim_target: bool,
412 interaction: InteractionState,
413 pressed: bool,
415 captured: bool,
417 on_changed: frust::authoring::ErasedArgCallback<Option<bool>>,
418}
419
420fn 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 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 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 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 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 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 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 #[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 #[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 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 assert_eq!(path.elements().len(), 2);
690 }
691
692 #[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 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 #[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); 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 #[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 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 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 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 #[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}