1use std::rc::Rc;
217use std::time::Duration;
218
219use frust::authoring::text::{FontWeight, LineHeight, TextContext, TextLayout, TextStyle};
220use frust::authoring::{
221 Action, AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, CornerRadii, CursorIcon,
222 EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, Role,
223 SemanticsCtx, Toggled, View, Widget,
224};
225use frust::input::TOUCH_SLOP;
226use frust::{AnimationController, FrameTime, SpringDesc, Theme};
227use kurbo::{Point, Rect, RoundedRect, RoundedRectRadii, Shape, Size};
228use peniko::{Brush, Color};
229
230use crate::ButtonVariant;
231use crate::interaction::{
232 DISABLED_CONTAINER_OPACITY, DISABLED_CONTENT_OPACITY, HapticSignal, InteractionState,
233 MaterialHaptics,
234};
235use crate::press::presses;
236
237const OUTLINE_WIDTH: f64 = 1.0;
242
243const PATH_TOLERANCE: f64 = 0.1;
246
247const SHAPING_INK: Color = Color::BLACK;
250
251#[derive(Clone, Copy, Debug, PartialEq)]
253struct SizeMetrics {
254 height: f64,
255 h_padding: f64,
256 icon_size: f64,
257 icon_gap: f64,
258}
259
260type TypeToken = (f32, f32, f32, FontWeight);
263
264const LABEL_MEDIUM: TypeToken = (12.0, 16.0, 0.5, FontWeight::MEDIUM);
265const LABEL_LARGE: TypeToken = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
266const TITLE_MEDIUM: TypeToken = (16.0, 24.0, 0.15, FontWeight::MEDIUM);
267
268#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
272pub enum ToggleButtonSize {
273 #[default]
275 Sm,
276 Md,
278 Lg,
280}
281
282impl ToggleButtonSize {
283 fn metrics(self) -> SizeMetrics {
285 match self {
286 ToggleButtonSize::Sm => SizeMetrics {
288 height: 40.0,
289 h_padding: 16.0,
290 icon_size: 20.0,
291 icon_gap: 8.0,
292 },
293 ToggleButtonSize::Md => SizeMetrics {
295 height: 56.0,
296 h_padding: 24.0,
297 icon_size: 24.0,
298 icon_gap: 8.0,
299 },
300 ToggleButtonSize::Lg => SizeMetrics {
302 height: 96.0,
303 h_padding: 48.0,
304 icon_size: 32.0,
305 icon_gap: 12.0,
306 },
307 }
308 }
309
310 fn square_radius(self, theme: Option<&Theme>) -> f64 {
318 match theme {
319 Some(theme) => match self {
320 ToggleButtonSize::Sm => theme.shape.medium,
321 ToggleButtonSize::Md => theme.shape.large,
322 ToggleButtonSize::Lg => theme.shape.extra_large,
323 },
324 None => match self {
325 ToggleButtonSize::Sm => 12.0,
326 ToggleButtonSize::Md => 16.0,
327 ToggleButtonSize::Lg => 28.0,
328 },
329 }
330 }
331
332 fn pressed_radius(self, theme: Option<&Theme>) -> f64 {
336 match theme {
337 Some(theme) => match self {
338 ToggleButtonSize::Sm => theme.shape.small,
339 ToggleButtonSize::Md => theme.shape.medium,
340 ToggleButtonSize::Lg => theme.shape.large,
341 },
342 None => match self {
343 ToggleButtonSize::Sm => 8.0,
344 ToggleButtonSize::Md => 12.0,
345 ToggleButtonSize::Lg => 16.0,
346 },
347 }
348 }
349
350 fn hovered_radius(self) -> f64 {
355 match self {
356 ToggleButtonSize::Sm => 10.0,
357 ToggleButtonSize::Md => 14.0,
358 ToggleButtonSize::Lg => 22.0,
359 }
360 }
361
362 fn label_style(self, theme: Option<&Theme>) -> TextStyle {
368 let mut style = match theme {
369 Some(theme) => match self {
370 ToggleButtonSize::Sm => theme.type_scale.label_medium.clone(),
371 ToggleButtonSize::Md => theme.type_scale.label_large.clone(),
372 ToggleButtonSize::Lg => theme.type_scale.title_medium.clone(),
373 },
374 None => {
375 let (size, line_height, letter_spacing, weight) = match self {
376 ToggleButtonSize::Sm => LABEL_MEDIUM,
377 ToggleButtonSize::Md => LABEL_LARGE,
378 ToggleButtonSize::Lg => TITLE_MEDIUM,
379 };
380 let mut style = TextStyle::new(size, SHAPING_INK);
381 style.line_height = LineHeight::Absolute(line_height);
382 style.letter_spacing = letter_spacing;
383 style.weight = weight;
384 style
385 }
386 };
387 style.color = SHAPING_INK;
388 style
389 }
390}
391
392const CONNECTED_INNER_RADIUS: f64 = 6.0;
400const CONNECTED_PRESSED_INNER_RADIUS: f64 = 2.0;
405
406const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
410const ON_PRIMARY: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
412const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
414const SURFACE_CONTAINER_HIGHEST: Color = Color::from_rgb8(0xE6, 0xE0, 0xE9);
416const SURFACE_CONTAINER_LOW: Color = Color::from_rgb8(0xF7, 0xF2, 0xFA);
418const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
420const ON_SECONDARY_CONTAINER: Color = Color::from_rgb8(0x1D, 0x19, 0x2B);
422const OUTLINE: Color = Color::from_rgb8(0x79, 0x74, 0x7E);
424const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
426const FALLBACK_SHADOW_COLOR: Color = Color::new([0.0, 0.0, 0.0, 0.3]);
429
430#[derive(Clone, Copy, Debug, PartialEq)]
433struct Roles {
434 primary: Color,
435 on_primary: Color,
436 on_surface_variant: Color,
437 surface_container_highest: Color,
438 surface_container_low: Color,
439 secondary_container: Color,
440 on_secondary_container: Color,
441 outline: Color,
442 on_surface: Color,
443}
444
445impl Roles {
446 fn resolve(theme: Option<&Theme>) -> Self {
447 match theme {
448 Some(theme) => {
449 let s = theme.scheme();
450 Self {
451 primary: s.primary,
452 on_primary: s.on_primary,
453 on_surface_variant: s.on_surface_variant,
454 surface_container_highest: s.surface_container_highest,
455 surface_container_low: s.surface_container_low,
456 secondary_container: s.secondary_container,
457 on_secondary_container: s.on_secondary_container,
458 outline: s.outline,
459 on_surface: s.on_surface,
460 }
461 }
462 None => Self {
463 primary: PRIMARY,
464 on_primary: ON_PRIMARY,
465 on_surface_variant: ON_SURFACE_VARIANT,
466 surface_container_highest: SURFACE_CONTAINER_HIGHEST,
467 surface_container_low: SURFACE_CONTAINER_LOW,
468 secondary_container: SECONDARY_CONTAINER,
469 on_secondary_container: ON_SECONDARY_CONTAINER,
470 outline: OUTLINE,
471 on_surface: ON_SURFACE,
472 },
473 }
474 }
475}
476
477#[derive(Clone, Copy, Debug, PartialEq)]
479struct ToggleColors {
480 container: Color,
482 content: Color,
484 outline: Option<Color>,
486}
487
488fn with_alpha(color: Color, alpha: f32) -> Color {
490 let c = color.components;
491 Color::new([c[0], c[1], c[2], alpha])
492}
493
494fn is_transparent(variant: ButtonVariant) -> bool {
501 matches!(variant, ButtonVariant::Outlined | ButtonVariant::Text)
502}
503
504fn resolve_colors(
514 theme: Option<&Theme>,
515 variant: ButtonVariant,
516 checked: bool,
517 enabled: bool,
518) -> ToggleColors {
519 let roles = Roles::resolve(theme);
520
521 if !enabled {
522 return ToggleColors {
523 container: if is_transparent(variant) {
524 Color::TRANSPARENT
525 } else {
526 with_alpha(roles.on_surface, DISABLED_CONTAINER_OPACITY)
527 },
528 content: with_alpha(roles.on_surface, DISABLED_CONTENT_OPACITY),
529 outline: (variant == ButtonVariant::Outlined)
530 .then(|| with_alpha(roles.on_surface, DISABLED_CONTAINER_OPACITY)),
531 };
532 }
533
534 let (content, container) = match variant {
535 ButtonVariant::Filled => (
536 if checked {
537 roles.on_primary
538 } else {
539 roles.on_surface_variant
540 },
541 if checked {
542 roles.primary
543 } else {
544 roles.surface_container_highest
545 },
546 ),
547 ButtonVariant::Elevated => (
548 if checked {
549 roles.on_primary
550 } else {
551 roles.primary
552 },
553 if checked {
554 roles.primary
555 } else {
556 roles.surface_container_low
557 },
558 ),
559 ButtonVariant::Tonal => (
560 if checked {
561 roles.on_secondary_container
562 } else {
563 roles.on_surface_variant
564 },
565 if checked {
566 roles.secondary_container
567 } else {
568 roles.surface_container_highest
569 },
570 ),
571 ButtonVariant::Outlined => (
572 if checked {
573 roles.on_secondary_container
574 } else {
575 roles.on_surface
576 },
577 if checked {
578 roles.secondary_container
579 } else {
580 Color::TRANSPARENT
581 },
582 ),
583 ButtonVariant::Text => (
584 if checked {
585 roles.primary
586 } else {
587 roles.on_surface
588 },
589 Color::TRANSPARENT,
590 ),
591 };
592
593 ToggleColors {
594 container,
595 content,
596 outline: (variant == ButtonVariant::Outlined).then_some(roles.outline),
597 }
598}
599
600fn elevation_dp(variant: ButtonVariant, enabled: bool, pressed: bool, hovered: bool) -> f64 {
607 if !enabled {
608 return 0.0;
609 }
610 match variant {
611 ButtonVariant::Elevated => {
612 if pressed {
613 0.0
614 } else if hovered {
615 3.0
616 } else {
617 1.0
618 }
619 }
620 ButtonVariant::Filled | ButtonVariant::Tonal => {
621 if !pressed && hovered {
622 1.0
623 } else {
624 0.0
625 }
626 }
627 ButtonVariant::Outlined | ButtonVariant::Text => 0.0,
628 }
629}
630
631fn resolve_shadow(theme: Option<&Theme>, dp: f64) -> Option<(f64, f64, Color)> {
636 if dp <= 0.0 {
637 return None;
638 }
639 match theme {
640 Some(theme) => {
641 let level = if dp >= theme.elevation.level2.dp {
642 theme.elevation.level2
643 } else {
644 theme.elevation.level1
645 };
646 let shadow = level.shadow(theme.brightness);
647 let color = with_alpha(theme.scheme().shadow, shadow.color_alpha);
648 Some((shadow.blur_std_dev, shadow.y_offset, color))
649 }
650 None => Some((dp, dp / 2.0 + 1.0, FALLBACK_SHADOW_COLOR)),
651 }
652}
653
654const SHAPE_SPRING: SpringDesc = SpringDesc {
660 mass: 1.0,
661 stiffness: 380.0,
662 damping_ratio: 0.55,
663};
664
665const ANIM_PERIOD: Duration = Duration::from_millis(300);
669
670const FLING_VELOCITY: f64 = 4.0;
674
675const RETARGET_TOLERANCE: f64 = 0.1;
680
681const OVERSHOOT_LIMIT: f64 = 1.5;
685
686fn lerp(from: f64, to: f64, t: f64) -> f64 {
689 from + (to - from) * t
690}
691
692#[derive(Clone, Copy, Debug, PartialEq)]
696struct ContentPadding {
697 left: f64,
698 right: f64,
699 top: f64,
700 bottom: f64,
701}
702
703impl ContentPadding {
704 const fn symmetric(horizontal: f64) -> Self {
705 Self {
706 left: horizontal,
707 right: horizontal,
708 top: 0.0,
709 bottom: 0.0,
710 }
711 }
712
713 fn lerp(self, to: Self, t: f64) -> Self {
714 Self {
715 left: lerp(self.left, to.left, t),
716 right: lerp(self.right, to.right, t),
717 top: lerp(self.top, to.top, t),
718 bottom: lerp(self.bottom, to.bottom, t),
719 }
720 }
721
722 fn matches(self, other: Self) -> bool {
723 (self.left - other.left).abs() <= RETARGET_TOLERANCE
724 && (self.right - other.right).abs() <= RETARGET_TOLERANCE
725 && (self.top - other.top).abs() <= RETARGET_TOLERANCE
726 && (self.bottom - other.bottom).abs() <= RETARGET_TOLERANCE
727 }
728}
729
730fn corners_match(a: CornerRadii, b: CornerRadii) -> bool {
732 (a.top_left - b.top_left).abs() <= RETARGET_TOLERANCE
733 && (a.top_right - b.top_right).abs() <= RETARGET_TOLERANCE
734 && (a.bottom_right - b.bottom_right).abs() <= RETARGET_TOLERANCE
735 && (a.bottom_left - b.bottom_left).abs() <= RETARGET_TOLERANCE
736}
737
738fn lerp_corners(from: CornerRadii, to: CornerRadii, t: f64) -> CornerRadii {
741 CornerRadii::new(
742 lerp(from.top_left, to.top_left, t).max(0.0),
743 lerp(from.top_right, to.top_right, t).max(0.0),
744 lerp(from.bottom_right, to.bottom_right, t).max(0.0),
745 lerp(from.bottom_left, to.bottom_left, t).max(0.0),
746 )
747}
748
749#[derive(Clone, Copy, Debug)]
755struct ShapeMotion {
756 from_radii: CornerRadii,
757 to_radii: CornerRadii,
758 from_padding: ContentPadding,
759 to_padding: ContentPadding,
760 anim: AnimationController,
761 seeded: bool,
764}
765
766impl ShapeMotion {
767 fn new() -> Self {
770 let zero_pad = ContentPadding {
771 left: 0.0,
772 right: 0.0,
773 top: 0.0,
774 bottom: 0.0,
775 };
776 Self {
777 from_radii: CornerRadii::default(),
778 to_radii: CornerRadii::default(),
779 from_padding: zero_pad,
780 to_padding: zero_pad,
781 anim: AnimationController::new(ANIM_PERIOD),
782 seeded: false,
783 }
784 }
785
786 fn retarget(&mut self, radii: CornerRadii, padding: ContentPadding) -> bool {
792 if !self.seeded {
793 self.snap_to(radii, padding);
794 return false;
795 }
796 if corners_match(self.to_radii, radii) && self.to_padding.matches(padding) {
797 return false;
798 }
799 self.from_radii = self.radii();
800 self.from_padding = self.padding();
801 self.to_radii = radii;
802 self.to_padding = padding;
803 self.anim = AnimationController::new(ANIM_PERIOD);
804 self.anim.fling(FLING_VELOCITY, SHAPE_SPRING);
805 true
806 }
807
808 fn snap_to(&mut self, radii: CornerRadii, padding: ContentPadding) {
811 self.from_radii = radii;
812 self.to_radii = radii;
813 self.from_padding = padding;
814 self.to_padding = padding;
815 self.anim = AnimationController::new(ANIM_PERIOD);
816 self.seeded = true;
817 }
818
819 fn advance(&mut self, now: FrameTime) -> bool {
822 self.anim.advance(now)
823 }
824
825 fn is_seeded(&self) -> bool {
827 self.seeded
828 }
829
830 fn factor(&self) -> f64 {
832 let raw = self.anim.value();
833 if raw.is_finite() {
834 raw.clamp(0.0, OVERSHOOT_LIMIT)
835 } else {
836 0.0
837 }
838 }
839
840 fn radii(&self) -> CornerRadii {
842 lerp_corners(self.from_radii, self.to_radii, self.factor())
843 }
844
845 fn padding(&self) -> ContentPadding {
847 self.from_padding.lerp(self.to_padding, self.factor())
848 }
849
850 #[cfg(test)]
852 fn target_radii(&self) -> CornerRadii {
853 self.to_radii
854 }
855}
856
857struct LabelRun {
873 content: String,
874 layout: Option<TextLayout>,
875 shaped_for: Option<TextStyle>,
876}
877
878impl LabelRun {
879 fn new(content: String) -> Self {
880 Self {
881 content,
882 layout: None,
883 shaped_for: None,
884 }
885 }
886
887 fn set_content(&mut self, content: &str) {
888 if self.content != content {
889 self.content = content.to_string();
890 self.layout = None;
891 self.shaped_for = None;
892 }
893 }
894
895 fn content(&self) -> &str {
896 &self.content
897 }
898
899 fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> Size {
902 if let Some(cached) = &self.layout
903 && self.shaped_for.as_ref() == Some(style)
904 {
905 return cached.size();
906 }
907 let text_ctx = ctx.text_context::<TextContext>();
908 let laid = text_ctx.layout(&self.content, style, None);
909 let size = laid.size();
910 self.layout = Some(laid);
911 self.shaped_for = Some(style.clone());
912 size
913 }
914
915 fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
916 let Some(layout) = &self.layout else {
917 return;
918 };
919 for mut run in layout.to_scene_runs(origin) {
920 run.brush = Brush::Solid(color);
921 scene.draw_glyph_run(run);
922 }
923 }
924}
925
926fn inside(pos: Point, size: Size) -> bool {
930 pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
931}
932
933fn horizontal_radii(is_first: bool, is_last: bool, outer: f64, inner: f64) -> CornerRadii {
938 let left = if is_first { outer } else { inner };
939 let right = if is_last { outer } else { inner };
940 CornerRadii::new(left, right, right, left)
941}
942
943fn inset_stroke_rrect(size: Size, radii: CornerRadii, width: f64) -> RoundedRect {
949 let half = width / 2.0;
950 let r = RoundedRectRadii::new(
951 (radii.top_left - half).max(0.0),
952 (radii.top_right - half).max(0.0),
953 (radii.bottom_right - half).max(0.0),
954 (radii.bottom_left - half).max(0.0),
955 );
956 RoundedRect::from_rect(
957 Rect::new(half, half, size.width - half, size.height - half),
958 r,
959 )
960}
961
962type OnCheckedChange<State> = Rc<dyn Fn(&mut State, bool)>;
966type OnLongPress<State> = Rc<dyn Fn(&mut State)>;
968
969pub struct ToggleButtonView<State: 'static> {
971 checked: bool,
972 on_checked_change: OnCheckedChange<State>,
973 variant: ButtonVariant,
974 size: ToggleButtonSize,
975 enabled: bool,
976 icon: Option<AnyView<State>>,
977 checked_icon: Option<AnyView<State>>,
978 label: Option<String>,
979 checked_label: Option<String>,
980 is_group_connected: bool,
981 is_first_in_group: bool,
982 is_last_in_group: bool,
983 haptic: HapticSignal,
984 on_long_press: Option<OnLongPress<State>>,
985}
986
987impl<State: 'static> ToggleButtonView<State> {
988 fn effective_icon_view(&self) -> Option<&AnyView<State>> {
991 if self.checked {
992 self.checked_icon.as_ref().or(self.icon.as_ref())
993 } else {
994 self.icon.as_ref()
995 }
996 }
997
998 fn effective_label_text(&self) -> Option<&str> {
1001 if self.checked {
1002 self.checked_label.as_deref().or(self.label.as_deref())
1003 } else {
1004 self.label.as_deref()
1005 }
1006 }
1007}
1008
1009pub fn toggle_button<State: 'static, F: Fn(&mut State, bool) + 'static>(
1019 checked: bool,
1020 on_checked_change: F,
1021) -> ToggleButtonView<State> {
1022 ToggleButtonView {
1023 checked,
1024 on_checked_change: Rc::new(on_checked_change),
1025 variant: ButtonVariant::default(),
1026 size: ToggleButtonSize::default(),
1027 enabled: true,
1028 icon: None,
1029 checked_icon: None,
1030 label: None,
1031 checked_label: None,
1032 is_group_connected: false,
1033 is_first_in_group: true,
1034 is_last_in_group: true,
1035 haptic: HapticSignal::None,
1036 on_long_press: None,
1037 }
1038}
1039
1040#[allow(non_snake_case)]
1043pub fn ToggleButton<State: 'static, F: Fn(&mut State, bool) + 'static>(
1044 checked: bool,
1045 on_checked_change: F,
1046) -> ToggleButtonView<State> {
1047 toggle_button(checked, on_checked_change)
1048}
1049
1050pub fn filled_toggle_button<State: 'static, F: Fn(&mut State, bool) + 'static>(
1053 checked: bool,
1054 on_checked_change: F,
1055) -> ToggleButtonView<State> {
1056 toggle_button(checked, on_checked_change)
1057}
1058
1059pub fn tonal_toggle_button<State: 'static, F: Fn(&mut State, bool) + 'static>(
1061 checked: bool,
1062 on_checked_change: F,
1063) -> ToggleButtonView<State> {
1064 toggle_button(checked, on_checked_change).variant(ButtonVariant::Tonal)
1065}
1066
1067pub fn elevated_toggle_button<State: 'static, F: Fn(&mut State, bool) + 'static>(
1069 checked: bool,
1070 on_checked_change: F,
1071) -> ToggleButtonView<State> {
1072 toggle_button(checked, on_checked_change).variant(ButtonVariant::Elevated)
1073}
1074
1075pub fn outlined_toggle_button<State: 'static, F: Fn(&mut State, bool) + 'static>(
1077 checked: bool,
1078 on_checked_change: F,
1079) -> ToggleButtonView<State> {
1080 toggle_button(checked, on_checked_change).variant(ButtonVariant::Outlined)
1081}
1082
1083pub fn text_toggle_button<State: 'static, F: Fn(&mut State, bool) + 'static>(
1085 checked: bool,
1086 on_checked_change: F,
1087) -> ToggleButtonView<State> {
1088 toggle_button(checked, on_checked_change).variant(ButtonVariant::Text)
1089}
1090
1091impl<State: 'static> ToggleButtonView<State> {
1092 pub fn variant(mut self, variant: ButtonVariant) -> Self {
1095 self.variant = variant;
1096 self
1097 }
1098
1099 pub fn size(mut self, size: ToggleButtonSize) -> Self {
1101 self.size = size;
1102 self
1103 }
1104
1105 pub fn enabled(mut self, enabled: bool) -> Self {
1108 self.enabled = enabled;
1109 self
1110 }
1111
1112 pub fn icon(mut self, icon: AnyView<State>) -> Self {
1115 self.icon = Some(icon);
1116 self
1117 }
1118
1119 pub fn checked_icon(mut self, icon: AnyView<State>) -> Self {
1121 self.checked_icon = Some(icon);
1122 self
1123 }
1124
1125 pub fn label(mut self, label: impl Into<String>) -> Self {
1128 self.label = Some(label.into());
1129 self
1130 }
1131
1132 pub fn checked_label(mut self, label: impl Into<String>) -> Self {
1134 self.checked_label = Some(label.into());
1135 self
1136 }
1137
1138 pub fn group_connected(mut self, connected: bool) -> Self {
1142 self.is_group_connected = connected;
1143 self
1144 }
1145
1146 pub fn first_in_group(mut self, first: bool) -> Self {
1150 self.is_first_in_group = first;
1151 self
1152 }
1153
1154 pub fn last_in_group(mut self, last: bool) -> Self {
1158 self.is_last_in_group = last;
1159 self
1160 }
1161
1162 pub fn haptic(mut self, haptic: HapticSignal) -> Self {
1165 self.haptic = haptic;
1166 self
1167 }
1168
1169 pub fn on_long_press<F: Fn(&mut State) + 'static>(mut self, on_long_press: F) -> Self {
1173 self.on_long_press = Some(Rc::new(on_long_press));
1174 self
1175 }
1176}
1177
1178#[derive(Debug, Default, Clone, Copy)]
1183struct LongPressState {
1184 press_start: Option<FrameTime>,
1187 elapsed: bool,
1190 fired: bool,
1193}
1194
1195impl LongPressState {
1196 fn clear(&mut self) {
1197 *self = Self::default();
1198 }
1199}
1200
1201const LONG_PRESS_MS: f64 = 500.0;
1206
1207pub struct ToggleButtonWidget {
1210 variant: ButtonVariant,
1211 size: ToggleButtonSize,
1212 enabled: bool,
1213 checked: bool,
1215 label: LabelRun,
1217 label_origin: Point,
1218 content_clipped: bool,
1221 icon: Option<ChildPod>,
1222 is_group_connected: bool,
1223 is_first_in_group: bool,
1224 is_last_in_group: bool,
1225 haptic: HapticSignal,
1226 state: InteractionState,
1229 captured: bool,
1231 down_pos: Point,
1234 motion: ShapeMotion,
1236 long_press: LongPressState,
1237 on_checked_change: frust::authoring::ErasedArgCallback<bool>,
1238 on_long_press: Option<frust::authoring::ErasedCallback>,
1239}
1240
1241impl ToggleButtonWidget {
1242 fn has_label(&self) -> bool {
1245 !self.label.content().is_empty()
1246 }
1247
1248 fn h_padding(&self, metrics: &SizeMetrics) -> f64 {
1251 if self.has_label() {
1252 metrics.h_padding
1253 } else {
1254 metrics.h_padding / 2.0
1255 }
1256 }
1257
1258 fn target_radii(&self, theme: Option<&Theme>, height: f64) -> CornerRadii {
1262 let pill = height / 2.0;
1263 if self.is_group_connected {
1264 self.connected_target_radii(theme, pill)
1265 } else {
1266 self.standalone_target_radii(theme, pill)
1267 }
1268 }
1269
1270 fn standalone_target_radii(&self, theme: Option<&Theme>, pill: f64) -> CornerRadii {
1274 if self.enabled && self.state.pressed {
1275 return CornerRadii::uniform(self.size.pressed_radius(theme));
1276 }
1277 if self.enabled && self.state.hovered {
1278 return CornerRadii::uniform(self.size.hovered_radius());
1279 }
1280 if self.checked {
1281 return CornerRadii::uniform(self.size.square_radius(theme));
1282 }
1283 CornerRadii::uniform(pill)
1284 }
1285
1286 fn connected_target_radii(&self, _theme: Option<&Theme>, outer: f64) -> CornerRadii {
1298 if self.enabled && self.state.pressed {
1299 return horizontal_radii(
1300 self.is_first_in_group,
1301 self.is_last_in_group,
1302 outer,
1303 CONNECTED_PRESSED_INNER_RADIUS,
1304 );
1305 }
1306 if self.enabled && self.state.hovered {
1307 return horizontal_radii(
1308 self.is_first_in_group,
1309 self.is_last_in_group,
1310 outer,
1311 self.size.hovered_radius(),
1312 );
1313 }
1314 if self.checked {
1315 return CornerRadii::uniform(outer);
1316 }
1317 horizontal_radii(
1318 self.is_first_in_group,
1319 self.is_last_in_group,
1320 outer,
1321 CONNECTED_INNER_RADIUS,
1322 )
1323 }
1324}
1325
1326impl<State: 'static> View<State> for ToggleButtonView<State> {
1327 type Element = ToggleButtonWidget;
1328
1329 fn build(&self, ctx: &mut BuildCtx<'_>) -> ToggleButtonWidget {
1330 let icon = self
1331 .effective_icon_view()
1332 .map(|icon| frust::authoring::build_child(icon, ctx));
1333 let mut label = LabelRun::new(String::new());
1334 if let Some(text) = self.effective_label_text() {
1335 label.set_content(text);
1336 }
1337 ToggleButtonWidget {
1338 variant: self.variant,
1339 size: self.size,
1340 enabled: self.enabled,
1341 checked: self.checked,
1342 label,
1343 label_origin: Point::ZERO,
1344 content_clipped: false,
1345 icon,
1346 is_group_connected: self.is_group_connected,
1347 is_first_in_group: self.is_first_in_group,
1348 is_last_in_group: self.is_last_in_group,
1349 haptic: self.haptic,
1350 state: InteractionState::new(),
1351 captured: false,
1352 down_pos: Point::ZERO,
1353 motion: ShapeMotion::new(),
1354 long_press: LongPressState::default(),
1355 on_checked_change: frust::authoring::erase_callback_arg(&self.on_checked_change),
1356 on_long_press: self
1357 .on_long_press
1358 .as_ref()
1359 .map(frust::authoring::erase_callback),
1360 }
1361 }
1362
1363 fn rebuild(
1364 &self,
1365 prev: &Self,
1366 element: &mut ToggleButtonWidget,
1367 ctx: &mut BuildCtx<'_>,
1368 ) -> ChangeFlags {
1369 element.on_checked_change = frust::authoring::erase_callback_arg(&self.on_checked_change);
1370 element.on_long_press = self
1371 .on_long_press
1372 .as_ref()
1373 .map(frust::authoring::erase_callback);
1374 element.haptic = self.haptic;
1375 let mut flags = ChangeFlags::NONE;
1376
1377 if prev.checked != self.checked {
1378 element.checked = self.checked;
1379 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1380 }
1381 if prev.variant != self.variant {
1382 element.variant = self.variant;
1383 flags |= ChangeFlags::PAINT;
1384 }
1385 if prev.size != self.size {
1386 element.size = self.size;
1387 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1388 }
1389 if prev.enabled != self.enabled {
1390 element.enabled = self.enabled;
1391 if !self.enabled {
1392 element.state.set_pressed(false);
1395 element.captured = false;
1396 element.long_press.clear();
1397 }
1398 flags |= ChangeFlags::PAINT;
1399 }
1400 if prev.is_group_connected != self.is_group_connected {
1401 element.is_group_connected = self.is_group_connected;
1402 flags |= ChangeFlags::PAINT;
1403 }
1404 if prev.is_first_in_group != self.is_first_in_group {
1405 element.is_first_in_group = self.is_first_in_group;
1406 flags |= ChangeFlags::PAINT;
1407 }
1408 if prev.is_last_in_group != self.is_last_in_group {
1409 element.is_last_in_group = self.is_last_in_group;
1410 flags |= ChangeFlags::PAINT;
1411 }
1412
1413 let prev_label = prev.effective_label_text();
1414 let next_label = self.effective_label_text();
1415 if prev_label != next_label {
1416 element.label.set_content(next_label.unwrap_or(""));
1417 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1418 }
1419
1420 match (prev.effective_icon_view(), self.effective_icon_view()) {
1421 (None, None) => {}
1422 (Some(p), Some(n)) => {
1423 let pod = element.icon.as_mut().expect("icon pod present");
1424 flags |= frust::authoring::rebuild_child(p, n, pod, ctx);
1425 }
1426 (None, Some(n)) => {
1427 element.icon = Some(frust::authoring::build_child(n, ctx));
1428 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1429 }
1430 (Some(p), None) => {
1431 let mut pod = element.icon.take().expect("icon pod present");
1432 frust::authoring::teardown_child(p, &mut pod, ctx);
1433 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1434 }
1435 }
1436
1437 flags
1438 }
1439
1440 fn teardown(&self, element: &mut ToggleButtonWidget, ctx: &mut BuildCtx<'_>) {
1441 if let (Some(icon_view), Some(pod)) = (self.effective_icon_view(), element.icon.as_mut()) {
1442 frust::authoring::teardown_child(icon_view, pod, ctx);
1443 }
1444 }
1445}
1446
1447impl Widget for ToggleButtonWidget {
1448 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1449 let metrics = self.size.metrics();
1450 let (label_style, h_padding) = {
1451 let theme = Theme::from_layout_ctx(ctx);
1452 (self.size.label_style(theme), self.h_padding(&metrics))
1453 };
1454 if !self.motion.is_seeded() {
1455 let theme = Theme::from_layout_ctx(ctx);
1457 let resting = self.target_radii(theme, metrics.height);
1458 self.motion
1459 .snap_to(resting, ContentPadding::symmetric(h_padding));
1460 }
1461 let padding = ContentPadding::symmetric(h_padding);
1478
1479 let icon_size = self.icon.as_mut().map(|pod| {
1480 pod.layout_child(
1481 ctx,
1482 &BoxConstraints::tight(Size::new(metrics.icon_size, metrics.icon_size)),
1483 )
1484 });
1485 let icon_width = icon_size.map_or(0.0, |s| s.width);
1486 let has_label = self.has_label();
1487 let gap = if icon_size.is_some() && has_label {
1489 metrics.icon_gap
1490 } else {
1491 0.0
1492 };
1493
1494 let label_size = self.label.shape(ctx, &label_style);
1499
1500 let content_width = icon_width + gap + label_size.width;
1501 let width = content_width + padding.left + padding.right;
1504 let size = bc.constrain(Size::new(width, metrics.height));
1505
1506 let content_x = (size.width - content_width) / 2.0;
1512 self.content_clipped = content_width > size.width;
1513 let icon_x = content_x;
1514 let label_x = content_x + icon_width + gap;
1515 if let Some(pod) = self.icon.as_mut() {
1516 let icon_height = icon_size.map_or(0.0, |s| s.height);
1517 pod.set_origin(Point::new(icon_x, (size.height - icon_height) / 2.0));
1518 }
1519 self.label_origin = Point::new(label_x, (size.height - label_size.height) / 2.0);
1520
1521 size
1522 }
1523
1524 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1525 let origin = ctx.origin();
1526 let size = ctx.size();
1527
1528 self.state.set_hovered(self.enabled && ctx.is_hovered());
1533 self.state.set_focused(self.enabled && ctx.has_focus());
1534
1535 let (colors, shadow, radii_target, h_padding) = {
1536 let theme = Theme::from_paint_ctx(ctx);
1537 let dp = elevation_dp(
1538 self.variant,
1539 self.enabled,
1540 self.state.pressed,
1541 self.state.hovered,
1542 );
1543 (
1544 resolve_colors(theme, self.variant, self.checked, self.enabled),
1545 resolve_shadow(theme, dp),
1546 self.target_radii(theme, size.height),
1547 self.h_padding(&self.size.metrics()),
1548 )
1549 };
1550
1551 let padding = ContentPadding::symmetric(h_padding);
1552 if self.motion.retarget(radii_target, padding) {
1553 ctx.request_frame();
1554 }
1555 if self.motion.advance(ctx.frame_time()) {
1556 ctx.request_frame();
1557 }
1558 let radii = self.motion.radii();
1559
1560 if let Some((blur, y_offset, shadow_color)) = shadow {
1561 scene.draw_shadow(
1564 Point::new(origin.x, origin.y + y_offset),
1565 size,
1566 radii.largest(),
1567 blur,
1568 shadow_color,
1569 );
1570 }
1571
1572 if colors.container.components[3] > 0.0 {
1573 scene.fill_rounded_rect_radii(origin, size, radii, colors.container);
1574 }
1575
1576 if self.enabled {
1582 let opacity = self.state.resolve_opacity();
1583 if opacity > 0.0 {
1584 scene.fill_rounded_rect_radii(
1585 origin,
1586 size,
1587 radii,
1588 with_alpha(colors.content, opacity),
1589 );
1590 }
1591 }
1592
1593 if let Some(outline) = colors.outline {
1594 let path = inset_stroke_rrect(size, radii, OUTLINE_WIDTH).to_path(PATH_TOLERANCE);
1595 scene.stroke_path(origin, &path, OUTLINE_WIDTH, &Brush::Solid(outline));
1596 }
1597
1598 if self.content_clipped {
1603 scene.push_clip(origin, size);
1604 }
1605 if let Some(pod) = self.icon.as_mut() {
1606 pod.paint_child(ctx, scene);
1607 }
1608 self.label.paint(
1609 Point::new(
1610 origin.x + self.label_origin.x,
1611 origin.y + self.label_origin.y,
1612 ),
1613 colors.content,
1614 scene,
1615 );
1616 if self.content_clipped {
1617 scene.pop_clip();
1618 }
1619
1620 if self.enabled && self.captured && self.on_long_press.is_some() && !self.long_press.elapsed
1623 {
1624 let start = *self.long_press.press_start.get_or_insert(ctx.frame_time());
1625 let elapsed_ms = ctx.frame_time().saturating_sub(start).as_secs_f64() * 1000.0;
1626 if elapsed_ms >= LONG_PRESS_MS {
1627 self.long_press.elapsed = true;
1628 } else {
1629 ctx.request_frame();
1630 }
1631 }
1632 }
1633
1634 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1635 if !self.enabled {
1636 return EventResult::Ignored;
1637 }
1638 let InputEvent::Pointer(p) = event else {
1639 return EventResult::Ignored;
1640 };
1641 match p.phase {
1642 PointerPhase::Down => {
1643 if !presses(p) {
1644 return EventResult::Ignored;
1645 }
1646 self.state.set_pressed(true);
1647 self.captured = true;
1648 self.down_pos = p.position;
1649 self.long_press.clear();
1650 ctx.capture_pointer();
1651 ctx.request_redraw();
1652 EventResult::Handled
1653 }
1654 PointerPhase::Move => {
1655 let over = inside(p.position, ctx.size());
1656 if !self.captured {
1657 if over {
1660 ctx.claim_hover();
1661 ctx.set_cursor(CursorIcon::Pointer);
1662 }
1663 if self.state.set_hovered(over) {
1664 ctx.request_redraw();
1665 }
1666 return EventResult::Ignored;
1667 }
1668 ctx.set_cursor(CursorIcon::Pointer);
1669 if self.state.set_pressed(over) {
1670 ctx.request_redraw();
1671 }
1672 if (p.position - self.down_pos).hypot() > TOUCH_SLOP {
1675 self.long_press.clear();
1676 } else if self.long_press.elapsed && !self.long_press.fired {
1677 self.long_press.fired = true;
1678 if let Some(cb) = self.on_long_press.as_mut() {
1679 cb(ctx);
1680 }
1681 ctx.request_redraw();
1682 }
1683 EventResult::Handled
1684 }
1685 PointerPhase::Up => {
1686 if !self.captured {
1687 return EventResult::Ignored;
1688 }
1689 if self.long_press.elapsed {
1697 if !self.long_press.fired {
1698 self.long_press.fired = true;
1699 if let Some(cb) = self.on_long_press.as_mut() {
1700 cb(ctx);
1701 }
1702 }
1703 } else if inside(p.position, ctx.size()) {
1704 if self.haptic != HapticSignal::None {
1705 MaterialHaptics::fire(self.haptic);
1706 }
1707 let requested = !self.checked;
1709 (self.on_checked_change)(ctx, requested);
1710 }
1711 self.state.set_pressed(false);
1712 self.captured = false;
1713 self.long_press.clear();
1714 ctx.request_redraw();
1715 EventResult::Handled
1716 }
1717 PointerPhase::Cancel => {
1718 if !self.captured {
1719 return EventResult::Ignored;
1720 }
1721 self.state.set_pressed(false);
1722 self.captured = false;
1723 self.long_press.clear();
1724 ctx.request_redraw();
1725 EventResult::Handled
1726 }
1727 }
1728 }
1729
1730 fn semantics(&self, ctx: &mut SemanticsCtx) {
1731 ctx.push_node(Role::Button, |node| {
1736 if !self.label.content().is_empty() {
1737 node.set_label(self.label.content());
1738 }
1739 node.set_toggled(Toggled::from(self.checked));
1740 if self.enabled {
1741 node.add_action(Action::Click);
1742 } else {
1743 node.set_disabled();
1744 }
1745 });
1746 }
1747
1748 frust::authoring::visit_children!(icon);
1749}
1750
1751#[cfg(test)]
1752mod tests {
1753 use super::*;
1754 use frust::authoring::scene::GlyphRun;
1755 use frust::authoring::{PointerButton, PointerEvent};
1756 use frust_widgets::test_support::leaf_any;
1757 use std::any::Any;
1758
1759 fn ft_secs(s: f64) -> FrameTime {
1762 FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
1763 }
1764
1765 fn build<S: 'static>(view: &ToggleButtonView<S>) -> ToggleButtonWidget {
1767 let mut counter = 0u64;
1768 View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1769 }
1770
1771 fn settle_morph(widget: &mut ToggleButtonWidget) {
1773 let mut t = 0.0;
1774 for _ in 0..600 {
1775 t += 1.0 / 60.0;
1776 if !widget.motion.advance(ft_secs(t)) {
1777 return;
1778 }
1779 }
1780 panic!("the shape morph never settled");
1781 }
1782
1783 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1784 InputEvent::Pointer(PointerEvent {
1785 phase,
1786 position: Point::new(x, y),
1787 button: PointerButton::Primary,
1788 })
1789 }
1790
1791 fn dispatch<S: 'static>(
1792 widget: &mut ToggleButtonWidget,
1793 state: &mut S,
1794 size: Size,
1795 event: &InputEvent,
1796 ) {
1797 let state_any: &mut dyn Any = state;
1798 let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
1799 widget.event(&mut ctx, event);
1800 }
1801
1802 #[derive(Default)]
1803 struct Recorder {
1804 rrects: Vec<(Point, Size, CornerRadii, Color)>,
1805 strokes: Vec<(Point, f64, Color)>,
1806 runs: Vec<(Point, Color)>,
1807 run_glyphs: Vec<usize>,
1811 clips: Vec<(Point, Size)>,
1813 }
1814
1815 impl PaintScene for Recorder {
1816 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1817 fn draw_text(&mut self, _o: Point, _t: &str) {}
1818 fn fill_rounded_rect_radii(&mut self, o: Point, s: Size, radii: CornerRadii, color: Color) {
1819 self.rrects.push((o, s, radii, color));
1820 }
1821 fn stroke_path(&mut self, o: Point, _path: &kurbo::BezPath, width: f64, brush: &Brush) {
1822 self.strokes.push((o, width, solid(brush)));
1823 }
1824 fn push_clip(&mut self, o: Point, s: Size) {
1825 self.clips.push((o, s));
1826 }
1827 fn draw_glyph_run(&mut self, run: GlyphRun) {
1828 let t = run.transform.translation();
1829 self.runs.push((Point::new(t.x, t.y), solid(&run.brush)));
1830 self.run_glyphs.push(run.glyphs.len());
1831 }
1832 }
1833
1834 fn solid(brush: &Brush) -> Color {
1835 match brush {
1836 Brush::Solid(c) => *c,
1837 _ => Color::TRANSPARENT,
1838 }
1839 }
1840
1841 fn layout_with(widget: &mut ToggleButtonWidget, max_width: f64, theme: Option<&Theme>) -> Size {
1842 let mut tcx = TextContext::new();
1843 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1844 if let Some(theme) = theme {
1845 lctx = lctx.with_theme(theme as &dyn Any);
1846 }
1847 widget.layout(
1848 &mut lctx,
1849 &BoxConstraints::loose(Size::new(max_width, 500.0)),
1850 )
1851 }
1852
1853 fn layout_tight_main(widget: &mut ToggleButtonWidget, main: f64) -> Size {
1857 let mut tcx = TextContext::new();
1858 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1859 widget.layout(
1860 &mut lctx,
1861 &BoxConstraints::new(Size::new(main, 0.0), Size::new(main, 500.0)),
1862 )
1863 }
1864
1865 fn paint_at(widget: &mut ToggleButtonWidget, size: Size, theme: Option<&Theme>) -> Recorder {
1866 let mut rec = Recorder::default();
1867 let mut pctx = PaintCtx::new(Point::ZERO, size);
1868 if let Some(theme) = theme {
1869 pctx = pctx.with_theme(theme as &dyn Any);
1870 }
1871 widget.paint(&mut pctx, &mut rec);
1872 rec
1873 }
1874
1875 const ALL_SIZES: [ToggleButtonSize; 3] = [
1876 ToggleButtonSize::Sm,
1877 ToggleButtonSize::Md,
1878 ToggleButtonSize::Lg,
1879 ];
1880
1881 const ALL_VARIANTS: [ButtonVariant; 5] = [
1882 ButtonVariant::Filled,
1883 ButtonVariant::Outlined,
1884 ButtonVariant::Tonal,
1885 ButtonVariant::Elevated,
1886 ButtonVariant::Text,
1887 ];
1888
1889 #[test]
1892 fn measurements_match_the_plain_buttons_sm_md_lg_rows() {
1893 let expected: [(ToggleButtonSize, f64, f64, f64, f64); 3] = [
1894 (ToggleButtonSize::Sm, 40.0, 16.0, 20.0, 8.0),
1895 (ToggleButtonSize::Md, 56.0, 24.0, 24.0, 8.0),
1896 (ToggleButtonSize::Lg, 96.0, 48.0, 32.0, 12.0),
1897 ];
1898 for (size, height, h_padding, icon_size, icon_gap) in expected {
1899 assert_eq!(
1900 size.metrics(),
1901 SizeMetrics {
1902 height,
1903 h_padding,
1904 icon_size,
1905 icon_gap
1906 },
1907 "{size:?}"
1908 );
1909 }
1910 }
1911
1912 #[test]
1913 fn radius_tables_match_the_reference_themed_and_unthemed() {
1914 let theme = crate::baseline();
1915 let expected: [(ToggleButtonSize, f64, f64, f64); 3] = [
1916 (ToggleButtonSize::Sm, 12.0, 8.0, 10.0),
1917 (ToggleButtonSize::Md, 16.0, 12.0, 14.0),
1918 (ToggleButtonSize::Lg, 28.0, 16.0, 22.0),
1919 ];
1920 for (size, square, pressed, hovered) in expected {
1921 assert_eq!(size.square_radius(None), square, "{size:?} square unthemed");
1922 assert_eq!(
1923 size.square_radius(Some(&theme)),
1924 square,
1925 "{size:?} square themed"
1926 );
1927 assert_eq!(
1928 size.pressed_radius(None),
1929 pressed,
1930 "{size:?} pressed unthemed"
1931 );
1932 assert_eq!(
1933 size.pressed_radius(Some(&theme)),
1934 pressed,
1935 "{size:?} pressed themed"
1936 );
1937 assert_eq!(size.hovered_radius(), hovered, "{size:?} hovered");
1938 }
1939 }
1940
1941 #[test]
1942 fn label_roles_match_the_shared_base_button_state_switch() {
1943 let theme = crate::baseline();
1944 let expected = [
1945 (ToggleButtonSize::Sm, theme.type_scale.label_medium.clone()),
1946 (ToggleButtonSize::Md, theme.type_scale.label_large.clone()),
1947 (ToggleButtonSize::Lg, theme.type_scale.title_medium.clone()),
1948 ];
1949 for (size, role) in expected {
1950 let style = size.label_style(Some(&theme));
1951 assert_eq!(style.size, role.size, "{size:?} size");
1952 assert_eq!(style.weight, role.weight, "{size:?} weight");
1953 }
1954 }
1955
1956 #[test]
1957 fn enabled_colors_match_the_reference_variant_x_checked_table() {
1958 let theme = crate::baseline();
1959 let s = theme.scheme();
1960 let expected: [(ButtonVariant, Color, Color, Color, Color, bool); 5] = [
1962 (
1963 ButtonVariant::Filled,
1964 s.on_surface_variant,
1965 s.on_primary,
1966 s.surface_container_highest,
1967 s.primary,
1968 false,
1969 ),
1970 (
1971 ButtonVariant::Outlined,
1972 s.on_surface,
1973 s.on_secondary_container,
1974 Color::TRANSPARENT,
1975 s.secondary_container,
1976 true,
1977 ),
1978 (
1979 ButtonVariant::Tonal,
1980 s.on_surface_variant,
1981 s.on_secondary_container,
1982 s.surface_container_highest,
1983 s.secondary_container,
1984 false,
1985 ),
1986 (
1987 ButtonVariant::Elevated,
1988 s.primary,
1989 s.on_primary,
1990 s.surface_container_low,
1991 s.primary,
1992 false,
1993 ),
1994 (
1995 ButtonVariant::Text,
1996 s.on_surface,
1997 s.primary,
1998 Color::TRANSPARENT,
1999 Color::TRANSPARENT,
2000 false,
2001 ),
2002 ];
2003 for (variant, fg_u, fg_c, bg_u, bg_c, has_outline) in expected {
2004 let unchecked = resolve_colors(Some(&theme), variant, false, true);
2005 let checked = resolve_colors(Some(&theme), variant, true, true);
2006 assert_eq!(unchecked.content, fg_u, "{variant:?} unchecked content");
2007 assert_eq!(unchecked.container, bg_u, "{variant:?} unchecked container");
2008 assert_eq!(checked.content, fg_c, "{variant:?} checked content");
2009 assert_eq!(checked.container, bg_c, "{variant:?} checked container");
2010 assert_eq!(
2011 unchecked.outline.is_some(),
2012 has_outline,
2013 "{variant:?} unchecked outline presence"
2014 );
2015 assert_eq!(
2016 checked.outline.is_some(),
2017 has_outline,
2018 "{variant:?} checked outline presence"
2019 );
2020 if has_outline {
2021 assert_eq!(unchecked.outline, Some(s.outline));
2022 assert_eq!(checked.outline, Some(s.outline));
2023 }
2024 }
2025 }
2026
2027 #[test]
2028 fn unthemed_colors_fall_back_to_the_baseline_light_roles() {
2029 let unchecked = resolve_colors(None, ButtonVariant::Filled, false, true);
2030 assert_eq!(unchecked.content, ON_SURFACE_VARIANT);
2031 assert_eq!(unchecked.container, SURFACE_CONTAINER_HIGHEST);
2032 let checked = resolve_colors(None, ButtonVariant::Filled, true, true);
2033 assert_eq!(checked.content, ON_PRIMARY);
2034 assert_eq!(checked.container, PRIMARY);
2035 }
2036
2037 #[test]
2043 fn every_variant_x_size_x_checked_combination_builds_lays_out_and_paints() {
2044 let theme = crate::baseline();
2045 for variant in ALL_VARIANTS {
2046 for size in ALL_SIZES {
2047 for checked in [false, true] {
2048 let view = toggle_button::<u32, _>(checked, |_, _| {})
2049 .variant(variant)
2050 .size(size)
2051 .label("Toggle");
2052 let mut widget = build(&view);
2053 let laid = layout_with(&mut widget, 200.0, Some(&theme));
2054 assert!(
2055 laid.width.is_finite() && laid.width >= 0.0,
2056 "{variant:?} {size:?} {checked}"
2057 );
2058 assert_eq!(laid.height, size.metrics().height);
2059 let target = widget.target_radii(Some(&theme), laid.height);
2060 for r in [
2061 target.top_left,
2062 target.top_right,
2063 target.bottom_left,
2064 target.bottom_right,
2065 ] {
2066 assert!(r.is_finite() && r >= 0.0, "{variant:?} {size:?} {checked}");
2067 }
2068 let _ = paint_at(&mut widget, laid, Some(&theme));
2069 }
2070 }
2071 }
2072 }
2073
2074 #[test]
2075 fn disabled_colors_are_the_38_and_12_percent_on_surface_roles_checked_independent() {
2076 let theme = crate::baseline();
2077 let on_surface = theme.scheme().on_surface;
2078 for variant in ALL_VARIANTS {
2079 for checked in [false, true] {
2080 let colors = resolve_colors(Some(&theme), variant, checked, false);
2081 assert_eq!(
2082 colors.content,
2083 with_alpha(on_surface, DISABLED_CONTENT_OPACITY),
2084 "{variant:?} checked={checked} content"
2085 );
2086 if is_transparent(variant) {
2087 assert_eq!(colors.container, Color::TRANSPARENT, "{variant:?}");
2088 } else {
2089 assert_eq!(
2090 colors.container,
2091 with_alpha(on_surface, DISABLED_CONTAINER_OPACITY),
2092 "{variant:?} checked={checked} container"
2093 );
2094 }
2095 }
2096 }
2097 }
2098
2099 #[test]
2100 fn elevation_is_not_checked_aware_and_matches_the_plain_buttons_table() {
2101 for variant in ALL_VARIANTS {
2102 for pressed in [false, true] {
2103 for hovered in [false, true] {
2104 let expected = match variant {
2105 ButtonVariant::Elevated => {
2106 if pressed {
2107 0.0
2108 } else if hovered {
2109 3.0
2110 } else {
2111 1.0
2112 }
2113 }
2114 ButtonVariant::Filled | ButtonVariant::Tonal => {
2115 if !pressed && hovered {
2116 1.0
2117 } else {
2118 0.0
2119 }
2120 }
2121 ButtonVariant::Outlined | ButtonVariant::Text => 0.0,
2122 };
2123 assert_eq!(elevation_dp(variant, true, pressed, hovered), expected);
2124 }
2125 }
2126 }
2127 }
2128
2129 #[test]
2132 fn shape_spring_matches_expressive_spatial_press() {
2133 let token = crate::tokens::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS;
2134 assert_eq!(SHAPE_SPRING.stiffness, token.stiffness);
2135 assert_eq!(SHAPE_SPRING.damping_ratio, token.damping_ratio);
2136 assert_eq!(SHAPE_SPRING.mass, 1.0);
2137 assert_eq!(SHAPE_SPRING.stiffness, 380.0);
2138 assert_eq!(SHAPE_SPRING.damping_ratio, 0.55);
2139 }
2140
2141 #[test]
2142 fn the_first_target_snaps_rather_than_springing() {
2143 let mut motion = ShapeMotion::new();
2144 let pad = ContentPadding::symmetric(16.0);
2145 assert!(!motion.retarget(CornerRadii::uniform(20.0), pad));
2146 assert_eq!(motion.radii(), CornerRadii::uniform(20.0));
2147 assert!(!motion.advance(ft_secs(0.0)));
2148 }
2149
2150 #[test]
2151 fn a_target_inside_the_dead_band_is_ignored() {
2152 let mut motion = ShapeMotion::new();
2153 let pad = ContentPadding::symmetric(16.0);
2154 motion.retarget(CornerRadii::uniform(20.0), pad);
2155 assert!(!motion.retarget(CornerRadii::uniform(20.0 + RETARGET_TOLERANCE / 2.0), pad));
2156 assert_eq!(motion.target_radii(), CornerRadii::uniform(20.0));
2157 assert!(motion.retarget(CornerRadii::uniform(20.0 + RETARGET_TOLERANCE * 2.0), pad));
2158 }
2159
2160 #[test]
2161 fn checked_toggle_springs_from_round_rest_to_the_square_radius_and_settles_exactly() {
2162 let mut motion = ShapeMotion::new();
2163 let pad = ContentPadding::symmetric(16.0);
2164 motion.retarget(CornerRadii::uniform(20.0), pad); assert!(motion.retarget(CornerRadii::uniform(12.0), pad)); assert_eq!(
2167 motion.radii(),
2168 CornerRadii::uniform(20.0),
2169 "starts where it was"
2170 );
2171 settle_at(&mut motion);
2172 assert_eq!(motion.radii(), CornerRadii::uniform(12.0));
2173 }
2174
2175 fn settle_at(motion: &mut ShapeMotion) {
2176 let mut t = 0.0;
2177 for _ in 0..600 {
2178 t += 1.0 / 60.0;
2179 if !motion.advance(ft_secs(t)) {
2180 return;
2181 }
2182 }
2183 panic!("never settled");
2184 }
2185
2186 #[test]
2187 fn rapid_toggle_retarget_mid_flight_is_continuous() {
2188 let mut motion = ShapeMotion::new();
2189 let pad = ContentPadding::symmetric(16.0);
2190 motion.retarget(CornerRadii::uniform(20.0), pad);
2191 motion.retarget(CornerRadii::uniform(12.0), pad);
2192 motion.advance(ft_secs(0.0));
2193 motion.advance(ft_secs(0.05));
2194 let mid = motion.radii();
2195 assert!(
2196 mid.top_left < 20.0 && mid.top_left > 12.0,
2197 "sampled mid-flight (got {mid:?})"
2198 );
2199
2200 assert!(motion.retarget(CornerRadii::uniform(20.0), pad));
2202 assert_eq!(motion.radii(), mid);
2203
2204 settle_at(&mut motion);
2205 assert_eq!(motion.radii(), CornerRadii::uniform(20.0));
2206 }
2207
2208 #[test]
2209 fn overshoot_never_drives_a_corner_negative() {
2210 let mut motion = ShapeMotion::new();
2211 let pad = ContentPadding::symmetric(16.0);
2212 motion.retarget(CornerRadii::uniform(28.0), pad);
2213 motion.retarget(CornerRadii::uniform(0.0), pad);
2214 let mut t = 0.0;
2215 for _ in 0..600 {
2216 t += 1.0 / 60.0;
2217 let animating = motion.advance(ft_secs(t));
2218 let r = motion.radii();
2219 assert!(
2220 r.top_left >= 0.0
2221 && r.top_right >= 0.0
2222 && r.bottom_left >= 0.0
2223 && r.bottom_right >= 0.0,
2224 "a corner went negative at t={t}"
2225 );
2226 if !animating {
2227 break;
2228 }
2229 }
2230 assert_eq!(motion.radii(), CornerRadii::uniform(0.0));
2231 }
2232
2233 #[test]
2234 fn widget_paint_retargets_the_morph_on_a_checked_flip() {
2235 let theme = crate::baseline();
2236 let mut widget = build(&toggle_button::<u32, _>(false, |_, _| {}));
2237 layout_with(&mut widget, 200.0, Some(&theme));
2238 paint_at(&mut widget, Size::new(40.0, 40.0), Some(&theme));
2239 settle_morph(&mut widget);
2240 let unchecked_radius = widget.motion.radii().top_left;
2241 assert_eq!(unchecked_radius, 20.0, "sm pill = height/2");
2242
2243 widget.checked = true;
2244 layout_with(&mut widget, 200.0, Some(&theme));
2245 paint_at(&mut widget, Size::new(40.0, 40.0), Some(&theme));
2246 assert_eq!(
2247 widget.motion.radii().top_left,
2248 unchecked_radius,
2249 "the leg starts where it was (continuity)"
2250 );
2251 settle_morph(&mut widget);
2252 assert_eq!(widget.motion.radii().top_left, 12.0, "sm square token");
2253 }
2254
2255 #[test]
2256 fn a_runtime_size_change_relayouts_at_the_new_sizes_padding_immediately() {
2257 let mut widget = build(
2267 &toggle_button::<(), _>(false, |_, _| {})
2268 .icon(leaf_any(20.0, 20.0))
2269 .size(ToggleButtonSize::Sm),
2270 );
2271 let sm = layout_with(&mut widget, 500.0, None);
2272 let sm_metrics = ToggleButtonSize::Sm.metrics();
2273 assert_eq!(
2274 sm.width,
2275 sm_metrics.icon_size + sm_metrics.h_padding, "sm icon-only width"
2277 );
2278
2279 widget.size = ToggleButtonSize::Lg;
2280 let lg = layout_with(&mut widget, 500.0, None);
2281 let lg_metrics = ToggleButtonSize::Lg.metrics();
2282 assert_eq!(
2283 lg.width,
2284 lg_metrics.icon_size + lg_metrics.h_padding,
2285 "layout must resolve the NEW size's padding on its very next pass, \
2286 not the stale one only a later paint would have retargeted"
2287 );
2288 }
2289
2290 #[test]
2293 fn standalone_single_member_shape_is_uniform() {
2294 let theme = crate::baseline();
2295 let mut widget = build(&toggle_button::<u32, _>(false, |_, _| {}));
2296 layout_with(&mut widget, 200.0, Some(&theme));
2297 let target = widget.target_radii(Some(&theme), 40.0);
2298 assert_eq!(target, CornerRadii::uniform(20.0));
2299 }
2300
2301 #[test]
2302 fn connected_first_member_is_outer_left_inner_right() {
2303 let theme = crate::baseline();
2304 let mut widget = build(
2305 &toggle_button::<u32, _>(false, |_, _| {})
2306 .group_connected(true)
2307 .first_in_group(true)
2308 .last_in_group(false),
2309 );
2310 layout_with(&mut widget, 200.0, Some(&theme));
2311 let target = widget.target_radii(Some(&theme), 40.0);
2312 assert_eq!(
2313 (target.top_left, target.bottom_left),
2314 (20.0, 20.0),
2315 "outer left"
2316 );
2317 assert_eq!(
2318 (target.top_right, target.bottom_right),
2319 (CONNECTED_INNER_RADIUS, CONNECTED_INNER_RADIUS),
2320 "inner right"
2321 );
2322 }
2323
2324 #[test]
2325 fn connected_last_member_is_inner_left_outer_right() {
2326 let theme = crate::baseline();
2327 let mut widget = build(
2328 &toggle_button::<u32, _>(false, |_, _| {})
2329 .group_connected(true)
2330 .first_in_group(false)
2331 .last_in_group(true),
2332 );
2333 layout_with(&mut widget, 200.0, Some(&theme));
2334 let target = widget.target_radii(Some(&theme), 40.0);
2335 assert_eq!(
2336 (target.top_left, target.bottom_left),
2337 (CONNECTED_INNER_RADIUS, CONNECTED_INNER_RADIUS),
2338 "inner left"
2339 );
2340 assert_eq!(
2341 (target.top_right, target.bottom_right),
2342 (20.0, 20.0),
2343 "outer right"
2344 );
2345 }
2346
2347 #[test]
2348 fn connected_middle_member_is_all_inner_corners() {
2349 let theme = crate::baseline();
2350 let mut widget = build(
2351 &toggle_button::<u32, _>(false, |_, _| {})
2352 .group_connected(true)
2353 .first_in_group(false)
2354 .last_in_group(false),
2355 );
2356 layout_with(&mut widget, 200.0, Some(&theme));
2357 let target = widget.target_radii(Some(&theme), 40.0);
2358 assert_eq!(
2359 target,
2360 CornerRadii::uniform(CONNECTED_INNER_RADIUS),
2361 "a middle member is all inner corners"
2362 );
2363 }
2364
2365 #[test]
2366 fn connected_pressed_member_uses_the_pressed_inner_radius() {
2367 let theme = crate::baseline();
2368 let mut widget = build(
2369 &toggle_button::<u32, _>(false, |_, _| {})
2370 .group_connected(true)
2371 .first_in_group(false)
2372 .last_in_group(false),
2373 );
2374 layout_with(&mut widget, 200.0, Some(&theme));
2375 widget.state.set_pressed(true);
2376 let target = widget.target_radii(Some(&theme), 40.0);
2377 assert_eq!(target, CornerRadii::uniform(CONNECTED_PRESSED_INNER_RADIUS));
2378 }
2379
2380 #[test]
2381 fn connected_checked_idle_member_is_fully_round_on_every_corner() {
2382 let theme = crate::baseline();
2383 let mut widget = build(
2384 &toggle_button::<u32, _>(true, |_, _| {})
2385 .group_connected(true)
2386 .first_in_group(false)
2387 .last_in_group(false),
2388 );
2389 layout_with(&mut widget, 200.0, Some(&theme));
2390 let target = widget.target_radii(Some(&theme), 40.0);
2391 assert_eq!(
2392 target,
2393 CornerRadii::uniform(20.0),
2394 "checked+connected+idle overrides the inner-corner asymmetry"
2395 );
2396 }
2397
2398 #[test]
2401 fn label_falls_back_to_the_unchecked_one_when_checked_label_is_unset() {
2402 let view = toggle_button::<u32, _>(true, |_, _| {}).label("Off");
2403 assert_eq!(view.effective_label_text(), Some("Off"));
2404 }
2405
2406 #[test]
2407 fn checked_label_wins_over_the_unchecked_one_when_both_are_set() {
2408 let view = toggle_button::<u32, _>(true, |_, _| {})
2409 .label("Off")
2410 .checked_label("On");
2411 assert_eq!(view.effective_label_text(), Some("On"));
2412 let unchecked = toggle_button::<u32, _>(false, |_, _| {})
2413 .label("Off")
2414 .checked_label("On");
2415 assert_eq!(unchecked.effective_label_text(), Some("Off"));
2416 }
2417
2418 #[test]
2419 fn build_resolves_the_effective_label_for_the_initial_checked_value() {
2420 let checked_view = toggle_button::<u32, _>(true, |_, _| {})
2421 .label("Off")
2422 .checked_label("On");
2423 let widget = build(&checked_view);
2424 assert_eq!(widget.label.content(), "On");
2425
2426 let unchecked_view = toggle_button::<u32, _>(false, |_, _| {})
2427 .label("Off")
2428 .checked_label("On");
2429 let widget = build(&unchecked_view);
2430 assert_eq!(widget.label.content(), "Off");
2431 }
2432
2433 #[test]
2434 fn rebuild_swaps_the_label_content_on_a_checked_flip() {
2435 let prev = toggle_button::<u32, _>(false, |_, _| {})
2436 .label("Off")
2437 .checked_label("On");
2438 let next = toggle_button::<u32, _>(true, |_, _| {})
2439 .label("Off")
2440 .checked_label("On");
2441 let mut widget = build(&prev);
2442 assert_eq!(widget.label.content(), "Off");
2443 let mut counter = 0u64;
2444 let flags =
2445 View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
2446 assert_eq!(widget.label.content(), "On");
2447 assert!(flags.contains(ChangeFlags::LAYOUT));
2448 }
2449
2450 #[test]
2451 fn icon_falls_back_and_swaps_the_same_way_as_the_label() {
2452 let unchecked = toggle_button::<(), _>(false, |_, _| {}).icon(leaf_any(20.0, 20.0));
2453 assert!(unchecked.effective_icon_view().is_some());
2454
2455 let checked_no_override =
2456 toggle_button::<(), _>(true, |_, _| {}).icon(leaf_any(20.0, 20.0));
2457 assert!(
2458 checked_no_override.effective_icon_view().is_some(),
2459 "falls back to `icon` while checked"
2460 );
2461
2462 let checked_with_override = toggle_button::<(), _>(true, |_, _| {})
2463 .icon(leaf_any(20.0, 20.0))
2464 .checked_icon(leaf_any(24.0, 24.0));
2465 let widget = build(&checked_with_override);
2466 assert!(widget.icon.is_some());
2467 }
2468
2469 #[test]
2470 fn an_icon_only_toggle_button_halves_its_horizontal_padding() {
2471 let mut icon_only =
2472 build(&toggle_button::<(), _>(false, |_, _| {}).icon(leaf_any(20.0, 20.0)));
2473 layout_with(&mut icon_only, 300.0, None);
2474 assert!(!icon_only.has_label());
2475 assert_eq!(icon_only.h_padding(&ToggleButtonSize::Sm.metrics()), 8.0);
2476
2477 let mut labeled = build(&toggle_button::<(), _>(false, |_, _| {}).label("Bold"));
2478 layout_with(&mut labeled, 300.0, None);
2479 assert!(labeled.has_label());
2480 assert_eq!(labeled.h_padding(&ToggleButtonSize::Sm.metrics()), 16.0);
2481 }
2482
2483 #[test]
2486 fn a_box_narrower_than_the_content_clips_it_instead_of_ellipsizing() {
2487 let mut widget = build(&toggle_button::<(), _>(false, |_, _| {}).label("Days per week"));
2488 let natural = layout_with(&mut widget, f64::INFINITY, None);
2489 let full = paint_at(&mut widget, natural, None).run_glyphs;
2490 assert_eq!(full.len(), 1, "one label run");
2491 assert!(full[0] > 1, "the label shaped some glyphs");
2492 assert!(!widget.content_clipped, "nothing to clip at natural width");
2493
2494 let padding = ToggleButtonSize::Sm.metrics().h_padding;
2499 let shallow = layout_tight_main(&mut widget, natural.width - padding);
2500 let rec = paint_at(&mut widget, shallow, None);
2501 assert_eq!(rec.run_glyphs, full, "the label is untouched");
2502 assert!(
2503 rec.clips.is_empty(),
2504 "and still fits, so nothing is clipped"
2505 );
2506
2507 let squeezed = layout_tight_main(&mut widget, natural.width - padding * 2.0 - 4.0);
2509 let rec = paint_at(&mut widget, squeezed, None);
2510 assert_eq!(
2511 rec.run_glyphs, full,
2512 "the shrinking box covers the label; it is never re-shaped to fit"
2513 );
2514 assert_eq!(
2515 rec.clips,
2516 vec![(Point::ZERO, squeezed)],
2517 "the overflowing content row is clipped to the box (`Clip.hardEdge`)"
2518 );
2519 assert!(
2521 widget.label_origin.x < 0.0,
2522 "the centered row hangs off the leading edge too: {}",
2523 widget.label_origin.x
2524 );
2525 }
2526
2527 #[test]
2528 fn a_squeezed_then_restored_label_keeps_its_full_shape() {
2529 let mut widget = build(&toggle_button::<(), _>(false, |_, _| {}).label("Days per week"));
2530 let natural = layout_with(&mut widget, f64::INFINITY, None);
2531 let full = paint_at(&mut widget, natural, None).run_glyphs;
2532
2533 layout_tight_main(&mut widget, natural.width - 12.0);
2534 for main in [natural.width - 0.0002, natural.width] {
2538 let size = layout_tight_main(&mut widget, main);
2539 assert_eq!(
2540 paint_at(&mut widget, size, None).run_glyphs,
2541 full,
2542 "the label is whole again at main {main}"
2543 );
2544 }
2545 assert!(!widget.content_clipped, "and needs no clip once it fits");
2546 }
2547
2548 #[test]
2551 fn defaults_match_the_reference_constructor_defaults() {
2552 let view = toggle_button::<u32, _>(false, |_, _| {});
2553 assert_eq!(view.variant, ButtonVariant::Filled);
2554 assert_eq!(view.size, ToggleButtonSize::Sm);
2555 assert!(view.enabled);
2556 assert_eq!(view.haptic, HapticSignal::None);
2557 assert!(!view.is_group_connected);
2558 assert!(view.is_first_in_group);
2559 assert!(view.is_last_in_group);
2560 }
2561
2562 #[test]
2563 fn the_named_constructors_map_onto_the_reference_styles() {
2564 assert_eq!(
2565 filled_toggle_button::<u32, _>(false, |_, _| {}).variant,
2566 ButtonVariant::Filled
2567 );
2568 assert_eq!(
2569 tonal_toggle_button::<u32, _>(false, |_, _| {}).variant,
2570 ButtonVariant::Tonal
2571 );
2572 assert_eq!(
2573 elevated_toggle_button::<u32, _>(false, |_, _| {}).variant,
2574 ButtonVariant::Elevated
2575 );
2576 assert_eq!(
2577 outlined_toggle_button::<u32, _>(false, |_, _| {}).variant,
2578 ButtonVariant::Outlined
2579 );
2580 assert_eq!(
2581 text_toggle_button::<u32, _>(false, |_, _| {}).variant,
2582 ButtonVariant::Text
2583 );
2584 }
2585
2586 #[test]
2587 fn a_tap_reports_the_requested_value_and_never_self_mutates() {
2588 let mut widget = build(&toggle_button::<bool, _>(false, |s, v| *s = v));
2589 layout_with(&mut widget, 200.0, None);
2590 let size = Size::new(60.0, 40.0);
2591 let mut state = false;
2592 let state_any: &mut dyn Any = &mut state;
2593 let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
2594 widget.event(&mut ctx, &ev(PointerPhase::Down, 30.0, 20.0));
2595 assert!(widget.captured);
2596 assert!(!widget.checked, "the widget itself never flips `checked`");
2597
2598 widget.event(&mut ctx, &ev(PointerPhase::Up, 30.0, 20.0));
2599 assert!(state, "on_checked_change fired with the requested value");
2600 assert!(!widget.checked, "still not self-mutated");
2601 }
2602
2603 #[test]
2604 fn disabling_mid_press_clears_the_press_capture_and_long_press_candidacy() {
2605 let prev = toggle_button::<u32, _>(false, |_, _| {});
2606 let next = toggle_button::<u32, _>(false, |_, _| {}).enabled(false);
2607 let mut widget = build(&prev);
2608 widget.state.set_pressed(true);
2609 widget.captured = true;
2610 widget.long_press.elapsed = true;
2611 let mut counter = 0u64;
2612 View::<u32>::rebuild(&next, &prev, &mut widget, &mut BuildCtx::new(&mut counter));
2613 assert!(!widget.state.pressed);
2614 assert!(!widget.captured);
2615 assert!(!widget.long_press.elapsed);
2616 }
2617
2618 #[test]
2621 fn a_long_press_fires_on_move_arrival_after_the_threshold_and_suppresses_the_tap() {
2622 let view =
2629 toggle_button::<(u32, u32), _>(false, |s, _| s.0 += 1).on_long_press(|s| s.1 += 1);
2630 let mut widget = build(&view);
2631 layout_with(&mut widget, 200.0, None);
2632 let size = Size::new(60.0, 40.0);
2633
2634 let mut state = (0u32, 0u32);
2635 dispatch(
2636 &mut widget,
2637 &mut state,
2638 size,
2639 &ev(PointerPhase::Down, 30.0, 20.0),
2640 );
2641 assert!(widget.captured);
2642
2643 widget.long_press.elapsed = true;
2645
2646 dispatch(
2649 &mut widget,
2650 &mut state,
2651 size,
2652 &ev(PointerPhase::Move, 31.0, 20.0),
2653 );
2654 assert_eq!(state.1, 1, "on_long_press fired");
2655 assert_eq!(state.0, 0, "on_checked_change must not also fire");
2656
2657 dispatch(
2658 &mut widget,
2659 &mut state,
2660 size,
2661 &ev(PointerPhase::Up, 31.0, 20.0),
2662 );
2663 assert_eq!(
2664 state.0, 0,
2665 "the up-inside toggle is suppressed after a long-press"
2666 );
2667 }
2668
2669 #[test]
2670 fn a_long_press_still_fires_at_up_if_no_move_arrives_first() {
2671 let view =
2672 toggle_button::<(u32, u32), _>(false, |s, _| s.0 += 1).on_long_press(|s| s.1 += 1);
2673 let mut widget = build(&view);
2674 layout_with(&mut widget, 200.0, None);
2675 let size = Size::new(60.0, 40.0);
2676 let mut state = (0u32, 0u32);
2677 let state_any: &mut dyn Any = &mut state;
2678 let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
2679 widget.event(&mut ctx, &ev(PointerPhase::Down, 30.0, 20.0));
2680 widget.long_press.elapsed = true;
2681 widget.event(&mut ctx, &ev(PointerPhase::Up, 30.0, 20.0));
2682 assert_eq!(state.1, 1, "the Up fallback fires the long-press");
2683 assert_eq!(state.0, 0, "the checked toggle is suppressed");
2684 }
2685
2686 #[test]
2687 fn movement_past_touch_slop_cancels_long_press_candidacy() {
2688 let view = toggle_button::<u32, _>(false, |s, _| *s += 1).on_long_press(|s| *s += 100);
2689 let mut widget = build(&view);
2690 layout_with(&mut widget, 200.0, None);
2691 let size = Size::new(200.0, 40.0);
2692 let mut state = 0u32;
2693 let state_any: &mut dyn Any = &mut state;
2694 let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
2695 widget.event(&mut ctx, &ev(PointerPhase::Down, 30.0, 20.0));
2696 widget.long_press.elapsed = true; widget.event(
2698 &mut ctx,
2699 &ev(PointerPhase::Move, 30.0 + TOUCH_SLOP + 5.0, 20.0),
2700 );
2701 assert!(!widget.long_press.elapsed, "a drag cancels the candidacy");
2702 }
2703
2704 #[test]
2705 fn cancel_clears_long_press_state_without_touching_app_state() {
2706 let mut widget = build(&toggle_button::<u32, _>(false, |s, _| *s = 1));
2707 layout_with(&mut widget, 200.0, None);
2708 let size = Size::new(60.0, 40.0);
2709 let mut state = 0u32;
2710 let state_any: &mut dyn Any = &mut state;
2711 let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
2712 widget.event(&mut ctx, &ev(PointerPhase::Down, 30.0, 20.0));
2713 widget.long_press.elapsed = true;
2714 widget.event(&mut ctx, &ev(PointerPhase::Cancel, 30.0, 20.0));
2715 assert!(!widget.long_press.elapsed);
2716 assert!(!widget.captured);
2717 assert_eq!(state, 0);
2718 }
2719
2720 fn semantics_button_node(
2726 mut view_fn: impl FnMut(&mut ()) -> ToggleButtonView<()> + 'static,
2727 ) -> frust_core::accesskit::Node {
2728 let mut root: frust_core::RenderRoot<(), ToggleButtonView<()>> =
2729 frust_core::RenderRoot::new();
2730 let mut state = ();
2731 root.rebuild(&mut view_fn, &mut state);
2732 let mut tcx = TextContext::new();
2733 root.layout_with_text(Size::new(300.0, 60.0), &mut tcx as &mut dyn Any);
2734 let update = root.semantics();
2735 update
2736 .nodes
2737 .iter()
2738 .find(|(_, n)| n.role() == Role::Button)
2739 .expect("a Button node is contributed")
2740 .1
2741 .clone()
2742 }
2743
2744 #[test]
2745 fn semantics_reports_toggled_state_and_label() {
2746 let node = semantics_button_node(|_| toggle_button::<(), _>(true, |_, _| {}).label("Bold"));
2747 assert_eq!(node.label(), Some("Bold"));
2748 assert_eq!(node.toggled(), Some(Toggled::True));
2749 }
2750
2751 #[test]
2752 fn disabled_semantics_report_no_click_action() {
2753 let node =
2754 semantics_button_node(|_| toggle_button::<(), _>(false, |_, _| {}).enabled(false));
2755 assert!(node.is_disabled());
2756 }
2757}