Skip to main content

frust_material/
navigation_rail.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments)
2// Upstream: https://github.com/paadevelopments/material_3_expressive
3//   lib/components/navigation_rail/ — m3e_navigation_rail.dart, its
4//   components/ (m3e_rail_item.dart, m3e_rail_item_button.dart,
5//   m3e_nav_selection_indicator.dart, m3e_rail_badge_view.dart,
6//   m3e_nav_icon_scale.dart, m3e_navigation_rail_children_mixin.dart),
7//   enums/, models/, res/m3e_navigation_rail_layout.dart and
8//   styles/m3e_navigation_rail_theme.dart (retrieved 2026-08-19). The
9//   reference's own tree is in turn vendored from the `navigation_rail_m3e`
10//   package (MIT) — already credited in `plugins/material/NOTICE`.
11// Porting decisions: expansion is a caller-owned prop here rather than the
12// reference's internal `_expanded` mirror; the modal presentation is painted
13// in-widget instead of through this crate's `overlay::modal` host; the
14// scrollable destination column, the collapsed "peek" overlay, per-item
15// tooltips, the icon scale-pop and the Android system-bar scrim styling stay
16// unported — see the module docs' *Not ported*.
17
18//! The M3 Expressive **navigation rail**: a vertical destination column that
19//! animates between a slim collapsed icon rail and a wide expanded
20//! icon-plus-label rail, optionally presenting the expanded state **modally**
21//! over a scrim.
22//!
23//! [`navigation_rail`] is *controlled* twice over, the same never-self-mutating
24//! contract every control in this catalog follows
25//! (`docs/CODE_STANDARDS.md`'s Interaction Semantics):
26//!
27//! * **Selection** — the caller owns `selected` and receives the pressed
28//!   destination's flat index through [`navigation_rail`]'s `on_select`.
29//! * **Expansion** — the caller owns [`NavigationRailView::rail_type`] and
30//!   receives the *requested* next type through
31//!   [`NavigationRailView::on_type_changed`] when the menu button is pressed.
32//!   The reference instead keeps a `_expanded` mirror inside its own `State`,
33//!   seeds it from `type`, flips it itself and merely *notifies*
34//!   `onTypeChanged`; this port drops the mirror so there is exactly one
35//!   source of truth (the prop), which is also what makes an app-driven
36//!   window-size-class switch (`alwaysCollapse` on compact, `expanded` on
37//!   medium+) land without fighting internal state.
38//!
39//! # Geometry
40//!
41//! Every value below is `M3ENavigationRailTheme`'s own default
42//! (`styles/m3e_navigation_rail_theme.dart`) or a
43//! `M3ENavigationRailLayout` constant (`res/`):
44//!
45//! | Metric | Value |
46//! |---|---|
47//! | Collapsed width | 96dp ([`RAIL_COLLAPSED_WIDTH`]) — `0` when [`NavigationRailView::hide_when_collapsed`] |
48//! | Expanded width | [`NavigationRailView::expanded_width`] clamped to 220–360dp ([`RAIL_EXPANDED_MIN_WIDTH`]/[`RAIL_EXPANDED_MAX_WIDTH`]), defaulting to the 220dp minimum |
49//! | Item height | 66dp collapsed, 40dp *minimum* expanded |
50//! | Icon | 24dp |
51//! | Row insets | 16dp leading/trailing, 8dp icon→label gap, 4dp between items |
52//! | Section header | 12dp above, 8dp below, `titleSmall` in `onSurfaceVariant` |
53//! | Top gap | 36dp above the menu button |
54//!
55//! A collapsed destination is a 52×40dp icon chip inside a 48dp tap target —
56//! the exact box [`mod@crate::icon_button`] paints for its
57//! `IconButtonSize::Sm`/`IconButtonWidth::Wide` pair, which is what the
58//! reference builds a collapsed item from — with its label (when
59//! [`RailLabelBehavior`] shows one) directly beneath. An expanded destination
60//! is a full-width row whose selection indicator *is* the row.
61//!
62//! # The width transition, and the layout-skip trap
63//!
64//! The rail's width is a **layout** value driven by an animation, so this is
65//! the layout-skip class [`mod@crate::expandable_list`] documents at length: on
66//! the mobile intra-frame layout skip
67//! (`docs/SHELLS_ARCHITECTURE.md`'s `frame_gate`), layout does not re-run
68//! merely because paint asked for another frame. While the width is in flight
69//! `paint` therefore calls [`frust::authoring::PaintCtx::request_layout`]
70//! (which implies `request_frame`), so the next frame relayouts at the
71//! freshly-advanced width on every platform, gated or not.
72//! `Theme.motion.reduce_motion` snaps straight to the target instead; the snap
73//! is still a layout-relevant value change, so it requests exactly one
74//! relayout (never an ongoing frame request — the driver is already stopped).
75//!
76//! **Timing, not a spring**: the reference animates the width with
77//! `AnimatedContainer(duration: 280ms, curve: Curves.easeOutCubic)`
78//! ([`RAIL_EXPAND_DURATION`], [`RAIL_EXPAND_CURVE`]) — a duration+curve, not a
79//! spring — and this port transcribes that rather than substituting a spring
80//! token. Flutter's `easeOutCubic` is `cubic-bezier(0.215, 0.61, 0.355, 1.0)`,
81//! carried over exactly as a [`frust::Curve::Cubic`]. The *selection*
82//! indicator below is the spring-driven part of this widget.
83//!
84//! # The liquid selection indicator
85//!
86//! Ported from `components/m3e_nav_selection_indicator.dart`: two independent
87//! springs track the **lead** and **trailing** edge centers of the pill along
88//! the rail's main axis. The lead spring is less damped ([`RAIL_LEAD_SPRING`],
89//! ζ 0.45) than the trailing one ([`RAIL_TRAIL_SPRING`], ζ 0.55), so on a
90//! selection change the pill *elongates* into a bridge spanning both
91//! destinations and then settles back to a stadium over the newly selected one
92//! — the reference's "liquid" morph, on its own `expressiveSpatialDefault`
93//! stiffness. Both are read **unclamped** so the lead's overshoot is real
94//! travel (the same `crate::switch` spatial-travel convention).
95//!
96//! While the bridge is traveling the rail paints it and every item's own
97//! resting fill is suppressed; once it settles the rail stops painting and each
98//! selected item paints its own indicator again — the reference's
99//! `onTravelingChanged`/`useLocalIndicator` handshake, which is what keeps the
100//! resting pill correct on a cold start (before the springs have ever run).
101//! A width transition is *not* a selection change: while the rail is
102//! expanding or collapsing the pill **jumps** to the selected item's new
103//! geometry every frame instead of morphing (the reference's `layoutToken` +
104//! `layoutSettleDuration` remeasure loop, which this port gets for free by
105//! reading the freshly laid-out rects).
106//!
107//! [`crate::navbar`] establishes the sibling per-item indicator for the bottom
108//! bar; this module transcribes the rail's own shared-overlay indicator rather
109//! than reusing it — they are different mechanisms upstream.
110//!
111//! # Modal presentation: painted in-widget, not through the modal host
112//!
113//! With [`NavigationRailModality::Modal`], the expanded rail overlays content
114//! behind a scrim ([`crate::overlay::OVERLAY_SCRIM_ALPHA`], the same M3 32%),
115//! and a press on the scrim reports [`NavigationRailView::on_dismiss_modal`].
116//!
117//! **This deliberately does not go through [`crate::overlay::modal`]**, the
118//! catalog's modal host. That host is *route-like*: it pushes a transparent
119//! navigator page, owns the panel's lifetime, pops with a value, and stages an
120//! Android back press as its own dismiss ramp. The reference's modal rail is
121//! none of those things — it inserts a bare `OverlayEntry` into the root
122//! `Overlay` (`_insertOverlay`), keeps the rail widget itself mounted in
123//! layout, and its lifetime is entirely a function of the `modality`/`type`
124//! props the app already owns. Routing it through the modal host would give
125//! the panel a second, competing owner (the navigator stack) for state the app
126//! is already controlling, so the scrim is painted here instead, exactly as
127//! upstream paints it.
128//!
129//! **What upstream actually wires for dismissal**: a scrim tap, and nothing
130//! else. `_buildModalOverlay` wraps the scrim in a
131//! `GestureDetector(onTap: widget.onDismissModal)`; there is no `Focus`,
132//! `Shortcuts`, `KeyboardListener` or `PopScope` anywhere in the rail tree, so
133//! neither **Escape** nor an Android **back** press dismisses it — despite
134//! `M3ENavigationRailModality.modal`'s own doc comment claiming "dismisses on
135//! tap/esc". This port matches the code, not the comment: the scrim press is
136//! the only dismissal, fired on release-inside like every other press in this
137//! catalog. An app that wants Escape/back handling wires it at the mount
138//! (a [`frust::BackHandler`], its own key handling) and flips the prop.
139//!
140//! # Mounting
141//!
142//! A [`NavigationRailModality::Standard`] rail is an ordinary in-layout widget:
143//! it self-sizes to the animated rail width and fills the height it is given,
144//! so it goes directly in a [`frust::Row`] beside the page body.
145//!
146//! A [`NavigationRailModality::Modal`] rail is a **top layer**: it fills the
147//! whole area it is given, pins the panel to the leading edge, paints the scrim
148//! over the rest, and swallows presses that land on the scrim — the same
149//! full-area mounting contract [`crate::overlay`]'s hosts document (the top
150//! child of a full-area [`frust::Stack`], or a transparent navigator page).
151//! Upstream achieves this by rendering a `SizedBox.shrink()` in place and
152//! duplicating the rail into the root overlay; one widget cannot be in two
153//! places here, so the port keeps a single widget whose *own* layout behaviour
154//! switches with the modality. The one behavioural consequence is that a
155//! collapsed **modal** rail floats over the content instead of occupying a
156//! layout column the way upstream's collapsed modal rail does — while
157//! collapsed it lets every press outside its panel fall through to the layer
158//! below, so nothing behind it goes inert.
159//!
160//! Either way the rail **fills the height it is given**, so a bounded height is
161//! part of the mounting contract: mounted where the vertical constraint is
162//! unbounded (inside a [`frust::scroll_view`], or a column that hands its
163//! children infinity), it reads a zero-height area and collapses — the same
164//! trap [`crate::overlay`]'s `finite_or_zero` documents for its own hosts.
165//!
166//! # Semantics
167//!
168//! One [`Role::TabList`] container node with a [`Role::Tab`] node per
169//! destination, carrying the destination's label (its `semantic_label` when
170//! given) and [`frust::authoring::Node::set_selected`] — the same vocabulary
171//! [`crate::navbar`] uses, and for the same reason. The menu button, the FAB
172//! and the trailing slot contribute their own nodes beneath the same
173//! container.
174//!
175//! # Not ported
176//!
177//! * **A scrollable destination column** (`scrollable: true`, upstream's
178//!   default `ListView`) — the column lays out top-down and a rail taller than
179//!   its box overflows. An app with more destinations than fit puts the rail
180//!   inside its own [`frust::scroll_view`].
181//! * **The collapsed "peek" overlay** (`_buildCollapsedPeekOverlay`) — when
182//!   `hideWhenCollapsed` shrinks the rail to zero width, upstream floats a
183//!   detached expand button into the root overlay via a
184//!   `CompositedTransformFollower`. A zero-width widget here receives no
185//!   pointer events at all, and this port has no seam for hosting an
186//!   interactive affordance outside its own box, so
187//!   [`NavigationRailView::hide_when_collapsed`] gives a genuinely empty rail
188//!   and the app owns its own expand affordance.
189//! * **Per-item tooltips** (collapsed items are `Tooltip`-wrapped upstream) —
190//!   the destination's label already reaches assistive technology through the
191//!   `Role::Tab` node, and re-wrapping a caller-supplied icon view into
192//!   [`mod@crate::tooltip`] would need to *own* that view, which a `&self`
193//!   `View::build` cannot do.
194//! * **The icon scale-pop** (`M3ENavIconScale`, a 0.98→1.0 spring on the newly
195//!   selected icon) — a per-item transform around a caller-supplied icon view,
196//!   deferred with the tooltips above.
197//! * **`M3EScrimSystemUi.wrap`** — Android status/navigation-bar icon
198//!   styling while the modal scrim is up. That is a `frust::system_ui`
199//!   concern an app drives itself, not a widget one.
200//! * **`M3ENavigationRailDestination.short`** and the theme's `headerMinSpace`
201//!   — both are declared upstream and read by nothing. So is
202//!   `_buildChildren`'s `showLabels` argument (computed from a `maxWidth >=
203//!   180` test and then never consulted), so a collapsed label's visibility
204//!   here is [`RailLabelBehavior`] alone, exactly as the reference behaves.
205//! * **`suppressInk`** — upstream suppresses splash/hover/highlight on both
206//!   item shapes (`NoSplash.splashFactory`, transparent hover/highlight/overlay
207//!   colors, `suppressInk: true` on the collapsed icon button) and then carries
208//!   a flag to suppress it *harder* during a transition. A rail item here
209//!   paints no state layer at all, so there is nothing left to suppress; the
210//!   only press feedback is the selection indicator.
211
212use 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
235// ---- Layout tokens ---------------------------------------------------------
236
237/// The collapsed rail's width, in logical px
238/// (`M3ENavigationRailTheme.collapsedWidth`).
239pub const RAIL_COLLAPSED_WIDTH: f64 = 96.0;
240/// The expanded rail's minimum (and default) width, in logical px
241/// (`M3ENavigationRailTheme.expandedMinWidth`).
242pub const RAIL_EXPANDED_MIN_WIDTH: f64 = 220.0;
243/// The expanded rail's maximum width, in logical px
244/// (`M3ENavigationRailTheme.expandedMaxWidth`).
245pub const RAIL_EXPANDED_MAX_WIDTH: f64 = 360.0;
246/// A collapsed destination's total height, in logical px
247/// (`M3ENavigationRailTheme.itemCollapsedHeight`).
248pub const RAIL_ITEM_COLLAPSED_HEIGHT: f64 = 66.0;
249/// An expanded destination row's *minimum* height, in logical px
250/// (`M3ENavigationRailTheme.itemExpandedHeight`) — a row with a taller
251/// measured label grows past it.
252pub const RAIL_ITEM_EXPANDED_HEIGHT: f64 = 40.0;
253
254/// The destination icon's side length, in logical px
255/// (`M3ENavigationRailTheme.iconSize`).
256const ICON_SIZE: f64 = 24.0;
257/// Leading inset inside an expanded row, in logical px
258/// (`M3ENavigationRailTheme.indicatorLeading`).
259const INDICATOR_LEADING: f64 = 16.0;
260/// Trailing inset inside an expanded row, in logical px
261/// (`M3ENavigationRailTheme.indicatorTrailing`).
262const INDICATOR_TRAILING: f64 = 16.0;
263/// Gap between a row's icon and its label, in logical px
264/// (`M3ENavigationRailTheme.iconLabelGap`).
265const ICON_LABEL_GAP: f64 = 8.0;
266/// Vertical padding above *and* below every destination, in logical px
267/// (`M3ENavigationRailTheme.itemVerticalGap`).
268const ITEM_VERTICAL_GAP: f64 = 4.0;
269/// Space above a section header, in logical px
270/// (`M3ENavigationRailTheme.sectionHeaderSpacingTop`).
271const SECTION_HEADER_SPACING_TOP: f64 = 12.0;
272/// Space below a section header, in logical px
273/// (`M3ENavigationRailTheme.sectionHeaderSpacingBottom`).
274const SECTION_HEADER_SPACING_BOTTOM: f64 = 8.0;
275/// Horizontal inset applied to every row of rail content, in logical px
276/// (`M3ENavigationRailLayout.horizontalInset`, and the `start`/`end` of its
277/// `sectionPadding`).
278const HORIZONTAL_INSET: f64 = 16.0;
279/// Bottom inset under the menu button / FAB / trailing slot, in logical px
280/// (`M3ENavigationRailLayout.sectionPadding`'s `bottom`).
281const SECTION_PADDING_BOTTOM: f64 = 12.0;
282/// Blank space above the menu button, in logical px
283/// (`M3ENavigationRailLayout.topGap`).
284const TOP_GAP: f64 = 36.0;
285
286/// A collapsed destination's icon-chip width, in logical px — the *visual*
287/// box [`crate::icon_button`] paints for
288/// [`IconButtonSize::Sm`](crate::icon_button::IconButtonSize::Sm) ×
289/// [`IconButtonWidth::Wide`](crate::icon_button::IconButtonWidth::Wide), which
290/// is the pair the reference's collapsed item builds
291/// (`M3EIconButton(width: M3EIconButtonWidth.wide)`).
292const CHIP_WIDTH: f64 = 52.0;
293/// That chip's visual height, in logical px (same source).
294const CHIP_HEIGHT: f64 = 40.0;
295/// That chip's tap-target height, in logical px — the 48dp minimum the icon
296/// button pads its `Sm` visual box out to.
297const CHIP_TARGET_HEIGHT: f64 = 48.0;
298
299/// The badge dot's diameter, in logical px (`M3ERailBadge`'s `count == 0`
300/// branch: a bare 8×8 circle).
301const BADGE_DOT: f64 = 8.0;
302/// Horizontal padding inside a labeled badge pill, in logical px
303/// (`M3ERailBadge`'s `pad + 2` with the non-dense `pad = 4`).
304const BADGE_H_PAD: f64 = 6.0;
305/// Vertical padding inside a labeled badge pill, in logical px
306/// (`M3ERailBadge`'s non-dense `pad`).
307const BADGE_V_PAD: f64 = 4.0;
308/// Highest badge count rendered verbatim; anything above shows `999+`
309/// (`M3ERailBadge.maxDigits`'s documented 3-digit cap).
310///
311/// The reference's own threshold expression is `count > 10 * (10^maxDigits -
312/// 1)` — 9990 for the default `maxDigits: 3` — which contradicts both its
313/// doc comment ("Maximum digits before showing a trailing '+', e.g. 999+")
314/// and the `999+` string it then renders, leaving 1000..=9990 as
315/// four-digit labels in a pill sized for three. This port takes the
316/// documented contract.
317const BADGE_MAX_COUNT: u32 = 999;
318
319// ---- Motion tokens ---------------------------------------------------------
320
321/// The width transition's duration (`M3ENavigationRailLayout.expandDuration`).
322pub const RAIL_EXPAND_DURATION: Duration = Duration::from_millis(280);
323
324/// The width transition's easing — Flutter's `Curves.easeOutCubic`, whose
325/// control points are `(0.215, 0.61)`/`(0.355, 1.0)`. Not
326/// [`frust::Curve::EaseOut`], which is CSS `ease-out`
327/// (`cubic-bezier(0, 0, 0.58, 1)`) and a visibly different ramp.
328pub const RAIL_EXPAND_CURVE: Curve = Curve::Cubic(0.215, 0.61, 0.355, 1.0);
329
330/// The liquid indicator's **lead**-edge spring: the reference's
331/// `expressiveSpatialDefault` stiffness with `damping: 0.45`
332/// (`_leadMotion`). Restated as a [`SpringDesc`] rather than read from a
333/// theme for the reason [`mod@crate::expandable_list`]'s `REVEAL_SPRING`
334/// documents — a `fling` needs one, and paint/event code reads no theme for
335/// motion. A test pins the stiffness to
336/// [`MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT`](crate::MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT).
337pub const RAIL_LEAD_SPRING: SpringDesc = SpringDesc {
338    mass: 1.0,
339    stiffness: 380.0,
340    damping_ratio: 0.45,
341};
342
343/// The liquid indicator's **trailing**-edge spring: the same stiffness with
344/// `damping: 0.55` (`_trailMotion`) — more damped than
345/// [`RAIL_LEAD_SPRING`], which is what makes the pill stretch on the way.
346pub const RAIL_TRAIL_SPRING: SpringDesc = SpringDesc {
347    mass: 1.0,
348    stiffness: 380.0,
349    damping_ratio: 0.55,
350};
351
352/// Rest threshold for a travelling edge, in logical px (and px/s for its
353/// velocity) — both are checked, per `Spring::is_at_rest`'s own contract —
354/// the same value `crate::navbar`'s module-private `PILL_REST_EPSILON` uses
355/// for the sibling liquid indicators.
356const TRAVEL_REST_EPSILON: f64 = 0.01;
357
358/// Distance in logical px under which two indicator positions count as the
359/// same place (a retarget guard, so an unchanged layout never relaunches a
360/// settled spring).
361const TRAVEL_EPSILON: f64 = 0.5;
362
363// ---- Type tokens -----------------------------------------------------------
364
365/// One type-scale row as `(size, line_height, letter_spacing, weight)` — the
366/// unthemed fallback for a text role, mirroring
367/// [`crate::button`]'s own `TypeToken` shape.
368type TypeToken = (f32, f32, f32, FontWeight);
369
370/// `label_large` — an expanded destination's label
371/// (`m3e_rail_item_button.dart`'s `typeScale.labelLarge`).
372const LABEL_LARGE: TypeToken = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
373/// `label_medium` — a collapsed destination's label (`typeScale.labelMedium`).
374const LABEL_MEDIUM: TypeToken = (12.0, 16.0, 0.5, FontWeight::MEDIUM);
375/// `label_small` — a badge's count (`M3ERailBadge`'s `typeScale.labelSmall`).
376const LABEL_SMALL: TypeToken = (11.0, 16.0, 0.5, FontWeight::MEDIUM);
377/// `title_small` — a section header (`_sectionHeader`'s `typeScale.titleSmall`).
378const TITLE_SMALL: TypeToken = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
379
380/// The ink every run is shaped with; paint re-brushes it to the resolved role
381/// color (the [`crate::button`]/[`crate::icon_button`] run convention).
382const SHAPING_INK: Color = Color::BLACK;
383
384// ---- Color fallbacks -------------------------------------------------------
385
386/// Unthemed-fallback `surface` — the rail container
387/// (`containerColorResolved`).
388const SURFACE: Color = Color::from_rgb8(0xFE, 0xF7, 0xFF);
389/// Unthemed-fallback `secondary_container` — the selection indicator
390/// (`activeIndicatorColorResolved`).
391const SECONDARY_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
392/// Unthemed-fallback `on_secondary_container` — a selected destination's icon
393/// and label (`activeIconAndLabelColor`).
394const ON_SECONDARY_CONTAINER: Color = Color::from_rgb8(0x1D, 0x19, 0x2B);
395/// Unthemed-fallback `on_surface_variant` — an unselected destination's label
396/// and every section header (`inactiveIconAndLabelColor`).
397const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
398/// Unthemed-fallback `primary` — a badge's fill (`badgeBackground`).
399const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
400/// Unthemed-fallback `on_primary` — a badge's label (`badgeLargeLabel`).
401const ON_PRIMARY: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
402/// Unthemed-fallback `scrim`, painted at [`OVERLAY_SCRIM_ALPHA`].
403const SCRIM: Color = Color::from_rgb8(0x00, 0x00, 0x00);
404
405/// Return `color` with its alpha channel replaced by `alpha` (the same helper
406/// [`crate::navbar`] and [`crate::overlay`] each carry).
407fn with_alpha(color: Color, alpha: f32) -> Color {
408    let c = color.components;
409    Color::new([c[0], c[1], c[2], alpha])
410}
411
412/// Whether a widget-local `pos` lies within a `size`-sized box anchored at the
413/// origin (the same helper [`crate::navbar`] carries).
414fn 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
418/// Resolve `token` (or the theme's matching role) into a shapeable style,
419/// always carrying [`SHAPING_INK`].
420fn 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// ---- Enums -----------------------------------------------------------------
437
438/// Which rail shape the caller is asking for — the reference's
439/// `M3ENavigationRailType` (`enums/m3e_navigation_rail_enums.dart`).
440///
441/// The two `Always*` variants differ from their plain counterparts only in
442/// dropping the menu button: nothing can toggle them from inside the rail.
443#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
444pub enum NavigationRailType {
445    /// The slim icon rail, with a menu button that requests expansion.
446    Collapsed,
447    /// The slim icon rail with no menu button at all.
448    AlwaysCollapse,
449    /// The wide icon-plus-label rail, with a menu button that requests
450    /// collapse (the reference's own default).
451    #[default]
452    Expanded,
453    /// The wide rail with no menu button at all.
454    AlwaysExpand,
455}
456
457impl NavigationRailType {
458    /// Whether this type shows labels beside icons and lays destinations out as
459    /// full-width rows.
460    pub fn is_expanded(self) -> bool {
461        matches!(self, Self::Expanded | Self::AlwaysExpand)
462    }
463
464    /// Whether the rail offers a menu button at all (the reference's
465    /// `_canToggle`: the plain `collapsed`/`expanded` pair only).
466    pub fn can_toggle(self) -> bool {
467        matches!(self, Self::Collapsed | Self::Expanded)
468    }
469
470    /// The type the menu button requests next — [`Self::Expanded`] from
471    /// collapsed, [`Self::Collapsed`] from expanded.
472    fn toggled(self) -> Self {
473        if self.is_expanded() {
474            Self::Collapsed
475        } else {
476            Self::Expanded
477        }
478    }
479}
480
481/// How the expanded rail relates to the content beside it — the reference's
482/// `M3ENavigationRailModality`.
483#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
484pub enum NavigationRailModality {
485    /// The rail occupies layout space beside the content (the default).
486    #[default]
487    Standard,
488    /// The rail floats over the content behind a scrim; a scrim press reports
489    /// [`NavigationRailView::on_dismiss_modal`]. See the [module docs](self)'
490    /// modal section for the mounting contract.
491    Modal,
492}
493
494/// When a **collapsed** destination shows its text label — the reference's
495/// `M3ENavigationRailLabelBehavior`.
496///
497/// An expanded row always shows its label; the reference consults this enum
498/// only on the collapsed shape.
499#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
500pub enum RailLabelBehavior {
501    /// Every destination shows its label (the default).
502    #[default]
503    AlwaysShow,
504    /// Only the selected destination shows its label.
505    OnlySelected,
506    /// No destination shows a label.
507    AlwaysHide,
508}
509
510impl RailLabelBehavior {
511    /// Whether a collapsed destination shows its label
512    /// (`_buildCollapsedContent`'s `showLabel`).
513    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
522// ---- Models ----------------------------------------------------------------
523
524/// One rail destination — the reference's `M3ENavigationRailDestination`.
525pub 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
533/// Create a destination showing `icon` above/beside `label`.
534///
535/// Tint is the supplied icon view's own responsibility, the same stance
536/// [`crate::navbar`]'s and [`crate::appbar`]'s icon slots take.
537pub 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    /// A distinct icon to show while this destination is selected (falls back
552    /// to the base icon).
553    pub fn selected_icon(mut self, icon: impl View<State>) -> Self {
554        self.selected_icon = Some(AnyView::new(icon));
555        self
556    }
557
558    /// A numeric badge; `0` paints the reference's bare 8dp dot.
559    pub fn badge_count(mut self, count: u32) -> Self {
560        self.badge_count = Some(count);
561        self
562    }
563
564    /// The accessible name, when the visible label isn't the right one.
565    pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
566        self.semantic_label = Some(label.into());
567        self
568    }
569
570    /// The icon view showing right now (`selected_icon` while selected, if
571    /// one was given) — the same resolution [`crate::icon_button`]'s
572    /// `active_icon` performs.
573    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
582/// A labeled group of destinations — the reference's
583/// `M3ENavigationRailSection`.
584///
585/// The header shows only on the **expanded** rail: upstream's collapsed pass
586/// (`_buildCollapsedDestinations`) flattens every section's destinations and
587/// drops the headers entirely.
588pub struct RailSection<State: 'static> {
589    header: Option<String>,
590    destinations: Vec<RailDestination<State>>,
591}
592
593/// Create an unlabeled section over `destinations` (chain
594/// [`RailSection::header`] to label it).
595pub 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    /// Label this section (`titleSmall`, `onSurfaceVariant`), as a single
606    /// ellipsized line — the same one-line treatment every other label in this
607    /// rail takes.
608    ///
609    /// Upstream takes an arbitrary header `Widget` and then imposes that exact
610    /// text style on it with a `DefaultTextStyle`; this port narrows the slot
611    /// to the string that styling is for.
612    pub fn header(mut self, header: impl Into<String>) -> Self {
613        self.header = Some(header.into());
614        self
615    }
616}
617
618/// The rail's built-in FAB slot — the reference's
619/// `M3ENavigationRailFabSlot`.
620///
621/// Renders as a plain [`mod@crate::fab`] while collapsed and an
622/// [`crate::extended_fab`] while expanded, exactly as upstream switches
623/// between `M3EFab` and `M3EExtendedFab`. The icon is an [`IconSource`]
624/// rather than an arbitrary view because this slot is *re-wrapped* into a
625/// catalog FAB view on every rebuild, which a non-clonable
626/// [`AnyView`] cannot be (destination and trailing slots, which become child
627/// pods directly, still take views).
628pub 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
637/// Create the rail's FAB slot: `icon` alone while collapsed, `icon` + `label`
638/// while expanded, firing `on_press` on release inside.
639pub 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    /// The container color tier (default [`FabColor::Primary`]).
656    pub fn color(mut self, color: FabColor) -> Self {
657        self.color = color;
658        self
659    }
660
661    /// The collapsed FAB's size tier (default [`FabSize::Medium`]); the
662    /// expanded variant's height is fixed by [`crate::extended_fab`].
663    pub fn size(mut self, size: FabSize) -> Self {
664        self.size = size;
665        self
666    }
667
668    /// The accessible name for the collapsed (icon-only) FAB; defaults to the
669    /// slot's own label.
670    pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
671        self.semantic_label = Some(label.into());
672        self
673    }
674
675    /// The presence/identity shape a rebuild diffs on before re-wrapping.
676    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
686// ---- Text runs -------------------------------------------------------------
687
688/// A lazily shaped, paint-time-rebrushed text run — the same idiom
689/// [`crate::icon_button`]'s `BadgeRun` and `crate::button`'s `LabelRun` use,
690/// scoped to what a rail label / section header / badge count needs.
691struct 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    /// Replace the run's text, invalidating any cached shaping. Returns
707    /// whether anything changed.
708    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    /// Shape (or reuse) the run, fitted to `max_width` as a **single
719    /// ellipsized line** when one is given — the reference's every label
720    /// carries `maxLines: 1, overflow: TextOverflow.ellipsis`.
721    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    /// Paint the run at `origin` in `color`, overriding the [`SHAPING_INK`] it
750    /// was shaped with. A never-shaped run paints nothing.
751    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
762// ---- Motion drivers --------------------------------------------------------
763
764/// The rail's animated width: a duration+curve ramp between two widths (see
765/// the [module docs](self)' width-transition section).
766struct WidthMotion {
767    /// The width `layout` reads this frame.
768    value: f64,
769    /// The width the current ramp is heading for.
770    target: f64,
771    /// Maps the controller's `0 → 1` ramp onto `from → target`.
772    tween: Tween<f64>,
773    driver: AnimationController,
774}
775
776impl WidthMotion {
777    /// A motion resting at `width` with no ramp in flight — a rail built
778    /// already expanded shows its full width on the first frame rather than
779    /// animating into it.
780    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    /// Ramp toward `target` (a no-op if already heading there).
790    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    /// Jump to the target, cancelling any ramp (the reduce-motion path).
801    /// Returns whether that actually moved the width.
802    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    /// Advance one frame, returning whether the ramp is still in flight.
811    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
822/// One edge of the liquid selection indicator: a spring-driven main-axis
823/// center.
824///
825/// Built directly on the analytic [`Spring`] rather than a duration-seeded
826/// [`AnimationController`] fling — the same shape as [`crate::navbar`]'s
827/// `LiquidEdge`, which this type transcribes (see that module's own doc
828/// comment for why `navbar.rs` itself stays read-only here). A [`Spring`]
829/// solves *displacement from equilibrium*, so the live position is `target +
830/// spring.position(elapsed)`; [`Self::travel_to`] re-solves from the current
831/// position **and velocity**, which is what keeps a fast double-tap between
832/// destinations reversing smoothly instead of relaunching from a near-zero
833/// rate.
834struct TravelAxis {
835    /// The edge center this frame, in rail-local px.
836    value: f64,
837    /// The center the current leg is heading for.
838    target: f64,
839    /// The in-flight analytic solution, or `None` once settled/jumped.
840    flight: Option<Spring>,
841    /// Seconds since [`Self::flight`] was solved.
842    elapsed: f64,
843    /// The last-solved instantaneous velocity, px/s — `0.0` once settled,
844    /// carried into the next [`Self::travel_to`]'s initial condition.
845    velocity: f64,
846    /// Clock for `advance`'s delta; `None` re-seeds it on the next call.
847    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    /// Snap to `to`, cancelling any leg (a first measure, a layout change, or
863    /// reduce motion).
864    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    /// Launch a fresh leg toward `to` on `spring` (a no-op if already heading
874    /// there), re-solving from wherever the edge currently reads **and its
875    /// current velocity** so an interrupted travel reverses smoothly — the
876    /// [`crate::navbar`] `LiquidEdge::retarget` carry, transcribed.
877    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    /// Advance to frame time `now`, returning whether the leg is still in
888    /// flight.
889    ///
890    /// The position is read **unclamped**: the lead spring is under-damped on
891    /// purpose and its overshoot past the target is the stretch that makes
892    /// the pill read as liquid.
893    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
921// ---- The view --------------------------------------------------------------
922
923/// A view-held, typed selection callback (erased per-destination on build).
924type OnSelect<State> = Rc<dyn Fn(&mut State, usize)>;
925/// A view-held, typed expansion-request callback.
926type OnTypeChanged<State> = Rc<dyn Fn(&mut State, NavigationRailType)>;
927/// A view-held, typed scrim-dismiss callback.
928type OnDismiss<State> = Rc<dyn Fn(&mut State)>;
929
930/// A declarative M3 Expressive navigation rail. See the [module docs](self).
931pub 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
948/// Create a navigation rail over `sections`, with `selected` the current
949/// (app-confirmed) **flat** destination index — counted across every section
950/// in order, exactly as upstream's `onDestinationSelected` reports it.
951///
952/// Fires `on_select(state, index)` on a release inside a destination. A
953/// **controlled** component: `selected` is never mutated here; the app feeds
954/// the confirmed index back in on the next rebuild.
955pub 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/// PascalCase alias for [`navigation_rail`], matching the catalog's container
979/// view-fn vocabulary.
980#[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    /// The rail shape (default [`NavigationRailType::Expanded`], the
991    /// reference's own default).
992    pub fn rail_type(mut self, rail_type: NavigationRailType) -> Self {
993        self.rail_type = rail_type;
994        self
995    }
996
997    /// Standard (in-layout) or modal (over a scrim) presentation — see the
998    /// [module docs](self)' modal section for the mounting contract.
999    pub fn modality(mut self, modality: NavigationRailModality) -> Self {
1000        self.modality = modality;
1001        self
1002    }
1003
1004    /// Attach the rail's built-in FAB slot.
1005    pub fn fab(mut self, fab: RailFab<State>) -> Self {
1006        self.fab = Some(fab);
1007        self
1008    }
1009
1010    /// Collapse to *zero* width instead of 96dp. The reference floats a
1011    /// detached expand button in the root overlay for this case; this port
1012    /// does not (see the [module docs](self)' Not ported), so the app owns its
1013    /// own expand affordance.
1014    pub fn hide_when_collapsed(mut self, hide: bool) -> Self {
1015        self.hide_when_collapsed = hide;
1016        self
1017    }
1018
1019    /// The expanded width, clamped to
1020    /// [`RAIL_EXPANDED_MIN_WIDTH`]..=[`RAIL_EXPANDED_MAX_WIDTH`] (default: the
1021    /// minimum).
1022    pub fn expanded_width(mut self, width: f64) -> Self {
1023        self.expanded_width = Some(width);
1024        self
1025    }
1026
1027    /// Called when the modal scrim is pressed. The rail does **not** collapse
1028    /// itself — it reports the request and the app flips
1029    /// [`Self::rail_type`]/[`Self::modality`].
1030    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    /// Called with the *requested* next type when the menu button is pressed.
1036    /// Without it the menu button still renders and still presses, but nothing
1037    /// observes the request — the same shape a [`mod@crate::checkbox`] with no
1038    /// `on_toggle` takes.
1039    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    /// When a **collapsed** destination shows its label (default
1048    /// [`RailLabelBehavior::AlwaysShow`]).
1049    pub fn label_behavior(mut self, behavior: RailLabelBehavior) -> Self {
1050        self.label_behavior = behavior;
1051        self
1052    }
1053
1054    /// A trailing slot below the destinations (a settings button, an avatar).
1055    pub fn trailing(mut self, trailing: impl View<State>) -> Self {
1056        self.trailing = Some(AnyView::new(trailing));
1057        self
1058    }
1059
1060    /// Whether the trailing slot pins to the rail's bottom edge (default
1061    /// `true`, matching `trailingAtBottom`) or simply follows the last
1062    /// destination.
1063    pub fn trailing_at_bottom(mut self, at_bottom: bool) -> Self {
1064        self.trailing_at_bottom = at_bottom;
1065        self
1066    }
1067
1068    /// Override the container fill (default: the theme's `surface`).
1069    pub fn background(mut self, color: Color) -> Self {
1070        self.background = Some(color);
1071        self
1072    }
1073
1074    /// Every destination, flattened across sections in order — the index space
1075    /// `selected`/`on_select` speak.
1076    fn destinations(&self) -> Vec<&RailDestination<State>> {
1077        self.sections
1078            .iter()
1079            .flat_map(|section| section.destinations.iter())
1080            .collect()
1081    }
1082
1083    /// The rail's resting width for the current props.
1084    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    /// The menu button's view for the current type, or `None` when this type
1097    /// carries no toggle (`_buildMenuButton`'s `_canToggle` guard).
1098    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        // The reference's own tooltip strings, carried as accessible names —
1106        // this port ships no per-item tooltips (see Not ported).
1107        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    /// The FAB slot's view for the current type: extended while expanded, a
1121    /// plain FAB while collapsed (`_buildFab`).
1122    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
1149/// Erase `on_select` into a per-destination callback that always reports
1150/// `index` (the same closed-over-index adapter [`crate::navbar`] carries).
1151fn 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
1159// ---- The destination item --------------------------------------------------
1160
1161/// One destination's retained content — a plain [`Widget`] (not
1162/// `View`/[`AnyView`]-erased, since there is only ever one concrete item type)
1163/// wrapped in a [`ChildPod`] by [`NavigationRailWidget`], the same shape
1164/// [`crate::navbar`]'s items take.
1165struct RailItemWidget {
1166    /// The active icon view's pod (base or selected icon).
1167    icon: ChildPod,
1168    /// The visible label, also the accessible name when no
1169    /// `semantic_label` was given.
1170    label: TextRun,
1171    semantic_label: Option<String>,
1172    /// The badge count, if any (`0` paints a bare dot).
1173    badge_count: Option<u32>,
1174    badge: TextRun,
1175    selected: bool,
1176    /// Whether the rail is currently in its expanded shape.
1177    expanded: bool,
1178    label_behavior: RailLabelBehavior,
1179    /// Whether this item paints its own resting indicator — cleared by the
1180    /// rail while the shared liquid pill is traveling
1181    /// (`useLocalIndicator`).
1182    use_local_indicator: bool,
1183    /// The indicator rect in item-local space: the icon chip while collapsed,
1184    /// the whole row while expanded.
1185    indicator: Rect,
1186    label_origin: Point,
1187    badge_origin: Point,
1188    badge_size: Size,
1189    on_select: ErasedCallback,
1190    /// The pressed visual/gesture state — armed by a `Down`, cleared on
1191    /// `Up`/`Cancel` ([`frust::Button`]'s fire-on-up-inside contract).
1192    captured: bool,
1193}
1194
1195impl RailItemWidget {
1196    /// Whether this item's label paints at all.
1197    fn shows_label(&self) -> bool {
1198        self.expanded || self.label_behavior.shows(self.selected)
1199    }
1200
1201    /// The badge's resolved text, or `None` for a bare dot / no badge.
1202    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
1211/// Build one destination's retained pod.
1212fn 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        // The badge's own box first — an expanded row's label is fitted to
1262        // whatever the badge leaves behind.
1263        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            // The expanded indicator *is* the row.
1310            self.indicator = Rect::new(0.0, 0.0, width, height);
1311            return bc.constrain(Size::new(width, height));
1312        }
1313
1314        // Collapsed: a left-aligned icon chip inside its 48dp tap target, with
1315        // the label (when shown) directly beneath, the pair centered in the
1316        // item's fixed height.
1317        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        // Badge at the chip's top-right corner, the placement
1342        // `crate::icon_button` gives its own badge slot.
1343        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                // A destination advertises itself as clickable; there is no
1425                // press/hover chrome to update (upstream suppresses ink
1426                // outright — see the module docs' Not ported).
1427                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                // A `Cancel` arm only clears internal flags — never state.
1451                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
1471// ---- The rail --------------------------------------------------------------
1472
1473/// One section's resolved position in the rail's flat destination list, plus
1474/// its (expanded-only) header run.
1475struct SectionLayout {
1476    /// Flat index of this section's first destination.
1477    first: usize,
1478    /// How many destinations this section holds.
1479    count: usize,
1480    header: Option<TextRun>,
1481    /// Where the header paints, in rail-local space (set in `layout`).
1482    header_origin: Point,
1483}
1484
1485/// The retained widget for a [`NavigationRailView`]. See the
1486/// [module docs](self).
1487pub struct NavigationRailWidget {
1488    /// Every destination, flattened across sections — each pod wraps a
1489    /// `RailItemWidget`.
1490    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    /// Whether the indicator has ever been measured against a real layout.
1507    ready: bool,
1508    /// A selection change waiting for the next layout to resolve into a travel.
1509    pending_travel: bool,
1510    /// Cross-axis center / cross size / resting main extent of the selected
1511    /// destination's indicator, from the last layout.
1512    indicator_cross: (f64, f64),
1513    indicator_extent: f64,
1514    /// Whether the liquid pill is mid-travel (the rail paints it and every
1515    /// item's own resting fill stays off).
1516    traveling: bool,
1517
1518    /// The rail panel's laid-out size (the animated width × the full height).
1519    panel: Size,
1520    /// The whole area the widget was given — equal to [`Self::panel`] outside
1521    /// modal presentation.
1522    area: Size,
1523    /// Whether a scrim press is armed (fire-on-up-inside, like every other
1524    /// press in this catalog).
1525    scrim_armed: bool,
1526}
1527
1528impl NavigationRailWidget {
1529    /// Whether the modal scrim paints at all this frame — it fades in and out
1530    /// on the width ramp, so it outlives the expansion by exactly one
1531    /// transition.
1532    fn scrim_visible(&self) -> bool {
1533        self.modal && self.expansion() > 0.0
1534    }
1535
1536    /// Whether the scrim currently *blocks* input. Gated on the expansion prop
1537    /// rather than on the fading paint, matching upstream's
1538    /// `IgnorePointer(ignoring: !_isExpanded)`: a scrim on its way out stops
1539    /// swallowing presses immediately, it does not hold the page inert for the
1540    /// length of its own fade.
1541    fn barrier_active(&self) -> bool {
1542        self.modal && self.expanded
1543    }
1544
1545    /// How far the rail currently reads as expanded, `0..=1` — derived from
1546    /// the animated width so the scrim fades on exactly the width ramp
1547    /// (upstream's `AnimatedContainer` scrim alpha, on the same duration and
1548    /// curve).
1549    fn expansion(&self) -> f64 {
1550        // The ramp's own two endpoints, whichever direction it runs.
1551        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    /// The selected destination's indicator rect in rail-local space, if the
1561    /// last layout produced one.
1562    ///
1563    /// `&mut self` because [`ChildPod`]'s downcast seam is mutable-only
1564    /// (`dyn Widget::downcast_mut`); nothing here mutates.
1565    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    /// Resolve the liquid indicator against the layout that just ran: a first
1578    /// measure and a width transition **jump**, a selection change travels.
1579    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    /// The liquid pill's rect for the current lead/trail positions — the
1601    /// reference's `_buildPill` math on the vertical axis.
1602    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    /// Route to whichever slot owns the event, in a fixed order so a captured
1617    /// gesture in a slot pod always wins over a hit test elsewhere.
1618    fn route_slots(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1619        if event.is_broadcast() {
1620            // A broadcast reaches every child unconditionally, ahead of every
1621            // other branch, and is never consumed.
1622            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 &section.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            // Every destination picks the new behavior up in the per-item pass
1714            // below; a collapsed label appearing or leaving is a size change.
1715            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        // ---- Destinations ---------------------------------------------------
1742        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            // Closures aren't comparable — always reinstall the adapter.
1777            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        // ---- Sections -------------------------------------------------------
1818        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        // ---- Slots ----------------------------------------------------------
1844        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        // The menu button's glyph flips with the type, which `rebuild_child`
1848        // cannot see through a rebuilt icon view of the same shape.
1849        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
1896/// Build/rebuild/tear down an optional slot pod against an optional view — the
1897/// four-way presence match every slot in this module takes.
1898fn 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            // Laid out **loose** so the FAB keeps its own token size: upstream's
1953            // `ListView` hands its children tight cross-axis constraints, which
1954            // stretches an `M3EFab`'s fixed square across the rail; a rail FAB
1955            // is a fixed 56dp square (or a pill when extended) here, aligned to
1956            // the leading edge while expanded and centered while collapsed.
1957            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        // Modal presentation fills the area it was given (the scrim covers
2013        // everything the panel does not); standard presentation is exactly its
2014        // own panel.
2015        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        // The rail's width is a layout value, so an in-flight ramp needs an
2031        // explicit relayout request, not merely another frame (see the module
2032        // docs' layout-skip section). A reduce-motion snap that actually moved
2033        // the width needs exactly one.
2034        let width_changed = if reduce_motion {
2035            self.width_motion.snap()
2036        } else {
2037            let before = self.width_motion.value;
2038            // Either arm asks: still in flight (the next frame moves it again),
2039            // or the value moved this frame — the *settling* frame is the one
2040            // that both stops animating and lands on the target, so gating on
2041            // the animating flag alone would leave the rail a fraction of a
2042            // pixel short of its resting width until some later relayout.
2043            self.width_motion.advance(now) || self.width_motion.value != before
2044        };
2045        if width_changed {
2046            ctx.request_layout();
2047        }
2048
2049        // The liquid pill is paint-only — the items it travels between do not
2050        // move — so it asks for a frame, never a relayout.
2051        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        // Top-to-bottom, the order `visit_children!` and `semantics` both
2084        // report: the header slots, the destinations (with their section
2085        // headers), then the trailing slot.
2086        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) = &section.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        // A broadcast is not user input: it reaches every child ahead of every
2119        // other branch and is never consumed.
2120        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            // Inside the panel: its own content owns the event, and whatever
2131            // the content declines is swallowed rather than falling through —
2132            // a modal rail blocks the page behind it, the same barrier
2133            // contract `crate::overlay`'s modal host holds.
2134            self.route_slots(ctx, event);
2135            return EventResult::Handled;
2136        }
2137        // On the scrim: the barrier swallows every pointer whatever button
2138        // pressed it, but only the primary one arms a dismiss (the catalog's
2139        // press rule).
2140        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                // A `Cancel` arm only clears internal flags — never state.
2160                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    /// Paint at `t_ms` with a real clock, returning `(recorder, needs_layout)`.
2258    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    /// Press and release inside `pos`, the catalog's fire-on-up-inside path.
2297    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    // ---- Geometry ---------------------------------------------------------
2303
2304    #[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        // Adjacent destinations are separated by the gap on both their edges.
2322        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        // The menu button sits above the first destination, under the top gap.
2329        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        // The collapsed indicator is the icon chip, not the whole item.
2337        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        // Icon then label, both inside the row's leading inset.
2362        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    // ---- The width transition ---------------------------------------------
2418
2419    #[test]
2420    fn a_type_change_ramps_the_width_and_paint_requests_layout_every_frame() {
2421        // The regression guard for the layout-skip trap: the rail's width is
2422        // computed in `layout` from a value advanced in `paint`, so every
2423        // in-flight frame must ask for a relayout — a paint-only frame request
2424        // would freeze the rail mid-ramp under the mobile intra-frame layout
2425        // skip.
2426        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            // A real shell relayouts on seeing `needs_layout`.
2446            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    // ---- Controlled semantics ---------------------------------------------
2496
2497    #[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        // The third destination lives in the *second* section: the reported
2513        // index is flat across sections, like upstream's own.
2514        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    // ---- Modal presentation -----------------------------------------------
2568
2569    #[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        // A standard rail paints no scrim at all.
2597        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        // A press the platform steals fires nothing.
2626        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        // The panel's own empty space is part of the barrier too: it takes the
2631        // press without dismissing, rather than letting the page behind it act.
2632        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    // ---- Sections, FAB, labels --------------------------------------------
2662
2663    #[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        // `extended_fab` grows its label in over its own `MEDIUM_2` ramp on
2741        // mount (that module's documented v1 mount quirk), so drive frames
2742        // before measuring the settled pill.
2743        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    // ---- The liquid selection indicator ------------------------------------
2806
2807    #[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        // The lead edge is the less damped of the two — that is the stretch.
2815        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        // Regression for the near-zero relaunch bug: before the fix,
2822        // `travel_to` rebuilt a fresh `AnimationController::fling` at a fixed
2823        // near-zero launch velocity on every retarget, discarding whatever
2824        // rate the interrupted leg was actually moving at — a fast
2825        // double-tap between destinations would visibly pause/kink instead
2826        // of reversing smoothly.
2827        let mut axis = TravelAxis::new();
2828        axis.travel_to(200.0, RAIL_LEAD_SPRING);
2829
2830        // A few real frames into the first leg, the edge has built up real
2831        // (well above near-zero) velocity toward its target.
2832        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        // Interrupt with a fresh target before the first leg settles.
2845        axis.travel_to(0.0, RAIL_LEAD_SPRING);
2846
2847        // The freshly solved spring's velocity at its own t=0 is exactly its
2848        // initial condition `v0` — asserting it equals the pre-retarget
2849        // reading is the direct "carried, not reset" check.
2850        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        // And the first *moving* post-retarget frame's displacement is
2862        // consistent with that carried rate, not a near-zero relaunch (a
2863        // 16ms frame at the near-zero pre-fix launch velocity of 1e-3 would
2864        // move a vanishing fraction of a pixel). `advance`'s own contract
2865        // seeds its delta clock on the first call after a motion starts
2866        // (zero delta, per `AnimationController::advance`'s doc, which
2867        // `TravelAxis::travel_to` mirrors by clearing `last_time`) — so the
2868        // seed frame is expected to read the same position, and the frame
2869        // after it is where the carried rate actually shows.
2870        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    // ---- Reconciliation ----------------------------------------------------
2978
2979    #[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    // ---- Semantics ----------------------------------------------------------
3030
3031    #[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}