1use std::rc::Rc;
207
208use frust::Theme;
209use frust::authoring::text::{TextContext, TextLayout, TextStyle};
210use frust::authoring::{
211 Action, AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, CursorIcon, EventCtx,
212 EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, Role, SemanticsCtx,
213 View, Widget,
214};
215use kurbo::{Point, RoundedRect, Shape, Size};
216use peniko::{Brush, Color};
217
218use crate::button::motion::{ContentPadding, RadiusPaddingMotion};
219use crate::interaction::{HapticSignal, InteractionState, MaterialHaptics};
220use crate::press::presses;
221
222fn icon_glyph_size(size: IconButtonSize) -> f64 {
227 match size {
228 IconButtonSize::Xs => 20.0,
229 IconButtonSize::Sm | IconButtonSize::Md => 24.0,
230 IconButtonSize::Lg => 32.0,
231 IconButtonSize::Xl => 40.0,
232 }
233}
234
235fn visual_size(size: IconButtonSize, width: IconButtonWidth) -> Size {
241 use IconButtonWidth::{Narrow, Standard, Wide};
242 match size {
243 IconButtonSize::Xs => match width {
244 Standard => Size::new(32.0, 32.0),
245 Narrow => Size::new(28.0, 32.0),
246 Wide => Size::new(40.0, 32.0),
247 },
248 IconButtonSize::Sm => match width {
249 Standard => Size::new(40.0, 40.0),
250 Narrow => Size::new(32.0, 40.0),
251 Wide => Size::new(52.0, 40.0),
252 },
253 IconButtonSize::Md => match width {
254 Standard => Size::new(56.0, 56.0),
255 Narrow => Size::new(48.0, 56.0),
256 Wide => Size::new(72.0, 56.0),
257 },
258 IconButtonSize::Lg => match width {
259 Standard => Size::new(96.0, 96.0),
260 Narrow => Size::new(64.0, 96.0),
261 Wide => Size::new(128.0, 96.0),
262 },
263 IconButtonSize::Xl => match width {
264 Standard => Size::new(136.0, 136.0),
265 Narrow => Size::new(104.0, 136.0),
266 Wide => Size::new(184.0, 136.0),
267 },
268 }
269}
270
271fn theme_target_size(size: IconButtonSize, width: IconButtonWidth) -> Size {
275 use IconButtonWidth::{Narrow, Standard, Wide};
276 match size {
277 IconButtonSize::Xs => Size::new(48.0, 48.0),
278 IconButtonSize::Sm => match width {
279 Standard | Narrow => Size::new(48.0, 48.0),
280 Wide => Size::new(52.0, 48.0),
281 },
282 IconButtonSize::Md => match width {
283 Standard => Size::new(56.0, 56.0),
284 Narrow => Size::new(48.0, 56.0),
285 Wide => Size::new(72.0, 56.0),
286 },
287 IconButtonSize::Lg => match width {
288 Standard => Size::new(96.0, 96.0),
289 Narrow => Size::new(64.0, 96.0),
290 Wide => Size::new(128.0, 96.0),
291 },
292 IconButtonSize::Xl => match width {
293 Standard => Size::new(136.0, 136.0),
294 Narrow => Size::new(104.0, 136.0),
295 Wide => Size::new(184.0, 136.0),
296 },
297 }
298}
299
300fn target_size(size: IconButtonSize, width: IconButtonWidth) -> Size {
307 let visual = visual_size(size, width);
308 let target = theme_target_size(size, width);
309 Size::new(
310 target.width.max(visual.width),
311 target.height.max(visual.height),
312 )
313}
314
315fn radius_rest_round(size: IconButtonSize) -> f64 {
320 match size {
321 IconButtonSize::Xs => 16.0,
322 IconButtonSize::Sm => 20.0,
323 IconButtonSize::Md => 28.0,
324 IconButtonSize::Lg => 48.0,
325 IconButtonSize::Xl => 68.0,
326 }
327}
328
329fn radius_rest_square(size: IconButtonSize) -> f64 {
332 match size {
333 IconButtonSize::Xs => 8.0,
334 IconButtonSize::Sm => 10.0,
335 IconButtonSize::Md => 14.0,
336 IconButtonSize::Lg => 24.0,
337 IconButtonSize::Xl => 34.0,
338 }
339}
340
341fn radius_pressed(size: IconButtonSize) -> f64 {
344 match size {
345 IconButtonSize::Xs => 6.0,
346 IconButtonSize::Sm => 8.0,
347 IconButtonSize::Md => 11.0,
348 IconButtonSize::Lg => 19.0,
349 IconButtonSize::Xl => 27.0,
350 }
351}
352
353fn radius_hovered(size: IconButtonSize) -> f64 {
356 match size {
357 IconButtonSize::Xs => 10.0,
358 IconButtonSize::Sm => 12.0,
359 IconButtonSize::Md => 16.0,
360 IconButtonSize::Lg => 28.0,
361 IconButtonSize::Xl => 40.0,
362 }
363}
364
365fn rest_variant(is_toggle: bool, is_selected: bool, base: IconButtonShape) -> IconButtonShape {
368 if is_toggle && is_selected {
369 base.flipped()
370 } else {
371 base
372 }
373}
374
375fn resting_radius(size: IconButtonSize, variant: IconButtonShape) -> f64 {
378 match variant {
379 IconButtonShape::Round => radius_rest_round(size),
380 IconButtonShape::Square => radius_rest_square(size),
381 }
382}
383
384fn effective_radius(
388 size: IconButtonSize,
389 base_shape: IconButtonShape,
390 is_toggle: bool,
391 is_selected: bool,
392 state: InteractionState,
393) -> f64 {
394 let variant = rest_variant(is_toggle, is_selected, base_shape);
395 if state.pressed {
396 radius_pressed(size)
397 } else if state.hovered {
398 radius_hovered(size)
399 } else {
400 resting_radius(size, variant)
401 }
402}
403
404const OUTLINE_WIDTH: f64 = 1.0;
409
410const PATH_TOLERANCE: f64 = 0.1;
413
414const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
416const ON_PRIMARY: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
418const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
420const ON_SECONDARY_CONTAINER: Color = Color::from_rgb8(0x1D, 0x19, 0x2B);
422const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
424const OUTLINE: Color = Color::from_rgb8(0x79, 0x74, 0x7E);
426
427#[derive(Clone, Copy, Debug, PartialEq)]
429struct IconButtonColors {
430 container: Color,
431 content: Color,
432 outline: Option<Color>,
433}
434
435fn resolve_colors(
442 theme: Option<&Theme>,
443 variant: IconButtonVariant,
444 selected: bool,
445) -> IconButtonColors {
446 let (
447 primary,
448 on_primary,
449 secondary_container,
450 on_secondary_container,
451 on_surface_variant,
452 outline,
453 ) = match theme {
454 Some(theme) => {
455 let s = theme.scheme();
456 (
457 s.primary,
458 s.on_primary,
459 s.secondary_container,
460 s.on_secondary_container,
461 s.on_surface_variant,
462 s.outline,
463 )
464 }
465 None => (
466 PRIMARY,
467 ON_PRIMARY,
468 SECONDARY_CONTAINER,
469 ON_SECONDARY_CONTAINER,
470 ON_SURFACE_VARIANT,
471 OUTLINE,
472 ),
473 };
474 match variant {
475 IconButtonVariant::Standard => IconButtonColors {
476 container: Color::TRANSPARENT,
477 content: if selected {
478 primary
479 } else {
480 on_surface_variant
481 },
482 outline: None,
483 },
484 IconButtonVariant::Filled => IconButtonColors {
485 container: primary,
486 content: on_primary,
487 outline: None,
488 },
489 IconButtonVariant::Tonal => IconButtonColors {
490 container: secondary_container,
491 content: on_secondary_container,
492 outline: None,
493 },
494 IconButtonVariant::Outlined => IconButtonColors {
495 container: Color::TRANSPARENT,
496 content: primary,
497 outline: Some(outline),
498 },
499 }
500}
501
502fn badge_colors(theme: Option<&Theme>) -> (Color, Color) {
506 match theme {
507 Some(theme) => (theme.scheme().primary, theme.scheme().on_primary),
508 None => (PRIMARY, ON_PRIMARY),
509 }
510}
511
512fn with_alpha(color: Color, alpha: f32) -> Color {
514 let c = color.components;
515 Color::new([c[0], c[1], c[2], alpha])
516}
517
518const ZERO_PADDING: ContentPadding = ContentPadding::symmetric(0.0);
531
532#[derive(Clone, Debug, PartialEq)]
539pub enum BadgeValue {
540 Count(u32),
543 Text(String),
545}
546
547#[derive(Clone, Debug, PartialEq)]
550enum ResolvedBadge {
551 None,
552 Dot,
553 Label(String),
554}
555
556fn resolve_badge(value: &Option<BadgeValue>) -> ResolvedBadge {
557 match value {
558 None => ResolvedBadge::None,
559 Some(BadgeValue::Count(n)) => {
560 let clamped = (*n).min(999_999);
561 if clamped == 0 {
562 ResolvedBadge::Dot
563 } else {
564 ResolvedBadge::Label(clamped.to_string())
565 }
566 }
567 Some(BadgeValue::Text(s)) => {
568 if s.is_empty() {
569 ResolvedBadge::None
570 } else {
571 ResolvedBadge::Label(s.clone())
572 }
573 }
574 }
575}
576
577const DOT_DIAMETER: f64 = 8.0;
580const BADGE_HEIGHT: f64 = 16.0;
584const BADGE_H_PAD: f64 = 4.0;
587const BADGE_TEXT_SIZE: f32 = 11.0;
590
591struct BadgeRun {
601 content: String,
602 style: TextStyle,
604 layout: Option<TextLayout>,
605}
606
607fn badge_style(theme: Option<&Theme>) -> TextStyle {
613 let mut style = TextStyle::new(BADGE_TEXT_SIZE, Color::BLACK);
614 if let Some(theme) = theme {
615 style.family = theme.type_scale.label_small.family.clone();
616 }
617 style
618}
619
620impl BadgeRun {
621 fn new() -> Self {
622 Self {
623 content: String::new(),
624 style: badge_style(None),
625 layout: None,
626 }
627 }
628
629 fn set_content(&mut self, content: &str) {
630 if self.content != content {
631 self.content = content.to_string();
632 self.layout = None;
633 }
634 }
635
636 fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> Size {
638 if self.style != *style {
639 self.style = style.clone();
640 self.layout = None;
641 }
642 if let Some(layout) = &self.layout {
643 return layout.size();
644 }
645 let text_ctx = ctx.text_context::<TextContext>();
646 let laid = text_ctx.layout(&self.content, &self.style, None);
647 let size = laid.size();
648 self.layout = Some(laid);
649 size
650 }
651
652 fn size(&self) -> Size {
653 self.layout.as_ref().map_or(Size::ZERO, TextLayout::size)
654 }
655
656 fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
658 let Some(layout) = &self.layout else {
659 return;
660 };
661 for mut run in layout.to_scene_runs(origin) {
662 run.brush = Brush::Solid(color);
663 scene.draw_glyph_run(run);
664 }
665 }
666}
667
668#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
674pub enum IconButtonVariant {
675 #[default]
678 Standard,
679 Filled,
681 Tonal,
683 Outlined,
685}
686
687#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
691pub enum IconButtonSize {
692 Xs,
694 #[default]
696 Sm,
697 Md,
699 Lg,
701 Xl,
703}
704
705#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
708pub enum IconButtonShape {
709 #[default]
711 Round,
712 Square,
714}
715
716impl IconButtonShape {
717 fn flipped(self) -> Self {
720 match self {
721 Self::Round => Self::Square,
722 Self::Square => Self::Round,
723 }
724 }
725}
726
727#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
732pub enum IconButtonWidth {
733 #[default]
735 Standard,
736 Narrow,
738 Wide,
740}
741
742type OnPress<State> = Rc<dyn Fn(&mut State)>;
746
747pub struct IconButtonView<State: 'static> {
749 icon: AnyView<State>,
750 selected_icon: Option<AnyView<State>>,
751 variant: IconButtonVariant,
752 size: IconButtonSize,
753 shape: IconButtonShape,
754 width: IconButtonWidth,
755 is_selected: Option<bool>,
756 enabled: bool,
757 haptic: HapticSignal,
758 badge_value: Option<BadgeValue>,
759 suppress_ink: bool,
760 semantic_label: Option<String>,
761 on_press: OnPress<State>,
762}
763
764pub fn icon_button<State: 'static, F: Fn(&mut State) + 'static>(
768 icon: impl View<State>,
769 on_press: F,
770) -> IconButtonView<State> {
771 IconButtonView {
772 icon: AnyView::new(icon),
773 selected_icon: None,
774 variant: IconButtonVariant::default(),
775 size: IconButtonSize::default(),
776 shape: IconButtonShape::default(),
777 width: IconButtonWidth::default(),
778 is_selected: None,
779 enabled: true,
780 haptic: HapticSignal::None,
781 badge_value: None,
782 suppress_ink: false,
783 semantic_label: None,
784 on_press: Rc::new(on_press),
785 }
786}
787
788#[allow(non_snake_case)]
791pub fn IconButton<State: 'static, F: Fn(&mut State) + 'static>(
792 icon: impl View<State>,
793 on_press: F,
794) -> IconButtonView<State> {
795 icon_button(icon, on_press)
796}
797
798pub fn filled_icon_button<State: 'static, F: Fn(&mut State) + 'static>(
801 icon: impl View<State>,
802 on_press: F,
803) -> IconButtonView<State> {
804 icon_button(icon, on_press).variant(IconButtonVariant::Filled)
805}
806
807pub fn tonal_icon_button<State: 'static, F: Fn(&mut State) + 'static>(
810 icon: impl View<State>,
811 on_press: F,
812) -> IconButtonView<State> {
813 icon_button(icon, on_press).variant(IconButtonVariant::Tonal)
814}
815
816pub fn outlined_icon_button<State: 'static, F: Fn(&mut State) + 'static>(
819 icon: impl View<State>,
820 on_press: F,
821) -> IconButtonView<State> {
822 icon_button(icon, on_press).variant(IconButtonVariant::Outlined)
823}
824
825impl<State: 'static> IconButtonView<State> {
826 pub fn variant(mut self, variant: IconButtonVariant) -> Self {
828 self.variant = variant;
829 self
830 }
831
832 pub fn size(mut self, size: IconButtonSize) -> Self {
834 self.size = size;
835 self
836 }
837
838 pub fn shape(mut self, shape: IconButtonShape) -> Self {
840 self.shape = shape;
841 self
842 }
843
844 pub fn width(mut self, width: IconButtonWidth) -> Self {
846 self.width = width;
847 self
848 }
849
850 pub fn selected(mut self, selected: bool) -> Self {
853 self.is_selected = Some(selected);
854 self
855 }
856
857 pub fn selected_icon(mut self, icon: impl View<State>) -> Self {
861 self.selected_icon = Some(AnyView::new(icon));
862 self
863 }
864
865 pub fn enabled(mut self, enabled: bool) -> Self {
868 self.enabled = enabled;
869 self
870 }
871
872 pub fn haptic(mut self, haptic: HapticSignal) -> Self {
875 self.haptic = haptic;
876 self
877 }
878
879 pub fn badge(mut self, value: BadgeValue) -> Self {
881 self.badge_value = Some(value);
882 self
883 }
884
885 pub fn suppress_ink(mut self, suppress: bool) -> Self {
890 self.suppress_ink = suppress;
891 self
892 }
893
894 pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
896 self.semantic_label = Some(label.into());
897 self
898 }
899
900 fn active_icon(&self) -> &AnyView<State> {
905 let selected = self.is_selected.unwrap_or(false);
906 if selected {
907 self.selected_icon.as_ref().unwrap_or(&self.icon)
908 } else {
909 &self.icon
910 }
911 }
912
913 fn is_toggle(&self) -> bool {
917 self.is_selected.is_some() || self.selected_icon.is_some()
918 }
919}
920
921pub struct IconButtonWidget {
923 variant: IconButtonVariant,
924 size: IconButtonSize,
925 shape: IconButtonShape,
926 width: IconButtonWidth,
927 is_selected: Option<bool>,
928 is_toggle: bool,
929 enabled: bool,
930 haptic: HapticSignal,
931 badge: ResolvedBadge,
932 suppress_ink: bool,
933 semantic_label: Option<String>,
934 icon: ChildPod,
935 badge_run: BadgeRun,
936 badge_size: Size,
938 visual: Size,
940 visual_origin: Point,
942 state: InteractionState,
943 captured: bool,
944 motion: RadiusPaddingMotion,
948 on_press: frust::authoring::ErasedCallback,
949}
950
951impl<State: 'static> View<State> for IconButtonView<State> {
952 type Element = IconButtonWidget;
953
954 fn build(&self, ctx: &mut BuildCtx<'_>) -> IconButtonWidget {
955 IconButtonWidget {
956 variant: self.variant,
957 size: self.size,
958 shape: self.shape,
959 width: self.width,
960 is_selected: self.is_selected,
961 is_toggle: self.is_toggle(),
962 enabled: self.enabled,
963 haptic: self.haptic,
964 badge: resolve_badge(&self.badge_value),
965 suppress_ink: self.suppress_ink,
966 semantic_label: self.semantic_label.clone(),
967 icon: frust::authoring::build_child(self.active_icon(), ctx),
968 badge_run: BadgeRun::new(),
969 badge_size: Size::ZERO,
970 visual: Size::ZERO,
971 visual_origin: Point::ZERO,
972 state: InteractionState::new(),
973 captured: false,
974 motion: RadiusPaddingMotion::new(),
975 on_press: frust::authoring::erase_callback(&self.on_press),
976 }
977 }
978
979 fn rebuild(
980 &self,
981 prev: &Self,
982 element: &mut IconButtonWidget,
983 ctx: &mut BuildCtx<'_>,
984 ) -> ChangeFlags {
985 element.on_press = frust::authoring::erase_callback(&self.on_press);
986 element.haptic = self.haptic;
987 element.semantic_label = self.semantic_label.clone();
988 let mut flags = ChangeFlags::NONE;
989
990 if prev.variant != self.variant {
991 element.variant = self.variant;
992 flags |= ChangeFlags::PAINT;
993 }
994 if prev.size != self.size {
995 element.size = self.size;
996 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
997 }
998 if prev.shape != self.shape {
999 element.shape = self.shape;
1000 flags |= ChangeFlags::PAINT;
1001 }
1002 if prev.width != self.width {
1003 element.width = self.width;
1004 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1005 }
1006 if prev.enabled != self.enabled {
1007 element.enabled = self.enabled;
1008 if !self.enabled {
1009 element.state.set_pressed(false);
1012 element.captured = false;
1013 }
1014 flags |= ChangeFlags::PAINT;
1015 }
1016 if prev.suppress_ink != self.suppress_ink {
1017 element.suppress_ink = self.suppress_ink;
1018 flags |= ChangeFlags::PAINT;
1019 }
1020
1021 let is_toggle = self.is_toggle();
1022 if element.is_toggle != is_toggle || element.is_selected != self.is_selected {
1023 element.is_toggle = is_toggle;
1024 element.is_selected = self.is_selected;
1025 flags |= ChangeFlags::PAINT;
1026 }
1027
1028 let next_badge = resolve_badge(&self.badge_value);
1029 if element.badge != next_badge {
1030 element.badge = next_badge;
1031 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1032 }
1033
1034 flags |= frust::authoring::rebuild_child(
1035 prev.active_icon(),
1036 self.active_icon(),
1037 &mut element.icon,
1038 ctx,
1039 );
1040
1041 flags
1042 }
1043
1044 fn teardown(&self, element: &mut IconButtonWidget, ctx: &mut BuildCtx<'_>) {
1045 frust::authoring::teardown_child(self.active_icon(), &mut element.icon, ctx);
1046 }
1047}
1048
1049fn inside(pos: Point, size: Size) -> bool {
1052 pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
1053}
1054
1055fn inset_stroke_rect(size: Size, radius: f64, width: f64) -> RoundedRect {
1059 let half = width / 2.0;
1060 RoundedRect::new(
1061 half,
1062 half,
1063 size.width - half,
1064 size.height - half,
1065 (radius - half).max(0.0),
1066 )
1067}
1068
1069impl Widget for IconButtonWidget {
1070 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1071 let visual = visual_size(self.size, self.width);
1072 let target = target_size(self.size, self.width);
1073 let visual_origin = Point::new(
1074 (target.width - visual.width) / 2.0,
1075 (target.height - visual.height) / 2.0,
1076 );
1077
1078 let icon_dim = icon_glyph_size(self.size);
1079 let icon_measured = self
1080 .icon
1081 .layout_child(ctx, &BoxConstraints::tight(Size::new(icon_dim, icon_dim)));
1082 self.icon.set_origin(Point::new(
1083 visual_origin.x + (visual.width - icon_measured.width) / 2.0,
1084 visual_origin.y + (visual.height - icon_measured.height) / 2.0,
1085 ));
1086
1087 self.badge_size = match &self.badge {
1088 ResolvedBadge::None => Size::ZERO,
1089 ResolvedBadge::Dot => Size::new(DOT_DIAMETER, DOT_DIAMETER),
1090 ResolvedBadge::Label(text) => {
1091 self.badge_run.set_content(text);
1092 let style = badge_style(Theme::from_layout_ctx(ctx));
1093 let text_size = self.badge_run.shape(ctx, &style);
1094 let width = (text_size.width + BADGE_H_PAD * 2.0).max(BADGE_HEIGHT);
1095 Size::new(width, BADGE_HEIGHT)
1096 }
1097 };
1098
1099 self.visual = visual;
1100 self.visual_origin = visual_origin;
1101 bc.constrain(target)
1102 }
1103
1104 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1105 let outer_origin = ctx.origin();
1106
1107 self.state.set_hovered(self.enabled && ctx.is_hovered());
1110 self.state.set_focused(self.enabled && ctx.has_focus());
1111
1112 let selected = self.is_selected.unwrap_or(false);
1113 let (colors, radius_target, badge_bg, badge_fg) = {
1114 let theme = Theme::from_paint_ctx(ctx);
1115 let colors = resolve_colors(theme, self.variant, selected);
1116 let radius =
1117 effective_radius(self.size, self.shape, self.is_toggle, selected, self.state);
1118 let (bg, fg) = badge_colors(theme);
1119 (colors, radius, bg, fg)
1120 };
1121
1122 if self.motion.retarget(radius_target, ZERO_PADDING) {
1123 ctx.request_frame();
1124 }
1125 if self.motion.advance(ctx.frame_time()) {
1126 ctx.request_frame();
1127 }
1128 let radius = self.motion.radius();
1129
1130 let visual_origin = Point::new(
1131 outer_origin.x + self.visual_origin.x,
1132 outer_origin.y + self.visual_origin.y,
1133 );
1134
1135 if colors.container.components[3] > 0.0 {
1136 scene.fill_rounded_rect(visual_origin, self.visual, radius, colors.container);
1137 }
1138 if let Some(outline) = colors.outline {
1139 let path =
1140 inset_stroke_rect(self.visual, radius, OUTLINE_WIDTH).to_path(PATH_TOLERANCE);
1141 scene.stroke_path(visual_origin, &path, OUTLINE_WIDTH, &Brush::Solid(outline));
1142 }
1143 if !self.suppress_ink && self.enabled {
1144 let opacity = self.state.resolve_opacity();
1145 if opacity > 0.0 {
1146 scene.fill_rounded_rect(
1147 visual_origin,
1148 self.visual,
1149 radius,
1150 with_alpha(colors.content, opacity),
1151 );
1152 }
1153 }
1154
1155 self.icon.paint_child(ctx, scene);
1156
1157 match &self.badge {
1158 ResolvedBadge::None => {}
1159 ResolvedBadge::Dot => {
1160 let d = DOT_DIAMETER;
1161 let dot_origin =
1162 Point::new(visual_origin.x + self.visual.width - d, visual_origin.y);
1163 scene.fill_rounded_rect(dot_origin, Size::new(d, d), d / 2.0, badge_bg);
1164 }
1165 ResolvedBadge::Label(_) => {
1166 let size = self.badge_size;
1167 let pill_origin = Point::new(
1168 visual_origin.x + self.visual.width - size.width,
1169 visual_origin.y,
1170 );
1171 scene.fill_rounded_rect(pill_origin, size, size.height / 2.0, badge_bg);
1172 let text_size = self.badge_run.size();
1173 let text_origin = Point::new(
1174 pill_origin.x + (size.width - text_size.width) / 2.0,
1175 pill_origin.y + (size.height - text_size.height) / 2.0,
1176 );
1177 self.badge_run.paint(text_origin, badge_fg, scene);
1178 }
1179 }
1180 }
1181
1182 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1183 if !self.enabled {
1184 return EventResult::Ignored;
1185 }
1186 let InputEvent::Pointer(p) = event else {
1187 return EventResult::Ignored;
1188 };
1189 match p.phase {
1190 PointerPhase::Down => {
1191 if !presses(p) {
1192 return EventResult::Ignored;
1193 }
1194 self.state.set_pressed(true);
1195 self.captured = true;
1196 ctx.capture_pointer();
1197 ctx.request_redraw();
1198 EventResult::Handled
1199 }
1200 PointerPhase::Move => {
1201 let over = inside(p.position, ctx.size());
1202 if !self.captured {
1203 if over {
1204 ctx.claim_hover();
1205 ctx.set_cursor(CursorIcon::Pointer);
1206 }
1207 if self.state.set_hovered(over) {
1208 ctx.request_redraw();
1209 }
1210 return EventResult::Ignored;
1211 }
1212 ctx.set_cursor(CursorIcon::Pointer);
1213 if self.state.set_pressed(over) {
1214 ctx.request_redraw();
1215 }
1216 EventResult::Handled
1217 }
1218 PointerPhase::Up => {
1219 if !self.captured {
1220 return EventResult::Ignored;
1221 }
1222 if inside(p.position, ctx.size()) {
1223 if self.haptic != HapticSignal::None {
1224 MaterialHaptics::fire(self.haptic);
1225 }
1226 (self.on_press)(ctx);
1227 }
1228 self.state.set_pressed(false);
1229 self.captured = false;
1230 ctx.request_redraw();
1231 EventResult::Handled
1232 }
1233 PointerPhase::Cancel => {
1234 if !self.captured {
1235 return EventResult::Ignored;
1236 }
1237 self.state.set_pressed(false);
1238 self.captured = false;
1239 ctx.request_redraw();
1240 EventResult::Handled
1241 }
1242 }
1243 }
1244
1245 fn semantics(&self, ctx: &mut SemanticsCtx) {
1246 ctx.push_node(Role::Button, |node| {
1247 if let Some(label) = &self.semantic_label {
1248 node.set_label(label.as_str());
1249 }
1250 node.set_selected(self.is_selected.unwrap_or(false));
1251 if self.enabled {
1252 node.add_action(Action::Click);
1253 } else {
1254 node.set_disabled();
1255 }
1256 });
1257 }
1258
1259 frust::authoring::visit_children!(icon);
1260}
1261
1262#[cfg(test)]
1263mod tests {
1264 use super::*;
1265 use frust::FrameTime;
1266 use frust::authoring::scene::GlyphRun;
1267 use frust::authoring::text::FontFamily;
1268 use frust::authoring::{BuildCtx, PointerButton, PointerEvent, View};
1269 use frust_widgets::test_support::leaf_any;
1270 use std::any::Any;
1271 use std::cell::RefCell;
1272
1273 fn build<S: 'static>(view: &IconButtonView<S>) -> IconButtonWidget {
1274 let mut counter = 0u64;
1275 View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1276 }
1277
1278 fn ft_secs(s: f64) -> FrameTime {
1279 FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
1280 }
1281
1282 fn settle_motion(motion: &mut RadiusPaddingMotion) {
1283 let mut t = 0.0;
1284 for _ in 0..600 {
1285 t += 1.0 / 60.0;
1286 if !motion.advance(ft_secs(t)) {
1287 return;
1288 }
1289 }
1290 panic!("the press morph never settled");
1291 }
1292
1293 fn settle_morph(widget: &mut IconButtonWidget) {
1294 settle_motion(&mut widget.motion);
1295 }
1296
1297 #[derive(Default)]
1298 struct Recorder {
1299 rrects: Vec<(Point, Size, f64, Color)>,
1300 strokes: Vec<(Point, f64, Color)>,
1301 runs: Vec<(Point, Color, usize)>,
1306 }
1307
1308 impl PaintScene for Recorder {
1309 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1310 fn draw_text(&mut self, _o: Point, _t: &str) {}
1311 fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
1312 self.rrects.push((o, s, radius, color));
1313 }
1314 fn draw_shadow(&mut self, _o: Point, _s: Size, _radius: f64, _std_dev: f64, _color: Color) {
1315 }
1316 fn stroke_path(&mut self, o: Point, _path: &kurbo::BezPath, width: f64, brush: &Brush) {
1317 let color = match brush {
1318 Brush::Solid(c) => *c,
1319 _ => Color::TRANSPARENT,
1320 };
1321 self.strokes.push((o, width, color));
1322 }
1323 fn draw_glyph_run(&mut self, run: GlyphRun) {
1324 let t = run.transform.translation();
1325 let color = match &run.brush {
1326 Brush::Solid(c) => *c,
1327 _ => Color::TRANSPARENT,
1328 };
1329 self.runs
1330 .push((Point::new(t.x, t.y), color, run.glyphs.len()));
1331 }
1332 }
1333
1334 fn layout_with(widget: &mut IconButtonWidget, max: f64) -> Size {
1335 let mut tcx = frust::authoring::text::TextContext::new();
1336 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1337 widget.layout(&mut lctx, &BoxConstraints::loose(Size::new(max, max)))
1338 }
1339
1340 fn paint_at(widget: &mut IconButtonWidget, size: Size, theme: Option<&Theme>) -> Recorder {
1341 let mut rec = Recorder::default();
1342 let mut pctx = PaintCtx::new(Point::ZERO, size);
1343 if let Some(theme) = theme {
1344 pctx = pctx.with_theme(theme as &dyn Any);
1345 }
1346 widget.paint(&mut pctx, &mut rec);
1347 rec
1348 }
1349
1350 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1351 InputEvent::Pointer(PointerEvent {
1352 phase,
1353 position: Point::new(x, y),
1354 button: PointerButton::Primary,
1355 })
1356 }
1357
1358 fn dispatch<S: 'static>(
1359 widget: &mut IconButtonWidget,
1360 state: &mut S,
1361 size: Size,
1362 event: &InputEvent,
1363 ) {
1364 let state_any: &mut dyn Any = state;
1365 let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
1366 widget.event(&mut ctx, event);
1367 }
1368
1369 #[test]
1372 fn defaults_match_the_reference_constructor_defaults() {
1373 let view = icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {});
1376 assert_eq!(view.variant, IconButtonVariant::Standard);
1377 assert_eq!(view.size, IconButtonSize::Sm);
1378 assert_eq!(view.shape, IconButtonShape::Round);
1379 assert_eq!(view.width, IconButtonWidth::Standard);
1380 assert!(view.enabled);
1381 assert!(!view.suppress_ink);
1382 assert_eq!(view.haptic, HapticSignal::None);
1383 assert!(!view.is_toggle());
1384 }
1385
1386 #[test]
1387 fn the_named_constructors_map_onto_the_reference_variants() {
1388 assert_eq!(
1389 filled_icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).variant,
1390 IconButtonVariant::Filled
1391 );
1392 assert_eq!(
1393 tonal_icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).variant,
1394 IconButtonVariant::Tonal
1395 );
1396 assert_eq!(
1397 outlined_icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).variant,
1398 IconButtonVariant::Outlined
1399 );
1400 }
1401
1402 const ALL_SIZES: [IconButtonSize; 5] = [
1405 IconButtonSize::Xs,
1406 IconButtonSize::Sm,
1407 IconButtonSize::Md,
1408 IconButtonSize::Lg,
1409 IconButtonSize::Xl,
1410 ];
1411
1412 #[test]
1413 fn icon_glyph_sizes_match_the_reference_table() {
1414 let expected = [
1416 (IconButtonSize::Xs, 20.0),
1417 (IconButtonSize::Sm, 24.0),
1418 (IconButtonSize::Md, 24.0),
1419 (IconButtonSize::Lg, 32.0),
1420 (IconButtonSize::Xl, 40.0),
1421 ];
1422 for (size, icon) in expected {
1423 assert_eq!(icon_glyph_size(size), icon, "{size:?}");
1424 }
1425 }
1426
1427 #[test]
1428 fn visual_sizes_match_the_reference_table_for_every_size_and_width() {
1429 use IconButtonWidth::{Narrow, Standard, Wide};
1431 let expected: [(IconButtonSize, Size, Size, Size); 5] = [
1432 (
1433 IconButtonSize::Xs,
1434 Size::new(32.0, 32.0),
1435 Size::new(28.0, 32.0),
1436 Size::new(40.0, 32.0),
1437 ),
1438 (
1439 IconButtonSize::Sm,
1440 Size::new(40.0, 40.0),
1441 Size::new(32.0, 40.0),
1442 Size::new(52.0, 40.0),
1443 ),
1444 (
1445 IconButtonSize::Md,
1446 Size::new(56.0, 56.0),
1447 Size::new(48.0, 56.0),
1448 Size::new(72.0, 56.0),
1449 ),
1450 (
1451 IconButtonSize::Lg,
1452 Size::new(96.0, 96.0),
1453 Size::new(64.0, 96.0),
1454 Size::new(128.0, 96.0),
1455 ),
1456 (
1457 IconButtonSize::Xl,
1458 Size::new(136.0, 136.0),
1459 Size::new(104.0, 136.0),
1460 Size::new(184.0, 136.0),
1461 ),
1462 ];
1463 for (size, default, narrow, wide) in expected {
1464 assert_eq!(visual_size(size, Standard), default, "{size:?} default");
1465 assert_eq!(visual_size(size, Narrow), narrow, "{size:?} narrow");
1466 assert_eq!(visual_size(size, Wide), wide, "{size:?} wide");
1467 }
1468 }
1469
1470 #[test]
1471 fn target_sizes_match_the_reference_table_for_every_size_and_width() {
1472 use IconButtonWidth::{Narrow, Standard, Wide};
1474 let expected: [(IconButtonSize, Size, Size, Size); 5] = [
1475 (
1476 IconButtonSize::Xs,
1477 Size::new(48.0, 48.0),
1478 Size::new(48.0, 48.0),
1479 Size::new(48.0, 48.0),
1480 ),
1481 (
1482 IconButtonSize::Sm,
1483 Size::new(48.0, 48.0),
1484 Size::new(48.0, 48.0),
1485 Size::new(52.0, 48.0),
1486 ),
1487 (
1488 IconButtonSize::Md,
1489 Size::new(56.0, 56.0),
1490 Size::new(48.0, 56.0),
1491 Size::new(72.0, 56.0),
1492 ),
1493 (
1494 IconButtonSize::Lg,
1495 Size::new(96.0, 96.0),
1496 Size::new(64.0, 96.0),
1497 Size::new(128.0, 96.0),
1498 ),
1499 (
1500 IconButtonSize::Xl,
1501 Size::new(136.0, 136.0),
1502 Size::new(104.0, 136.0),
1503 Size::new(184.0, 136.0),
1504 ),
1505 ];
1506 for (size, default, narrow, wide) in expected {
1507 assert_eq!(target_size(size, Standard), default, "{size:?} default");
1508 assert_eq!(target_size(size, Narrow), narrow, "{size:?} narrow");
1509 assert_eq!(target_size(size, Wide), wide, "{size:?} wide");
1510 }
1511 }
1512
1513 #[test]
1514 fn xs_and_sm_visual_is_smaller_than_the_48dp_target_but_the_target_never_is() {
1515 for size in ALL_SIZES {
1519 for width in [
1520 IconButtonWidth::Standard,
1521 IconButtonWidth::Narrow,
1522 IconButtonWidth::Wide,
1523 ] {
1524 let visual = visual_size(size, width);
1525 let target = target_size(size, width);
1526 assert!(
1527 target.width >= 48.0 && target.height >= 48.0,
1528 "{size:?}/{width:?} target below 48dp"
1529 );
1530 assert!(target.width >= visual.width && target.height >= visual.height);
1531 }
1532 }
1533 assert!(visual_size(IconButtonSize::Xs, IconButtonWidth::Standard).width < 48.0);
1534 assert!(visual_size(IconButtonSize::Sm, IconButtonWidth::Standard).width < 48.0);
1535 }
1536
1537 #[test]
1540 fn radius_tables_match_the_reference() {
1541 let expected: [(IconButtonSize, f64, f64, f64, f64); 5] = [
1543 (IconButtonSize::Xs, 16.0, 8.0, 6.0, 10.0),
1544 (IconButtonSize::Sm, 20.0, 10.0, 8.0, 12.0),
1545 (IconButtonSize::Md, 28.0, 14.0, 11.0, 16.0),
1546 (IconButtonSize::Lg, 48.0, 24.0, 19.0, 28.0),
1547 (IconButtonSize::Xl, 68.0, 34.0, 27.0, 40.0),
1548 ];
1549 for (size, round, square, pressed, hovered) in expected {
1550 assert_eq!(radius_rest_round(size), round, "{size:?} round");
1551 assert_eq!(radius_rest_square(size), square, "{size:?} square");
1552 assert_eq!(radius_pressed(size), pressed, "{size:?} pressed");
1553 assert_eq!(radius_hovered(size), hovered, "{size:?} hovered");
1554 }
1555 }
1556
1557 #[test]
1558 fn a_toggle_flips_shape_only_while_selected() {
1559 assert_eq!(
1561 rest_variant(true, true, IconButtonShape::Round),
1562 IconButtonShape::Square
1563 );
1564 assert_eq!(
1565 rest_variant(true, true, IconButtonShape::Square),
1566 IconButtonShape::Round
1567 );
1568 assert_eq!(
1569 rest_variant(true, false, IconButtonShape::Round),
1570 IconButtonShape::Round
1571 );
1572 assert_eq!(
1573 rest_variant(false, true, IconButtonShape::Round),
1574 IconButtonShape::Round,
1575 "not a toggle at all: no flip even if selected is somehow true"
1576 );
1577 }
1578
1579 #[test]
1580 fn effective_radius_precedence_is_pressed_then_hovered_then_resting() {
1581 let mut state = InteractionState::new();
1582 assert_eq!(
1583 effective_radius(
1584 IconButtonSize::Sm,
1585 IconButtonShape::Round,
1586 false,
1587 false,
1588 state
1589 ),
1590 radius_rest_round(IconButtonSize::Sm)
1591 );
1592 state.set_hovered(true);
1593 assert_eq!(
1594 effective_radius(
1595 IconButtonSize::Sm,
1596 IconButtonShape::Round,
1597 false,
1598 false,
1599 state
1600 ),
1601 radius_hovered(IconButtonSize::Sm)
1602 );
1603 state.set_pressed(true);
1604 assert_eq!(
1605 effective_radius(
1606 IconButtonSize::Sm,
1607 IconButtonShape::Round,
1608 false,
1609 false,
1610 state
1611 ),
1612 radius_pressed(IconButtonSize::Sm)
1613 );
1614 }
1615
1616 #[test]
1619 fn unthemed_colors_match_the_reference_variant_table() {
1620 let standard = resolve_colors(None, IconButtonVariant::Standard, false);
1622 assert_eq!(standard.container, Color::TRANSPARENT);
1623 assert_eq!(standard.content, ON_SURFACE_VARIANT);
1624 assert_eq!(standard.outline, None);
1625
1626 let standard_selected = resolve_colors(None, IconButtonVariant::Standard, true);
1627 assert_eq!(
1628 standard_selected.content, PRIMARY,
1629 "selected standard swaps content to primary — the one color the toggle changes"
1630 );
1631
1632 let filled = resolve_colors(None, IconButtonVariant::Filled, false);
1633 assert_eq!(filled.container, PRIMARY);
1634 assert_eq!(filled.content, ON_PRIMARY);
1635
1636 let tonal = resolve_colors(None, IconButtonVariant::Tonal, false);
1637 assert_eq!(tonal.container, SECONDARY_CONTAINER);
1638 assert_eq!(tonal.content, ON_SECONDARY_CONTAINER);
1639
1640 let outlined = resolve_colors(None, IconButtonVariant::Outlined, false);
1641 assert_eq!(outlined.container, Color::TRANSPARENT);
1642 assert_eq!(outlined.content, PRIMARY);
1643 assert_eq!(outlined.outline, Some(OUTLINE));
1644 }
1645
1646 #[test]
1647 fn only_standard_swaps_color_on_selection() {
1648 for variant in [
1649 IconButtonVariant::Filled,
1650 IconButtonVariant::Tonal,
1651 IconButtonVariant::Outlined,
1652 ] {
1653 let unselected = resolve_colors(None, variant, false);
1654 let selected = resolve_colors(None, variant, true);
1655 assert_eq!(
1656 unselected, selected,
1657 "{variant:?} must not change color on selection"
1658 );
1659 }
1660 }
1661
1662 #[test]
1665 fn press_spring_matches_expressive_spatial_press() {
1666 let token = crate::tokens::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS;
1672 let spring = crate::button::motion::PRESS_SPRING;
1673 assert_eq!(spring.stiffness, token.stiffness);
1674 assert_eq!(spring.damping_ratio, token.damping_ratio);
1675 assert_eq!(spring.mass, 1.0);
1676 assert_eq!(spring.stiffness, 380.0);
1677 assert_eq!(spring.damping_ratio, 0.55);
1678 }
1679
1680 #[test]
1681 fn the_morph_snaps_on_mount_and_springs_on_a_later_retarget() {
1682 let mut motion = RadiusPaddingMotion::new();
1683 assert!(
1684 !motion.retarget(20.0, ZERO_PADDING),
1685 "the mount frame seeds, it does not animate"
1686 );
1687 assert_eq!(motion.radius(), 20.0);
1688 assert!(
1689 motion.retarget(8.0, ZERO_PADDING),
1690 "a later retarget starts a leg"
1691 );
1692 assert_eq!(motion.radius(), 20.0, "the leg starts where it was");
1693 settle_motion(&mut motion);
1694 assert_eq!(motion.radius(), 8.0);
1695 }
1696
1697 #[test]
1698 fn a_sub_tolerance_retarget_is_ignored() {
1699 let mut motion = RadiusPaddingMotion::new();
1700 motion.retarget(20.0, ZERO_PADDING);
1701 let tolerance = crate::button::motion::RETARGET_TOLERANCE;
1702 assert!(!motion.retarget(20.0 + tolerance / 2.0, ZERO_PADDING));
1703 assert_eq!(motion.target_radius(), 20.0);
1704 }
1705
1706 #[test]
1707 fn a_press_and_release_cycle_settles_at_the_resting_radius() {
1708 let presses = Rc::new(RefCell::new(0u32));
1709 let counted = presses.clone();
1710 let view = icon_button::<(), _>(leaf_any(24.0, 24.0), move |_| {
1711 *counted.borrow_mut() += 1;
1712 });
1713 let mut widget = build(&view);
1714 let size = layout_with(&mut widget, 200.0);
1715
1716 paint_at(&mut widget, size, None);
1718 settle_morph(&mut widget);
1719 let resting = widget.motion.radius();
1720 assert_eq!(resting, radius_rest_round(IconButtonSize::Sm));
1721
1722 let mut state = ();
1724 dispatch(
1725 &mut widget,
1726 &mut state,
1727 size,
1728 &ev(PointerPhase::Down, 10.0, 10.0),
1729 );
1730 paint_at(&mut widget, size, None);
1731 settle_morph(&mut widget);
1732 assert_eq!(widget.motion.radius(), radius_pressed(IconButtonSize::Sm));
1733
1734 dispatch(
1736 &mut widget,
1737 &mut state,
1738 size,
1739 &ev(PointerPhase::Up, 10.0, 10.0),
1740 );
1741 assert_eq!(*presses.borrow(), 1, "on_press fired exactly once");
1742 paint_at(&mut widget, size, None);
1743 settle_morph(&mut widget);
1744 assert_eq!(widget.motion.radius(), resting);
1745 }
1746
1747 #[test]
1750 fn badge_resolution_matches_build_badge() {
1751 assert_eq!(resolve_badge(&None), ResolvedBadge::None);
1753 assert_eq!(
1754 resolve_badge(&Some(BadgeValue::Count(0))),
1755 ResolvedBadge::Dot
1756 );
1757 assert_eq!(
1758 resolve_badge(&Some(BadgeValue::Count(3))),
1759 ResolvedBadge::Label("3".to_string())
1760 );
1761 assert_eq!(
1762 resolve_badge(&Some(BadgeValue::Count(1_000_000))),
1763 ResolvedBadge::Label("999999".to_string()),
1764 "clamped to 999_999"
1765 );
1766 assert_eq!(
1767 resolve_badge(&Some(BadgeValue::Text(String::new()))),
1768 ResolvedBadge::None
1769 );
1770 assert_eq!(
1771 resolve_badge(&Some(BadgeValue::Text("NEW".to_string()))),
1772 ResolvedBadge::Label("NEW".to_string())
1773 );
1774 }
1775
1776 #[test]
1777 fn a_dot_badge_paints_one_small_filled_circle_at_the_top_end_corner() {
1778 let view = icon_button::<(), _>(leaf_any(24.0, 24.0), |_| {}).badge(BadgeValue::Count(0));
1779 let mut widget = build(&view);
1780 let size = layout_with(&mut widget, 200.0);
1781 let rec = paint_at(&mut widget, size, None);
1782
1783 let (origin, dot_size, radius, color) = rec
1784 .rrects
1785 .iter()
1786 .find(|(_, s, _, _)| *s == Size::new(DOT_DIAMETER, DOT_DIAMETER))
1787 .copied()
1788 .expect("a dot-sized rounded rect is painted");
1789 let _ = dot_size;
1790 assert_eq!(radius, DOT_DIAMETER / 2.0);
1791 assert_eq!(color, PRIMARY);
1792 let visual = visual_size(IconButtonSize::Sm, IconButtonWidth::Standard);
1794 let visual_origin = widget.visual_origin;
1795 assert_eq!(origin.y, visual_origin.y);
1796 assert_eq!(origin.x, visual_origin.x + visual.width - DOT_DIAMETER);
1797 }
1798
1799 #[test]
1800 fn a_count_badge_paints_a_labeled_pill_with_the_digit_text() {
1801 let view = icon_button::<(), _>(leaf_any(24.0, 24.0), |_| {}).badge(BadgeValue::Count(7));
1802 let mut widget = build(&view);
1803 let size = layout_with(&mut widget, 200.0);
1804 let rec = paint_at(&mut widget, size, None);
1805
1806 assert!(
1807 rec.rrects.iter().any(|(_, s, r, c)| *s == widget.badge_size
1808 && *r == BADGE_HEIGHT / 2.0
1809 && *c == PRIMARY),
1810 "the pill background is painted at primary"
1811 );
1812 assert!(
1813 rec.runs
1814 .iter()
1815 .any(|(_, ink, glyphs)| *ink == ON_PRIMARY && *glyphs == 1),
1816 "the badge label's one-digit glyph run is painted at on_primary"
1817 );
1818 }
1819
1820 fn theme_with_label_small(family: FontFamily) -> Theme {
1824 let mut theme = crate::baseline();
1825 theme.type_scale.label_small.family = family;
1826 theme
1827 }
1828
1829 fn layout_themed(widget: &mut IconButtonWidget, tcx: &mut TextContext, theme: Option<&Theme>) {
1832 let mut ctx =
1833 LayoutCtx::with_resources(Some(tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
1834 widget.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
1835 }
1836
1837 #[test]
1838 fn a_badge_label_takes_its_family_from_label_small() {
1839 let view = icon_button::<(), _>(leaf_any(24.0, 24.0), |_| {}).badge(BadgeValue::Count(7));
1840 let mut widget = build(&view);
1841 let mut tcx = TextContext::new();
1842 let probe = FontFamily::named("Icon Badge Role Probe");
1843 layout_themed(
1844 &mut widget,
1845 &mut tcx,
1846 Some(&theme_with_label_small(probe.clone())),
1847 );
1848 assert_eq!(widget.badge_run.style.family, probe);
1849 assert_eq!(widget.badge_run.style.size, BADGE_TEXT_SIZE);
1851 }
1852
1853 #[test]
1854 fn without_a_theme_the_badge_label_keeps_the_unthemed_style() {
1855 let unthemed = TextStyle::new(BADGE_TEXT_SIZE, Color::BLACK);
1856 assert_eq!(badge_style(None), unthemed);
1857 assert_eq!(unthemed.family, FontFamily::SystemUi);
1858
1859 let view = icon_button::<(), _>(leaf_any(24.0, 24.0), |_| {}).badge(BadgeValue::Count(7));
1860 let mut widget = build(&view);
1861 let mut tcx = TextContext::new();
1862 layout_themed(&mut widget, &mut tcx, None);
1863 assert_eq!(widget.badge_run.style, unthemed);
1864 }
1865
1866 #[test]
1867 fn a_theme_swap_reshapes_the_cached_badge_label() {
1868 let view = icon_button::<(), _>(leaf_any(24.0, 24.0), |_| {}).badge(BadgeValue::Count(7));
1869 let mut widget = build(&view);
1870 let mut tcx = TextContext::new();
1871 let first = theme_with_label_small(FontFamily::named("Icon Badge Swap Probe A"));
1872 layout_themed(&mut widget, &mut tcx, Some(&first));
1873
1874 let settled = tcx.shape_cache_stats();
1876 layout_themed(&mut widget, &mut tcx, Some(&first));
1877 assert_eq!(
1878 tcx.shape_cache_stats(),
1879 settled,
1880 "an unchanged theme reshapes nothing"
1881 );
1882
1883 let second = theme_with_label_small(FontFamily::named("Icon Badge Swap Probe B"));
1884 layout_themed(&mut widget, &mut tcx, Some(&second));
1885 assert!(
1886 tcx.shape_cache_stats().shapes > settled.shapes,
1887 "a family swap must reshape the badge label, not serve the old face"
1888 );
1889 }
1890
1891 #[test]
1892 fn no_badge_value_paints_no_badge_geometry() {
1893 let view = icon_button::<(), _>(leaf_any(24.0, 24.0), |_| {});
1894 let mut widget = build(&view);
1895 let size = layout_with(&mut widget, 200.0);
1896 let rec = paint_at(&mut widget, size, None);
1897 assert!(
1898 rec.rrects
1899 .iter()
1900 .all(|(_, s, _, _)| *s != Size::new(DOT_DIAMETER, DOT_DIAMETER)),
1901 "no dot painted"
1902 );
1903 assert!(rec.runs.is_empty(), "no badge label painted");
1904 }
1905
1906 #[test]
1909 fn an_outlined_icon_button_paints_a_hairline_stroke() {
1910 let view =
1911 icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).variant(IconButtonVariant::Outlined);
1912 let mut widget = build(&view);
1913 let size = layout_with(&mut widget, 200.0);
1914 let rec = paint_at(&mut widget, size, None);
1915 assert_eq!(rec.strokes.len(), 1, "the outline hairline is stroked once");
1916 let (_, width, color) = rec.strokes[0];
1917 assert_eq!(width, OUTLINE_WIDTH);
1918 assert_eq!(color, OUTLINE);
1919 }
1920
1921 #[test]
1922 fn a_filled_icon_button_paints_no_outline_stroke() {
1923 let view =
1924 icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).variant(IconButtonVariant::Filled);
1925 let mut widget = build(&view);
1926 let size = layout_with(&mut widget, 200.0);
1927 let rec = paint_at(&mut widget, size, None);
1928 assert!(rec.strokes.is_empty());
1929 }
1930
1931 #[test]
1932 fn selected_icon_becomes_active_only_while_selected() {
1933 let base = leaf_any(20.0, 20.0);
1934 let selected_icon = leaf_any(24.0, 24.0);
1935 let view = icon_button::<(), _>(base, |_| {}).selected_icon(selected_icon);
1936
1937 assert!(view.is_toggle());
1938 assert!(
1939 std::ptr::eq(view.active_icon(), &view.icon),
1940 "unselected: base icon is active"
1941 );
1942
1943 let selected_view = view.selected(true);
1944 assert!(
1945 std::ptr::eq(
1946 selected_view.active_icon(),
1947 selected_view.selected_icon.as_ref().unwrap()
1948 ),
1949 "selected: selected_icon is active"
1950 );
1951 }
1952
1953 #[test]
1954 fn selected_without_a_selected_icon_still_counts_as_toggle() {
1955 let view = icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).selected(false);
1956 assert!(view.is_toggle());
1957 }
1958
1959 #[test]
1962 fn semantics_reports_role_button_selected_and_label() {
1963 fn logic(_s: &mut ()) -> IconButtonView<()> {
1964 icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {})
1965 .selected(true)
1966 .semantic_label("Favorite")
1967 }
1968 let mut root: frust_core::RenderRoot<(), IconButtonView<()>> =
1969 frust_core::RenderRoot::new();
1970 let mut state = ();
1971 root.rebuild(&mut logic, &mut state);
1972 let mut tcx = frust::authoring::text::TextContext::new();
1973 root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1974 let update = root.semantics();
1975 let (_, node) = update
1976 .nodes
1977 .iter()
1978 .find(|(_, n)| n.role() == Role::Button)
1979 .expect("icon button contributes a Role::Button node");
1980 assert_eq!(node.label(), Some("Favorite"));
1981 assert_eq!(node.is_selected(), Some(true));
1982 assert!(node.supports_action(Action::Click));
1983 }
1984
1985 #[test]
1986 fn semantics_omits_click_action_when_disabled() {
1987 fn logic(_s: &mut ()) -> IconButtonView<()> {
1988 icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).enabled(false)
1989 }
1990 let mut root: frust_core::RenderRoot<(), IconButtonView<()>> =
1991 frust_core::RenderRoot::new();
1992 let mut state = ();
1993 root.rebuild(&mut logic, &mut state);
1994 let mut tcx = frust::authoring::text::TextContext::new();
1995 root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1996 let update = root.semantics();
1997 let (_, node) = update
1998 .nodes
1999 .iter()
2000 .find(|(_, n)| n.role() == Role::Button)
2001 .expect("icon button contributes a Role::Button node");
2002 assert!(!node.supports_action(Action::Click));
2003 }
2004
2005 #[test]
2008 fn disabled_colors_are_identical_to_enabled_colors() {
2009 for variant in [
2012 IconButtonVariant::Standard,
2013 IconButtonVariant::Filled,
2014 IconButtonVariant::Tonal,
2015 IconButtonVariant::Outlined,
2016 ] {
2017 let enabled = resolve_colors(None, variant, false);
2018 let disabled = resolve_colors(None, variant, false); assert_eq!(enabled, disabled);
2020 }
2021 }
2022
2023 #[test]
2026 fn a_disabled_button_ignores_every_pointer_event() {
2027 let view = icon_button::<(), _>(leaf_any(20.0, 20.0), |_| {}).enabled(false);
2028 let mut widget = build(&view);
2029 let size = layout_with(&mut widget, 200.0);
2030 let mut state = ();
2031 dispatch(
2032 &mut widget,
2033 &mut state,
2034 size,
2035 &ev(PointerPhase::Down, 5.0, 5.0),
2036 );
2037 assert!(!widget.captured);
2038 assert!(!widget.state.pressed);
2039 }
2040
2041 #[test]
2042 fn a_release_outside_bounds_does_not_fire() {
2043 let presses = Rc::new(RefCell::new(0u32));
2044 let counted = presses.clone();
2045 let view = icon_button::<(), _>(leaf_any(20.0, 20.0), move |_| {
2046 *counted.borrow_mut() += 1;
2047 });
2048 let mut widget = build(&view);
2049 let size = layout_with(&mut widget, 200.0);
2050 let mut state = ();
2051 dispatch(
2052 &mut widget,
2053 &mut state,
2054 size,
2055 &ev(PointerPhase::Down, 5.0, 5.0),
2056 );
2057 dispatch(
2058 &mut widget,
2059 &mut state,
2060 size,
2061 &ev(PointerPhase::Up, 9999.0, 9999.0),
2062 );
2063 assert_eq!(*presses.borrow(), 0);
2064 assert!(!widget.captured);
2065 }
2066}