1use frust::Theme;
19use frust::authoring::text::{
20 FontWeight, LineHeight, TextContext, TextLayout, TextOverflow, TextStyle,
21};
22use frust::authoring::{
23 Action, BoxConstraints, CursorIcon, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx,
24 PaintScene, PointerPhase, Role, SemanticsCtx, Widget,
25};
26use kurbo::{Point, RoundedRect, Shape, Size};
27use peniko::{Brush, Color};
28
29use super::gradient;
30use super::motion::ContentPadding;
31use super::overflow::max_scroll;
32use super::{
33 ButtonShape, ButtonSize, ButtonVariant, ButtonWidget, IconAlignment, OverflowStrategy,
34};
35use crate::interaction::{
36 DISABLED_CONTAINER_OPACITY, DISABLED_CONTENT_OPACITY, HapticSignal, InteractionState,
37 MaterialHaptics,
38};
39use crate::press::presses;
40
41const MIN_WIDTH_FLOOR: f64 = 48.0;
46
47const OUTLINE_WIDTH: f64 = 1.0;
51
52const FOCUS_RING_GAP: f64 = 2.0;
55const FOCUS_RING_WIDTH: f64 = 2.0;
58
59pub(super) const PATH_TOLERANCE: f64 = 0.1;
64
65const SHAPING_INK: Color = Color::BLACK;
70
71#[derive(Clone, Copy, Debug, PartialEq)]
73pub(super) struct SizeMetrics {
74 pub(super) height: f64,
76 pub(super) h_padding: f64,
78 pub(super) icon_size: f64,
80 pub(super) icon_gap: f64,
82}
83
84type TypeToken = (f32, f32, f32, FontWeight);
87
88const LABEL_SMALL: TypeToken = (11.0, 16.0, 0.5, FontWeight::MEDIUM);
89const LABEL_MEDIUM: TypeToken = (12.0, 16.0, 0.5, FontWeight::MEDIUM);
90const LABEL_LARGE: TypeToken = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
91const TITLE_MEDIUM: TypeToken = (16.0, 24.0, 0.15, FontWeight::MEDIUM);
92const TITLE_LARGE: TypeToken = (22.0, 28.0, 0.0, FontWeight::REGULAR);
93
94impl ButtonSize {
95 pub(super) const fn metrics(self) -> SizeMetrics {
97 match self {
98 ButtonSize::Xs => SizeMetrics {
100 height: 32.0,
101 h_padding: 16.0,
102 icon_size: 20.0,
103 icon_gap: 8.0,
104 },
105 ButtonSize::Sm => SizeMetrics {
107 height: 40.0,
108 h_padding: 16.0,
109 icon_size: 20.0,
110 icon_gap: 8.0,
111 },
112 ButtonSize::Md => SizeMetrics {
114 height: 56.0,
115 h_padding: 24.0,
116 icon_size: 24.0,
117 icon_gap: 8.0,
118 },
119 ButtonSize::Lg => SizeMetrics {
121 height: 96.0,
122 h_padding: 48.0,
123 icon_size: 32.0,
124 icon_gap: 12.0,
125 },
126 ButtonSize::Xl => SizeMetrics {
128 height: 136.0,
129 h_padding: 64.0,
130 icon_size: 40.0,
131 icon_gap: 16.0,
132 },
133 }
134 }
135
136 pub(super) fn square_radius(self, theme: Option<&Theme>) -> f64 {
142 match theme {
143 Some(theme) => match self {
144 ButtonSize::Xs | ButtonSize::Sm => theme.shape.medium,
145 ButtonSize::Md => theme.shape.large,
146 ButtonSize::Lg | ButtonSize::Xl => theme.shape.extra_large,
147 },
148 None => match self {
149 ButtonSize::Xs | ButtonSize::Sm => 12.0,
150 ButtonSize::Md => 16.0,
151 ButtonSize::Lg | ButtonSize::Xl => 28.0,
152 },
153 }
154 }
155
156 pub(super) fn pressed_radius(self, theme: Option<&Theme>) -> f64 {
160 match theme {
161 Some(theme) => match self {
162 ButtonSize::Xs | ButtonSize::Sm => theme.shape.small,
163 ButtonSize::Md => theme.shape.medium,
164 ButtonSize::Lg | ButtonSize::Xl => theme.shape.large,
165 },
166 None => match self {
167 ButtonSize::Xs | ButtonSize::Sm => 8.0,
168 ButtonSize::Md => 12.0,
169 ButtonSize::Lg | ButtonSize::Xl => 16.0,
170 },
171 }
172 }
173
174 pub(super) const fn hovered_radius(self) -> f64 {
180 match self {
181 ButtonSize::Xs | ButtonSize::Sm => 10.0,
182 ButtonSize::Md => 14.0,
183 ButtonSize::Lg | ButtonSize::Xl => 22.0,
184 }
185 }
186
187 pub(super) fn label_style(self, theme: Option<&Theme>) -> TextStyle {
192 let mut style = match theme {
193 Some(theme) => match self {
194 ButtonSize::Xs => theme.type_scale.label_small.clone(),
195 ButtonSize::Sm => theme.type_scale.label_medium.clone(),
196 ButtonSize::Md => theme.type_scale.label_large.clone(),
197 ButtonSize::Lg => theme.type_scale.title_medium.clone(),
198 ButtonSize::Xl => theme.type_scale.title_large.clone(),
199 },
200 None => {
201 let (size, line_height, letter_spacing, weight) = match self {
202 ButtonSize::Xs => LABEL_SMALL,
203 ButtonSize::Sm => LABEL_MEDIUM,
204 ButtonSize::Md => LABEL_LARGE,
205 ButtonSize::Lg => TITLE_MEDIUM,
206 ButtonSize::Xl => TITLE_LARGE,
207 };
208 let mut style = TextStyle::new(size, SHAPING_INK);
209 style.line_height = LineHeight::Absolute(line_height);
210 style.letter_spacing = letter_spacing;
211 style.weight = weight;
212 style
213 }
214 };
215 style.color = SHAPING_INK;
216 style
217 }
218}
219
220const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
224const ON_PRIMARY: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
226const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
228const ON_SECONDARY_CONTAINER: Color = Color::from_rgb8(0x1D, 0x19, 0x2B);
230const SURFACE_CONTAINER_LOW: Color = Color::from_rgb8(0xF7, 0xF2, 0xFA);
232const OUTLINE: Color = Color::from_rgb8(0x79, 0x74, 0x7E);
234const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
236const FALLBACK_SHADOW_COLOR: Color = Color::new([0.0, 0.0, 0.0, 0.3]);
239
240#[derive(Clone, Copy, Debug, PartialEq)]
243struct Roles {
244 primary: Color,
245 on_primary: Color,
246 secondary_container: Color,
247 on_secondary_container: Color,
248 surface_container_low: Color,
249 outline: Color,
250 on_surface: Color,
251}
252
253impl Roles {
254 fn resolve(theme: Option<&Theme>) -> Self {
255 match theme {
256 Some(theme) => {
257 let s = theme.scheme();
258 Self {
259 primary: s.primary,
260 on_primary: s.on_primary,
261 secondary_container: s.secondary_container,
262 on_secondary_container: s.on_secondary_container,
263 surface_container_low: s.surface_container_low,
264 outline: s.outline,
265 on_surface: s.on_surface,
266 }
267 }
268 None => Self {
269 primary: PRIMARY,
270 on_primary: ON_PRIMARY,
271 secondary_container: SECONDARY_CONTAINER,
272 on_secondary_container: ON_SECONDARY_CONTAINER,
273 surface_container_low: SURFACE_CONTAINER_LOW,
274 outline: OUTLINE,
275 on_surface: ON_SURFACE,
276 },
277 }
278 }
279}
280
281#[derive(Clone, Copy, Debug, PartialEq)]
283pub(super) struct ButtonColors {
284 pub(super) container: Color,
286 pub(super) content: Color,
288 pub(super) outline: Option<Color>,
290}
291
292fn with_alpha(color: Color, alpha: f32) -> Color {
294 let c = color.components;
295 Color::new([c[0], c[1], c[2], alpha])
296}
297
298fn is_transparent(variant: ButtonVariant) -> bool {
301 matches!(variant, ButtonVariant::Outlined | ButtonVariant::Text)
302}
303
304pub(super) fn resolve_colors(
312 theme: Option<&Theme>,
313 variant: ButtonVariant,
314 enabled: bool,
315) -> ButtonColors {
316 let roles = Roles::resolve(theme);
317
318 if !enabled {
319 return ButtonColors {
320 container: if is_transparent(variant) {
321 Color::TRANSPARENT
322 } else {
323 with_alpha(roles.on_surface, DISABLED_CONTAINER_OPACITY)
324 },
325 content: with_alpha(roles.on_surface, DISABLED_CONTENT_OPACITY),
326 outline: (variant == ButtonVariant::Outlined)
327 .then(|| with_alpha(roles.on_surface, DISABLED_CONTAINER_OPACITY)),
328 };
329 }
330
331 ButtonColors {
332 container: match variant {
333 ButtonVariant::Filled => roles.primary,
334 ButtonVariant::Tonal => roles.secondary_container,
335 ButtonVariant::Elevated => roles.surface_container_low,
336 ButtonVariant::Outlined | ButtonVariant::Text => Color::TRANSPARENT,
337 },
338 content: match variant {
339 ButtonVariant::Filled => roles.on_primary,
340 ButtonVariant::Tonal => roles.on_secondary_container,
341 ButtonVariant::Elevated | ButtonVariant::Outlined | ButtonVariant::Text => {
342 roles.primary
343 }
344 },
345 outline: (variant == ButtonVariant::Outlined).then_some(roles.outline),
346 }
347}
348
349pub(super) fn elevation_dp(
354 variant: ButtonVariant,
355 enabled: bool,
356 pressed: bool,
357 hovered: bool,
358) -> f64 {
359 if !enabled {
360 return 0.0;
361 }
362 match variant {
363 ButtonVariant::Elevated => {
364 if pressed {
365 0.0
366 } else if hovered {
367 3.0
368 } else {
369 1.0
370 }
371 }
372 ButtonVariant::Filled | ButtonVariant::Tonal => {
373 if !pressed && hovered {
374 1.0
375 } else {
376 0.0
377 }
378 }
379 ButtonVariant::Outlined | ButtonVariant::Text => 0.0,
380 }
381}
382
383pub(super) fn resolve_shadow(theme: Option<&Theme>, dp: f64) -> Option<(f64, f64, Color)> {
392 if dp <= 0.0 {
393 return None;
394 }
395 match theme {
396 Some(theme) => {
397 let level = if dp >= theme.elevation.level2.dp {
398 theme.elevation.level2
399 } else {
400 theme.elevation.level1
401 };
402 let shadow = level.shadow(theme.brightness);
403 let color = with_alpha(theme.scheme().shadow, shadow.color_alpha);
404 Some((shadow.blur_std_dev, shadow.y_offset, color))
405 }
406 None => Some((dp, dp / 2.0 + 1.0, FALLBACK_SHADOW_COLOR)),
407 }
408}
409
410#[derive(Clone, Copy, Debug, PartialEq)]
417pub struct ButtonSurface {
418 pub origin: Point,
420 pub size: Size,
422 pub radius: f64,
424 pub container: Color,
426 pub content: Color,
428 pub outline: Option<Color>,
430 pub outline_width: f64,
432 pub enabled: bool,
434 pub state: InteractionState,
437}
438
439#[derive(Clone, Copy, Debug, PartialEq, Eq)]
441pub enum DecorationOutcome {
442 Painted,
444 Skipped,
446}
447
448pub trait ButtonDecoration {
463 fn paint_fill(
466 &self,
467 _surface: &ButtonSurface,
468 _scene: &mut dyn PaintScene,
469 ) -> DecorationOutcome {
470 DecorationOutcome::Skipped
471 }
472
473 fn paint_overlay(
478 &self,
479 _surface: &ButtonSurface,
480 _scene: &mut dyn PaintScene,
481 ) -> DecorationOutcome {
482 DecorationOutcome::Skipped
483 }
484
485 fn paint_outline(
488 &self,
489 _surface: &ButtonSurface,
490 _scene: &mut dyn PaintScene,
491 ) -> DecorationOutcome {
492 DecorationOutcome::Skipped
493 }
494
495 fn foreground_brush(&self, _surface: &ButtonSurface) -> Option<Brush> {
532 None
533 }
534}
535
536#[derive(Clone, Copy, Debug, PartialEq)]
539pub struct ContentMetrics {
540 pub natural_label_width: f64,
542 pub available_label_width: f64,
545 pub painted_label_width: f64,
548 pub content_width: f64,
550}
551
552impl ContentMetrics {
553 pub(super) fn empty() -> Self {
555 Self {
556 natural_label_width: 0.0,
557 available_label_width: f64::INFINITY,
558 painted_label_width: 0.0,
559 content_width: 0.0,
560 }
561 }
562
563 pub fn overflows(&self) -> bool {
566 self.natural_label_width > self.available_label_width
567 }
568}
569
570pub trait OverflowObserver {
578 fn measured(&self, metrics: ContentMetrics);
580
581 fn overflow_pressed(&self) {}
589}
590
591pub(super) struct LabelRun {
600 content: String,
601 layout: Option<TextLayout>,
602 shaped_for: Option<(TextStyle, Option<f64>)>,
604 natural: Option<(TextStyle, f64)>,
606}
607
608impl LabelRun {
609 pub(super) fn new(content: String) -> Self {
611 Self {
612 content,
613 layout: None,
614 shaped_for: None,
615 natural: None,
616 }
617 }
618
619 pub(super) fn set_content(&mut self, content: &str) {
622 if self.content != content {
623 self.content = content.to_string();
624 self.layout = None;
625 self.shaped_for = None;
626 self.natural = None;
627 }
628 }
629
630 pub(super) fn content(&self) -> &str {
632 &self.content
633 }
634
635 pub(super) fn natural_width(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> f64 {
637 if let Some((cached_style, width)) = &self.natural
638 && cached_style == style
639 {
640 return *width;
641 }
642 let text_ctx = ctx.text_context::<TextContext>();
643 let width = text_ctx.layout(&self.content, style, None).size().width;
644 self.natural = Some((style.clone(), width));
645 width
646 }
647
648 pub(super) fn shape(
657 &mut self,
658 ctx: &mut LayoutCtx,
659 style: &TextStyle,
660 max_width: Option<f64>,
661 ) -> Size {
662 let key = (style.clone(), max_width);
663 if let Some(cached) = &self.layout
664 && self.shaped_for.as_ref() == Some(&key)
665 {
666 return cached.size();
667 }
668 let text_ctx = ctx.text_context::<TextContext>();
669 let laid = match max_width {
670 Some(width) => text_ctx.layout_bounded(
671 &self.content,
672 style,
673 Some(width as f32),
674 Some(1),
675 TextOverflow::Ellipsis,
676 ),
677 None => text_ctx.layout(&self.content, style, None),
678 };
679 let size = laid.size();
680 self.layout = Some(laid);
681 self.shaped_for = Some(key);
682 size
683 }
684
685 pub(super) fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
688 let Some(layout) = &self.layout else {
689 return;
690 };
691 for mut run in layout.to_scene_runs(origin) {
692 run.brush = Brush::Solid(color);
693 scene.draw_glyph_run(run);
694 }
695 }
696
697 pub(super) fn paint_brush(&self, origin: Point, brush: &Brush, scene: &mut dyn PaintScene) {
703 let Some(layout) = &self.layout else {
704 return;
705 };
706 for mut run in layout.to_scene_runs(origin) {
707 run.brush = brush.clone();
708 scene.draw_glyph_run(run);
709 }
710 }
711}
712
713fn inside(pos: Point, size: Size) -> bool {
717 pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
718}
719
720pub(super) fn inset_stroke_rect(size: Size, radius: f64, width: f64) -> RoundedRect {
727 let half = width / 2.0;
728 RoundedRect::new(
729 half,
730 half,
731 size.width - half,
732 size.height - half,
733 (radius - half).max(0.0),
734 )
735}
736
737fn focus_ring_rect(size: Size, radius: f64) -> RoundedRect {
745 let outset = FOCUS_RING_GAP + FOCUS_RING_WIDTH;
746 let half = FOCUS_RING_WIDTH / 2.0;
747 RoundedRect::new(
748 -outset + half,
749 -outset + half,
750 size.width + outset - half,
751 size.height + outset - half,
752 (radius + outset - half).max(0.0),
753 )
754}
755
756fn paint_focus_ring(
760 origin: Point,
761 size: Size,
762 radius: f64,
763 color: Color,
764 scene: &mut dyn PaintScene,
765) {
766 let path = focus_ring_rect(size, radius).to_path(PATH_TOLERANCE);
767 scene.stroke_path(origin, &path, FOCUS_RING_WIDTH, &Brush::Solid(color));
768}
769
770impl ButtonWidget {
771 fn target_radius(&self, theme: Option<&Theme>, height: f64) -> f64 {
780 let resting = self.corner_radius.unwrap_or(match self.shape {
781 ButtonShape::Round => height / 2.0,
782 ButtonShape::Square => self.size.square_radius(theme),
783 });
784 if !self.enabled {
785 return resting;
786 }
787 if self.state.pressed {
788 return self
789 .pressed_radius
790 .or(self.corner_radius)
791 .unwrap_or_else(|| self.size.pressed_radius(theme));
792 }
793 if self.state.hovered {
794 return self
795 .corner_radius
796 .unwrap_or_else(|| self.size.hovered_radius());
797 }
798 resting
799 }
800
801 pub fn content_metrics(&self) -> ContentMetrics {
805 self.metrics
806 }
807}
808
809impl Widget for ButtonWidget {
810 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
811 let metrics = self.size.metrics();
812 let (label_style, resting_radius) = {
815 let theme = Theme::from_layout_ctx(ctx);
816 (
817 self.size.label_style(theme),
818 self.target_radius(theme, metrics.height),
819 )
820 };
821 let padding = ContentPadding::symmetric(metrics.h_padding);
838 if !self.motion.is_seeded() {
839 self.motion.snap_to(resting_radius, padding);
841 }
842
843 let icon_size = self.icon.as_mut().map(|pod| {
844 pod.layout_child(
845 ctx,
846 &BoxConstraints::tight(Size::new(metrics.icon_size, metrics.icon_size)),
847 )
848 });
849 let icon_width = icon_size.map_or(0.0, |s| s.width);
850 let gap = if icon_size.is_some() {
853 metrics.icon_gap
854 } else {
855 0.0
856 };
857
858 let available_label_width = if bc.max().width.is_finite() {
859 (bc.max().width - padding.left - padding.right - icon_width - gap).max(0.0)
860 } else {
861 f64::INFINITY
862 };
863
864 let natural_label_width = self.label.natural_width(ctx, &label_style);
865 let overflows = natural_label_width > available_label_width;
866 let fit_to = if overflows && self.overflow_strategy != OverflowStrategy::Scroll {
873 Some(available_label_width).filter(|w| w.is_finite())
874 } else {
875 None
876 };
877 let label_size = self.label.shape(ctx, &label_style, fit_to);
878
879 let content_width = icon_width + gap + label_size.width;
880 let width = (content_width + padding.left + padding.right).max(MIN_WIDTH_FLOOR);
881 let size = bc.constrain(Size::new(width, metrics.height));
882
883 let content_x = ((size.width - content_width) / 2.0).max(0.0);
887 let (icon_x, label_x) = match self.icon_alignment {
888 IconAlignment::Start => (content_x, content_x + icon_width + gap),
889 IconAlignment::End => (content_x + label_size.width + gap, content_x),
890 };
891 if let Some(pod) = self.icon.as_mut() {
892 let icon_height = icon_size.map_or(0.0, |s| s.height);
893 pod.set_origin(Point::new(icon_x, (size.height - icon_height) / 2.0));
894 }
895 self.label_origin = Point::new(label_x, (size.height - label_size.height) / 2.0);
896
897 self.metrics = ContentMetrics {
898 natural_label_width,
899 available_label_width,
900 painted_label_width: label_size.width,
901 content_width,
902 };
903 if let Some(observer) = &self.overflow {
904 observer.measured(self.metrics);
905 }
906 self.scroll_offset = self.scroll_offset.clamp(0.0, max_scroll(self.metrics));
910 size
911 }
912
913 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
914 let origin = ctx.origin();
915 let size = ctx.size();
916
917 self.state.set_hovered(self.enabled && ctx.is_hovered());
922 self.state.set_focused(self.enabled && ctx.has_focus());
923
924 let (colors, shadow, radius_target, focus_ring_color) = {
926 let theme = Theme::from_paint_ctx(ctx);
927 let dp = elevation_dp(
928 self.variant,
929 self.enabled,
930 self.state.pressed,
931 self.state.hovered,
932 );
933 (
934 resolve_colors(theme, self.variant, self.enabled),
935 resolve_shadow(theme, dp),
936 self.target_radius(theme, size.height),
937 theme.map_or(PRIMARY, |theme| theme.scheme().primary),
939 )
940 };
941
942 let padding = ContentPadding::symmetric(self.size.metrics().h_padding);
949 if self.motion.retarget(radius_target, padding) {
950 ctx.request_frame();
951 }
952 if self.motion.advance(ctx.frame_time()) {
953 ctx.request_frame();
954 }
955 let radius = self.motion.radius();
956
957 let surface = ButtonSurface {
958 origin,
959 size,
960 radius,
961 container: colors.container,
962 content: colors.content,
963 outline: colors.outline,
964 outline_width: OUTLINE_WIDTH,
965 enabled: self.enabled,
966 state: self.state,
967 };
968
969 if let Some((blur, y_offset, shadow_color)) = shadow {
970 scene.draw_shadow(
971 Point::new(origin.x, origin.y + y_offset),
972 size,
973 radius,
974 blur,
975 shadow_color,
976 );
977 }
978
979 let fill = self
980 .decoration
981 .as_ref()
982 .map_or(DecorationOutcome::Skipped, |d| {
983 d.paint_fill(&surface, scene)
984 });
985 if fill == DecorationOutcome::Skipped && colors.container.components[3] > 0.0 {
986 scene.fill_rounded_rect(origin, size, radius, colors.container);
987 }
988
989 let overlay = self
990 .decoration
991 .as_ref()
992 .map_or(DecorationOutcome::Skipped, |d| {
993 d.paint_overlay(&surface, scene)
994 });
995 if overlay == DecorationOutcome::Skipped && self.enabled {
1003 let opacity = self.state.resolve_opacity();
1004 if opacity > 0.0 {
1005 scene.fill_rounded_rect(origin, size, radius, with_alpha(colors.content, opacity));
1006 }
1007 }
1008
1009 let outlined = self
1010 .decoration
1011 .as_ref()
1012 .map_or(DecorationOutcome::Skipped, |d| {
1013 d.paint_outline(&surface, scene)
1014 });
1015 if outlined == DecorationOutcome::Skipped
1016 && let Some(outline) = colors.outline
1017 {
1018 let path = inset_stroke_rect(size, radius, OUTLINE_WIDTH).to_path(PATH_TOLERANCE);
1019 scene.stroke_path(origin, &path, OUTLINE_WIDTH, &Brush::Solid(outline));
1020 }
1021
1022 if let Some(pod) = self.icon.as_mut() {
1023 pod.paint_child(ctx, scene);
1024 }
1025 let panning =
1029 self.overflow_strategy == OverflowStrategy::Scroll && self.metrics.overflows();
1030 let label_origin = Point::new(
1031 origin.x + self.label_origin.x - if panning { self.scroll_offset } else { 0.0 },
1032 origin.y + self.label_origin.y,
1033 );
1034 if panning {
1035 scene.push_clip(origin, size);
1036 }
1037 match self
1038 .decoration
1039 .as_ref()
1040 .and_then(|d| d.foreground_brush(&surface))
1041 {
1042 Some(brush) => {
1043 let local_brush = gradient::brush_relative_to_run_origin(&brush, label_origin);
1053 self.label.paint_brush(label_origin, &local_brush, scene);
1054 }
1055 None => self.label.paint(label_origin, colors.content, scene),
1056 }
1057 if panning {
1058 scene.pop_clip();
1059 }
1060
1061 if self.state.focused {
1064 paint_focus_ring(origin, size, radius, focus_ring_color, scene);
1065 }
1066 }
1067
1068 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1069 if !self.enabled {
1070 return EventResult::Ignored;
1074 }
1075 let InputEvent::Pointer(p) = event else {
1076 return EventResult::Ignored;
1077 };
1078 match p.phase {
1079 PointerPhase::Down => {
1080 if !presses(p) {
1081 return EventResult::Ignored;
1082 }
1083 self.state.set_pressed(true);
1084 self.captured = true;
1085 self.drag_last_x = Some(p.position.x);
1088 ctx.capture_pointer();
1089 ctx.request_redraw();
1090 EventResult::Handled
1091 }
1092 PointerPhase::Move => {
1093 let over = inside(p.position, ctx.size());
1094 if !self.captured {
1095 if over {
1098 ctx.claim_hover();
1099 ctx.set_cursor(CursorIcon::Pointer);
1102 }
1103 if self.state.set_hovered(over) {
1104 ctx.request_redraw();
1105 }
1106 return EventResult::Ignored;
1107 }
1108 ctx.set_cursor(CursorIcon::Pointer);
1109 if self.overflow_strategy == OverflowStrategy::Scroll && self.metrics.overflows() {
1115 if let Some(last_x) = self.drag_last_x {
1116 let dx = p.position.x - last_x;
1117 self.scroll_offset =
1118 (self.scroll_offset - dx).clamp(0.0, max_scroll(self.metrics));
1119 ctx.request_redraw();
1120 }
1121 self.drag_last_x = Some(p.position.x);
1122 }
1123 if self.state.set_pressed(over) {
1124 ctx.request_redraw();
1125 }
1126 EventResult::Handled
1127 }
1128 PointerPhase::Up => {
1129 if !self.captured {
1130 return EventResult::Ignored;
1131 }
1132 if inside(p.position, ctx.size()) {
1133 let deferred = self.overflow_strategy == OverflowStrategy::BottomSheet
1141 && self.metrics.overflows()
1142 && self.overflow.is_some();
1143 if deferred {
1144 if let Some(observer) = &self.overflow {
1145 observer.overflow_pressed();
1146 }
1147 } else {
1148 if self.haptic != HapticSignal::None {
1154 MaterialHaptics::fire(self.haptic);
1155 }
1156 (self.on_press)(ctx);
1157 }
1158 }
1159 self.state.set_pressed(false);
1160 self.captured = false;
1161 self.drag_last_x = None;
1162 ctx.request_redraw();
1163 EventResult::Handled
1164 }
1165 PointerPhase::Cancel => {
1166 if !self.captured {
1167 return EventResult::Ignored;
1168 }
1169 self.state.set_pressed(false);
1170 self.captured = false;
1171 self.drag_last_x = None;
1172 ctx.request_redraw();
1173 EventResult::Handled
1174 }
1175 }
1176 }
1177
1178 fn semantics(&self, ctx: &mut SemanticsCtx) {
1179 ctx.push_node(Role::Button, |node| {
1183 node.set_label(self.label.content());
1184 if self.enabled {
1185 node.add_action(Action::Click);
1186 } else {
1187 node.set_disabled();
1188 }
1189 });
1190 }
1191
1192 frust::authoring::visit_children!(icon);
1193}
1194
1195#[cfg(test)]
1196mod tests {
1197 use super::*;
1198 use crate::button::{ButtonView, button, button_with_icon};
1199 use frust::FrameTime;
1200 use frust::authoring::scene::GlyphRun;
1201 use frust::authoring::{BuildCtx, PointerButton, PointerEvent, View};
1202 use frust_widgets::test_support::leaf_any;
1203 use std::any::Any;
1204 use std::cell::RefCell;
1205 use std::rc::Rc;
1206
1207 #[derive(Default)]
1212 struct Recorder {
1213 rrects: Vec<(Point, Size, f64, Color)>,
1214 strokes: Vec<(Point, f64, Color)>,
1215 shadows: Vec<(Point, Size, f64, f64, Color)>,
1216 runs: Vec<(Point, Color)>,
1217 }
1218
1219 impl PaintScene for Recorder {
1220 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1221 fn draw_text(&mut self, _o: Point, _t: &str) {}
1222 fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
1223 self.rrects.push((o, s, radius, color));
1224 }
1225 fn draw_shadow(&mut self, o: Point, s: Size, radius: f64, std_dev: f64, color: Color) {
1226 self.shadows.push((o, s, radius, std_dev, color));
1227 }
1228 fn stroke_path(&mut self, o: Point, _path: &kurbo::BezPath, width: f64, brush: &Brush) {
1229 self.strokes.push((o, width, solid(brush)));
1230 }
1231 fn draw_glyph_run(&mut self, run: GlyphRun) {
1232 let t = run.transform.translation();
1233 self.runs.push((Point::new(t.x, t.y), solid(&run.brush)));
1234 }
1235 }
1236
1237 fn solid(brush: &Brush) -> Color {
1238 match brush {
1239 Brush::Solid(c) => *c,
1240 _ => Color::TRANSPARENT,
1241 }
1242 }
1243
1244 fn ft_secs(s: f64) -> FrameTime {
1245 FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
1246 }
1247
1248 fn build<S: 'static>(view: &ButtonView<S>) -> ButtonWidget {
1252 let mut counter = 0u64;
1253 View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1254 }
1255
1256 fn layout_with(widget: &mut ButtonWidget, max_width: f64, theme: Option<&Theme>) -> Size {
1259 let mut tcx = TextContext::new();
1260 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1261 if let Some(theme) = theme {
1262 lctx = lctx.with_theme(theme as &dyn Any);
1263 }
1264 widget.layout(
1265 &mut lctx,
1266 &BoxConstraints::loose(Size::new(max_width, 500.0)),
1267 )
1268 }
1269
1270 fn paint_at(widget: &mut ButtonWidget, size: Size, theme: Option<&Theme>) -> Recorder {
1271 let mut rec = Recorder::default();
1272 let mut pctx = PaintCtx::new(Point::ZERO, size);
1273 if let Some(theme) = theme {
1274 pctx = pctx.with_theme(theme as &dyn Any);
1275 }
1276 widget.paint(&mut pctx, &mut rec);
1277 rec
1278 }
1279
1280 fn settle_morph(widget: &mut ButtonWidget) {
1285 let mut t = 0.0;
1286 for _ in 0..600 {
1287 t += 1.0 / 60.0;
1288 if !widget.motion.advance(ft_secs(t)) {
1289 return;
1290 }
1291 }
1292 panic!("the press morph never settled");
1293 }
1294
1295 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1296 InputEvent::Pointer(PointerEvent {
1297 phase,
1298 position: Point::new(x, y),
1299 button: PointerButton::Primary,
1300 })
1301 }
1302
1303 fn dispatch(widget: &mut ButtonWidget, state: &mut u32, size: Size, event: &InputEvent) {
1304 let state_any: &mut dyn Any = state;
1305 let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
1306 widget.event(&mut ctx, event);
1307 }
1308
1309 const ALL_SIZES: [ButtonSize; 5] = [
1310 ButtonSize::Xs,
1311 ButtonSize::Sm,
1312 ButtonSize::Md,
1313 ButtonSize::Lg,
1314 ButtonSize::Xl,
1315 ];
1316
1317 const ALL_VARIANTS: [ButtonVariant; 5] = [
1318 ButtonVariant::Filled,
1319 ButtonVariant::Outlined,
1320 ButtonVariant::Tonal,
1321 ButtonVariant::Elevated,
1322 ButtonVariant::Text,
1323 ];
1324
1325 #[test]
1328 fn measurements_match_the_reference_table() {
1329 let expected: [(ButtonSize, f64, f64, f64, f64); 5] = [
1332 (ButtonSize::Xs, 32.0, 16.0, 20.0, 8.0),
1333 (ButtonSize::Sm, 40.0, 16.0, 20.0, 8.0),
1334 (ButtonSize::Md, 56.0, 24.0, 24.0, 8.0),
1335 (ButtonSize::Lg, 96.0, 48.0, 32.0, 12.0),
1336 (ButtonSize::Xl, 136.0, 64.0, 40.0, 16.0),
1337 ];
1338 for (size, height, h_padding, icon_size, icon_gap) in expected {
1339 assert_eq!(
1340 size.metrics(),
1341 SizeMetrics {
1342 height,
1343 h_padding,
1344 icon_size,
1345 icon_gap
1346 },
1347 "{size:?}"
1348 );
1349 }
1350 }
1351
1352 #[test]
1353 fn radius_tables_match_the_reference_themed_and_unthemed() {
1354 let theme = crate::baseline();
1356 let expected: [(ButtonSize, f64, f64, f64); 5] = [
1357 (ButtonSize::Xs, 12.0, 8.0, 10.0),
1358 (ButtonSize::Sm, 12.0, 8.0, 10.0),
1359 (ButtonSize::Md, 16.0, 12.0, 14.0),
1360 (ButtonSize::Lg, 28.0, 16.0, 22.0),
1361 (ButtonSize::Xl, 28.0, 16.0, 22.0),
1362 ];
1363 for (size, square, pressed, hovered) in expected {
1364 assert_eq!(size.square_radius(None), square, "{size:?} square unthemed");
1365 assert_eq!(
1366 size.square_radius(Some(&theme)),
1367 square,
1368 "{size:?} square themed"
1369 );
1370 assert_eq!(
1371 size.pressed_radius(None),
1372 pressed,
1373 "{size:?} pressed unthemed"
1374 );
1375 assert_eq!(
1376 size.pressed_radius(Some(&theme)),
1377 pressed,
1378 "{size:?} pressed themed"
1379 );
1380 assert_eq!(size.hovered_radius(), hovered, "{size:?} hovered");
1381 }
1382 }
1383
1384 #[test]
1385 fn every_hovered_radius_is_the_midpoint_of_its_square_and_pressed_radii() {
1386 for size in ALL_SIZES {
1388 assert_eq!(
1389 size.hovered_radius(),
1390 (size.square_radius(None) + size.pressed_radius(None)) / 2.0,
1391 "{size:?}"
1392 );
1393 }
1394 }
1395
1396 #[test]
1397 fn label_roles_match_the_reference_switch() {
1398 let theme = crate::baseline();
1401 let expected = [
1402 (ButtonSize::Xs, theme.type_scale.label_small.clone()),
1403 (ButtonSize::Sm, theme.type_scale.label_medium.clone()),
1404 (ButtonSize::Md, theme.type_scale.label_large.clone()),
1405 (ButtonSize::Lg, theme.type_scale.title_medium.clone()),
1406 (ButtonSize::Xl, theme.type_scale.title_large.clone()),
1407 ];
1408 for (size, role) in expected {
1409 let style = size.label_style(Some(&theme));
1410 assert_eq!(style.size, role.size, "{size:?} size");
1411 assert_eq!(style.weight, role.weight, "{size:?} weight");
1412 assert_eq!(style.line_height, role.line_height, "{size:?} line height");
1413 assert_eq!(style.color, SHAPING_INK, "{size:?} shapes in the fixed ink");
1414 }
1415 }
1416
1417 #[test]
1418 fn unthemed_label_roles_match_the_m3_type_tokens() {
1419 let expected: [(ButtonSize, TypeToken); 5] = [
1420 (ButtonSize::Xs, LABEL_SMALL),
1421 (ButtonSize::Sm, LABEL_MEDIUM),
1422 (ButtonSize::Md, LABEL_LARGE),
1423 (ButtonSize::Lg, TITLE_MEDIUM),
1424 (ButtonSize::Xl, TITLE_LARGE),
1425 ];
1426 for (size, (pt, line_height, letter_spacing, weight)) in expected {
1427 let style = size.label_style(None);
1428 assert_eq!(style.size, pt, "{size:?}");
1429 assert_eq!(style.line_height, LineHeight::Absolute(line_height));
1430 assert_eq!(style.letter_spacing, letter_spacing);
1431 assert_eq!(style.weight, weight);
1432 }
1433 }
1434
1435 #[test]
1438 fn enabled_colors_match_the_reference_container_and_foreground_switches() {
1439 let theme = crate::baseline();
1441 let s = theme.scheme();
1442 let expected: [(ButtonVariant, Color, Color, Option<Color>); 5] = [
1443 (ButtonVariant::Filled, s.primary, s.on_primary, None),
1444 (
1445 ButtonVariant::Outlined,
1446 Color::TRANSPARENT,
1447 s.primary,
1448 Some(s.outline),
1449 ),
1450 (
1451 ButtonVariant::Tonal,
1452 s.secondary_container,
1453 s.on_secondary_container,
1454 None,
1455 ),
1456 (
1457 ButtonVariant::Elevated,
1458 s.surface_container_low,
1459 s.primary,
1460 None,
1461 ),
1462 (ButtonVariant::Text, Color::TRANSPARENT, s.primary, None),
1463 ];
1464 for (variant, container, content, outline) in expected {
1465 assert_eq!(
1466 resolve_colors(Some(&theme), variant, true),
1467 ButtonColors {
1468 container,
1469 content,
1470 outline
1471 },
1472 "{variant:?}"
1473 );
1474 }
1475 }
1476
1477 #[test]
1478 fn unthemed_colors_fall_back_to_the_baseline_light_roles() {
1479 let expected: [(ButtonVariant, Color, Color, Option<Color>); 5] = [
1480 (ButtonVariant::Filled, PRIMARY, ON_PRIMARY, None),
1481 (
1482 ButtonVariant::Outlined,
1483 Color::TRANSPARENT,
1484 PRIMARY,
1485 Some(OUTLINE),
1486 ),
1487 (
1488 ButtonVariant::Tonal,
1489 SECONDARY_CONTAINER,
1490 ON_SECONDARY_CONTAINER,
1491 None,
1492 ),
1493 (
1494 ButtonVariant::Elevated,
1495 SURFACE_CONTAINER_LOW,
1496 PRIMARY,
1497 None,
1498 ),
1499 (ButtonVariant::Text, Color::TRANSPARENT, PRIMARY, None),
1500 ];
1501 for (variant, container, content, outline) in expected {
1502 assert_eq!(
1503 resolve_colors(None, variant, true),
1504 ButtonColors {
1505 container,
1506 content,
1507 outline
1508 },
1509 "{variant:?}"
1510 );
1511 }
1512 }
1513
1514 #[test]
1515 fn disabled_colors_are_the_38_and_12_percent_on_surface_roles() {
1516 assert_eq!(DISABLED_CONTENT_OPACITY, 0.38);
1519 assert_eq!(DISABLED_CONTAINER_OPACITY, 0.12);
1520 let theme = crate::baseline();
1521 let on_surface = theme.scheme().on_surface;
1522 for variant in ALL_VARIANTS {
1523 let colors = resolve_colors(Some(&theme), variant, false);
1524 assert_eq!(
1525 colors.content,
1526 with_alpha(on_surface, 0.38),
1527 "{variant:?} content"
1528 );
1529 if is_transparent(variant) {
1530 assert_eq!(
1531 colors.container,
1532 Color::TRANSPARENT,
1533 "{variant:?} stays transparent"
1534 );
1535 } else {
1536 assert_eq!(
1537 colors.container,
1538 with_alpha(on_surface, 0.12),
1539 "{variant:?} container"
1540 );
1541 }
1542 if variant == ButtonVariant::Outlined {
1543 assert_eq!(colors.outline, Some(with_alpha(on_surface, 0.12)));
1544 } else {
1545 assert_eq!(colors.outline, None, "{variant:?} draws no outline");
1546 }
1547 }
1548 }
1549
1550 #[test]
1551 fn elevation_matches_the_reference_state_table() {
1552 for variant in ALL_VARIANTS {
1554 for pressed in [false, true] {
1555 for hovered in [false, true] {
1556 let expected = match variant {
1557 ButtonVariant::Elevated => {
1558 if pressed {
1559 0.0
1560 } else if hovered {
1561 3.0
1562 } else {
1563 1.0
1564 }
1565 }
1566 ButtonVariant::Filled | ButtonVariant::Tonal => {
1567 if !pressed && hovered {
1568 1.0
1569 } else {
1570 0.0
1571 }
1572 }
1573 ButtonVariant::Outlined | ButtonVariant::Text => 0.0,
1574 };
1575 assert_eq!(
1576 elevation_dp(variant, true, pressed, hovered),
1577 expected,
1578 "{variant:?} pressed={pressed} hovered={hovered}"
1579 );
1580 assert_eq!(
1581 elevation_dp(variant, false, pressed, hovered),
1582 0.0,
1583 "{variant:?} disabled is always flat"
1584 );
1585 }
1586 }
1587 }
1588 }
1589
1590 #[test]
1591 fn shadows_resolve_onto_m3_elevation_levels_one_and_two() {
1592 let theme = crate::baseline();
1593 assert_eq!(
1594 resolve_shadow(Some(&theme), 0.0),
1595 None,
1596 "flat casts nothing"
1597 );
1598 let (blur, y, _) = resolve_shadow(Some(&theme), 1.0).expect("1dp casts a shadow");
1599 let level1 = theme.elevation.level1.shadow(theme.brightness);
1600 assert_eq!((blur, y), (level1.blur_std_dev, level1.y_offset));
1601 let (blur, y, _) = resolve_shadow(Some(&theme), 3.0).expect("3dp casts a shadow");
1602 let level2 = theme.elevation.level2.shadow(theme.brightness);
1603 assert_eq!((blur, y), (level2.blur_std_dev, level2.y_offset));
1604 assert_eq!(
1606 resolve_shadow(None, 3.0),
1607 Some((3.0, 2.5, FALLBACK_SHADOW_COLOR))
1608 );
1609 }
1610
1611 #[test]
1614 fn every_size_lays_out_at_its_token_height() {
1615 for size in ALL_SIZES {
1616 let mut widget = build(&button::<u32, _>("Save", |_| {}).size(size));
1617 let laid = layout_with(&mut widget, 500.0, None);
1618 assert_eq!(laid.height, size.metrics().height, "{size:?}");
1619 }
1620 }
1621
1622 #[test]
1623 fn width_is_the_content_plus_the_size_padding_floored_at_48() {
1624 for size in ALL_SIZES {
1625 let mut widget = build(&button::<u32, _>("Save", |_| {}).size(size));
1626 let laid = layout_with(&mut widget, 500.0, None);
1627 let metrics = widget.content_metrics();
1628 let expected =
1629 (metrics.content_width + size.metrics().h_padding * 2.0).max(MIN_WIDTH_FLOOR);
1630 assert_eq!(laid.width, expected, "{size:?}");
1631 }
1632 }
1633
1634 #[test]
1635 fn an_empty_label_still_respects_the_min_width_floor() {
1636 let mut widget = build(&button::<u32, _>("", |_| {}).size(ButtonSize::Xs));
1638 let laid = layout_with(&mut widget, 500.0, None);
1639 assert_eq!(laid.width, MIN_WIDTH_FLOOR);
1640 }
1641
1642 #[test]
1643 fn the_icon_constructor_lays_the_icon_out_at_the_size_icon_and_gap() {
1644 for size in ALL_SIZES {
1647 let metrics = size.metrics();
1648 let view = button_with_icon::<(), _>(leaf_any(64.0, 64.0), "Save", |_| {}).size(size);
1649 let mut widget = build(&view);
1650 let laid = layout_with(&mut widget, 500.0, None);
1651
1652 let icon = widget.icon.as_ref().expect("icon pod");
1653 assert_eq!(
1654 icon.size(),
1655 Size::new(metrics.icon_size, metrics.icon_size),
1656 "{size:?} icon is tight-constrained to the token size"
1657 );
1658 assert_eq!(
1659 icon.origin().y,
1660 (laid.height - metrics.icon_size) / 2.0,
1661 "{size:?} icon is vertically centered"
1662 );
1663 assert_eq!(
1664 widget.label_origin.x - (icon.origin().x + metrics.icon_size),
1665 metrics.icon_gap,
1666 "{size:?} label starts one icon gap past the icon"
1667 );
1668 let m = widget.content_metrics();
1669 assert_eq!(
1670 m.content_width,
1671 metrics.icon_size + metrics.icon_gap + m.painted_label_width,
1672 "{size:?} content is icon + gap + label"
1673 );
1674 }
1675 }
1676
1677 #[test]
1678 fn end_alignment_moves_the_icon_to_the_trailing_side() {
1679 let view = button_with_icon::<(), _>(leaf_any(20.0, 20.0), "Save", |_| {})
1680 .icon_alignment(IconAlignment::End);
1681 let mut widget = build(&view);
1682 layout_with(&mut widget, 500.0, None);
1683 let icon = widget.icon.as_ref().expect("icon pod");
1684 let metrics = ButtonSize::Sm.metrics();
1685 assert!(
1686 icon.origin().x > widget.label_origin.x,
1687 "the icon trails the label"
1688 );
1689 assert_eq!(
1690 icon.origin().x
1691 - (widget.label_origin.x + widget.content_metrics().painted_label_width),
1692 metrics.icon_gap,
1693 "still separated by exactly one icon gap"
1694 );
1695 }
1696
1697 #[test]
1698 fn a_button_with_no_icon_adds_no_gap() {
1699 let mut widget = build(&button::<u32, _>("Save", |_| {}));
1700 layout_with(&mut widget, 500.0, None);
1701 let m = widget.content_metrics();
1702 assert_eq!(m.content_width, m.painted_label_width);
1703 }
1704
1705 #[derive(Default)]
1708 struct RecordingObserver {
1709 seen: RefCell<Vec<ContentMetrics>>,
1710 }
1711
1712 impl OverflowObserver for RecordingObserver {
1713 fn measured(&self, metrics: ContentMetrics) {
1714 self.seen.borrow_mut().push(metrics);
1715 }
1716 }
1717
1718 #[test]
1719 fn the_overflow_seam_fires_every_layout_with_what_it_measured() {
1720 let observer = Rc::new(RecordingObserver::default());
1721 let view = button::<u32, _>("A comfortably long button label", |_| {})
1722 .overflow_observer(observer.clone());
1723 let mut widget = build(&view);
1724 layout_with(&mut widget, 500.0, None);
1725
1726 let seen = observer.seen.borrow().clone();
1727 assert_eq!(seen.len(), 1, "one measurement per layout pass");
1728 let metrics = seen[0];
1729 assert!(metrics.natural_label_width > 0.0);
1730 assert!(!metrics.overflows(), "500px is plenty of room");
1731 assert_eq!(
1732 metrics,
1733 widget.content_metrics(),
1734 "the observer sees exactly what the widget kept"
1735 );
1736 }
1737
1738 #[test]
1739 fn a_label_wider_than_the_room_left_reports_an_overflow_and_is_fitted() {
1740 let observer = Rc::new(RecordingObserver::default());
1741 let view = button::<u32, _>(
1742 "A label far too long to ever fit inside a narrow button",
1743 |_| {},
1744 )
1745 .overflow_observer(observer.clone());
1746 let mut widget = build(&view);
1747 let laid = layout_with(&mut widget, 120.0, None);
1748
1749 let metrics = widget.content_metrics();
1750 assert!(
1751 metrics.overflows(),
1752 "the natural width exceeds what is available"
1753 );
1754 assert!(
1755 metrics.painted_label_width <= metrics.available_label_width + 0.5,
1756 "the painted label was fitted to the available width"
1757 );
1758 assert!(laid.width <= 120.0, "and the button honors its constraint");
1759 assert_eq!(observer.seen.borrow().len(), 1);
1760 }
1761
1762 #[test]
1763 fn an_unbounded_constraint_never_reports_an_overflow() {
1764 let mut widget = build(&button::<u32, _>("Save", |_| {}));
1765 let mut tcx = TextContext::new();
1766 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1767 widget.layout(
1768 &mut lctx,
1769 &BoxConstraints::loose(Size::new(f64::INFINITY, 500.0)),
1770 );
1771 let metrics = widget.content_metrics();
1772 assert_eq!(metrics.available_label_width, f64::INFINITY);
1773 assert!(!metrics.overflows());
1774 }
1775
1776 const LONG_LABEL: &str = "A label far too long to ever fit inside a narrow button";
1779 const NARROW: f64 = 120.0;
1781 const ROOMY: f64 = 500.0;
1783
1784 #[derive(Default)]
1785 struct BottomSheetObserver {
1786 overflow_presses: RefCell<u32>,
1787 }
1788
1789 impl OverflowObserver for BottomSheetObserver {
1790 fn measured(&self, _metrics: ContentMetrics) {}
1791 fn overflow_pressed(&self) {
1792 *self.overflow_presses.borrow_mut() += 1;
1793 }
1794 }
1795
1796 #[test]
1797 fn a_fitted_button_behaves_identically_under_every_strategy() {
1798 for strategy in [
1802 OverflowStrategy::None,
1803 OverflowStrategy::Scroll,
1804 OverflowStrategy::BottomSheet,
1805 ] {
1806 let presses = Rc::new(RefCell::new(0u32));
1807 let counted = presses.clone();
1808 let observer = Rc::new(BottomSheetObserver::default());
1809 let view = button::<u32, _>("Save", move |_| {
1810 *counted.borrow_mut() += 1;
1811 })
1812 .overflow(strategy)
1813 .overflow_observer(observer.clone());
1814 let mut widget = build(&view);
1815 let size = layout_with(&mut widget, ROOMY, None);
1816 assert!(!widget.content_metrics().overflows(), "{strategy:?}");
1817
1818 let mut state = 0u32;
1819 dispatch(
1820 &mut widget,
1821 &mut state,
1822 size,
1823 &ev(PointerPhase::Down, 5.0, 5.0),
1824 );
1825 dispatch(
1826 &mut widget,
1827 &mut state,
1828 size,
1829 &ev(PointerPhase::Move, 40.0, 5.0),
1830 );
1831 dispatch(
1832 &mut widget,
1833 &mut state,
1834 size,
1835 &ev(PointerPhase::Up, 40.0, 5.0),
1836 );
1837
1838 assert_eq!(*presses.borrow(), 1, "{strategy:?} fires the normal press");
1839 assert_eq!(
1840 *observer.overflow_presses.borrow(),
1841 0,
1842 "{strategy:?} never defers when fitted"
1843 );
1844 assert_eq!(widget.scroll_offset, 0.0, "{strategy:?} pans nothing");
1845 }
1846 }
1847
1848 #[test]
1849 fn scroll_shapes_the_label_at_its_full_natural_width_while_overflowing() {
1850 let view = button::<u32, _>(LONG_LABEL, |_| {}).overflow(OverflowStrategy::Scroll);
1851 let mut widget = build(&view);
1852 layout_with(&mut widget, NARROW, None);
1853 let m = widget.content_metrics();
1854 assert!(m.overflows());
1855 assert_eq!(
1856 m.painted_label_width, m.natural_label_width,
1857 "scroll never ellipsizes — it needs the whole run to pan"
1858 );
1859 }
1860
1861 #[test]
1862 fn a_drag_pans_an_overflowing_scroll_label_clamped_to_the_hidden_extent() {
1863 let view = button::<u32, _>(LONG_LABEL, |_| {}).overflow(OverflowStrategy::Scroll);
1864 let mut widget = build(&view);
1865 let size = layout_with(&mut widget, NARROW, None);
1866 let max = max_scroll(widget.content_metrics());
1867 assert!(max > 0.0, "the fixture must actually overflow");
1868
1869 let mut state = 0u32;
1870 dispatch(
1871 &mut widget,
1872 &mut state,
1873 size,
1874 &ev(PointerPhase::Down, 100.0, 5.0),
1875 );
1876 dispatch(
1877 &mut widget,
1878 &mut state,
1879 size,
1880 &ev(PointerPhase::Move, 40.0, 5.0),
1881 );
1882 assert_eq!(widget.scroll_offset, 60.0);
1885
1886 dispatch(
1888 &mut widget,
1889 &mut state,
1890 size,
1891 &ev(PointerPhase::Move, -900.0, 5.0),
1892 );
1893 assert_eq!(widget.scroll_offset, max);
1894
1895 dispatch(
1898 &mut widget,
1899 &mut state,
1900 size,
1901 &ev(PointerPhase::Up, -900.0, 5.0),
1902 );
1903 assert_eq!(widget.scroll_offset, max);
1904 }
1905
1906 #[test]
1907 fn a_drag_on_a_fitted_scroll_button_pans_nothing() {
1908 let view = button::<u32, _>("Save", |_| {}).overflow(OverflowStrategy::Scroll);
1909 let mut widget = build(&view);
1910 let size = layout_with(&mut widget, ROOMY, None);
1911 assert!(!widget.content_metrics().overflows());
1912
1913 let mut state = 0u32;
1914 dispatch(
1915 &mut widget,
1916 &mut state,
1917 size,
1918 &ev(PointerPhase::Down, 40.0, 5.0),
1919 );
1920 dispatch(
1921 &mut widget,
1922 &mut state,
1923 size,
1924 &ev(PointerPhase::Move, 5.0, 5.0),
1925 );
1926 assert_eq!(widget.scroll_offset, 0.0);
1927 }
1928
1929 #[test]
1930 fn a_relayout_that_stops_overflowing_resets_the_scroll_offset() {
1931 let view = button::<u32, _>(LONG_LABEL, |_| {}).overflow(OverflowStrategy::Scroll);
1932 let mut widget = build(&view);
1933 let size = layout_with(&mut widget, NARROW, None);
1934 let mut state = 0u32;
1935 dispatch(
1936 &mut widget,
1937 &mut state,
1938 size,
1939 &ev(PointerPhase::Down, 100.0, 5.0),
1940 );
1941 dispatch(
1942 &mut widget,
1943 &mut state,
1944 size,
1945 &ev(PointerPhase::Move, 40.0, 5.0),
1946 );
1947 assert!(widget.scroll_offset > 0.0);
1948
1949 layout_with(&mut widget, ROOMY, None);
1950 assert_eq!(widget.scroll_offset, 0.0);
1951 }
1952
1953 #[test]
1954 fn bottom_sheet_defers_an_overflowing_press_to_the_observer_instead_of_on_press() {
1955 let presses = Rc::new(RefCell::new(0u32));
1956 let counted = presses.clone();
1957 let observer = Rc::new(BottomSheetObserver::default());
1958 let view = button::<u32, _>(LONG_LABEL, move |_| {
1959 *counted.borrow_mut() += 1;
1960 })
1961 .overflow(OverflowStrategy::BottomSheet)
1962 .overflow_observer(observer.clone());
1963 let mut widget = build(&view);
1964 let size = layout_with(&mut widget, NARROW, None);
1965 assert!(widget.content_metrics().overflows());
1966
1967 let mut state = 0u32;
1968 dispatch(
1969 &mut widget,
1970 &mut state,
1971 size,
1972 &ev(PointerPhase::Down, 5.0, 5.0),
1973 );
1974 dispatch(
1975 &mut widget,
1976 &mut state,
1977 size,
1978 &ev(PointerPhase::Up, 5.0, 5.0),
1979 );
1980
1981 assert_eq!(*observer.overflow_presses.borrow(), 1);
1982 assert_eq!(*presses.borrow(), 0, "on_press does not also fire");
1983 }
1984
1985 #[test]
1986 fn bottom_sheet_falls_back_to_a_normal_press_with_no_observer_installed() {
1987 let presses = Rc::new(RefCell::new(0u32));
1988 let counted = presses.clone();
1989 let view = button::<u32, _>(LONG_LABEL, move |_| {
1990 *counted.borrow_mut() += 1;
1991 })
1992 .overflow(OverflowStrategy::BottomSheet);
1993 let mut widget = build(&view);
1994 let size = layout_with(&mut widget, NARROW, None);
1995 assert!(widget.content_metrics().overflows());
1996
1997 let mut state = 0u32;
1998 dispatch(
1999 &mut widget,
2000 &mut state,
2001 size,
2002 &ev(PointerPhase::Down, 5.0, 5.0),
2003 );
2004 dispatch(
2005 &mut widget,
2006 &mut state,
2007 size,
2008 &ev(PointerPhase::Up, 5.0, 5.0),
2009 );
2010
2011 assert_eq!(*presses.borrow(), 1, "no observer means no dead press");
2012 }
2013
2014 struct RecordingDecoration {
2017 fills: RefCell<Vec<ButtonSurface>>,
2018 overlays: RefCell<Vec<ButtonSurface>>,
2019 outlines: RefCell<Vec<ButtonSurface>>,
2020 outcome: DecorationOutcome,
2022 }
2023
2024 impl RecordingDecoration {
2025 fn new(outcome: DecorationOutcome) -> Self {
2026 Self {
2027 fills: RefCell::new(Vec::new()),
2028 overlays: RefCell::new(Vec::new()),
2029 outlines: RefCell::new(Vec::new()),
2030 outcome,
2031 }
2032 }
2033 }
2034
2035 impl ButtonDecoration for RecordingDecoration {
2036 fn paint_fill(
2037 &self,
2038 surface: &ButtonSurface,
2039 _scene: &mut dyn PaintScene,
2040 ) -> DecorationOutcome {
2041 self.fills.borrow_mut().push(*surface);
2042 self.outcome
2043 }
2044 fn paint_overlay(
2045 &self,
2046 surface: &ButtonSurface,
2047 _scene: &mut dyn PaintScene,
2048 ) -> DecorationOutcome {
2049 self.overlays.borrow_mut().push(*surface);
2050 self.outcome
2051 }
2052 fn paint_outline(
2053 &self,
2054 surface: &ButtonSurface,
2055 _scene: &mut dyn PaintScene,
2056 ) -> DecorationOutcome {
2057 self.outlines.borrow_mut().push(*surface);
2058 self.outcome
2059 }
2060 }
2061
2062 #[test]
2063 fn every_decoration_hook_fires_with_the_painted_surface() {
2064 let decoration = Rc::new(RecordingDecoration::new(DecorationOutcome::Skipped));
2065 let view = button::<u32, _>("Save", |_| {})
2066 .variant(ButtonVariant::Outlined)
2067 .decoration(decoration.clone());
2068 let mut widget = build(&view);
2069 let laid = layout_with(&mut widget, 500.0, None);
2070 paint_at(&mut widget, laid, None);
2071
2072 for (name, seen) in [
2073 ("fill", decoration.fills.borrow().clone()),
2074 ("overlay", decoration.overlays.borrow().clone()),
2075 ("outline", decoration.outlines.borrow().clone()),
2076 ] {
2077 assert_eq!(seen.len(), 1, "the {name} hook fired exactly once");
2078 let surface = seen[0];
2079 assert_eq!(surface.size, laid);
2080 assert_eq!(surface.radius, laid.height / 2.0, "the live pill radius");
2081 assert_eq!(surface.outline_width, OUTLINE_WIDTH);
2082 assert!(surface.enabled);
2083 assert_eq!(
2084 surface.outline,
2085 Some(OUTLINE),
2086 "outlined carries a hairline"
2087 );
2088 }
2089 }
2090
2091 #[test]
2092 fn a_default_decoration_is_a_no_op_the_core_paints_through() {
2093 struct Silent;
2094 impl ButtonDecoration for Silent {}
2095
2096 let mut plain = build(&button::<u32, _>("Save", |_| {}));
2097 let laid = layout_with(&mut plain, 500.0, None);
2098 let without = paint_at(&mut plain, laid, None);
2099
2100 let mut decorated = build(&button::<u32, _>("Save", |_| {}).decoration(Rc::new(Silent)));
2101 layout_with(&mut decorated, 500.0, None);
2102 let with = paint_at(&mut decorated, laid, None);
2103
2104 assert_eq!(with.rrects, without.rrects, "identical fills");
2105 assert_eq!(with.strokes.len(), without.strokes.len());
2106 assert_eq!(with.runs, without.runs, "and an identically painted label");
2107 }
2108
2109 #[test]
2110 fn a_painting_decoration_suppresses_the_core_layers_it_took_over() {
2111 let decoration = Rc::new(RecordingDecoration::new(DecorationOutcome::Painted));
2112 let view = button::<u32, _>("Save", |_| {})
2113 .variant(ButtonVariant::Outlined)
2114 .decoration(decoration.clone());
2115 let mut widget = build(&view);
2116 let laid = layout_with(&mut widget, 500.0, None);
2117 let rec = paint_at(&mut widget, laid, None);
2118
2119 assert!(
2120 rec.rrects.is_empty(),
2121 "the fill was taken over, and so is the overlay slot the state \
2122 layer would paint into"
2123 );
2124 assert!(rec.strokes.is_empty(), "and so was the outline");
2125 assert_eq!(rec.runs.len(), 1, "the label is still the core's to paint");
2126 }
2127
2128 #[test]
2131 fn a_resting_round_button_paints_its_container_at_a_pill_radius() {
2132 let mut widget = build(&button::<u32, _>("Save", |_| {}));
2133 let laid = layout_with(&mut widget, 500.0, None);
2134 let rec = paint_at(&mut widget, laid, None);
2135 let (origin, size, radius, color) = rec.rrects[0];
2136 assert_eq!(origin, Point::ZERO);
2137 assert_eq!(size, laid);
2138 assert_eq!(radius, laid.height / 2.0);
2139 assert_eq!(color, PRIMARY);
2140 assert!(rec.shadows.is_empty(), "a filled button rests flat");
2141 assert_eq!(rec.runs.len(), 1, "the label paints one run");
2142 assert_eq!(rec.runs[0].1, ON_PRIMARY);
2143 }
2144
2145 #[test]
2146 fn a_square_button_paints_the_size_token_radius() {
2147 let theme = crate::baseline();
2148 let mut widget = build(
2149 &button::<u32, _>("Save", |_| {})
2150 .shape(ButtonShape::Square)
2151 .size(ButtonSize::Md),
2152 );
2153 let laid = layout_with(&mut widget, 500.0, Some(&theme));
2154 let rec = paint_at(&mut widget, laid, Some(&theme));
2155 assert_eq!(rec.rrects[0].2, ButtonSize::Md.square_radius(Some(&theme)));
2156 assert_eq!(rec.rrects[0].3, theme.scheme().primary);
2157 }
2158
2159 #[test]
2160 fn an_explicit_corner_radius_overrides_both_shape_families() {
2161 let mut widget = build(&button::<u32, _>("Save", |_| {}).corner_radius(3.0));
2162 let laid = layout_with(&mut widget, 500.0, None);
2163 let rec = paint_at(&mut widget, laid, None);
2164 assert_eq!(rec.rrects[0].2, 3.0);
2165 }
2166
2167 #[test]
2168 fn a_text_button_paints_no_container_and_no_outline() {
2169 let mut widget = build(&button::<u32, _>("Save", |_| {}).variant(ButtonVariant::Text));
2170 let laid = layout_with(&mut widget, 500.0, None);
2171 let rec = paint_at(&mut widget, laid, None);
2172 assert!(
2173 rec.rrects.is_empty(),
2174 "a fully transparent container is never filled"
2175 );
2176 assert!(rec.strokes.is_empty());
2177 assert_eq!(rec.runs[0].1, PRIMARY);
2178 }
2179
2180 #[test]
2181 fn an_outlined_button_strokes_a_one_dp_hairline() {
2182 let mut widget = build(&button::<u32, _>("Save", |_| {}).variant(ButtonVariant::Outlined));
2183 let laid = layout_with(&mut widget, 500.0, None);
2184 let rec = paint_at(&mut widget, laid, None);
2185 assert!(rec.rrects.is_empty(), "no container fill");
2186 assert_eq!(rec.strokes.len(), 1);
2187 assert_eq!(rec.strokes[0].1, OUTLINE_WIDTH);
2188 assert_eq!(rec.strokes[0].2, OUTLINE);
2189 }
2190
2191 #[test]
2192 fn the_outlined_hairline_is_inset_by_half_its_width() {
2193 let rr = inset_stroke_rect(Size::new(100.0, 40.0), 20.0, OUTLINE_WIDTH);
2196 assert_eq!(rr.rect().x0, 0.5);
2197 assert_eq!(rr.rect().y0, 0.5);
2198 assert_eq!(rr.rect().x1, 99.5);
2199 assert_eq!(rr.rect().y1, 39.5);
2200 }
2201
2202 #[test]
2203 fn an_elevated_button_casts_its_resting_level_one_shadow() {
2204 let mut widget = build(&button::<u32, _>("Save", |_| {}).variant(ButtonVariant::Elevated));
2205 let laid = layout_with(&mut widget, 500.0, None);
2206 let rec = paint_at(&mut widget, laid, None);
2207 assert_eq!(rec.shadows.len(), 1);
2208 let (origin, _, _, blur, _) = rec.shadows[0];
2209 assert_eq!(blur, 1.0, "1dp of elevation");
2210 assert_eq!(origin.y, 1.5, "offset by the level's own y");
2211 }
2212
2213 #[test]
2214 fn a_disabled_button_paints_the_twelve_percent_container_and_a_38_percent_label() {
2215 let mut widget = build(&button::<u32, _>("Save", |_| {}).enabled(false));
2216 let laid = layout_with(&mut widget, 500.0, None);
2217 let rec = paint_at(&mut widget, laid, None);
2218 assert_eq!(rec.rrects.len(), 1, "container only, no state layer");
2219 assert_eq!(rec.rrects[0].3, with_alpha(ON_SURFACE, 0.12));
2220 assert_eq!(rec.runs[0].1, with_alpha(ON_SURFACE, 0.38));
2221 }
2222
2223 #[test]
2224 fn an_unfocused_button_draws_no_ring_and_paint_re_syncs_the_flag() {
2225 let mut widget = build(&button::<u32, _>("Save", |_| {}));
2226 let laid = layout_with(&mut widget, 500.0, None);
2227 let rec = paint_at(&mut widget, laid, None);
2228 assert!(rec.strokes.is_empty(), "an unfocused button draws no ring");
2229
2230 widget.state.set_focused(true);
2233 let rec = paint_at(&mut widget, laid, None);
2234 assert!(rec.strokes.is_empty());
2235 assert!(!widget.state.focused, "the flag was re-synced from the ctx");
2236 }
2237
2238 #[test]
2239 fn the_focus_ring_paints_at_the_reference_outset_and_radius() {
2240 let size = Size::new(100.0, 40.0);
2243 let ring = focus_ring_rect(size, 20.0);
2244 let outset = FOCUS_RING_GAP + FOCUS_RING_WIDTH;
2245 let half = FOCUS_RING_WIDTH / 2.0;
2246 assert_eq!(ring.rect().x0, -outset + half);
2247 assert_eq!(ring.rect().y0, -outset + half);
2248 assert_eq!(ring.rect().x1, size.width + outset - half);
2249 assert_eq!(ring.rect().y1, size.height + outset - half);
2250 assert_eq!(ring.radii().top_left, 20.0 + outset - half);
2251 assert_eq!(
2252 ring.rect().x0 - half,
2253 -outset,
2254 "the ring's outer edge lands exactly on the reference's inset"
2255 );
2256
2257 let mut rec = Recorder::default();
2258 paint_focus_ring(Point::ZERO, size, 20.0, PRIMARY, &mut rec);
2259 assert_eq!(rec.strokes.len(), 1);
2260 assert_eq!(rec.strokes[0].1, FOCUS_RING_WIDTH);
2261 assert_eq!(rec.strokes[0].2, PRIMARY);
2262 }
2263
2264 #[test]
2267 fn a_press_morphs_the_radius_from_the_pill_to_the_pressed_token() {
2268 let mut state = 0u32;
2269 let mut widget = build(&button::<u32, _>("Save", |_| {}));
2270 let laid = layout_with(&mut widget, 500.0, None);
2271 let rec = paint_at(&mut widget, laid, None);
2272 let resting = rec.rrects[0].2;
2273 assert_eq!(resting, laid.height / 2.0);
2274
2275 dispatch(
2276 &mut widget,
2277 &mut state,
2278 laid,
2279 &ev(PointerPhase::Down, 10.0, 10.0),
2280 );
2281 let rec = paint_at(&mut widget, laid, None);
2284 assert_eq!(rec.rrects[0].2, resting);
2285
2286 settle_morph(&mut widget);
2287 let rec = paint_at(&mut widget, laid, None);
2288 assert_eq!(
2289 rec.rrects[0].2,
2290 ButtonSize::Sm.pressed_radius(None),
2291 "settled on the pressed radius"
2292 );
2293
2294 dispatch(
2296 &mut widget,
2297 &mut state,
2298 laid,
2299 &ev(PointerPhase::Up, 10.0, 10.0),
2300 );
2301 paint_at(&mut widget, laid, None);
2302 settle_morph(&mut widget);
2303 let rec = paint_at(&mut widget, laid, None);
2304 assert_eq!(rec.rrects[0].2, resting, "and back to rest on release");
2305 }
2306
2307 #[test]
2308 fn a_pressed_radius_override_wins_over_the_token() {
2309 let mut state = 0u32;
2310 let mut widget = build(&button::<u32, _>("Save", |_| {}).pressed_radius(2.0));
2311 let laid = layout_with(&mut widget, 500.0, None);
2312 dispatch(
2313 &mut widget,
2314 &mut state,
2315 laid,
2316 &ev(PointerPhase::Down, 10.0, 10.0),
2317 );
2318 assert_eq!(widget.target_radius(None, laid.height), 2.0);
2319 }
2320
2321 #[test]
2322 fn hover_targets_the_hovered_radius_and_press_outranks_it() {
2323 let mut widget = build(&button::<u32, _>("Save", |_| {}).size(ButtonSize::Md));
2324 let laid = layout_with(&mut widget, 500.0, None);
2325 assert_eq!(widget.target_radius(None, laid.height), laid.height / 2.0);
2326
2327 widget.state.set_hovered(true);
2328 assert_eq!(
2329 widget.target_radius(None, laid.height),
2330 ButtonSize::Md.hovered_radius()
2331 );
2332
2333 widget.state.set_pressed(true);
2334 assert_eq!(
2335 widget.target_radius(None, laid.height),
2336 ButtonSize::Md.pressed_radius(None)
2337 );
2338 }
2339
2340 #[test]
2341 fn a_disabled_button_never_leaves_its_resting_radius() {
2342 let mut widget = build(&button::<u32, _>("Save", |_| {}).enabled(false));
2343 let laid = layout_with(&mut widget, 500.0, None);
2344 widget.state.set_pressed(true);
2345 widget.state.set_hovered(true);
2346 assert_eq!(widget.target_radius(None, laid.height), laid.height / 2.0);
2347 }
2348
2349 #[test]
2350 fn a_runtime_size_change_relayouts_at_the_new_sizes_padding_immediately() {
2351 let mut widget = build(&button::<u32, _>("Save", |_| {}).size(ButtonSize::Sm));
2360 layout_with(&mut widget, 1000.0, None);
2361 assert_eq!(
2362 widget.content_metrics().available_label_width,
2363 1000.0 - ButtonSize::Sm.metrics().h_padding * 2.0
2364 );
2365
2366 widget.size = ButtonSize::Xl;
2367 layout_with(&mut widget, 1000.0, None);
2368 assert_eq!(
2369 widget.content_metrics().available_label_width,
2370 1000.0 - ButtonSize::Xl.metrics().h_padding * 2.0,
2371 "layout must resolve the NEW size's padding on its very next pass, \
2372 not the stale one only a later paint would have retargeted"
2373 );
2374 }
2375
2376 #[test]
2377 fn a_plain_buttons_padding_never_moves_during_a_press_morph() {
2378 let mut state = 0u32;
2384 let mut widget = build(&button::<u32, _>("Save", |_| {}));
2385 let laid = layout_with(&mut widget, 500.0, None);
2386 let resting_available = widget.content_metrics().available_label_width;
2387
2388 dispatch(
2389 &mut widget,
2390 &mut state,
2391 laid,
2392 &ev(PointerPhase::Down, 10.0, 10.0),
2393 );
2394 paint_at(&mut widget, laid, None);
2396 widget.motion.advance(ft_secs(0.05));
2398 layout_with(&mut widget, 500.0, None);
2399 assert_eq!(
2400 widget.content_metrics().available_label_width,
2401 resting_available,
2402 "a plain button's padding target never depends on press state, \
2403 mid-flight or otherwise"
2404 );
2405 }
2406
2407 #[test]
2410 fn a_press_released_inside_fires_on_press() {
2411 let mut state = 0u32;
2412 let mut widget = build(&button::<u32, _>("Save", |s: &mut u32| *s += 1));
2413 let laid = layout_with(&mut widget, 500.0, None);
2414 dispatch(
2415 &mut widget,
2416 &mut state,
2417 laid,
2418 &ev(PointerPhase::Down, 10.0, 10.0),
2419 );
2420 assert!(widget.state.pressed);
2421 dispatch(
2422 &mut widget,
2423 &mut state,
2424 laid,
2425 &ev(PointerPhase::Up, 10.0, 10.0),
2426 );
2427 assert_eq!(state, 1);
2428 assert!(!widget.state.pressed);
2429 assert!(!widget.captured);
2430 }
2431
2432 #[test]
2433 fn a_press_released_outside_fires_nothing() {
2434 let mut state = 0u32;
2435 let mut widget = build(&button::<u32, _>("Save", |s: &mut u32| *s += 1));
2436 let laid = layout_with(&mut widget, 500.0, None);
2437 dispatch(
2438 &mut widget,
2439 &mut state,
2440 laid,
2441 &ev(PointerPhase::Down, 10.0, 10.0),
2442 );
2443 dispatch(
2444 &mut widget,
2445 &mut state,
2446 laid,
2447 &ev(PointerPhase::Up, 999.0, 10.0),
2448 );
2449 assert_eq!(state, 0);
2450 }
2451
2452 #[test]
2453 fn a_cancelled_press_fires_nothing_and_clears_the_press() {
2454 let mut state = 0u32;
2455 let mut widget = build(&button::<u32, _>("Save", |s: &mut u32| *s += 1));
2456 let laid = layout_with(&mut widget, 500.0, None);
2457 dispatch(
2458 &mut widget,
2459 &mut state,
2460 laid,
2461 &ev(PointerPhase::Down, 10.0, 10.0),
2462 );
2463 dispatch(
2464 &mut widget,
2465 &mut state,
2466 laid,
2467 &ev(PointerPhase::Cancel, 10.0, 10.0),
2468 );
2469 assert_eq!(state, 0);
2470 assert!(!widget.state.pressed);
2471 assert!(!widget.captured);
2472 }
2473
2474 #[test]
2475 fn only_a_primary_button_presses() {
2476 let mut state = 0u32;
2477 let mut widget = build(&button::<u32, _>("Save", |s: &mut u32| *s += 1));
2478 let laid = layout_with(&mut widget, 500.0, None);
2479 let secondary = InputEvent::Pointer(PointerEvent {
2480 phase: PointerPhase::Down,
2481 position: Point::new(10.0, 10.0),
2482 button: PointerButton::Secondary,
2483 });
2484 dispatch(&mut widget, &mut state, laid, &secondary);
2485 assert!(!widget.state.pressed);
2486 }
2487
2488 #[test]
2489 fn a_disabled_button_ignores_every_pointer_phase() {
2490 let mut state = 0u32;
2491 let mut widget = build(&button::<u32, _>("Save", |s: &mut u32| *s += 1).enabled(false));
2492 let laid = layout_with(&mut widget, 500.0, None);
2493 for phase in [
2494 PointerPhase::Down,
2495 PointerPhase::Move,
2496 PointerPhase::Up,
2497 PointerPhase::Cancel,
2498 ] {
2499 dispatch(&mut widget, &mut state, laid, &ev(phase, 10.0, 10.0));
2500 }
2501 assert_eq!(state, 0);
2502 assert!(!widget.state.pressed);
2503 }
2504
2505 #[test]
2506 fn dragging_off_the_button_disarms_the_press_and_dragging_back_rearms_it() {
2507 let mut state = 0u32;
2508 let mut widget = build(&button::<u32, _>("Save", |s: &mut u32| *s += 1));
2509 let laid = layout_with(&mut widget, 500.0, None);
2510 dispatch(
2511 &mut widget,
2512 &mut state,
2513 laid,
2514 &ev(PointerPhase::Down, 10.0, 10.0),
2515 );
2516 dispatch(
2517 &mut widget,
2518 &mut state,
2519 laid,
2520 &ev(PointerPhase::Move, 999.0, 10.0),
2521 );
2522 assert!(!widget.state.pressed);
2523 dispatch(
2524 &mut widget,
2525 &mut state,
2526 laid,
2527 &ev(PointerPhase::Move, 10.0, 10.0),
2528 );
2529 assert!(widget.state.pressed);
2530 dispatch(
2531 &mut widget,
2532 &mut state,
2533 laid,
2534 &ev(PointerPhase::Up, 10.0, 10.0),
2535 );
2536 assert_eq!(state, 1);
2537 }
2538
2539 #[test]
2540 fn an_uncaptured_move_latches_hover_without_handling_the_event() {
2541 let mut state = 0u32;
2542 let mut widget = build(&button::<u32, _>("Save", |_| {}));
2543 let laid = layout_with(&mut widget, 500.0, None);
2544 dispatch(
2545 &mut widget,
2546 &mut state,
2547 laid,
2548 &ev(PointerPhase::Move, 10.0, 10.0),
2549 );
2550 assert!(
2551 widget.state.hovered,
2552 "the latch is the frame source for gain"
2553 );
2554 dispatch(
2555 &mut widget,
2556 &mut state,
2557 laid,
2558 &ev(PointerPhase::Move, 999.0, 10.0),
2559 );
2560 assert!(!widget.state.hovered);
2561 }
2562
2563 const HARNESS_WINDOW: Size = Size::new(300.0, 200.0);
2572
2573 struct Harness {
2574 root: frust_core::RenderRoot<u32, ButtonView<u32>>,
2575 state: u32,
2576 tcx: TextContext,
2577 enabled: bool,
2578 }
2579
2580 impl Harness {
2581 fn new(enabled: bool) -> Self {
2582 let mut harness = Self {
2583 root: frust_core::RenderRoot::new(),
2584 state: 0,
2585 tcx: TextContext::new(),
2586 enabled,
2587 };
2588 harness.sync();
2589 harness
2590 }
2591
2592 fn sync(&mut self) {
2593 let enabled = self.enabled;
2594 let mut app =
2595 move |_: &mut u32| button::<u32, _>("Save", |s: &mut u32| *s += 1).enabled(enabled);
2596 self.root.rebuild(&mut app, &mut self.state);
2597 self.root
2598 .layout_with_text(HARNESS_WINDOW, &mut self.tcx as &mut dyn Any);
2599 }
2600
2601 fn dispatch(&mut self, phase: PointerPhase, x: f64, y: f64) {
2602 self.root.event(
2603 &mut self.state,
2604 &InputEvent::Pointer(PointerEvent {
2605 phase,
2606 position: Point::new(x, y),
2607 button: PointerButton::Primary,
2608 }),
2609 );
2610 }
2611
2612 fn paint(&mut self) -> Recorder {
2613 let mut rec = Recorder::default();
2614 self.root.paint(&mut rec, FrameTime::ZERO);
2615 rec
2616 }
2617
2618 fn overlay_alpha(&mut self) -> Option<f32> {
2621 let rec = self.paint();
2622 rec.rrects
2623 .get(1)
2624 .map(|(_, _, _, color)| color.components[3])
2625 }
2626 }
2627
2628 #[test]
2629 fn hovering_paints_the_hover_state_layer_and_pressing_outranks_it() {
2630 let mut h = Harness::new(true);
2631 assert_eq!(h.overlay_alpha(), None, "no overlay at rest");
2632
2633 h.dispatch(PointerPhase::Move, 10.0, 10.0);
2634 assert_eq!(
2635 h.overlay_alpha(),
2636 Some(crate::interaction::HOVER_OPACITY),
2637 "the hovered button tints at the M3E hover opacity"
2638 );
2639
2640 h.dispatch(PointerPhase::Down, 10.0, 10.0);
2641 assert_eq!(
2642 h.overlay_alpha(),
2643 Some(crate::interaction::PRESSED_OPACITY),
2644 "pressed outranks hover in the precedence order"
2645 );
2646
2647 h.dispatch(PointerPhase::Up, 10.0, 10.0);
2648 assert_eq!(h.state, 1, "and the release fired the callback");
2649 }
2650
2651 #[test]
2652 fn moving_off_the_button_drops_the_hover_tint() {
2653 let mut h = Harness::new(true);
2654 h.dispatch(PointerPhase::Move, 10.0, 10.0);
2655 assert!(h.overlay_alpha().is_some());
2656 h.dispatch(PointerPhase::Move, 290.0, 190.0);
2657 assert_eq!(h.overlay_alpha(), None);
2658 }
2659
2660 #[test]
2661 fn a_disabled_button_never_tints_and_never_fires() {
2662 let mut h = Harness::new(false);
2663 h.dispatch(PointerPhase::Move, 10.0, 10.0);
2664 assert_eq!(h.overlay_alpha(), None, "a disabled button claims no hover");
2665 h.dispatch(PointerPhase::Down, 10.0, 10.0);
2666 h.dispatch(PointerPhase::Up, 10.0, 10.0);
2667 assert_eq!(h.state, 0);
2668 }
2669
2670 fn semantics_node<V>(logic: impl FnMut(&mut ()) -> V) -> frust::authoring::Node
2673 where
2674 V: frust::authoring::View<()> + 'static,
2675 {
2676 let mut root: frust_core::RenderRoot<(), V> = frust_core::RenderRoot::new();
2677 let mut state = ();
2678 let mut tcx = TextContext::new();
2679 let mut logic = logic;
2680 root.rebuild(&mut logic, &mut state);
2681 root.layout_with_text(HARNESS_WINDOW, &mut tcx as &mut dyn Any);
2682 let update = root.semantics();
2683 let (_, node) = update
2684 .nodes
2685 .iter()
2686 .find(|(id, _)| *id != update.root)
2687 .expect("the button contributes exactly one node");
2688 node.clone()
2689 }
2690
2691 #[test]
2692 fn semantics_report_a_labelled_clickable_button() {
2693 let node = semantics_node(|_| button::<(), _>("Save", |_| {}));
2694 assert_eq!(node.role(), Role::Button);
2695 assert_eq!(node.label(), Some("Save"));
2696 assert!(!node.is_disabled());
2697 }
2698
2699 #[test]
2700 fn a_disabled_button_reports_disabled_semantics() {
2701 let node = semantics_node(|_| button::<(), _>("Save", |_| {}).enabled(false));
2702 assert_eq!(node.role(), Role::Button);
2703 assert!(node.is_disabled());
2704 }
2705}