1use std::rc::Rc;
97use std::time::Duration;
98
99use frust::authoring::Role;
100use frust::authoring::text::{FontWeight, LineHeight};
101use frust::authoring::{
102 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
103 LayoutCtx, PaintCtx, PaintScene, PointerButton, PointerEvent, PointerPhase, SemanticsCtx, View,
104 Widget,
105};
106use frust::{AnimationController, FrameTime, Spring, SpringDesc, Theme, text};
107use kurbo::{Affine, Point, Rect, Size, Vec2};
108use peniko::Color;
109
110use frust::authoring::{ThemeTextColor, ThemeTextType};
111
112use super::press::presses;
113use crate::tokens::MaterialSpring;
114
115pub const HEIGHT_SMALL: f64 = 64.0;
119pub const HEIGHT_MEDIUM: f64 = 80.0;
122pub const INDICATOR_W: f64 = 64.0;
127pub const INDICATOR_H: f64 = 32.0;
130pub const UNDERLINE_THICKNESS: f64 = 3.0;
133const LABEL_GAP: f64 = 4.0;
136
137const LABEL_SIZE: f32 = 12.0;
145const LABEL_LINE_HEIGHT: f32 = 16.0;
146const LABEL_LETTER_SPACING: f32 = 0.5;
147const LABEL_WEIGHT: FontWeight = FontWeight::BOLD;
148
149const CONTAINER: Color = Color::from_rgb8(0xF3, 0xED, 0xF7);
152const INDICATOR: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
155
156pub const LEAD_SPRING: SpringDesc = SpringDesc {
171 mass: 1.0,
172 stiffness: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.stiffness,
173 damping_ratio: 0.45,
174};
175pub const TRAIL_SPRING: SpringDesc = SpringDesc {
179 mass: 1.0,
180 stiffness: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.stiffness,
181 damping_ratio: 0.55,
182};
183const PILL_REST_EPSILON: f64 = 0.01;
190const GEOMETRY_EPSILON: f64 = 0.5;
198
199fn geometry_unchanged(a: Rect, b: Rect) -> bool {
203 (a.x0 - b.x0).abs() < GEOMETRY_EPSILON
204 && (a.x1 - b.x1).abs() < GEOMETRY_EPSILON
205 && (a.y0 - b.y0).abs() < GEOMETRY_EPSILON
206 && (a.y1 - b.y1).abs() < GEOMETRY_EPSILON
207}
208
209#[derive(Clone, Copy, Debug)]
218pub(crate) struct LiquidEdge {
219 desc: SpringDesc,
220 target: f64,
222 flight: Option<Spring>,
224 elapsed: f64,
226 position: f64,
227 velocity: f64,
228}
229
230impl LiquidEdge {
231 pub(crate) const fn new(desc: SpringDesc) -> Self {
232 LiquidEdge {
233 desc,
234 target: 0.0,
235 flight: None,
236 elapsed: 0.0,
237 position: 0.0,
238 velocity: 0.0,
239 }
240 }
241
242 pub(crate) fn snap(&mut self, to: f64) {
244 self.target = to;
245 self.position = to;
246 self.velocity = 0.0;
247 self.flight = None;
248 self.elapsed = 0.0;
249 }
250
251 pub(crate) fn retarget(&mut self, to: f64) {
254 self.flight = Some(Spring::new(self.desc, self.position - to, self.velocity));
255 self.target = to;
256 self.elapsed = 0.0;
257 }
258
259 pub(crate) fn advance(&mut self, dt: f64) -> bool {
261 let Some(spring) = self.flight else {
262 return false;
263 };
264 self.elapsed += dt;
265 if spring.is_at_rest(self.elapsed, PILL_REST_EPSILON) {
266 self.settle();
267 return false;
268 }
269 self.position = self.target + spring.position(self.elapsed);
270 self.velocity = spring.velocity(self.elapsed);
271 true
272 }
273
274 pub(crate) fn settle(&mut self) {
276 self.position = self.target;
277 self.velocity = 0.0;
278 self.flight = None;
279 self.elapsed = 0.0;
280 }
281
282 pub(crate) fn is_animating(&self) -> bool {
283 self.flight.is_some()
284 }
285}
286
287#[derive(Clone, Copy, Debug)]
343pub(crate) struct LiquidIndicator {
344 lead: LiquidEdge,
345 trail: LiquidEdge,
346 last_time: Option<FrameTime>,
348 rest: Rect,
350 ready: bool,
353}
354
355impl LiquidIndicator {
356 pub(crate) const fn new() -> Self {
357 LiquidIndicator {
358 lead: LiquidEdge::new(LEAD_SPRING),
359 trail: LiquidEdge::new(TRAIL_SPRING),
360 last_time: None,
361 rest: Rect::ZERO,
362 ready: false,
363 }
364 }
365
366 pub(crate) fn sync(&mut self, rest: Rect, travel: bool) {
371 let center = rest.center().x;
372 if !self.ready {
373 self.rest = rest;
374 self.lead.snap(center);
375 self.trail.snap(center);
376 self.last_time = None;
377 self.ready = true;
378 return;
379 }
380 if travel {
381 self.rest = rest;
382 if (self.lead.target - center).abs() < GEOMETRY_EPSILON && !self.is_animating() {
383 return;
384 }
385 self.lead.retarget(center);
386 self.trail.retarget(center);
387 self.last_time = None;
388 return;
389 }
390 if geometry_unchanged(self.rest, rest) {
396 return;
397 }
398 self.rest = rest;
399 self.lead.snap(center);
400 self.trail.snap(center);
401 self.last_time = None;
402 }
403
404 pub(crate) fn advance(&mut self, now: FrameTime) -> bool {
407 let dt = match self.last_time {
408 Some(last) => now.saturating_sub(last).as_secs_f64(),
409 None => 0.0,
410 };
411 self.last_time = Some(now);
412 let lead = self.lead.advance(dt);
413 let trail = self.trail.advance(dt);
414 lead || trail
415 }
416
417 pub(crate) fn settle(&mut self) {
419 self.lead.settle();
420 self.trail.settle();
421 }
422
423 pub(crate) fn is_animating(&self) -> bool {
424 self.lead.is_animating() || self.trail.is_animating()
425 }
426
427 pub(crate) fn pill_rect(&self) -> Option<Rect> {
429 if !self.ready {
430 return None;
431 }
432 let base = self.rest.width();
433 let min = self.lead.position.min(self.trail.position);
434 let max = self.lead.position.max(self.trail.position);
435 let x0 = min - base / 2.0;
436 Some(Rect::new(
437 x0,
438 self.rest.y0,
439 x0 + (max - min) + base,
440 self.rest.y1,
441 ))
442 }
443
444 pub(crate) fn radius(&self) -> f64 {
447 self.rest.height().min(self.rest.width()) / 2.0
448 }
449}
450
451const ICON_POP_SPRING: SpringDesc = SpringDesc {
459 mass: 1.0,
460 stiffness: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.stiffness,
461 damping_ratio: 0.5,
462};
463const ICON_POP_FROM: f64 = 0.98;
467const ICON_POP_LAUNCH_VELOCITY: f64 = 1e-3;
472const ICON_POP_EPSILON: f64 = 1e-4;
474
475fn settled_pop() -> AnimationController {
478 let mut pop = AnimationController::new(Duration::ZERO);
479 pop.animate_to(1.0);
480 pop.advance(FrameTime::ZERO); pop
482}
483
484fn launched_pop() -> AnimationController {
486 let mut pop = AnimationController::new(Duration::ZERO);
487 pop.fling(ICON_POP_LAUNCH_VELOCITY, ICON_POP_SPRING);
488 pop
489}
490
491#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
497pub enum NavBarSize {
498 Small,
501 #[default]
503 Medium,
504}
505
506impl NavBarSize {
507 pub fn height(self) -> f64 {
509 match self {
510 NavBarSize::Small => HEIGHT_SMALL,
511 NavBarSize::Medium => HEIGHT_MEDIUM,
512 }
513 }
514}
515
516#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
518pub enum NavBarLabelBehavior {
519 #[default]
521 AlwaysShow,
522 OnlySelected,
524 AlwaysHide,
527}
528
529#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
531pub enum NavBarIndicatorStyle {
532 #[default]
535 Pill,
536 Underline,
539 None,
541}
542
543#[derive(Clone, Copy, Debug, PartialEq, Eq)]
547enum NavBadge {
548 Dot,
550 Count(u32),
553}
554
555fn resolve_colors(theme: Option<&Theme>) -> (Color, Color) {
560 match theme {
561 Some(theme) => {
562 let scheme = theme.scheme();
563 (scheme.surface_container, scheme.secondary_container)
564 }
565 None => (CONTAINER, INDICATOR),
566 }
567}
568
569fn label_view<State: 'static>(label: String, role: ThemeTextColor) -> AnyView<State> {
573 frust::authoring::any::<State, _>(
574 text(label)
575 .size(LABEL_SIZE)
576 .weight(LABEL_WEIGHT)
577 .letter_spacing(LABEL_LETTER_SPACING)
578 .line_height(LineHeight::Absolute(LABEL_LINE_HEIGHT))
579 .themed_role(role)
580 .themed_family(ThemeTextType::LabelMediumEmphasized),
581 )
582}
583
584fn label_role(selected: bool) -> ThemeTextColor {
588 if selected {
589 ThemeTextColor::OnSurface
590 } else {
591 ThemeTextColor::OnSurfaceVariant
592 }
593}
594
595fn label_shown(behavior: NavBarLabelBehavior, selected: bool) -> bool {
598 match behavior {
599 NavBarLabelBehavior::AlwaysShow => true,
600 NavBarLabelBehavior::OnlySelected => selected,
601 NavBarLabelBehavior::AlwaysHide => false,
602 }
603}
604
605fn inside(pos: Point, size: Size) -> bool {
608 pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
609}
610
611pub struct NavItem<State: 'static> {
615 icon: Option<AnyView<State>>,
616 label: String,
617 badge: Option<NavBadge>,
618}
619
620pub fn nav_item<State: 'static>(label: impl Into<String>) -> NavItem<State> {
623 NavItem {
624 icon: None,
625 label: label.into(),
626 badge: None,
627 }
628}
629
630impl<State: 'static> NavItem<State> {
631 pub fn icon(mut self, icon: impl View<State>) -> Self {
634 self.icon = Some(AnyView::new(icon));
635 self
636 }
637
638 pub fn badge_dot(mut self) -> Self {
643 self.badge = Some(NavBadge::Dot);
644 self
645 }
646
647 pub fn badge_count(mut self, count: u32) -> Self {
651 self.badge = Some(NavBadge::Count(count));
652 self
653 }
654
655 fn compose_badge(mut self) -> Self {
662 let Some(kind) = self.badge else {
663 return self;
664 };
665 let Some(icon) = self.icon.take() else {
666 return self;
667 };
668 let badged = crate::badge::badge::<State, _>(icon);
669 let badged = match kind {
670 NavBadge::Dot => badged.dot(),
671 NavBadge::Count(count) => badged.count(count),
672 };
673 self.icon = Some(frust::authoring::any::<State, _>(badged));
674 self
675 }
676}
677
678type OnSelect<State> = Rc<dyn Fn(&mut State, usize)>;
680
681pub struct NavigationBarView<State: 'static> {
683 items: Vec<NavItem<State>>,
684 selected: usize,
685 on_select: OnSelect<State>,
686 size: NavBarSize,
687 label_behavior: NavBarLabelBehavior,
688 indicator_style: NavBarIndicatorStyle,
689 safe_area: bool,
690}
691
692pub fn navigation_bar<State: 'static, F: Fn(&mut State, usize) + 'static>(
698 items: Vec<NavItem<State>>,
699 selected: usize,
700 on_select: F,
701) -> NavigationBarView<State> {
702 NavigationBarView {
703 items: items
704 .into_iter()
705 .map(NavItem::compose_badge)
706 .collect::<Vec<_>>(),
707 selected,
708 on_select: Rc::new(on_select),
709 size: NavBarSize::default(),
710 label_behavior: NavBarLabelBehavior::default(),
711 indicator_style: NavBarIndicatorStyle::default(),
712 safe_area: true,
713 }
714}
715
716#[allow(non_snake_case)]
719pub fn NavigationBar<State: 'static, F: Fn(&mut State, usize) + 'static>(
720 items: Vec<NavItem<State>>,
721 selected: usize,
722 on_select: F,
723) -> NavigationBarView<State> {
724 navigation_bar(items, selected, on_select)
725}
726
727impl<State: 'static> NavigationBarView<State> {
728 pub fn size(mut self, size: NavBarSize) -> Self {
730 self.size = size;
731 self
732 }
733
734 pub fn label_behavior(mut self, behavior: NavBarLabelBehavior) -> Self {
737 self.label_behavior = behavior;
738 self
739 }
740
741 pub fn indicator_style(mut self, style: NavBarIndicatorStyle) -> Self {
743 self.indicator_style = style;
744 self
745 }
746
747 pub fn safe_area(mut self, enabled: bool) -> Self {
763 self.safe_area = enabled;
764 self
765 }
766}
767
768fn item_on_select<State: 'static>(
772 on_select: &OnSelect<State>,
773 idx: usize,
774) -> frust::authoring::ErasedCallback {
775 let callback = on_select.clone();
776 Box::new(move |ctx: &mut EventCtx| {
777 let state = ctx.state_mut::<State>();
778 callback(state, idx);
779 })
780}
781
782struct NavItemWidget {
787 icon: Option<ChildPod>,
788 label: Option<ChildPod>,
791 label_text: String,
794 selected: bool,
795 icon_pop: AnimationController,
798 on_select: frust::authoring::ErasedCallback,
799 captured: bool,
803 indicator_rect: Rect,
807}
808
809fn build_item<State: 'static>(
811 item: &NavItem<State>,
812 selected: bool,
813 behavior: NavBarLabelBehavior,
814 on_select: &OnSelect<State>,
815 idx: usize,
816 ctx: &mut BuildCtx<'_>,
817) -> ChildPod {
818 let icon = item
819 .icon
820 .as_ref()
821 .map(|icon| frust::authoring::build_child(icon, ctx));
822 let label = label_shown(behavior, selected).then(|| {
823 let view = label_view::<State>(item.label.clone(), label_role(selected));
824 frust::authoring::build_child(&view, ctx)
825 });
826
827 let widget = NavItemWidget {
828 icon,
829 label,
830 label_text: item.label.clone(),
831 selected,
832 icon_pop: settled_pop(),
835 on_select: item_on_select::<State>(on_select, idx),
836 captured: false,
837 indicator_rect: Rect::ZERO,
838 };
839 ChildPod::new(Box::new(widget))
840}
841
842impl Widget for NavItemWidget {
843 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
844 let width = if bc.max().width.is_finite() {
845 bc.max().width
846 } else {
847 0.0
848 };
849 let height = if bc.max().height.is_finite() {
852 bc.max().height
853 } else {
854 NavBarSize::default().height()
855 };
856
857 let label_size = self.label.as_mut().map(|label| {
858 label.layout_child(ctx, &BoxConstraints::loose(Size::new(width, f64::INFINITY)))
859 });
860 let content_h = INDICATOR_H + label_size.map_or(0.0, |size| LABEL_GAP + size.height);
864 let top = ((height - content_h) / 2.0).max(0.0);
865
866 let pill_w = INDICATOR_W.min(width);
867 let pill_x = (width - pill_w) / 2.0;
868 self.indicator_rect = Rect::new(pill_x, top, pill_x + pill_w, top + INDICATOR_H);
869
870 if let Some(icon) = self.icon.as_mut() {
871 let icon_size =
872 icon.layout_child(ctx, &BoxConstraints::loose(Size::new(pill_w, INDICATOR_H)));
873 icon.set_origin(Point::new(
874 pill_x + (pill_w - icon_size.width) / 2.0,
875 top + (INDICATOR_H - icon_size.height) / 2.0,
876 ));
877 }
878 if let (Some(label), Some(size)) = (self.label.as_mut(), label_size) {
879 label.set_origin(Point::new(
880 (width - size.width) / 2.0,
881 top + INDICATOR_H + LABEL_GAP,
882 ));
883 }
884 bc.constrain(Size::new(width, height))
885 }
886
887 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
888 let reduce_motion = Theme::from_paint_ctx(ctx).is_some_and(|t| t.motion.reduce_motion);
889 if reduce_motion {
890 if self.icon_pop.is_animating() {
893 self.icon_pop = settled_pop();
894 }
895 } else if self.icon_pop.advance(ctx.frame_time()) {
896 ctx.request_frame();
897 }
898
899 if let Some(icon) = self.icon.as_mut() {
900 let scale = ICON_POP_FROM + (1.0 - ICON_POP_FROM) * self.icon_pop.value();
901 let popped = (scale - 1.0).abs() > ICON_POP_EPSILON;
902 if popped {
903 let center = (ctx.origin() + icon.origin().to_vec2()).to_vec2()
904 + icon.size().to_vec2() / 2.0;
905 scene.push_transform(
906 Affine::translate(center) * Affine::scale(scale) * Affine::translate(-center),
907 );
908 }
909 icon.paint_child(ctx, scene);
910 if popped {
911 scene.pop_transform();
912 }
913 }
914 if let Some(label) = self.label.as_mut() {
915 label.paint_child(ctx, scene);
916 }
917 }
918
919 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
920 let InputEvent::Pointer(p) = event else {
921 return EventResult::Ignored;
922 };
923 match p.phase {
924 PointerPhase::Down => {
925 if !presses(p) {
926 return EventResult::Ignored;
927 }
928 self.captured = true;
929 ctx.capture_pointer();
930 EventResult::Handled
931 }
932 PointerPhase::Move => {
933 if !self.captured {
934 return EventResult::Ignored;
935 }
936 EventResult::Handled
937 }
938 PointerPhase::Up => {
939 if !self.captured {
940 return EventResult::Ignored;
941 }
942 if inside(p.position, ctx.size()) {
943 (self.on_select)(ctx);
944 }
945 self.captured = false;
946 EventResult::Handled
947 }
948 PointerPhase::Cancel => {
949 if !self.captured {
950 return EventResult::Ignored;
951 }
952 self.captured = false;
953 EventResult::Handled
954 }
955 }
956 }
957
958 fn semantics(&self, ctx: &mut SemanticsCtx) {
959 ctx.push_node(Role::Tab, |node| {
960 node.set_label(self.label_text.as_str());
961 node.set_selected(self.selected);
962 });
963 }
964
965 frust::authoring::visit_children!(icon, label);
966}
967
968fn cancel_item(pod: &mut ChildPod) {
973 let mut dummy_state = ();
974 let mut ctx = EventCtx::new(&mut dummy_state, pod.origin(), pod.size());
975 let cancel = InputEvent::Pointer(PointerEvent {
976 phase: PointerPhase::Cancel,
977 position: Point::ZERO,
978 button: PointerButton::Primary,
979 });
980 pod.event_child(&mut ctx, &cancel);
981}
982
983fn teardown_item<State: 'static>(
986 item: &NavItem<State>,
987 selected: bool,
988 behavior: NavBarLabelBehavior,
989 widget: &mut NavItemWidget,
990 ctx: &mut BuildCtx<'_>,
991) {
992 if let (Some(icon_view), Some(icon_pod)) = (&item.icon, widget.icon.as_mut()) {
993 frust::authoring::teardown_child(icon_view, icon_pod, ctx);
994 }
995 if let (true, Some(label_pod)) = (label_shown(behavior, selected), widget.label.as_mut()) {
996 let view = label_view::<State>(item.label.clone(), label_role(selected));
997 frust::authoring::teardown_child(&view, label_pod, ctx);
998 }
999}
1000
1001pub struct NavigationBarWidget {
1003 items: Vec<ChildPod>,
1005 selected: usize,
1008 size: NavBarSize,
1009 safe_area: bool,
1010 bottom_inset: f64,
1015 indicator_style: NavBarIndicatorStyle,
1016 indicator: LiquidIndicator,
1017 selection_moved: bool,
1021}
1022
1023impl<State: 'static> View<State> for NavigationBarView<State> {
1024 type Element = NavigationBarWidget;
1025
1026 fn build(&self, ctx: &mut BuildCtx<'_>) -> NavigationBarWidget {
1027 let mut items = Vec::with_capacity(self.items.len());
1028 for (i, item) in self.items.iter().enumerate() {
1029 items.push(build_item(
1030 item,
1031 i == self.selected,
1032 self.label_behavior,
1033 &self.on_select,
1034 i,
1035 ctx,
1036 ));
1037 }
1038 NavigationBarWidget {
1039 items,
1040 selected: self.selected,
1041 size: self.size,
1042 safe_area: self.safe_area,
1043 bottom_inset: 0.0,
1044 indicator_style: self.indicator_style,
1045 indicator: LiquidIndicator::new(),
1046 selection_moved: false,
1047 }
1048 }
1049
1050 fn rebuild(
1051 &self,
1052 prev: &Self,
1053 element: &mut NavigationBarWidget,
1054 ctx: &mut BuildCtx<'_>,
1055 ) -> ChangeFlags {
1056 let mut flags = ChangeFlags::NONE;
1057 let common = prev.items.len().min(self.items.len());
1058
1059 for i in 0..common {
1060 let prev_item = &prev.items[i];
1061 let next_item = &self.items[i];
1062 let was_selected = i == prev.selected;
1063 let now_selected = i == self.selected;
1064 let pod = &mut element.items[i];
1065 let widget = pod
1066 .widget_mut()
1067 .downcast_mut::<NavItemWidget>()
1068 .expect("nav item pod holds a NavItemWidget");
1069
1070 match (&prev_item.icon, &next_item.icon) {
1071 (Some(p), Some(n)) => {
1072 flags |= frust::authoring::rebuild_child(
1073 p,
1074 n,
1075 widget.icon.as_mut().expect("icon pod present"),
1076 ctx,
1077 );
1078 }
1079 (None, Some(n)) => {
1080 widget.icon = Some(frust::authoring::build_child(n, ctx));
1081 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1082 }
1083 (Some(p), None) => {
1084 if let Some(mut old) = widget.icon.take() {
1085 frust::authoring::teardown_child(p, &mut old, ctx);
1086 }
1087 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1088 }
1089 (None, None) => {}
1090 }
1091
1092 let prev_role = label_role(was_selected);
1093 let next_role = label_role(now_selected);
1094 let was_shown = label_shown(prev.label_behavior, was_selected);
1095 let now_shown = label_shown(self.label_behavior, now_selected);
1096 widget.label_text = next_item.label.clone();
1097 match (was_shown, now_shown) {
1098 (true, true) => {
1099 if (prev_item.label != next_item.label || prev_role != next_role)
1100 && let Some(label) = widget.label.as_mut()
1101 {
1102 let prev_view = label_view::<State>(prev_item.label.clone(), prev_role);
1103 let next_view = label_view::<State>(next_item.label.clone(), next_role);
1104 flags |=
1105 frust::authoring::rebuild_child(&prev_view, &next_view, label, ctx);
1106 }
1107 }
1108 (false, true) => {
1109 let view = label_view::<State>(next_item.label.clone(), next_role);
1110 widget.label = Some(frust::authoring::build_child(&view, ctx));
1111 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1112 }
1113 (true, false) => {
1114 if let Some(mut label) = widget.label.take() {
1115 let view = label_view::<State>(prev_item.label.clone(), prev_role);
1116 frust::authoring::teardown_child(&view, &mut label, ctx);
1117 }
1118 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1119 }
1120 (false, false) => {}
1121 }
1122
1123 if was_selected != now_selected {
1124 widget.selected = now_selected;
1125 if now_selected {
1126 widget.icon_pop = launched_pop();
1127 } else {
1128 widget.icon_pop = settled_pop();
1129 }
1130 flags |= ChangeFlags::PAINT;
1131 }
1132
1133 widget.on_select = item_on_select::<State>(&self.on_select, i);
1135 }
1136
1137 if self.items.len() > prev.items.len() {
1138 for (offset, item) in self.items[common..].iter().enumerate() {
1139 let idx = common + offset;
1140 element.items.push(build_item(
1141 item,
1142 idx == self.selected,
1143 self.label_behavior,
1144 &self.on_select,
1145 idx,
1146 ctx,
1147 ));
1148 }
1149 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1150 } else if self.items.len() < prev.items.len() {
1151 for (offset, item) in prev.items[common..].iter().enumerate() {
1152 let idx = common + offset;
1153 let pod = &mut element.items[idx];
1154 if pod.is_active() {
1155 cancel_item(pod);
1156 pod.set_active(false);
1157 }
1158 let widget = pod
1159 .widget_mut()
1160 .downcast_mut::<NavItemWidget>()
1161 .expect("nav item pod holds a NavItemWidget");
1162 teardown_item(item, idx == prev.selected, prev.label_behavior, widget, ctx);
1163 }
1164 element.items.truncate(common);
1165 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1166 }
1167
1168 if prev.selected != self.selected {
1169 element.selected = self.selected;
1170 element.selection_moved = true;
1174 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1175 }
1176 if prev.size != self.size {
1177 element.size = self.size;
1178 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1179 }
1180 if prev.safe_area != self.safe_area {
1181 element.safe_area = self.safe_area;
1182 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1183 }
1184 if prev.label_behavior != self.label_behavior {
1185 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1186 }
1187 if prev.indicator_style != self.indicator_style {
1188 element.indicator_style = self.indicator_style;
1189 flags |= ChangeFlags::PAINT;
1190 }
1191
1192 flags
1193 }
1194
1195 fn teardown(&self, element: &mut NavigationBarWidget, ctx: &mut BuildCtx<'_>) {
1196 for (i, (item, pod)) in self.items.iter().zip(element.items.iter_mut()).enumerate() {
1197 let widget = pod
1198 .widget_mut()
1199 .downcast_mut::<NavItemWidget>()
1200 .expect("nav item pod holds a NavItemWidget");
1201 teardown_item(item, i == self.selected, self.label_behavior, widget, ctx);
1202 }
1203 }
1204}
1205
1206impl NavigationBarWidget {
1207 fn selected_indicator_rect(&mut self) -> Option<Rect> {
1210 let last = self.items.len().checked_sub(1)?;
1211 let index = self.selected.min(last);
1212 let pod = self.items.get_mut(index)?;
1213 let origin = pod.origin();
1214 let local = pod
1215 .widget_mut()
1216 .downcast_mut::<NavItemWidget>()
1217 .expect("nav item pod holds a NavItemWidget")
1218 .indicator_rect;
1219 Some(local + origin.to_vec2())
1220 }
1221}
1222
1223impl Widget for NavigationBarWidget {
1224 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1225 let width = if bc.max().width.is_finite() {
1226 bc.max().width
1227 } else {
1228 0.0
1229 };
1230 let band = self.size.height();
1231 let inset = if self.safe_area {
1232 ctx.window_insets().padding().bottom
1233 } else {
1234 0.0
1235 };
1236 self.bottom_inset = inset;
1237 let height = band + inset;
1238 let reduce_motion = Theme::from_layout_ctx(ctx).is_some_and(|t| t.motion.reduce_motion);
1241 let travel = std::mem::take(&mut self.selection_moved)
1242 && self.indicator_style == NavBarIndicatorStyle::Pill
1243 && !reduce_motion;
1244
1245 let n = self.items.len();
1246 if n == 0 {
1247 return bc.constrain(Size::new(width, height));
1248 }
1249 let slot_w = width / n as f64;
1250 for (i, pod) in self.items.iter_mut().enumerate() {
1251 pod.layout_child(ctx, &BoxConstraints::tight(Size::new(slot_w, band)));
1252 pod.set_origin(Point::new(i as f64 * slot_w, 0.0));
1253 }
1254 if let Some(rest) = self.selected_indicator_rect() {
1255 self.indicator.sync(rest, travel);
1256 }
1257 bc.constrain(Size::new(width, height))
1258 }
1259
1260 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1261 let theme = Theme::from_paint_ctx(ctx);
1262 let (container_fill, indicator_fill) = resolve_colors(theme);
1263 let reduce_motion = theme.is_some_and(|t| t.motion.reduce_motion);
1264 scene.fill_rect(ctx.origin(), ctx.size(), container_fill);
1265
1266 match self.indicator_style {
1267 NavBarIndicatorStyle::Pill => {
1268 if reduce_motion {
1269 self.indicator.settle();
1270 } else if self.indicator.advance(ctx.frame_time()) {
1271 ctx.request_frame();
1274 }
1275 if let Some(pill) = self.indicator.pill_rect() {
1276 scene.fill_rounded_rect(
1277 ctx.origin() + Vec2::new(pill.x0, pill.y0),
1278 pill.size(),
1279 self.indicator.radius(),
1280 indicator_fill,
1281 );
1282 }
1283 }
1284 NavBarIndicatorStyle::Underline => {
1285 if let Some(rest) = self.selected_indicator_rect() {
1286 scene.fill_rect(
1288 ctx.origin() + Vec2::new(rest.x0, rest.y1 - UNDERLINE_THICKNESS),
1289 Size::new(rest.width(), UNDERLINE_THICKNESS),
1290 indicator_fill,
1291 );
1292 }
1293 }
1294 NavBarIndicatorStyle::None => {}
1295 }
1296
1297 for pod in &mut self.items {
1298 pod.paint_child(ctx, scene);
1299 }
1300 }
1301
1302 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1303 frust::authoring::route_event(&mut self.items, ctx, event)
1304 }
1305
1306 fn semantics(&self, ctx: &mut SemanticsCtx) {
1307 ctx.push_container(
1308 Role::TabList,
1309 |_| {},
1310 |ctx| {
1311 for pod in &self.items {
1312 pod.semantics_child(ctx);
1313 }
1314 },
1315 );
1316 }
1317
1318 frust::authoring::visit_children!(items);
1319}
1320
1321#[cfg(test)]
1322mod tests {
1323 use super::*;
1324 use frust::authoring::text::TextContext;
1325 use frust::authoring::{BuildCtx, PointerButton, PointerEvent, WindowEdgeInsets, WindowInsets};
1326 use frust_widgets::test_support::{RecordingScene, leaf_any};
1327 use std::any::Any;
1328 use std::cell::Cell;
1329
1330 fn ctx(counter: &mut u64) -> BuildCtx<'_> {
1331 BuildCtx::new(counter)
1332 }
1333
1334 fn build(view: &NavigationBarView<()>) -> NavigationBarWidget {
1335 let mut counter = 0u64;
1336 View::<()>::build(view, &mut ctx(&mut counter))
1337 }
1338
1339 fn layout(w: &mut NavigationBarWidget, bc: &BoxConstraints) -> Size {
1340 let mut tcx = TextContext::new();
1341 let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), None);
1342 w.layout(&mut lctx, bc)
1343 }
1344
1345 fn layout_themed(w: &mut NavigationBarWidget, bc: &BoxConstraints, theme: &Theme) -> Size {
1346 let mut tcx = TextContext::new();
1347 let mut lctx =
1348 LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), Some(theme as &dyn Any));
1349 w.layout(&mut lctx, bc)
1350 }
1351
1352 fn layout_with_bottom_inset(
1356 w: &mut NavigationBarWidget,
1357 bc: &BoxConstraints,
1358 bottom: f64,
1359 ) -> Size {
1360 let insets = WindowInsets::new(
1361 WindowEdgeInsets::new(0.0, 0.0, 0.0, bottom),
1362 WindowEdgeInsets::ZERO,
1363 );
1364 let mut tcx = TextContext::new();
1365 let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), None);
1366 lctx.with_window_insets(insets, |ctx| w.layout(ctx, bc))
1367 }
1368
1369 fn three_items() -> Vec<NavItem<()>> {
1370 vec![nav_item("Home"), nav_item("Search"), nav_item("Profile")]
1371 }
1372
1373 fn ft(ms: f64) -> FrameTime {
1374 FrameTime::from_nanos((ms * 1_000_000.0) as u64)
1375 }
1376
1377 #[derive(Default)]
1380 struct PillRecorder {
1381 rects: Vec<(Point, Size)>,
1382 rounded: Vec<(Point, Size, f64)>,
1383 transforms: Vec<Affine>,
1384 transform_pops: u32,
1385 }
1386
1387 impl PaintScene for PillRecorder {
1388 fn fill_rect(&mut self, origin: Point, size: Size, _color: Color) {
1389 self.rects.push((origin, size));
1390 }
1391 fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, _color: Color) {
1392 self.rounded.push((origin, size, radius));
1393 }
1394 fn draw_text(&mut self, _origin: Point, _text: &str) {}
1395 fn push_transform(&mut self, transform: Affine) {
1396 self.transforms.push(transform);
1397 }
1398 fn pop_transform(&mut self) {
1399 self.transform_pops += 1;
1400 }
1401 }
1402
1403 fn paint_at(w: &mut NavigationBarWidget, size: Size, time: FrameTime) -> (PillRecorder, bool) {
1404 let mut scene = PillRecorder::default();
1405 let mut pctx = PaintCtx::for_test(Point::ZERO, size, time);
1406 w.paint(&mut pctx, &mut scene);
1407 let needs_frame = pctx.needs_frame();
1408 (scene, needs_frame)
1409 }
1410
1411 fn paint_themed_at(
1412 w: &mut NavigationBarWidget,
1413 size: Size,
1414 time: FrameTime,
1415 theme: &Theme,
1416 ) -> (PillRecorder, bool) {
1417 let mut scene = PillRecorder::default();
1418 let mut pctx = PaintCtx::for_test(Point::ZERO, size, time).with_theme(theme);
1419 w.paint(&mut pctx, &mut scene);
1420 let needs_frame = pctx.needs_frame();
1421 (scene, needs_frame)
1422 }
1423
1424 #[test]
1427 fn layout_divides_width_evenly_and_fills_height() {
1428 let view: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
1429 let mut w = build(&view);
1430 let size = layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
1431 assert_eq!(size, Size::new(300.0, NavBarSize::default().height()));
1432 assert_eq!(w.items[0].origin().x, 0.0);
1433 assert_eq!(w.items[1].origin().x, 100.0);
1434 assert_eq!(w.items[2].origin().x, 200.0);
1435 assert_eq!(
1436 w.items[0].size(),
1437 Size::new(100.0, NavBarSize::default().height())
1438 );
1439 }
1440
1441 #[test]
1442 fn the_two_sizes_carry_their_reference_container_heights() {
1443 assert_eq!(NavBarSize::Small.height(), HEIGHT_SMALL);
1444 assert_eq!(NavBarSize::Medium.height(), HEIGHT_MEDIUM);
1445 for (size, expected) in [
1446 (NavBarSize::Small, HEIGHT_SMALL),
1447 (NavBarSize::Medium, HEIGHT_MEDIUM),
1448 ] {
1449 let view: NavigationBarView<()> =
1450 navigation_bar(three_items(), 0, |_s: &mut (), _i| {}).size(size);
1451 let mut w = build(&view);
1452 let laid = layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
1453 assert_eq!(laid.height, expected, "{size:?}");
1454 assert_eq!(w.items[0].size().height, expected, "{size:?}");
1455 }
1456 }
1457
1458 #[test]
1459 fn default_size_is_medium() {
1460 let view: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
1461 let mut w = build(&view);
1462 let size = layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
1463 assert_eq!(size, Size::new(300.0, HEIGHT_MEDIUM));
1464 assert_eq!(w.items[0].size().height, HEIGHT_MEDIUM);
1465 }
1466
1467 #[test]
1470 fn safe_area_grows_the_bar_by_the_consumed_bottom_inset() {
1471 let view: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
1472 let mut w = build(&view);
1473 let size = layout_with_bottom_inset(
1474 &mut w,
1475 &BoxConstraints::loose(Size::new(300.0, 200.0)),
1476 34.0,
1477 );
1478 assert_eq!(
1479 size,
1480 Size::new(300.0, NavBarSize::default().height() + 34.0)
1481 );
1482 assert_eq!(w.bottom_inset, 34.0);
1483 }
1484
1485 #[test]
1486 fn items_stay_at_the_bare_band_height_under_a_self_consumed_inset() {
1487 let view: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
1488 let mut w = build(&view);
1489 layout_with_bottom_inset(
1490 &mut w,
1491 &BoxConstraints::loose(Size::new(300.0, 200.0)),
1492 34.0,
1493 );
1494 for item in &w.items {
1495 assert_eq!(
1496 item.origin().y,
1497 0.0,
1498 "items sit at the top of the taller bar"
1499 );
1500 assert_eq!(
1501 item.size().height,
1502 NavBarSize::default().height(),
1503 "items stay the bare band tall"
1504 );
1505 }
1506 }
1507
1508 #[test]
1509 fn indicator_geometry_is_unchanged_by_a_self_consumed_inset() {
1510 let view: NavigationBarView<()> = navigation_bar(three_items(), 1, |_s: &mut (), _i| {});
1511 let bc = BoxConstraints::loose(Size::new(300.0, 200.0));
1512
1513 let mut zero = build(&view);
1514 layout(&mut zero, &bc);
1515 let (zero_scene, _) = paint_at(
1516 &mut zero,
1517 Size::new(300.0, NavBarSize::default().height()),
1518 ft(0.0),
1519 );
1520
1521 let mut inset = build(&view);
1522 layout_with_bottom_inset(&mut inset, &bc, 34.0);
1523 let (inset_scene, _) = paint_at(
1524 &mut inset,
1525 Size::new(300.0, NavBarSize::default().height() + 34.0),
1526 ft(0.0),
1527 );
1528
1529 assert_eq!(
1530 zero_scene.rounded, inset_scene.rounded,
1531 "the pill's geometry is item-local and byte-identical for a nonzero inset"
1532 );
1533 }
1534
1535 #[test]
1536 fn safe_area_false_ignores_the_window_inset() {
1537 let view: NavigationBarView<()> =
1538 navigation_bar(three_items(), 0, |_s: &mut (), _i| {}).safe_area(false);
1539 let mut w = build(&view);
1540 let size = layout_with_bottom_inset(
1541 &mut w,
1542 &BoxConstraints::loose(Size::new(300.0, 200.0)),
1543 34.0,
1544 );
1545 assert_eq!(size, Size::new(300.0, NavBarSize::default().height()));
1546 assert_eq!(w.bottom_inset, 0.0, "an opted-out bar consumes nothing");
1547 }
1548
1549 #[test]
1550 fn container_fill_covers_the_full_band_plus_inset_size() {
1551 let view: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
1552 let mut w = build(&view);
1553 layout_with_bottom_inset(
1554 &mut w,
1555 &BoxConstraints::loose(Size::new(300.0, 200.0)),
1556 34.0,
1557 );
1558 let (scene, _) = paint_at(
1559 &mut w,
1560 Size::new(300.0, NavBarSize::default().height() + 34.0),
1561 ft(0.0),
1562 );
1563 assert_eq!(
1564 scene.rects[0],
1565 (
1566 Point::ZERO,
1567 Size::new(300.0, NavBarSize::default().height() + 34.0)
1568 ),
1569 "the container fill covers the band plus the consumed inset"
1570 );
1571 }
1572
1573 #[test]
1574 fn rebuild_flipping_safe_area_returns_layout() {
1575 let mut counter = 0u64;
1576 let prev: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
1577 let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
1578
1579 let next: NavigationBarView<()> =
1580 navigation_bar(three_items(), 0, |_s: &mut (), _i| {}).safe_area(false);
1581 let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
1582 assert!(
1583 flags.needs_layout(),
1584 "safe_area toggles the self-inset, a layout change"
1585 );
1586 assert!(!w.safe_area);
1587 }
1588
1589 #[test]
1590 fn label_behavior_decides_which_destinations_hold_a_label_child() {
1591 let cases = [
1592 (NavBarLabelBehavior::AlwaysShow, [true, true, true]),
1593 (NavBarLabelBehavior::OnlySelected, [false, true, false]),
1594 (NavBarLabelBehavior::AlwaysHide, [false, false, false]),
1595 ];
1596 for (behavior, expected) in cases {
1597 let view: NavigationBarView<()> =
1598 navigation_bar(three_items(), 1, |_s: &mut (), _i| {}).label_behavior(behavior);
1599 let mut w = build(&view);
1600 layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
1601 for (i, want) in expected.iter().enumerate() {
1602 let item = w.items[i]
1603 .widget_mut()
1604 .downcast_mut::<NavItemWidget>()
1605 .unwrap();
1606 assert_eq!(item.label.is_some(), *want, "{behavior:?} item {i}");
1607 assert!(!item.label_text.is_empty());
1609 }
1610 }
1611 }
1612
1613 #[test]
1614 fn a_hidden_label_centres_the_indicator_box_on_its_own() {
1615 let hidden: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {})
1616 .label_behavior(NavBarLabelBehavior::AlwaysHide);
1617 let mut w = build(&hidden);
1618 layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
1619 let item = w.items[0]
1620 .widget_mut()
1621 .downcast_mut::<NavItemWidget>()
1622 .unwrap();
1623 let expected_top = (NavBarSize::default().height() - INDICATOR_H) / 2.0;
1624 assert!((item.indicator_rect.y0 - expected_top).abs() < 1e-9);
1625 assert_eq!(item.indicator_rect.width(), INDICATOR_W);
1626 }
1627
1628 #[test]
1629 fn changing_the_label_behavior_asks_for_a_relayout() {
1630 let mut counter = 0u64;
1631 let prev: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
1632 let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
1633 let next: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {})
1634 .label_behavior(NavBarLabelBehavior::AlwaysHide);
1635 let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
1636 assert!(flags.needs_layout(), "label presence is a layout input");
1637 let item = w.items[0]
1638 .widget_mut()
1639 .downcast_mut::<NavItemWidget>()
1640 .unwrap();
1641 assert!(item.label.is_none(), "the label child was torn down");
1642 }
1643
1644 #[test]
1647 fn the_three_indicator_styles_paint_a_pill_an_underline_or_nothing() {
1648 let bar = Size::new(300.0, HEIGHT_SMALL);
1649
1650 let pill: NavigationBarView<()> = navigation_bar(three_items(), 1, |_s: &mut (), _i| {});
1651 let mut w = build(&pill);
1652 layout(&mut w, &BoxConstraints::loose(bar));
1653 let (scene, _) = paint_at(&mut w, bar, ft(0.0));
1654 assert_eq!(scene.rects.len(), 1, "container fill only");
1655 assert_eq!(scene.rounded.len(), 1, "one stadium pill");
1656 let (origin, size, radius) = scene.rounded[0];
1657 assert_eq!(size, Size::new(INDICATOR_W, INDICATOR_H));
1658 assert_eq!(radius, INDICATOR_H / 2.0);
1659 assert!((origin.x + size.width / 2.0 - 150.0).abs() < 1e-9);
1661
1662 let underline: NavigationBarView<()> =
1663 navigation_bar(three_items(), 1, |_s: &mut (), _i| {})
1664 .indicator_style(NavBarIndicatorStyle::Underline);
1665 let mut w = build(&underline);
1666 layout(&mut w, &BoxConstraints::loose(bar));
1667 let (scene, _) = paint_at(&mut w, bar, ft(0.0));
1668 assert!(scene.rounded.is_empty(), "no pill in underline style");
1669 assert_eq!(scene.rects.len(), 2, "container plus underline");
1670 let (origin, size) = scene.rects[1];
1671 assert_eq!(size, Size::new(INDICATOR_W, UNDERLINE_THICKNESS));
1672 assert!((origin.x + size.width / 2.0 - 150.0).abs() < 1e-9);
1673
1674 let none: NavigationBarView<()> = navigation_bar(three_items(), 1, |_s: &mut (), _i| {})
1675 .indicator_style(NavBarIndicatorStyle::None);
1676 let mut w = build(&none);
1677 layout(&mut w, &BoxConstraints::loose(bar));
1678 let (scene, needs_frame) = paint_at(&mut w, bar, ft(0.0));
1679 assert!(scene.rounded.is_empty());
1680 assert_eq!(scene.rects.len(), 1, "container fill only");
1681 assert!(!needs_frame);
1682 }
1683
1684 #[test]
1687 fn the_edge_springs_match_the_reference_preset_with_only_damping_overridden() {
1688 let reference = crate::MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT;
1689 assert_eq!(LEAD_SPRING.stiffness, reference.stiffness);
1690 assert_eq!(TRAIL_SPRING.stiffness, reference.stiffness);
1691 assert_eq!(ICON_POP_SPRING.stiffness, reference.stiffness);
1692 assert_eq!(LEAD_SPRING.mass, 1.0);
1693 assert_eq!(TRAIL_SPRING.mass, 1.0);
1694 assert_eq!(LEAD_SPRING.damping_ratio, 0.45);
1695 assert_eq!(TRAIL_SPRING.damping_ratio, 0.55);
1696 assert_eq!(ICON_POP_SPRING.damping_ratio, 0.5);
1697 const {
1700 assert!(LEAD_SPRING.damping_ratio < TRAIL_SPRING.damping_ratio);
1701 }
1702 }
1703
1704 #[test]
1707 fn the_pill_stretches_toward_the_new_destination_and_then_settles() {
1708 let bar = Size::new(300.0, HEIGHT_SMALL);
1709 let mut counter = 0u64;
1710 let prev: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
1711 let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
1712 layout(&mut w, &BoxConstraints::loose(bar));
1713 let (scene, needs_frame) = paint_at(&mut w, bar, ft(0.0));
1714 assert_eq!(scene.rounded[0].1.width, INDICATOR_W, "resting at item 0");
1715 assert!(!needs_frame, "a settled pill asks for no frames");
1716
1717 let next: NavigationBarView<()> = navigation_bar(three_items(), 2, |_s: &mut (), _i| {});
1718 View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
1719 layout(&mut w, &BoxConstraints::loose(bar));
1720 assert!(w.indicator.is_animating(), "the travel started at layout");
1721
1722 let mut widest: f64 = 0.0;
1723 let mut widest_at = 0usize;
1724 let mut lead_ahead = false;
1725 let mut settled_at = None;
1726 for frame in 0..240 {
1727 let (scene, needs_frame) = paint_at(&mut w, bar, ft(16.0 * frame as f64));
1728 let (origin, size, _) = scene.rounded[0];
1729 if size.width > widest {
1730 widest = size.width;
1731 widest_at = frame;
1732 }
1733 if origin.x + size.width > 250.0 + INDICATOR_W / 2.0 - 1.0 && size.width > INDICATOR_W {
1737 lead_ahead = true;
1738 }
1739 if !needs_frame {
1740 settled_at = Some(frame);
1741 break;
1742 }
1743 }
1744 assert!(
1745 widest > INDICATOR_W + 1.0,
1746 "phase 1 never stretched: widest {widest} vs resting {INDICATOR_W}"
1747 );
1748 assert!(widest_at > 0, "the stretch builds up over frames");
1749 assert!(lead_ahead, "the leading edge reaches the new slot first");
1750
1751 let settled_at = settled_at.expect("the pill settles within 240 frames");
1752 assert!(settled_at > widest_at, "settling follows the stretch");
1753 let (scene, needs_frame) = paint_at(&mut w, bar, ft(16.0 * (settled_at + 1) as f64));
1754 assert!(!needs_frame, "a settled pill stops asking for frames");
1755 let (origin, size, _) = scene.rounded[0];
1756 assert!(
1757 (size.width - INDICATOR_W).abs() < 1e-6,
1758 "phase 2 must collapse the bridge back to the resting width"
1759 );
1760 assert!((origin.x + size.width / 2.0 - 250.0).abs() < 1e-6);
1762 }
1763
1764 #[test]
1770 fn a_same_geometry_relayout_mid_travel_does_not_snap_the_pill() {
1771 let bar = Size::new(300.0, HEIGHT_SMALL);
1772 let mut counter = 0u64;
1773 let prev: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
1774 let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
1775 layout(&mut w, &BoxConstraints::loose(bar));
1776 paint_at(&mut w, bar, ft(0.0));
1777
1778 let next: NavigationBarView<()> = navigation_bar(three_items(), 2, |_s: &mut (), _i| {});
1779 View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
1780 layout(&mut w, &BoxConstraints::loose(bar));
1781 assert!(w.indicator.is_animating(), "the travel started at layout");
1782
1783 paint_at(&mut w, bar, ft(16.0));
1786 paint_at(&mut w, bar, ft(32.0));
1787 let (scene, _) = paint_at(&mut w, bar, ft(48.0));
1788 let mid_flight_width = scene.rounded[0].1.width;
1789 assert!(
1790 mid_flight_width > INDICATOR_W + 1.0,
1791 "must actually be stretched before the interleaved layout, got {mid_flight_width}"
1792 );
1793
1794 layout(&mut w, &BoxConstraints::loose(bar));
1798 assert!(
1799 w.indicator.is_animating(),
1800 "a same-geometry relayout mid-travel must not cancel the travel"
1801 );
1802 let (scene, _) = paint_at(&mut w, bar, ft(64.0));
1803 let width_after = scene.rounded[0].1.width;
1804 assert!(
1805 width_after > INDICATOR_W + 1.0,
1806 "the pill must still be mid-flight after the interleaved layout, \
1807 not snapped to rest (got {width_after})"
1808 );
1809
1810 let mut settled = false;
1812 for frame in 5..240 {
1813 let (scene, needs_frame) = paint_at(&mut w, bar, ft(16.0 * frame as f64));
1814 if !needs_frame {
1815 let (origin, size, _) = scene.rounded[0];
1816 assert!((size.width - INDICATOR_W).abs() < 1e-6);
1817 assert!((origin.x + size.width / 2.0 - 250.0).abs() < 1e-6);
1819 settled = true;
1820 break;
1821 }
1822 }
1823 assert!(
1824 settled,
1825 "the pill still settles within 240 frames despite the interleave"
1826 );
1827 }
1828
1829 #[test]
1830 fn a_width_change_jumps_the_pill_instead_of_travelling() {
1831 let mut counter = 0u64;
1832 let view: NavigationBarView<()> = navigation_bar(three_items(), 1, |_s: &mut (), _i| {});
1833 let mut w = View::<()>::build(&view, &mut ctx(&mut counter));
1834 layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
1835 layout(&mut w, &BoxConstraints::loose(Size::new(600.0, 200.0)));
1836 assert!(!w.indicator.is_animating(), "a relayout never travels");
1837 let pill = w.indicator.pill_rect().expect("laid out");
1838 assert!((pill.width() - INDICATOR_W).abs() < 1e-9);
1839 assert!((pill.center().x - 300.0).abs() < 1e-9);
1841 }
1842
1843 #[test]
1844 fn reduce_motion_snaps_the_liquid_travel_and_asks_for_no_frames() {
1845 let bar = Size::new(300.0, HEIGHT_SMALL);
1846 let mut theme = crate::baseline();
1847 theme.motion.reduce_motion = true;
1848 let mut counter = 0u64;
1849 let prev: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
1850 let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
1851 layout_themed(&mut w, &BoxConstraints::loose(bar), &theme);
1852
1853 let next: NavigationBarView<()> = navigation_bar(three_items(), 2, |_s: &mut (), _i| {});
1854 View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
1855 layout_themed(&mut w, &BoxConstraints::loose(bar), &theme);
1856 assert!(!w.indicator.is_animating(), "reduce_motion never travels");
1857
1858 let (scene, needs_frame) = paint_themed_at(&mut w, bar, ft(0.0), &theme);
1859 assert!(!needs_frame, "reduce_motion asks for no continuation frame");
1860 let (origin, size, _) = scene.rounded[0];
1861 assert!((size.width - INDICATOR_W).abs() < 1e-9, "no stretch at all");
1862 assert!((origin.x + size.width / 2.0 - 250.0).abs() < 1e-9);
1863 }
1864
1865 #[test]
1868 fn selecting_an_item_pops_its_icon_and_then_returns_to_rest() {
1869 let bar = Size::new(300.0, HEIGHT_SMALL);
1870 let mut counter = 0u64;
1871 let items = || {
1872 vec![
1873 nav_item::<()>("Home").icon(leaf_any(24.0, 24.0)),
1874 nav_item::<()>("Search").icon(leaf_any(24.0, 24.0)),
1875 ]
1876 };
1877 let prev: NavigationBarView<()> = navigation_bar(items(), 0, |_s: &mut (), _i| {});
1878 let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
1879 layout(&mut w, &BoxConstraints::loose(bar));
1880 let (scene, _) = paint_at(&mut w, bar, ft(0.0));
1881 assert!(
1882 scene.transforms.is_empty(),
1883 "a settled bar pushes no scale transform"
1884 );
1885
1886 let next: NavigationBarView<()> = navigation_bar(items(), 1, |_s: &mut (), _i| {});
1887 View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
1888 let item = w.items[1]
1889 .widget_mut()
1890 .downcast_mut::<NavItemWidget>()
1891 .unwrap();
1892 assert!(item.icon_pop.is_animating(), "the incoming icon pops");
1893 assert!(
1894 (item.icon_pop.value() - 0.0).abs() < 1e-9,
1895 "starts at 0.98x"
1896 );
1897
1898 layout(&mut w, &BoxConstraints::loose(bar));
1899 let mut settled = false;
1900 let mut scaled_frames = 0;
1901 let mut overshot = false;
1902 for frame in 0..240 {
1903 let (scene, needs_frame) = paint_at(&mut w, bar, ft(16.0 * frame as f64));
1904 assert_eq!(
1905 scene.transforms.len() as u32,
1906 scene.transform_pops,
1907 "every pushed transform is popped"
1908 );
1909 if let Some(t) = scene.transforms.first() {
1910 scaled_frames += 1;
1911 if t.determinant() > 1.0 {
1913 overshot = true;
1914 }
1915 }
1916 if !needs_frame {
1917 settled = true;
1918 break;
1919 }
1920 }
1921 assert!(settled, "the pop settles");
1922 assert!(scaled_frames > 0, "the pop actually scaled the icon");
1923 assert!(overshot, "the pop overshoots past the resting scale");
1924 let item = w.items[1]
1925 .widget_mut()
1926 .downcast_mut::<NavItemWidget>()
1927 .unwrap();
1928 assert!((item.icon_pop.value() - 1.0).abs() < 1e-9, "rests at 1.0x");
1929 }
1930
1931 #[test]
1934 fn a_badge_wraps_the_icon_without_moving_the_hit_target() {
1935 let bar = Size::new(300.0, NavBarSize::default().height());
1936 let bare: NavigationBarView<()> = navigation_bar(
1937 vec![nav_item("Home").icon(leaf_any(24.0, 24.0))],
1938 0,
1939 |_s: &mut (), _i| {},
1940 );
1941 let mut w = build(&bare);
1942 layout(&mut w, &BoxConstraints::loose(bar));
1943 let bare_icon = w.items[0]
1944 .widget_mut()
1945 .downcast_mut::<NavItemWidget>()
1946 .unwrap()
1947 .icon
1948 .as_ref()
1949 .expect("icon pod present")
1950 .size();
1951 assert_eq!(bare_icon, Size::new(24.0, 24.0));
1952
1953 let tapped: Rc<Cell<Option<usize>>> = Rc::new(Cell::new(None));
1954 let sink = tapped.clone();
1955 let badged: NavigationBarView<()> = navigation_bar(
1956 vec![
1957 nav_item("Home").icon(leaf_any(24.0, 24.0)).badge_count(3),
1958 nav_item("Search").icon(leaf_any(24.0, 24.0)).badge_dot(),
1959 ],
1960 0,
1961 move |_s: &mut (), i| sink.set(Some(i)),
1962 );
1963 let mut w = build(&badged);
1964 layout(&mut w, &BoxConstraints::loose(bar));
1965 let item = w.items[0]
1966 .widget_mut()
1967 .downcast_mut::<NavItemWidget>()
1968 .unwrap();
1969 let badged_icon = item.icon.as_ref().expect("icon pod present").size();
1970 assert!(
1971 badged_icon.width > bare_icon.width,
1972 "the badge composes onto the icon: {badged_icon:?}"
1973 );
1974 assert!(badged_icon.height <= INDICATOR_H, "still inside the box");
1975 assert_eq!(
1977 w.items[1].size(),
1978 Size::new(150.0, NavBarSize::default().height())
1979 );
1980
1981 let mut unit = ();
1982 let unit_any: &mut dyn Any = &mut unit;
1983 let mut ectx = EventCtx::new(
1984 unit_any,
1985 Point::ZERO,
1986 Size::new(300.0, NavBarSize::default().height()),
1987 );
1988 w.event(&mut ectx, &ev(PointerPhase::Down, 200.0, 20.0));
1989 w.event(&mut ectx, &ev(PointerPhase::Up, 200.0, 20.0));
1990 assert_eq!(
1991 tapped.get(),
1992 Some(1),
1993 "a badged bar still reports the tapped index"
1994 );
1995 }
1996
1997 #[test]
2000 fn unthemed_paint_uses_fallback_container() {
2001 let view: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
2002 let mut w = build(&view);
2003 layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
2004 let mut scene = RecordingScene::default();
2005 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(300.0, HEIGHT_SMALL));
2006 w.paint(&mut pctx, &mut scene);
2007 assert_eq!(scene.rects[0].1, Size::new(300.0, HEIGHT_SMALL));
2008 }
2009
2010 #[test]
2011 fn themed_paint_resolves_surface_container_and_the_indicator_role() {
2012 let theme = crate::baseline();
2013 let view: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
2014 let mut w = build(&view);
2015 layout_themed(
2016 &mut w,
2017 &BoxConstraints::loose(Size::new(300.0, 200.0)),
2018 &theme,
2019 );
2020
2021 #[derive(Default)]
2022 struct ColorRecorder {
2023 colors: Vec<Color>,
2024 rounded: Vec<Color>,
2025 }
2026 impl PaintScene for ColorRecorder {
2027 fn fill_rect(&mut self, _o: Point, _s: Size, c: Color) {
2028 self.colors.push(c);
2029 }
2030 fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, c: Color) {
2031 self.rounded.push(c);
2032 }
2033 fn draw_text(&mut self, _o: Point, _t: &str) {}
2034 }
2035 let mut scene = ColorRecorder::default();
2036 let mut pctx = PaintCtx::for_test(Point::ZERO, Size::new(300.0, HEIGHT_SMALL), ft(0.0))
2037 .with_theme(&theme);
2038 w.paint(&mut pctx, &mut scene);
2039 assert_eq!(scene.colors[0], theme.scheme().surface_container);
2040 assert_eq!(scene.rounded[0], theme.scheme().secondary_container);
2041 }
2042
2043 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
2046 InputEvent::Pointer(PointerEvent {
2047 phase,
2048 position: Point::new(x, y),
2049 button: PointerButton::Primary,
2050 })
2051 }
2052
2053 #[allow(clippy::ptr_arg)]
2057 fn dispatch(w: &mut NavigationBarWidget, state: &mut Vec<usize>, event: &InputEvent) {
2058 let state_any: &mut dyn Any = state;
2059 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(300.0, HEIGHT_SMALL));
2060 w.event(&mut ctx, event);
2061 }
2062
2063 #[test]
2064 fn tap_inside_an_item_reports_its_index_without_self_mutating() {
2065 let view: NavigationBarView<Vec<usize>> = navigation_bar(
2066 vec![nav_item("Home"), nav_item("Search"), nav_item("Profile")],
2067 0,
2068 |s: &mut Vec<usize>, i| s.push(i),
2069 );
2070 let mut counter = 0u64;
2071 let mut w = View::<Vec<usize>>::build(&view, &mut ctx(&mut counter));
2072 layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
2073
2074 let mut log: Vec<usize> = Vec::new();
2075 dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 150.0, 20.0));
2077 dispatch(&mut w, &mut log, &ev(PointerPhase::Up, 150.0, 20.0));
2078 assert_eq!(log, vec![1]);
2079 assert_eq!(w.selected, 0);
2081 let item = w.items[1]
2082 .widget_mut()
2083 .downcast_mut::<NavItemWidget>()
2084 .unwrap();
2085 assert!(!item.selected, "a tap alone never selects");
2086 }
2087
2088 #[test]
2089 fn rebuild_adopts_new_selected_index() {
2090 let mut counter = 0u64;
2091 let prev: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
2092 let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
2093 let widget0 = w.items[0]
2094 .widget_mut()
2095 .downcast_mut::<NavItemWidget>()
2096 .unwrap();
2097 assert!(widget0.selected);
2098
2099 let next: NavigationBarView<()> = navigation_bar(three_items(), 2, |_s: &mut (), _i| {});
2100 let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
2101 assert!(flags.needs_paint());
2102 assert!(
2103 flags.needs_layout(),
2104 "the pill's travel is resolved in layout"
2105 );
2106
2107 let widget0 = w.items[0]
2108 .widget_mut()
2109 .downcast_mut::<NavItemWidget>()
2110 .unwrap();
2111 assert!(!widget0.selected, "selection moved off item 0");
2112 let widget2 = w.items[2]
2113 .widget_mut()
2114 .downcast_mut::<NavItemWidget>()
2115 .unwrap();
2116 assert!(widget2.selected, "item 2 is now selected");
2117 assert_eq!(w.selected, 2);
2118 assert!(w.selection_moved, "the next layout starts the travel");
2119 }
2120
2121 #[test]
2122 fn growing_item_list_builds_a_fresh_unselected_item() {
2123 let mut counter = 0u64;
2124 let prev: NavigationBarView<()> = navigation_bar(
2125 vec![nav_item("Home"), nav_item("Search")],
2126 0,
2127 |_s: &mut (), _i| {},
2128 );
2129 let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
2130 assert_eq!(w.items.len(), 2);
2131
2132 let next: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
2133 let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
2134 assert_eq!(w.items.len(), 3);
2135 assert!(flags.needs_layout());
2136 }
2137
2138 #[test]
2139 fn shrinking_item_list_tears_down_the_removed_item() {
2140 let mut counter = 0u64;
2141 let prev: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
2142 let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
2143
2144 let next: NavigationBarView<()> =
2145 navigation_bar(vec![nav_item("Home")], 0, |_s: &mut (), _i| {});
2146 let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
2147 assert_eq!(w.items.len(), 1);
2148 assert!(flags.needs_layout());
2149 }
2150
2151 #[test]
2152 fn a_selection_past_the_last_item_still_places_the_indicator() {
2153 let mut counter = 0u64;
2154 let prev: NavigationBarView<()> = navigation_bar(three_items(), 2, |_s: &mut (), _i| {});
2155 let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
2156 let next: NavigationBarView<()> =
2157 navigation_bar(vec![nav_item("Home")], 2, |_s: &mut (), _i| {});
2158 View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
2159 layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
2160 let pill = w.indicator.pill_rect().expect("clamped onto the last item");
2161 assert!((pill.center().x - 150.0).abs() < 1e-9);
2162 }
2163
2164 #[test]
2165 fn icon_slot_is_routed_and_laid_out_above_the_label() {
2166 let view: NavigationBarView<()> = navigation_bar(
2167 vec![nav_item("Home").icon(leaf_any(24.0, 24.0))],
2168 0,
2169 |_s: &mut (), _i| {},
2170 );
2171 let mut w = build(&view);
2172 layout(&mut w, &BoxConstraints::loose(Size::new(100.0, 200.0)));
2173 let widget0 = w.items[0]
2174 .widget_mut()
2175 .downcast_mut::<NavItemWidget>()
2176 .unwrap();
2177 let icon = widget0.icon.as_ref().expect("icon pod present");
2178 let label = widget0.label.as_ref().expect("label pod present");
2179 assert!(icon.origin().y < label.origin().y);
2180 }
2181
2182 #[test]
2183 fn semantics_yields_a_tablist_of_tabs_with_selection() {
2184 fn logic(_state: &mut ()) -> NavigationBarView<()> {
2185 navigation_bar(three_items(), 1, |_s: &mut (), _i| {})
2186 }
2187 let mut root: frust_core::RenderRoot<(), NavigationBarView<()>> =
2188 frust_core::RenderRoot::new();
2189 let mut state = ();
2190 root.rebuild(&mut logic, &mut state);
2191 let mut tcx = TextContext::new();
2192 root.layout_with_text(Size::new(300.0, HEIGHT_SMALL), &mut tcx as &mut dyn Any);
2193 let update = root.semantics();
2194
2195 let tablist = update
2196 .nodes
2197 .iter()
2198 .find(|(_, n)| n.role() == Role::TabList)
2199 .expect("a TabList container node is contributed");
2200 assert_eq!(tablist.1.children().len(), 3);
2201
2202 let tabs: Vec<_> = update
2203 .nodes
2204 .iter()
2205 .filter(|(_, n)| n.role() == Role::Tab)
2206 .collect();
2207 assert_eq!(tabs.len(), 3);
2208 let selected_tab = tabs
2209 .iter()
2210 .find(|(_, n)| n.label() == Some("Search"))
2211 .expect("the Search tab is present");
2212 assert_eq!(selected_tab.1.is_selected(), Some(true));
2213 let unselected_tab = tabs
2214 .iter()
2215 .find(|(_, n)| n.label() == Some("Home"))
2216 .expect("the Home tab is present");
2217 assert_eq!(unselected_tab.1.is_selected(), Some(false));
2218 }
2219
2220 #[test]
2221 fn a_hidden_label_still_reaches_the_semantics_node() {
2222 fn logic(_state: &mut ()) -> NavigationBarView<()> {
2223 navigation_bar(three_items(), 1, |_s: &mut (), _i| {})
2224 .label_behavior(NavBarLabelBehavior::AlwaysHide)
2225 }
2226 let mut root: frust_core::RenderRoot<(), NavigationBarView<()>> =
2227 frust_core::RenderRoot::new();
2228 let mut state = ();
2229 root.rebuild(&mut logic, &mut state);
2230 let mut tcx = TextContext::new();
2231 root.layout_with_text(Size::new(300.0, HEIGHT_SMALL), &mut tcx as &mut dyn Any);
2232 let update = root.semantics();
2233 let labelled = update
2234 .nodes
2235 .iter()
2236 .filter(|(_, n)| n.role() == Role::Tab && n.label().is_some())
2237 .count();
2238 assert_eq!(
2239 labelled, 3,
2240 "every tab keeps its label with no text painted"
2241 );
2242 }
2243
2244 #[cfg(feature = "bundled-fonts")]
2245 #[test]
2246 fn every_label_paints_in_roboto_flex_under_the_material_theme() {
2247 use crate::appbar::top::typeface_probe::{Face, assert_paints_only_in};
2250 let flex = crate::tokens::font_data()[0];
2251 let runs = assert_paints_only_in(
2252 "the navigation bar's labels",
2253 |_: &mut ()| navigation_bar(three_items(), 1, |_s: &mut (), _i| {}),
2254 crate::baseline(),
2255 &[flex],
2256 Size::new(300.0, HEIGHT_SMALL),
2257 Face {
2258 bytes: flex,
2259 name: "Roboto Flex",
2260 },
2261 );
2262 assert!(
2263 runs >= 3,
2264 "fixture sanity: expected a run per label, got {runs}"
2265 );
2266 }
2267}