1use std::rc::Rc;
131use std::time::Duration;
132
133use frust::authoring::{Action, Role, Toggled};
134use frust::authoring::{
135 BoxConstraints, BuildCtx, ChangeFlags, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx,
136 PaintScene, PointerPhase, SemanticsCtx, View, Widget,
137};
138use frust::{AnimationController, IconData, IconSource, SpringDesc, Theme, Tween};
139use kurbo::{Affine, BezPath, Point, Rect, RoundedRect, Shape, Size, Vec2};
140use peniko::{Brush, Color};
141
142use super::interaction::{HapticSignal, MaterialHaptics};
143use super::press::presses;
144use super::state_layer::StateLayer;
145
146const PATH_TOLERANCE: f64 = 0.1;
150
151const TRACK_W: f64 = 52.0;
156const TRACK_H: f64 = 32.0;
159const TRACK_PADDING: f64 = 4.0;
162const THUMB_PRESSED: f64 = 32.0;
167const THUMB_SELECTED: f64 = 24.0;
170const THUMB_UNSELECTED: f64 = 16.0;
173const STATE_LAYER_SIZE_DEFAULT: f64 = 48.0;
177const ICON_SIZE: f64 = 16.0;
181const BORDER_WIDTH: f64 = 2.0;
184const DISABLED_TRACK_OPACITY: f32 = 0.12;
187const DISABLED_THUMB_OPACITY: f32 = 0.38;
190const DISABLED_OUTLINE_OPACITY: f32 = 0.12;
193
194const TRACK_OFF: Color = Color::from_rgb8(0xE5, 0xE7, 0xEB);
197const TRACK_ON: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
200const THUMB_OFF: Color = Color::from_rgb8(0x9C, 0xA3, 0xAF);
203const THUMB_ON: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
206const FALLBACK_ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
211const FALLBACK_ON_PRIMARY_CONTAINER: Color = THUMB_ON;
217
218const POSITION_SPRING: SpringDesc = SpringDesc {
223 mass: 1.0,
224 stiffness: 380.0,
225 damping_ratio: 0.55,
226};
227const SIZE_SPRING: SpringDesc = SpringDesc {
232 mass: 1.0,
233 stiffness: 380.0,
234 damping_ratio: 0.7,
235};
236
237const RELEASE_VELOCITY: f64 = 1e-3;
242
243fn lerp_color_exact(begin: Color, end: Color, t: f64) -> Color {
252 if t <= 0.0 {
253 begin
254 } else if t >= 1.0 {
255 end
256 } else {
257 Tween::new(begin, end).lerp(t)
258 }
259}
260
261fn with_alpha(color: Color, alpha: f32) -> Color {
265 let c = color.components;
266 Color::new([c[0], c[1], c[2], alpha])
267}
268
269#[derive(Clone, Copy)]
273struct SwitchColors {
274 on_surface: Color,
275 surface_container_highest: Color,
276 primary: Color,
277 outline: Color,
278 on_primary: Color,
279 on_primary_container: Color,
280}
281
282fn resolve_switch_colors(theme: Option<&Theme>) -> SwitchColors {
287 match theme {
288 Some(theme) => {
289 let s = theme.scheme();
290 SwitchColors {
291 on_surface: s.on_surface,
292 surface_container_highest: s.surface_container_highest,
293 primary: s.primary,
294 outline: s.outline,
295 on_primary: s.on_primary,
296 on_primary_container: s.on_primary_container,
297 }
298 }
299 None => SwitchColors {
300 on_surface: FALLBACK_ON_SURFACE,
301 surface_container_highest: TRACK_OFF,
302 primary: TRACK_ON,
303 outline: THUMB_OFF,
304 on_primary: THUMB_ON,
305 on_primary_container: FALLBACK_ON_PRIMARY_CONTAINER,
306 },
307 }
308}
309
310fn track_color(c: SwitchColors, enabled: bool, value: bool) -> Color {
312 if !enabled {
313 with_alpha(
314 if value {
315 c.on_surface
316 } else {
317 c.surface_container_highest
318 },
319 DISABLED_TRACK_OPACITY,
320 )
321 } else if value {
322 c.primary
323 } else {
324 c.surface_container_highest
325 }
326}
327
328fn thumb_color(c: SwitchColors, enabled: bool, value: bool) -> Color {
330 if !enabled {
331 with_alpha(c.on_surface, DISABLED_THUMB_OPACITY)
332 } else if value {
333 c.on_primary
334 } else {
335 c.outline
336 }
337}
338
339fn outline_color(c: SwitchColors, enabled: bool) -> Color {
341 if !enabled {
342 with_alpha(c.on_surface, DISABLED_OUTLINE_OPACITY)
343 } else {
344 c.outline
345 }
346}
347
348fn icon_color(c: SwitchColors, value: bool) -> Color {
352 if value {
353 c.on_primary_container
354 } else {
355 c.surface_container_highest
356 }
357}
358
359fn state_layer_color(c: SwitchColors, value: bool) -> Color {
363 if value { c.primary } else { c.on_surface }
364}
365
366fn thumb_origin(pos_frac: f64, diam: f64) -> (f64, f64) {
370 let max_w = TRACK_W - 2.0 * TRACK_PADDING;
371 let max_h = TRACK_H - 2.0 * TRACK_PADDING;
372 let bleed = if diam > max_h {
373 (diam - max_h) / 2.0
374 } else {
375 0.0
376 };
377 let left = TRACK_PADDING - bleed + (max_w - diam + 2.0 * bleed) * pos_frac;
378 let top = TRACK_PADDING + (max_h - diam) / 2.0;
379 (left, top)
380}
381
382fn same_icon_source(a: Option<IconSource>, b: Option<IconSource>) -> bool {
386 match (a, b) {
387 (Some(x), Some(y)) => x.d == y.d && x.design == y.design,
388 (None, None) => true,
389 _ => false,
390 }
391}
392
393fn resolve_icon(source: Option<IconSource>) -> Option<(BezPath, f64)> {
396 source.map(|s| IconData::from(s).resolve())
397}
398
399type OnToggle<State> = Rc<dyn Fn(&mut State, bool)>;
401
402pub struct SwitchView<State: 'static> {
404 checked: bool,
405 on_toggle: OnToggle<State>,
406 enabled: bool,
407 selected_icon: Option<IconSource>,
408 unselected_icon: Option<IconSource>,
409 state_layer_size: Option<f64>,
410}
411
412pub fn switch<State: 'static, F: Fn(&mut State, bool) + 'static>(
415 checked: bool,
416 on_toggle: F,
417) -> SwitchView<State> {
418 SwitchView {
419 checked,
420 on_toggle: Rc::new(on_toggle),
421 enabled: true,
422 selected_icon: None,
423 unselected_icon: None,
424 state_layer_size: None,
425 }
426}
427
428#[allow(non_snake_case)]
430pub fn Switch<State: 'static, F: Fn(&mut State, bool) + 'static>(
431 checked: bool,
432 on_toggle: F,
433) -> SwitchView<State> {
434 switch(checked, on_toggle)
435}
436
437impl<State: 'static> SwitchView<State> {
438 pub fn enabled(mut self, enabled: bool) -> Self {
443 self.enabled = enabled;
444 self
445 }
446
447 pub fn selected_icon(mut self, icon: IconSource) -> Self {
450 self.selected_icon = Some(icon);
451 self
452 }
453
454 pub fn unselected_icon(mut self, icon: IconSource) -> Self {
457 self.unselected_icon = Some(icon);
458 self
459 }
460
461 pub fn state_layer_size(mut self, size: f64) -> Self {
465 self.state_layer_size = Some(size);
466 self
467 }
468}
469
470pub struct SwitchWidget {
472 checked: bool,
474 enabled: bool,
475 position_anim: AnimationController,
477 size_anim: AnimationController,
479 anim_target: bool,
484 state_layer: StateLayer,
485 pressed: bool,
489 captured: bool,
491 selected_icon: Option<IconSource>,
492 unselected_icon: Option<IconSource>,
493 selected_icon_geom: Option<(BezPath, f64)>,
494 unselected_icon_geom: Option<(BezPath, f64)>,
495 state_layer_size: Option<f64>,
496 on_toggle: frust::authoring::ErasedArgCallback<bool>,
497}
498
499fn inside(pos: Point, size: Size) -> bool {
500 pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
501}
502
503impl<State: 'static> View<State> for SwitchView<State> {
504 type Element = SwitchWidget;
505
506 fn build(&self, _ctx: &mut BuildCtx<'_>) -> SwitchWidget {
507 let mut position_anim = AnimationController::new(Duration::ZERO);
511 let mut size_anim = AnimationController::new(Duration::ZERO);
512 if self.checked {
513 position_anim.forward();
514 size_anim.forward();
515 }
516 SwitchWidget {
517 checked: self.checked,
518 enabled: self.enabled,
519 position_anim,
520 size_anim,
521 anim_target: self.checked,
522 state_layer: StateLayer::new(),
523 pressed: false,
524 captured: false,
525 selected_icon: self.selected_icon,
526 unselected_icon: self.unselected_icon,
527 selected_icon_geom: resolve_icon(self.selected_icon),
528 unselected_icon_geom: resolve_icon(self.unselected_icon),
529 state_layer_size: self.state_layer_size,
530 on_toggle: frust::authoring::erase_callback_arg(&self.on_toggle),
531 }
532 }
533
534 fn rebuild(
535 &self,
536 prev: &Self,
537 element: &mut SwitchWidget,
538 _ctx: &mut BuildCtx<'_>,
539 ) -> ChangeFlags {
540 element.on_toggle = frust::authoring::erase_callback_arg(&self.on_toggle);
541 let mut flags = ChangeFlags::NONE;
542
543 if prev.checked != self.checked {
544 element.checked = self.checked;
548 flags |= ChangeFlags::PAINT;
549 }
550 if prev.enabled != self.enabled {
551 element.enabled = self.enabled;
552 if !self.enabled {
553 element.pressed = false;
557 element.captured = false;
558 element.state_layer.set_pressed(false);
559 }
560 flags |= ChangeFlags::PAINT;
561 }
562 if prev.state_layer_size != self.state_layer_size {
563 element.state_layer_size = self.state_layer_size;
564 flags |= ChangeFlags::PAINT;
565 }
566 if !same_icon_source(prev.selected_icon, self.selected_icon) {
567 element.selected_icon = self.selected_icon;
568 element.selected_icon_geom = resolve_icon(self.selected_icon);
569 flags |= ChangeFlags::PAINT;
570 }
571 if !same_icon_source(prev.unselected_icon, self.unselected_icon) {
572 element.unselected_icon = self.unselected_icon;
573 element.unselected_icon_geom = resolve_icon(self.unselected_icon);
574 flags |= ChangeFlags::PAINT;
575 }
576
577 flags
578 }
579}
580
581impl Widget for SwitchWidget {
582 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
583 bc.constrain(Size::new(TRACK_W, TRACK_H))
584 }
585
586 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
587 let theme = Theme::from_paint_ctx(ctx);
588 let colors = resolve_switch_colors(theme);
589
590 if self.checked != self.anim_target {
591 let velocity = if self.checked {
592 RELEASE_VELOCITY
593 } else {
594 -RELEASE_VELOCITY
595 };
596 self.position_anim.fling(velocity, POSITION_SPRING);
597 self.size_anim.fling(velocity, SIZE_SPRING);
598 self.anim_target = self.checked;
599 }
600 let position_animating = self.position_anim.advance(ctx.frame_time());
601 let size_animating = self.size_anim.advance(ctx.frame_time());
602 if position_animating || size_animating {
603 ctx.request_frame();
604 }
605
606 let pos_frac = self.position_anim.value();
610 let pos_frac_clamped = self.position_anim.value_clamped();
611 let size_frac = self.size_anim.value();
612 let size_frac_clamped = self.size_anim.value_clamped().clamp(0.0, 1.0);
613
614 let o = ctx.origin();
615
616 let track_off = track_color(colors, self.enabled, false);
620 let track_on = track_color(colors, self.enabled, true);
621 let track_fill = lerp_color_exact(track_off, track_on, pos_frac_clamped);
622 scene.fill_rounded_rect(o, Size::new(TRACK_W, TRACK_H), TRACK_H / 2.0, track_fill);
623
624 if !self.checked {
627 let outline = outline_color(colors, self.enabled);
628 let inset = BORDER_WIDTH / 2.0;
629 let border_rect = Rect::new(inset, inset, TRACK_W - inset, TRACK_H - inset);
630 let border_radius = TRACK_H / 2.0 - inset;
631 let border_path =
632 RoundedRect::from_rect(border_rect, border_radius).to_path(PATH_TOLERANCE);
633 scene.stroke_path(o, &border_path, BORDER_WIDTH, &Brush::Solid(outline));
634 }
635
636 let base_diam = THUMB_UNSELECTED + size_frac * (THUMB_SELECTED - THUMB_UNSELECTED);
637 let diam = if self.pressed {
638 THUMB_PRESSED
639 } else {
640 base_diam
641 };
642 let (left, top) = thumb_origin(pos_frac, diam);
643
644 let state_layer_size = self.state_layer_size.unwrap_or(STATE_LAYER_SIZE_DEFAULT);
645 let center = Point::new(o.x + left + diam / 2.0, o.y + top + diam / 2.0);
646 self.state_layer.paint(
647 ctx,
648 scene,
649 Rect::from_center_size(center, Size::new(state_layer_size, state_layer_size)),
650 state_layer_size / 2.0,
651 state_layer_color(colors, self.checked),
652 );
653
654 let thumb_off = thumb_color(colors, self.enabled, false);
655 let thumb_on = thumb_color(colors, self.enabled, true);
656 let thumb_fill = lerp_color_exact(thumb_off, thumb_on, pos_frac_clamped);
657 scene.fill_rounded_rect(
658 Point::new(o.x + left, o.y + top),
659 Size::new(diam, diam),
660 diam / 2.0,
661 thumb_fill,
662 );
663
664 let icon_geom = if self.checked {
666 &self.selected_icon_geom
667 } else {
668 &self.unselected_icon_geom
669 };
670 if let Some((base_path, design)) = icon_geom {
671 let opacity = size_frac_clamped as f32;
672 if opacity > 0.0 {
673 let icon_render_scale = 0.5 + 0.5 * size_frac_clamped;
674 let effective = ICON_SIZE * icon_render_scale;
675 let path_scale = if *design > 0.0 {
676 effective / design
677 } else {
678 1.0
679 };
680 let icon_local = Point::new(
681 left + diam / 2.0 - effective / 2.0,
682 top + diam / 2.0 - effective / 2.0,
683 );
684 let transform = Affine::translate(Vec2::new(icon_local.x, icon_local.y))
685 * Affine::scale(path_scale);
686 let scaled_path = transform * base_path.clone();
687 let icon_fill = with_alpha(icon_color(colors, self.checked), opacity);
688 scene.fill_path(o, &scaled_path, &Brush::Solid(icon_fill));
689 }
690 }
691 }
692
693 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
694 let InputEvent::Pointer(p) = event else {
695 return EventResult::Ignored;
696 };
697 match p.phase {
698 PointerPhase::Down => {
699 if !self.enabled || !presses(p) {
700 return EventResult::Ignored;
701 }
702 self.pressed = true;
703 self.captured = true;
704 self.state_layer.set_pressed(true);
705 ctx.capture_pointer();
706 ctx.request_redraw();
707 EventResult::Handled
708 }
709 PointerPhase::Move => {
710 if !self.captured {
711 return EventResult::Ignored;
712 }
713 let inside_now = inside(p.position, ctx.size());
714 self.pressed = inside_now;
715 self.state_layer.set_pressed(inside_now);
716 ctx.request_redraw();
717 EventResult::Handled
718 }
719 PointerPhase::Up => {
720 if !self.captured {
721 return EventResult::Ignored;
722 }
723 if self.enabled && inside(p.position, ctx.size()) {
729 MaterialHaptics::fire(HapticSignal::None);
733 let requested = !self.checked;
735 (self.on_toggle)(ctx, requested);
736 }
737 self.pressed = false;
738 self.captured = false;
739 self.state_layer.set_pressed(false);
740 ctx.request_redraw();
741 EventResult::Handled
742 }
743 PointerPhase::Cancel => {
744 if !self.captured {
745 return EventResult::Ignored;
746 }
747 self.pressed = false;
748 self.captured = false;
749 self.state_layer.set_pressed(false);
750 ctx.request_redraw();
751 EventResult::Handled
752 }
753 }
754 }
755
756 fn semantics(&self, ctx: &mut SemanticsCtx) {
757 ctx.push_node(Role::Switch, |node| {
758 node.set_toggled(Toggled::from(self.checked));
759 if self.enabled {
760 node.add_action(Action::Click);
761 }
762 });
763 }
764}
765
766#[cfg(test)]
767mod tests {
768 use super::*;
769 use frust::FrameTime;
770 use std::any::Any;
771
772 fn ft_secs(s: f64) -> FrameTime {
773 FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
774 }
775
776 #[derive(Default)]
777 struct ToggleState {
778 last: Option<bool>,
779 toggles: u32,
780 }
781
782 fn widget(checked: bool) -> SwitchWidget {
783 let view = switch::<ToggleState, _>(checked, |s: &mut ToggleState, v: bool| {
784 s.last = Some(v);
785 s.toggles += 1;
786 });
787 let mut counter = 0u64;
788 View::<ToggleState>::build(&view, &mut BuildCtx::new(&mut counter))
789 }
790
791 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
792 InputEvent::Pointer(frust::authoring::PointerEvent {
793 phase,
794 position: Point::new(x, y),
795 button: frust::authoring::PointerButton::Primary,
796 })
797 }
798
799 fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
801 InputEvent::Pointer(frust::authoring::PointerEvent {
802 phase,
803 position: Point::new(x, y),
804 button: frust::authoring::PointerButton::Secondary,
805 })
806 }
807
808 fn dispatch(w: &mut SwitchWidget, state: &mut ToggleState, event: &InputEvent) {
809 let state_any: &mut dyn Any = state;
810 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(TRACK_W, TRACK_H));
811 w.event(&mut ctx, event);
812 }
813
814 #[test]
815 fn a_secondary_press_never_presses_captures_or_toggles() {
816 let mut w = widget(false);
817 let mut state = ToggleState::default();
818 dispatch(
819 &mut w,
820 &mut state,
821 &secondary_ev(PointerPhase::Down, 5.0, 12.0),
822 );
823 assert!(!w.pressed, "no pressed state layer on a right-click");
824 assert!(!w.captured, "and no capture for the shell to wedge on");
825 dispatch(
826 &mut w,
827 &mut state,
828 &secondary_ev(PointerPhase::Up, 5.0, 12.0),
829 );
830 assert_eq!(state.toggles, 0);
831
832 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
834 assert!(w.pressed);
835 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
836 assert_eq!(state.toggles, 1);
837 }
838
839 #[test]
840 fn off_fires_true_and_does_not_self_toggle() {
841 let mut w = widget(false);
842 let mut state = ToggleState::default();
843 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
844 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
845 assert_eq!(state.last, Some(true));
846 assert_eq!(state.toggles, 1);
847 assert!(!w.checked, "switch must not mutate its own checked flag");
848 }
849
850 #[test]
851 fn on_fires_false() {
852 let mut w = widget(true);
853 let mut state = ToggleState::default();
854 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
855 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
856 assert_eq!(state.last, Some(false));
857 assert!(w.checked, "still checked until the app rebuilds it");
858 }
859
860 #[test]
861 fn up_outside_does_not_fire() {
862 let mut w = widget(false);
863 let mut state = ToggleState::default();
864 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
865 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 12.0));
866 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 12.0));
867 assert_eq!(state.toggles, 0);
868 }
869
870 #[test]
871 fn hover_move_without_down_is_ignored_noop() {
872 let mut w = widget(false);
873 let mut state = ToggleState::default();
874 let state_any: &mut dyn Any = &mut state;
875 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(TRACK_W, TRACK_H));
876 let result = w.event(&mut ctx, &ev(PointerPhase::Move, 5.0, 12.0));
877 assert!(matches!(result, EventResult::Ignored));
878 assert!(!w.pressed, "hover must not press");
879 assert!(!ctx.needs_redraw(), "hover must not request a redraw");
880 assert_eq!(state.toggles, 0);
881 }
882
883 #[test]
884 fn cancel_clears_armed_state() {
885 let mut w = widget(false);
886 let mut state = ToggleState::default();
887 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
888 assert!(w.captured);
889 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 5.0, 12.0));
890 assert!(!w.captured, "Cancel disarms the press");
891 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
892 assert_eq!(state.toggles, 0);
893 }
894
895 #[test]
896 fn disabled_ignores_pointer_and_never_toggles() {
897 let view = switch::<ToggleState, _>(false, |s: &mut ToggleState, v: bool| {
898 s.last = Some(v);
899 s.toggles += 1;
900 })
901 .enabled(false);
902 let mut counter = 0u64;
903 let mut w = View::<ToggleState>::build(&view, &mut BuildCtx::new(&mut counter));
904 let mut state = ToggleState::default();
905 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
906 assert!(!w.pressed, "disabled never presses");
907 assert!(!w.captured, "disabled never captures");
908 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
909 assert_eq!(state.toggles, 0, "disabled never toggles");
910 }
911
912 #[test]
913 fn rebuild_adopts_new_checked_value_without_self_mutation() {
914 let mut counter = 0u64;
915 let prev = switch::<ToggleState, _>(false, |_s, _v| {});
916 let mut w = View::<ToggleState>::build(&prev, &mut BuildCtx::new(&mut counter));
917 assert!(!w.checked);
918 assert!(!w.anim_target);
919 let next = switch::<ToggleState, _>(true, |_s, _v| {});
920 let flags =
921 View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
922 assert!(w.checked, "the confirmed value adopts immediately");
923 assert!(
924 !w.anim_target,
925 "both flings only start lazily in paint, once a theme is in scope"
926 );
927 assert!(flags.needs_paint());
928 }
929
930 #[test]
931 fn disabling_mid_press_clears_the_press_and_the_capture() {
932 let mut counter = 0u64;
933 let prev = switch::<ToggleState, _>(false, |_s, _v| {});
934 let mut w = View::<ToggleState>::build(&prev, &mut BuildCtx::new(&mut counter));
935 w.pressed = true;
936 w.captured = true;
937 w.state_layer.set_pressed(true);
938
939 let next = switch::<ToggleState, _>(false, |_s, _v| {}).enabled(false);
940 let flags =
941 View::<ToggleState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
942 assert!(!w.pressed);
943 assert!(!w.captured);
944 assert_eq!(
945 w.state_layer.opacity(),
946 0.0,
947 "the state-layer's own pressed flag clears too"
948 );
949 assert!(flags.needs_paint());
950 }
951
952 #[test]
953 fn disabled_up_fires_no_callback_even_if_still_captured() {
954 let mut w = widget(false);
963 let mut state = ToggleState::default();
964 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
965 assert!(w.captured);
966 w.enabled = false;
967 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
968 assert_eq!(
969 state.toggles, 0,
970 "a disabled switch must not report a toggle on Up"
971 );
972 }
973
974 #[derive(Default)]
977 struct RRectRecorder {
978 rrects: Vec<(Point, Size, f64, Color)>,
979 }
980
981 impl PaintScene for RRectRecorder {
982 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
983 fn draw_text(&mut self, _o: Point, _t: &str) {}
984 fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
985 self.rrects.push((o, s, radius, color));
986 }
987 }
988
989 fn paint_rec(w: &mut SwitchWidget, theme: Option<&Theme>) -> RRectRecorder {
990 let mut rec = RRectRecorder::default();
991 let mut ctx = match theme {
992 Some(t) => PaintCtx::new(Point::ZERO, Size::new(TRACK_W, TRACK_H)).with_theme(t),
993 None => PaintCtx::new(Point::ZERO, Size::new(TRACK_W, TRACK_H)),
994 };
995 w.paint(&mut ctx, &mut rec);
996 rec
997 }
998
999 #[test]
1000 fn unthemed_paint_uses_fallback_constants() {
1001 let mut off = widget(false);
1002 let rec = paint_rec(&mut off, None);
1003 assert_eq!(rec.rrects.len(), 2, "no active state layer paints nothing");
1008 assert_eq!(rec.rrects[0].3, TRACK_OFF);
1009 assert_eq!(rec.rrects[0].2, TRACK_H / 2.0, "pill radius");
1010 assert_eq!(rec.rrects[1].3, THUMB_OFF);
1011 assert_eq!(
1012 rec.rrects[1].1,
1013 Size::new(THUMB_UNSELECTED, THUMB_UNSELECTED)
1014 );
1015
1016 let mut on = widget(true);
1017 let rec = paint_rec(&mut on, None);
1018 assert_eq!(rec.rrects[0].3, TRACK_ON);
1019 assert_eq!(rec.rrects[1].3, THUMB_ON);
1020 assert_eq!(rec.rrects[1].1, Size::new(THUMB_SELECTED, THUMB_SELECTED));
1021 }
1022
1023 #[test]
1024 fn themed_paint_resolves_switch_tokens() {
1025 let theme = crate::baseline();
1026 let scheme = theme.scheme();
1027 let mut off = widget(false);
1028 let rec = paint_rec(&mut off, Some(&theme));
1029 assert_eq!(rec.rrects[0].3, scheme.surface_container_highest);
1030 assert_eq!(rec.rrects[1].3, scheme.outline);
1031
1032 let mut on = widget(true);
1033 let rec = paint_rec(&mut on, Some(&theme));
1034 assert_eq!(rec.rrects[0].3, scheme.primary);
1035 assert_eq!(rec.rrects[1].3, scheme.on_primary);
1036 }
1037
1038 #[test]
1039 fn pressed_thumb_expands_regardless_of_state() {
1040 let mut w = widget(false);
1041 w.pressed = true;
1042 let rec = paint_rec(&mut w, None);
1043 assert_eq!(rec.rrects[1].1, Size::new(THUMB_PRESSED, THUMB_PRESSED));
1044 }
1045
1046 #[test]
1047 fn paint_starts_both_flings_when_checked_disagrees_with_anim_target() {
1048 let mut w = widget(false);
1049 w.checked = true; assert!(!w.position_anim.is_animating());
1051 assert!(!w.size_anim.is_animating());
1052 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(TRACK_W, TRACK_H));
1053 let mut rec = RRectRecorder::default();
1054 w.paint(&mut ctx, &mut rec);
1055 assert!(w.anim_target, "paint syncs the animation target");
1056 assert!(
1057 w.position_anim.is_animating(),
1058 "a position fling toward the new target started"
1059 );
1060 assert!(
1061 w.size_anim.is_animating(),
1062 "a size fling toward the new target started"
1063 );
1064 assert!(
1065 ctx.needs_frame(),
1066 "an in-flight fling must request another frame"
1067 );
1068 }
1069
1070 #[test]
1071 fn both_springs_progress_toward_and_settle_at_target() {
1072 let mut w = widget(false);
1073 w.checked = true;
1074 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(TRACK_W, TRACK_H));
1076 let mut rec = RRectRecorder::default();
1077 w.paint(&mut ctx, &mut rec);
1078
1079 let mut position_running = true;
1083 let mut size_running = true;
1084 let mut t = 0.0;
1085 for _ in 0..100_000 {
1086 position_running = w.position_anim.advance(ft_secs(t));
1087 size_running = w.size_anim.advance(ft_secs(t));
1088 if !position_running && !size_running {
1089 break;
1090 }
1091 t += 1.0 / 120.0;
1092 }
1093 assert!(!position_running, "position fling failed to settle");
1094 assert!(!size_running, "size fling failed to settle");
1095 assert!((w.position_anim.value() - 1.0).abs() < 1e-6);
1096 assert!((w.size_anim.value() - 1.0).abs() < 1e-6);
1097 }
1098
1099 #[test]
1100 fn spring_specs_match_the_m3e_reference_table() {
1101 let table = [
1106 ("position", POSITION_SPRING, 380.0_f64, 0.55_f64),
1107 ("size", SIZE_SPRING, 380.0_f64, 0.7_f64),
1108 ];
1109 for (name, spring, stiffness, damping_ratio) in table {
1110 assert_eq!(spring.mass, 1.0, "{name} spring mass");
1111 assert_eq!(spring.stiffness, stiffness, "{name} spring stiffness");
1112 assert_eq!(
1113 spring.damping_ratio, damping_ratio,
1114 "{name} spring damping ratio"
1115 );
1116 }
1117 }
1118
1119 #[test]
1120 fn thumb_rests_at_spec_exact_positions_off_and_on() {
1121 let mut off = widget(false);
1124 let rec = paint_rec(&mut off, None);
1125 assert_eq!(rec.rrects[1].0, Point::new(4.0, 8.0));
1126 assert_eq!(rec.rrects[1].1, Size::new(16.0, 16.0));
1127
1128 let mut on = widget(true);
1131 let rec = paint_rec(&mut on, None);
1132 assert_eq!(rec.rrects[1].0, Point::new(24.0, 4.0));
1133 assert_eq!(rec.rrects[1].1, Size::new(24.0, 24.0));
1134 }
1135
1136 #[test]
1137 fn thumb_travel_is_monotone_toward_target_mid_flight() {
1138 let mut w = widget(false);
1139 w.checked = true;
1140 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(TRACK_W, TRACK_H));
1146 let mut rec = RRectRecorder::default();
1147 w.paint(&mut ctx, &mut rec);
1148
1149 let mut xs = Vec::new();
1153 let mut t = 0.0;
1154 for _ in 0..6 {
1155 t += 1.0 / 120.0;
1156 w.position_anim.advance(ft_secs(t));
1157 w.size_anim.advance(ft_secs(t));
1158 let diam = THUMB_UNSELECTED + w.size_anim.value() * (THUMB_SELECTED - THUMB_UNSELECTED);
1159 let (left, _top) = thumb_origin(w.position_anim.value(), diam);
1160 xs.push(left);
1161 }
1162 for pair in xs.windows(2) {
1163 assert!(
1164 pair[1] > pair[0],
1165 "thumb x-origin must move monotonically toward the target mid-flight: {xs:?}"
1166 );
1167 }
1168 }
1169
1170 #[test]
1171 fn icon_paints_selected_variant_at_full_opacity_when_on_at_rest() {
1172 let view = switch::<ToggleState, _>(true, |_s, _v| {})
1173 .selected_icon(crate::icons::CHECK)
1174 .unselected_icon(crate::icons::CLOSE);
1175 let mut counter = 0u64;
1176 let mut w = View::<ToggleState>::build(&view, &mut BuildCtx::new(&mut counter));
1177
1178 #[derive(Default)]
1179 struct IconRecorder {
1180 fills: Vec<(BezPath, Color)>,
1181 }
1182 impl PaintScene for IconRecorder {
1183 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1184 fn draw_text(&mut self, _o: Point, _t: &str) {}
1185 fn fill_path(&mut self, _o: Point, path: &BezPath, brush: &Brush) {
1186 let Brush::Solid(color) = brush else {
1187 panic!("expected a solid brush");
1188 };
1189 self.fills.push((path.clone(), *color));
1190 }
1191 }
1192
1193 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(TRACK_W, TRACK_H));
1194 let mut rec = IconRecorder::default();
1195 w.paint(&mut ctx, &mut rec);
1196 assert_eq!(rec.fills.len(), 1, "exactly one icon paints");
1197 assert_eq!(
1198 rec.fills[0].1.components[3], 1.0,
1199 "fully opaque at rest (size fraction 1.0)"
1200 );
1201 let (unselected_path, unselected_design) = IconData::from(crate::icons::CLOSE).resolve();
1204 let unselected_scale = ICON_SIZE / unselected_design;
1205 let unselected_bounds = (Affine::scale(unselected_scale) * unselected_path).bounding_box();
1206 assert_ne!(
1207 rec.fills[0].0.bounding_box().width(),
1208 unselected_bounds.width(),
1209 "must not paint the unselected icon while checked"
1210 );
1211 }
1212
1213 #[test]
1214 fn icon_selects_unselected_variant_from_confirmed_value_even_mid_size_flight() {
1215 let view = switch::<ToggleState, _>(false, |_s, _v| {})
1220 .selected_icon(crate::icons::CHECK)
1221 .unselected_icon(crate::icons::CLOSE);
1222 let mut counter = 0u64;
1223 let mut w = View::<ToggleState>::build(&view, &mut BuildCtx::new(&mut counter));
1224 w.size_anim.forward(); #[derive(Default)]
1227 struct IconRecorder {
1228 count: u32,
1229 }
1230 impl PaintScene for IconRecorder {
1231 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1232 fn draw_text(&mut self, _o: Point, _t: &str) {}
1233 fn fill_path(&mut self, _o: Point, _p: &BezPath, _b: &Brush) {
1234 self.count += 1;
1235 }
1236 }
1237 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(TRACK_W, TRACK_H));
1238 let mut rec = IconRecorder::default();
1239 w.paint(&mut ctx, &mut rec);
1240 assert_eq!(
1241 rec.count, 1,
1242 "the unselected icon still paints (opacity 1.0)"
1243 );
1244 }
1245
1246 #[test]
1247 fn no_icon_configured_paints_nothing() {
1248 let mut w = widget(true);
1249 #[derive(Default)]
1250 struct IconRecorder {
1251 count: u32,
1252 }
1253 impl PaintScene for IconRecorder {
1254 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1255 fn draw_text(&mut self, _o: Point, _t: &str) {}
1256 fn fill_path(&mut self, _o: Point, _p: &BezPath, _b: &Brush) {
1257 self.count += 1;
1258 }
1259 }
1260 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(TRACK_W, TRACK_H));
1261 let mut rec = IconRecorder::default();
1262 w.paint(&mut ctx, &mut rec);
1263 assert_eq!(rec.count, 0);
1264 }
1265
1266 #[test]
1267 fn off_track_paints_an_outline_border_on_paints_none() {
1268 #[derive(Default)]
1269 struct StrokeRecorder {
1270 strokes: u32,
1271 }
1272 impl PaintScene for StrokeRecorder {
1273 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1274 fn draw_text(&mut self, _o: Point, _t: &str) {}
1275 fn stroke_path(&mut self, _o: Point, _p: &BezPath, _w: f64, _b: &Brush) {
1276 self.strokes += 1;
1277 }
1278 }
1279 let mut off = widget(false);
1280 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(TRACK_W, TRACK_H));
1281 let mut rec = StrokeRecorder::default();
1282 off.paint(&mut ctx, &mut rec);
1283 assert_eq!(rec.strokes, 1, "off track paints its outline ring");
1284
1285 let mut on = widget(true);
1286 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(TRACK_W, TRACK_H));
1287 let mut rec = StrokeRecorder::default();
1288 on.paint(&mut ctx, &mut rec);
1289 assert_eq!(rec.strokes, 0, "on track paints no outline ring");
1290 }
1291
1292 #[test]
1293 fn custom_state_layer_size_flows_into_paint() {
1294 let view = switch::<ToggleState, _>(false, |_s, _v| {}).state_layer_size(64.0);
1295 let mut counter = 0u64;
1296 let mut w = View::<ToggleState>::build(&view, &mut BuildCtx::new(&mut counter));
1297 w.pressed = true;
1298 w.state_layer.set_pressed(true);
1299 let rec = paint_rec(&mut w, None);
1300 assert_eq!(rec.rrects.len(), 3, "track, state layer, thumb");
1301 assert_eq!(rec.rrects[1].1, Size::new(64.0, 64.0));
1302 assert_eq!(rec.rrects[1].2, 32.0, "radius is half the custom diameter");
1303 }
1304
1305 #[test]
1306 fn color_table_matches_the_m3e_theme_for_every_enabled_value_combo() {
1307 let theme = crate::baseline();
1308 let scheme = theme.scheme();
1309 let colors = resolve_switch_colors(Some(&theme));
1310
1311 for enabled in [true, false] {
1312 for value in [true, false] {
1313 let track = track_color(colors, enabled, value);
1314 let thumb = thumb_color(colors, enabled, value);
1315 let expected_track = if !enabled {
1316 with_alpha(
1317 if value {
1318 scheme.on_surface
1319 } else {
1320 scheme.surface_container_highest
1321 },
1322 DISABLED_TRACK_OPACITY,
1323 )
1324 } else if value {
1325 scheme.primary
1326 } else {
1327 scheme.surface_container_highest
1328 };
1329 let expected_thumb = if !enabled {
1330 with_alpha(scheme.on_surface, DISABLED_THUMB_OPACITY)
1331 } else if value {
1332 scheme.on_primary
1333 } else {
1334 scheme.outline
1335 };
1336 assert_eq!(track, expected_track, "enabled={enabled} value={value}");
1337 assert_eq!(thumb, expected_thumb, "enabled={enabled} value={value}");
1338 }
1339 let outline = outline_color(colors, enabled);
1340 let expected_outline = if !enabled {
1341 with_alpha(scheme.on_surface, DISABLED_OUTLINE_OPACITY)
1342 } else {
1343 scheme.outline
1344 };
1345 assert_eq!(outline, expected_outline, "enabled={enabled}");
1346 }
1347
1348 assert_eq!(icon_color(colors, true), scheme.on_primary_container);
1350 assert_eq!(icon_color(colors, false), scheme.surface_container_highest);
1351 assert_eq!(state_layer_color(colors, true), scheme.primary);
1352 assert_eq!(state_layer_color(colors, false), scheme.on_surface);
1353 }
1354
1355 #[test]
1356 fn semantics_reports_switch_role_toggled_state_and_bounds() {
1357 fn logic(_state: &mut ToggleState) -> SwitchView<ToggleState> {
1363 switch::<ToggleState, _>(true, |_s, _v| {})
1364 }
1365 let mut root: frust_core::RenderRoot<ToggleState, SwitchView<ToggleState>> =
1366 frust_core::RenderRoot::new();
1367 let mut state = ToggleState::default();
1368 root.rebuild(&mut logic, &mut state);
1369 root.layout(Size::new(200.0, 200.0));
1370 let update = root.semantics();
1371
1372 let (_, node) = update
1373 .nodes
1374 .iter()
1375 .find(|(_, n)| n.role() == Role::Switch)
1376 .expect("switch contributes a Role::Switch node");
1377 assert_eq!(node.toggled(), Some(Toggled::True));
1378 assert!(node.supports_action(Action::Click));
1379 let bounds = node.bounds().expect("switch node has bounds");
1380 assert_eq!((bounds.x0, bounds.y0), (0.0, 0.0));
1381 assert_eq!(
1382 (bounds.x1 - bounds.x0, bounds.y1 - bounds.y0),
1383 (TRACK_W, TRACK_H)
1384 );
1385 }
1386
1387 #[test]
1388 fn semantics_omits_click_action_when_disabled() {
1389 fn logic(_state: &mut ToggleState) -> SwitchView<ToggleState> {
1390 switch::<ToggleState, _>(true, |_s, _v| {}).enabled(false)
1391 }
1392 let mut root: frust_core::RenderRoot<ToggleState, SwitchView<ToggleState>> =
1393 frust_core::RenderRoot::new();
1394 let mut state = ToggleState::default();
1395 root.rebuild(&mut logic, &mut state);
1396 root.layout(Size::new(200.0, 200.0));
1397 let update = root.semantics();
1398
1399 let (_, node) = update
1400 .nodes
1401 .iter()
1402 .find(|(_, n)| n.role() == Role::Switch)
1403 .expect("switch contributes a Role::Switch node");
1404 assert!(!node.supports_action(Action::Click));
1405 }
1406}