1use frus_core::{Color, Rect, Scene};
4use frus_layout::{Dimension, Style};
5
6use crate::disabled::DISABLED_CONTAINER_OPACITY;
7use crate::disabled::{disabled_container, disabled_content, disabled_mark, over_surface};
8use crate::icons::IconData;
9use crate::interaction::Status;
10use crate::theme::{TapTarget, Theme};
11use crate::widget::Widget;
12
13const W: f32 = 52.0;
15const H: f32 = 32.0;
16const THUMB_OFF: f32 = 8.0;
21const THUMB_ON: f32 = 12.0;
22const THUMB_PRESSED: f32 = 14.0;
26const THUMB_WITH_ICON: f32 = 12.0;
30const ICON_SIZE: f32 = 16.0;
32const TRACK_OUTLINE: f32 = 2.0;
34
35pub struct Switch<Msg = crate::callback::Callback> {
37 on: bool,
38 enabled: bool,
39 track_color: Option<Color>,
40 inactive_track_color: Option<Color>,
41 thumb_color: Option<Color>,
42 inactive_thumb_color: Option<Color>,
43 thumb_icon: Option<IconData>,
44 inactive_thumb_icon: Option<IconData>,
45 tap_target: Option<TapTarget>,
46 on_toggle: Option<Box<dyn Fn(bool) -> Msg>>,
47}
48
49impl<Msg> Switch<Msg> {
50 pub fn new(on: bool) -> Self {
52 Self {
53 on,
54 enabled: true,
55 track_color: None,
56 inactive_track_color: None,
57 thumb_color: None,
58 inactive_thumb_color: None,
59 thumb_icon: None,
60 inactive_thumb_icon: None,
61 tap_target: None,
62 on_toggle: None,
63 }
64 }
65
66 pub fn tap_target(mut self, target: TapTarget) -> Self {
70 self.tap_target = Some(target);
71 self
72 }
73
74 fn reserved(&self, theme: &Theme) -> f32 {
77 self.tap_target
78 .or(theme.widgets.switch.tap_target)
79 .unwrap_or(theme.tap_target)
80 .min_side()
81 }
82
83 pub fn track_color(mut self, color: Color) -> Self {
85 self.track_color = Some(color);
86 self
87 }
88
89 pub fn inactive_track_color(mut self, color: Color) -> Self {
92 self.inactive_track_color = Some(color);
93 self
94 }
95
96 pub fn thumb_icon(mut self, icon: IconData) -> Self {
107 self.thumb_icon = Some(icon);
108 self
109 }
110
111 pub fn inactive_thumb_icon(mut self, icon: IconData) -> Self {
114 self.inactive_thumb_icon = Some(icon);
115 self
116 }
117
118 pub fn thumb_color(mut self, color: Color) -> Self {
121 self.thumb_color = Some(color);
122 self
123 }
124
125 pub fn inactive_thumb_color(mut self, color: Color) -> Self {
134 self.inactive_thumb_color = Some(color);
135 self
136 }
137
138 pub fn on_toggle<R: crate::callback::IntoMsg<Msg>>(
140 mut self,
141 on_toggle: impl Fn(bool) -> R + 'static,
142 ) -> Self {
143 self.on_toggle = Some(Box::new(crate::callback::handler1(on_toggle)));
144 self
145 }
146
147 pub fn enabled(mut self, enabled: bool) -> Self {
152 self.enabled = enabled;
153 self
154 }
155}
156
157impl<Msg> Widget<Msg> for Switch<Msg> {
158 fn style(&self) -> Style {
159 Widget::<Msg>::style_themed(self, &Theme::default())
160 }
161
162 fn style_themed(&self, theme: &Theme) -> Style {
167 Style {
168 width: Dimension::Length(W),
169 height: Dimension::Length(H.max(self.reserved(theme))),
170 ..Default::default()
171 }
172 }
173
174 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
175 &[]
176 }
177
178 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
179 let o = status.opacity;
180 let t = status.value;
182 let (track, thumb, edge) = if self.enabled {
190 let on_track = self
191 .track_color
192 .or(theme.widgets.switch.track_color)
193 .unwrap_or(theme.primary);
194 let off_track = self
195 .inactive_track_color
196 .or(theme.widgets.switch.inactive_track_color)
197 .unwrap_or(theme.scheme.surface_container_highest);
198 let on_thumb = self
199 .thumb_color
200 .or(theme.widgets.switch.thumb_color)
201 .unwrap_or(theme.scheme.on_primary);
202 let off_thumb = self
203 .inactive_thumb_color
204 .or(theme.widgets.switch.inactive_thumb_color)
205 .unwrap_or(theme.scheme.outline);
206 (
207 off_track.lerp(on_track, t),
208 off_thumb.lerp(on_thumb, t),
209 theme.scheme.outline,
210 )
211 } else {
212 (
213 if self.on {
214 disabled_container(theme)
217 } else {
218 theme.scheme.surface.lerp(
226 theme.scheme.surface_container_highest,
227 DISABLED_CONTAINER_OPACITY,
228 )
229 },
230 if self.on {
231 disabled_mark(theme)
232 } else {
233 disabled_content(theme)
234 },
235 disabled_container(theme),
236 )
237 };
238 let track_rect = Rect::new(
245 bounds.x,
246 bounds.y + (bounds.height - H) * 0.5,
247 bounds.width,
248 H,
249 );
250 scene.draw_rect(
251 track_rect,
252 track.fade(o),
253 H * 0.5,
254 TRACK_OUTLINE,
255 edge.fade((1.0 - t) * o),
256 );
257
258 let icon = if self.on {
261 self.thumb_icon
262 } else {
263 self.inactive_thumb_icon
264 };
265 let carries_an_icon = self.thumb_icon.is_some() || self.inactive_thumb_icon.is_some();
271 let off_r = if carries_an_icon {
272 THUMB_WITH_ICON
273 } else {
274 THUMB_OFF
275 };
276 let mut r = off_r + (THUMB_ON - off_r) * t;
277 if self.enabled {
282 r += (THUMB_PRESSED - r) * status.press_progress.clamp(0.0, 1.0);
283 }
284 let cx = track_rect.x + H * 0.5 + (W - H) * t;
285 let cy = track_rect.y + H * 0.5;
286
287 if self.enabled {
304 let ink = theme.scheme.on_surface.lerp(theme.scheme.on_primary, t);
305 let layer = theme.state_layer(track, ink, &status);
306 if layer != track {
307 let reach = H * 0.5;
308 scene.draw_rect(
309 Rect::new(cx - reach, cy - reach, reach * 2.0, reach * 2.0),
310 layer.fade(o),
311 reach,
312 0.0,
313 Color::TRANSPARENT,
314 );
315 }
316 }
317
318 scene.draw_rect(
319 Rect::new(cx - r, cy - r, r * 2.0, r * 2.0),
320 thumb.fade(o),
321 r,
322 0.0,
323 Color::TRANSPARENT,
324 );
325
326 if let Some(icon) = icon {
330 let t_widget = &theme.widgets.switch;
331 let ink = if !self.enabled {
332 over_surface(theme, crate::disabled::DISABLED_CONTENT_OPACITY)
333 } else if self.on {
334 t_widget
335 .icon_color
336 .unwrap_or(theme.scheme.on_primary_container)
337 } else {
338 t_widget
339 .inactive_icon_color
340 .unwrap_or(theme.scheme.surface_container_highest)
341 };
342 let path = icon.placed(
343 ICON_SIZE,
344 cx - ICON_SIZE * 0.5,
345 cy - ICON_SIZE * 0.5,
346 theme.direction,
347 );
348 scene.fill_path(&path, ink.fade(o));
349 }
350 }
351
352 fn on_click(&self) -> Option<Msg> {
353 if !self.enabled {
354 return None;
355 }
356 self.on_toggle.as_ref().map(|make| make(!self.on))
357 }
358
359 fn focusable(&self) -> bool {
360 self.enabled
361 }
362
363 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
364 let semantics =
367 frus_core::SemanticsProperties::new(frus_core::Role::Switch).toggled(self.on);
368 Some(if self.enabled {
369 semantics.clickable()
370 } else {
371 semantics.disabled(true)
372 })
373 }
374
375 fn anim_target(&self) -> Option<f32> {
376 Some(if self.on { 1.0 } else { 0.0 })
377 }
378
379 fn anim_curve(&self) -> frus_core::Curve {
382 frus_core::Curve::ease_in_out()
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use crate::disabled::{disabled_container, disabled_content, disabled_mark};
390 use crate::icons::Icons;
391 use crate::theme::{MIN_TAP_TARGET, SHRUNK_TAP_TARGET};
392 use crate::widget::Widget;
393
394 #[derive(Clone, Debug, PartialEq)]
395 enum Msg {
396 Set(bool),
397 }
398
399 fn painted(on: bool, enabled: bool, theme: &Theme) -> Vec<(Color, Color)> {
401 let mut scene = Scene::new();
402 Widget::<Msg>::paint(
403 &Switch::<Msg>::new(on).enabled(enabled),
404 Rect::new(0.0, 0.0, W, H),
405 Status {
406 opacity: 1.0,
407 value: if on { 1.0 } else { 0.0 },
408 ..Default::default()
409 },
410 theme,
411 &mut scene,
412 );
413 scene
414 .primitives()
415 .iter()
416 .filter_map(|p| match p {
417 frus_core::Primitive::Rect {
418 color,
419 border_color,
420 ..
421 } => Some((*color, *border_color)),
422 _ => None,
423 })
424 .collect()
425 }
426
427 fn boxes(switch: &Switch<Msg>, status: Status, theme: &Theme) -> Vec<(Rect, Color)> {
429 let mut scene = Scene::new();
430 Widget::<Msg>::paint(switch, Rect::new(0.0, 0.0, W, H), status, theme, &mut scene);
431 scene
432 .primitives()
433 .iter()
434 .filter_map(|p| match p {
435 frus_core::Primitive::Rect { rect, color, .. } => Some((*rect, *color)),
436 _ => None,
437 })
438 .collect()
439 }
440
441 fn state(on: bool) -> Status {
443 Status {
444 opacity: 1.0,
445 value: if on { 1.0 } else { 0.0 },
446 ..Default::default()
447 }
448 }
449
450 #[test]
458 fn a_switch_answers_the_pointer() {
459 let theme = Theme::default();
460 let switch = Switch::<Msg>::new(false).on_toggle(Msg::Set);
461 let hovered = Status {
462 hover_progress: 1.0,
463 ..state(false)
464 };
465 assert_eq!(
466 boxes(&switch, state(false), &theme).len(),
467 2,
468 "track, thumb"
469 );
470 let lit = boxes(&switch, hovered, &theme);
471 assert_eq!(lit.len(), 3, "track, layer, thumb");
472
473 let (rect, color) = lit[1];
474 assert_eq!(color.a, 1.0, "resolved here, not handed over as an alpha");
475 assert_eq!(
476 color,
477 theme.state_layer(
478 theme.scheme.surface_container_highest,
479 theme.scheme.on_surface,
480 &hovered
481 ),
482 "the theme's rule, over the track it stands on"
483 );
484 assert!(
486 rect.x >= 0.0 && rect.x + rect.width <= W,
487 "the layer left the switch: {rect:?}"
488 );
489 assert!(
490 (rect.height - H).abs() < 0.01,
491 "and it reaches the track's full height"
492 );
493 }
494
495 #[test]
504 fn the_layer_is_the_accent_at_one_end_and_the_ink_at_the_other() {
505 let theme = Theme::default();
506 let switch = Switch::<Msg>::new(true).on_toggle(Msg::Set);
507 let hovered = |on: bool| Status {
508 hover_progress: 1.0,
509 ..state(on)
510 };
511 let off = boxes(&switch, hovered(false), &theme)[1].1;
512 let on = boxes(&switch, hovered(true), &theme)[1].1;
513 assert_eq!(
514 on,
515 theme.state_layer(theme.primary, theme.scheme.on_primary, &hovered(true)),
516 "the accent track, tinted with what is legible on it"
517 );
518 assert_ne!(off, on, "the two ends do not light the same way");
519 assert_ne!(on, theme.primary, "and it is visible against the track");
520 }
521
522 #[test]
525 fn a_disabled_switch_does_not_light() {
526 let theme = Theme::default();
527 let dead = Switch::<Msg>::new(false).on_toggle(Msg::Set).enabled(false);
528 for status in [
529 Status {
530 hover_progress: 1.0,
531 ..state(false)
532 },
533 Status {
534 focus_progress: 1.0,
535 ..state(false)
536 },
537 Status {
538 press_progress: 1.0,
539 ..state(false)
540 },
541 ] {
542 assert_eq!(
543 boxes(&dead, status, &theme).len(),
544 2,
545 "track and thumb only"
546 );
547 }
548 }
549
550 fn thumb_radius(switch: &Switch<Msg>, status: Status, theme: &Theme) -> f32 {
552 boxes(switch, status, theme)
553 .last()
554 .map(|(rect, _)| rect.width * 0.5)
555 .expect("a switch paints a thumb")
556 }
557
558 #[test]
565 fn a_thumb_that_carries_a_glyph_is_the_larger_one() {
566 let theme = Theme::default();
567 let bare = Switch::<Msg>::new(false).on_toggle(Msg::Set);
568 assert_eq!(thumb_radius(&bare, state(false), &theme), THUMB_OFF);
569
570 let ticked = Switch::<Msg>::new(false)
571 .on_toggle(Msg::Set)
572 .thumb_icon(Icons::CHECK);
573 assert_eq!(
574 thumb_radius(&ticked, state(false), &theme),
575 THUMB_WITH_ICON,
576 "the on end's icon grows the off thumb too"
577 );
578 assert_eq!(
579 thumb_radius(&ticked, state(true), &theme),
580 THUMB_ON,
581 "and the on thumb was already that size"
582 );
583 }
584
585 #[test]
587 fn the_glyph_is_drawn_at_the_end_that_has_one() {
588 let theme = Theme::default();
589 let glyph = |switch: &Switch<Msg>, on: bool| {
590 let mut scene = Scene::new();
591 Widget::<Msg>::paint(
592 switch,
593 Rect::new(0.0, 0.0, W, H),
594 state(on),
595 &theme,
596 &mut scene,
597 );
598 scene.primitives().iter().find_map(|p| match p {
599 frus_core::Primitive::Path { fill, .. } => *fill,
600 _ => None,
601 })
602 };
603 let ticked = Switch::<Msg>::new(true)
604 .on_toggle(Msg::Set)
605 .thumb_icon(Icons::CHECK);
606 assert_eq!(
607 glyph(&ticked, true),
608 Some(theme.scheme.on_primary_container)
609 );
610 let unticked = Switch::<Msg>::new(false)
614 .on_toggle(Msg::Set)
615 .thumb_icon(Icons::CHECK);
616 assert_eq!(glyph(&unticked, false), None, "nothing at the other end");
617
618 let crossed = Switch::<Msg>::new(false)
619 .on_toggle(Msg::Set)
620 .thumb_icon(Icons::CHECK)
621 .inactive_thumb_icon(Icons::CLOSE);
622 assert_eq!(
623 glyph(&crossed, false),
624 Some(theme.scheme.surface_container_highest),
625 "off, the glyph takes the track's own colour: a hole, not a mark"
626 );
627 }
628
629 #[test]
631 fn a_held_thumb_swells() {
632 let theme = Theme::default();
633 let switch = Switch::<Msg>::new(false).on_toggle(Msg::Set);
634 let pressed = Status {
635 press_progress: 1.0,
636 ..state(false)
637 };
638 assert_eq!(thumb_radius(&switch, pressed, &theme), THUMB_PRESSED);
639 for end in [false, true] {
642 assert!(
643 thumb_radius(&switch, pressed, &theme) > thumb_radius(&switch, state(end), &theme),
644 "not past the {end} end"
645 );
646 }
647 }
648
649 #[test]
653 fn a_held_thumb_grows_into_it() {
654 let theme = Theme::default();
655 let switch = Switch::<Msg>::new(false).on_toggle(Msg::Set);
656 let at = |p: f32| {
657 thumb_radius(
658 &switch,
659 Status {
660 press_progress: p,
661 ..state(false)
662 },
663 &theme,
664 )
665 };
666 assert_eq!(at(0.0), THUMB_OFF, "untouched");
667 assert_eq!(at(1.0), THUMB_PRESSED, "held");
668 assert!(
669 (at(0.5) - (THUMB_OFF + THUMB_PRESSED) * 0.5).abs() < 0.01,
670 "half way = {}",
671 at(0.5)
672 );
673 }
674
675 fn reserved_height(switch: &Switch<Msg>, theme: &Theme) -> f32 {
677 match Widget::<Msg>::style_themed(switch, theme).height {
678 Dimension::Length(h) => h,
679 other => panic!("a switch asks for a length, not {other:?}"),
680 }
681 }
682
683 #[test]
690 fn a_switch_reserves_room_for_a_finger() {
691 let theme = Theme::default();
692 assert_eq!(
693 reserved_height(&Switch::<Msg>::new(false).on_toggle(Msg::Set), &theme),
694 MIN_TAP_TARGET
695 );
696 assert_eq!(
697 reserved_height(
698 &Switch::<Msg>::new(false)
699 .on_toggle(Msg::Set)
700 .tap_target(TapTarget::ShrinkWrap),
701 &theme
702 ),
703 SHRUNK_TAP_TARGET,
704 "and a caller may ask for the smaller answer"
705 );
706
707 let dense = Theme {
710 tap_target: TapTarget::ShrinkWrap,
711 ..Theme::default()
712 };
713 let plain = Switch::<Msg>::new(false).on_toggle(Msg::Set);
714 assert_eq!(reserved_height(&plain, &dense), SHRUNK_TAP_TARGET);
715 let mut mixed = dense;
716 mixed.widgets.switch.tap_target = Some(TapTarget::Padded);
717 assert_eq!(reserved_height(&plain, &mixed), MIN_TAP_TARGET);
718 }
719
720 #[test]
723 fn the_track_is_centred_in_the_room() {
724 let theme = Theme::default();
725 let switch = Switch::<Msg>::new(false).on_toggle(Msg::Set);
726 let mut scene = Scene::new();
727 Widget::<Msg>::paint(
728 &switch,
729 Rect::new(0.0, 0.0, W, MIN_TAP_TARGET),
730 state(false),
731 &theme,
732 &mut scene,
733 );
734 let rects: Vec<Rect> = scene
735 .primitives()
736 .iter()
737 .filter_map(|p| match p {
738 frus_core::Primitive::Rect { rect, .. } => Some(*rect),
739 _ => None,
740 })
741 .collect();
742
743 let track = rects[0];
744 assert_eq!(track.height, H, "the track keeps its own height");
745 assert!(
746 (track.y - (MIN_TAP_TARGET - H) * 0.5).abs() < 0.01,
747 "and sits in the middle of the room: {track:?}"
748 );
749 let thumb = *rects.last().expect("a switch paints a thumb");
750 assert!(
751 (thumb.y + thumb.height * 0.5 - MIN_TAP_TARGET * 0.5).abs() < 0.01,
752 "the thumb is centred on the track: {thumb:?}"
753 );
754 }
755
756 #[test]
757 fn click_toggles() {
758 assert_eq!(
759 Widget::on_click(&Switch::new(false).on_toggle(Msg::Set)),
760 Some(Msg::Set(true))
761 );
762 }
763
764 #[test]
765 fn a_disabled_switch_is_inert_but_still_says_which_way_it_is_set() {
766 let dead = Switch::new(true).on_toggle(Msg::Set).enabled(false);
767 assert_eq!(Widget::on_click(&dead), None, "the press goes nowhere");
768 assert!(!Widget::<Msg>::focusable(&dead), "out of the tab order");
769 let semantics = Widget::<Msg>::semantics(&dead).expect("still announced");
770 assert!(semantics.disabled, "and announced as unavailable");
771 assert_eq!(semantics.toggled, frus_core::Toggled::True, "still on");
772 }
773
774 #[test]
778 fn a_disabled_switch_takes_both_halves_of_the_rule() {
779 for theme in [Theme::dark(), Theme::light()] {
780 let off = painted(false, false, &theme);
781 let ((track, ring), thumb) = (off[0], off[1].0);
782 assert_eq!(ring, disabled_container(&theme), "the ring is a container");
788 assert_eq!(
789 thumb,
790 disabled_content(&theme),
791 "the thumb is content on it"
792 );
793 let from_surface = |c: Color| {
796 (c.r - theme.scheme.surface.r).abs()
797 + (c.g - theme.scheme.surface.g).abs()
798 + (c.b - theme.scheme.surface.b).abs()
799 };
800 assert!(
801 from_surface(ring) < from_surface(thumb),
802 "and the container is the quieter of the two"
803 );
804 assert!(
805 from_surface(track) < from_surface(ring),
806 "with the wash inside it quieter still"
807 );
808
809 let on = painted(true, false, &theme);
813 assert_eq!(on[0].0, disabled_container(&theme), "a filled track");
814 assert_eq!(on[1].0, disabled_mark(&theme), "an opaque thumb");
815 assert_eq!(on[0].1.a, 0.0, "and no ring round it");
816 }
817 }
818
819 #[test]
820 fn a_live_switch_is_untouched_by_any_of_it() {
821 let theme = Theme::dark();
822 let live = painted(true, true, &theme);
823 assert_ne!(live[0].0, disabled_container(&theme));
824 assert_ne!(live[1].0, disabled_content(&theme));
825 }
826}
827
828#[cfg(test)]
829mod color_tests {
830 use super::*;
831 use crate::widget::Widget;
832
833 const BRAND: Color = Color::rgb(0.0, 0.6, 0.3);
834 const RAIL: Color = Color::rgb(0.9, 0.9, 0.2);
835 const KNOB: Color = Color::rgb(0.1, 0.1, 0.9);
836
837 fn painted(switch: &Switch<()>, on: bool, theme: &Theme) -> (Color, Color, Color) {
839 at(switch, if on { 1.0 } else { 0.0 }, theme)
840 }
841
842 fn at(switch: &Switch<()>, t: f32, theme: &Theme) -> (Color, Color, Color) {
844 let mut scene = Scene::new();
845 Widget::<()>::paint(
846 switch,
847 Rect::new(0.0, 0.0, W, H),
848 Status {
849 opacity: 1.0,
850 value: t,
851 ..Default::default()
852 },
853 theme,
854 &mut scene,
855 );
856 let rects: Vec<(Color, Color)> = scene
857 .primitives()
858 .iter()
859 .filter_map(|p| match p {
860 frus_core::Primitive::Rect {
861 color,
862 border_color,
863 ..
864 } => Some((*color, *border_color)),
865 _ => None,
866 })
867 .collect();
868 (rects[0].0, rects[1].0, rects[0].1)
869 }
870
871 #[test]
874 fn the_defaults_are_the_reference_s() {
875 let theme = Theme::default();
876 let (track, thumb, ring) = painted(&Switch::<()>::new(true), true, &theme);
879 lands_on(track, theme.primary); lands_on(thumb, theme.scheme.on_primary); assert_eq!(ring.a, 0.0, "a filled track has no edge (`:2254`)");
882
883 let (track, thumb, ring) = painted(&Switch::<()>::new(false), false, &theme);
884 lands_on(track, theme.scheme.surface_container_highest); lands_on(thumb, theme.scheme.outline); lands_on(ring, theme.scheme.outline); assert_eq!(ring.a, 1.0, "and it is drawn");
888 }
889
890 #[test]
893 fn the_ring_belongs_to_the_off_end_alone() {
894 let theme = Theme::default();
895 let switch = Switch::<()>::new(false);
896 let alpha = |t: f32| at(&switch, t, &theme).2.a;
897 assert_eq!(alpha(0.0), 1.0, "fully drawn off");
898 assert_eq!(alpha(1.0), 0.0, "gone on");
899 assert!(
900 (alpha(0.5) - 0.5).abs() < 1e-6,
901 "and half drawn halfway, rather than snapping at one end"
902 );
903 }
904
905 #[test]
910 fn the_thumb_grows_as_it_travels() {
911 let theme = Theme::default();
912 let switch = Switch::<()>::new(false);
913 let thumb = |t: f32| {
914 let mut scene = Scene::new();
915 Widget::<()>::paint(
916 &switch,
917 Rect::new(0.0, 0.0, W, H),
918 Status {
919 opacity: 1.0,
920 value: t,
921 ..Default::default()
922 },
923 &theme,
924 &mut scene,
925 );
926 match scene.primitives()[1] {
927 frus_core::Primitive::Rect { rect, .. } => rect,
928 _ => panic!("the thumb is a rectangle"),
929 }
930 };
931 assert_eq!(thumb(0.0).width, THUMB_OFF * 2.0);
932 assert_eq!(thumb(1.0).width, THUMB_ON * 2.0);
933 assert!((thumb(0.0).x - (H * 0.5 - THUMB_OFF)).abs() < 1e-4);
935 assert!((thumb(1.0).x + thumb(1.0).width - (W - H * 0.5 + THUMB_ON)).abs() < 1e-4);
936 }
937
938 #[test]
940 fn each_end_takes_its_own_colour() {
941 let theme = Theme::default();
942 let switch = Switch::<()>::new(true)
943 .track_color(BRAND)
944 .inactive_track_color(RAIL)
945 .thumb_color(KNOB);
946 let (track, thumb, _) = painted(&switch, true, &theme);
947 lands_on(track, BRAND);
948 lands_on(thumb, KNOB);
949 lands_on(painted(&switch, false, &theme).0, RAIL);
950 }
951
952 #[test]
955 fn the_travel_runs_between_the_two_overrides() {
956 let theme = Theme::default();
957 let switch = Switch::<()>::new(true)
958 .track_color(BRAND)
959 .inactive_track_color(RAIL);
960 let mut scene = Scene::new();
961 Widget::<()>::paint(
962 &switch,
963 Rect::new(0.0, 0.0, W, H),
964 Status {
965 opacity: 1.0,
966 value: 0.5,
967 ..Default::default()
968 },
969 &theme,
970 &mut scene,
971 );
972 let track = match scene.primitives()[0] {
973 frus_core::Primitive::Rect { color, .. } => color,
974 _ => panic!("the track is a rectangle"),
975 };
976 assert_eq!(track, RAIL.lerp(BRAND, 0.5), "halfway between the two ends");
977 }
978
979 #[test]
987 fn the_two_ends_of_the_thumb_are_two_colours() {
988 let theme = Theme::default();
989 let switch = Switch::<()>::new(false).thumb_color(KNOB);
990 lands_on(painted(&switch, false, &theme).1, theme.scheme.outline);
991 lands_on(painted(&switch, true, &theme).1, KNOB);
992 let both = Switch::<()>::new(false)
993 .thumb_color(KNOB)
994 .inactive_thumb_color(RAIL);
995 lands_on(painted(&both, false, &theme).1, RAIL);
996 }
997
998 fn lands_on(got: Color, want: Color) {
1002 let off = (got.r - want.r)
1003 .abs()
1004 .max((got.g - want.g).abs())
1005 .max((got.b - want.b).abs());
1006 assert!(off < 1e-4, "{got:?} is not {want:?}");
1007 }
1008
1009 #[test]
1011 fn the_theme_answers_and_the_instance_overrules_it() {
1012 let mut theme = Theme::default();
1013 theme.widgets.switch.track_color = Some(RAIL);
1014 lands_on(painted(&Switch::<()>::new(true), true, &theme).0, RAIL);
1015 lands_on(
1016 painted(&Switch::<()>::new(true).track_color(BRAND), true, &theme).0,
1017 BRAND,
1018 );
1019 }
1020}