1use std::rc::Rc;
99use std::time::Duration;
100
101use frust::authoring::{Action, Role, Toggled};
102use frust::authoring::{
103 BoxConstraints, BuildCtx, ChangeFlags, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx,
104 PaintScene, PointerPhase, SemanticsCtx, View, Widget,
105};
106use frust::{AnimationController, Brightness, Curve, FrameTime, ShapeScale, Theme, Tween};
107use kurbo::{Point, Rect, RoundedRect, Shape, Size, Vec2};
108use peniko::{Brush, Color};
109
110use crate::press::presses;
111
112const TRACK_W: f64 = 38.0;
118const TRACK_H: f64 = 22.0;
121const BORDER_W: f64 = 1.0;
127const TOUCH_TARGET: f64 = 44.0;
135const KNOB_SIZE: f64 = 16.0;
139const KNOB_INSET: f64 = 2.0;
144const KNOB_OFFSET: f64 = BORDER_W + KNOB_INSET;
148const KNOB_TRAVEL: f64 = 16.0;
153const PATH_TOLERANCE: f64 = 0.1;
156
157const KNOB_DURATION: Duration = Duration::from_millis(220);
174const KNOB_CURVE: Curve = Curve::Cubic(0.34, 1.35, 0.64, 1.0);
179const FADE_DURATION: Duration = Duration::from_millis(150);
184const FADE_CURVE: Curve = Curve::Cubic(0.16, 1.0, 0.3, 1.0);
189
190const BORDER_OFF_ALPHA: f32 = 0.18;
201const TRACK_ON_ALPHA_DARK: f32 = 0.12;
204const TRACK_ON_ALPHA_LIGHT: f32 = 0.10;
208const BORDER_ON_ALPHA_DARK: f32 = 0.35;
212const BORDER_ON_ALPHA_LIGHT: f32 = 0.38;
215const KNOB_SHADOW_ALPHA: f32 = 0.2;
220const KNOB_SHADOW_BLUR: f64 = 2.0;
224const KNOB_SHADOW_DY: f64 = 1.0;
226
227const TOGGLE_TRACK_OFF: Color = Color::from_rgb8(0x27, 0x2d, 0x3d);
232const TOGGLE_KNOB_OFF: Color = Color::from_rgb8(0x6b, 0x65, 0x56);
236const TOGGLE_ACCENT: Color = Color::from_rgb8(0xff, 0xb6, 0x27);
239const TOGGLE_BORDER_INK: Color = Color::from_rgb8(0xf2, 0xea, 0xd9);
242
243fn with_alpha(color: Color, alpha: f32) -> Color {
246 let c = color.components;
247 Color::new([c[0], c[1], c[2], alpha])
248}
249
250fn lerp_color(begin: Color, end: Color, t: f64) -> Color {
256 if t <= 0.0 {
257 begin
258 } else if t >= 1.0 {
259 end
260 } else {
261 Tween::new(begin, end).lerp(t)
262 }
263}
264
265struct Progress {
274 ctrl: AnimationController,
275 duration: Duration,
276 curve: Curve,
277 from: f64,
278 to: f64,
279 displayed: f64,
281}
282
283impl Progress {
284 fn at_rest(value: f64, duration: Duration, curve: Curve) -> Self {
286 Self {
287 ctrl: AnimationController::new(duration).with_curve(curve),
288 duration,
289 curve,
290 from: value,
291 to: value,
292 displayed: value,
293 }
294 }
295
296 fn retarget(&mut self, to: f64) {
298 self.from = self.displayed;
299 self.to = to;
300 self.ctrl = AnimationController::new(self.duration).with_curve(self.curve);
301 self.ctrl.forward();
302 }
303
304 fn snap(&mut self) {
309 self.from = self.to;
310 self.displayed = self.to;
311 self.ctrl = AnimationController::new(self.duration).with_curve(self.curve);
312 }
313
314 fn advance(&mut self, now: FrameTime) -> bool {
317 if self.ctrl.is_animating() {
318 let animating = self.ctrl.advance(now);
319 self.displayed = self.from + (self.to - self.from) * self.ctrl.value();
320 animating
321 } else {
322 self.displayed = self.to;
323 false
324 }
325 }
326
327 fn value(&self) -> f64 {
329 self.displayed
330 }
331
332 fn value_clamped(&self) -> f64 {
335 self.displayed.clamp(0.0, 1.0)
336 }
337}
338
339struct ToggleColors {
342 track_off: Color,
343 track_on: Color,
344 knob_off: Color,
345 knob_on: Color,
346 border_off: Color,
347 border_on: Color,
348 knob_shadow: Option<Color>,
349}
350
351fn resolve_toggle_colors(theme: Option<&Theme>) -> ToggleColors {
354 match theme {
355 Some(theme) => {
356 let scheme = theme.scheme();
357 let (track_on_alpha, border_on_alpha, knob_off, knob_shadow) = match theme.brightness {
358 Brightness::Dark => (
362 TRACK_ON_ALPHA_DARK,
363 BORDER_ON_ALPHA_DARK,
364 scheme.on_surface_variant,
365 None,
366 ),
367 Brightness::Light => (
368 TRACK_ON_ALPHA_LIGHT,
369 BORDER_ON_ALPHA_LIGHT,
370 scheme.surface_container_lowest,
371 Some(with_alpha(scheme.shadow, KNOB_SHADOW_ALPHA)),
372 ),
373 };
374 ToggleColors {
375 track_off: scheme.surface_container_highest,
376 track_on: with_alpha(scheme.primary, track_on_alpha),
377 knob_off,
378 knob_on: scheme.primary_container,
379 border_off: with_alpha(scheme.on_surface, BORDER_OFF_ALPHA),
380 border_on: with_alpha(scheme.primary, border_on_alpha),
381 knob_shadow,
382 }
383 }
384 None => ToggleColors {
385 track_off: TOGGLE_TRACK_OFF,
386 track_on: with_alpha(TOGGLE_ACCENT, TRACK_ON_ALPHA_DARK),
387 knob_off: TOGGLE_KNOB_OFF,
388 knob_on: TOGGLE_ACCENT,
389 border_off: with_alpha(TOGGLE_BORDER_INK, BORDER_OFF_ALPHA),
390 border_on: with_alpha(TOGGLE_ACCENT, BORDER_ON_ALPHA_DARK),
391 knob_shadow: None,
392 },
393 }
394}
395
396fn track_radius(theme: Option<&Theme>, size: Size) -> f64 {
399 match theme {
400 Some(theme) => ShapeScale::resolve(theme.shape.full, size.width, size.height),
401 None => size.height / 2.0,
402 }
403}
404
405type OnToggle<State> = Rc<dyn Fn(&mut State, bool)>;
407
408pub struct ToggleView<State: 'static> {
410 checked: bool,
411 label: Option<String>,
412 on_toggle: OnToggle<State>,
413}
414
415pub fn toggle<State: 'static, F: Fn(&mut State, bool) + 'static>(
419 checked: bool,
420 on_toggle: F,
421) -> ToggleView<State> {
422 ToggleView {
423 checked,
424 label: None,
425 on_toggle: Rc::new(on_toggle),
426 }
427}
428
429#[allow(non_snake_case)]
432pub fn Toggle<State: 'static, F: Fn(&mut State, bool) + 'static>(
433 checked: bool,
434 on_toggle: F,
435) -> ToggleView<State> {
436 toggle(checked, on_toggle)
437}
438
439impl<State: 'static> ToggleView<State> {
440 pub fn label(mut self, label: impl Into<String>) -> Self {
443 self.label = Some(label.into());
444 self
445 }
446}
447
448pub struct ToggleWidget {
450 checked: bool,
452 label: Option<String>,
453 knob: Progress,
455 fade: Progress,
457 captured: bool,
461 on_toggle: frust::authoring::ErasedArgCallback<bool>,
462}
463
464fn inside(pos: Point, size: Size) -> bool {
465 pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
466}
467
468impl<State: 'static> View<State> for ToggleView<State> {
469 type Element = ToggleWidget;
470
471 fn build(&self, _ctx: &mut BuildCtx<'_>) -> ToggleWidget {
472 let rest = if self.checked { 1.0 } else { 0.0 };
473 ToggleWidget {
474 checked: self.checked,
475 label: self.label.clone(),
476 knob: Progress::at_rest(rest, KNOB_DURATION, KNOB_CURVE),
477 fade: Progress::at_rest(rest, FADE_DURATION, FADE_CURVE),
478 captured: false,
479 on_toggle: frust::authoring::erase_callback_arg(&self.on_toggle),
480 }
481 }
482
483 fn rebuild(
484 &self,
485 prev: &Self,
486 element: &mut ToggleWidget,
487 _ctx: &mut BuildCtx<'_>,
488 ) -> ChangeFlags {
489 element.on_toggle = frust::authoring::erase_callback_arg(&self.on_toggle);
491 let mut flags = ChangeFlags::NONE;
492 if prev.label != self.label {
493 element.label = self.label.clone();
494 flags |= ChangeFlags::PAINT;
497 }
498 if prev.checked != self.checked {
499 element.checked = self.checked;
503 let target = if self.checked { 1.0 } else { 0.0 };
504 element.knob.retarget(target);
505 element.fade.retarget(target);
506 flags |= ChangeFlags::PAINT;
507 }
508 flags
509 }
510}
511
512impl Widget for ToggleWidget {
513 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
514 bc.constrain(Size::new(
516 TRACK_W.max(TOUCH_TARGET),
517 TRACK_H.max(TOUCH_TARGET),
518 ))
519 }
520
521 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
522 let track_size = Size::new(TRACK_W, TRACK_H);
525 let theme = Theme::from_paint_ctx(ctx);
526 let colors = resolve_toggle_colors(theme);
527 let radius = track_radius(theme, track_size);
528 let reduce_motion = theme.map(|t| t.motion.reduce_motion).unwrap_or(false);
529
530 if reduce_motion {
531 self.knob.snap();
533 self.fade.snap();
534 } else {
535 let now = ctx.frame_time();
538 let knob_running = self.knob.advance(now);
539 let fade_running = self.fade.advance(now);
540 if knob_running || fade_running {
541 ctx.request_frame();
542 }
543 }
544
545 let box_size = ctx.size();
548 let origin = ctx.origin()
549 + Vec2::new(
550 ((box_size.width - TRACK_W) / 2.0).max(0.0),
551 ((box_size.height - TRACK_H) / 2.0).max(0.0),
552 );
553 let fade = self.fade.value_clamped();
554
555 scene.fill_rounded_rect(
559 origin,
560 track_size,
561 radius,
562 lerp_color(colors.track_off, colors.track_on, fade),
563 );
564 let inset = BORDER_W / 2.0;
565 let outline = RoundedRect::from_rect(
566 Rect::from_origin_size(Point::ORIGIN, track_size).inset(-inset),
567 (radius - inset).max(0.0),
568 );
569 scene.stroke_path(
570 origin,
571 &Shape::to_path(&outline, PATH_TOLERANCE),
572 BORDER_W,
573 &Brush::Solid(lerp_color(colors.border_off, colors.border_on, fade)),
574 );
575
576 let knob_origin =
579 origin + Vec2::new(KNOB_OFFSET + KNOB_TRAVEL * self.knob.value(), KNOB_OFFSET);
580 let knob_size = Size::new(KNOB_SIZE, KNOB_SIZE);
581 let knob_radius = KNOB_SIZE / 2.0;
582 if let Some(shadow) = colors.knob_shadow {
583 scene.draw_shadow(
584 knob_origin + Vec2::new(0.0, KNOB_SHADOW_DY),
585 knob_size,
586 knob_radius,
587 KNOB_SHADOW_BLUR,
588 shadow,
589 );
590 }
591 scene.fill_rounded_rect(
592 knob_origin,
593 knob_size,
594 knob_radius,
595 lerp_color(colors.knob_off, colors.knob_on, fade),
596 );
597 }
598
599 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
600 let InputEvent::Pointer(p) = event else {
601 return EventResult::Ignored;
602 };
603 match p.phase {
604 PointerPhase::Down => {
605 if !presses(p) {
606 return EventResult::Ignored;
607 }
608 self.captured = true;
609 ctx.capture_pointer();
610 EventResult::Handled
611 }
612 PointerPhase::Move => {
613 if self.captured {
616 EventResult::Handled
617 } else {
618 EventResult::Ignored
619 }
620 }
621 PointerPhase::Up => {
622 if !self.captured {
623 return EventResult::Ignored;
624 }
625 self.captured = false;
626 if inside(p.position, ctx.size()) {
629 (self.on_toggle)(ctx, !self.checked);
631 }
632 EventResult::Handled
633 }
634 PointerPhase::Cancel => {
635 if !self.captured {
636 return EventResult::Ignored;
637 }
638 self.captured = false;
639 EventResult::Handled
640 }
641 }
642 }
643
644 fn semantics(&self, ctx: &mut SemanticsCtx) {
645 ctx.push_node(Role::Switch, |node| {
646 node.set_toggled(Toggled::from(self.checked));
647 node.add_action(Action::Click);
648 if let Some(label) = &self.label {
649 node.set_label(label.as_str());
650 }
651 });
652 }
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658 use frust::authoring::{PointerButton, PointerEvent};
659 use std::any::Any;
660
661 #[derive(Default)]
662 struct ToggleState {
663 last: Option<bool>,
664 toggles: u32,
665 }
666
667 #[derive(Default)]
671 struct Recorder {
672 rrects: Vec<(Point, Size, f64, Color)>,
673 strokes: Vec<Color>,
674 shadows: Vec<(Point, Size, f64, f64, Color)>,
675 }
676
677 impl PaintScene for Recorder {
678 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
679 fn draw_text(&mut self, _o: Point, _t: &str) {}
680 fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
681 self.rrects.push((o, s, radius, color));
682 }
683 fn stroke_path(&mut self, _o: Point, _p: &kurbo::BezPath, _w: f64, brush: &Brush) {
684 if let Brush::Solid(c) = brush {
685 self.strokes.push(*c);
686 }
687 }
688 fn draw_shadow(&mut self, o: Point, s: Size, radius: f64, std_dev: f64, color: Color) {
689 self.shadows.push((o, s, radius, std_dev, color));
690 }
691 }
692
693 fn ft_ms(ms: f64) -> FrameTime {
694 FrameTime::from_nanos((ms * 1_000_000.0) as u64)
695 }
696
697 const SLAB: Size = Size::new(TOUCH_TARGET, TOUCH_TARGET);
699
700 fn pill_origin() -> Point {
704 Point::new(
705 (TOUCH_TARGET - TRACK_W) / 2.0,
706 (TOUCH_TARGET - TRACK_H) / 2.0,
707 )
708 }
709
710 fn widget(checked: bool) -> ToggleWidget {
711 build(&toggle::<ToggleState, _>(
712 checked,
713 |s: &mut ToggleState, v| {
714 s.last = Some(v);
715 s.toggles += 1;
716 },
717 ))
718 }
719
720 fn build(view: &ToggleView<ToggleState>) -> ToggleWidget {
721 let mut counter = 0u64;
722 View::<ToggleState>::build(view, &mut BuildCtx::new(&mut counter))
723 }
724
725 fn paint_at(w: &mut ToggleWidget, theme: Option<&Theme>, ms: f64) -> (Recorder, bool) {
728 let mut rec = Recorder::default();
729 let base = PaintCtx::for_test(Point::ZERO, SLAB, ft_ms(ms));
730 let mut ctx = match theme {
731 Some(t) => base.with_theme(t),
732 None => base,
733 };
734 w.paint(&mut ctx, &mut rec);
735 let again = ctx.needs_frame();
736 (rec, again)
737 }
738
739 fn paint(w: &mut ToggleWidget, theme: Option<&Theme>) -> Recorder {
740 paint_at(w, theme, 0.0).0
741 }
742
743 fn knob_x(rec: &Recorder) -> f64 {
747 rec.rrects[1].0.x - pill_origin().x
748 }
749
750 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
751 InputEvent::Pointer(PointerEvent {
752 phase,
753 position: Point::new(x, y),
754 button: PointerButton::Primary,
755 })
756 }
757
758 fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
760 InputEvent::Pointer(PointerEvent {
761 phase,
762 position: Point::new(x, y),
763 button: PointerButton::Secondary,
764 })
765 }
766
767 fn dispatch(w: &mut ToggleWidget, state: &mut ToggleState, event: &InputEvent) {
768 let state_any: &mut dyn Any = state;
769 let mut ctx = EventCtx::new(state_any, Point::ZERO, SLAB);
770 w.event(&mut ctx, event);
771 }
772
773 #[test]
774 fn a_secondary_press_never_captures_or_toggles() {
775 let mut w = widget(false);
776 let mut state = ToggleState::default();
777 dispatch(
778 &mut w,
779 &mut state,
780 &secondary_ev(PointerPhase::Down, 22.0, 22.0),
781 );
782 assert!(!w.captured, "no capture for the shell to wedge on");
783 dispatch(
784 &mut w,
785 &mut state,
786 &secondary_ev(PointerPhase::Up, 22.0, 22.0),
787 );
788 assert_eq!(state.toggles, 0);
789
790 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 22.0, 22.0));
792 assert!(w.captured);
793 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 22.0, 22.0));
794 assert_eq!(state.toggles, 1);
795 assert_eq!(state.last, Some(true));
796 }
797
798 #[test]
801 fn glyph_dark_theme_paints_the_source_token_table() {
802 let theme = crate::baseline();
803 let scheme = theme.scheme();
804
805 let mut off = widget(false);
806 let rec = paint(&mut off, Some(&theme));
807 assert_eq!(rec.rrects.len(), 2, "track + knob only");
808 assert_eq!(rec.rrects[0].3, scheme.surface_container_highest);
810 assert_eq!(rec.rrects[0].3, Color::from_rgb8(0x27, 0x2d, 0x3d));
811 assert_eq!(rec.rrects[0].1, Size::new(TRACK_W, TRACK_H));
812 assert_eq!(rec.rrects[0].0, pill_origin());
813 assert_eq!(rec.rrects[0].2, TRACK_H / 2.0);
814 assert_eq!(
816 rec.strokes[0],
817 with_alpha(Color::from_rgb8(0xf2, 0xea, 0xd9), 0.18)
818 );
819 assert_eq!(rec.rrects[1].3, scheme.on_surface_variant);
823 assert_eq!(rec.rrects[1].3, Color::from_rgb8(0xa3, 0x9c, 0x88));
824 assert_ne!(rec.rrects[1].3, TOGGLE_KNOB_OFF, "and it is not `--fg-dim`");
825 assert_eq!(rec.rrects[1].1, Size::new(KNOB_SIZE, KNOB_SIZE));
826 assert_eq!(knob_x(&rec), KNOB_OFFSET, "off rests at the pill inset");
827 assert_eq!(rec.rrects[1].0.y, pill_origin().y + KNOB_OFFSET);
828 assert!(rec.shadows.is_empty(), "dark authors no knob shadow");
829
830 let mut on = widget(true);
831 let rec = paint(&mut on, Some(&theme));
832 assert_eq!(
834 rec.rrects[0].3,
835 with_alpha(Color::from_rgb8(0xff, 0xb6, 0x27), 0.12)
836 );
837 assert_eq!(
839 rec.strokes[0],
840 with_alpha(Color::from_rgb8(0xff, 0xb6, 0x27), 0.35)
841 );
842 assert_eq!(rec.rrects[1].3, scheme.primary_container);
844 assert_eq!(rec.rrects[1].3, Color::from_rgb8(0xff, 0xb6, 0x27));
845 assert_eq!(knob_x(&rec), KNOB_OFFSET + KNOB_TRAVEL);
846 assert!(rec.shadows.is_empty());
847 }
848
849 #[test]
850 fn glyph_light_theme_paints_a_white_knob_with_a_lift_shadow() {
851 let theme = crate::baseline().with_brightness(Brightness::Light);
852 let scheme = theme.scheme();
853
854 let mut off = widget(false);
855 let rec = paint(&mut off, Some(&theme));
856 assert_eq!(rec.rrects[0].3, scheme.surface_container_highest);
858 assert_eq!(rec.rrects[0].3, Color::from_rgb8(0xe8, 0xe1, 0xd0));
859 assert_eq!(
861 rec.strokes[0],
862 with_alpha(Color::from_rgb8(0x22, 0x1d, 0x12), 0.18)
863 );
864 assert_eq!(rec.rrects[1].3, scheme.surface_container_lowest);
866 assert_eq!(rec.rrects[1].3, Color::from_rgb8(0xff, 0xff, 0xff));
867 assert_eq!(knob_x(&rec), KNOB_OFFSET);
868 assert_eq!(rec.shadows.len(), 1, "light lifts the knob");
869 let (shadow_origin, shadow_size, _, blur, shadow_color) = rec.shadows[0];
870 assert_eq!(
871 shadow_origin,
872 pill_origin() + Vec2::new(KNOB_OFFSET, KNOB_OFFSET + KNOB_SHADOW_DY)
873 );
874 assert_eq!(shadow_size, Size::new(KNOB_SIZE, KNOB_SIZE));
875 assert_eq!(blur, KNOB_SHADOW_BLUR);
876 assert_eq!(shadow_color, with_alpha(scheme.shadow, KNOB_SHADOW_ALPHA));
877 assert_eq!(
879 shadow_color,
880 with_alpha(Color::from_rgb8(0x22, 0x1d, 0x12), KNOB_SHADOW_ALPHA)
881 );
882
883 let mut on = widget(true);
884 let rec = paint(&mut on, Some(&theme));
885 assert_eq!(
887 rec.rrects[0].3,
888 with_alpha(Color::from_rgb8(0xa3, 0x65, 0x0a), 0.10)
889 );
890 assert_eq!(
892 rec.strokes[0],
893 with_alpha(Color::from_rgb8(0xa3, 0x65, 0x0a), 0.38)
894 );
895 assert_eq!(rec.rrects[1].3, scheme.primary_container);
897 assert_eq!(rec.rrects[1].3, Color::from_rgb8(0xff, 0xb6, 0x27));
898 assert_eq!(knob_x(&rec), KNOB_OFFSET + KNOB_TRAVEL);
899 assert_eq!(rec.shadows.len(), 1, "the lift survives the checked state");
901 }
902
903 #[test]
904 fn unthemed_paint_uses_the_dark_fallback_constants() {
905 let mut off = widget(false);
906 let rec = paint(&mut off, None);
907 assert_eq!(rec.rrects[0].3, TOGGLE_TRACK_OFF);
908 assert_eq!(rec.rrects[0].2, TRACK_H / 2.0, "pill radius");
909 assert_eq!(rec.rrects[1].3, TOGGLE_KNOB_OFF, "the exact `fg-dim` hex");
910 assert_eq!(
911 rec.strokes[0],
912 with_alpha(TOGGLE_BORDER_INK, BORDER_OFF_ALPHA)
913 );
914 assert!(rec.shadows.is_empty());
915
916 let mut on = widget(true);
917 let rec = paint(&mut on, None);
918 assert_eq!(
919 rec.rrects[0].3,
920 with_alpha(TOGGLE_ACCENT, TRACK_ON_ALPHA_DARK)
921 );
922 assert_eq!(rec.rrects[1].3, TOGGLE_ACCENT);
923 assert_eq!(
924 rec.strokes[0],
925 with_alpha(TOGGLE_ACCENT, BORDER_ON_ALPHA_DARK)
926 );
927 }
928
929 #[test]
932 fn layout_requests_a_touch_slab_around_the_pill() {
933 let mut w = widget(false);
934 let mut ctx = LayoutCtx::new();
935 let size = w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
936 assert_eq!(size, SLAB);
937 assert!(
938 size.width > TRACK_W && size.height > TRACK_H,
939 "the interactive box is larger than the 38×22 pill it paints"
940 );
941 assert!(
942 size.width >= TOUCH_TARGET && size.height >= TOUCH_TARGET,
943 "and clears WCAG 2.2 / iOS's minimum on both axes (4dp under \
944 Android's 48dp, accepted — see `TOUCH_TARGET`'s doc)"
945 );
946 }
947
948 #[test]
949 fn paint_centers_the_fixed_pill_in_whatever_box_it_is_given() {
950 let mut w = widget(true);
953 let mut rec = Recorder::default();
954 let box_size = Size::new(80.0, 60.0);
955 let mut ctx = PaintCtx::for_test(Point::new(10.0, 20.0), box_size, ft_ms(0.0));
956 w.paint(&mut ctx, &mut rec);
957
958 let expected = Point::new(
959 10.0 + (box_size.width - TRACK_W) / 2.0,
960 20.0 + (box_size.height - TRACK_H) / 2.0,
961 );
962 assert_eq!(rec.rrects[0].0, expected);
963 assert_eq!(rec.rrects[0].1, Size::new(TRACK_W, TRACK_H), "never scaled");
964 assert_eq!(
966 rec.rrects[1].0,
967 expected + Vec2::new(KNOB_OFFSET + KNOB_TRAVEL, KNOB_OFFSET)
968 );
969 }
970
971 #[test]
972 fn the_overshooting_knob_stays_inside_the_slab() {
973 let prev = toggle::<ToggleState, _>(false, |_s, _v| {});
976 let mut w = build(&prev);
977 let next = toggle::<ToggleState, _>(true, |_s, _v| {});
978 let mut counter = 0u64;
979 View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
980 paint_at(&mut w, None, 0.0);
981
982 let mut peak_right = f64::MIN;
983 for ms in (10..=210).step_by(10) {
984 let (rec, _) = paint_at(&mut w, None, ms as f64);
985 peak_right = peak_right.max(rec.rrects[1].0.x + KNOB_SIZE);
986 }
987 let rest_right = pill_origin().x + KNOB_OFFSET + KNOB_TRAVEL + KNOB_SIZE;
988 assert!(
989 peak_right > rest_right,
990 "the overshoot is real ({peak_right} vs {rest_right})"
991 );
992 assert!(
993 peak_right <= pill_origin().x + TRACK_W - BORDER_W,
994 "yet the knob never escapes the track's inner edge ({peak_right})"
995 );
996 assert!(
997 peak_right <= SLAB.width,
998 "nor the slab ({peak_right} vs {})",
999 SLAB.width
1000 );
1001 }
1002
1003 #[test]
1004 fn a_tap_in_the_slab_margin_outside_the_pill_still_toggles() {
1005 let mut w = widget(false);
1006 let mut state = ToggleState::default();
1007 let (x, y) = (TOUCH_TARGET - 1.0, TOUCH_TARGET - 1.0);
1009 assert!(
1010 x > pill_origin().x + TRACK_W && y > pill_origin().y + TRACK_H,
1011 "the probe really is outside the painted pill"
1012 );
1013 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, x, y));
1014 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, x, y));
1015 assert_eq!(state.last, Some(true));
1016 assert_eq!(state.toggles, 1);
1017 }
1018
1019 #[test]
1022 fn knob_travels_the_source_distance_and_settles() {
1023 let prev = toggle::<ToggleState, _>(false, |_s, _v| {});
1024 let mut w = build(&prev);
1025 let (rec, again) = paint_at(&mut w, None, 0.0);
1026 assert_eq!(knob_x(&rec), KNOB_OFFSET, "off rests at the inset");
1027 assert!(!again, "a resting toggle requests no frame");
1028
1029 let next = toggle::<ToggleState, _>(true, |_s, _v| {});
1031 let mut counter = 0u64;
1032 let flags =
1033 View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1034 assert!(flags.needs_paint());
1035 assert!(!flags.needs_layout(), "the track never resizes");
1036
1037 let (rec, again) = paint_at(&mut w, None, 0.0);
1040 assert_eq!(knob_x(&rec), KNOB_OFFSET);
1041 assert!(again, "an in-flight knob owes another frame");
1042
1043 let settled_x = KNOB_OFFSET + KNOB_TRAVEL;
1044 let mut peak_x = f64::MIN;
1045 for ms in (10..=210).step_by(10) {
1046 let (rec, again) = paint_at(&mut w, None, ms as f64);
1047 peak_x = peak_x.max(knob_x(&rec));
1048 assert!(again, "still animating at {ms}ms");
1049 }
1050 assert!(
1051 peak_x > settled_x,
1052 "the spatial curve overshoots its rest spot ({peak_x} vs {settled_x})"
1053 );
1054
1055 let (rec, again) = paint_at(&mut w, None, 400.0);
1056 assert_eq!(knob_x(&rec), settled_x, "lands exactly 16px across");
1057 assert!(!again, "a settled toggle stops requesting frames");
1058 assert_eq!(rec.rrects[1].3, TOGGLE_ACCENT, "and the fade completed");
1059 }
1060
1061 #[test]
1062 fn a_reversal_travels_back_to_the_off_inset() {
1063 let prev = toggle::<ToggleState, _>(true, |_s, _v| {});
1064 let mut w = build(&prev);
1065 let next = toggle::<ToggleState, _>(false, |_s, _v| {});
1066 let mut counter = 0u64;
1067 View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1068 let (_, again) = paint_at(&mut w, None, 0.0);
1069 assert!(again);
1070 let (rec, again) = paint_at(&mut w, None, 400.0);
1071 assert_eq!(knob_x(&rec), KNOB_OFFSET);
1072 assert!(!again);
1073 assert_eq!(rec.rrects[1].3, TOGGLE_KNOB_OFF);
1074 }
1075
1076 #[test]
1077 fn reduce_motion_paints_the_final_state_at_once_and_requests_no_frame() {
1078 let mut theme = crate::baseline();
1079 theme.motion.reduce_motion = true;
1080
1081 let prev = toggle::<ToggleState, _>(false, |_s, _v| {});
1082 let mut w = build(&prev);
1083 let next = toggle::<ToggleState, _>(true, |_s, _v| {});
1084 let mut counter = 0u64;
1085 View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1086
1087 let (rec, again) = paint_at(&mut w, Some(&theme), 0.0);
1090 assert!(!again, "reduce_motion owes no frame");
1091 assert_eq!(
1092 knob_x(&rec),
1093 KNOB_OFFSET + KNOB_TRAVEL,
1094 "the knob lands immediately"
1095 );
1096 assert_eq!(
1097 rec.rrects[1].3,
1098 theme.scheme().primary_container,
1099 "and the color lane is done too"
1100 );
1101
1102 let (rec, again) = paint_at(&mut w, Some(&theme), 400.0);
1104 assert!(!again);
1105 assert_eq!(knob_x(&rec), KNOB_OFFSET + KNOB_TRAVEL);
1106 }
1107
1108 #[test]
1109 fn reduce_motion_snaps_a_reversal_back_as_well() {
1110 let mut theme = crate::baseline();
1111 theme.motion.reduce_motion = true;
1112
1113 let prev = toggle::<ToggleState, _>(true, |_s, _v| {});
1114 let mut w = build(&prev);
1115 let next = toggle::<ToggleState, _>(false, |_s, _v| {});
1116 let mut counter = 0u64;
1117 View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1118
1119 let (rec, again) = paint_at(&mut w, Some(&theme), 0.0);
1120 assert!(!again);
1121 assert_eq!(knob_x(&rec), KNOB_OFFSET);
1122 assert_eq!(rec.rrects[1].3, theme.scheme().on_surface_variant);
1123 }
1124
1125 #[test]
1128 fn release_inside_reports_the_requested_value_once() {
1129 let mut w = widget(false);
1130 let mut state = ToggleState::default();
1131 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 8.0, 11.0));
1132 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 8.0, 11.0));
1133 assert_eq!(state.last, Some(true));
1134 assert_eq!(state.toggles, 1);
1135 assert!(!w.checked, "a controlled toggle never self-mutates");
1136
1137 let mut w = widget(true);
1138 let mut state = ToggleState::default();
1139 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 8.0, 11.0));
1140 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 8.0, 11.0));
1141 assert_eq!(state.last, Some(false));
1142 assert!(w.checked, "still checked until the app rebuilds it");
1143 }
1144
1145 #[test]
1146 fn release_outside_does_not_fire() {
1147 let mut w = widget(false);
1148 let mut state = ToggleState::default();
1149 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 8.0, 11.0));
1150 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 11.0));
1151 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 11.0));
1152 assert_eq!(state.toggles, 0);
1153 assert!(!w.captured, "the release disarms either way");
1154 }
1155
1156 #[test]
1157 fn cancel_disarms_the_press() {
1158 let mut w = widget(false);
1159 let mut state = ToggleState::default();
1160 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 8.0, 11.0));
1161 assert!(w.captured);
1162 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 8.0, 11.0));
1163 assert!(!w.captured);
1164 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 8.0, 11.0));
1166 assert_eq!(state.toggles, 0);
1167 }
1168
1169 #[test]
1170 fn an_unarmed_move_is_ignored() {
1171 let mut w = widget(false);
1172 let mut state = ToggleState::default();
1173 let state_any: &mut dyn Any = &mut state;
1174 let mut ctx = EventCtx::new(state_any, Point::ZERO, SLAB);
1175 let result = w.event(&mut ctx, &ev(PointerPhase::Move, 8.0, 11.0));
1176 assert!(matches!(result, EventResult::Ignored));
1177 assert!(!ctx.needs_redraw(), "a hover must not request a redraw");
1178 }
1179
1180 #[test]
1183 fn semantics_reports_a_labelled_switch_with_its_toggled_state() {
1184 fn logic(_s: &mut ToggleState) -> ToggleView<ToggleState> {
1188 toggle::<ToggleState, _>(true, |_s, _v| {}).label("auto-reconnect")
1189 }
1190 let mut root: frust_core::RenderRoot<ToggleState, ToggleView<ToggleState>> =
1191 frust_core::RenderRoot::new();
1192 let mut state = ToggleState::default();
1193 root.rebuild(&mut logic, &mut state);
1194 root.layout(Size::new(200.0, 200.0));
1195 let update = root.semantics();
1196
1197 let (_, node) = update
1198 .nodes
1199 .iter()
1200 .find(|(_, n)| n.role() == Role::Switch)
1201 .expect("the toggle contributes a Role::Switch node");
1202 assert_eq!(node.toggled(), Some(Toggled::True));
1203 assert!(node.supports_action(Action::Click));
1204 assert_eq!(node.label(), Some("auto-reconnect"));
1205 let bounds = node.bounds().expect("the switch node has bounds");
1208 assert_eq!(
1209 (bounds.x1 - bounds.x0, bounds.y1 - bounds.y0),
1210 (TOUCH_TARGET, TOUCH_TARGET)
1211 );
1212 }
1213
1214 #[test]
1215 fn semantics_mirrors_an_unchecked_unlabelled_toggle() {
1216 fn logic(_s: &mut ToggleState) -> ToggleView<ToggleState> {
1217 toggle::<ToggleState, _>(false, |_s, _v| {})
1218 }
1219 let mut root: frust_core::RenderRoot<ToggleState, ToggleView<ToggleState>> =
1220 frust_core::RenderRoot::new();
1221 let mut state = ToggleState::default();
1222 root.rebuild(&mut logic, &mut state);
1223 root.layout(Size::new(200.0, 200.0));
1224 let update = root.semantics();
1225
1226 let (_, node) = update
1227 .nodes
1228 .iter()
1229 .find(|(_, n)| n.role() == Role::Switch)
1230 .expect("the toggle contributes a Role::Switch node");
1231 assert_eq!(node.toggled(), Some(Toggled::False));
1232 assert_eq!(node.label(), None);
1233 }
1234
1235 #[test]
1236 fn a_label_change_alone_is_a_republish() {
1237 let prev = toggle::<ToggleState, _>(false, |_s, _v| {});
1238 let mut w = build(&prev);
1239 assert_eq!(w.label, None);
1240 let next = toggle::<ToggleState, _>(false, |_s, _v| {}).label("haptics");
1241 let mut counter = 0u64;
1242 let flags =
1243 View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1244 assert_eq!(w.label.as_deref(), Some("haptics"));
1245 assert!(
1246 flags.needs_paint(),
1247 "the semantics dirty gate rides `PAINT`"
1248 );
1249 }
1250}