1use frus_core::{Color, Curve, Insets, Point, 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;
12use crate::widgetstate::{WidgetState, WidgetStateProperty, WidgetStates};
13
14const W: f32 = 52.0;
16const H: f32 = 32.0;
17const THUMB_OFF: f32 = 8.0;
22const THUMB_ON: f32 = 12.0;
23const THUMB_PRESSED: f32 = 14.0;
27const THUMB_WITH_ICON: f32 = 12.0;
30const THUMB_MIDWAY: (f32, f32) = (34.0, 22.0);
33const ICON_SIZE: f32 = 16.0;
35const TRACK_OUTLINE: f32 = 2.0;
37const SPLASH: f32 = 20.0;
39const PADDING: Insets = Insets::new(0.0, 4.0, 0.0, 4.0);
41const TOGGLE_SECONDS: f32 = 0.3;
43
44fn ease_out_back() -> Curve {
47 Curve::Cubic {
48 x1: 0.175,
49 y1: 0.885,
50 x2: 0.32,
51 y2: 1.275,
52 }
53}
54
55pub struct Switch<Msg = crate::callback::Callback> {
57 on: bool,
58 enabled: bool,
59 track_color: Option<Color>,
60 inactive_track_color: Option<Color>,
61 thumb_color: Option<Color>,
62 inactive_thumb_color: Option<Color>,
63 thumb_colors: Option<WidgetStateProperty<Color>>,
64 track_colors: Option<WidgetStateProperty<Color>>,
65 track_outline_color: Option<WidgetStateProperty<Color>>,
66 track_outline_width: Option<f32>,
67 overlay_color: Option<WidgetStateProperty<Color>>,
68 splash_radius: Option<f32>,
69 padding: Option<Insets>,
70 thumb_icon: Option<IconData>,
71 inactive_thumb_icon: Option<IconData>,
72 tap_target: Option<TapTarget>,
73 on_toggle: Option<Box<dyn Fn(bool) -> Msg>>,
74}
75
76impl<Msg> Switch<Msg> {
77 pub fn new(on: bool) -> Self {
79 Self {
80 on,
81 enabled: true,
82 track_color: None,
83 inactive_track_color: None,
84 thumb_color: None,
85 inactive_thumb_color: None,
86 thumb_colors: None,
87 track_colors: None,
88 track_outline_color: None,
89 track_outline_width: None,
90 overlay_color: None,
91 splash_radius: None,
92 padding: None,
93 thumb_icon: None,
94 inactive_thumb_icon: None,
95 tap_target: None,
96 on_toggle: None,
97 }
98 }
99
100 pub fn tap_target(mut self, target: TapTarget) -> Self {
104 self.tap_target = Some(target);
105 self
106 }
107
108 fn reserved(&self, theme: &Theme) -> f32 {
111 self.tap_target
112 .or(theme.widgets.switch.tap_target)
113 .unwrap_or(theme.tap_target)
114 .min_side()
115 }
116
117 fn room(&self, theme: &Theme) -> Insets {
119 self.padding
120 .or(theme.widgets.switch.padding)
121 .unwrap_or(PADDING)
122 }
123
124 pub fn track_color(mut self, color: Color) -> Self {
127 self.track_color = Some(color);
128 self
129 }
130
131 pub fn inactive_track_color(mut self, color: Color) -> Self {
134 self.inactive_track_color = Some(color);
135 self
136 }
137
138 pub fn thumb_icon(mut self, icon: IconData) -> Self {
145 self.thumb_icon = Some(icon);
146 self
147 }
148
149 pub fn inactive_thumb_icon(mut self, icon: IconData) -> Self {
152 self.inactive_thumb_icon = Some(icon);
153 self
154 }
155
156 pub fn thumb_color(mut self, color: Color) -> Self {
160 self.thumb_color = Some(color);
161 self
162 }
163
164 pub fn inactive_thumb_color(mut self, color: Color) -> Self {
172 self.inactive_thumb_color = Some(color);
173 self
174 }
175
176 pub fn thumb_colors(mut self, colors: WidgetStateProperty<Color>) -> Self {
181 self.thumb_colors = Some(colors);
182 self
183 }
184
185 pub fn track_colors(mut self, colors: WidgetStateProperty<Color>) -> Self {
188 self.track_colors = Some(colors);
189 self
190 }
191
192 pub fn track_outline_color(mut self, colors: WidgetStateProperty<Color>) -> Self {
196 self.track_outline_color = Some(colors);
197 self
198 }
199
200 pub fn track_outline_width(mut self, width: f32) -> Self {
202 self.track_outline_width = Some(width);
203 self
204 }
205
206 pub fn overlay_color(mut self, colors: WidgetStateProperty<Color>) -> Self {
210 self.overlay_color = Some(colors);
211 self
212 }
213
214 pub fn splash_radius(mut self, radius: f32) -> Self {
216 self.splash_radius = Some(radius);
217 self
218 }
219
220 pub fn padding(mut self, padding: Insets) -> Self {
223 self.padding = Some(padding);
224 self
225 }
226
227 pub fn on_toggle<R: crate::callback::IntoMsg<Msg>>(
229 mut self,
230 on_toggle: impl Fn(bool) -> R + 'static,
231 ) -> Self {
232 self.on_toggle = Some(Box::new(crate::callback::handler1(on_toggle)));
233 self
234 }
235
236 pub fn enabled(mut self, enabled: bool) -> Self {
241 self.enabled = enabled;
242 self
243 }
244
245 fn states_at(&self, status: &Status, selected: bool) -> WidgetStates {
247 let states = if self.enabled {
248 status.states()
249 } else {
250 WidgetStates::EMPTY
251 };
252 states
253 .set(WidgetState::Selected, selected)
254 .set(WidgetState::Disabled, !self.enabled)
255 }
256
257 fn thumb_at(&self, theme: &Theme, states: WidgetStates) -> Color {
260 let t = &theme.widgets.switch;
261 let selected = states.contains(WidgetState::Selected);
262 let plain = |mine: Option<Color>, inactive: Option<Color>| {
263 if selected && self.enabled {
264 mine
265 } else {
266 inactive
267 }
268 };
269 self.thumb_colors
270 .as_ref()
271 .and_then(|p| p.resolve(states).copied())
272 .or_else(|| plain(self.thumb_color, self.inactive_thumb_color))
273 .or_else(|| {
274 t.thumb_colors
275 .as_ref()
276 .and_then(|p| p.resolve(states).copied())
277 })
278 .or_else(|| plain(t.thumb_color, t.inactive_thumb_color))
279 .unwrap_or_else(|| default_thumb(theme, states))
280 }
281
282 fn track_at(&self, theme: &Theme, states: WidgetStates) -> Color {
284 let t = &theme.widgets.switch;
285 let selected = states.contains(WidgetState::Selected);
286 let plain = |mine: Option<Color>, inactive: Option<Color>| {
287 if selected {
288 mine
289 } else {
290 inactive
291 }
292 };
293 self.track_colors
294 .as_ref()
295 .and_then(|p| p.resolve(states).copied())
296 .or_else(|| plain(self.track_color, self.inactive_track_color))
297 .or_else(|| {
298 t.track_colors
299 .as_ref()
300 .and_then(|p| p.resolve(states).copied())
301 })
302 .or_else(|| plain(t.track_color, t.inactive_track_color))
303 .unwrap_or_else(|| default_track(theme, states))
304 }
305
306 fn outline_at(&self, theme: &Theme, states: WidgetStates) -> Color {
308 self.track_outline_color
309 .as_ref()
310 .and_then(|p| p.resolve(states).copied())
311 .or_else(|| {
312 theme
313 .widgets
314 .switch
315 .track_outline_color
316 .as_ref()
317 .and_then(|p| p.resolve(states).copied())
318 })
319 .unwrap_or_else(|| {
320 if states.contains(WidgetState::Selected) {
321 Color::TRANSPARENT
322 } else if states.contains(WidgetState::Disabled) {
323 disabled_container(theme)
324 } else {
325 theme.scheme.outline
326 }
327 })
328 }
329
330 fn halo_at(&self, theme: &Theme, selected: bool, state: WidgetState) -> Color {
332 let states = WidgetStates::of(state).set(WidgetState::Selected, selected);
333 self.overlay_color
334 .as_ref()
335 .and_then(|p| p.resolve(states).copied())
336 .or_else(|| {
337 theme
338 .widgets
339 .switch
340 .overlay_color
341 .as_ref()
342 .and_then(|p| p.resolve(states).copied())
343 })
344 .unwrap_or_else(|| default_overlay(theme, states))
345 }
346
347 fn thumb_size(&self, t: f32) -> (f32, f32) {
351 let off = if self.inactive_thumb_icon.is_some() {
352 THUMB_WITH_ICON
353 } else {
354 THUMB_OFF
355 };
356 let (off, on) = ((2.0 * off, 2.0 * off), (2.0 * THUMB_ON, 2.0 * THUMB_ON));
357 let lerp =
358 |a: (f32, f32), b: (f32, f32), k: f32| (a.0 + (b.0 - a.0) * k, a.1 + (b.1 - a.1) * k);
359 let early = Curve::Cubic {
360 x1: 0.31,
361 y1: 0.0,
362 x2: 0.56,
363 y2: 1.0,
364 };
365 let late = Curve::Cubic {
366 x1: 0.2,
367 y1: 0.0,
368 x2: 0.0,
369 y2: 1.0,
370 };
371 if t <= 0.0 {
372 return off;
373 }
374 if t >= 1.0 {
375 return on;
376 }
377 if self.on {
378 if t < 0.11 {
379 lerp(off, THUMB_MIDWAY, early.transform(t / 0.11))
380 } else if t < 0.83 {
381 lerp(THUMB_MIDWAY, on, late.transform((t - 0.11) / 0.72))
382 } else {
383 on
384 }
385 } else if t < 0.17 {
386 off
387 } else if t < 0.89 {
388 let k = Curve::Flipped(Box::new(late)).transform((t - 0.17) / 0.72);
389 lerp(off, THUMB_MIDWAY, k)
390 } else {
391 let k = Curve::Flipped(Box::new(early)).transform((t - 0.89) / 0.11);
392 lerp(THUMB_MIDWAY, on, k)
393 }
394 }
395}
396
397fn default_thumb(theme: &Theme, states: WidgetStates) -> Color {
401 let c = &theme.scheme;
402 let selected = states.contains(WidgetState::Selected);
403 if states.contains(WidgetState::Disabled) {
404 return if selected {
405 disabled_mark(theme)
406 } else {
407 disabled_content(theme)
408 };
409 }
410 let busy = states.contains(WidgetState::Pressed)
411 || states.contains(WidgetState::Hovered)
412 || states.contains(WidgetState::Focused);
413 match (selected, busy) {
414 (true, true) => c.primary_container,
415 (true, false) => c.on_primary,
416 (false, true) => c.on_surface_variant,
417 (false, false) => c.outline,
418 }
419}
420
421fn default_track(theme: &Theme, states: WidgetStates) -> Color {
426 let selected = states.contains(WidgetState::Selected);
427 if states.contains(WidgetState::Disabled) {
428 return if selected {
429 disabled_container(theme)
430 } else {
431 theme.scheme.surface.lerp(
432 theme.scheme.surface_container_highest,
433 DISABLED_CONTAINER_OPACITY,
434 )
435 };
436 }
437 if selected {
438 theme.primary
439 } else {
440 theme.scheme.surface_container_highest
441 }
442}
443
444fn default_overlay(theme: &Theme, states: WidgetStates) -> Color {
447 let ink = if states.contains(WidgetState::Selected) {
448 theme.primary
449 } else {
450 theme.scheme.on_surface
451 };
452 if states.contains(WidgetState::Pressed) {
453 ink.with_alpha(0.1)
454 } else if states.contains(WidgetState::Hovered) {
455 ink.with_alpha(0.08)
456 } else if states.contains(WidgetState::Focused) {
457 ink.with_alpha(0.1)
458 } else {
459 Color::TRANSPARENT
460 }
461}
462
463fn travel(a: Color, b: Color, k: f32) -> Color {
466 if k <= 0.0 {
467 a
468 } else if k >= 1.0 {
469 b
470 } else {
471 a.lerp(b, k)
472 }
473}
474
475fn opaque_on(page: Color, c: Color) -> Color {
478 if c.a >= 1.0 {
479 c
480 } else {
481 page.lerp(c.with_alpha(1.0), c.a)
482 }
483}
484
485impl<Msg> Widget<Msg> for Switch<Msg> {
486 fn style(&self) -> Style {
487 Widget::<Msg>::style_themed(self, &Theme::default())
488 }
489
490 fn style_themed(&self, theme: &Theme) -> Style {
493 let room = self.room(theme);
494 Style {
495 width: Dimension::Length(W + room.left + room.right),
496 height: Dimension::Length(H.max(self.reserved(theme)) + room.top + room.bottom),
497 ..Default::default()
498 }
499 }
500
501 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
502 &[]
503 }
504
505 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
506 let o = status.opacity;
507 let t = status.value;
512 let curved = |curve: Curve| {
513 if t <= 0.0 || t >= 1.0 {
516 t.clamp(0.0, 1.0)
517 } else {
518 curve.transform(t)
519 }
520 };
521 let (place, mix) = if status.value_placed {
522 (t.clamp(0.0, 1.0), curved(Curve::ease_out()))
525 } else if self.on {
526 (curved(ease_out_back()), curved(Curve::ease_out()))
527 } else {
528 (
529 curved(Curve::Flipped(Box::new(ease_out_back()))),
530 curved(Curve::ease_in()),
531 )
532 };
533 let on_states = self.states_at(&status, true);
536 let off_states = self.states_at(&status, false);
537 let track = travel(
538 self.track_at(theme, off_states),
539 self.track_at(theme, on_states),
540 mix,
541 );
542 let thumb = opaque_on(
543 theme.scheme.surface,
544 travel(
545 self.thumb_at(theme, off_states),
546 self.thumb_at(theme, on_states),
547 mix,
548 ),
549 );
550 let (rule_off, rule_on) = (
553 self.outline_at(theme, off_states),
554 self.outline_at(theme, on_states),
555 );
556 let edge = if rule_on.a == 0.0 {
557 rule_off.fade(1.0 - mix)
558 } else if rule_off.a == 0.0 {
559 rule_on.fade(mix)
560 } else {
561 travel(rule_off, rule_on, mix)
562 };
563 let edge_width = self
564 .track_outline_width
565 .or(theme.widgets.switch.track_outline_width)
566 .unwrap_or(TRACK_OUTLINE);
567
568 let track_rect = Rect::new(
570 bounds.x + (bounds.width - W) * 0.5,
571 bounds.y + (bounds.height - H) * 0.5,
572 W,
573 H,
574 );
575 scene.draw_rect(track_rect, track.fade(o), H * 0.5, edge_width, edge.fade(o));
576
577 let along = if theme.direction == frus_core::TextDirection::Rtl {
580 1.0 - place
581 } else {
582 place
583 };
584 let cx = track_rect.x + H * 0.5 + (W - H) * along;
585 let cy = track_rect.y + H * 0.5;
586
587 if self.enabled {
591 let radius = self
592 .splash_radius
593 .or(theme.widgets.switch.splash_radius)
594 .unwrap_or(SPLASH);
595 crate::toggleable::paint_halos(scene, Point::new(cx, cy), radius, &status, |s| {
596 self.halo_at(theme, self.on, s)
597 });
598 }
599
600 let (mut w, mut h) = self.thumb_size(t);
603 if self.enabled {
604 let press = status.press_progress.clamp(0.0, 1.0);
605 w += (2.0 * THUMB_PRESSED - w) * press;
606 h += (2.0 * THUMB_PRESSED - h) * press;
607 }
608 scene.draw_rect(
609 Rect::new(cx - w * 0.5, cy - h * 0.5, w, h),
610 thumb.fade(o),
611 h * 0.5,
612 0.0,
613 Color::TRANSPARENT,
614 );
615
616 let icon = if self.on {
620 self.thumb_icon
621 } else {
622 self.inactive_thumb_icon
623 };
624 if let Some(icon) = icon {
625 let t_widget = &theme.widgets.switch;
626 let ink = if !self.enabled {
627 over_surface(theme, crate::disabled::DISABLED_CONTENT_OPACITY)
628 } else if self.on {
629 t_widget
630 .icon_color
631 .unwrap_or(theme.scheme.on_primary_container)
632 } else {
633 t_widget
634 .inactive_icon_color
635 .unwrap_or(theme.scheme.surface_container_highest)
636 };
637 let path = icon.placed(
638 ICON_SIZE,
639 cx - ICON_SIZE * 0.5,
640 cy - ICON_SIZE * 0.5,
641 theme.direction,
642 );
643 scene.fill_path(&path, ink.fade(o));
644 }
645 }
646
647 fn on_click(&self) -> Option<Msg> {
648 if !self.enabled {
649 return None;
650 }
651 self.on_toggle.as_ref().map(|make| make(!self.on))
652 }
653
654 fn pan_axis(&self) -> Option<crate::PanAxis> {
657 (self.enabled && self.on_toggle.is_some()).then_some(crate::PanAxis::Horizontal)
658 }
659
660 fn pan_value(&self, event: crate::PanEvent, value: f32, rtl: bool) -> Option<f32> {
663 match event {
664 crate::PanEvent::Start { .. } => Some(value.clamp(0.0, 1.0)),
665 crate::PanEvent::Update { delta, .. } => {
666 let along = delta.x / (W - H);
667 let along = if rtl { -along } else { along };
668 Some((value + along).clamp(0.0, 1.0))
669 }
670 crate::PanEvent::End { .. } => None,
671 }
672 }
673
674 fn on_value_release(&self, value: f32) -> Option<Msg> {
677 if !self.enabled || (value >= 0.5) == self.on {
678 return None;
679 }
680 self.on_toggle.as_ref().map(|make| make(!self.on))
681 }
682
683 fn focusable(&self) -> bool {
684 self.enabled
685 }
686
687 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
688 let semantics =
691 frus_core::SemanticsProperties::new(frus_core::Role::Switch).toggled(self.on);
692 Some(if self.enabled {
693 semantics.clickable()
694 } else {
695 semantics.disabled(true)
696 })
697 }
698
699 fn anim_target(&self) -> Option<f32> {
700 Some(if self.on { 1.0 } else { 0.0 })
701 }
702
703 fn anim_duration(&self) -> f32 {
705 TOGGLE_SECONDS
706 }
707
708 fn anim_curve(&self) -> frus_core::Curve {
712 frus_core::Curve::Linear
713 }
714}
715
716#[cfg(test)]
717mod tests {
718 use super::*;
719 use crate::disabled::{disabled_container, disabled_content, disabled_mark};
720 use crate::icons::Icons;
721 use crate::theme::{MIN_TAP_TARGET, SHRUNK_TAP_TARGET};
722 use crate::widget::Widget;
723
724 #[derive(Clone, Debug, PartialEq)]
725 enum Msg {
726 Set(bool),
727 }
728
729 fn painted(on: bool, enabled: bool, theme: &Theme) -> Vec<(Color, Color)> {
731 let mut scene = Scene::new();
732 Widget::<Msg>::paint(
733 &Switch::<Msg>::new(on).enabled(enabled),
734 Rect::new(0.0, 0.0, W, H),
735 Status {
736 opacity: 1.0,
737 value: if on { 1.0 } else { 0.0 },
738 ..Default::default()
739 },
740 theme,
741 &mut scene,
742 );
743 scene
744 .primitives()
745 .iter()
746 .filter_map(|p| match p {
747 frus_core::Primitive::Rect {
748 color,
749 border_color,
750 ..
751 } => Some((*color, *border_color)),
752 _ => None,
753 })
754 .collect()
755 }
756
757 fn boxes(switch: &Switch<Msg>, status: Status, theme: &Theme) -> Vec<(Rect, Color)> {
759 let mut scene = Scene::new();
760 Widget::<Msg>::paint(switch, Rect::new(0.0, 0.0, W, H), status, theme, &mut scene);
761 scene
762 .primitives()
763 .iter()
764 .filter_map(|p| match p {
765 frus_core::Primitive::Rect { rect, color, .. } => Some((*rect, *color)),
766 _ => None,
767 })
768 .collect()
769 }
770
771 fn state(on: bool) -> Status {
773 Status {
774 opacity: 1.0,
775 value: if on { 1.0 } else { 0.0 },
776 ..Default::default()
777 }
778 }
779
780 #[test]
785 fn a_switch_answers_the_pointer_with_a_halo() {
786 let theme = Theme::default();
787 let switch = Switch::<Msg>::new(false).on_toggle(Msg::Set);
788 let with = |on: bool, f: fn(&mut Status)| {
789 let mut status = state(on);
790 f(&mut status);
791 status
792 };
793 let hover = |s: &mut Status| s.hover_progress = 1.0;
794 let press = |s: &mut Status| s.press_progress = 1.0;
795 let focus = |s: &mut Status| s.focus_progress = 1.0;
796 assert_eq!(
797 boxes(&switch, state(false), &theme).len(),
798 2,
799 "track, thumb"
800 );
801 let lit = boxes(&switch, with(false, hover), &theme);
802 assert_eq!(lit.len(), 3, "track, halo, thumb");
803 let (halo, colour) = lit[1];
804 assert_eq!(colour, theme.scheme.on_surface.with_alpha(0.08));
805 assert_eq!((halo.width, halo.height), (2.0 * SPLASH, 2.0 * SPLASH));
806 assert!(halo.height > H, "wider than the track");
807 let thumb = lit[2].0;
808 assert_eq!(
809 (halo.x + SPLASH, halo.y + SPLASH),
810 (thumb.x + thumb.width * 0.5, thumb.y + thumb.height * 0.5),
811 "round the thumb"
812 );
813
814 let on = Switch::<Msg>::new(true).on_toggle(Msg::Set);
815 assert_eq!(
816 boxes(&on, with(true, hover), &theme)[1].1,
817 theme.primary.with_alpha(0.08)
818 );
819 assert_eq!(
820 boxes(&on, with(true, press), &theme)[1].1,
821 theme.primary.with_alpha(0.1)
822 );
823 assert_eq!(
824 boxes(&switch, with(false, focus), &theme)[1].1,
825 theme.scheme.on_surface.with_alpha(0.1)
826 );
827
828 let mine = Switch::<Msg>::new(false)
830 .on_toggle(Msg::Set)
831 .overlay_color(WidgetStateProperty::all(Color::rgb(1.0, 0.0, 0.0)))
832 .splash_radius(12.0);
833 let (halo, colour) = boxes(&mine, with(false, hover), &theme)[1];
834 assert_eq!((colour, halo.width), (Color::rgb(1.0, 0.0, 0.0), 24.0));
835 }
836
837 #[test]
840 fn the_thumb_answers_the_pointer() {
841 let theme = Theme::light();
842 let hovered = |on: bool| Status {
843 hover_progress: 1.0,
844 interaction: crate::interaction::Interaction::Hovered,
845 ..state(on)
846 };
847 let thumb = |on: bool, status: Status| {
848 boxes(&Switch::<Msg>::new(on).on_toggle(Msg::Set), status, &theme)
849 .last()
850 .expect("a thumb")
851 .1
852 };
853 assert_eq!(thumb(true, hovered(true)), theme.scheme.primary_container);
854 assert_eq!(
855 thumb(false, hovered(false)),
856 theme.scheme.on_surface_variant
857 );
858 assert_eq!(thumb(true, state(true)), theme.scheme.on_primary);
859 assert_eq!(thumb(false, state(false)), theme.scheme.outline);
860
861 let mine = WidgetStateProperty::new()
863 .when(WidgetState::Hovered, Color::rgb(0.0, 0.0, 1.0))
864 .when(WidgetState::Selected, Color::rgb(0.0, 1.0, 0.0));
865 let painted = |on: bool, status: Status| {
866 boxes(
867 &Switch::<Msg>::new(on)
868 .on_toggle(Msg::Set)
869 .thumb_colors(mine.clone()),
870 status,
871 &theme,
872 )
873 .last()
874 .expect("a thumb")
875 .1
876 };
877 assert_eq!(painted(false, hovered(false)), Color::rgb(0.0, 0.0, 1.0));
878 assert_eq!(painted(true, state(true)), Color::rgb(0.0, 1.0, 0.0));
879 assert_eq!(
880 painted(false, state(false)),
881 theme.scheme.outline,
882 "what it leaves unsaid is the reference's"
883 );
884 }
885
886 #[test]
888 fn the_track_and_its_rule_are_the_callers() {
889 let theme = Theme::light();
890 let red = Color::rgb(1.0, 0.0, 0.0);
891 let blue = Color::rgb(0.0, 0.0, 1.0);
892 let switch = Switch::<Msg>::new(false)
893 .on_toggle(Msg::Set)
894 .track_colors(WidgetStateProperty::all(red))
895 .track_outline_color(WidgetStateProperty::all(blue))
896 .track_outline_width(3.0);
897 let mut scene = Scene::new();
898 Widget::<Msg>::paint(
899 &switch,
900 Rect::new(0.0, 0.0, W, H),
901 state(false),
902 &theme,
903 &mut scene,
904 );
905 match scene.primitives()[0] {
906 frus_core::Primitive::Rect {
907 color,
908 border_color,
909 border_width,
910 ..
911 } => assert_eq!((color, border_color, border_width), (red, blue, 3.0)),
912 _ => panic!("the track is a rectangle"),
913 }
914 let plain = Switch::<Msg>::new(false).on_toggle(Msg::Set);
916 let mut scene = Scene::new();
917 Widget::<Msg>::paint(
918 &plain,
919 Rect::new(0.0, 0.0, W, H),
920 state(false),
921 &theme,
922 &mut scene,
923 );
924 match scene.primitives()[0] {
925 frus_core::Primitive::Rect { border_width, .. } => assert_eq!(border_width, 2.0),
926 _ => panic!("the track is a rectangle"),
927 }
928 }
929
930 #[test]
933 fn a_disabled_switch_does_not_light() {
934 let theme = Theme::default();
935 let dead = Switch::<Msg>::new(false).on_toggle(Msg::Set).enabled(false);
936 for status in [
937 Status {
938 hover_progress: 1.0,
939 ..state(false)
940 },
941 Status {
942 focus_progress: 1.0,
943 ..state(false)
944 },
945 Status {
946 press_progress: 1.0,
947 ..state(false)
948 },
949 ] {
950 assert_eq!(
951 boxes(&dead, status, &theme).len(),
952 2,
953 "track and thumb only"
954 );
955 }
956 }
957
958 fn thumb_radius(switch: &Switch<Msg>, status: Status, theme: &Theme) -> f32 {
960 boxes(switch, status, theme)
961 .last()
962 .map(|(rect, _)| rect.width * 0.5)
963 .expect("a switch paints a thumb")
964 }
965
966 #[test]
970 fn a_thumb_that_carries_a_glyph_is_the_larger_one() {
971 let theme = Theme::default();
972 let bare = Switch::<Msg>::new(false).on_toggle(Msg::Set);
973 assert_eq!(thumb_radius(&bare, state(false), &theme), THUMB_OFF);
974
975 let ticked = Switch::<Msg>::new(false)
976 .on_toggle(Msg::Set)
977 .thumb_icon(Icons::CHECK);
978 assert_eq!(
979 thumb_radius(&ticked, state(false), &theme),
980 THUMB_OFF,
981 "an on glyph leaves the off thumb alone"
982 );
983 let crossed = Switch::<Msg>::new(false)
984 .on_toggle(Msg::Set)
985 .inactive_thumb_icon(Icons::CLOSE);
986 assert_eq!(
987 thumb_radius(&crossed, state(false), &theme),
988 THUMB_WITH_ICON
989 );
990 assert_eq!(
991 thumb_radius(&crossed, state(true), &theme),
992 THUMB_ON,
993 "and the on thumb was already that size"
994 );
995 }
996
997 #[test]
999 fn the_glyph_is_drawn_at_the_end_that_has_one() {
1000 let theme = Theme::default();
1001 let glyph = |switch: &Switch<Msg>, on: bool| {
1002 let mut scene = Scene::new();
1003 Widget::<Msg>::paint(
1004 switch,
1005 Rect::new(0.0, 0.0, W, H),
1006 state(on),
1007 &theme,
1008 &mut scene,
1009 );
1010 scene.primitives().iter().find_map(|p| match p {
1011 frus_core::Primitive::Path { fill, .. } => *fill,
1012 _ => None,
1013 })
1014 };
1015 let ticked = Switch::<Msg>::new(true)
1016 .on_toggle(Msg::Set)
1017 .thumb_icon(Icons::CHECK);
1018 assert_eq!(
1019 glyph(&ticked, true),
1020 Some(theme.scheme.on_primary_container)
1021 );
1022 let unticked = Switch::<Msg>::new(false)
1026 .on_toggle(Msg::Set)
1027 .thumb_icon(Icons::CHECK);
1028 assert_eq!(glyph(&unticked, false), None, "nothing at the other end");
1029
1030 let crossed = Switch::<Msg>::new(false)
1031 .on_toggle(Msg::Set)
1032 .thumb_icon(Icons::CHECK)
1033 .inactive_thumb_icon(Icons::CLOSE);
1034 assert_eq!(
1035 glyph(&crossed, false),
1036 Some(theme.scheme.surface_container_highest),
1037 "off, the glyph takes the track's own colour: a hole, not a mark"
1038 );
1039 }
1040
1041 #[test]
1043 fn a_held_thumb_swells() {
1044 let theme = Theme::default();
1045 let switch = Switch::<Msg>::new(false).on_toggle(Msg::Set);
1046 let pressed = Status {
1047 press_progress: 1.0,
1048 ..state(false)
1049 };
1050 assert_eq!(thumb_radius(&switch, pressed, &theme), THUMB_PRESSED);
1051 for end in [false, true] {
1054 assert!(
1055 thumb_radius(&switch, pressed, &theme) > thumb_radius(&switch, state(end), &theme),
1056 "not past the {end} end"
1057 );
1058 }
1059 }
1060
1061 #[test]
1065 fn a_held_thumb_grows_into_it() {
1066 let theme = Theme::default();
1067 let switch = Switch::<Msg>::new(false).on_toggle(Msg::Set);
1068 let at = |p: f32| {
1069 thumb_radius(
1070 &switch,
1071 Status {
1072 press_progress: p,
1073 ..state(false)
1074 },
1075 &theme,
1076 )
1077 };
1078 assert_eq!(at(0.0), THUMB_OFF, "untouched");
1079 assert_eq!(at(1.0), THUMB_PRESSED, "held");
1080 assert!(
1081 (at(0.5) - (THUMB_OFF + THUMB_PRESSED) * 0.5).abs() < 0.01,
1082 "half way = {}",
1083 at(0.5)
1084 );
1085 }
1086
1087 fn reserved_height(switch: &Switch<Msg>, theme: &Theme) -> f32 {
1089 match Widget::<Msg>::style_themed(switch, theme).height {
1090 Dimension::Length(h) => h,
1091 other => panic!("a switch asks for a length, not {other:?}"),
1092 }
1093 }
1094
1095 #[test]
1102 fn a_switch_reserves_room_for_a_finger() {
1103 let theme = Theme::default();
1104 assert_eq!(
1105 reserved_height(&Switch::<Msg>::new(false).on_toggle(Msg::Set), &theme),
1106 MIN_TAP_TARGET
1107 );
1108 assert_eq!(
1109 reserved_height(
1110 &Switch::<Msg>::new(false)
1111 .on_toggle(Msg::Set)
1112 .tap_target(TapTarget::ShrinkWrap),
1113 &theme
1114 ),
1115 SHRUNK_TAP_TARGET,
1116 "and a caller may ask for the smaller answer"
1117 );
1118
1119 let dense = Theme {
1122 tap_target: TapTarget::ShrinkWrap,
1123 ..Theme::default()
1124 };
1125 let plain = Switch::<Msg>::new(false).on_toggle(Msg::Set);
1126 assert_eq!(reserved_height(&plain, &dense), SHRUNK_TAP_TARGET);
1127 let mut mixed = dense;
1128 mixed.widgets.switch.tap_target = Some(TapTarget::Padded);
1129 assert_eq!(reserved_height(&plain, &mixed), MIN_TAP_TARGET);
1130 }
1131
1132 #[test]
1135 fn the_track_is_centred_in_the_room() {
1136 let theme = Theme::default();
1137 let switch = Switch::<Msg>::new(false).on_toggle(Msg::Set);
1138 let mut scene = Scene::new();
1139 Widget::<Msg>::paint(
1140 &switch,
1141 Rect::new(0.0, 0.0, W, MIN_TAP_TARGET),
1142 state(false),
1143 &theme,
1144 &mut scene,
1145 );
1146 let rects: Vec<Rect> = scene
1147 .primitives()
1148 .iter()
1149 .filter_map(|p| match p {
1150 frus_core::Primitive::Rect { rect, .. } => Some(*rect),
1151 _ => None,
1152 })
1153 .collect();
1154
1155 let track = rects[0];
1156 assert_eq!(track.height, H, "the track keeps its own height");
1157 assert!(
1158 (track.y - (MIN_TAP_TARGET - H) * 0.5).abs() < 0.01,
1159 "and sits in the middle of the room: {track:?}"
1160 );
1161 let thumb = *rects.last().expect("a switch paints a thumb");
1162 assert!(
1163 (thumb.y + thumb.height * 0.5 - MIN_TAP_TARGET * 0.5).abs() < 0.01,
1164 "the thumb is centred on the track: {thumb:?}"
1165 );
1166 }
1167
1168 #[test]
1171 fn a_live_switch_takes_a_horizontal_drag() {
1172 let live = Switch::<Msg>::new(false).on_toggle(Msg::Set);
1173 assert_eq!(
1174 Widget::<Msg>::pan_axis(&live),
1175 Some(crate::PanAxis::Horizontal)
1176 );
1177 assert_eq!(Widget::<Msg>::pan_axis(&live.enabled(false)), None);
1178 assert_eq!(Widget::<Msg>::pan_axis(&Switch::<Msg>::new(false)), None);
1179 }
1180
1181 #[test]
1185 fn the_drag_moves_the_thumb_by_the_finger() {
1186 let switch = Switch::<Msg>::new(false).on_toggle(Msg::Set);
1187 let update = |dx: f32| crate::PanEvent::Update {
1188 local: frus_core::Point::new(0.0, 0.0),
1189 delta: frus_core::Point::new(dx, 0.0),
1190 };
1191 let start = crate::PanEvent::Start {
1192 local: frus_core::Point::new(0.0, 0.0),
1193 };
1194 let value = |e, v, rtl| Widget::<Msg>::pan_value(&switch, e, v, rtl);
1195 assert_eq!(value(start, 0.0, false), Some(0.0), "held where it is");
1196 assert_eq!(value(update(5.0), 0.0, false), Some(0.25));
1197 assert_eq!(value(update(5.0), 0.5, true), Some(0.25), "mirrored");
1198 assert_eq!(
1199 value(update(50.0), 0.5, false),
1200 Some(1.0),
1201 "not past the end"
1202 );
1203 assert_eq!(value(update(-50.0), 0.5, false), Some(0.0), "nor the start");
1204 let end = crate::PanEvent::End {
1205 velocity: frus_core::Point::new(0.0, 0.0),
1206 };
1207 assert_eq!(value(end, 0.7, false), None, "the release is not a move");
1208 }
1209
1210 #[test]
1212 fn let_go_past_half_way_it_flips() {
1213 let off = Switch::<Msg>::new(false).on_toggle(Msg::Set);
1214 assert_eq!(
1215 Widget::<Msg>::on_value_release(&off, 0.5),
1216 Some(Msg::Set(true))
1217 );
1218 assert_eq!(Widget::<Msg>::on_value_release(&off, 0.49), None);
1219 let on = Switch::<Msg>::new(true).on_toggle(Msg::Set);
1220 assert_eq!(
1221 Widget::<Msg>::on_value_release(&on, 0.3),
1222 Some(Msg::Set(false))
1223 );
1224 assert_eq!(Widget::<Msg>::on_value_release(&on, 0.5), None);
1225 assert_eq!(
1226 Widget::<Msg>::on_value_release(&off.enabled(false), 1.0),
1227 None,
1228 "a disabled switch never flips"
1229 );
1230 }
1231
1232 #[test]
1235 fn a_placed_thumb_is_where_the_finger_put_it() {
1236 let theme = Theme::default();
1237 let switch = Switch::<Msg>::new(true).on_toggle(Msg::Set);
1238 let centre = |placed: bool| {
1239 let thumb = boxes(
1240 &switch,
1241 Status {
1242 value: 0.7,
1243 value_placed: placed,
1244 ..state(true)
1245 },
1246 &theme,
1247 )
1248 .last()
1249 .expect("a thumb")
1250 .0;
1251 thumb.x + thumb.width * 0.5
1252 };
1253 assert!((centre(true) - (H * 0.5 + (W - H) * 0.7)).abs() < 1e-4);
1254 assert!(
1255 centre(false) > centre(true),
1256 "the curve would have overshot"
1257 );
1258 }
1259
1260 #[test]
1261 fn click_toggles() {
1262 assert_eq!(
1263 Widget::on_click(&Switch::new(false).on_toggle(Msg::Set)),
1264 Some(Msg::Set(true))
1265 );
1266 }
1267
1268 #[test]
1269 fn a_disabled_switch_is_inert_but_still_says_which_way_it_is_set() {
1270 let dead = Switch::new(true).on_toggle(Msg::Set).enabled(false);
1271 assert_eq!(Widget::on_click(&dead), None, "the press goes nowhere");
1272 assert!(!Widget::<Msg>::focusable(&dead), "out of the tab order");
1273 let semantics = Widget::<Msg>::semantics(&dead).expect("still announced");
1274 assert!(semantics.disabled, "and announced as unavailable");
1275 assert_eq!(semantics.toggled, frus_core::Toggled::True, "still on");
1276 }
1277
1278 #[test]
1282 fn a_disabled_switch_takes_both_halves_of_the_rule() {
1283 for theme in [Theme::dark(), Theme::light()] {
1284 let off = painted(false, false, &theme);
1285 let ((track, ring), thumb) = (off[0], off[1].0);
1286 assert_eq!(ring, disabled_container(&theme), "the ring is a container");
1292 assert_eq!(
1293 thumb,
1294 disabled_content(&theme),
1295 "the thumb is content on it"
1296 );
1297 let from_surface = |c: Color| {
1300 (c.r - theme.scheme.surface.r).abs()
1301 + (c.g - theme.scheme.surface.g).abs()
1302 + (c.b - theme.scheme.surface.b).abs()
1303 };
1304 assert!(
1305 from_surface(ring) < from_surface(thumb),
1306 "and the container is the quieter of the two"
1307 );
1308 assert!(
1309 from_surface(track) < from_surface(ring),
1310 "with the wash inside it quieter still"
1311 );
1312
1313 let on = painted(true, false, &theme);
1317 assert_eq!(on[0].0, disabled_container(&theme), "a filled track");
1318 assert_eq!(on[1].0, disabled_mark(&theme), "an opaque thumb");
1319 assert_eq!(on[0].1.a, 0.0, "and no ring round it");
1320 }
1321 }
1322
1323 #[test]
1324 fn a_live_switch_is_untouched_by_any_of_it() {
1325 let theme = Theme::dark();
1326 let live = painted(true, true, &theme);
1327 assert_ne!(live[0].0, disabled_container(&theme));
1328 assert_ne!(live[1].0, disabled_content(&theme));
1329 }
1330}
1331
1332#[cfg(test)]
1333mod color_tests {
1334 use super::*;
1335 use crate::widget::Widget;
1336
1337 const BRAND: Color = Color::rgb(0.0, 0.6, 0.3);
1338 const RAIL: Color = Color::rgb(0.9, 0.9, 0.2);
1339 const KNOB: Color = Color::rgb(0.1, 0.1, 0.9);
1340
1341 fn painted(switch: &Switch<()>, on: bool, theme: &Theme) -> (Color, Color, Color) {
1343 at(switch, if on { 1.0 } else { 0.0 }, theme)
1344 }
1345
1346 fn at(switch: &Switch<()>, t: f32, theme: &Theme) -> (Color, Color, Color) {
1348 let mut scene = Scene::new();
1349 Widget::<()>::paint(
1350 switch,
1351 Rect::new(0.0, 0.0, W, H),
1352 Status {
1353 opacity: 1.0,
1354 value: t,
1355 ..Default::default()
1356 },
1357 theme,
1358 &mut scene,
1359 );
1360 let rects: Vec<(Color, Color)> = scene
1361 .primitives()
1362 .iter()
1363 .filter_map(|p| match p {
1364 frus_core::Primitive::Rect {
1365 color,
1366 border_color,
1367 ..
1368 } => Some((*color, *border_color)),
1369 _ => None,
1370 })
1371 .collect();
1372 (rects[0].0, rects[1].0, rects[0].1)
1373 }
1374
1375 #[test]
1378 fn the_defaults_are_the_reference_s() {
1379 let theme = Theme::default();
1380 let (track, thumb, ring) = painted(&Switch::<()>::new(true), true, &theme);
1383 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`)");
1386
1387 let (track, thumb, ring) = painted(&Switch::<()>::new(false), false, &theme);
1388 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");
1392 }
1393
1394 #[test]
1397 fn the_ring_belongs_to_the_off_end_alone() {
1398 let theme = Theme::default();
1399 let switch = Switch::<()>::new(false);
1400 let alpha = |t: f32| at(&switch, t, &theme).2.a;
1401 assert_eq!(alpha(0.0), 1.0, "fully drawn off");
1402 assert_eq!(alpha(1.0), 0.0, "gone on");
1403 assert!(
1405 (alpha(0.5) - (1.0 - Curve::ease_in().transform(0.5))).abs() < 1e-6,
1406 "and part drawn halfway, rather than snapping at one end"
1407 );
1408 }
1409
1410 #[test]
1415 fn the_thumb_grows_as_it_travels() {
1416 let theme = Theme::default();
1417 let switch = Switch::<()>::new(false);
1418 let thumb = |t: f32| {
1419 let mut scene = Scene::new();
1420 Widget::<()>::paint(
1421 &switch,
1422 Rect::new(0.0, 0.0, W, H),
1423 Status {
1424 opacity: 1.0,
1425 value: t,
1426 ..Default::default()
1427 },
1428 &theme,
1429 &mut scene,
1430 );
1431 match scene.primitives()[1] {
1432 frus_core::Primitive::Rect { rect, .. } => rect,
1433 _ => panic!("the thumb is a rectangle"),
1434 }
1435 };
1436 assert_eq!(thumb(0.0).width, THUMB_OFF * 2.0);
1437 assert_eq!(thumb(1.0).width, THUMB_ON * 2.0);
1438 assert!((thumb(0.0).x - (H * 0.5 - THUMB_OFF)).abs() < 1e-4);
1440 assert!((thumb(1.0).x + thumb(1.0).width - (W - H * 0.5 + THUMB_ON)).abs() < 1e-4);
1441 let mid = thumb(0.5);
1443 assert!(mid.width > mid.height, "a pill: {mid:?}");
1444 assert!(mid.width <= THUMB_MIDWAY.0 && mid.height <= THUMB_MIDWAY.1);
1445 }
1446
1447 #[test]
1450 fn the_flip_overshoots_and_settles() {
1451 let theme = Theme::default();
1452 let on = Switch::<()>::new(true);
1453 assert_eq!(Widget::<()>::anim_duration(&on), 0.3);
1454 assert_eq!(
1455 Widget::<()>::anim_curve(&on),
1456 Curve::Linear,
1457 "curved in the paint"
1458 );
1459 let centre = |switch: &Switch<()>, t: f32| {
1460 let mut scene = Scene::new();
1461 Widget::<()>::paint(
1462 switch,
1463 Rect::new(0.0, 0.0, W, H),
1464 Status {
1465 opacity: 1.0,
1466 value: t,
1467 ..Default::default()
1468 },
1469 &theme,
1470 &mut scene,
1471 );
1472 match scene.primitives()[1] {
1473 frus_core::Primitive::Rect { rect, .. } => rect.x + rect.width * 0.5,
1474 _ => panic!("the thumb is a rectangle"),
1475 }
1476 };
1477 let end = W - H * 0.5;
1478 assert!(centre(&on, 0.7) > end, "past the end on the way on");
1479 assert!((centre(&on, 1.0) - end).abs() < 1e-4, "and back on it");
1480 let off = Switch::<()>::new(false);
1481 assert!(centre(&off, 0.3) < H * 0.5, "past the start on the way off");
1482 let size = |t: f32| on.thumb_size(t);
1484 let early = size(0.05);
1485 assert!(
1486 early.0 > early.1 && early.0 > 2.0 * THUMB_OFF,
1487 "already stretching towards the pill: {early:?}"
1488 );
1489 assert_eq!(size(0.11), THUMB_MIDWAY);
1490 assert_eq!(size(0.9), (2.0 * THUMB_ON, 2.0 * THUMB_ON));
1491 assert_eq!(off.thumb_size(0.89), THUMB_MIDWAY, "and back the other way");
1492 assert_eq!(off.thumb_size(0.1), (2.0 * THUMB_OFF, 2.0 * THUMB_OFF));
1493 }
1494
1495 #[test]
1498 fn the_box_has_room_either_side() {
1499 let theme = Theme::default();
1500 let width =
1501 |switch: &Switch<()>, theme: &Theme| match Widget::<()>::style_themed(switch, theme)
1502 .width
1503 {
1504 Dimension::Length(w) => w,
1505 other => panic!("a length, not {other:?}"),
1506 };
1507 assert_eq!(width(&Switch::<()>::new(false), &theme), 60.0);
1508 assert_eq!(
1509 width(&Switch::<()>::new(false).padding(Insets::ZERO), &theme),
1510 W
1511 );
1512 let mut roomy = Theme::default();
1513 roomy.widgets.switch.padding = Some(Insets::new(0.0, 10.0, 0.0, 10.0));
1514 assert_eq!(width(&Switch::<()>::new(false), &roomy), 72.0);
1515 }
1516
1517 #[test]
1519 fn each_end_takes_its_own_colour() {
1520 let theme = Theme::default();
1521 let switch = Switch::<()>::new(true)
1522 .track_color(BRAND)
1523 .inactive_track_color(RAIL)
1524 .thumb_color(KNOB);
1525 let (track, thumb, _) = painted(&switch, true, &theme);
1526 lands_on(track, BRAND);
1527 lands_on(thumb, KNOB);
1528 lands_on(painted(&switch, false, &theme).0, RAIL);
1529 }
1530
1531 #[test]
1534 fn the_travel_runs_between_the_two_overrides() {
1535 let theme = Theme::default();
1536 let switch = Switch::<()>::new(true)
1537 .track_color(BRAND)
1538 .inactive_track_color(RAIL);
1539 let mut scene = Scene::new();
1540 Widget::<()>::paint(
1541 &switch,
1542 Rect::new(0.0, 0.0, W, H),
1543 Status {
1544 opacity: 1.0,
1545 value: 0.5,
1546 ..Default::default()
1547 },
1548 &theme,
1549 &mut scene,
1550 );
1551 let track = match scene.primitives()[0] {
1552 frus_core::Primitive::Rect { color, .. } => color,
1553 _ => panic!("the track is a rectangle"),
1554 };
1555 assert_eq!(
1557 track,
1558 RAIL.lerp(BRAND, Curve::ease_out().transform(0.5)),
1559 "between the two ends"
1560 );
1561 }
1562
1563 #[test]
1571 fn the_two_ends_of_the_thumb_are_two_colours() {
1572 let theme = Theme::default();
1573 let switch = Switch::<()>::new(false).thumb_color(KNOB);
1574 lands_on(painted(&switch, false, &theme).1, theme.scheme.outline);
1575 lands_on(painted(&switch, true, &theme).1, KNOB);
1576 let both = Switch::<()>::new(false)
1577 .thumb_color(KNOB)
1578 .inactive_thumb_color(RAIL);
1579 lands_on(painted(&both, false, &theme).1, RAIL);
1580 }
1581
1582 fn lands_on(got: Color, want: Color) {
1586 let off = (got.r - want.r)
1587 .abs()
1588 .max((got.g - want.g).abs())
1589 .max((got.b - want.b).abs());
1590 assert!(off < 1e-4, "{got:?} is not {want:?}");
1591 }
1592
1593 #[test]
1595 fn the_theme_answers_and_the_instance_overrules_it() {
1596 let mut theme = Theme::default();
1597 theme.widgets.switch.track_color = Some(RAIL);
1598 lands_on(painted(&Switch::<()>::new(true), true, &theme).0, RAIL);
1599 lands_on(
1600 painted(&Switch::<()>::new(true).track_color(BRAND), true, &theme).0,
1601 BRAND,
1602 );
1603 }
1604}