1use std::sync::OnceLock;
219use std::time::Duration;
220
221use frust::authoring::{Action, Role};
222use frust::authoring::{
223 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
224 Key, LayoutCtx, NamedKey, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget,
225};
226use frust::{AnimationController, FrameTime, SpringDesc, Theme};
227use kurbo::{Affine, BezPath, Point, Rect, Size};
228use peniko::{Brush, Color};
229
230use frust::authoring::{ThemeTextColor, ThemeTextType};
231
232use super::press::presses;
233use crate::shapes::{Morph, ShapeKind};
234use crate::tokens::MaterialSpring;
235
236const MAIN_CONTAINER_CLOSED: f64 = 80.0;
239const MAIN_CONTAINER_OPEN: f64 = 56.0;
243const MAIN_ICON: f64 = 24.0;
248
249const EDGE_MARGIN: f64 = 16.0;
255
256const ITEM_HEIGHT: f64 = 56.0;
259const ITEM_ICON: f64 = 24.0;
262const ITEM_PAD_X: f64 = 20.0;
266const ITEM_ICON_LABEL_GAP: f64 = 12.0;
269const ITEM_GAP: f64 = 12.0;
272const FAB_TO_ITEMS_GAP: f64 = 12.0;
276
277const MAIN_CONTAINER_COLOR: Color = Color::from_rgb8(0xEA, 0xDD, 0xFF);
280
281const PRIMARY_CONTAINER: Color = Color::from_rgb8(0xEA, 0xDD, 0xFF);
285const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
288const TERTIARY_CONTAINER: Color = Color::from_rgb8(0xFF, 0xD8, 0xE4);
291
292const SCRIM_ALPHA: f32 = 0.0;
296const SCRIM_FALLBACK: Color = Color::from_rgb8(0x00, 0x00, 0x00);
298
299const FALLBACK_SHADOW_Y_OFFSET: f64 = 4.0;
303const FALLBACK_SHADOW_BLUR: f64 = 6.0;
306const FALLBACK_SHADOW_COLOR: Color = Color::new([0.0, 0.0, 0.0, 0.3]);
309
310const TRIGGER_CLOSED_SHAPE: ShapeKind = ShapeKind::Square;
313const TRIGGER_OPEN_SHAPE: ShapeKind = ShapeKind::Circle;
316
317const SHADOW_RADIUS_FRACTION_CLOSED: f64 = 16.0 / 56.0;
325
326const FAB_SHAPE_SPRING: SpringDesc = SpringDesc {
332 mass: 1.0,
333 stiffness: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.stiffness,
334 damping_ratio: 0.7,
335};
336
337const ITEM_SPRING: SpringDesc = SpringDesc {
342 mass: 1.0,
343 stiffness: MaterialSpring::EXPRESSIVE_SPATIAL_PRESS.stiffness,
344 damping_ratio: MaterialSpring::EXPRESSIVE_SPATIAL_PRESS.damping_ratio,
345};
346
347const EXPAND_STAGGER_MS: u64 = 30;
350
351const ANIM_PERIOD: Duration = Duration::from_millis(300);
354
355const FLING_VELOCITY: f64 = 3.0;
360
361fn with_alpha(color: Color, alpha: f32) -> Color {
363 let c = color.components;
364 Color::new([c[0], c[1], c[2], alpha])
365}
366
367fn lerp(a: f64, b: f64, t: f64) -> f64 {
371 a + (b - a) * t
372}
373
374fn finite_or_zero(v: f64) -> f64 {
378 if v.is_finite() { v } else { 0.0 }
379}
380
381fn icon_size_for(container: f64) -> f64 {
386 MAIN_ICON * (container / MAIN_CONTAINER_OPEN)
387}
388
389fn trigger_shadow_radius(container: f64, t: f64) -> f64 {
393 let closed = container * SHADOW_RADIUS_FRACTION_CLOSED;
394 let open = container / 2.0;
395 lerp(closed, open, t.clamp(0.0, 1.0))
396}
397
398fn trigger_morph() -> &'static Morph {
401 static CACHE: OnceLock<Morph> = OnceLock::new();
402 CACHE.get_or_init(|| {
403 Morph::new(
404 TRIGGER_CLOSED_SHAPE.polygon().clone(),
405 TRIGGER_OPEN_SHAPE.polygon().clone(),
406 )
407 })
408}
409
410fn trigger_path(t: f64, rect: Rect) -> BezPath {
418 let raw = trigger_morph().path(t.clamp(0.0, 1.0));
419 let scale = Affine::new([rect.width(), 0.0, 0.0, rect.height(), 0.0, 0.0]);
420 Affine::translate(rect.origin().to_vec2()) * (scale * raw)
421}
422
423fn cascade_delay(i: usize, count: usize, opening: bool) -> Duration {
426 let step = if opening {
427 i
428 } else {
429 count.saturating_sub(1).saturating_sub(i)
430 };
431 Duration::from_millis(step as u64 * EXPAND_STAGGER_MS)
432}
433
434fn snap_anim(anim: &mut AnimationController, open: bool, now: FrameTime) -> bool {
443 let target = if open { 1.0 } else { 0.0 };
444 let moved = anim.value() != target;
445 *anim = AnimationController::new(Duration::ZERO);
446 if open {
447 anim.forward();
448 } else {
449 anim.reverse();
450 }
451 anim.advance(now);
452 moved
453}
454
455fn resolve_main_color(theme: Option<&Theme>) -> Color {
458 match theme {
459 Some(theme) => theme.scheme().primary_container,
460 None => MAIN_CONTAINER_COLOR,
461 }
462}
463
464fn resolve_shadow(theme: Option<&Theme>) -> (f64, f64, Color) {
467 match theme {
468 Some(theme) => {
469 let level = theme.elevation.level3;
470 let shadow = level.shadow(theme.brightness);
471 let color = with_alpha(theme.scheme().shadow, shadow.color_alpha);
472 (shadow.blur_std_dev, shadow.y_offset, color)
473 }
474 None => (
475 FALLBACK_SHADOW_BLUR,
476 FALLBACK_SHADOW_Y_OFFSET,
477 FALLBACK_SHADOW_COLOR,
478 ),
479 }
480}
481
482fn resolve_scrim_base(theme: Option<&Theme>) -> Color {
485 match theme {
486 Some(theme) => theme.scheme().scrim,
487 None => SCRIM_FALLBACK,
488 }
489}
490
491fn resolve_item_color(theme: Option<&Theme>, index: usize) -> Color {
496 match theme {
497 Some(theme) => {
498 let s = theme.scheme();
499 match index % 3 {
500 0 => s.primary_container,
501 1 => s.secondary_container,
502 _ => s.tertiary_container,
503 }
504 }
505 None => match index % 3 {
506 0 => PRIMARY_CONTAINER,
507 1 => SECONDARY_CONTAINER,
508 _ => TERTIARY_CONTAINER,
509 },
510 }
511}
512
513fn item_radius() -> f64 {
516 ITEM_HEIGHT / 2.0
517}
518
519fn label_view<State: 'static>(label: String) -> AnyView<State> {
524 frust::authoring::any::<State, _>(
525 frust::text(label)
526 .themed_role(ThemeTextColor::OnSurface)
527 .themed_family(ThemeTextType::TitleMedium),
528 )
529}
530
531#[derive(Clone, Copy, Debug, PartialEq, Eq)]
533enum Target {
534 Fab,
536 Item(usize),
538 Scrim,
540}
541
542pub struct FabMenuItem<State: 'static> {
544 icon: AnyView<State>,
545 label: String,
546 on_select: std::rc::Rc<dyn Fn(&mut State)>,
547}
548
549pub fn fab_menu_item<State: 'static, F: Fn(&mut State) + 'static>(
554 icon: AnyView<State>,
555 label: impl Into<String>,
556 on_select: F,
557) -> FabMenuItem<State> {
558 FabMenuItem {
559 icon,
560 label: label.into(),
561 on_select: std::rc::Rc::new(on_select),
562 }
563}
564
565pub struct FabMenuView<State: 'static> {
567 icon: AnyView<State>,
568 open: bool,
570 items: Vec<FabMenuItem<State>>,
571 label: Option<String>,
573 on_toggle: std::rc::Rc<dyn Fn(&mut State)>,
574}
575
576pub fn fab_menu<State: 'static, F: Fn(&mut State) + 'static>(
583 icon: AnyView<State>,
584 open: bool,
585 items: Vec<FabMenuItem<State>>,
586 on_toggle: F,
587) -> FabMenuView<State> {
588 FabMenuView {
589 icon,
590 open,
591 items,
592 label: None,
593 on_toggle: std::rc::Rc::new(on_toggle),
594 }
595}
596
597#[allow(non_snake_case)]
600pub fn FabMenu<State: 'static, F: Fn(&mut State) + 'static>(
601 icon: AnyView<State>,
602 open: bool,
603 items: Vec<FabMenuItem<State>>,
604 on_toggle: F,
605) -> FabMenuView<State> {
606 fab_menu(icon, open, items, on_toggle)
607}
608
609impl<State: 'static> FabMenuView<State> {
610 pub fn label(mut self, label: impl Into<String>) -> Self {
612 self.label = Some(label.into());
613 self
614 }
615}
616
617struct FabMenuItemPod {
619 icon: ChildPod,
620 label: ChildPod,
621 on_select: frust::authoring::ErasedCallback,
622}
623
624impl frust::authoring::VisitPods for FabMenuItemPod {
625 fn visit_pods(&self, visitor: &mut dyn FnMut(&ChildPod)) {
626 visitor(&self.icon);
627 visitor(&self.label);
628 }
629}
630
631pub struct FabMenuWidget {
633 icon: ChildPod,
634 items: Vec<FabMenuItemPod>,
635 item_labels: Vec<String>,
637 open: bool,
639 label: Option<String>,
641 fab_morph: AnimationController,
645 item_anim: Vec<AnimationController>,
647 cascade_launched: Vec<bool>,
650 cascade_epoch: Option<FrameTime>,
654 fab_rect: Rect,
656 item_rects: Vec<Rect>,
658 armed: Option<Target>,
660 pressed_inside: bool,
663 on_toggle: frust::authoring::ErasedCallback,
664}
665
666impl FabMenuWidget {
667 fn drive_reveal(&mut self) {
674 let velocity = if self.open {
675 FLING_VELOCITY
676 } else {
677 -FLING_VELOCITY
678 };
679 self.fab_morph.fling(velocity, FAB_SHAPE_SPRING);
680 self.cascade_launched = vec![false; self.item_anim.len()];
681 self.cascade_epoch = None;
682 }
683
684 fn advance_cascade(&mut self, now: FrameTime) -> bool {
691 let epoch = *self.cascade_epoch.get_or_insert(now);
692 let elapsed = now.saturating_sub(epoch);
693 let n = self.item_anim.len();
694 let mut moved = false;
695 for i in 0..n {
696 if !self.cascade_launched[i] && elapsed >= cascade_delay(i, n, self.open) {
697 let velocity = if self.open {
698 FLING_VELOCITY
699 } else {
700 -FLING_VELOCITY
701 };
702 self.item_anim[i].fling(velocity, ITEM_SPRING);
703 self.cascade_launched[i] = true;
704 }
705 moved |= self.item_anim[i].advance(now);
706 if !self.open && self.item_anim[i].is_animating() && self.item_anim[i].value() <= 0.0 {
716 snap_anim(&mut self.item_anim[i], false, now);
717 }
718 }
719 moved
720 }
721}
722
723impl<State: 'static> View<State> for FabMenuView<State> {
724 type Element = FabMenuWidget;
725
726 fn build(&self, ctx: &mut BuildCtx<'_>) -> FabMenuWidget {
727 let items = self
728 .items
729 .iter()
730 .map(|item| FabMenuItemPod {
731 icon: frust::authoring::build_child(&item.icon, ctx),
732 label: frust::authoring::build_child(&label_view::<State>(item.label.clone()), ctx),
733 on_select: frust::authoring::erase_callback(&item.on_select),
734 })
735 .collect();
736
737 let n = self.items.len();
738 let mut fab_morph = AnimationController::new(ANIM_PERIOD);
739 let mut item_anim: Vec<AnimationController> = (0..n)
740 .map(|_| AnimationController::new(ANIM_PERIOD))
741 .collect();
742 snap_anim(&mut fab_morph, self.open, FrameTime::ZERO);
748 for a in item_anim.iter_mut() {
749 snap_anim(a, self.open, FrameTime::ZERO);
750 }
751 let cascade_launched = vec![true; n];
752
753 FabMenuWidget {
754 icon: frust::authoring::build_child(&self.icon, ctx),
755 items,
756 item_labels: self.items.iter().map(|i| i.label.clone()).collect(),
757 open: self.open,
758 label: self.label.clone(),
759 fab_morph,
760 item_anim,
761 cascade_launched,
762 cascade_epoch: None,
763 fab_rect: Rect::ZERO,
764 item_rects: Vec::new(),
765 armed: None,
766 pressed_inside: false,
767 on_toggle: frust::authoring::erase_callback(&self.on_toggle),
768 }
769 }
770
771 fn rebuild(
772 &self,
773 prev: &Self,
774 element: &mut FabMenuWidget,
775 ctx: &mut BuildCtx<'_>,
776 ) -> ChangeFlags {
777 element.on_toggle = frust::authoring::erase_callback(&self.on_toggle);
778 let mut flags = ChangeFlags::NONE;
779
780 flags |= frust::authoring::rebuild_child(&prev.icon, &self.icon, &mut element.icon, ctx);
781
782 if prev.open != self.open {
783 element.open = self.open;
784 element.drive_reveal();
785 if !self.open {
788 element.armed = None;
789 element.pressed_inside = false;
790 }
791 flags |= ChangeFlags::PAINT;
792 }
793
794 if prev.label != self.label {
795 element.label = self.label.clone();
796 }
797
798 if prev.items.len() != self.items.len() {
799 for (old_item, pod) in prev.items.iter().zip(element.items.iter_mut()) {
803 frust::authoring::teardown_child(&old_item.icon, &mut pod.icon, ctx);
804 let old_label_view = label_view::<State>(old_item.label.clone());
805 frust::authoring::teardown_child(&old_label_view, &mut pod.label, ctx);
806 }
807 element.items = self
808 .items
809 .iter()
810 .map(|item| FabMenuItemPod {
811 icon: frust::authoring::build_child(&item.icon, ctx),
812 label: frust::authoring::build_child(
813 &label_view::<State>(item.label.clone()),
814 ctx,
815 ),
816 on_select: frust::authoring::erase_callback(&item.on_select),
817 })
818 .collect();
819 element.item_labels = self.items.iter().map(|i| i.label.clone()).collect();
820 element.armed = None;
821 element.pressed_inside = false;
822 let n = self.items.len();
826 element.item_anim = (0..n)
827 .map(|_| AnimationController::new(ANIM_PERIOD))
828 .collect();
829 element.cascade_epoch = None;
830 for a in element.item_anim.iter_mut() {
834 snap_anim(a, element.open, FrameTime::ZERO);
835 }
836 element.cascade_launched = vec![true; n];
837 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
838 } else {
839 for (i, (prev_item, next_item)) in prev.items.iter().zip(self.items.iter()).enumerate()
840 {
841 let pod = &mut element.items[i];
842 flags |= frust::authoring::rebuild_child(
843 &prev_item.icon,
844 &next_item.icon,
845 &mut pod.icon,
846 ctx,
847 );
848 if prev_item.label != next_item.label {
849 let prev_view = label_view::<State>(prev_item.label.clone());
850 let next_view = label_view::<State>(next_item.label.clone());
851 flags |= frust::authoring::rebuild_child(
852 &prev_view,
853 &next_view,
854 &mut pod.label,
855 ctx,
856 );
857 element.item_labels[i] = next_item.label.clone();
858 }
859 pod.on_select = frust::authoring::erase_callback(&next_item.on_select);
860 }
861 }
862
863 flags
864 }
865
866 fn teardown(&self, element: &mut FabMenuWidget, ctx: &mut BuildCtx<'_>) {
867 frust::authoring::teardown_child(&self.icon, &mut element.icon, ctx);
868 for (item, pod) in self.items.iter().zip(element.items.iter_mut()) {
869 frust::authoring::teardown_child(&item.icon, &mut pod.icon, ctx);
870 let label_view = label_view::<State>(item.label.clone());
871 frust::authoring::teardown_child(&label_view, &mut pod.label, ctx);
872 }
873 }
874}
875
876impl Widget for FabMenuWidget {
877 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
878 let area_w = finite_or_zero(bc.max().width);
879 let area_h = finite_or_zero(bc.max().height);
880
881 let fab_t = self.fab_morph.value_clamped();
884 let container = lerp(MAIN_CONTAINER_CLOSED, MAIN_CONTAINER_OPEN, fab_t);
885 let icon_size = icon_size_for(container);
886
887 let icon_layout_size = self
888 .icon
889 .layout_child(ctx, &BoxConstraints::tight(Size::new(icon_size, icon_size)));
890 let fab_origin = Point::new(
891 (area_w - EDGE_MARGIN - container).max(0.0),
892 (area_h - EDGE_MARGIN - container).max(0.0),
893 );
894 self.fab_rect = Rect::from_origin_size(fab_origin, Size::new(container, container));
895 self.icon.set_origin(Point::new(
896 fab_origin.x + (container - icon_layout_size.width) / 2.0,
897 fab_origin.y + (container - icon_layout_size.height) / 2.0,
898 ));
899
900 self.item_rects.clear();
903 let right_edge = self.fab_rect.x1;
904 let mut bottom = self.fab_rect.y0 - FAB_TO_ITEMS_GAP;
905 let radius = item_radius();
906 for pod in self.items.iter_mut() {
907 let icon_size = pod
908 .icon
909 .layout_child(ctx, &BoxConstraints::tight(Size::new(ITEM_ICON, ITEM_ICON)));
910 let label_size = pod.label.layout_child(
911 ctx,
912 &BoxConstraints::loose(Size::new(f64::INFINITY, ITEM_HEIGHT)),
913 );
914 let content_w = icon_size.width + ITEM_ICON_LABEL_GAP + label_size.width;
915 let item_w = (content_w + ITEM_PAD_X * 2.0).max(radius * 2.0);
916 let top = bottom - ITEM_HEIGHT;
917 let rect = Rect::new((right_edge - item_w).max(0.0), top, right_edge, bottom);
918
919 pod.icon.set_origin(Point::new(
920 rect.x0 + ITEM_PAD_X,
921 rect.y0 + (ITEM_HEIGHT - icon_size.height) / 2.0,
922 ));
923 pod.label.set_origin(Point::new(
924 rect.x0 + ITEM_PAD_X + icon_size.width + ITEM_ICON_LABEL_GAP,
925 rect.y0 + (ITEM_HEIGHT - label_size.height) / 2.0,
926 ));
927
928 self.item_rects.push(rect);
929 bottom = top - ITEM_GAP;
930 }
931
932 bc.constrain(Size::new(area_w, area_h))
933 }
934
935 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
936 let theme = Theme::from_paint_ctx(ctx);
941 let scrim_base = resolve_scrim_base(theme);
942 let item_colors: Vec<Color> = (0..self.items.len())
943 .map(|i| resolve_item_color(theme, i))
944 .collect();
945 let main_color = resolve_main_color(theme);
946 let (blur, y_offset, shadow_color) = resolve_shadow(theme);
947 let reduce_motion = theme.map(|t| t.motion.reduce_motion).unwrap_or(false);
948 let now = ctx.frame_time();
949
950 let fab_moved = if reduce_motion {
957 snap_anim(&mut self.fab_morph, self.open, now)
958 } else {
959 let before = self.fab_morph.value();
960 let animating = self.fab_morph.advance(now);
961 animating || self.fab_morph.value() != before
962 };
963 if fab_moved {
964 ctx.request_layout();
965 }
966
967 let n = self.item_anim.len();
970 if reduce_motion {
971 for a in self.item_anim.iter_mut() {
972 snap_anim(a, self.open, now);
973 }
974 self.cascade_launched = vec![true; n];
975 } else {
976 self.advance_cascade(now);
977 }
978
979 let origin = ctx.origin();
980
981 if self.open {
982 let scrim = with_alpha(scrim_base, SCRIM_ALPHA);
983 scene.fill_rect(origin, ctx.size(), scrim);
984 }
985
986 let item_radius = item_radius();
987 for (i, (pod, rect)) in self
988 .items
989 .iter_mut()
990 .zip(self.item_rects.iter())
991 .enumerate()
992 {
993 let t = self.item_anim[i].value_clamped();
994 if t <= 0.0 {
995 continue;
996 }
997 let item_origin = Point::new(origin.x + rect.x0, origin.y + rect.y0);
1005 scene.push_layer(item_origin, rect.size(), t as f32);
1006 scene.fill_rounded_rect(item_origin, rect.size(), item_radius, item_colors[i]);
1007 pod.icon.paint_child(ctx, scene);
1008 pod.label.paint_child(ctx, scene);
1009 scene.pop_layer();
1010 }
1011
1012 let fab_t = self.fab_morph.value_clamped();
1016 let shadow_radius = trigger_shadow_radius(self.fab_rect.width(), fab_t);
1017 scene.draw_shadow(
1018 Point::new(
1019 origin.x + self.fab_rect.x0,
1020 origin.y + self.fab_rect.y0 + y_offset,
1021 ),
1022 self.fab_rect.size(),
1023 shadow_radius,
1024 blur,
1025 shadow_color,
1026 );
1027 let path = trigger_path(fab_t, self.fab_rect);
1028 scene.fill_path(origin, &path, &Brush::Solid(main_color));
1029 self.icon.paint_child(ctx, scene);
1030
1031 if !reduce_motion {
1032 let still_animating = self.fab_morph.is_animating()
1033 || self.item_anim.iter().any(AnimationController::is_animating)
1034 || self.cascade_launched.iter().any(|launched| !launched);
1035 if still_animating {
1036 ctx.request_frame();
1037 }
1038 }
1039 }
1040
1041 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1042 if let InputEvent::Key(key_event) = event {
1047 if self.open && key_event.key == Key::Named(NamedKey::Escape) {
1048 (self.on_toggle)(ctx);
1049 ctx.request_redraw();
1050 return EventResult::Handled;
1051 }
1052 return EventResult::Ignored;
1053 }
1054 let InputEvent::Pointer(p) = event else {
1055 return EventResult::Ignored;
1056 };
1057 match p.phase {
1058 PointerPhase::Down => {
1059 if self.fab_rect.contains(p.position) {
1060 ctx.request_focus();
1070 if !presses(p) {
1075 return if self.open {
1076 EventResult::Handled
1077 } else {
1078 EventResult::Ignored
1079 };
1080 }
1081 self.armed = Some(Target::Fab);
1082 self.pressed_inside = true;
1083 ctx.capture_pointer();
1084 ctx.request_redraw();
1085 return EventResult::Handled;
1086 }
1087 if !self.open {
1088 return EventResult::Ignored;
1089 }
1090 ctx.request_focus();
1094 if !presses(p) {
1098 return EventResult::Handled;
1099 }
1100 if let Some(i) = self.item_rects.iter().position(|r| r.contains(p.position)) {
1101 self.armed = Some(Target::Item(i));
1102 } else {
1103 self.armed = Some(Target::Scrim);
1104 }
1105 self.pressed_inside = true;
1106 ctx.capture_pointer();
1107 ctx.request_redraw();
1108 EventResult::Handled
1109 }
1110 PointerPhase::Move => {
1111 let Some(target) = self.armed else {
1112 return EventResult::Ignored;
1113 };
1114 self.pressed_inside = match target {
1115 Target::Fab => self.fab_rect.contains(p.position),
1116 Target::Item(i) => self
1117 .item_rects
1118 .get(i)
1119 .is_some_and(|r| r.contains(p.position)),
1120 Target::Scrim => true,
1123 };
1124 ctx.request_redraw();
1125 EventResult::Handled
1126 }
1127 PointerPhase::Up => {
1128 let Some(target) = self.armed.take() else {
1129 return EventResult::Ignored;
1130 };
1131 if self.pressed_inside {
1132 match target {
1133 Target::Fab => (self.on_toggle)(ctx),
1134 Target::Item(i) => {
1135 if let Some(pod) = self.items.get_mut(i) {
1136 (pod.on_select)(ctx);
1137 }
1138 (self.on_toggle)(ctx);
1139 }
1140 Target::Scrim => (self.on_toggle)(ctx),
1141 }
1142 }
1143 self.pressed_inside = false;
1144 ctx.request_redraw();
1145 EventResult::Handled
1146 }
1147 PointerPhase::Cancel => {
1148 if self.armed.is_none() {
1149 return EventResult::Ignored;
1150 }
1151 self.armed = None;
1152 self.pressed_inside = false;
1153 ctx.request_redraw();
1154 EventResult::Handled
1155 }
1156 }
1157 }
1158
1159 fn semantics(&self, ctx: &mut SemanticsCtx) {
1160 let trigger_label = self.label.as_deref().unwrap_or("Menu");
1161 if self.open {
1162 ctx.push_container(
1163 Role::Group,
1164 |_| {},
1165 |ctx| {
1166 ctx.push_node(Role::Button, |node| {
1167 node.set_label(trigger_label);
1168 node.set_expanded(true);
1169 node.add_action(Action::Click);
1170 node.add_action(Action::Expand);
1171 });
1172 ctx.push_container(
1173 Role::Menu,
1174 |_| {},
1175 |ctx| {
1176 for label in &self.item_labels {
1177 ctx.push_node(Role::MenuItem, |node| {
1178 node.set_label(label.as_str());
1179 node.add_action(Action::Click);
1180 });
1181 }
1182 },
1183 );
1184 },
1185 );
1186 } else {
1187 ctx.push_node(Role::Button, |node| {
1188 node.set_label(trigger_label);
1189 node.set_expanded(false);
1190 node.add_action(Action::Click);
1191 node.add_action(Action::Expand);
1192 });
1193 }
1194 }
1195
1196 frust::authoring::visit_children!(items, icon);
1197}
1198
1199#[cfg(test)]
1200mod tests {
1201 use super::*;
1202 use frust::authoring::{KeyEvent, Modifiers, PointerButton, PointerEvent};
1203 use frust_core::RenderRoot;
1204 use frust_widgets::test_support::leaf_any;
1205 use kurbo::{PathEl, Shape};
1206 use std::any::Any;
1207
1208 fn icon_stub<State: 'static>() -> AnyView<State> {
1214 frust::authoring::any::<State, _>(frust::text("i"))
1215 }
1216
1217 #[derive(Default)]
1218 struct Log {
1219 toggles: u32,
1220 selected: Vec<usize>,
1221 }
1222
1223 fn item(i: usize) -> FabMenuItem<Log> {
1224 fab_menu_item(
1225 icon_stub::<Log>(),
1226 format!("Item {i}"),
1227 move |s: &mut Log| s.selected.push(i),
1228 )
1229 }
1230
1231 fn build_menu(open: bool, item_count: usize) -> FabMenuWidget {
1232 let view = fab_menu::<Log, _>(
1233 icon_stub::<Log>(),
1234 open,
1235 (0..item_count).map(item).collect(),
1236 |s: &mut Log| s.toggles += 1,
1237 );
1238 let mut counter = 0u64;
1239 View::<Log>::build(&view, &mut BuildCtx::new(&mut counter))
1240 }
1241
1242 fn open_fully(w: &mut FabMenuWidget) {
1248 snap_anim(&mut w.fab_morph, true, FrameTime::ZERO);
1249 for a in w.item_anim.iter_mut() {
1250 snap_anim(a, true, FrameTime::ZERO);
1251 }
1252 w.cascade_launched = vec![true; w.item_anim.len()];
1253 }
1254
1255 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1256 InputEvent::Pointer(PointerEvent {
1257 phase,
1258 position: Point::new(x, y),
1259 button: PointerButton::Primary,
1260 })
1261 }
1262
1263 fn dispatch(w: &mut FabMenuWidget, state: &mut Log, event: &InputEvent) -> EventResult {
1264 let state_any: &mut dyn Any = state;
1265 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(400.0, 600.0));
1266 w.event(&mut ctx, event)
1267 }
1268
1269 fn laid_out(w: &mut FabMenuWidget) {
1270 let mut tcx = frust::authoring::text::TextContext::new();
1271 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1272 w.layout(&mut lctx, &BoxConstraints::tight(Size::new(400.0, 600.0)));
1273 }
1274
1275 #[test]
1280 fn trigger_shape_endpoints_are_a_rounded_square_and_a_circle() {
1281 assert_eq!(TRIGGER_CLOSED_SHAPE, ShapeKind::Square);
1282 assert_eq!(TRIGGER_OPEN_SHAPE, ShapeKind::Circle);
1283 let morph = trigger_morph();
1284 assert_eq!(*morph.start(), *ShapeKind::Square.polygon());
1285 assert_eq!(*morph.end(), *ShapeKind::Circle.polygon());
1286 }
1287
1288 #[test]
1289 fn trigger_path_is_closed_at_every_progress_and_stays_near_its_box() {
1290 let rect = Rect::from_origin_size(Point::new(10.0, 20.0), Size::new(56.0, 56.0));
1291 for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
1292 let path = trigger_path(t, rect);
1293 assert!(
1294 matches!(path.elements().last(), Some(PathEl::ClosePath)),
1295 "t={t}"
1296 );
1297 let bounds = path.bounding_box();
1298 assert!(
1299 bounds.x0 >= rect.x0 - 1.0 && bounds.y0 >= rect.y0 - 1.0,
1300 "t={t} {bounds:?}"
1301 );
1302 assert!(
1303 bounds.x1 <= rect.x1 + 1.0 && bounds.y1 <= rect.y1 + 1.0,
1304 "t={t} {bounds:?}"
1305 );
1306 }
1307 }
1308
1309 #[test]
1310 fn fab_shape_spring_matches_upstream_fab_shape_motion() {
1311 assert_eq!(
1313 FAB_SHAPE_SPRING.stiffness,
1314 MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.stiffness
1315 );
1316 assert_eq!(FAB_SHAPE_SPRING.damping_ratio, 0.7);
1317 assert_eq!(FAB_SHAPE_SPRING.mass, 1.0);
1318 }
1319
1320 #[test]
1321 fn trigger_container_lerps_between_the_named_endpoints() {
1322 assert_eq!(MAIN_CONTAINER_CLOSED, 80.0);
1323 assert_eq!(MAIN_CONTAINER_OPEN, 56.0);
1324 assert_eq!(lerp(MAIN_CONTAINER_CLOSED, MAIN_CONTAINER_OPEN, 0.0), 80.0);
1325 assert_eq!(lerp(MAIN_CONTAINER_CLOSED, MAIN_CONTAINER_OPEN, 1.0), 56.0);
1326 assert_eq!(icon_size_for(MAIN_CONTAINER_OPEN), MAIN_ICON);
1327 assert!(icon_size_for(MAIN_CONTAINER_CLOSED) > MAIN_ICON);
1328 }
1329
1330 #[test]
1331 fn trigger_morph_is_a_layout_value_and_requests_relayout_while_in_flight() {
1332 let mut w = build_menu(false, 0);
1333 laid_out(&mut w);
1334 let closed_size = w.fab_rect.width();
1335 assert_eq!(closed_size, MAIN_CONTAINER_CLOSED, "built closed: 80dp");
1336
1337 let prev = fab_menu::<Log, _>(icon_stub(), false, vec![], |_s: &mut Log| {});
1338 let next = fab_menu::<Log, _>(icon_stub(), true, vec![], |_s: &mut Log| {});
1339 let mut counter = 0u64;
1340 View::<Log>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1341
1342 let area = Size::new(400.0, 600.0);
1348 let mut rec = RectRecorder::default();
1349 let mut pctx = PaintCtx::for_test(Point::ZERO, area, FrameTime::from_nanos(0));
1350 w.paint(&mut pctx, &mut rec);
1351 let mut pctx = PaintCtx::for_test(Point::ZERO, area, FrameTime::from_nanos(80_000_000));
1352 w.paint(&mut pctx, &mut rec);
1353 assert!(
1354 pctx.needs_layout(),
1355 "an in-flight morph must request a relayout, not merely a repaint"
1356 );
1357
1358 laid_out(&mut w);
1359 assert!(
1360 w.fab_rect.width() < closed_size,
1361 "relaying out after the spring moved shrinks the trigger toward 56dp"
1362 );
1363 }
1364
1365 #[test]
1370 fn cascade_stagger_step_matches_upstream() {
1371 assert_eq!(EXPAND_STAGGER_MS, 30); }
1373
1374 #[test]
1375 fn item_spring_matches_expressive_spatial_press() {
1376 assert_eq!(ITEM_SPRING.stiffness, 380.0);
1379 assert_eq!(ITEM_SPRING.damping_ratio, 0.55);
1380 assert_eq!(
1381 ITEM_SPRING.stiffness,
1382 MaterialSpring::EXPRESSIVE_SPATIAL_PRESS.stiffness
1383 );
1384 assert_eq!(
1385 ITEM_SPRING.damping_ratio,
1386 MaterialSpring::EXPRESSIVE_SPATIAL_PRESS.damping_ratio
1387 );
1388 }
1389
1390 #[test]
1391 fn opening_cascade_fires_nearest_the_fab_first() {
1392 assert_eq!(cascade_delay(0, 4, true), Duration::ZERO);
1393 assert_eq!(cascade_delay(1, 4, true), Duration::from_millis(30));
1394 assert_eq!(cascade_delay(2, 4, true), Duration::from_millis(60));
1395 assert_eq!(cascade_delay(3, 4, true), Duration::from_millis(90));
1396 }
1397
1398 #[test]
1399 fn closing_cascade_reverses_the_stagger_order() {
1400 assert_eq!(cascade_delay(3, 4, false), Duration::ZERO);
1401 assert_eq!(cascade_delay(2, 4, false), Duration::from_millis(30));
1402 assert_eq!(cascade_delay(1, 4, false), Duration::from_millis(60));
1403 assert_eq!(cascade_delay(0, 4, false), Duration::from_millis(90));
1404 }
1405
1406 #[test]
1407 fn advance_cascade_launches_items_in_order_as_time_elapses() {
1408 let mut w = build_menu(false, 3);
1409 laid_out(&mut w);
1410 let prev = fab_menu::<Log, _>(
1411 icon_stub(),
1412 false,
1413 (0..3).map(item).collect(),
1414 |_s: &mut Log| {},
1415 );
1416 let next = fab_menu::<Log, _>(
1417 icon_stub(),
1418 true,
1419 (0..3).map(item).collect(),
1420 |_s: &mut Log| {},
1421 );
1422 let mut counter = 0u64;
1423 View::<Log>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1424 assert!(w.cascade_launched.iter().all(|&l| !l));
1425
1426 w.advance_cascade(FrameTime::ZERO);
1427 assert!(
1428 w.cascade_launched[0],
1429 "index 0 (nearest the FAB) has a 0ms delay"
1430 );
1431 assert!(!w.cascade_launched[1]);
1432 assert!(!w.cascade_launched[2]);
1433
1434 w.advance_cascade(FrameTime::from_nanos(35_000_000)); assert!(w.cascade_launched[1], "30ms delay elapsed");
1436 assert!(!w.cascade_launched[2], "60ms delay hasn't elapsed yet");
1437
1438 w.advance_cascade(FrameTime::from_nanos(65_000_000)); assert!(w.cascade_launched[2], "60ms delay elapsed");
1440 }
1441
1442 #[test]
1443 fn item_count_change_rebuilds_pods_and_clears_armed() {
1444 let mut w = build_menu(true, 1);
1445 let prev = fab_menu::<Log, _>(icon_stub::<Log>(), true, vec![item(0)], |_s: &mut Log| {});
1446 let next = fab_menu::<Log, _>(
1447 icon_stub::<Log>(),
1448 true,
1449 vec![item(0), item(1), item(2)],
1450 |_s: &mut Log| {},
1451 );
1452 let mut counter = 0u64;
1453 View::<Log>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1454 assert_eq!(w.items.len(), 3);
1455 assert_eq!(w.item_labels, vec!["Item 0", "Item 1", "Item 2"]);
1456 assert_eq!(w.item_anim.len(), 3);
1457 assert_eq!(
1458 w.cascade_launched,
1459 vec![true, true, true],
1460 "already open: settles instantly"
1461 );
1462 }
1463
1464 #[test]
1469 fn tapping_the_closed_trigger_requests_open() {
1470 let mut w = build_menu(false, 2);
1471 laid_out(&mut w);
1472 let mut state = Log::default();
1473 let fab_center = w.fab_rect.center();
1474 dispatch(
1475 &mut w,
1476 &mut state,
1477 &ev(PointerPhase::Down, fab_center.x, fab_center.y),
1478 );
1479 assert_eq!(w.armed, Some(Target::Fab));
1480 dispatch(
1481 &mut w,
1482 &mut state,
1483 &ev(PointerPhase::Up, fab_center.x, fab_center.y),
1484 );
1485 assert_eq!(state.toggles, 1, "trigger tap requests an open/close flip");
1486 assert!(state.selected.is_empty());
1487 }
1488
1489 #[test]
1490 fn tapping_the_open_trigger_again_also_requests_a_flip() {
1491 let mut w = build_menu(true, 2);
1492 laid_out(&mut w);
1493 let mut state = Log::default();
1494 let fab_center = w.fab_rect.center();
1495 dispatch(
1496 &mut w,
1497 &mut state,
1498 &ev(PointerPhase::Down, fab_center.x, fab_center.y),
1499 );
1500 dispatch(
1501 &mut w,
1502 &mut state,
1503 &ev(PointerPhase::Up, fab_center.x, fab_center.y),
1504 );
1505 assert_eq!(state.toggles, 1, "tap-again closes");
1506 }
1507
1508 #[test]
1509 fn item_tap_fires_its_own_callback_then_closes() {
1510 let mut w = build_menu(true, 2);
1511 laid_out(&mut w);
1512 assert_eq!(w.item_rects.len(), 2);
1513 let mut state = Log::default();
1514 let target = w.item_rects[0].center();
1515 dispatch(
1516 &mut w,
1517 &mut state,
1518 &ev(PointerPhase::Down, target.x, target.y),
1519 );
1520 assert_eq!(w.armed, Some(Target::Item(0)));
1521 dispatch(
1522 &mut w,
1523 &mut state,
1524 &ev(PointerPhase::Up, target.x, target.y),
1525 );
1526 assert_eq!(state.selected, vec![0], "the tapped item's on_select fired");
1527 assert_eq!(state.toggles, 1, "an item tap also requests a close");
1528 }
1529
1530 #[test]
1531 fn scrim_tap_closes_without_selecting_an_item() {
1532 let mut w = build_menu(true, 1);
1533 laid_out(&mut w);
1534 let mut state = Log::default();
1535 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1538 assert_eq!(w.armed, Some(Target::Scrim));
1539 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1540 assert_eq!(state.toggles, 1, "a scrim tap requests a close");
1541 assert!(state.selected.is_empty());
1542 }
1543
1544 #[test]
1545 fn down_outside_the_trigger_is_ignored_while_closed() {
1546 let mut w = build_menu(false, 1);
1547 laid_out(&mut w);
1548 let mut state = Log::default();
1549 let result = dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1550 assert!(matches!(result, EventResult::Ignored));
1551 assert!(w.armed.is_none());
1552 }
1553
1554 #[test]
1555 fn cancel_disarms_without_firing() {
1556 let mut w = build_menu(true, 1);
1557 laid_out(&mut w);
1558 let mut state = Log::default();
1559 let target = w.item_rects[0].center();
1560 dispatch(
1561 &mut w,
1562 &mut state,
1563 &ev(PointerPhase::Down, target.x, target.y),
1564 );
1565 dispatch(
1566 &mut w,
1567 &mut state,
1568 &ev(PointerPhase::Cancel, target.x, target.y),
1569 );
1570 assert!(w.armed.is_none());
1571 dispatch(
1572 &mut w,
1573 &mut state,
1574 &ev(PointerPhase::Up, target.x, target.y),
1575 );
1576 assert_eq!(state.toggles, 0);
1577 assert!(state.selected.is_empty());
1578 }
1579
1580 #[test]
1581 fn release_outside_the_armed_item_fires_nothing() {
1582 let mut w = build_menu(true, 2);
1583 laid_out(&mut w);
1584 let mut state = Log::default();
1585 let item0 = w.item_rects[0].center();
1586 dispatch(
1587 &mut w,
1588 &mut state,
1589 &ev(PointerPhase::Down, item0.x, item0.y),
1590 );
1591 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 500.0));
1592 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 500.0));
1593 assert!(state.selected.is_empty());
1594 assert_eq!(state.toggles, 0);
1595 }
1596
1597 fn escape_event() -> InputEvent {
1600 InputEvent::Key(KeyEvent {
1601 key: Key::Named(NamedKey::Escape),
1602 modifiers: Modifiers::default(),
1603 repeat: false,
1604 })
1605 }
1606
1607 #[test]
1608 fn escape_while_open_requests_toggle() {
1609 let mut w = build_menu(true, 2);
1610 laid_out(&mut w);
1611 let mut state = Log::default();
1612 let result = dispatch(&mut w, &mut state, &escape_event());
1613 assert!(matches!(result, EventResult::Handled));
1614 assert_eq!(state.toggles, 1, "Escape while open requests a close");
1615 assert!(state.selected.is_empty());
1616 }
1617
1618 #[test]
1619 fn escape_while_closed_is_ignored() {
1620 let mut w = build_menu(false, 2);
1621 laid_out(&mut w);
1622 let mut state = Log::default();
1623 let result = dispatch(&mut w, &mut state, &escape_event());
1624 assert!(matches!(result, EventResult::Ignored));
1625 assert_eq!(state.toggles, 0, "a closed menu has nothing to dismiss");
1626 }
1627
1628 #[test]
1629 fn trigger_press_claims_focus_so_a_later_escape_reaches_the_open_menu() {
1630 let mut root: RenderRoot<Log, FabMenuView<Log>> = RenderRoot::new();
1634 let mut app = |_s: &mut Log| {
1635 fab_menu::<Log, _>(
1636 icon_stub::<Log>(),
1637 true,
1638 (0..2).map(item).collect(),
1639 |s: &mut Log| s.toggles += 1,
1640 )
1641 };
1642 let mut state = Log::default();
1643 root.rebuild(&mut app, &mut state);
1644 let mut tcx = frust::authoring::text::TextContext::new();
1645 root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
1646
1647 root.event(&mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1652 root.event(&mut state, &ev(PointerPhase::Cancel, 5.0, 5.0));
1653 assert_eq!(
1654 state.toggles, 0,
1655 "the cancelled scrim press alone never toggles"
1656 );
1657
1658 root.event(&mut state, &escape_event());
1659 assert_eq!(
1660 state.toggles, 1,
1661 "Escape reaches the now-focused, still-open menu and requests a close"
1662 );
1663 }
1664
1665 #[test]
1666 fn a_down_reclaims_trigger_focus_after_an_external_blur() {
1667 use frust::authoring::ChildPod;
1668
1669 let mut w = build_menu(false, 2);
1670 laid_out(&mut w);
1671 let fab_center = w.fab_rect.center();
1672 let mut pod = ChildPod::new(Box::new(w));
1673 let mut tcx = frust::authoring::text::TextContext::new();
1674 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1675 let area = Size::new(400.0, 600.0);
1676 pod.layout_child(&mut lctx, &BoxConstraints::tight(area));
1679
1680 let mut state = Log::default();
1681 {
1682 let s: &mut dyn Any = &mut state;
1683 let mut ctx = EventCtx::new(s, Point::ZERO, area);
1684 pod.event_child(
1685 &mut ctx,
1686 &ev(PointerPhase::Down, fab_center.x, fab_center.y),
1687 );
1688 }
1689 assert!(pod.is_focused(), "the first trigger Down claims focus");
1690 {
1691 let s: &mut dyn Any = &mut state;
1692 let mut ctx = EventCtx::new(s, Point::ZERO, area);
1693 pod.event_child(&mut ctx, &ev(PointerPhase::Up, fab_center.x, fab_center.y));
1694 }
1695
1696 pod.set_focused(false);
1700 {
1701 let s: &mut dyn Any = &mut state;
1702 let mut ctx = EventCtx::new(s, Point::ZERO, area);
1703 pod.event_child(
1704 &mut ctx,
1705 &ev(PointerPhase::Down, fab_center.x, fab_center.y),
1706 );
1707 }
1708 assert!(
1709 pod.is_focused(),
1710 "a second trigger Down must re-claim focus after an external blur — this \
1711 is what keeps the root's focus/IME session alive while the menu is up"
1712 );
1713 }
1714
1715 #[test]
1716 fn a_down_reclaims_scrim_focus_after_an_external_blur() {
1717 use frust::authoring::ChildPod;
1718
1719 let w = build_menu(true, 2);
1720 let mut pod = ChildPod::new(Box::new(w));
1721 let mut tcx = frust::authoring::text::TextContext::new();
1722 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1723 let area = Size::new(400.0, 600.0);
1724 pod.layout_child(&mut lctx, &BoxConstraints::tight(area));
1725
1726 let mut state = Log::default();
1727 {
1730 let s: &mut dyn Any = &mut state;
1731 let mut ctx = EventCtx::new(s, Point::ZERO, area);
1732 pod.event_child(&mut ctx, &ev(PointerPhase::Down, 5.0, 5.0));
1733 }
1734 assert!(pod.is_focused(), "the first scrim Down claims focus");
1735 {
1736 let s: &mut dyn Any = &mut state;
1737 let mut ctx = EventCtx::new(s, Point::ZERO, area);
1738 pod.event_child(&mut ctx, &ev(PointerPhase::Cancel, 5.0, 5.0));
1739 }
1740
1741 pod.set_focused(false);
1745 {
1746 let s: &mut dyn Any = &mut state;
1747 let mut ctx = EventCtx::new(s, Point::ZERO, area);
1748 pod.event_child(&mut ctx, &ev(PointerPhase::Down, 5.0, 5.0));
1749 }
1750 assert!(
1751 pod.is_focused(),
1752 "a second scrim/item Down must re-claim focus after an external blur — \
1753 this is what keeps the root's focus/IME session alive while the menu is up"
1754 );
1755 }
1756
1757 #[test]
1758 fn reopening_after_a_close_reclaims_focus() {
1759 use frust::authoring::ChildPod;
1765
1766 let mut w = build_menu(false, 1);
1767 laid_out(&mut w);
1768 let fab_center = w.fab_rect.center();
1769 let mut pod = ChildPod::new(Box::new(w));
1770 let mut tcx = frust::authoring::text::TextContext::new();
1771 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1772 let area = Size::new(400.0, 600.0);
1773 pod.layout_child(&mut lctx, &BoxConstraints::tight(area));
1774
1775 let mut state = Log::default();
1776 {
1777 let s: &mut dyn Any = &mut state;
1778 let mut ctx = EventCtx::new(s, Point::ZERO, area);
1779 pod.event_child(
1780 &mut ctx,
1781 &ev(PointerPhase::Down, fab_center.x, fab_center.y),
1782 );
1783 }
1784 assert!(pod.is_focused(), "the trigger press claims focus");
1785
1786 let mut counter = 0u64;
1789 let closed = fab_menu::<Log, _>(icon_stub::<Log>(), false, vec![item(0)], |s: &mut Log| {
1790 s.toggles += 1
1791 });
1792 let opened = fab_menu::<Log, _>(icon_stub::<Log>(), true, vec![item(0)], |s: &mut Log| {
1793 s.toggles += 1
1794 });
1795 {
1796 let widget = pod
1797 .widget_mut()
1798 .downcast_mut::<FabMenuWidget>()
1799 .expect("the pod wraps a FabMenuWidget");
1800 View::<Log>::rebuild(&opened, &closed, widget, &mut BuildCtx::new(&mut counter));
1801 View::<Log>::rebuild(&closed, &opened, widget, &mut BuildCtx::new(&mut counter));
1802 }
1803 assert!(
1804 pod.is_focused(),
1805 "a same-identity rebuild (open then close) never touches the pod's \
1806 recorded focus path on its own"
1807 );
1808
1809 pod.set_focused(false);
1813 pod.layout_child(&mut lctx, &BoxConstraints::tight(area));
1814 {
1815 let s: &mut dyn Any = &mut state;
1816 let mut ctx = EventCtx::new(s, Point::ZERO, area);
1817 pod.event_child(
1818 &mut ctx,
1819 &ev(PointerPhase::Down, fab_center.x, fab_center.y),
1820 );
1821 }
1822 assert!(
1823 pod.is_focused(),
1824 "reopening after a close still lets a fresh trigger press re-claim focus"
1825 );
1826 }
1827
1828 #[test]
1829 fn is_controlled_open_only_moves_via_rebuild() {
1830 let mut w = build_menu(false, 1);
1831 assert!(!w.open);
1832 let prev = fab_menu::<Log, _>(icon_stub::<Log>(), false, vec![item(0)], |_s: &mut Log| {});
1833 let next = fab_menu::<Log, _>(icon_stub::<Log>(), true, vec![item(0)], |_s: &mut Log| {});
1834 let mut counter = 0u64;
1835 View::<Log>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1836 assert!(w.open, "open state moved on rebuild (controlled)");
1837 assert!(
1838 w.fab_morph.is_animating(),
1839 "the trigger morph relaunched toward the open target"
1840 );
1841 }
1842
1843 #[derive(Default)]
1848 struct RectRecorder {
1849 rrects: Vec<(Point, Size, f64, Color)>,
1850 rects: Vec<(Point, Size, Color)>,
1851 fills: Vec<BezPath>,
1852 layers: Vec<(Point, Size, f32)>,
1855 pops: usize,
1856 }
1857 impl PaintScene for RectRecorder {
1858 fn fill_rect(&mut self, o: Point, s: Size, c: Color) {
1859 self.rects.push((o, s, c));
1860 }
1861 fn draw_text(&mut self, _o: Point, _t: &str) {}
1862 fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
1863 self.rrects.push((o, s, radius, color));
1864 }
1865 fn fill_path(&mut self, _origin: Point, path: &BezPath, _brush: &Brush) {
1866 self.fills.push(path.clone());
1867 }
1868 fn push_layer(&mut self, o: Point, s: Size, alpha: f32) {
1869 self.layers.push((o, s, alpha));
1870 }
1871 fn pop_layer(&mut self) {
1872 self.pops += 1;
1873 }
1874 }
1875
1876 fn paint_at(w: &mut FabMenuWidget, millis: u64, rec: &mut RectRecorder) {
1879 let mut pctx = PaintCtx::for_test(
1880 Point::ZERO,
1881 Size::new(400.0, 600.0),
1882 FrameTime::from_nanos(millis * 1_000_000),
1883 );
1884 w.paint(&mut pctx, rec);
1885 }
1886
1887 fn set_open(w: &mut FabMenuWidget, from: bool, to: bool, item_count: usize) {
1890 let prev = fab_menu::<Log, _>(
1891 icon_stub(),
1892 from,
1893 (0..item_count).map(item).collect(),
1894 |_s: &mut Log| {},
1895 );
1896 let next = fab_menu::<Log, _>(
1897 icon_stub(),
1898 to,
1899 (0..item_count).map(item).collect(),
1900 |_s: &mut Log| {},
1901 );
1902 let mut counter = 0u64;
1903 View::<Log>::rebuild(&next, &prev, w, &mut BuildCtx::new(&mut counter));
1904 }
1905
1906 #[test]
1907 fn closed_paint_draws_only_the_trigger_shape() {
1908 let mut w = build_menu(false, 2);
1909 laid_out(&mut w);
1910 let mut rec = RectRecorder::default();
1911 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(400.0, 600.0));
1912 w.paint(&mut pctx, &mut rec);
1913 assert!(rec.rects.is_empty(), "no scrim while closed");
1914 assert!(rec.rrects.is_empty(), "no item containers while closed");
1915 assert!(rec.layers.is_empty(), "no item fade layers while closed");
1916 assert_eq!(rec.fills.len(), 1, "only the trigger's morphed outline");
1917 }
1918
1919 #[test]
1920 fn open_paint_draws_scrim_and_item_containers() {
1921 let mut w = build_menu(true, 2);
1922 laid_out(&mut w);
1923 open_fully(&mut w);
1924 let mut rec = RectRecorder::default();
1925 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(400.0, 600.0));
1926 w.paint(&mut pctx, &mut rec);
1927 assert_eq!(rec.rects.len(), 1, "one full-area scrim fill");
1928 assert_eq!(rec.rrects.len(), 2, "2 item containers");
1929 assert_eq!(rec.fills.len(), 1, "the trigger's morphed outline");
1930 assert_eq!(rec.layers.len(), 2, "one fade layer per visible item");
1934 assert_eq!(rec.pops, 2, "every pushed layer is popped again");
1935 assert!(
1936 rec.layers.iter().all(|l| l.2 == 1.0),
1937 "settled open: t == 1"
1938 );
1939 assert_eq!(rec.rrects[0].3, PRIMARY_CONTAINER);
1940 assert_eq!(rec.rrects[1].3, SECONDARY_CONTAINER);
1941 assert_eq!(rec.rects[0].2, with_alpha(SCRIM_FALLBACK, 0.0));
1944 }
1945
1946 #[test]
1947 fn themed_paint_resolves_container_family_tokens() {
1948 let theme = crate::baseline();
1949 let mut w = build_menu(true, 3);
1950 laid_out(&mut w);
1951 open_fully(&mut w);
1952 let mut rec = RectRecorder::default();
1953 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(400.0, 600.0)).with_theme(&theme);
1954 w.paint(&mut pctx, &mut rec);
1955
1956 let scheme = theme.scheme();
1957 assert_eq!(rec.rrects[0].3, scheme.primary_container);
1958 assert_eq!(rec.rrects[1].3, scheme.secondary_container);
1959 assert_eq!(rec.rrects[2].3, scheme.tertiary_container);
1960 }
1961
1962 #[test]
1963 fn item_shape_is_a_full_pill() {
1964 assert_eq!(item_radius(), ITEM_HEIGHT / 2.0);
1965 }
1966
1967 #[test]
1972 fn a_mid_cascade_item_fades_as_one_unit_through_its_own_layer() {
1973 let mut w = build_menu(false, 2);
1974 laid_out(&mut w);
1975 set_open(&mut w, false, true, 2);
1976
1977 let mut rec = RectRecorder::default();
1978 paint_at(&mut w, 0, &mut rec);
1982 rec = RectRecorder::default();
1983 paint_at(&mut w, 20, &mut rec);
1984
1985 let t = w.item_anim[0].value_clamped();
1986 assert!(t > 0.0 && t < 1.0, "item 0 is mid-reveal (t={t})");
1987 assert_eq!(
1988 w.item_anim[1].value_clamped(),
1989 0.0,
1990 "item 1 hasn't launched"
1991 );
1992
1993 assert_eq!(rec.layers.len(), 1, "one fade layer per *visible* item");
1994 assert_eq!(rec.pops, 1, "and it is popped again");
1995 assert_eq!(
1996 rec.layers[0].2, t as f32,
1997 "the layer carries the item's own reveal progress"
1998 );
1999 let item = w.item_rects[0];
2000 assert_eq!(rec.layers[0].0, item.origin(), "layer covers the item row");
2001 assert_eq!(rec.layers[0].1, item.size());
2002 assert_eq!(rec.rrects.len(), 1, "only the revealed item's container");
2003 assert_eq!(
2004 rec.rrects[0].3, PRIMARY_CONTAINER,
2005 "the container paints at full alpha INSIDE the layer — fading it \
2006 again there would double-fade it, and fading it alone (while the \
2007 icon/label pods paint opaque regardless) is what made a low-`t` \
2008 item read as a bare content row with no pill behind it"
2009 );
2010 }
2011
2012 #[test]
2013 fn a_menu_built_closed_never_flings_and_never_paints_an_item() {
2014 let mut w = build_menu(false, 3);
2015 laid_out(&mut w);
2016 assert_eq!(
2017 w.cascade_launched,
2018 vec![true; 3],
2019 "a widget built at rest mounts fully-launched: an unseeded closed \
2020 mount lets the first paint fling every item from rest with the \
2021 retreat velocity, dipping below zero and rebounding back above it"
2022 );
2023 assert!(
2024 w.item_anim
2025 .iter()
2026 .all(|a| !a.is_animating() && a.value() == 0.0),
2027 "every item spring rests exactly at 0"
2028 );
2029 assert!(!w.fab_morph.is_animating() && w.fab_morph.value() == 0.0);
2030
2031 let mut rec = RectRecorder::default();
2032 for frame in 0..40u64 {
2033 paint_at(&mut w, frame * 16, &mut rec);
2034 }
2035 assert!(rec.rrects.is_empty(), "no item container ever paints");
2036 assert!(rec.layers.is_empty(), "no item fade layer ever pushes");
2037 assert!(
2038 w.item_anim.iter().all(|a| !a.is_animating()),
2039 "and no item spring ever launched"
2040 );
2041 }
2042
2043 #[test]
2044 fn the_closing_retreat_stops_at_zero_instead_of_ringing_back_above_it() {
2045 let mut w = build_menu(true, 1);
2046 laid_out(&mut w);
2047 open_fully(&mut w);
2048 set_open(&mut w, true, false, 1);
2049
2050 let mut rec = RectRecorder::default();
2054 for frame in 0..13u64 {
2055 paint_at(&mut w, frame * 16, &mut rec); }
2057 assert!(
2058 !w.item_anim[0].is_animating(),
2059 "the retreating spring stops the frame it reaches zero"
2060 );
2061 assert_eq!(w.item_anim[0].value(), 0.0, "and stops exactly *at* zero");
2062
2063 let mut after = RectRecorder::default();
2064 for frame in 13..60u64 {
2065 paint_at(&mut w, frame * 16, &mut after); }
2067 assert!(
2068 after.rrects.is_empty() && after.layers.is_empty(),
2069 "no rebound ever paints the item back on"
2070 );
2071 }
2072
2073 #[test]
2074 fn the_trigger_morphs_retreat_rebound_is_imperceptible_so_it_stays_unclamped() {
2075 let mut anim = AnimationController::new(ANIM_PERIOD);
2083 snap_anim(&mut anim, true, FrameTime::ZERO);
2084 anim.fling(-FLING_VELOCITY, FAB_SHAPE_SPRING);
2085 let (mut crossed, mut peak) = (false, 0.0f64);
2086 for frame in 0..1000u64 {
2087 anim.advance(FrameTime::from_nanos(frame * 1_000_000)); let v = anim.value();
2089 crossed |= v <= 0.0;
2090 if crossed {
2091 peak = peak.max(v);
2092 }
2093 }
2094 assert!(crossed, "the retreat does cross its target");
2095 let px = MAIN_CONTAINER_CLOSED - lerp(MAIN_CONTAINER_CLOSED, MAIN_CONTAINER_OPEN, peak);
2096 assert!(
2097 px < 0.1,
2098 "rebound peak {peak} = {px}px of container size — big enough to see, \
2099 so the retreat clamp now needs to cover the morph too"
2100 );
2101 }
2102
2103 #[test]
2104 fn reduce_motion_snaps_the_trigger_and_items_without_requesting_a_frame() {
2105 let mut theme = crate::baseline();
2106 theme.motion.reduce_motion = true;
2107 let mut w = build_menu(true, 2);
2108 laid_out(&mut w);
2109 let mut rec = RectRecorder::default();
2110 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(400.0, 600.0)).with_theme(&theme);
2111 w.paint(&mut pctx, &mut rec);
2112
2113 assert_eq!(w.fab_morph.value_clamped(), 1.0, "snapped straight open");
2114 assert!(
2115 w.item_anim.iter().all(|a| a.value_clamped() == 1.0),
2116 "every item snapped straight open"
2117 );
2118 assert!(
2119 !pctx.needs_frame(),
2120 "reduce_motion must not request a continuation frame"
2121 );
2122 }
2123
2124 #[test]
2125 fn inspect_surfaces_icon_and_label_pods_per_item_plus_the_trigger() {
2126 fn logic(_s: &mut ()) -> FabMenuView<()> {
2136 fab_menu::<(), _>(
2137 icon_stub::<()>(),
2138 true,
2139 vec![
2140 fab_menu_item(icon_stub::<()>(), "alpha", |_s: &mut ()| {}),
2141 fab_menu_item(icon_stub::<()>(), "beta", |_s: &mut ()| {}),
2142 ],
2143 |_s: &mut ()| {},
2144 )
2145 }
2146 let mut root: RenderRoot<(), FabMenuView<()>> = RenderRoot::new();
2147 let mut state = ();
2148 root.rebuild(&mut logic, &mut state);
2149 let mut tcx = frust::authoring::text::TextContext::new();
2150 root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
2151 let nodes = root.inspect();
2152
2153 let short_names: Vec<&str> = nodes
2154 .iter()
2155 .map(|n| {
2156 let bare = n.type_name.split('<').next().unwrap_or(n.type_name);
2157 bare.rsplit("::").next().unwrap_or(bare)
2158 })
2159 .collect();
2160 assert_eq!(
2161 short_names
2162 .iter()
2163 .filter(|n| **n == "FabMenuWidget")
2164 .count(),
2165 1
2166 );
2167 assert_eq!(
2168 nodes[0].children.len(),
2169 5,
2170 "two items x (icon + label), plus the trigger icon"
2171 );
2172 assert_eq!(
2173 short_names.iter().filter(|n| **n == "TextWidget").count(),
2174 5
2175 );
2176 }
2177
2178 #[test]
2179 fn semantics_reports_closed_trigger_then_open_menu() {
2180 fn logic_closed(_s: &mut ()) -> FabMenuView<()> {
2181 fab_menu::<(), _>(leaf_any(24.0, 24.0), false, vec![], |_s: &mut ()| {})
2182 .label("Compose")
2183 }
2184 let mut root: frust_core::RenderRoot<(), FabMenuView<()>> = frust_core::RenderRoot::new();
2185 let mut state = ();
2186 root.rebuild(&mut logic_closed, &mut state);
2187 let mut tcx = frust::authoring::text::TextContext::new();
2188 root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
2189 let update = root.semantics();
2190 let (_, node) = update
2191 .nodes
2192 .iter()
2193 .find(|(_, n)| n.role() == Role::Button)
2194 .expect("closed menu contributes a Role::Button trigger node");
2195 assert_eq!(node.label(), Some("Compose"));
2196 assert_eq!(node.is_expanded(), Some(false));
2197
2198 fn logic_open(_s: &mut ()) -> FabMenuView<()> {
2199 fab_menu::<(), _>(
2200 leaf_any(24.0, 24.0),
2201 true,
2202 vec![fab_menu_item(
2203 leaf_any(24.0, 24.0),
2204 "New note",
2205 |_s: &mut ()| {},
2206 )],
2207 |_s: &mut ()| {},
2208 )
2209 }
2210 let mut root: frust_core::RenderRoot<(), FabMenuView<()>> = frust_core::RenderRoot::new();
2211 let mut state = ();
2212 root.rebuild(&mut logic_open, &mut state);
2213 let mut tcx = frust::authoring::text::TextContext::new();
2214 root.layout_with_text(Size::new(400.0, 600.0), &mut tcx as &mut dyn Any);
2215 let update = root.semantics();
2216 let menu = update
2217 .nodes
2218 .iter()
2219 .find(|(_, n)| n.role() == Role::Menu)
2220 .expect("an open menu contributes a Role::Menu container node");
2221 assert_eq!(menu.1.children().len(), 1);
2222 let menu_item = update
2223 .nodes
2224 .iter()
2225 .find(|(_, n)| n.role() == Role::MenuItem)
2226 .expect("an item contributes a Role::MenuItem node");
2227 assert_eq!(menu_item.1.label(), Some("New note"));
2228 }
2229}