1use std::rc::Rc;
147use std::time::Duration;
148
149use frust::authoring::{Action, Role};
150use frust::authoring::{
151 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
152 LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget,
153};
154use frust::{AnimationController, ShapeScale, SpringDesc, Theme, Tween};
155use kurbo::{Affine, Point, Rect, Size};
156use peniko::Color;
157
158use super::press::presses;
159use super::state_layer::StateLayer;
160use frust::authoring::{ThemeTextColor, ThemeTextType};
161
162const SMALL_CONTAINER: f64 = 40.0;
167const MEDIUM_CONTAINER: f64 = 56.0;
170const LARGE_CONTAINER: f64 = 96.0;
173
174const SMALL_ICON: f64 = 24.0;
177const MEDIUM_ICON: f64 = 24.0;
180const LARGE_ICON: f64 = 36.0;
183
184const SMALL_RADIUS: f64 = 12.0;
187const MEDIUM_RADIUS: f64 = 16.0;
192const LARGE_RADIUS: f64 = 28.0;
195
196const EXTENDED_HEIGHT: f64 = 56.0;
201const EXTENDED_RADIUS_FALLBACK: f64 = MEDIUM_RADIUS;
206const EXTENDED_PAD_EXPANDED: f64 = 20.0;
209const EXTENDED_PAD_COLLAPSED: f64 = 16.0;
212const EXTENDED_ICON: f64 = 24.0;
215const EXTENDED_ICON_LABEL_GAP: f64 = 12.0;
218const EXTENDED_PRESSED_SCALE: f64 = 0.97;
221
222const FAB_PRESSED_SCALE: f64 = 0.95;
226
227const PRESS_SPRING: SpringDesc = SpringDesc {
234 mass: 1.0,
235 stiffness: 380.0,
236 damping_ratio: 0.55,
237};
238
239const PRESS_ANIM_PERIOD: Duration = Duration::from_millis(300);
244const FLING_VELOCITY: f64 = 4.0;
248
249const PRIMARY_CONTAINER: Color = Color::from_rgb8(0xEA, 0xDD, 0xFF);
254const ON_PRIMARY_CONTAINER: Color = Color::from_rgb8(0x21, 0x00, 0x5D);
257const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
260const ON_SECONDARY_CONTAINER: Color = Color::from_rgb8(0x1D, 0x19, 0x2B);
263const TERTIARY_CONTAINER: Color = Color::from_rgb8(0xFF, 0xD8, 0xE4);
266const ON_TERTIARY_CONTAINER: Color = Color::from_rgb8(0x31, 0x11, 0x1D);
269const SURFACE_CONTAINER_HIGH: Color = Color::from_rgb8(0xEC, 0xE6, 0xF0);
272const SURFACE_FOREGROUND: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
276
277const LEVEL3_SHADOW_Y_OFFSET: f64 = 4.0;
283const LEVEL3_SHADOW_BLUR: f64 = 6.0;
286const LEVEL4_SHADOW_Y_OFFSET: f64 = 5.0;
290const LEVEL4_SHADOW_BLUR: f64 = 8.0;
293const FALLBACK_SHADOW_COLOR: Color = Color::new([0.0, 0.0, 0.0, 0.3]);
296
297#[derive(Clone, Copy, Debug, PartialEq, Eq)]
300pub enum FabSize {
301 Small,
303 Medium,
305 Large,
307}
308
309impl FabSize {
310 fn container(self) -> f64 {
311 match self {
312 FabSize::Small => SMALL_CONTAINER,
313 FabSize::Medium => MEDIUM_CONTAINER,
314 FabSize::Large => LARGE_CONTAINER,
315 }
316 }
317
318 fn icon_size(self) -> f64 {
319 match self {
320 FabSize::Small => SMALL_ICON,
321 FabSize::Medium => MEDIUM_ICON,
322 FabSize::Large => LARGE_ICON,
323 }
324 }
325
326 fn fallback_radius(self) -> f64 {
327 match self {
328 FabSize::Small => SMALL_RADIUS,
329 FabSize::Medium => MEDIUM_RADIUS,
330 FabSize::Large => LARGE_RADIUS,
331 }
332 }
333
334 fn themed_radius(self, shape: &ShapeScale) -> f64 {
335 match self {
336 FabSize::Small => shape.medium,
337 FabSize::Medium => shape.large,
338 FabSize::Large => shape.extra_large,
339 }
340 }
341}
342
343#[derive(Clone, Copy, Debug, PartialEq, Eq)]
346pub enum FabColor {
347 Primary,
349 Secondary,
351 Tertiary,
353 Surface,
355}
356
357fn with_alpha(color: Color, alpha: f32) -> Color {
359 let c = color.components;
360 Color::new([c[0], c[1], c[2], alpha])
361}
362
363fn resolve_colors(theme: Option<&Theme>, color: FabColor) -> (Color, Color) {
367 match theme {
368 Some(theme) => {
369 let s = theme.scheme();
370 match color {
371 FabColor::Primary => (s.primary_container, s.on_primary_container),
372 FabColor::Secondary => (s.secondary_container, s.on_secondary_container),
373 FabColor::Tertiary => (s.tertiary_container, s.on_tertiary_container),
374 FabColor::Surface => (s.surface_container_high, s.primary),
375 }
376 }
377 None => match color {
378 FabColor::Primary => (PRIMARY_CONTAINER, ON_PRIMARY_CONTAINER),
379 FabColor::Secondary => (SECONDARY_CONTAINER, ON_SECONDARY_CONTAINER),
380 FabColor::Tertiary => (TERTIARY_CONTAINER, ON_TERTIARY_CONTAINER),
381 FabColor::Surface => (SURFACE_CONTAINER_HIGH, SURFACE_FOREGROUND),
382 },
383 }
384}
385
386fn resolve_shadow(theme: Option<&Theme>, hovered: bool) -> (f64, f64, Color) {
392 match theme {
393 Some(theme) => {
394 let level = if hovered {
395 theme.elevation.level4
396 } else {
397 theme.elevation.level3
398 };
399 let shadow = level.shadow(theme.brightness);
400 let color = with_alpha(theme.scheme().shadow, shadow.color_alpha);
401 (shadow.blur_std_dev, shadow.y_offset, color)
402 }
403 None => {
404 if hovered {
405 (
406 LEVEL4_SHADOW_BLUR,
407 LEVEL4_SHADOW_Y_OFFSET,
408 FALLBACK_SHADOW_COLOR,
409 )
410 } else {
411 (
412 LEVEL3_SHADOW_BLUR,
413 LEVEL3_SHADOW_Y_OFFSET,
414 FALLBACK_SHADOW_COLOR,
415 )
416 }
417 }
418 }
419}
420
421fn inside(pos: Point, size: Size) -> bool {
422 pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
423}
424
425fn scale_about(pivot: Point, scale: f64) -> Affine {
430 Affine::translate((pivot.x, pivot.y))
431 * Affine::scale(scale)
432 * Affine::translate((-pivot.x, -pivot.y))
433}
434
435type OnPress<State> = Rc<dyn Fn(&mut State)>;
437
438fn label_view<State: 'static>(label: String) -> AnyView<State> {
443 frust::authoring::any::<State, _>(
444 frust::text(label)
445 .themed_role(ThemeTextColor::OnPrimaryContainer)
446 .themed_family(ThemeTextType::LabelLarge),
447 )
448}
449
450pub struct FabView<State: 'static> {
452 size: FabSize,
453 color: FabColor,
454 icon: Option<AnyView<State>>,
455 label: Option<String>,
460 is_extended: bool,
465 label_shown: bool,
468 corner_radius: Option<f64>,
471 on_press: OnPress<State>,
472}
473
474pub fn fab<State: 'static, F: Fn(&mut State) + 'static>(
480 icon: AnyView<State>,
481 on_press: F,
482) -> FabView<State> {
483 FabView {
484 size: FabSize::Medium,
485 color: FabColor::Primary,
486 icon: Some(icon),
487 label: None,
488 is_extended: false,
489 label_shown: true,
490 corner_radius: None,
491 on_press: Rc::new(on_press),
492 }
493}
494
495pub fn extended_fab<State: 'static, F: Fn(&mut State) + 'static>(
500 label: impl Into<String>,
501 on_press: F,
502) -> FabView<State> {
503 FabView {
504 size: FabSize::Medium,
505 color: FabColor::Primary,
506 icon: None,
507 label: Some(label.into()),
508 is_extended: true,
509 label_shown: true,
510 corner_radius: None,
511 on_press: Rc::new(on_press),
512 }
513}
514
515impl<State: 'static> FabView<State> {
516 pub fn size(mut self, size: FabSize) -> Self {
519 self.size = size;
520 self
521 }
522
523 pub fn color(mut self, color: FabColor) -> Self {
525 self.color = color;
526 self
527 }
528
529 pub fn icon(mut self, icon: AnyView<State>) -> Self {
532 self.icon = Some(icon);
533 self
534 }
535
536 pub fn label(mut self, label: impl Into<String>) -> Self {
540 self.label = Some(label.into());
541 self
542 }
543
544 pub fn extended(mut self, extended: bool) -> Self {
550 self.label_shown = extended;
551 self
552 }
553
554 pub fn corner_radius(mut self, radius: Option<f64>) -> Self {
561 self.corner_radius = radius;
562 self
563 }
564}
565
566pub struct FabWidget {
568 size: FabSize,
569 color: FabColor,
570 is_extended: bool,
571 label_shown: bool,
572 corner_radius: Option<f64>,
573 icon: Option<ChildPod>,
574 visible_label: Option<ChildPod>,
579 label_text: Option<String>,
582 pressed: bool,
583 captured: bool,
584 state_layer: StateLayer,
585 press_anim: AnimationController,
589 label_anim: AnimationController,
593 radius_from: f64,
595 radius_to: f64,
598 radius_seeded: bool,
603 radius_anim: AnimationController,
607 on_press: frust::authoring::ErasedCallback,
608}
609
610impl<State: 'static> View<State> for FabView<State> {
611 type Element = FabWidget;
612
613 fn build(&self, ctx: &mut BuildCtx<'_>) -> FabWidget {
614 let icon = self
615 .icon
616 .as_ref()
617 .map(|icon| frust::authoring::build_child(icon, ctx));
618 let visible_label = if self.is_extended {
619 self.label.as_ref().map(|label| {
620 frust::authoring::build_child(&label_view::<State>(label.clone()), ctx)
621 })
622 } else {
623 None
624 };
625 let mut label_anim = AnimationController::new(crate::tokens::MaterialMotion::MEDIUM_2)
626 .with_curve(crate::tokens::MaterialMotion::EMPHASIZED);
627 if self.label_shown {
628 label_anim.forward();
629 }
630 FabWidget {
631 size: self.size,
632 color: self.color,
633 is_extended: self.is_extended,
634 label_shown: self.label_shown,
635 corner_radius: self.corner_radius,
636 icon,
637 visible_label,
638 label_text: self.label.clone(),
639 pressed: false,
640 captured: false,
641 state_layer: StateLayer::new(),
642 press_anim: AnimationController::new(PRESS_ANIM_PERIOD),
643 label_anim,
644 radius_from: 0.0,
645 radius_to: 0.0,
646 radius_seeded: false,
647 radius_anim: AnimationController::new(crate::tokens::MaterialMotion::SHORT_4)
648 .with_curve(crate::tokens::MaterialMotion::STANDARD),
649 on_press: frust::authoring::erase_callback(&self.on_press),
650 }
651 }
652
653 fn rebuild(&self, prev: &Self, element: &mut FabWidget, ctx: &mut BuildCtx<'_>) -> ChangeFlags {
654 element.on_press = frust::authoring::erase_callback(&self.on_press);
655 let mut flags = ChangeFlags::NONE;
656
657 if prev.size != self.size {
658 element.size = self.size;
659 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
660 }
661 if prev.color != self.color {
662 element.color = self.color;
663 flags |= ChangeFlags::PAINT;
664 }
665 if prev.is_extended != self.is_extended {
666 element.is_extended = self.is_extended;
667 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
668 }
669 if prev.label_shown != self.label_shown {
670 element.label_shown = self.label_shown;
671 if self.label_shown {
672 element.label_anim.forward();
673 } else {
674 element.label_anim.reverse();
675 }
676 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
677 }
678 if prev.corner_radius != self.corner_radius {
679 element.corner_radius = self.corner_radius;
680 flags |= ChangeFlags::PAINT;
681 }
682
683 match (&prev.icon, &self.icon) {
684 (None, None) => {}
685 (Some(p), Some(n)) => {
686 let pod = element.icon.as_mut().expect("icon pod present");
687 flags |= frust::authoring::rebuild_child(p, n, pod, ctx);
688 }
689 (None, Some(n)) => {
690 element.icon = Some(frust::authoring::build_child(n, ctx));
691 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
692 }
693 (Some(p), None) => {
694 let mut pod = element.icon.take().expect("icon pod present");
695 frust::authoring::teardown_child(p, &mut pod, ctx);
696 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
697 }
698 }
699
700 if self.is_extended {
701 let freshly_mounted = element.visible_label.is_none();
706 match (&prev.label, &self.label) {
707 (None, None) => {}
708 (Some(p), Some(n)) if p == n && prev.is_extended == self.is_extended => {}
709 (Some(_), Some(n)) => {
710 element.label_text = Some(n.clone());
711 if let Some(pod) = element.visible_label.as_mut() {
712 let prev_view = label_view::<State>(prev.label.clone().unwrap_or_default());
713 let next_view = label_view::<State>(n.clone());
714 flags |= frust::authoring::rebuild_child(&prev_view, &next_view, pod, ctx);
715 } else {
716 element.visible_label = Some(frust::authoring::build_child(
717 &label_view::<State>(n.clone()),
718 ctx,
719 ));
720 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
721 }
722 }
723 (None, Some(n)) => {
724 element.label_text = Some(n.clone());
725 element.visible_label = Some(frust::authoring::build_child(
726 &label_view::<State>(n.clone()),
727 ctx,
728 ));
729 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
730 }
731 (Some(_), None) => {
732 element.label_text = None;
733 if let Some(mut pod) = element.visible_label.take() {
734 let prev_view = label_view::<State>(prev.label.clone().unwrap_or_default());
735 frust::authoring::teardown_child(&prev_view, &mut pod, ctx);
736 }
737 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
738 }
739 }
740 if freshly_mounted && element.visible_label.is_some() {
751 element.label_anim =
752 AnimationController::new(crate::tokens::MaterialMotion::MEDIUM_2)
753 .with_curve(crate::tokens::MaterialMotion::EMPHASIZED);
754 if self.label_shown {
755 element.label_anim.forward();
756 }
757 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
758 }
759 } else {
760 if prev.is_extended
769 && let Some(mut pod) = element.visible_label.take()
770 {
771 let prev_view = label_view::<State>(prev.label.clone().unwrap_or_default());
772 frust::authoring::teardown_child(&prev_view, &mut pod, ctx);
773 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
774 }
775 if prev.label != self.label {
776 element.label_text = self.label.clone();
778 flags |= ChangeFlags::PAINT;
779 }
780 }
781
782 flags
783 }
784
785 fn teardown(&self, element: &mut FabWidget, ctx: &mut BuildCtx<'_>) {
786 if let (Some(icon_view), Some(pod)) = (&self.icon, element.icon.as_mut()) {
787 frust::authoring::teardown_child(icon_view, pod, ctx);
788 }
789 if self.is_extended
790 && let (Some(label), Some(pod)) = (&self.label, element.visible_label.as_mut())
791 {
792 let label_view = label_view::<State>(label.clone());
793 frust::authoring::teardown_child(&label_view, pod, ctx);
794 }
795 }
796}
797
798impl FabWidget {
799 fn resolve_radius_target(&self, theme: Option<&Theme>) -> f64 {
803 if let Some(r) = self.corner_radius
804 && !self.is_extended
805 {
806 return r;
807 }
808 match theme {
809 Some(theme) => {
810 if self.is_extended {
811 theme.shape.large
812 } else {
813 self.size.themed_radius(&theme.shape)
814 }
815 }
816 None => {
817 if self.is_extended {
818 EXTENDED_RADIUS_FALLBACK
819 } else {
820 self.size.fallback_radius()
821 }
822 }
823 }
824 }
825
826 fn advance_radius(&mut self, ctx: &mut PaintCtx, target: f64, immediate: bool) -> f64 {
832 if !self.radius_seeded {
833 self.radius_from = target;
834 self.radius_to = target;
835 self.radius_seeded = true;
836 } else if (self.radius_to - target).abs() > f64::EPSILON {
837 if immediate {
838 self.radius_from = target;
839 self.radius_to = target;
840 self.radius_anim = AnimationController::new(Duration::ZERO);
841 } else {
842 let current = Tween::new(self.radius_from, self.radius_to)
843 .lerp(self.radius_anim.value_clamped());
844 self.radius_from = current;
845 self.radius_to = target;
846 self.radius_anim = AnimationController::new(crate::tokens::MaterialMotion::SHORT_4)
847 .with_curve(crate::tokens::MaterialMotion::STANDARD);
848 self.radius_anim.forward();
849 }
850 }
851 if self.radius_anim.advance(ctx.frame_time()) {
852 ctx.request_frame();
853 }
854 Tween::new(self.radius_from, self.radius_to).lerp(self.radius_anim.value_clamped())
855 }
856}
857
858impl Widget for FabWidget {
859 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
860 if !self.is_extended {
861 let container = self.size.container();
862 if let Some(icon) = self.icon.as_mut() {
863 let dim = self.size.icon_size();
864 let icon_size = icon.layout_child(ctx, &BoxConstraints::tight(Size::new(dim, dim)));
865 icon.set_origin(Point::new(
866 (container - icon_size.width) / 2.0,
867 (container - icon_size.height) / 2.0,
868 ));
869 }
870 return bc.constrain(Size::new(container, container));
871 }
872
873 let height = EXTENDED_HEIGHT;
874 let t = self.label_anim.value_clamped();
875 let pad = Tween::new(EXTENDED_PAD_COLLAPSED, EXTENDED_PAD_EXPANDED).lerp(t);
876 let has_icon = self.icon.is_some();
877
878 let mut x = pad;
879 if let Some(icon) = self.icon.as_mut() {
880 let icon_size = icon.layout_child(
881 ctx,
882 &BoxConstraints::tight(Size::new(EXTENDED_ICON, EXTENDED_ICON)),
883 );
884 icon.set_origin(Point::new(x, (height - icon_size.height) / 2.0));
885 x += icon_size.width;
886 }
887 if let Some(label) = self.visible_label.as_mut() {
888 let label_size = label.layout_child(
889 ctx,
890 &BoxConstraints::loose(Size::new(f64::INFINITY, height)),
891 );
892 let gap = if has_icon {
893 EXTENDED_ICON_LABEL_GAP * t
894 } else {
895 0.0
896 };
897 x += gap;
898 label.set_origin(Point::new(x, (height - label_size.height) / 2.0));
899 x += label_size.width * t;
900 }
901 x += pad;
902 bc.constrain(Size::new(x, height))
903 }
904
905 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
906 let theme = Theme::from_paint_ctx(ctx);
912 let (container_color, content_color) = resolve_colors(theme, self.color);
913 let hovered = ctx.is_hovered();
914 let radius_target = self.resolve_radius_target(theme);
915 let (blur, y_offset, shadow_color) = resolve_shadow(theme, hovered);
916
917 self.state_layer.set_hovered(hovered);
918
919 let immediate = self.corner_radius.is_some() && !self.is_extended;
920 let radius = self.advance_radius(ctx, radius_target, immediate);
921
922 if self.press_anim.advance(ctx.frame_time()) {
923 ctx.request_frame();
924 }
925 let pressed_scale = if self.is_extended {
926 EXTENDED_PRESSED_SCALE
927 } else {
928 FAB_PRESSED_SCALE
929 };
930 let scale = Tween::new(1.0, pressed_scale).lerp(self.press_anim.value());
931
932 let label_before = self.label_anim.value_clamped();
947 let label_animating = self.label_anim.advance(ctx.frame_time());
948 if label_animating || self.label_anim.value_clamped() != label_before {
949 ctx.request_layout();
950 }
951
952 let o = ctx.origin();
953 let size = ctx.size();
954 let pivot = Point::new(o.x + size.width / 2.0, o.y + size.height / 2.0);
955 scene.push_transform(scale_about(pivot, scale));
956
957 scene.draw_shadow(
958 Point::new(o.x, o.y + y_offset),
959 size,
960 radius,
961 blur,
962 shadow_color,
963 );
964 scene.fill_rounded_rect(o, size, radius, container_color);
965 self.state_layer.paint(
966 ctx,
967 scene,
968 Rect::from_origin_size(o, size),
969 radius,
970 content_color,
971 );
972 if let Some(icon) = self.icon.as_mut() {
973 icon.paint_child(ctx, scene);
974 }
975 if self.is_extended
982 && let Some(label) = self.visible_label.as_mut()
983 {
984 label.paint_child(ctx, scene);
985 }
986
987 scene.pop_transform();
988 }
989
990 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
991 let InputEvent::Pointer(p) = event else {
992 return EventResult::Ignored;
993 };
994 match p.phase {
995 PointerPhase::Down => {
996 if !presses(p) {
997 return EventResult::Ignored;
998 }
999 self.pressed = true;
1000 self.captured = true;
1001 self.state_layer.set_pressed(true);
1002 self.press_anim.fling(FLING_VELOCITY, PRESS_SPRING);
1003 ctx.capture_pointer();
1004 ctx.request_redraw();
1005 EventResult::Handled
1006 }
1007 PointerPhase::Move => {
1008 if !self.captured {
1009 let over = inside(p.position, ctx.size());
1013 if over {
1014 ctx.claim_hover();
1015 }
1016 if self.state_layer.set_hovered(over) {
1017 ctx.request_redraw();
1018 }
1019 return EventResult::Ignored;
1020 }
1021 let inside_now = inside(p.position, ctx.size());
1022 self.pressed = inside_now;
1023 self.state_layer.set_pressed(inside_now);
1024 ctx.request_redraw();
1025 EventResult::Handled
1026 }
1027 PointerPhase::Up => {
1028 if !self.captured {
1029 return EventResult::Ignored;
1030 }
1031 if inside(p.position, ctx.size()) {
1032 (self.on_press)(ctx);
1033 }
1034 self.pressed = false;
1035 self.captured = false;
1036 self.state_layer.set_pressed(false);
1037 self.press_anim.fling(-FLING_VELOCITY, PRESS_SPRING);
1038 ctx.request_redraw();
1039 EventResult::Handled
1040 }
1041 PointerPhase::Cancel => {
1042 if !self.captured {
1043 return EventResult::Ignored;
1044 }
1045 self.pressed = false;
1046 self.captured = false;
1047 self.state_layer.set_pressed(false);
1048 self.press_anim.fling(-FLING_VELOCITY, PRESS_SPRING);
1049 ctx.request_redraw();
1050 EventResult::Handled
1051 }
1052 }
1053 }
1054
1055 fn semantics(&self, ctx: &mut SemanticsCtx) {
1056 ctx.push_node(Role::Button, |node| {
1057 if let Some(label) = &self.label_text {
1058 node.set_label(label.as_str());
1059 }
1060 node.add_action(Action::Click);
1061 });
1062 }
1063
1064 frust::authoring::visit_children!(icon, visible_label);
1065}
1066
1067#[cfg(test)]
1068mod tests {
1069 use super::*;
1070 use frust::FrameTime;
1071 use frust_widgets::test_support::leaf_any;
1072 use std::any::Any;
1073
1074 fn ft_secs(s: f64) -> FrameTime {
1075 FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
1076 }
1077
1078 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1079 InputEvent::Pointer(frust::authoring::PointerEvent {
1080 phase,
1081 position: Point::new(x, y),
1082 button: frust::authoring::PointerButton::Primary,
1083 })
1084 }
1085
1086 #[derive(Default)]
1090 struct Recorder {
1091 rrects: Vec<(Point, Size, f64, Color)>,
1092 shadows: Vec<(Point, Size, f64, f64, Color)>,
1093 transforms: Vec<Affine>,
1094 transform_pops: u32,
1095 }
1096
1097 impl PaintScene for Recorder {
1098 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1099 fn draw_text(&mut self, _o: Point, _t: &str) {}
1100 fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
1101 self.rrects.push((o, s, radius, color));
1102 }
1103 fn draw_shadow(&mut self, o: Point, s: Size, radius: f64, std_dev: f64, color: Color) {
1104 self.shadows.push((o, s, radius, std_dev, color));
1105 }
1106 fn push_transform(&mut self, transform: Affine) {
1107 self.transforms.push(transform);
1108 }
1109 fn pop_transform(&mut self) {
1110 self.transform_pops += 1;
1111 }
1112 }
1113
1114 fn build_icon_fab(size: FabSize) -> FabWidget {
1115 let view = fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {}).size(size);
1116 let mut counter = 0u64;
1117 View::<()>::build(&view, &mut BuildCtx::new(&mut counter))
1118 }
1119
1120 #[test]
1121 fn container_sizes_match_m3e_theme_table() {
1122 assert_eq!(FabSize::Small.container(), 40.0);
1125 assert_eq!(FabSize::Medium.container(), 56.0);
1126 assert_eq!(FabSize::Large.container(), 96.0);
1127 }
1128
1129 #[test]
1130 fn icon_sizes_match_m3e_theme_table() {
1131 assert_eq!(FabSize::Small.icon_size(), 24.0);
1134 assert_eq!(FabSize::Medium.icon_size(), 24.0);
1135 assert_eq!(FabSize::Large.icon_size(), 36.0);
1136 }
1137
1138 #[test]
1139 fn themed_radii_match_m3e_theme_table() {
1140 let theme = crate::baseline();
1144 assert_eq!(FabSize::Small.themed_radius(&theme.shape), 12.0);
1145 assert_eq!(FabSize::Medium.themed_radius(&theme.shape), 16.0);
1146 assert_eq!(FabSize::Large.themed_radius(&theme.shape), 28.0);
1147 }
1148
1149 #[test]
1150 fn color_mapping_matches_m3e_theme_table_themed() {
1151 let theme = crate::baseline();
1152 let s = theme.scheme();
1153 assert_eq!(
1154 resolve_colors(Some(&theme), FabColor::Primary),
1155 (s.primary_container, s.on_primary_container)
1156 );
1157 assert_eq!(
1158 resolve_colors(Some(&theme), FabColor::Secondary),
1159 (s.secondary_container, s.on_secondary_container)
1160 );
1161 assert_eq!(
1162 resolve_colors(Some(&theme), FabColor::Tertiary),
1163 (s.tertiary_container, s.on_tertiary_container)
1164 );
1165 assert_eq!(
1168 resolve_colors(Some(&theme), FabColor::Surface),
1169 (s.surface_container_high, s.primary)
1170 );
1171 }
1172
1173 #[test]
1174 fn color_mapping_matches_m3e_theme_table_unthemed() {
1175 assert_eq!(
1176 resolve_colors(None, FabColor::Primary),
1177 (PRIMARY_CONTAINER, ON_PRIMARY_CONTAINER)
1178 );
1179 assert_eq!(
1180 resolve_colors(None, FabColor::Secondary),
1181 (SECONDARY_CONTAINER, ON_SECONDARY_CONTAINER)
1182 );
1183 assert_eq!(
1184 resolve_colors(None, FabColor::Tertiary),
1185 (TERTIARY_CONTAINER, ON_TERTIARY_CONTAINER)
1186 );
1187 assert_eq!(
1188 resolve_colors(None, FabColor::Surface),
1189 (SURFACE_CONTAINER_HIGH, SURFACE_FOREGROUND)
1190 );
1191 }
1192
1193 #[test]
1194 fn press_spring_matches_reference() {
1195 let spring = crate::tokens::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS;
1198 assert_eq!(PRESS_SPRING.stiffness, spring.stiffness);
1199 assert_eq!(PRESS_SPRING.damping_ratio, spring.damping_ratio);
1200 assert_eq!(PRESS_SPRING.mass, 1.0);
1201 }
1202
1203 #[test]
1204 fn pressed_scale_targets_match_m3e_theme_table() {
1205 assert_eq!(FAB_PRESSED_SCALE, 0.95);
1207 assert_eq!(EXTENDED_PRESSED_SCALE, 0.97);
1208 }
1209
1210 #[test]
1211 fn layout_is_a_fixed_square_at_the_container_size() {
1212 let mut w = build_icon_fab(FabSize::Large);
1213 let mut lctx = LayoutCtx::new();
1214 let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1215 assert_eq!(size, Size::new(96.0, 96.0));
1216 let icon = w.icon.as_ref().unwrap();
1217 assert_eq!(icon.size(), Size::new(36.0, 36.0));
1218 assert_eq!(icon.origin(), Point::new(30.0, 30.0));
1220 }
1221
1222 #[test]
1223 fn unthemed_paint_uses_fallback_container_and_shadow() {
1224 let mut w = build_icon_fab(FabSize::Medium);
1225 let mut lctx = LayoutCtx::new();
1226 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1227 let mut rec = Recorder::default();
1228 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(56.0, 56.0));
1229 w.paint(&mut pctx, &mut rec);
1230
1231 assert_eq!(rec.rrects[0].3, PRIMARY_CONTAINER);
1232 assert_eq!(rec.rrects[0].2, MEDIUM_RADIUS);
1233 assert_eq!(rec.shadows.len(), 1, "a resting FAB paints one shadow");
1234 assert_eq!(rec.shadows[0].3, LEVEL3_SHADOW_BLUR);
1235 assert_eq!(rec.shadows[0].4, FALLBACK_SHADOW_COLOR);
1236 assert_eq!(
1237 rec.shadows[0].0,
1238 Point::new(0.0, LEVEL3_SHADOW_Y_OFFSET),
1239 "the shadow origin is offset by y_offset"
1240 );
1241 assert_eq!(rec.transforms.len(), 1, "one press-scale transform pushed");
1242 assert_eq!(rec.transform_pops, 1, "paired with one pop");
1243 }
1244
1245 #[test]
1246 fn themed_paint_resolves_primary_container_tokens() {
1247 let theme = crate::baseline();
1248 let mut w = build_icon_fab(FabSize::Small);
1249 let mut lctx = LayoutCtx::new();
1250 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1251 let mut rec = Recorder::default();
1252 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(40.0, 40.0)).with_theme(&theme);
1253 w.paint(&mut pctx, &mut rec);
1254
1255 let scheme = theme.scheme();
1256 assert_eq!(rec.rrects[0].3, scheme.primary_container);
1257 assert_eq!(rec.rrects[0].2, theme.shape.medium);
1258 assert_eq!(
1259 rec.shadows[0].4.components[3],
1260 theme.elevation.level3.shadow(theme.brightness).color_alpha
1261 );
1262 }
1263
1264 #[test]
1265 fn extended_layout_has_fixed_height_and_grows_width() {
1266 let view: FabView<()> =
1267 extended_fab("Compose", |_s: &mut ()| {}).icon(leaf_any(24.0, 24.0));
1268 let mut counter = 0u64;
1269 let mut w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
1270 w.label_anim.advance(FrameTime::ZERO);
1274 w.label_anim.advance(ft_secs(1.0));
1275 let mut tcx = frust::authoring::text::TextContext::new();
1276 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1277 let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1278 assert_eq!(size.height, EXTENDED_HEIGHT);
1279 assert!(size.width > EXTENDED_ICON + EXTENDED_PAD_EXPANDED * 2.0);
1280 }
1281
1282 #[test]
1283 fn extended_label_collapse_animates_width_between_endpoints() {
1284 let extended_view: FabView<()> =
1286 extended_fab("Compose", |_s: &mut ()| {}).icon(leaf_any(24.0, 24.0));
1287 let mut counter = 0u64;
1288 let mut w = View::<()>::build(&extended_view, &mut BuildCtx::new(&mut counter));
1289 w.label_anim.advance(FrameTime::ZERO);
1290 w.label_anim.advance(ft_secs(1.0)); assert_eq!(w.label_anim.value_clamped(), 1.0);
1292 let mut tcx = frust::authoring::text::TextContext::new();
1293 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1294 let extended_size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1295
1296 let collapsed_view: FabView<()> = extended_fab("Compose", |_s: &mut ()| {})
1299 .icon(leaf_any(24.0, 24.0))
1300 .extended(false);
1301 let mut w2 = View::<()>::build(&collapsed_view, &mut BuildCtx::new(&mut counter));
1302 assert_eq!(w2.label_anim.value_clamped(), 0.0);
1303 let mut tcx2 = frust::authoring::text::TextContext::new();
1304 let mut lctx2 = LayoutCtx::with_text_context(&mut tcx2 as &mut dyn Any);
1305 let collapsed_size = w2.layout(&mut lctx2, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1306
1307 assert_eq!(extended_size.height, EXTENDED_HEIGHT);
1308 assert_eq!(collapsed_size.height, EXTENDED_HEIGHT);
1309 assert!(
1310 collapsed_size.width < extended_size.width,
1311 "a collapsed extended FAB is narrower than a fully open one"
1312 );
1313 assert_eq!(
1315 collapsed_size.width,
1316 EXTENDED_PAD_COLLAPSED * 2.0 + EXTENDED_ICON
1317 );
1318 }
1319
1320 #[test]
1327 fn rebuild_extended_to_plain_tears_down_the_stale_label_pod() {
1328 let extended_view: FabView<()> =
1329 extended_fab("Compose", |_s: &mut ()| {}).icon(leaf_any(24.0, 24.0));
1330 let mut counter = 0u64;
1331 let mut w = View::<()>::build(&extended_view, &mut BuildCtx::new(&mut counter));
1332 assert!(
1333 w.visible_label.is_some(),
1334 "an extended FAB mounts a visible label pod"
1335 );
1336
1337 let plain_view: FabView<()> = fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {});
1338 View::<()>::rebuild(
1339 &plain_view,
1340 &extended_view,
1341 &mut w,
1342 &mut BuildCtx::new(&mut counter),
1343 );
1344 assert!(
1345 w.visible_label.is_none(),
1346 "swapping to a plain FAB tears down the stale label pod"
1347 );
1348
1349 let mut lctx = LayoutCtx::new();
1350 let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1351 assert_eq!(
1352 size,
1353 Size::new(MEDIUM_CONTAINER, MEDIUM_CONTAINER),
1354 "the collapsed FAB lays out as the plain square, not the old pill"
1355 );
1356 }
1357
1358 #[test]
1364 fn rebuild_plain_to_extended_mounts_the_label_and_reveal_animates() {
1365 let plain_view: FabView<()> = fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {});
1366 let mut counter = 0u64;
1367 let mut w = View::<()>::build(&plain_view, &mut BuildCtx::new(&mut counter));
1368 w.label_anim.advance(FrameTime::ZERO);
1373 w.label_anim.advance(ft_secs(1.0));
1374 assert_eq!(w.label_anim.value_clamped(), 1.0);
1375 assert!(w.visible_label.is_none());
1376
1377 let extended_view: FabView<()> =
1378 extended_fab("Compose", |_s: &mut ()| {}).icon(leaf_any(24.0, 24.0));
1379 View::<()>::rebuild(
1380 &extended_view,
1381 &plain_view,
1382 &mut w,
1383 &mut BuildCtx::new(&mut counter),
1384 );
1385 assert!(
1386 w.visible_label.is_some(),
1387 "swapping to an extended FAB mounts the label pod"
1388 );
1389
1390 let mut tcx = frust::authoring::text::TextContext::new();
1391 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1392 let just_swapped_size =
1393 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1394
1395 w.label_anim.advance(FrameTime::ZERO);
1399 w.label_anim.advance(ft_secs(1.0));
1400 let mut tcx2 = frust::authoring::text::TextContext::new();
1401 let mut lctx2 = LayoutCtx::with_text_context(&mut tcx2 as &mut dyn Any);
1402 let grown_size = w.layout(&mut lctx2, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1403
1404 assert!(
1405 grown_size.width > just_swapped_size.width,
1406 "the label grows in across advanced frames: {just_swapped_size:?} -> {grown_size:?}"
1407 );
1408 }
1409
1410 #[test]
1418 fn paint_requests_layout_while_label_reveal_is_in_flight_and_stops_once_settled() {
1419 fn layout_at(w: &mut FabWidget) -> Size {
1420 let mut tcx = frust::authoring::text::TextContext::new();
1421 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1422 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)))
1423 }
1424 fn paint_at(w: &mut FabWidget, size: Size, t: f64) -> (Recorder, bool) {
1425 let mut rec = Recorder::default();
1426 let mut ctx = PaintCtx::for_test(Point::ZERO, size, ft_secs(t));
1427 w.paint(&mut ctx, &mut rec);
1428 let needs_layout = ctx.needs_layout();
1429 (rec, needs_layout)
1430 }
1431
1432 let collapsed: FabView<()> = extended_fab("Compose", |_s: &mut ()| {})
1433 .icon(leaf_any(24.0, 24.0))
1434 .extended(false);
1435 let mut counter = 0u64;
1436 let mut w = View::<()>::build(&collapsed, &mut BuildCtx::new(&mut counter));
1437 let mut size = layout_at(&mut w);
1438 let collapsed_width = size.width;
1439
1440 let expanded: FabView<()> =
1445 extended_fab("Compose", |_s: &mut ()| {}).icon(leaf_any(24.0, 24.0));
1446 View::<()>::rebuild(
1447 &expanded,
1448 &collapsed,
1449 &mut w,
1450 &mut BuildCtx::new(&mut counter),
1451 );
1452 size = layout_at(&mut w);
1453 assert_eq!(size.width, collapsed_width);
1454
1455 let mut widths = vec![size.width];
1456 let mut frames = 0u32;
1457 let mut t = 0.0;
1458 loop {
1459 let (_, needs_layout) = paint_at(&mut w, size, t);
1460 if !needs_layout {
1461 break;
1462 }
1463 frames += 1;
1464 size = layout_at(&mut w);
1468 widths.push(size.width);
1469 t += 1.0 / 60.0;
1470 assert!(
1471 frames < 600,
1472 "the reveal should settle well inside 600 frames"
1473 );
1474 }
1475 assert!(frames > 1, "the reveal spans more than one frame");
1476 assert!(
1477 widths.windows(2).all(|pair| pair[1] + 1e-9 >= pair[0]),
1478 "the pill's laid-out width never shrinks while opening: {widths:?}"
1479 );
1480 let last = *widths.last().expect("at least one frame recorded");
1481 assert!(
1482 last > collapsed_width,
1483 "the settled pill is wider than the frozen collapsed square: {last} > {collapsed_width}"
1484 );
1485
1486 let (_, still) = paint_at(&mut w, size, t + 1.0 / 60.0);
1488 assert!(!still, "settled: no more layout requests");
1489 }
1490
1491 #[test]
1492 fn corner_radius_override_is_immediate_no_animation() {
1493 let view: FabView<()> =
1494 fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {}).corner_radius(Some(40.0));
1495 let mut counter = 0u64;
1496 let mut w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
1497 let mut lctx = LayoutCtx::new();
1498 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1499
1500 let mut rec = Recorder::default();
1501 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(56.0, 56.0));
1502 w.paint(&mut pctx, &mut rec);
1503 assert_eq!(rec.rrects[0].2, 40.0, "paints the override radius exactly");
1504
1505 let view2: FabView<()> =
1508 fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {}).corner_radius(Some(8.0));
1509 let mut lctx2 = LayoutCtx::new();
1510 View::<()>::rebuild(&view2, &view, &mut w, &mut BuildCtx::new(&mut counter));
1511 w.layout(&mut lctx2, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1512 let mut rec2 = Recorder::default();
1513 let mut pctx2 = PaintCtx::new(Point::ZERO, Size::new(56.0, 56.0));
1514 w.paint(&mut pctx2, &mut rec2);
1515 assert_eq!(
1516 rec2.rrects[0].2, 8.0,
1517 "a changed override still snaps immediately, no ease from the prior value"
1518 );
1519 }
1520
1521 #[test]
1522 fn corner_radius_none_eases_a_changed_target_with_short_4() {
1523 let mut w = build_icon_fab(FabSize::Small);
1524 let mut lctx = LayoutCtx::new();
1525 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1526
1527 let theme = crate::baseline();
1528 let mut rec = Recorder::default();
1531 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(40.0, 40.0)).with_theme(&theme);
1532 w.paint(&mut pctx, &mut rec);
1533 assert_eq!(rec.rrects[0].2, 12.0);
1534
1535 let prev_view: FabView<()> =
1540 fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {}).size(FabSize::Small);
1541 let next_view: FabView<()> =
1542 fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {}).size(FabSize::Large);
1543 let mut counter = 1u64;
1544 View::<()>::rebuild(
1545 &next_view,
1546 &prev_view,
1547 &mut w,
1548 &mut BuildCtx::new(&mut counter),
1549 );
1550 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1551
1552 let mut rec2 = Recorder::default();
1553 let mut pctx2 = PaintCtx::new(Point::ZERO, Size::new(96.0, 96.0)).with_theme(&theme);
1554 w.paint(&mut pctx2, &mut rec2);
1555 assert_eq!(
1556 rec2.rrects[0].2, 12.0,
1557 "the change-triggering frame hasn't eased yet (first advance only seeds the clock)"
1558 );
1559
1560 w.radius_anim.advance(ft_secs(1.0));
1562 let mut rec3 = Recorder::default();
1563 let mut pctx3 = PaintCtx::new(Point::ZERO, Size::new(96.0, 96.0)).with_theme(&theme);
1564 w.paint(&mut pctx3, &mut rec3);
1565 assert_eq!(
1566 rec3.rrects[0].2, 28.0,
1567 "settled at the new (Large) themed radius"
1568 );
1569 }
1570
1571 fn icon_stub<State: 'static>() -> AnyView<State> {
1576 frust::authoring::any::<State, _>(frust::text("i"))
1577 }
1578
1579 fn dispatch(w: &mut FabWidget, state: &mut u32, event: &InputEvent, size: Size) {
1580 let state_any: &mut dyn Any = state;
1581 let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
1582 w.event(&mut ctx, event);
1583 }
1584
1585 #[test]
1586 fn up_inside_fires_on_press() {
1587 let view: FabView<u32> =
1588 fab(icon_stub::<u32>(), |s: &mut u32| *s += 1).size(FabSize::Medium);
1589 let mut counter = 0u64;
1590 let mut w = View::<u32>::build(&view, &mut BuildCtx::new(&mut counter));
1591 let mut state = 0u32;
1592 let size = Size::new(56.0, 56.0);
1593 dispatch(
1594 &mut w,
1595 &mut state,
1596 &ev(PointerPhase::Down, 28.0, 28.0),
1597 size,
1598 );
1599 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 28.0, 28.0), size);
1600 assert_eq!(state, 1);
1601 }
1602
1603 #[test]
1604 fn up_outside_does_not_fire() {
1605 let view: FabView<u32> = fab(icon_stub::<u32>(), |s: &mut u32| *s += 1);
1606 let mut counter = 0u64;
1607 let mut w = View::<u32>::build(&view, &mut BuildCtx::new(&mut counter));
1608 let mut state = 0u32;
1609 let size = Size::new(56.0, 56.0);
1610 dispatch(
1611 &mut w,
1612 &mut state,
1613 &ev(PointerPhase::Down, 10.0, 10.0),
1614 size,
1615 );
1616 dispatch(
1617 &mut w,
1618 &mut state,
1619 &ev(PointerPhase::Move, 500.0, 500.0),
1620 size,
1621 );
1622 dispatch(
1623 &mut w,
1624 &mut state,
1625 &ev(PointerPhase::Up, 500.0, 500.0),
1626 size,
1627 );
1628 assert_eq!(state, 0);
1629 }
1630
1631 #[test]
1632 fn cancel_clears_armed_state_without_firing() {
1633 let view: FabView<u32> = fab(icon_stub::<u32>(), |s: &mut u32| *s += 1);
1634 let mut counter = 0u64;
1635 let mut w = View::<u32>::build(&view, &mut BuildCtx::new(&mut counter));
1636 let mut state = 0u32;
1637 let size = Size::new(56.0, 56.0);
1638 dispatch(
1639 &mut w,
1640 &mut state,
1641 &ev(PointerPhase::Down, 10.0, 10.0),
1642 size,
1643 );
1644 assert!(w.captured);
1645 dispatch(
1646 &mut w,
1647 &mut state,
1648 &ev(PointerPhase::Cancel, 10.0, 10.0),
1649 size,
1650 );
1651 assert!(!w.captured);
1652 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0), size);
1653 assert_eq!(state, 0);
1654 }
1655
1656 #[test]
1657 fn hover_move_outside_bounds_without_down_is_ignored_noop() {
1658 let mut w = build_icon_fab(FabSize::Medium);
1659 let mut state = 0u32;
1660 let state_any: &mut dyn Any = &mut state;
1661 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(56.0, 56.0));
1662 let result = w.event(&mut ctx, &ev(PointerPhase::Move, 500.0, 500.0));
1663 assert!(matches!(result, EventResult::Ignored));
1664 assert!(
1665 !ctx.needs_redraw(),
1666 "outside the bounds, nothing claims hover"
1667 );
1668 }
1669
1670 #[test]
1671 fn hover_move_inside_bounds_without_down_claims_hover_and_redraws() {
1672 let mut w = build_icon_fab(FabSize::Medium);
1677 let mut state = 0u32;
1678 let state_any: &mut dyn Any = &mut state;
1679 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(56.0, 56.0));
1680 let result = w.event(&mut ctx, &ev(PointerPhase::Move, 10.0, 10.0));
1681 assert!(matches!(result, EventResult::Ignored));
1682 assert!(ctx.needs_redraw());
1683 }
1684
1685 #[test]
1686 fn down_starts_the_press_scale_spring_toward_pressed_and_up_reverses_it() {
1687 let view: FabView<u32> = fab(icon_stub::<u32>(), |_s: &mut u32| {}).size(FabSize::Medium);
1688 let mut counter = 0u64;
1689 let mut w = View::<u32>::build(&view, &mut BuildCtx::new(&mut counter));
1690 let mut state = 0u32;
1691 let size = Size::new(56.0, 56.0);
1692 assert!(!w.press_anim.is_animating());
1693
1694 dispatch(
1695 &mut w,
1696 &mut state,
1697 &ev(PointerPhase::Down, 28.0, 28.0),
1698 size,
1699 );
1700 assert!(
1701 w.press_anim.is_animating(),
1702 "Down starts the fling toward pressed"
1703 );
1704
1705 let mut t = 0.0;
1708 let mut running = true;
1709 for _ in 0..100_000 {
1710 running = w.press_anim.advance(ft_secs(t));
1711 if !running {
1712 break;
1713 }
1714 t += 1.0 / 120.0;
1715 }
1716 assert!(!running, "press-in fling failed to settle");
1717 let pressed_scale = Tween::new(1.0, FAB_PRESSED_SCALE).lerp(w.press_anim.value());
1718 assert!((pressed_scale - FAB_PRESSED_SCALE).abs() < 1e-6);
1719
1720 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 28.0, 28.0), size);
1721 assert!(
1722 w.press_anim.is_animating(),
1723 "Up starts the fling back to rest"
1724 );
1725 t = 0.0;
1726 running = true;
1727 for _ in 0..100_000 {
1728 running = w.press_anim.advance(ft_secs(t));
1729 if !running {
1730 break;
1731 }
1732 t += 1.0 / 120.0;
1733 }
1734 assert!(!running, "press-out fling failed to settle");
1735 let rest_scale = Tween::new(1.0, FAB_PRESSED_SCALE).lerp(w.press_anim.value());
1736 assert!((rest_scale - 1.0).abs() < 1e-6);
1737 }
1738
1739 #[test]
1740 fn semantics_reports_button_role_and_label() {
1741 fn logic(_s: &mut ()) -> FabView<()> {
1742 fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {}).label("Compose")
1743 }
1744 let mut root: frust_core::RenderRoot<(), FabView<()>> = frust_core::RenderRoot::new();
1745 let mut state = ();
1746 root.rebuild(&mut logic, &mut state);
1747 root.layout(Size::new(200.0, 200.0));
1748 let update = root.semantics();
1749 let (_, node) = update
1750 .nodes
1751 .iter()
1752 .find(|(_, n)| n.role() == Role::Button)
1753 .expect("fab contributes a Role::Button node");
1754 assert_eq!(node.label(), Some("Compose"));
1755 assert!(node.supports_action(Action::Click));
1756 }
1757
1758 #[test]
1759 fn extended_semantics_uses_visible_label_as_accessible_name() {
1760 fn logic(_s: &mut ()) -> FabView<()> {
1761 extended_fab::<(), _>("New item", |_s: &mut ()| {})
1762 }
1763 let mut root: frust_core::RenderRoot<(), FabView<()>> = frust_core::RenderRoot::new();
1764 let mut state = ();
1765 root.rebuild(&mut logic, &mut state);
1766 let mut tcx = frust::authoring::text::TextContext::new();
1767 root.layout_with_text(Size::new(300.0, 200.0), &mut tcx as &mut dyn Any);
1768 let update = root.semantics();
1769 let (_, node) = update
1770 .nodes
1771 .iter()
1772 .find(|(_, n)| n.role() == Role::Button)
1773 .expect("extended fab contributes a Role::Button node");
1774 assert_eq!(node.label(), Some("New item"));
1775 }
1776
1777 #[test]
1782 fn hover_lifts_elevation_from_level3_to_level4() {
1783 fn logic(_s: &mut ()) -> FabView<()> {
1784 fab::<(), _>(leaf_any(24.0, 24.0), |_s: &mut ()| {})
1785 }
1786 let mut root: frust_core::RenderRoot<(), FabView<()>> = frust_core::RenderRoot::new();
1787 let mut state = ();
1788 root.rebuild(&mut logic, &mut state);
1789 root.layout(Size::new(200.0, 200.0));
1790
1791 let mut rec = Recorder::default();
1792 root.paint(&mut rec, frust::FrameTime::ZERO);
1793 assert_eq!(
1794 rec.shadows[0].3, LEVEL3_SHADOW_BLUR,
1795 "resting FAB is at level 3"
1796 );
1797
1798 root.event(
1799 &mut state,
1800 &InputEvent::Pointer(frust::authoring::PointerEvent {
1801 phase: PointerPhase::Move,
1802 position: Point::new(28.0, 28.0),
1803 button: frust::authoring::PointerButton::Primary,
1804 }),
1805 );
1806 let mut rec2 = Recorder::default();
1807 root.paint(&mut rec2, frust::FrameTime::ZERO);
1808 assert_eq!(
1809 rec2.shadows[0].3, LEVEL4_SHADOW_BLUR,
1810 "a hovered FAB lifts to level 4"
1811 );
1812 }
1813}