1use std::rc::Rc;
213use std::time::Duration;
214
215use frust::authoring::text::{
216 FontWeight, LineHeight, TextContext, TextLayout, TextOverflow, TextStyle,
217};
218use frust::authoring::{
219 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, CursorIcon, ErasedCallback, EventCtx,
220 EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, Role, SemanticsCtx,
221 View, Widget, any, build_child, erase_callback, rebuild_child, route_event, route_event_single,
222 teardown_child, visit_children,
223};
224use frust::{
225 AnimationController, Curve, FrameTime, IconSource, Spring, SpringDesc, Theme, Tween, icon,
226};
227use kurbo::{Point, Rect, Size};
228use peniko::{Brush, Color};
229
230use crate::fab::{FabColor, FabSize, extended_fab, fab};
231use crate::icon_button::icon_button;
232use crate::overlay::{OVERLAY_SCRIM_ALPHA, finite_or_zero};
233use crate::press::presses;
234
235pub const RAIL_COLLAPSED_WIDTH: f64 = 96.0;
240pub const RAIL_EXPANDED_MIN_WIDTH: f64 = 220.0;
243pub const RAIL_EXPANDED_MAX_WIDTH: f64 = 360.0;
246pub const RAIL_ITEM_COLLAPSED_HEIGHT: f64 = 66.0;
249pub const RAIL_ITEM_EXPANDED_HEIGHT: f64 = 40.0;
253
254const ICON_SIZE: f64 = 24.0;
257const INDICATOR_LEADING: f64 = 16.0;
260const INDICATOR_TRAILING: f64 = 16.0;
263const ICON_LABEL_GAP: f64 = 8.0;
266const ITEM_VERTICAL_GAP: f64 = 4.0;
269const SECTION_HEADER_SPACING_TOP: f64 = 12.0;
272const SECTION_HEADER_SPACING_BOTTOM: f64 = 8.0;
275const HORIZONTAL_INSET: f64 = 16.0;
279const SECTION_PADDING_BOTTOM: f64 = 12.0;
282const TOP_GAP: f64 = 36.0;
285
286const CHIP_WIDTH: f64 = 52.0;
293const CHIP_HEIGHT: f64 = 40.0;
295const CHIP_TARGET_HEIGHT: f64 = 48.0;
298
299const BADGE_DOT: f64 = 8.0;
302const BADGE_H_PAD: f64 = 6.0;
305const BADGE_V_PAD: f64 = 4.0;
308const BADGE_MAX_COUNT: u32 = 999;
318
319pub const RAIL_EXPAND_DURATION: Duration = Duration::from_millis(280);
323
324pub const RAIL_EXPAND_CURVE: Curve = Curve::Cubic(0.215, 0.61, 0.355, 1.0);
329
330pub const RAIL_LEAD_SPRING: SpringDesc = SpringDesc {
338 mass: 1.0,
339 stiffness: 380.0,
340 damping_ratio: 0.45,
341};
342
343pub const RAIL_TRAIL_SPRING: SpringDesc = SpringDesc {
347 mass: 1.0,
348 stiffness: 380.0,
349 damping_ratio: 0.55,
350};
351
352const TRAVEL_REST_EPSILON: f64 = 0.01;
357
358const TRAVEL_EPSILON: f64 = 0.5;
362
363type TypeToken = (f32, f32, f32, FontWeight);
369
370const LABEL_LARGE: TypeToken = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
373const LABEL_MEDIUM: TypeToken = (12.0, 16.0, 0.5, FontWeight::MEDIUM);
375const LABEL_SMALL: TypeToken = (11.0, 16.0, 0.5, FontWeight::MEDIUM);
377const TITLE_SMALL: TypeToken = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
379
380const SHAPING_INK: Color = Color::BLACK;
383
384const SURFACE: Color = Color::from_rgb8(0xFE, 0xF7, 0xFF);
389const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
392const ON_SECONDARY_CONTAINER: Color = Color::from_rgb8(0x1D, 0x19, 0x2B);
395const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
398const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
400const ON_PRIMARY: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
402const SCRIM: Color = Color::from_rgb8(0x00, 0x00, 0x00);
404
405fn with_alpha(color: Color, alpha: f32) -> Color {
408 let c = color.components;
409 Color::new([c[0], c[1], c[2], alpha])
410}
411
412fn inside(pos: Point, size: Size) -> bool {
415 pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
416}
417
418fn type_style(themed: Option<&TextStyle>, token: TypeToken) -> TextStyle {
421 let mut style = match themed {
422 Some(style) => style.clone(),
423 None => {
424 let (size, line_height, letter_spacing, weight) = token;
425 let mut style = TextStyle::new(size, SHAPING_INK);
426 style.line_height = LineHeight::Absolute(line_height);
427 style.letter_spacing = letter_spacing;
428 style.weight = weight;
429 style
430 }
431 };
432 style.color = SHAPING_INK;
433 style
434}
435
436#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
444pub enum NavigationRailType {
445 Collapsed,
447 AlwaysCollapse,
449 #[default]
452 Expanded,
453 AlwaysExpand,
455}
456
457impl NavigationRailType {
458 pub fn is_expanded(self) -> bool {
461 matches!(self, Self::Expanded | Self::AlwaysExpand)
462 }
463
464 pub fn can_toggle(self) -> bool {
467 matches!(self, Self::Collapsed | Self::Expanded)
468 }
469
470 fn toggled(self) -> Self {
473 if self.is_expanded() {
474 Self::Collapsed
475 } else {
476 Self::Expanded
477 }
478 }
479}
480
481#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
484pub enum NavigationRailModality {
485 #[default]
487 Standard,
488 Modal,
492}
493
494#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
500pub enum RailLabelBehavior {
501 #[default]
503 AlwaysShow,
504 OnlySelected,
506 AlwaysHide,
508}
509
510impl RailLabelBehavior {
511 fn shows(self, selected: bool) -> bool {
514 match self {
515 Self::AlwaysShow => true,
516 Self::OnlySelected => selected,
517 Self::AlwaysHide => false,
518 }
519 }
520}
521
522pub struct RailDestination<State: 'static> {
526 icon: AnyView<State>,
527 selected_icon: Option<AnyView<State>>,
528 label: String,
529 badge_count: Option<u32>,
530 semantic_label: Option<String>,
531}
532
533pub fn rail_destination<State: 'static>(
538 icon: impl View<State>,
539 label: impl Into<String>,
540) -> RailDestination<State> {
541 RailDestination {
542 icon: AnyView::new(icon),
543 selected_icon: None,
544 label: label.into(),
545 badge_count: None,
546 semantic_label: None,
547 }
548}
549
550impl<State: 'static> RailDestination<State> {
551 pub fn selected_icon(mut self, icon: impl View<State>) -> Self {
554 self.selected_icon = Some(AnyView::new(icon));
555 self
556 }
557
558 pub fn badge_count(mut self, count: u32) -> Self {
560 self.badge_count = Some(count);
561 self
562 }
563
564 pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
566 self.semantic_label = Some(label.into());
567 self
568 }
569
570 fn active_icon(&self, selected: bool) -> &AnyView<State> {
574 if selected {
575 self.selected_icon.as_ref().unwrap_or(&self.icon)
576 } else {
577 &self.icon
578 }
579 }
580}
581
582pub struct RailSection<State: 'static> {
589 header: Option<String>,
590 destinations: Vec<RailDestination<State>>,
591}
592
593pub fn rail_section<State: 'static>(
596 destinations: Vec<RailDestination<State>>,
597) -> RailSection<State> {
598 RailSection {
599 header: None,
600 destinations,
601 }
602}
603
604impl<State: 'static> RailSection<State> {
605 pub fn header(mut self, header: impl Into<String>) -> Self {
613 self.header = Some(header.into());
614 self
615 }
616}
617
618pub struct RailFab<State: 'static> {
629 icon: IconSource,
630 label: String,
631 on_press: Rc<dyn Fn(&mut State)>,
632 color: FabColor,
633 size: FabSize,
634 semantic_label: Option<String>,
635}
636
637pub fn rail_fab<State: 'static, F: Fn(&mut State) + 'static>(
640 icon: IconSource,
641 label: impl Into<String>,
642 on_press: F,
643) -> RailFab<State> {
644 RailFab {
645 icon,
646 label: label.into(),
647 on_press: Rc::new(on_press),
648 color: FabColor::Primary,
649 size: FabSize::Medium,
650 semantic_label: None,
651 }
652}
653
654impl<State: 'static> RailFab<State> {
655 pub fn color(mut self, color: FabColor) -> Self {
657 self.color = color;
658 self
659 }
660
661 pub fn size(mut self, size: FabSize) -> Self {
664 self.size = size;
665 self
666 }
667
668 pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
671 self.semantic_label = Some(label.into());
672 self
673 }
674
675 fn shape(&self) -> (&str, FabColor, FabSize, Option<&str>) {
677 (
678 self.label.as_str(),
679 self.color,
680 self.size,
681 self.semantic_label.as_deref(),
682 )
683 }
684}
685
686struct TextRun {
692 content: String,
693 layout: Option<TextLayout>,
694 shaped_for: Option<(TextStyle, Option<f64>)>,
695}
696
697impl TextRun {
698 fn new(content: impl Into<String>) -> Self {
699 Self {
700 content: content.into(),
701 layout: None,
702 shaped_for: None,
703 }
704 }
705
706 fn set_content(&mut self, content: &str) -> bool {
709 if self.content == content {
710 return false;
711 }
712 self.content = content.to_string();
713 self.layout = None;
714 self.shaped_for = None;
715 true
716 }
717
718 fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle, max_width: Option<f64>) -> Size {
722 let key = (style.clone(), max_width);
723 if let Some(layout) = &self.layout
724 && self.shaped_for.as_ref() == Some(&key)
725 {
726 return layout.size();
727 }
728 let text_ctx = ctx.text_context::<TextContext>();
729 let laid = match max_width {
730 Some(width) => text_ctx.layout_bounded(
731 &self.content,
732 style,
733 Some(width as f32),
734 Some(1),
735 TextOverflow::Ellipsis,
736 ),
737 None => text_ctx.layout(&self.content, style, None),
738 };
739 let size = laid.size();
740 self.layout = Some(laid);
741 self.shaped_for = Some(key);
742 size
743 }
744
745 fn size(&self) -> Size {
746 self.layout.as_ref().map_or(Size::ZERO, TextLayout::size)
747 }
748
749 fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
752 let Some(layout) = &self.layout else {
753 return;
754 };
755 for mut run in layout.to_scene_runs(origin) {
756 run.brush = Brush::Solid(color);
757 scene.draw_glyph_run(run);
758 }
759 }
760}
761
762struct WidthMotion {
767 value: f64,
769 target: f64,
771 tween: Tween<f64>,
773 driver: AnimationController,
774}
775
776impl WidthMotion {
777 fn resting(width: f64) -> Self {
781 Self {
782 value: width,
783 target: width,
784 tween: Tween::new(width, width),
785 driver: AnimationController::new(RAIL_EXPAND_DURATION).with_curve(RAIL_EXPAND_CURVE),
786 }
787 }
788
789 fn set_target(&mut self, target: f64) {
791 if self.target == target {
792 return;
793 }
794 self.target = target;
795 self.tween = Tween::new(self.value, target);
796 self.driver = AnimationController::new(RAIL_EXPAND_DURATION).with_curve(RAIL_EXPAND_CURVE);
797 self.driver.forward();
798 }
799
800 fn snap(&mut self) -> bool {
803 let moved = self.value != self.target;
804 self.value = self.target;
805 self.tween = Tween::new(self.target, self.target);
806 self.driver.stop();
807 moved
808 }
809
810 fn advance(&mut self, now: FrameTime) -> bool {
812 let animating = self.driver.advance(now);
813 self.value = self.tween.lerp(self.driver.value_clamped());
814 animating
815 }
816
817 fn is_animating(&self) -> bool {
818 self.driver.is_animating()
819 }
820}
821
822struct TravelAxis {
835 value: f64,
837 target: f64,
839 flight: Option<Spring>,
841 elapsed: f64,
843 velocity: f64,
846 last_time: Option<FrameTime>,
848}
849
850impl TravelAxis {
851 fn new() -> Self {
852 Self {
853 value: 0.0,
854 target: 0.0,
855 flight: None,
856 elapsed: 0.0,
857 velocity: 0.0,
858 last_time: None,
859 }
860 }
861
862 fn jump(&mut self, to: f64) {
865 self.value = to;
866 self.target = to;
867 self.flight = None;
868 self.elapsed = 0.0;
869 self.velocity = 0.0;
870 self.last_time = None;
871 }
872
873 fn travel_to(&mut self, to: f64, spring: SpringDesc) {
878 if (self.target - to).abs() < TRAVEL_EPSILON {
879 return;
880 }
881 self.flight = Some(Spring::new(spring, self.value - to, self.velocity));
882 self.target = to;
883 self.elapsed = 0.0;
884 self.last_time = None;
885 }
886
887 fn advance(&mut self, now: FrameTime) -> bool {
894 let Some(spring) = self.flight else {
895 return false;
896 };
897 let dt = match self.last_time {
898 Some(last) => now.saturating_sub(last).as_secs_f64(),
899 None => 0.0,
900 };
901 self.last_time = Some(now);
902 self.elapsed += dt;
903 if spring.is_at_rest(self.elapsed, TRAVEL_REST_EPSILON) {
904 self.value = self.target;
905 self.velocity = 0.0;
906 self.flight = None;
907 self.elapsed = 0.0;
908 self.last_time = None;
909 return false;
910 }
911 self.value = self.target + spring.position(self.elapsed);
912 self.velocity = spring.velocity(self.elapsed);
913 true
914 }
915
916 fn is_animating(&self) -> bool {
917 self.flight.is_some()
918 }
919}
920
921type OnSelect<State> = Rc<dyn Fn(&mut State, usize)>;
925type OnTypeChanged<State> = Rc<dyn Fn(&mut State, NavigationRailType)>;
927type OnDismiss<State> = Rc<dyn Fn(&mut State)>;
929
930pub struct NavigationRailView<State: 'static> {
932 sections: Vec<RailSection<State>>,
933 selected: usize,
934 on_select: OnSelect<State>,
935 rail_type: NavigationRailType,
936 modality: NavigationRailModality,
937 fab: Option<RailFab<State>>,
938 hide_when_collapsed: bool,
939 expanded_width: Option<f64>,
940 on_dismiss_modal: Option<OnDismiss<State>>,
941 on_type_changed: Option<OnTypeChanged<State>>,
942 label_behavior: RailLabelBehavior,
943 trailing: Option<AnyView<State>>,
944 trailing_at_bottom: bool,
945 background: Option<Color>,
946}
947
948pub fn navigation_rail<State: 'static, F: Fn(&mut State, usize) + 'static>(
956 sections: Vec<RailSection<State>>,
957 selected: usize,
958 on_select: F,
959) -> NavigationRailView<State> {
960 NavigationRailView {
961 sections,
962 selected,
963 on_select: Rc::new(on_select),
964 rail_type: NavigationRailType::default(),
965 modality: NavigationRailModality::default(),
966 fab: None,
967 hide_when_collapsed: false,
968 expanded_width: None,
969 on_dismiss_modal: None,
970 on_type_changed: None,
971 label_behavior: RailLabelBehavior::default(),
972 trailing: None,
973 trailing_at_bottom: true,
974 background: None,
975 }
976}
977
978#[allow(non_snake_case)]
981pub fn NavigationRail<State: 'static, F: Fn(&mut State, usize) + 'static>(
982 sections: Vec<RailSection<State>>,
983 selected: usize,
984 on_select: F,
985) -> NavigationRailView<State> {
986 navigation_rail(sections, selected, on_select)
987}
988
989impl<State: 'static> NavigationRailView<State> {
990 pub fn rail_type(mut self, rail_type: NavigationRailType) -> Self {
993 self.rail_type = rail_type;
994 self
995 }
996
997 pub fn modality(mut self, modality: NavigationRailModality) -> Self {
1000 self.modality = modality;
1001 self
1002 }
1003
1004 pub fn fab(mut self, fab: RailFab<State>) -> Self {
1006 self.fab = Some(fab);
1007 self
1008 }
1009
1010 pub fn hide_when_collapsed(mut self, hide: bool) -> Self {
1015 self.hide_when_collapsed = hide;
1016 self
1017 }
1018
1019 pub fn expanded_width(mut self, width: f64) -> Self {
1023 self.expanded_width = Some(width);
1024 self
1025 }
1026
1027 pub fn on_dismiss_modal<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
1031 self.on_dismiss_modal = Some(Rc::new(on_dismiss));
1032 self
1033 }
1034
1035 pub fn on_type_changed<F: Fn(&mut State, NavigationRailType) + 'static>(
1040 mut self,
1041 on_changed: F,
1042 ) -> Self {
1043 self.on_type_changed = Some(Rc::new(on_changed));
1044 self
1045 }
1046
1047 pub fn label_behavior(mut self, behavior: RailLabelBehavior) -> Self {
1050 self.label_behavior = behavior;
1051 self
1052 }
1053
1054 pub fn trailing(mut self, trailing: impl View<State>) -> Self {
1056 self.trailing = Some(AnyView::new(trailing));
1057 self
1058 }
1059
1060 pub fn trailing_at_bottom(mut self, at_bottom: bool) -> Self {
1064 self.trailing_at_bottom = at_bottom;
1065 self
1066 }
1067
1068 pub fn background(mut self, color: Color) -> Self {
1070 self.background = Some(color);
1071 self
1072 }
1073
1074 fn destinations(&self) -> Vec<&RailDestination<State>> {
1077 self.sections
1078 .iter()
1079 .flat_map(|section| section.destinations.iter())
1080 .collect()
1081 }
1082
1083 fn target_width(&self) -> f64 {
1085 if self.rail_type.is_expanded() {
1086 self.expanded_width
1087 .unwrap_or(RAIL_EXPANDED_MIN_WIDTH)
1088 .clamp(RAIL_EXPANDED_MIN_WIDTH, RAIL_EXPANDED_MAX_WIDTH)
1089 } else if self.hide_when_collapsed {
1090 0.0
1091 } else {
1092 RAIL_COLLAPSED_WIDTH
1093 }
1094 }
1095
1096 fn menu_view(&self) -> Option<AnyView<State>> {
1099 if !self.rail_type.can_toggle() {
1100 return None;
1101 }
1102 let expanded = self.rail_type.is_expanded();
1103 let next = self.rail_type.toggled();
1104 let callback = self.on_type_changed.clone();
1105 let (source, label) = if expanded {
1108 (crate::icons::MENU_OPEN, "Collapse")
1109 } else {
1110 (crate::icons::MENU, "Expand")
1111 };
1112 Some(any(icon_button(icon(source), move |state: &mut State| {
1113 if let Some(callback) = &callback {
1114 callback(state, next);
1115 }
1116 })
1117 .semantic_label(label)))
1118 }
1119
1120 fn fab_view(&self) -> Option<AnyView<State>> {
1123 let slot = self.fab.as_ref()?;
1124 let on_press = slot.on_press.clone();
1125 if self.rail_type.is_expanded() {
1126 Some(any(extended_fab(
1127 slot.label.clone(),
1128 move |state: &mut State| {
1129 on_press(state);
1130 },
1131 )
1132 .icon(icon(slot.icon))
1133 .color(slot.color)))
1134 } else {
1135 let label = slot
1136 .semantic_label
1137 .clone()
1138 .unwrap_or_else(|| slot.label.clone());
1139 Some(any(fab(icon(slot.icon), move |state: &mut State| {
1140 on_press(state);
1141 })
1142 .color(slot.color)
1143 .size(slot.size)
1144 .label(label)))
1145 }
1146 }
1147}
1148
1149fn item_on_select<State: 'static>(on_select: &OnSelect<State>, index: usize) -> ErasedCallback {
1152 let callback = on_select.clone();
1153 Box::new(move |ctx: &mut EventCtx| {
1154 let state = ctx.state_mut::<State>();
1155 callback(state, index);
1156 })
1157}
1158
1159struct RailItemWidget {
1166 icon: ChildPod,
1168 label: TextRun,
1171 semantic_label: Option<String>,
1172 badge_count: Option<u32>,
1174 badge: TextRun,
1175 selected: bool,
1176 expanded: bool,
1178 label_behavior: RailLabelBehavior,
1179 use_local_indicator: bool,
1183 indicator: Rect,
1186 label_origin: Point,
1187 badge_origin: Point,
1188 badge_size: Size,
1189 on_select: ErasedCallback,
1190 captured: bool,
1193}
1194
1195impl RailItemWidget {
1196 fn shows_label(&self) -> bool {
1198 self.expanded || self.label_behavior.shows(self.selected)
1199 }
1200
1201 fn badge_text(&self) -> Option<String> {
1203 match self.badge_count {
1204 None | Some(0) => None,
1205 Some(count) if count > BADGE_MAX_COUNT => Some(format!("{BADGE_MAX_COUNT}+")),
1206 Some(count) => Some(count.to_string()),
1207 }
1208 }
1209}
1210
1211fn build_item<State: 'static>(
1213 destination: &RailDestination<State>,
1214 selected: bool,
1215 expanded: bool,
1216 label_behavior: RailLabelBehavior,
1217 on_select: &OnSelect<State>,
1218 index: usize,
1219 ctx: &mut BuildCtx<'_>,
1220) -> ChildPod {
1221 let widget = RailItemWidget {
1222 icon: build_child(destination.active_icon(selected), ctx),
1223 label: TextRun::new(destination.label.clone()),
1224 semantic_label: destination.semantic_label.clone(),
1225 badge_count: destination.badge_count,
1226 badge: TextRun::new(String::new()),
1227 selected,
1228 expanded,
1229 label_behavior,
1230 use_local_indicator: true,
1231 indicator: Rect::ZERO,
1232 label_origin: Point::ZERO,
1233 badge_origin: Point::ZERO,
1234 badge_size: Size::ZERO,
1235 on_select: item_on_select::<State>(on_select, index),
1236 captured: false,
1237 };
1238 ChildPod::new(Box::new(widget))
1239}
1240
1241impl Widget for RailItemWidget {
1242 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1243 let width = finite_or_zero(bc.max().width);
1244 let theme = Theme::from_layout_ctx(ctx);
1245 let label_style = type_style(
1246 theme.map(|t| {
1247 if self.expanded {
1248 &t.type_scale.label_large
1249 } else {
1250 &t.type_scale.label_medium
1251 }
1252 }),
1253 if self.expanded {
1254 LABEL_LARGE
1255 } else {
1256 LABEL_MEDIUM
1257 },
1258 );
1259 let badge_style = type_style(theme.map(|t| &t.type_scale.label_small), LABEL_SMALL);
1260
1261 let badge_text = self.badge_text();
1264 self.badge_size = match (&badge_text, self.badge_count) {
1265 (Some(text), _) => {
1266 self.badge.set_content(text);
1267 let measured = self.badge.shape(ctx, &badge_style, None);
1268 Size::new(
1269 measured.width + BADGE_H_PAD * 2.0,
1270 measured.height + BADGE_V_PAD * 2.0,
1271 )
1272 }
1273 (None, Some(_)) => Size::new(BADGE_DOT, BADGE_DOT),
1274 (None, None) => Size::ZERO,
1275 };
1276
1277 if self.expanded {
1278 let badge_slot = if self.badge_size.width > 0.0 {
1279 self.badge_size.width + ICON_LABEL_GAP
1280 } else {
1281 0.0
1282 };
1283 let label_max = (width
1284 - INDICATOR_LEADING
1285 - INDICATOR_TRAILING
1286 - ICON_SIZE
1287 - ICON_LABEL_GAP
1288 - badge_slot)
1289 .max(0.0);
1290 let label_size = self.label.shape(ctx, &label_style, Some(label_max));
1291 let content_height = label_size.height.max(ICON_SIZE).max(self.badge_size.height);
1292 let height = content_height.max(RAIL_ITEM_EXPANDED_HEIGHT);
1293
1294 let icon_measured = self
1295 .icon
1296 .layout_child(ctx, &BoxConstraints::tight(Size::new(ICON_SIZE, ICON_SIZE)));
1297 self.icon.set_origin(Point::new(
1298 INDICATOR_LEADING,
1299 (height - icon_measured.height) / 2.0,
1300 ));
1301 self.label_origin = Point::new(
1302 INDICATOR_LEADING + ICON_SIZE + ICON_LABEL_GAP,
1303 (height - label_size.height) / 2.0,
1304 );
1305 self.badge_origin = Point::new(
1306 (width - INDICATOR_TRAILING - self.badge_size.width).max(0.0),
1307 (height - self.badge_size.height) / 2.0,
1308 );
1309 self.indicator = Rect::new(0.0, 0.0, width, height);
1311 return bc.constrain(Size::new(width, height));
1312 }
1313
1314 let label_size = if self.shows_label() {
1318 self.label.shape(ctx, &label_style, Some(width))
1319 } else {
1320 Size::ZERO
1321 };
1322 let content_height = CHIP_TARGET_HEIGHT + label_size.height;
1323 let top = ((RAIL_ITEM_COLLAPSED_HEIGHT - content_height) / 2.0).max(0.0);
1324 let chip = Rect::new(
1325 0.0,
1326 top + (CHIP_TARGET_HEIGHT - CHIP_HEIGHT) / 2.0,
1327 CHIP_WIDTH.min(width),
1328 top + (CHIP_TARGET_HEIGHT + CHIP_HEIGHT) / 2.0,
1329 );
1330 let icon_measured = self
1331 .icon
1332 .layout_child(ctx, &BoxConstraints::tight(Size::new(ICON_SIZE, ICON_SIZE)));
1333 self.icon.set_origin(Point::new(
1334 chip.x0 + (chip.width() - icon_measured.width) / 2.0,
1335 chip.y0 + (chip.height() - icon_measured.height) / 2.0,
1336 ));
1337 self.label_origin = Point::new(
1338 chip.x0 + (chip.width() - label_size.width) / 2.0,
1339 top + CHIP_TARGET_HEIGHT,
1340 );
1341 self.badge_origin = Point::new(chip.x1 - self.badge_size.width, chip.y0);
1344 self.indicator = chip;
1345 bc.constrain(Size::new(width, RAIL_ITEM_COLLAPSED_HEIGHT))
1346 }
1347
1348 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1349 let theme = Theme::from_paint_ctx(ctx);
1350 let indicator_fill = theme.map_or(SECONDARY_CONTAINER, |t| t.scheme().secondary_container);
1351 let content = if self.selected {
1352 theme.map_or(ON_SECONDARY_CONTAINER, |t| {
1353 t.scheme().on_secondary_container
1354 })
1355 } else {
1356 theme.map_or(ON_SURFACE_VARIANT, |t| t.scheme().on_surface_variant)
1357 };
1358 let badge_bg = theme.map_or(PRIMARY, |t| t.scheme().primary);
1359 let badge_fg = theme.map_or(ON_PRIMARY, |t| t.scheme().on_primary);
1360 let origin = ctx.origin();
1361
1362 if self.selected && self.use_local_indicator {
1363 let rect = self.indicator;
1364 scene.fill_rounded_rect(
1365 Point::new(origin.x + rect.x0, origin.y + rect.y0),
1366 rect.size(),
1367 rect.width().min(rect.height()) / 2.0,
1368 indicator_fill,
1369 );
1370 }
1371
1372 self.icon.paint_child(ctx, scene);
1373
1374 if self.shows_label() {
1375 self.label.paint(
1376 Point::new(
1377 origin.x + self.label_origin.x,
1378 origin.y + self.label_origin.y,
1379 ),
1380 content,
1381 scene,
1382 );
1383 }
1384
1385 if self.badge_size.width > 0.0 {
1386 let pill = Point::new(
1387 origin.x + self.badge_origin.x,
1388 origin.y + self.badge_origin.y,
1389 );
1390 scene.fill_rounded_rect(
1391 pill,
1392 self.badge_size,
1393 self.badge_size.height / 2.0,
1394 badge_bg,
1395 );
1396 if self.badge_text().is_some() {
1397 let text = self.badge.size();
1398 self.badge.paint(
1399 Point::new(
1400 pill.x + (self.badge_size.width - text.width) / 2.0,
1401 pill.y + (self.badge_size.height - text.height) / 2.0,
1402 ),
1403 badge_fg,
1404 scene,
1405 );
1406 }
1407 }
1408 }
1409
1410 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1411 let InputEvent::Pointer(p) = event else {
1412 return EventResult::Ignored;
1413 };
1414 match p.phase {
1415 PointerPhase::Down => {
1416 if !presses(p) {
1417 return EventResult::Ignored;
1418 }
1419 self.captured = true;
1420 ctx.capture_pointer();
1421 EventResult::Handled
1422 }
1423 PointerPhase::Move => {
1424 if inside(p.position, ctx.size()) {
1428 ctx.set_cursor(CursorIcon::Pointer);
1429 }
1430 if self.captured {
1431 EventResult::Handled
1432 } else {
1433 EventResult::Ignored
1434 }
1435 }
1436 PointerPhase::Up => {
1437 if !self.captured {
1438 return EventResult::Ignored;
1439 }
1440 if inside(p.position, ctx.size()) {
1441 (self.on_select)(ctx);
1442 }
1443 self.captured = false;
1444 EventResult::Handled
1445 }
1446 PointerPhase::Cancel => {
1447 if !self.captured {
1448 return EventResult::Ignored;
1449 }
1450 self.captured = false;
1452 EventResult::Handled
1453 }
1454 }
1455 }
1456
1457 fn semantics(&self, ctx: &mut SemanticsCtx) {
1458 ctx.push_node(Role::Tab, |node| {
1459 let label = self
1460 .semantic_label
1461 .as_deref()
1462 .unwrap_or(&self.label.content);
1463 node.set_label(label);
1464 node.set_selected(self.selected);
1465 });
1466 }
1467
1468 visit_children!(icon);
1469}
1470
1471struct SectionLayout {
1476 first: usize,
1478 count: usize,
1480 header: Option<TextRun>,
1481 header_origin: Point,
1483}
1484
1485pub struct NavigationRailWidget {
1488 items: Vec<ChildPod>,
1491 menu: Option<ChildPod>,
1492 fab: Option<ChildPod>,
1493 trailing: Option<ChildPod>,
1494 sections: Vec<SectionLayout>,
1495
1496 selected: usize,
1497 expanded: bool,
1498 modal: bool,
1499 trailing_at_bottom: bool,
1500 background: Option<Color>,
1501 on_dismiss_modal: Option<ErasedCallback>,
1502
1503 width_motion: WidthMotion,
1504 lead: TravelAxis,
1505 trail: TravelAxis,
1506 ready: bool,
1508 pending_travel: bool,
1510 indicator_cross: (f64, f64),
1513 indicator_extent: f64,
1514 traveling: bool,
1517
1518 panel: Size,
1520 area: Size,
1523 scrim_armed: bool,
1526}
1527
1528impl NavigationRailWidget {
1529 fn scrim_visible(&self) -> bool {
1533 self.modal && self.expansion() > 0.0
1534 }
1535
1536 fn barrier_active(&self) -> bool {
1542 self.modal && self.expanded
1543 }
1544
1545 fn expansion(&self) -> f64 {
1550 let from = self.width_motion.tween.lerp(0.0);
1552 let to = self.width_motion.target;
1553 let (lo, hi) = (from.min(to), from.max(to));
1554 if hi - lo <= f64::EPSILON {
1555 return if self.expanded { 1.0 } else { 0.0 };
1556 }
1557 ((self.width_motion.value - lo) / (hi - lo)).clamp(0.0, 1.0)
1558 }
1559
1560 fn selected_indicator(&mut self) -> Option<Rect> {
1566 let pod = self.items.get_mut(self.selected)?;
1567 let origin = pod.origin();
1568 let rect = pod.widget_mut().downcast_mut::<RailItemWidget>()?.indicator;
1569 Some(Rect::new(
1570 origin.x + rect.x0,
1571 origin.y + rect.y0,
1572 origin.x + rect.x1,
1573 origin.y + rect.y1,
1574 ))
1575 }
1576
1577 fn sync_indicator(&mut self) {
1580 let Some(rect) = self.selected_indicator() else {
1581 return;
1582 };
1583 self.indicator_cross = (rect.center().x, rect.width());
1584 self.indicator_extent = rect.height();
1585 let center = rect.center().y;
1586 if !self.ready || self.width_motion.is_animating() {
1587 self.lead.jump(center);
1588 self.trail.jump(center);
1589 self.ready = true;
1590 self.pending_travel = false;
1591 return;
1592 }
1593 if self.pending_travel {
1594 self.lead.travel_to(center, RAIL_LEAD_SPRING);
1595 self.trail.travel_to(center, RAIL_TRAIL_SPRING);
1596 self.pending_travel = false;
1597 }
1598 }
1599
1600 fn pill_rect(&self) -> Rect {
1603 let (cross_center, cross_size) = self.indicator_cross;
1604 let min_main = self.lead.value.min(self.trail.value);
1605 let max_main = self.lead.value.max(self.trail.value);
1606 let extent = (max_main - min_main) + self.indicator_extent;
1607 let start = min_main - self.indicator_extent / 2.0;
1608 Rect::new(
1609 cross_center - cross_size / 2.0,
1610 start,
1611 cross_center + cross_size / 2.0,
1612 start + extent,
1613 )
1614 }
1615
1616 fn route_slots(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1619 if event.is_broadcast() {
1620 if let Some(pod) = self.menu.as_mut() {
1623 route_event_single(pod, ctx, event);
1624 }
1625 if let Some(pod) = self.fab.as_mut() {
1626 route_event_single(pod, ctx, event);
1627 }
1628 if let Some(pod) = self.trailing.as_mut() {
1629 route_event_single(pod, ctx, event);
1630 }
1631 route_event(&mut self.items, ctx, event);
1632 return EventResult::Ignored;
1633 }
1634 for slot in [&mut self.menu, &mut self.fab, &mut self.trailing] {
1635 if let Some(pod) = slot.as_mut()
1636 && route_event_single(pod, ctx, event) == EventResult::Handled
1637 {
1638 return EventResult::Handled;
1639 }
1640 }
1641 route_event(&mut self.items, ctx, event)
1642 }
1643}
1644
1645impl<State: 'static> View<State> for NavigationRailView<State> {
1646 type Element = NavigationRailWidget;
1647
1648 fn build(&self, ctx: &mut BuildCtx<'_>) -> NavigationRailWidget {
1649 let expanded = self.rail_type.is_expanded();
1650 let mut items = Vec::new();
1651 let mut sections = Vec::new();
1652 for section in &self.sections {
1653 sections.push(SectionLayout {
1654 first: items.len(),
1655 count: section.destinations.len(),
1656 header: section.header.as_ref().map(TextRun::new),
1657 header_origin: Point::ZERO,
1658 });
1659 for destination in §ion.destinations {
1660 let index = items.len();
1661 items.push(build_item(
1662 destination,
1663 index == self.selected,
1664 expanded,
1665 self.label_behavior,
1666 &self.on_select,
1667 index,
1668 ctx,
1669 ));
1670 }
1671 }
1672
1673 NavigationRailWidget {
1674 items,
1675 menu: self.menu_view().map(|view| build_child(&view, ctx)),
1676 fab: self.fab_view().map(|view| build_child(&view, ctx)),
1677 trailing: self.trailing.as_ref().map(|view| build_child(view, ctx)),
1678 sections,
1679 selected: self.selected,
1680 expanded,
1681 modal: self.modality == NavigationRailModality::Modal,
1682 trailing_at_bottom: self.trailing_at_bottom,
1683 background: self.background,
1684 on_dismiss_modal: self.on_dismiss_modal.as_ref().map(erase_callback),
1685 width_motion: WidthMotion::resting(self.target_width()),
1686 lead: TravelAxis::new(),
1687 trail: TravelAxis::new(),
1688 ready: false,
1689 pending_travel: false,
1690 indicator_cross: (0.0, 0.0),
1691 indicator_extent: 0.0,
1692 traveling: false,
1693 panel: Size::ZERO,
1694 area: Size::ZERO,
1695 scrim_armed: false,
1696 }
1697 }
1698
1699 fn rebuild(
1700 &self,
1701 prev: &Self,
1702 element: &mut NavigationRailWidget,
1703 ctx: &mut BuildCtx<'_>,
1704 ) -> ChangeFlags {
1705 let mut flags = ChangeFlags::NONE;
1706 let expanded = self.rail_type.is_expanded();
1707 let was_expanded = prev.rail_type.is_expanded();
1708
1709 element.on_dismiss_modal = self.on_dismiss_modal.as_ref().map(erase_callback);
1710 element.trailing_at_bottom = self.trailing_at_bottom;
1711
1712 if prev.label_behavior != self.label_behavior {
1713 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1716 }
1717 if element.background != self.background {
1718 element.background = self.background;
1719 flags |= ChangeFlags::PAINT;
1720 }
1721 if element.modal != (self.modality == NavigationRailModality::Modal) {
1722 element.modal = self.modality == NavigationRailModality::Modal;
1723 element.scrim_armed = false;
1724 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1725 }
1726 if element.expanded != expanded {
1727 element.expanded = expanded;
1728 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1729 }
1730 let target = self.target_width();
1731 if (element.width_motion.target - target).abs() > f64::EPSILON {
1732 element.width_motion.set_target(target);
1733 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1734 }
1735 if element.selected != self.selected {
1736 element.selected = self.selected;
1737 element.pending_travel = true;
1738 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1739 }
1740
1741 let prev_dests = prev.destinations();
1743 let next_dests = self.destinations();
1744 let common = prev_dests.len().min(next_dests.len());
1745 for index in 0..common {
1746 let was_selected = index == prev.selected;
1747 let now_selected = index == self.selected;
1748 let pod = &mut element.items[index];
1749 let widget = pod
1750 .widget_mut()
1751 .downcast_mut::<RailItemWidget>()
1752 .expect("a rail destination pod holds a RailItemWidget");
1753 flags |= rebuild_child(
1754 prev_dests[index].active_icon(was_selected),
1755 next_dests[index].active_icon(now_selected),
1756 &mut widget.icon,
1757 ctx,
1758 );
1759 if widget.label.set_content(&next_dests[index].label) {
1760 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1761 }
1762 if widget.badge_count != next_dests[index].badge_count {
1763 widget.badge_count = next_dests[index].badge_count;
1764 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1765 }
1766 widget.semantic_label = next_dests[index].semantic_label.clone();
1767 if widget.selected != now_selected {
1768 widget.selected = now_selected;
1769 flags |= ChangeFlags::PAINT;
1770 }
1771 if widget.expanded != expanded {
1772 widget.expanded = expanded;
1773 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1774 }
1775 widget.label_behavior = self.label_behavior;
1776 widget.on_select = item_on_select::<State>(&self.on_select, index);
1778 }
1779
1780 if next_dests.len() > prev_dests.len() {
1781 for (offset, destination) in next_dests[common..].iter().enumerate() {
1782 let index = common + offset;
1783 element.items.push(build_item(
1784 destination,
1785 index == self.selected,
1786 expanded,
1787 self.label_behavior,
1788 &self.on_select,
1789 index,
1790 ctx,
1791 ));
1792 }
1793 element.ready = false;
1794 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1795 } else if next_dests.len() < prev_dests.len() {
1796 for (offset, destination) in prev_dests[common..].iter().enumerate() {
1797 let index = common + offset;
1798 let pod = &mut element.items[index];
1799 if pod.is_active() {
1800 pod.set_active(false);
1801 }
1802 let widget = pod
1803 .widget_mut()
1804 .downcast_mut::<RailItemWidget>()
1805 .expect("a rail destination pod holds a RailItemWidget");
1806 teardown_child(
1807 destination.active_icon(index == prev.selected),
1808 &mut widget.icon,
1809 ctx,
1810 );
1811 }
1812 element.items.truncate(common);
1813 element.ready = false;
1814 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1815 }
1816
1817 let sections_changed = prev.sections.len() != self.sections.len()
1819 || prev
1820 .sections
1821 .iter()
1822 .zip(self.sections.iter())
1823 .any(|(a, b)| a.destinations.len() != b.destinations.len() || a.header != b.header);
1824 if sections_changed {
1825 let mut first = 0;
1826 element.sections = self
1827 .sections
1828 .iter()
1829 .map(|section| {
1830 let layout = SectionLayout {
1831 first,
1832 count: section.destinations.len(),
1833 header: section.header.as_ref().map(TextRun::new),
1834 header_origin: Point::ZERO,
1835 };
1836 first += section.destinations.len();
1837 layout
1838 })
1839 .collect();
1840 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1841 }
1842
1843 let prev_menu = prev.menu_view();
1845 let next_menu = self.menu_view();
1846 flags |= rebuild_slot(&prev_menu, &next_menu, &mut element.menu, ctx);
1847 if was_expanded != expanded {
1850 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1851 }
1852
1853 let fab_changed = match (&prev.fab, &self.fab) {
1854 (Some(a), Some(b)) => a.shape() != b.shape() || was_expanded != expanded,
1855 (a, b) => a.is_some() != b.is_some(),
1856 };
1857 let prev_fab = prev.fab_view();
1858 let next_fab = self.fab_view();
1859 flags |= rebuild_slot(&prev_fab, &next_fab, &mut element.fab, ctx);
1860 if fab_changed {
1861 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1862 }
1863
1864 flags |= rebuild_slot(&prev.trailing, &self.trailing, &mut element.trailing, ctx);
1865
1866 flags
1867 }
1868
1869 fn teardown(&self, element: &mut NavigationRailWidget, ctx: &mut BuildCtx<'_>) {
1870 for (index, destination) in self.destinations().iter().enumerate() {
1871 let Some(pod) = element.items.get_mut(index) else {
1872 continue;
1873 };
1874 let widget = pod
1875 .widget_mut()
1876 .downcast_mut::<RailItemWidget>()
1877 .expect("a rail destination pod holds a RailItemWidget");
1878 teardown_child(
1879 destination.active_icon(index == self.selected),
1880 &mut widget.icon,
1881 ctx,
1882 );
1883 }
1884 if let (Some(view), Some(pod)) = (self.menu_view(), element.menu.as_mut()) {
1885 teardown_child(&view, pod, ctx);
1886 }
1887 if let (Some(view), Some(pod)) = (self.fab_view(), element.fab.as_mut()) {
1888 teardown_child(&view, pod, ctx);
1889 }
1890 if let (Some(view), Some(pod)) = (self.trailing.as_ref(), element.trailing.as_mut()) {
1891 teardown_child(view, pod, ctx);
1892 }
1893 }
1894}
1895
1896fn rebuild_slot<State: 'static>(
1899 prev: &Option<AnyView<State>>,
1900 next: &Option<AnyView<State>>,
1901 pod: &mut Option<ChildPod>,
1902 ctx: &mut BuildCtx<'_>,
1903) -> ChangeFlags {
1904 match (prev, next) {
1905 (Some(prev_view), Some(next_view)) => match pod.as_mut() {
1906 Some(pod) => rebuild_child(prev_view, next_view, pod, ctx),
1907 None => {
1908 *pod = Some(build_child(next_view, ctx));
1909 ChangeFlags::LAYOUT | ChangeFlags::PAINT
1910 }
1911 },
1912 (None, Some(next_view)) => {
1913 *pod = Some(build_child(next_view, ctx));
1914 ChangeFlags::LAYOUT | ChangeFlags::PAINT
1915 }
1916 (Some(prev_view), None) => {
1917 if let Some(mut old) = pod.take() {
1918 teardown_child(prev_view, &mut old, ctx);
1919 }
1920 ChangeFlags::LAYOUT | ChangeFlags::PAINT
1921 }
1922 (None, None) => ChangeFlags::NONE,
1923 }
1924}
1925
1926impl Widget for NavigationRailWidget {
1927 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1928 let area = Size::new(
1929 finite_or_zero(bc.max().width),
1930 finite_or_zero(bc.max().height),
1931 );
1932 let panel_width = self.width_motion.value.max(0.0).min(area.width.max(0.0));
1933 self.area = area;
1934 self.panel = Size::new(panel_width, area.height);
1935
1936 let inner = (panel_width - HORIZONTAL_INSET * 2.0).max(0.0);
1937 let mut y = TOP_GAP;
1938
1939 if let Some(pod) = self.menu.as_mut() {
1940 let size =
1941 pod.layout_child(ctx, &BoxConstraints::loose(Size::new(inner, f64::INFINITY)));
1942 let x = if self.expanded {
1943 HORIZONTAL_INSET
1944 } else {
1945 HORIZONTAL_INSET + (inner - size.width) / 2.0
1946 };
1947 pod.set_origin(Point::new(x.max(0.0), y));
1948 y += size.height + SECTION_PADDING_BOTTOM;
1949 }
1950
1951 if let Some(pod) = self.fab.as_mut() {
1952 let size =
1958 pod.layout_child(ctx, &BoxConstraints::loose(Size::new(inner, f64::INFINITY)));
1959 let x = if self.expanded {
1960 HORIZONTAL_INSET
1961 } else {
1962 HORIZONTAL_INSET + (inner - size.width) / 2.0
1963 };
1964 pod.set_origin(Point::new(x.max(0.0), y));
1965 y += size.height + SECTION_PADDING_BOTTOM;
1966 }
1967
1968 let expanded = self.expanded;
1969 let mut sections = std::mem::take(&mut self.sections);
1970 for section in &mut sections {
1971 if expanded && let Some(header) = section.header.as_mut() {
1972 let theme = Theme::from_layout_ctx(ctx);
1973 let style = type_style(theme.map(|t| &t.type_scale.title_small), TITLE_SMALL);
1974 y += SECTION_HEADER_SPACING_TOP;
1975 let size = header.shape(ctx, &style, Some(inner));
1976 section.header_origin = Point::new(HORIZONTAL_INSET, y);
1977 y += size.height + SECTION_HEADER_SPACING_BOTTOM;
1978 }
1979 for index in section.first..section.first + section.count {
1980 let Some(pod) = self.items.get_mut(index) else {
1981 continue;
1982 };
1983 y += ITEM_VERTICAL_GAP;
1984 let size = pod.layout_child(
1985 ctx,
1986 &BoxConstraints::new(Size::new(inner, 0.0), Size::new(inner, f64::INFINITY)),
1987 );
1988 pod.set_origin(Point::new(HORIZONTAL_INSET, y));
1989 y += size.height + ITEM_VERTICAL_GAP;
1990 }
1991 }
1992 self.sections = sections;
1993
1994 if let Some(pod) = self.trailing.as_mut() {
1995 let size =
1996 pod.layout_child(ctx, &BoxConstraints::loose(Size::new(inner, f64::INFINITY)));
1997 let x = if expanded {
1998 HORIZONTAL_INSET
1999 } else {
2000 HORIZONTAL_INSET + (inner - size.width) / 2.0
2001 };
2002 let top = if self.trailing_at_bottom {
2003 (area.height - SECTION_PADDING_BOTTOM - size.height).max(y)
2004 } else {
2005 y
2006 };
2007 pod.set_origin(Point::new(x.max(0.0), top));
2008 }
2009
2010 self.sync_indicator();
2011
2012 let size = if self.modal { area } else { self.panel };
2016 bc.constrain(size)
2017 }
2018
2019 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
2020 let theme = Theme::from_paint_ctx(ctx);
2021 let container = self
2022 .background
2023 .unwrap_or_else(|| theme.map_or(SURFACE, |t| t.scheme().surface));
2024 let scrim_base = theme.map_or(SCRIM, |t| t.scheme().scrim);
2025 let indicator_fill = theme.map_or(SECONDARY_CONTAINER, |t| t.scheme().secondary_container);
2026 let header_ink = theme.map_or(ON_SURFACE_VARIANT, |t| t.scheme().on_surface_variant);
2027 let reduce_motion = theme.is_some_and(|t| t.motion.reduce_motion);
2028 let now = ctx.frame_time();
2029
2030 let width_changed = if reduce_motion {
2035 self.width_motion.snap()
2036 } else {
2037 let before = self.width_motion.value;
2038 self.width_motion.advance(now) || self.width_motion.value != before
2044 };
2045 if width_changed {
2046 ctx.request_layout();
2047 }
2048
2049 if reduce_motion {
2052 self.lead.jump(self.lead.target);
2053 self.trail.jump(self.trail.target);
2054 } else {
2055 let lead = self.lead.advance(now);
2056 let trail = self.trail.advance(now);
2057 if lead || trail {
2058 ctx.request_frame();
2059 }
2060 }
2061 self.traveling = self.lead.is_animating() || self.trail.is_animating();
2062
2063 let origin = ctx.origin();
2064 if self.scrim_visible() {
2065 let alpha = OVERLAY_SCRIM_ALPHA * self.expansion() as f32;
2066 scene.fill_rect(origin, self.area, with_alpha(scrim_base, alpha));
2067 }
2068
2069 if self.panel.width > 0.0 {
2070 scene.fill_rect(origin, self.panel, container);
2071 }
2072
2073 if self.traveling && self.ready {
2074 let pill = self.pill_rect();
2075 scene.fill_rounded_rect(
2076 Point::new(origin.x + pill.x0, origin.y + pill.y0),
2077 pill.size(),
2078 pill.width().min(pill.height()) / 2.0,
2079 indicator_fill,
2080 );
2081 }
2082
2083 for slot in [&mut self.menu, &mut self.fab] {
2087 if let Some(pod) = slot.as_mut() {
2088 pod.paint_child(ctx, scene);
2089 }
2090 }
2091 let traveling = self.traveling;
2092 for pod in &mut self.items {
2093 if let Some(widget) = pod.widget_mut().downcast_mut::<RailItemWidget>() {
2094 widget.use_local_indicator = !traveling;
2095 }
2096 pod.paint_child(ctx, scene);
2097 }
2098 if self.expanded {
2099 for section in &self.sections {
2100 if let Some(header) = §ion.header {
2101 header.paint(
2102 Point::new(
2103 origin.x + section.header_origin.x,
2104 origin.y + section.header_origin.y,
2105 ),
2106 header_ink,
2107 scene,
2108 );
2109 }
2110 }
2111 }
2112 if let Some(pod) = self.trailing.as_mut() {
2113 pod.paint_child(ctx, scene);
2114 }
2115 }
2116
2117 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
2118 if event.is_broadcast() {
2121 return self.route_slots(ctx, event);
2122 }
2123 let InputEvent::Pointer(p) = event else {
2124 return self.route_slots(ctx, event);
2125 };
2126 if !self.barrier_active() {
2127 return self.route_slots(ctx, event);
2128 }
2129 if p.position.x < self.panel.width {
2130 self.route_slots(ctx, event);
2135 return EventResult::Handled;
2136 }
2137 match p.phase {
2141 PointerPhase::Down => {
2142 if presses(p) {
2143 self.scrim_armed = true;
2144 ctx.capture_pointer();
2145 }
2146 EventResult::Handled
2147 }
2148 PointerPhase::Up => {
2149 if self.scrim_armed
2150 && p.position.x >= self.panel.width
2151 && let Some(on_dismiss) = self.on_dismiss_modal.as_mut()
2152 {
2153 (on_dismiss)(ctx);
2154 }
2155 self.scrim_armed = false;
2156 EventResult::Handled
2157 }
2158 PointerPhase::Cancel => {
2159 self.scrim_armed = false;
2161 EventResult::Handled
2162 }
2163 PointerPhase::Move => EventResult::Handled,
2164 }
2165 }
2166
2167 fn semantics(&self, ctx: &mut SemanticsCtx) {
2168 ctx.push_container(
2169 Role::TabList,
2170 |_| {},
2171 |ctx| {
2172 if let Some(pod) = &self.menu {
2173 pod.semantics_child(ctx);
2174 }
2175 if let Some(pod) = &self.fab {
2176 pod.semantics_child(ctx);
2177 }
2178 for pod in &self.items {
2179 pod.semantics_child(ctx);
2180 }
2181 if let Some(pod) = &self.trailing {
2182 pod.semantics_child(ctx);
2183 }
2184 },
2185 );
2186 }
2187
2188 visit_children!(menu, fab, items, trailing);
2189}
2190
2191#[cfg(test)]
2192mod tests {
2193 use super::*;
2194 use frust::authoring::text::TextContext;
2195 use frust::authoring::{PointerButton, PointerEvent};
2196 use std::any::Any;
2197
2198 const AREA: Size = Size::new(400.0, 600.0);
2199
2200 fn dest<State: 'static>(label: &str) -> RailDestination<State> {
2201 rail_destination(icon(crate::icons::HOME), label)
2202 }
2203
2204 fn one_section<State: 'static>() -> Vec<RailSection<State>> {
2205 vec![rail_section(vec![
2206 dest("Home"),
2207 dest("Search"),
2208 dest("Profile"),
2209 ])]
2210 }
2211
2212 fn rail<State: 'static>() -> NavigationRailView<State> {
2213 navigation_rail(one_section(), 0, |_state: &mut State, _index| {})
2214 }
2215
2216 fn build<State: 'static>(view: &NavigationRailView<State>) -> NavigationRailWidget {
2217 let mut counter = 0u64;
2218 View::<State>::build(view, &mut BuildCtx::new(&mut counter))
2219 }
2220
2221 fn rebuild<State: 'static>(
2222 next: &NavigationRailView<State>,
2223 prev: &NavigationRailView<State>,
2224 widget: &mut NavigationRailWidget,
2225 ) -> ChangeFlags {
2226 let mut counter = 1u64;
2227 View::<State>::rebuild(next, prev, widget, &mut BuildCtx::new(&mut counter))
2228 }
2229
2230 fn layout_themed(widget: &mut NavigationRailWidget, theme: Option<&Theme>, area: Size) -> Size {
2231 let mut tcx = TextContext::new();
2232 let mut ctx =
2233 LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
2234 widget.layout(&mut ctx, &BoxConstraints::loose(area))
2235 }
2236
2237 fn layout(widget: &mut NavigationRailWidget) -> Size {
2238 layout_themed(widget, None, AREA)
2239 }
2240
2241 #[derive(Default)]
2242 struct Recorder {
2243 rects: Vec<(Point, Size, Color)>,
2244 rrects: Vec<(Point, Size, f64, Color)>,
2245 }
2246
2247 impl PaintScene for Recorder {
2248 fn fill_rect(&mut self, origin: Point, size: Size, color: Color) {
2249 self.rects.push((origin, size, color));
2250 }
2251 fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, color: Color) {
2252 self.rrects.push((origin, size, radius, color));
2253 }
2254 fn draw_text(&mut self, _origin: Point, _text: &str) {}
2255 }
2256
2257 fn paint_at(
2259 widget: &mut NavigationRailWidget,
2260 t_ms: u64,
2261 theme: Option<&Theme>,
2262 ) -> (Recorder, bool) {
2263 let mut rec = Recorder::default();
2264 let mut ctx =
2265 PaintCtx::for_test(Point::ZERO, AREA, FrameTime::from_nanos(t_ms * 1_000_000));
2266 if let Some(theme) = theme {
2267 ctx = ctx.with_theme(theme);
2268 }
2269 widget.paint(&mut ctx, &mut rec);
2270 let needs_layout = ctx.needs_layout();
2271 (rec, needs_layout)
2272 }
2273
2274 fn dispatch(
2275 widget: &mut NavigationRailWidget,
2276 state: &mut dyn Any,
2277 phase: PointerPhase,
2278 pos: Point,
2279 ) -> EventResult {
2280 let event = InputEvent::Pointer(PointerEvent {
2281 phase,
2282 position: pos,
2283 button: PointerButton::Primary,
2284 });
2285 let mut ctx = EventCtx::new(state, Point::ZERO, AREA);
2286 widget.event(&mut ctx, &event)
2287 }
2288
2289 fn item(widget: &mut NavigationRailWidget, index: usize) -> &mut RailItemWidget {
2290 widget.items[index]
2291 .widget_mut()
2292 .downcast_mut::<RailItemWidget>()
2293 .expect("a rail destination pod holds a RailItemWidget")
2294 }
2295
2296 fn tap(widget: &mut NavigationRailWidget, state: &mut dyn Any, pos: Point) {
2298 dispatch(widget, state, PointerPhase::Down, pos);
2299 dispatch(widget, state, PointerPhase::Up, pos);
2300 }
2301
2302 #[test]
2305 fn collapsed_geometry_pins_the_rail_width_and_its_item_boxes() {
2306 let view: NavigationRailView<()> = rail().rail_type(NavigationRailType::Collapsed);
2307 let mut w = build(&view);
2308 let size = layout(&mut w);
2309 assert_eq!(
2310 size,
2311 Size::new(RAIL_COLLAPSED_WIDTH, AREA.height),
2312 "a standard rail is exactly its own width by the height it was given"
2313 );
2314
2315 let inner = RAIL_COLLAPSED_WIDTH - HORIZONTAL_INSET * 2.0;
2316 for index in 0..3 {
2317 let pod = &w.items[index];
2318 assert_eq!(pod.origin().x, HORIZONTAL_INSET);
2319 assert_eq!(pod.size(), Size::new(inner, RAIL_ITEM_COLLAPSED_HEIGHT));
2320 }
2321 let spacing = w.items[1].origin().y - w.items[0].origin().y;
2323 assert_eq!(
2324 spacing,
2325 RAIL_ITEM_COLLAPSED_HEIGHT + ITEM_VERTICAL_GAP * 2.0
2326 );
2327
2328 let menu = w
2330 .menu
2331 .as_ref()
2332 .expect("a collapsed rail carries a menu button");
2333 assert_eq!(menu.origin().y, TOP_GAP);
2334 assert!(w.items[0].origin().y > menu.origin().y + menu.size().height);
2335
2336 let chip = item(&mut w, 0).indicator;
2338 assert_eq!(chip.x0, 0.0);
2339 assert_eq!(chip.width(), CHIP_WIDTH);
2340 assert_eq!(chip.height(), CHIP_HEIGHT);
2341 }
2342
2343 #[test]
2344 fn expanded_geometry_pins_the_rail_width_and_makes_the_row_the_indicator() {
2345 let view: NavigationRailView<()> = rail().rail_type(NavigationRailType::Expanded);
2346 let mut w = build(&view);
2347 let size = layout(&mut w);
2348 assert_eq!(size, Size::new(RAIL_EXPANDED_MIN_WIDTH, AREA.height));
2349
2350 let inner = RAIL_EXPANDED_MIN_WIDTH - HORIZONTAL_INSET * 2.0;
2351 let pod = &w.items[0];
2352 assert_eq!(pod.origin().x, HORIZONTAL_INSET);
2353 assert_eq!(pod.size(), Size::new(inner, RAIL_ITEM_EXPANDED_HEIGHT));
2354
2355 let widget = item(&mut w, 0);
2356 assert_eq!(
2357 widget.indicator,
2358 Rect::new(0.0, 0.0, inner, RAIL_ITEM_EXPANDED_HEIGHT),
2359 "an expanded row *is* its own selection indicator"
2360 );
2361 assert_eq!(widget.icon.origin().x, INDICATOR_LEADING);
2363 assert_eq!(
2364 widget.label_origin.x,
2365 INDICATOR_LEADING + ICON_SIZE + ICON_LABEL_GAP
2366 );
2367 }
2368
2369 #[test]
2370 fn the_expanded_width_clamps_to_the_reference_band() {
2371 let wide: NavigationRailView<()> = rail()
2372 .rail_type(NavigationRailType::Expanded)
2373 .expanded_width(1000.0);
2374 assert_eq!(wide.target_width(), RAIL_EXPANDED_MAX_WIDTH);
2375 let narrow: NavigationRailView<()> = rail()
2376 .rail_type(NavigationRailType::Expanded)
2377 .expanded_width(10.0);
2378 assert_eq!(narrow.target_width(), RAIL_EXPANDED_MIN_WIDTH);
2379 let default: NavigationRailView<()> = rail().rail_type(NavigationRailType::Expanded);
2380 assert_eq!(default.target_width(), RAIL_EXPANDED_MIN_WIDTH);
2381 }
2382
2383 #[test]
2384 fn hide_when_collapsed_collapses_the_rail_to_zero_width() {
2385 let view: NavigationRailView<()> = rail()
2386 .rail_type(NavigationRailType::Collapsed)
2387 .hide_when_collapsed(true);
2388 let mut w = build(&view);
2389 assert_eq!(layout(&mut w), Size::new(0.0, AREA.height));
2390 }
2391
2392 #[test]
2393 fn trailing_pins_to_the_bottom_edge_by_default_and_follows_the_items_otherwise() {
2394 let bottom: NavigationRailView<()> = rail()
2395 .rail_type(NavigationRailType::Expanded)
2396 .trailing(icon(crate::icons::SEARCH));
2397 let mut w = build(&bottom);
2398 layout(&mut w);
2399 let pod = w.trailing.as_ref().expect("a trailing slot");
2400 assert_eq!(
2401 pod.origin().y + pod.size().height + SECTION_PADDING_BOTTOM,
2402 AREA.height
2403 );
2404
2405 let inline: NavigationRailView<()> = rail()
2406 .rail_type(NavigationRailType::Expanded)
2407 .trailing(icon(crate::icons::SEARCH))
2408 .trailing_at_bottom(false);
2409 let mut w = build(&inline);
2410 layout(&mut w);
2411 let last_item = w.items.last().expect("three destinations");
2412 let bottom_of_items = last_item.origin().y + last_item.size().height;
2413 let pod = w.trailing.as_ref().expect("a trailing slot");
2414 assert!(pod.origin().y < bottom_of_items + RAIL_ITEM_EXPANDED_HEIGHT);
2415 }
2416
2417 #[test]
2420 fn a_type_change_ramps_the_width_and_paint_requests_layout_every_frame() {
2421 let collapsed: NavigationRailView<()> = rail().rail_type(NavigationRailType::Collapsed);
2427 let mut w = build(&collapsed);
2428 assert_eq!(layout(&mut w).width, RAIL_COLLAPSED_WIDTH);
2429 let (_, settled) = paint_at(&mut w, 0, None);
2430 assert!(!settled, "a resting rail requests no layout");
2431
2432 let expanded: NavigationRailView<()> = rail().rail_type(NavigationRailType::Expanded);
2433 let flags = rebuild(&expanded, &collapsed, &mut w);
2434 assert!(flags.needs_layout());
2435
2436 let mut widths = vec![layout(&mut w).width];
2437 let mut t_ms = 0u64;
2438 let mut frames = 0u32;
2439 loop {
2440 let (_, needs_layout) = paint_at(&mut w, t_ms, None);
2441 if !needs_layout {
2442 break;
2443 }
2444 frames += 1;
2445 widths.push(layout(&mut w).width);
2447 t_ms += 16;
2448 assert!(frames < 200, "a 280ms ramp settles well inside 200 frames");
2449 }
2450 assert!(frames > 1, "the ramp spans more than one frame");
2451 assert!(
2452 widths
2453 .windows(2)
2454 .all(|pair| pair[1] >= pair[0] - f64::EPSILON),
2455 "the width rises monotonically: {widths:?}"
2456 );
2457 assert_eq!(
2458 widths.last().copied(),
2459 Some(RAIL_EXPANDED_MIN_WIDTH),
2460 "the settled ramp lands exactly on the expanded width"
2461 );
2462 let (_, still) = paint_at(&mut w, t_ms + 16, None);
2463 assert!(!still, "settled: no more layout requests");
2464 }
2465
2466 #[test]
2467 fn reduce_motion_snaps_the_width_and_requests_exactly_one_relayout() {
2468 let mut theme = crate::baseline();
2469 theme.motion.reduce_motion = true;
2470 let collapsed: NavigationRailView<()> = rail().rail_type(NavigationRailType::Collapsed);
2471 let mut w = build(&collapsed);
2472 layout_themed(&mut w, Some(&theme), AREA);
2473
2474 let expanded: NavigationRailView<()> = rail().rail_type(NavigationRailType::Expanded);
2475 rebuild(&expanded, &collapsed, &mut w);
2476
2477 let (_, needs_layout) = paint_at(&mut w, 0, Some(&theme));
2478 assert_eq!(w.width_motion.value, RAIL_EXPANDED_MIN_WIDTH, "snapped");
2479 assert!(
2480 needs_layout,
2481 "the snap changed a layout value, so it must request one relayout"
2482 );
2483 assert_eq!(
2484 layout_themed(&mut w, Some(&theme), AREA).width,
2485 RAIL_EXPANDED_MIN_WIDTH,
2486 "the rail reaches its target on the snap frame, not some later one"
2487 );
2488 let (_, still) = paint_at(&mut w, 16, Some(&theme));
2489 assert!(
2490 !still,
2491 "a snap requests exactly one relayout, never an ongoing frame request"
2492 );
2493 }
2494
2495 #[test]
2498 fn a_tap_on_a_destination_reports_its_flat_index_without_self_mutating() {
2499 let view: NavigationRailView<Vec<usize>> = navigation_rail(
2500 vec![
2501 rail_section(vec![dest("Home"), dest("Search")]),
2502 rail_section(vec![dest("Profile")]).header("Account"),
2503 ],
2504 0,
2505 |state: &mut Vec<usize>, index| state.push(index),
2506 )
2507 .rail_type(NavigationRailType::Expanded);
2508 let mut w = build(&view);
2509 layout(&mut w);
2510
2511 let mut log: Vec<usize> = Vec::new();
2512 let pod = &w.items[2];
2515 let pos = Point::new(
2516 pod.origin().x + 10.0,
2517 pod.origin().y + pod.size().height / 2.0,
2518 );
2519 tap(&mut w, &mut log, pos);
2520 assert_eq!(log, vec![2]);
2521 assert_eq!(w.selected, 0, "the rail never moves its own selection");
2522 }
2523
2524 #[test]
2525 fn the_menu_button_reports_the_toggled_type_without_self_mutating() {
2526 let view: NavigationRailView<Vec<NavigationRailType>> = navigation_rail(
2527 one_section(),
2528 0,
2529 |_state: &mut Vec<NavigationRailType>, _index| {},
2530 )
2531 .rail_type(NavigationRailType::Expanded)
2532 .on_type_changed(|state: &mut Vec<NavigationRailType>, next| state.push(next));
2533 let mut w = build(&view);
2534 layout(&mut w);
2535
2536 let mut log: Vec<NavigationRailType> = Vec::new();
2537 let pod = w
2538 .menu
2539 .as_ref()
2540 .expect("an expanded rail carries a menu button");
2541 let pos = Point::new(
2542 pod.origin().x + pod.size().width / 2.0,
2543 pod.origin().y + pod.size().height / 2.0,
2544 );
2545 tap(&mut w, &mut log, pos);
2546 assert_eq!(log, vec![NavigationRailType::Collapsed]);
2547 assert!(w.expanded, "the rail never collapses itself");
2548 }
2549
2550 #[test]
2551 fn the_always_variants_carry_no_menu_button() {
2552 for rail_type in [
2553 NavigationRailType::AlwaysExpand,
2554 NavigationRailType::AlwaysCollapse,
2555 ] {
2556 let view: NavigationRailView<()> = rail().rail_type(rail_type);
2557 let w = build(&view);
2558 assert!(w.menu.is_none(), "{rail_type:?} offers no toggle");
2559 }
2560 for rail_type in [NavigationRailType::Expanded, NavigationRailType::Collapsed] {
2561 let view: NavigationRailView<()> = rail().rail_type(rail_type);
2562 let w = build(&view);
2563 assert!(w.menu.is_some(), "{rail_type:?} offers a toggle");
2564 }
2565 }
2566
2567 #[test]
2570 fn a_modal_rail_fills_its_area_and_paints_a_scrim_beside_its_panel() {
2571 let view: NavigationRailView<()> = rail()
2572 .rail_type(NavigationRailType::Expanded)
2573 .modality(NavigationRailModality::Modal);
2574 let mut w = build(&view);
2575 assert_eq!(
2576 layout(&mut w),
2577 AREA,
2578 "a modal rail is a full-area host, not a layout column"
2579 );
2580 let (rec, _) = paint_at(&mut w, 0, None);
2581 let scrim = rec
2582 .rects
2583 .iter()
2584 .find(|(_, size, _)| *size == AREA)
2585 .expect("the scrim covers the whole area");
2586 assert!(
2587 (scrim.2.components[3] - OVERLAY_SCRIM_ALPHA).abs() < 1e-6,
2588 "the scrim paints at the M3 32%"
2589 );
2590 assert!(
2591 rec.rects
2592 .iter()
2593 .any(|(_, size, _)| *size == Size::new(RAIL_EXPANDED_MIN_WIDTH, AREA.height)),
2594 "the panel is pinned to the leading edge at the rail's own width"
2595 );
2596 let standard: NavigationRailView<()> = rail().rail_type(NavigationRailType::Expanded);
2598 let mut w = build(&standard);
2599 layout(&mut w);
2600 let (rec, _) = paint_at(&mut w, 0, None);
2601 assert!(rec.rects.iter().all(|(_, size, _)| *size != AREA));
2602 }
2603
2604 #[test]
2605 fn a_scrim_press_reports_the_dismiss_request_and_never_reaches_the_page() {
2606 let view: NavigationRailView<u32> =
2607 navigation_rail(one_section(), 0, |_state: &mut u32, _index| {})
2608 .rail_type(NavigationRailType::Expanded)
2609 .modality(NavigationRailModality::Modal)
2610 .on_dismiss_modal(|state: &mut u32| *state += 1);
2611 let mut w = build(&view);
2612 layout(&mut w);
2613
2614 let mut dismissed = 0u32;
2615 let on_scrim = Point::new(RAIL_EXPANDED_MIN_WIDTH + 40.0, 200.0);
2616 assert_eq!(
2617 dispatch(&mut w, &mut dismissed, PointerPhase::Down, on_scrim),
2618 EventResult::Handled,
2619 "the barrier swallows the press rather than letting the page take it"
2620 );
2621 assert_eq!(dismissed, 0, "nothing fires on the way down");
2622 dispatch(&mut w, &mut dismissed, PointerPhase::Up, on_scrim);
2623 assert_eq!(dismissed, 1, "fire-on-up-inside, like every other press");
2624
2625 dispatch(&mut w, &mut dismissed, PointerPhase::Down, on_scrim);
2627 dispatch(&mut w, &mut dismissed, PointerPhase::Cancel, on_scrim);
2628 assert_eq!(dismissed, 1);
2629
2630 let on_panel = Point::new(RAIL_EXPANDED_MIN_WIDTH - 4.0, AREA.height - 4.0);
2633 assert_eq!(
2634 dispatch(&mut w, &mut dismissed, PointerPhase::Down, on_panel),
2635 EventResult::Handled
2636 );
2637 dispatch(&mut w, &mut dismissed, PointerPhase::Up, on_panel);
2638 assert_eq!(dismissed, 1, "a press on the panel is not a dismiss");
2639 }
2640
2641 #[test]
2642 fn a_collapsed_modal_rail_lets_presses_outside_its_panel_through() {
2643 let view: NavigationRailView<u32> =
2644 navigation_rail(one_section(), 0, |_state: &mut u32, _index| {})
2645 .rail_type(NavigationRailType::Collapsed)
2646 .modality(NavigationRailModality::Modal)
2647 .on_dismiss_modal(|state: &mut u32| *state += 1);
2648 let mut w = build(&view);
2649 layout(&mut w);
2650
2651 let mut dismissed = 0u32;
2652 let beside = Point::new(RAIL_COLLAPSED_WIDTH + 40.0, 200.0);
2653 assert_eq!(
2654 dispatch(&mut w, &mut dismissed, PointerPhase::Down, beside),
2655 EventResult::Ignored,
2656 "nothing behind a collapsed modal rail goes inert"
2657 );
2658 assert_eq!(dismissed, 0);
2659 }
2660
2661 #[test]
2664 fn a_section_header_takes_space_only_while_expanded() {
2665 let headed = || {
2666 vec![
2667 rail_section(vec![dest::<()>("Home")]).header("Main"),
2668 rail_section(vec![dest::<()>("Profile")]).header("Account"),
2669 ]
2670 };
2671 let bare = || {
2672 vec![
2673 rail_section(vec![dest::<()>("Home")]),
2674 rail_section(vec![dest::<()>("Profile")]),
2675 ]
2676 };
2677
2678 let mut expanded_headed = build(&navigation_rail(headed(), 0, |_s: &mut (), _i| {}));
2679 let mut expanded_bare = build(&navigation_rail(bare(), 0, |_s: &mut (), _i| {}));
2680 layout(&mut expanded_headed);
2681 layout(&mut expanded_bare);
2682 assert!(
2683 expanded_headed.items[0].origin().y > expanded_bare.items[0].origin().y,
2684 "an expanded rail reserves space above its first destination for the header"
2685 );
2686 assert!(
2687 expanded_headed.sections[1].header_origin.y > 0.0,
2688 "every section's header is placed"
2689 );
2690
2691 let mut collapsed_headed = build(
2692 &navigation_rail(headed(), 0, |_s: &mut (), _i| {})
2693 .rail_type(NavigationRailType::Collapsed),
2694 );
2695 let mut collapsed_bare = build(
2696 &navigation_rail(bare(), 0, |_s: &mut (), _i| {})
2697 .rail_type(NavigationRailType::Collapsed),
2698 );
2699 layout(&mut collapsed_headed);
2700 layout(&mut collapsed_bare);
2701 assert_eq!(
2702 collapsed_headed.items[1].origin().y,
2703 collapsed_bare.items[1].origin().y,
2704 "a collapsed rail flattens every section and drops the headers"
2705 );
2706 }
2707
2708 #[test]
2709 fn the_fab_slot_renders_per_type_and_fires() {
2710 let collapsed: NavigationRailView<u32> =
2711 navigation_rail(one_section(), 0, |_state: &mut u32, _index| {})
2712 .rail_type(NavigationRailType::Collapsed)
2713 .fab(rail_fab(crate::icons::ADD, "Compose", |state: &mut u32| {
2714 *state += 1
2715 }));
2716 let mut w = build(&collapsed);
2717 layout(&mut w);
2718 let pod = w.fab.as_ref().expect("the FAB slot renders");
2719 let square = pod.size();
2720 assert_eq!(
2721 square.width, square.height,
2722 "a collapsed rail shows the plain square FAB"
2723 );
2724
2725 let mut pressed = 0u32;
2726 let pos = Point::new(
2727 pod.origin().x + square.width / 2.0,
2728 pod.origin().y + square.height / 2.0,
2729 );
2730 tap(&mut w, &mut pressed, pos);
2731 assert_eq!(pressed, 1);
2732
2733 let expanded: NavigationRailView<u32> =
2734 navigation_rail(one_section(), 0, |_state: &mut u32, _index| {})
2735 .rail_type(NavigationRailType::Expanded)
2736 .fab(rail_fab(crate::icons::ADD, "Compose", |state: &mut u32| {
2737 *state += 1
2738 }));
2739 let mut w = build(&expanded);
2740 let mut t_ms = 0u64;
2744 for _ in 0..40 {
2745 layout(&mut w);
2746 paint_at(&mut w, t_ms, None);
2747 t_ms += 16;
2748 }
2749 let pill = w.fab.as_ref().expect("the FAB slot renders").size();
2750 assert!(
2751 pill.width > pill.height,
2752 "an expanded rail shows the extended (labeled) FAB, got {pill:?}"
2753 );
2754 }
2755
2756 #[test]
2757 fn the_label_behavior_gates_a_collapsed_label_but_never_an_expanded_one() {
2758 for (behavior, selected_shows, unselected_shows) in [
2759 (RailLabelBehavior::AlwaysShow, true, true),
2760 (RailLabelBehavior::OnlySelected, true, false),
2761 (RailLabelBehavior::AlwaysHide, false, false),
2762 ] {
2763 let view: NavigationRailView<()> = rail()
2764 .rail_type(NavigationRailType::Collapsed)
2765 .label_behavior(behavior);
2766 let mut w = build(&view);
2767 layout(&mut w);
2768 assert_eq!(item(&mut w, 0).shows_label(), selected_shows);
2769 assert_eq!(item(&mut w, 1).shows_label(), unselected_shows);
2770
2771 let view: NavigationRailView<()> = rail()
2772 .rail_type(NavigationRailType::Expanded)
2773 .label_behavior(behavior);
2774 let mut w = build(&view);
2775 layout(&mut w);
2776 assert!(
2777 item(&mut w, 1).shows_label(),
2778 "an expanded row always shows its label, whatever {behavior:?} says"
2779 );
2780 }
2781 }
2782
2783 #[test]
2784 fn a_badge_count_clamps_to_the_documented_three_digit_cap() {
2785 let view: NavigationRailView<()> = navigation_rail(
2786 vec![rail_section(vec![
2787 dest("None"),
2788 dest("Dot").badge_count(0),
2789 dest("Small").badge_count(7),
2790 dest("Huge").badge_count(5_000),
2791 ])],
2792 0,
2793 |_s: &mut (), _i| {},
2794 );
2795 let mut w = build(&view);
2796 layout(&mut w);
2797 assert_eq!(item(&mut w, 0).badge_text(), None);
2798 assert_eq!(item(&mut w, 1).badge_text(), None, "a zero count is a dot");
2799 assert_eq!(item(&mut w, 1).badge_size, Size::new(BADGE_DOT, BADGE_DOT));
2800 assert_eq!(item(&mut w, 2).badge_text().as_deref(), Some("7"));
2801 assert_eq!(item(&mut w, 3).badge_text().as_deref(), Some("999+"));
2802 assert_eq!(item(&mut w, 0).badge_size, Size::ZERO);
2803 }
2804
2805 #[test]
2808 fn the_indicator_springs_match_the_reference_motion() {
2809 let reference = crate::MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT;
2810 assert_eq!(RAIL_LEAD_SPRING.stiffness, reference.stiffness);
2811 assert_eq!(RAIL_TRAIL_SPRING.stiffness, reference.stiffness);
2812 assert_eq!(RAIL_LEAD_SPRING.mass, 1.0);
2813 assert_eq!(RAIL_TRAIL_SPRING.mass, 1.0);
2814 assert_eq!(RAIL_LEAD_SPRING.damping_ratio, 0.45);
2816 assert_eq!(RAIL_TRAIL_SPRING.damping_ratio, 0.55);
2817 }
2818
2819 #[test]
2820 fn an_interrupted_retarget_carries_the_edges_real_velocity() {
2821 let mut axis = TravelAxis::new();
2828 axis.travel_to(200.0, RAIL_LEAD_SPRING);
2829
2830 let mut now = FrameTime::from_nanos(0);
2833 for step in 1..=3u64 {
2834 now = FrameTime::from_nanos(step * 16_000_000);
2835 assert!(axis.advance(now), "the leg should still be in flight");
2836 }
2837 let pre_retarget_velocity = axis.velocity;
2838 assert!(
2839 pre_retarget_velocity.abs() > 10.0,
2840 "a few frames into a 200px travel should already be moving well \
2841 above a near-zero relaunch rate, got {pre_retarget_velocity}"
2842 );
2843
2844 axis.travel_to(0.0, RAIL_LEAD_SPRING);
2846
2847 let carried = axis
2851 .flight
2852 .expect("travel_to launches a fresh leg")
2853 .velocity(0.0);
2854 assert!(
2855 (carried - pre_retarget_velocity).abs() < 1e-6,
2856 "retarget must carry the interrupted leg's real velocity into the \
2857 new spring's initial condition, got carried={carried} \
2858 pre_retarget={pre_retarget_velocity}"
2859 );
2860
2861 axis.advance(now);
2871 let value_before_retarget = axis.value;
2872 let next = FrameTime::from_nanos(now.as_nanos() + 16_000_000);
2873 axis.advance(next);
2874 let post_retarget_displacement = (axis.value - value_before_retarget).abs();
2875 assert!(
2876 post_retarget_displacement > 0.5,
2877 "the first post-retarget frame should move visibly, matching the \
2878 carried rate rather than pausing/kinking near zero, got \
2879 displacement={post_retarget_displacement}"
2880 );
2881 }
2882
2883 #[test]
2884 fn a_selection_change_travels_the_pill_then_hands_it_back_to_the_item() {
2885 let first: NavigationRailView<()> = rail().rail_type(NavigationRailType::Expanded);
2886 let mut w = build(&first);
2887 layout(&mut w);
2888 let (rec, _) = paint_at(&mut w, 0, None);
2889 assert!(!w.traveling, "a settled rail paints no bridge");
2890 assert!(
2891 item(&mut w, 0).use_local_indicator,
2892 "the selected item owns its own resting pill"
2893 );
2894 let resting = w.items[0].size();
2895 assert!(
2896 rec.rrects.iter().any(|(_, size, _, _)| *size == resting),
2897 "the resting indicator is the selected row itself"
2898 );
2899
2900 let moved: NavigationRailView<()> = navigation_rail(one_section(), 2, |_s: &mut (), _i| {})
2901 .rail_type(NavigationRailType::Expanded);
2902 rebuild(&moved, &first, &mut w);
2903 layout(&mut w);
2904
2905 let start = w.items[0].origin().y + w.items[0].size().height / 2.0;
2906 let end = w.items[2].origin().y + w.items[2].size().height / 2.0;
2907 let mut t_ms = 0u64;
2908 let mut frames = 0u32;
2909 let mut stretched = false;
2910 loop {
2911 paint_at(&mut w, t_ms, None);
2912 if !w.traveling {
2913 break;
2914 }
2915 assert!(
2916 !item(&mut w, 2).use_local_indicator,
2917 "while the bridge travels, no item paints its own fill"
2918 );
2919 let pill = w.pill_rect();
2920 stretched |= pill.height() > w.indicator_extent + 1.0;
2921 assert!(
2922 pill.center().y >= start - 40.0 && pill.center().y <= end + 40.0,
2923 "the bridge stays between the two destinations"
2924 );
2925 frames += 1;
2926 t_ms += 16;
2927 assert!(frames < 400, "the travel settles well inside 400 frames");
2928 }
2929 assert!(frames > 1, "the travel spans more than one frame");
2930 assert!(
2931 stretched,
2932 "the lead runs ahead of the trail — the pill elongates into a bridge"
2933 );
2934 assert!(
2935 (w.pill_rect().center().y - end).abs() < 1e-6,
2936 "the settled pill lands on the newly selected destination"
2937 );
2938 assert!(
2939 item(&mut w, 2).use_local_indicator,
2940 "once settled the item takes its resting pill back"
2941 );
2942 }
2943
2944 #[test]
2945 fn a_width_transition_jumps_the_pill_instead_of_morphing_it() {
2946 let collapsed: NavigationRailView<()> =
2947 navigation_rail(one_section(), 1, |_s: &mut (), _i| {})
2948 .rail_type(NavigationRailType::Collapsed);
2949 let mut w = build(&collapsed);
2950 layout(&mut w);
2951
2952 let expanded: NavigationRailView<()> =
2953 navigation_rail(one_section(), 1, |_s: &mut (), _i| {})
2954 .rail_type(NavigationRailType::Expanded);
2955 rebuild(&expanded, &collapsed, &mut w);
2956
2957 let mut t_ms = 0u64;
2958 for _ in 0..40 {
2959 let (_, needs_layout) = paint_at(&mut w, t_ms, None);
2960 assert!(
2961 !w.traveling,
2962 "a width transition remeasures the pill, it never morphs it"
2963 );
2964 if !needs_layout {
2965 break;
2966 }
2967 layout(&mut w);
2968 let center = w.items[1].origin().y + w.items[1].size().height / 2.0;
2969 assert!(
2970 (w.pill_rect().center().y - center).abs() < 1e-6,
2971 "the pill tracks the selected destination through the ramp"
2972 );
2973 t_ms += 16;
2974 }
2975 }
2976
2977 #[test]
2980 fn growing_and_shrinking_the_destination_list_reconciles() {
2981 let two: NavigationRailView<()> = navigation_rail(
2982 vec![rail_section(vec![dest("Home"), dest("Search")])],
2983 0,
2984 |_s: &mut (), _i| {},
2985 );
2986 let mut w = build(&two);
2987 layout(&mut w);
2988 assert_eq!(w.items.len(), 2);
2989
2990 let three: NavigationRailView<()> = rail();
2991 let flags = rebuild(&three, &two, &mut w);
2992 assert_eq!(w.items.len(), 3);
2993 assert!(flags.needs_layout());
2994
2995 let one: NavigationRailView<()> = navigation_rail(
2996 vec![rail_section(vec![dest("Home")])],
2997 0,
2998 |_s: &mut (), _i| {},
2999 );
3000 let flags = rebuild(&one, &three, &mut w);
3001 assert_eq!(w.items.len(), 1);
3002 assert!(flags.needs_layout());
3003 layout(&mut w);
3004 }
3005
3006 #[test]
3007 fn a_rebuild_adopts_the_new_selection_and_labels() {
3008 let first: NavigationRailView<()> = rail();
3009 let mut w = build(&first);
3010 layout(&mut w);
3011 assert!(item(&mut w, 0).selected);
3012
3013 let second: NavigationRailView<()> = navigation_rail(
3014 vec![rail_section(vec![
3015 dest("Home"),
3016 dest("Explore"),
3017 dest("Profile"),
3018 ])],
3019 1,
3020 |_s: &mut (), _i| {},
3021 );
3022 let flags = rebuild(&second, &first, &mut w);
3023 assert!(flags.needs_paint());
3024 assert!(!item(&mut w, 0).selected);
3025 assert!(item(&mut w, 1).selected);
3026 assert_eq!(item(&mut w, 1).label.content, "Explore");
3027 }
3028
3029 #[test]
3032 fn semantics_yields_a_tablist_of_tabs_with_selection() {
3033 fn logic(_state: &mut ()) -> NavigationRailView<()> {
3034 navigation_rail(one_section(), 1, |_s: &mut (), _i| {})
3035 .rail_type(NavigationRailType::Expanded)
3036 }
3037 let mut root: frust_core::RenderRoot<(), NavigationRailView<()>> =
3038 frust_core::RenderRoot::new();
3039 let mut state = ();
3040 root.rebuild(&mut logic, &mut state);
3041 let mut tcx = TextContext::new();
3042 root.layout_with_text(AREA, &mut tcx as &mut dyn Any);
3043 let update = root.semantics();
3044
3045 let tablist = update
3046 .nodes
3047 .iter()
3048 .find(|(_, node)| node.role() == Role::TabList)
3049 .expect("a TabList container node is contributed");
3050 assert!(
3051 tablist.1.children().len() >= 3,
3052 "every destination is forwarded beneath it"
3053 );
3054
3055 let tabs: Vec<_> = update
3056 .nodes
3057 .iter()
3058 .filter(|(_, node)| node.role() == Role::Tab)
3059 .collect();
3060 assert_eq!(tabs.len(), 3);
3061 let selected = tabs
3062 .iter()
3063 .find(|(_, node)| node.label() == Some("Search"))
3064 .expect("the Search tab is present");
3065 assert_eq!(selected.1.is_selected(), Some(true));
3066 let unselected = tabs
3067 .iter()
3068 .find(|(_, node)| node.label() == Some("Home"))
3069 .expect("the Home tab is present");
3070 assert_eq!(unselected.1.is_selected(), Some(false));
3071 }
3072}