Skip to main content

frust_material/
navbar.rs

1//! The Material 3 Expressive bottom `NavigationBar`: two container heights,
2//! three label behaviors, three selection-indicator styles, per-item badges,
3//! and the **liquid selection indicator** — a two-spring pill that stretches
4//! into a bridge between the outgoing and incoming destination and then
5//! settles onto the new one.
6//!
7//! Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
8//! `lib/components/navigation_bar/` (`m3e_navigation_bar.dart`,
9//! `enums/m3e_nav_bar_enums.dart`, `models/m3e_nav_metrics.dart`,
10//! `models/m3e_navigation_bar_destination.dart`,
11//! `styles/m3e_navigation_bar_theme.dart`,
12//! `components/m3e_nav_bar_destination_button.dart`,
13//! `components/m3e_nav_badge_view.dart`) plus the two motion pieces the bar
14//! shares with the rail (`lib/components/navigation_rail/components/
15//! m3e_nav_selection_indicator.dart` and `m3e_nav_icon_scale.dart`), retrieved
16//! 2026-08-20. Upstream: <https://github.com/paadevelopments/material_3_expressive>
17//!
18//! # Controlled selection
19//!
20//! `NavigationBarView`/`NavigationBarWidget` follow the widget-authoring
21//! recipe: a **controlled** component — [`NavigationBarView`] reports the
22//! *requested* selection through `on_select(index)` and never self-mutates; the
23//! app feeds the confirmed `selected` index back in on the next rebuild,
24//! exactly like [`frust::Checkbox`]/[`frust::Slider`].
25//!
26//! Each item is a small, self-contained retained widget
27//! (`NavItemWidget`, **not** built through `View`/[`frust::authoring::AnyView`]
28//! — there is only ever one concrete item type, so no type erasure is
29//! needed) wrapped in its own [`frust::authoring::ChildPod`] purely so the bar
30//! can route pointer/semantics through the same
31//! [`frust::authoring::route_event`]/[`frust::authoring::ChildPod::semantics_child`]
32//! helpers every multi-child container uses. Each item lays out an optional
33//! caller-supplied `icon` (an opaque `AnyView` — tint is the caller's own
34//! responsibility, mirroring [`super::appbar`]'s leading/actions slots) inside
35//! a fixed [`INDICATOR_W`]×[`INDICATOR_H`] indicator box, optionally above a
36//! label (a real child [`frust::text`]), vertically centred as a group inside
37//! the bar's height; it fires `on_select` on release-inside, fire-on-up-inside
38//! like [`frust::Button`].
39//!
40//! # The liquid selection indicator
41//!
42//! The pill is owned by the **bar**, not by the item — the crate-private
43//! `LiquidIndicator` block below carries the two-spring stretch/settle
44//! mechanism, its transcribed spring constants ([`LEAD_SPRING`]/
45//! [`TRAIL_SPRING`]), and the two-path (travel vs. jump) sync rule; that block
46//! is the tier's reference implementation, cited by the rail and drawer rather
47//! than re-derived. Items paint only their icon and label; every indicator style
48//! ([`NavBarIndicatorStyle`]) is painted by the bar, under the items.
49//!
50//! # Porting decisions
51//!
52//! * **One pill, not an overlay plus a resting copy.** The reference stacks a
53//!   shared morph overlay over the destination row and hands the *resting*
54//!   fill back to each destination (`showRestingPill`/`onTravelingChanged`),
55//!   because its overlay is measured asynchronously through `GlobalKey`s a
56//!   frame after layout. A retained widget has no such gap: the bar reads the
57//!   selected item's own indicator box in the same layout pass and paints one
58//!   pill that *is* the resting box while settled, so the handoff, the
59//!   traveling flag, and the deferred `setState` machinery around it have no
60//!   analogue here.
61//! * **No ink splash**, matching the reference (`M3ENavBarDestinationButton`'s
62//!   own note): selection feedback is the pill, so an item runs no
63//!   [`super::state_layer`]/[`crate::interaction`] overlay and repaints on
64//!   selection, never on press.
65//! * **Default size is [`NavBarSize::Medium`]** (80dp), matching the reference
66//!   default. [`NavBarSize::Small`] (64dp) — androidx `NavigationBarTokens`'
67//!   current Expressive container height and the height this widget has
68//!   shipped — is still reachable via `.size(NavBarSize::Small)`.
69//! * **A selected label keeps the `on_surface` themed role.** The reference
70//!   paints selected content `onSecondaryContainer`; [`ThemeTextColor`] has no
71//!   such role, and a themed text color resolves from a *role* after `build`
72//!   (there is no `Theme` to read at build time), so the closest shipped role
73//!   stays. Unselected labels are `on_surface_variant` as upstream.
74//! * **Unported upstream props**: `density`, `shapeFamily`, `elevation`,
75//!   `padding`, and `selectedIcon` — container/decoration knobs with no axis
76//!   in this port's scope. `safeArea` **is** ported: the bar consumes the
77//!   bottom window inset itself by default, in its own `layout`
78//!   (`docs/CODE_STANDARDS.md`'s self-sizing-chrome rule), and paints its
79//!   `surface_container` fill through the consumed band; `.safe_area(false)`
80//!   opts out for a bar that isn't docked to the window's bottom edge. Do not
81//!   additionally wrap the bar in `frust::safe_area(..)` for the bottom edge:
82//!   a `SafeArea` removes what it consumes from its subtree, so this never
83//!   double-insets, but the safe area's own padding sits outside the bar's
84//!   box and is left unpainted instead of matching the bar's container color.
85//!
86//! # Semantics
87//!
88//! The bar is one [`Role::TabList`] container node; each item contributes a
89//! [`Role::Tab`] node labelled with its text and carrying
90//! [`frust::authoring::Node::set_selected`] — the closest accesskit vocabulary to
91//! "one of a set of mutually-exclusive destinations", chosen over
92//! `Role::RadioGroup`/`Role::Toggled` since a bottom nav bar reads to a
93//! screen reader exactly like a tab strip. The label rides the node even when
94//! [`NavBarLabelBehavior::AlwaysHide`] paints no text.
95
96use std::rc::Rc;
97use std::time::Duration;
98
99use frust::authoring::Role;
100use frust::authoring::text::{FontWeight, LineHeight};
101use frust::authoring::{
102    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
103    LayoutCtx, PaintCtx, PaintScene, PointerButton, PointerEvent, PointerPhase, SemanticsCtx, View,
104    Widget,
105};
106use frust::{AnimationController, FrameTime, Spring, SpringDesc, Theme, text};
107use kurbo::{Affine, Point, Rect, Size, Vec2};
108use peniko::Color;
109
110use frust::authoring::{ThemeTextColor, ThemeTextType};
111
112use super::press::presses;
113use crate::tokens::MaterialSpring;
114
115/// Compact container height, in logical px
116/// (`M3ENavigationBarTheme.heightSmall`; equal to androidx
117/// `NavigationBarTokens`' current Expressive container height).
118pub const HEIGHT_SMALL: f64 = 64.0;
119/// Standard container height, in logical px
120/// (`M3ENavigationBarTheme.heightMedium`).
121pub const HEIGHT_MEDIUM: f64 = 80.0;
122/// Selection indicator box width, in logical px
123/// (`_M3ENavigationBarState._indicatorWidth`). The androidx "active indicator"
124/// token is 56dp; the reference's own 64dp is what this port carries, since
125/// every other metric here comes from the same table.
126pub const INDICATOR_W: f64 = 64.0;
127/// Selection indicator box height, in logical px
128/// (`_M3ENavigationBarState._indicatorHeight`).
129pub const INDICATOR_H: f64 = 32.0;
130/// Underline thickness for [`NavBarIndicatorStyle::Underline`], in logical px
131/// (`M3ENavigationBarTheme.indicatorThickness`).
132pub const UNDERLINE_THICKNESS: f64 = 3.0;
133/// Gap between the indicator box and the label below it, in logical px
134/// (`m3e_nav_bar_destination_button.dart`'s `SizedBox(height: 4)`).
135const LABEL_GAP: f64 = 4.0;
136
137/// The label's M3 `labelMediumEmphasized` type-scale token (see
138/// [`super::appbar`]'s `TITLE_SIZE` doc comment for why this is a hardcoded
139/// constant rather than a live `Theme::type_scale` read — `Text` defers *color*
140/// and an opt-in family past `View::build`, never size/weight). Matches
141/// `frust-theme::typography`'s `LABEL_MEDIUM_EMPHASIZED` token: same
142/// size/line-height/letter-spacing as the baseline `LABEL_MEDIUM`, weight
143/// stepped up from Medium to Bold.
144const LABEL_SIZE: f32 = 12.0;
145const LABEL_LINE_HEIGHT: f32 = 16.0;
146const LABEL_LETTER_SPACING: f32 = 0.5;
147const LABEL_WEIGHT: FontWeight = FontWeight::BOLD;
148
149/// Unthemed fallback container fill (a theme resolves this from
150/// `colors.surface_container`).
151const CONTAINER: Color = Color::from_rgb8(0xF3, 0xED, 0xF7);
152/// Unthemed fallback indicator fill (a theme resolves this from
153/// `colors.secondary_container`).
154const INDICATOR: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
155
156// ---------------------------------------------------------------------------
157// Liquid selection indicator (the port core)
158// ---------------------------------------------------------------------------
159//
160// `m3e_nav_selection_indicator.dart`'s `M3ENavSelectionIndicator`, transcribed.
161// The rail and drawer ports reuse this block rather than re-deriving it.
162
163/// Leading-edge spring: the reference's
164/// `MaterialSpringMotion.expressiveSpatialDefault().copyWith(damping: 0.45)`
165/// (`_M3ENavSelectionIndicatorState._leadMotion`). Stiffness comes from the
166/// shared [`MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT`] token (380); only the
167/// damping ratio is overridden, and motor's `damping` is a damping *ratio* —
168/// the same ζ [`SpringDesc::damping_ratio`] takes, so it carries over with no
169/// conversion. Lower ζ than the trail: this edge runs ahead.
170pub const LEAD_SPRING: SpringDesc = SpringDesc {
171    mass: 1.0,
172    stiffness: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.stiffness,
173    damping_ratio: 0.45,
174};
175/// Trailing-edge spring: the same preset at `damping: 0.55`
176/// (`_M3ENavSelectionIndicatorState._trailMotion`). Higher ζ than the lead, so
177/// this edge lags — the gap between the two *is* the stretch.
178pub const TRAIL_SPRING: SpringDesc = SpringDesc {
179    mass: 1.0,
180    stiffness: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.stiffness,
181    damping_ratio: 0.55,
182};
183/// Rest threshold for a travelling edge, in logical px (and px/s for its
184/// velocity) — both are checked, per `Spring::is_at_rest`'s own contract. The
185/// reference leans on motor's default simulation tolerance (1e-3) for the same
186/// job; this unit is a *logical pixel*, so a hundredth of one is already an
187/// order of magnitude below anything a display can resolve, and stopping there
188/// saves the long analytic tail of frames that changes nothing on screen.
189const PILL_REST_EPSILON: f64 = 0.01;
190/// Movement below this (logical px) doesn't count as a geometry change — the
191/// reference's own 0.5px `_isAtGeometry` test
192/// (`_M3ENavSelectionIndicatorState`). Gates two things: a travel retarget
193/// within this of the live lead position is skipped (a re-selection of the
194/// already-selected destination, or a rounding-scale slot shift), and
195/// [`geometry_unchanged`] uses it to tell a genuine resize/add/remove apart
196/// from a same-geometry relayout in [`LiquidIndicator::sync`]'s jump branch.
197const GEOMETRY_EPSILON: f64 = 0.5;
198
199/// Whether `a` and `b` are the same resting rect within [`GEOMETRY_EPSILON`]
200/// on every edge — the "did this relayout actually move anything" test
201/// gating [`LiquidIndicator::sync`]'s non-travel jump.
202fn geometry_unchanged(a: Rect, b: Rect) -> bool {
203    (a.x0 - b.x0).abs() < GEOMETRY_EPSILON
204        && (a.x1 - b.x1).abs() < GEOMETRY_EPSILON
205        && (a.y0 - b.y0).abs() < GEOMETRY_EPSILON
206        && (a.y1 - b.y1).abs() < GEOMETRY_EPSILON
207}
208
209/// One spring-driven edge of the liquid pill, in container-local **main-axis**
210/// px — a bare scalar, so it carries over to a vertical consumer unchanged.
211///
212/// A [`Spring`] solves *displacement from equilibrium*, so the live position is
213/// `target + spring.position(elapsed)`. Retargeting mid-flight re-solves from
214/// the current position **and velocity**, which is what motor's
215/// `SingleMotionController.animateTo` does and what keeps a fast double-tap
216/// between destinations from snapping.
217#[derive(Clone, Copy, Debug)]
218pub(crate) struct LiquidEdge {
219    desc: SpringDesc,
220    /// Equilibrium this edge is settling toward.
221    target: f64,
222    /// The in-flight solution, or `None` once settled.
223    flight: Option<Spring>,
224    /// Seconds since [`Self::flight`] was solved.
225    elapsed: f64,
226    position: f64,
227    velocity: f64,
228}
229
230impl LiquidEdge {
231    pub(crate) const fn new(desc: SpringDesc) -> Self {
232        LiquidEdge {
233            desc,
234            target: 0.0,
235            flight: None,
236            elapsed: 0.0,
237            position: 0.0,
238            velocity: 0.0,
239        }
240    }
241
242    /// Jump to `to` with no motion (`_jumpToGeometry`).
243    pub(crate) fn snap(&mut self, to: f64) {
244        self.target = to;
245        self.position = to;
246        self.velocity = 0.0;
247        self.flight = None;
248        self.elapsed = 0.0;
249    }
250
251    /// Start (or re-aim) a travel toward `to`, continuing from the current
252    /// position and velocity (`_animateToGeometry`).
253    pub(crate) fn retarget(&mut self, to: f64) {
254        self.flight = Some(Spring::new(self.desc, self.position - to, self.velocity));
255        self.target = to;
256        self.elapsed = 0.0;
257    }
258
259    /// Advance `dt` seconds, returning whether the edge is still travelling.
260    pub(crate) fn advance(&mut self, dt: f64) -> bool {
261        let Some(spring) = self.flight else {
262            return false;
263        };
264        self.elapsed += dt;
265        if spring.is_at_rest(self.elapsed, PILL_REST_EPSILON) {
266            self.settle();
267            return false;
268        }
269        self.position = self.target + spring.position(self.elapsed);
270        self.velocity = spring.velocity(self.elapsed);
271        true
272    }
273
274    /// Land on the target immediately, dropping any in-flight solution.
275    pub(crate) fn settle(&mut self) {
276        self.position = self.target;
277        self.velocity = 0.0;
278        self.flight = None;
279        self.elapsed = 0.0;
280    }
281
282    pub(crate) fn is_animating(&self) -> bool {
283        self.flight.is_some()
284    }
285}
286
287/// The bar's liquid selection indicator: two independently-sprung edge centres
288/// whose span is the painted pill.
289///
290/// # The two-phase motion
291///
292/// Both edges chase the *same* target — the newly selected item's indicator-box
293/// centre — but through different springs ([`LEAD_SPRING`] ζ 0.45,
294/// [`TRAIL_SPRING`] ζ 0.55). So:
295///
296/// 1. **Stretch.** The lead edge accelerates away first and the trail lags, so
297///    `|lead − trail|` grows: the pill's main extent is
298///    `|lead − trail| + rest_width` (`_buildPill`), a bridge spanning from the
299///    outgoing destination toward the incoming one.
300/// 2. **Settle.** The trail catches the lead up; the gap collapses, the extent
301///    returns to `rest_width`, and the pill lands centred on the new
302///    destination. Both edges are then at rest and no further frame is asked
303///    for.
304///
305/// The painted rect is always `[min(lead, trail) − rest_width/2, max(lead,
306/// trail) + rest_width/2]` on the main axis and the selected item's own box on
307/// the cross axis, with a corner radius of `min(cross, rest_width)/2` — the
308/// reference's `_buildPill` verbatim, which is why a settled pill *is* exactly
309/// the resting box.
310///
311/// # Two sync paths
312///
313/// [`Self::sync`] takes the same fork the reference's `_sync(forceJump:)` does:
314/// a **selection change** animates (`didUpdateWidget`'s `selectedIndex` arm),
315/// while anything else that *moves the geometry* — a width change, an item
316/// added/removed, first layout, reduced motion — **jumps**
317/// (`_trackLayoutChange`/`_scheduleMeasure(forceJump: true)`). Measuring is
318/// synchronous here, so the reference's retry/suspicious-jump guards
319/// (`_measureAttempts`, `_isSuspiciousJump`) have no analogue: there is no
320/// frame in which the geometry is not yet knowable.
321///
322/// A non-travel call whose `rest` reproduces the last-synced one (within
323/// [`GEOMETRY_EPSILON`]) is a **no-op**, matching the reference's own
324/// `_isAtGeometry` guard (`m3e_nav_selection_indicator.dart`). `layout` runs
325/// on every full-tree layout pass, not just the one where the selection
326/// actually moved — an animating sibling's `request_layout`, or any other
327/// app-side relayout, re-enters [`Self::sync`] with `travel == false` while a
328/// previously-started travel is still mid-flight. Since the resting rect
329/// hasn't actually changed, this must leave the in-flight travel (or a
330/// settled pill) exactly as it was — only a call that reproduces a *genuinely
331/// different* resting rect jumps.
332///
333/// # Reuse across the nav family
334///
335/// This type and [`LiquidEdge`] are crate-visible on purpose: the reference
336/// shares one `M3ENavSelectionIndicator` between the bar, the rail, and the
337/// drawer, and so does this port. Everything above is main-axis scalar work
338/// and carries over unchanged; the **only** horizontal-specific piece is the
339/// main↔`x` / cross↔`y` mapping inside [`Self::sync`] and [`Self::pill_rect`],
340/// which a vertical consumer swaps (the same primary-axis split
341/// [`crate::slider`]'s core documents).
342#[derive(Clone, Copy, Debug)]
343pub(crate) struct LiquidIndicator {
344    lead: LiquidEdge,
345    trail: LiquidEdge,
346    /// Clock for both edges; `None` re-seeds the delta on the next advance.
347    last_time: Option<FrameTime>,
348    /// The selected item's resting indicator box, in bar-local px.
349    rest: Rect,
350    /// Whether [`Self::rest`] has ever been resolved (pre-first-layout, the
351    /// indicator paints nothing).
352    ready: bool,
353}
354
355impl LiquidIndicator {
356    pub(crate) const fn new() -> Self {
357        LiquidIndicator {
358            lead: LiquidEdge::new(LEAD_SPRING),
359            trail: LiquidEdge::new(TRAIL_SPRING),
360            last_time: None,
361            rest: Rect::ZERO,
362            ready: false,
363        }
364    }
365
366    /// Adopt the selected item's resting box. `travel` runs the two-phase
367    /// motion; otherwise the pill jumps to a *genuine* geometry change and is
368    /// a no-op against a reproduction of the last-synced one (see the type
369    /// docs' two sync paths).
370    pub(crate) fn sync(&mut self, rest: Rect, travel: bool) {
371        let center = rest.center().x;
372        if !self.ready {
373            self.rest = rest;
374            self.lead.snap(center);
375            self.trail.snap(center);
376            self.last_time = None;
377            self.ready = true;
378            return;
379        }
380        if travel {
381            self.rest = rest;
382            if (self.lead.target - center).abs() < GEOMETRY_EPSILON && !self.is_animating() {
383                return;
384            }
385            self.lead.retarget(center);
386            self.trail.retarget(center);
387            self.last_time = None;
388            return;
389        }
390        // Not a selection-driven travel. A relayout that reproduces the
391        // resting rect already synced (an unrelated widget's animating
392        // frame, any other app-side LAYOUT flag) touches nothing — snapping
393        // here would cancel an in-flight travel mid-stretch. Only a rect
394        // that actually differs — a resize, an item add/remove — jumps.
395        if geometry_unchanged(self.rest, rest) {
396            return;
397        }
398        self.rest = rest;
399        self.lead.snap(center);
400        self.trail.snap(center);
401        self.last_time = None;
402    }
403
404    /// Advance both edges to frame time `now`, returning whether the pill is
405    /// still travelling (i.e. whether the caller must ask for another frame).
406    pub(crate) fn advance(&mut self, now: FrameTime) -> bool {
407        let dt = match self.last_time {
408            Some(last) => now.saturating_sub(last).as_secs_f64(),
409            None => 0.0,
410        };
411        self.last_time = Some(now);
412        let lead = self.lead.advance(dt);
413        let trail = self.trail.advance(dt);
414        lead || trail
415    }
416
417    /// Land the pill on its target immediately (reduced motion).
418    pub(crate) fn settle(&mut self) {
419        self.lead.settle();
420        self.trail.settle();
421    }
422
423    pub(crate) fn is_animating(&self) -> bool {
424        self.lead.is_animating() || self.trail.is_animating()
425    }
426
427    /// The painted pill in bar-local px, or `None` before first layout.
428    pub(crate) fn pill_rect(&self) -> Option<Rect> {
429        if !self.ready {
430            return None;
431        }
432        let base = self.rest.width();
433        let min = self.lead.position.min(self.trail.position);
434        let max = self.lead.position.max(self.trail.position);
435        let x0 = min - base / 2.0;
436        Some(Rect::new(
437            x0,
438            self.rest.y0,
439            x0 + (max - min) + base,
440            self.rest.y1,
441        ))
442    }
443
444    /// Corner radius of the pill: `math.min(_crossSize, _baseMain) / 2`, held
445    /// at the *resting* size so a stretched bridge keeps stadium ends.
446    pub(crate) fn radius(&self) -> f64 {
447        self.rest.height().min(self.rest.width()) / 2.0
448    }
449}
450
451// ---------------------------------------------------------------------------
452// Icon selection pop (`m3e_nav_icon_scale.dart`)
453// ---------------------------------------------------------------------------
454
455/// The newly-selected icon's pop spring: the reference's
456/// `expressiveSpatialDefault().copyWith(damping: 0.5)`
457/// (`_M3ENavIconScaleState._motion`).
458const ICON_POP_SPRING: SpringDesc = SpringDesc {
459    mass: 1.0,
460    stiffness: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.stiffness,
461    damping_ratio: 0.5,
462};
463/// Scale the pop starts from (`_M3ENavIconScaleState.didUpdateWidget`'s
464/// `..value = 0.98`, then `animateTo(1)`): the spring's own overshoot past
465/// `1.0` is the pop — it never dips below the resting scale first.
466const ICON_POP_FROM: f64 = 0.98;
467/// Launch velocity for a pop. Sign-only, per the convention
468/// [`crate::expandable_list`]'s `REVEAL_LAUNCH_VELOCITY` documents: the
469/// spring's shape comes from its displacement, and the leg always runs
470/// `0 → 1` in progress space.
471const ICON_POP_LAUNCH_VELOCITY: f64 = 1e-3;
472/// Scale deviation below which the pop transform is skipped entirely.
473const ICON_POP_EPSILON: f64 = 1e-4;
474
475/// A pop controller parked at its settled value (scale `1.0`) — the state
476/// every item builds in and returns to.
477fn settled_pop() -> AnimationController {
478    let mut pop = AnimationController::new(Duration::ZERO);
479    pop.animate_to(1.0);
480    pop.advance(FrameTime::ZERO); // zero-duration: settles instantly.
481    pop
482}
483
484/// A fresh pop controller launched from [`ICON_POP_FROM`].
485fn launched_pop() -> AnimationController {
486    let mut pop = AnimationController::new(Duration::ZERO);
487    pop.fling(ICON_POP_LAUNCH_VELOCITY, ICON_POP_SPRING);
488    pop
489}
490
491// ---------------------------------------------------------------------------
492// Public props
493// ---------------------------------------------------------------------------
494
495/// The bar's container height variant (`M3ENavBarSize`).
496#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
497pub enum NavBarSize {
498    /// [`HEIGHT_SMALL`] (64dp) — androidx `NavigationBarTokens`' Expressive container height,
499    /// reachable via `.size(NavBarSize::Small)`; see the [module docs](self)' porting decisions.
500    Small,
501    /// [`HEIGHT_MEDIUM`] (80dp), the reference's own default — this port's default.
502    #[default]
503    Medium,
504}
505
506impl NavBarSize {
507    /// This variant's container height, in logical px.
508    pub fn height(self) -> f64 {
509        match self {
510            NavBarSize::Small => HEIGHT_SMALL,
511            NavBarSize::Medium => HEIGHT_MEDIUM,
512        }
513    }
514}
515
516/// When destination labels are painted (`M3ENavBarLabelBehavior`).
517#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
518pub enum NavBarLabelBehavior {
519    /// Every destination shows its label (`alwaysShow`).
520    #[default]
521    AlwaysShow,
522    /// Only the selected destination shows its label (`onlySelected`).
523    OnlySelected,
524    /// No destination shows a label (`alwaysHide`); the text still rides the
525    /// item's semantics node.
526    AlwaysHide,
527}
528
529/// The selection indicator's visual style (`M3ENavBarIndicatorStyle`).
530#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
531pub enum NavBarIndicatorStyle {
532    /// The liquid stadium pill behind the icon (`pill`) — the only style that
533    /// animates; see the [module docs](self)' liquid-indicator section.
534    #[default]
535    Pill,
536    /// A [`UNDERLINE_THICKNESS`]-thick rule along the bottom edge of the
537    /// selected destination's indicator box (`underline`).
538    Underline,
539    /// No indicator at all (`none`).
540    None,
541}
542
543/// A destination's badge (`M3ENavigationBarDestination.badgeDot`/`badgeCount`),
544/// composed onto the item's icon by [`navigation_bar`] through
545/// [`crate::badge`] — the same delegation `M3ENavBadge` makes to `M3EBadge`.
546#[derive(Clone, Copy, Debug, PartialEq, Eq)]
547enum NavBadge {
548    /// An unlabeled dot (`badgeDot: true`).
549    Dot,
550    /// A count, formatted against [`crate::BadgeView::max_count`]'s default
551    /// (`badgeCount`).
552    Count(u32),
553}
554
555/// The resolved `(container, indicator)` fills. Themed:
556/// `surface_container`/`secondary_container`
557/// (`M3ENavigationBarTheme.containerColor`/`indicatorColor`). Unthemed: the
558/// [`CONTAINER`]/[`INDICATOR`] constants exactly.
559fn resolve_colors(theme: Option<&Theme>) -> (Color, Color) {
560    match theme {
561        Some(theme) => {
562            let scheme = theme.scheme();
563            (scheme.surface_container, scheme.secondary_container)
564        }
565        None => (CONTAINER, INDICATOR),
566    }
567}
568
569/// Build a label's type-erased child view at `labelMediumEmphasized`, tagged
570/// with the themed color `role` selection determines, its font family
571/// following the live theme's `labelMediumEmphasized` role.
572fn label_view<State: 'static>(label: String, role: ThemeTextColor) -> AnyView<State> {
573    frust::authoring::any::<State, _>(
574        text(label)
575            .size(LABEL_SIZE)
576            .weight(LABEL_WEIGHT)
577            .letter_spacing(LABEL_LETTER_SPACING)
578            .line_height(LineHeight::Absolute(LABEL_LINE_HEIGHT))
579            .themed_role(role)
580            .themed_family(ThemeTextType::LabelMediumEmphasized),
581    )
582}
583
584/// The themed color role a destination's label takes
585/// (`M3ENavigationBarTheme.selectedColor`/`unselectedColor`, see the [module
586/// docs](self)' note on the selected role).
587fn label_role(selected: bool) -> ThemeTextColor {
588    if selected {
589        ThemeTextColor::OnSurface
590    } else {
591        ThemeTextColor::OnSurfaceVariant
592    }
593}
594
595/// Whether a destination paints its label
596/// (`M3ENavBarDestinationButton._showLabel`).
597fn label_shown(behavior: NavBarLabelBehavior, selected: bool) -> bool {
598    match behavior {
599        NavBarLabelBehavior::AlwaysShow => true,
600        NavBarLabelBehavior::OnlySelected => selected,
601        NavBarLabelBehavior::AlwaysHide => false,
602    }
603}
604
605/// Whether a widget-local `pos` lies within a `size`-sized box anchored at the
606/// origin (mirrors [`frust::button`]'s helper of the same shape).
607fn inside(pos: Point, size: Size) -> bool {
608    pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
609}
610
611/// One destination's declarative content: an optional leading icon (an
612/// opaque [`AnyView`] — tint is the caller's own responsibility), a text
613/// label, and an optional badge.
614pub struct NavItem<State: 'static> {
615    icon: Option<AnyView<State>>,
616    label: String,
617    badge: Option<NavBadge>,
618}
619
620/// Create a nav item labelled `label`, with no icon (attach one with
621/// [`NavItem::icon`]).
622pub fn nav_item<State: 'static>(label: impl Into<String>) -> NavItem<State> {
623    NavItem {
624        icon: None,
625        label: label.into(),
626        badge: None,
627    }
628}
629
630impl<State: 'static> NavItem<State> {
631    /// Attach a leading icon, erased as an [`AnyView`]. Tint is the supplied
632    /// view's own responsibility — see the [module docs](self).
633    pub fn icon(mut self, icon: impl View<State>) -> Self {
634        self.icon = Some(AnyView::new(icon));
635        self
636    }
637
638    /// Show an unlabeled badge dot over the icon
639    /// (`M3ENavigationBarDestination.badgeDot`). Wins over
640    /// [`NavItem::badge_count`], whichever is set last; an item with no icon
641    /// has nothing to badge and ignores it.
642    pub fn badge_dot(mut self) -> Self {
643        self.badge = Some(NavBadge::Dot);
644        self
645    }
646
647    /// Show a numeric badge over the icon
648    /// (`M3ENavigationBarDestination.badgeCount`), formatted by
649    /// [`mod@crate::badge`]'s own overflow rule.
650    pub fn badge_count(mut self, count: u32) -> Self {
651        self.badge = Some(NavBadge::Count(count));
652        self
653    }
654
655    /// Fold this item's badge onto its icon view
656    /// (`M3ENavigationBarDestination.buildIcon`).
657    ///
658    /// Composition happens exactly once, when [`navigation_bar`] takes
659    /// ownership of the item list — not inside a builder method, which could
660    /// wrap a second time on a later `badge_*` call.
661    fn compose_badge(mut self) -> Self {
662        let Some(kind) = self.badge else {
663            return self;
664        };
665        let Some(icon) = self.icon.take() else {
666            return self;
667        };
668        let badged = crate::badge::badge::<State, _>(icon);
669        let badged = match kind {
670            NavBadge::Dot => badged.dot(),
671            NavBadge::Count(count) => badged.count(count),
672        };
673        self.icon = Some(frust::authoring::any::<State, _>(badged));
674        self
675    }
676}
677
678/// A view-held, typed selection callback (erased per-item on build/rebuild).
679type OnSelect<State> = Rc<dyn Fn(&mut State, usize)>;
680
681/// A declarative M3 Expressive bottom NavigationBar. See the [module docs](self).
682pub struct NavigationBarView<State: 'static> {
683    items: Vec<NavItem<State>>,
684    selected: usize,
685    on_select: OnSelect<State>,
686    size: NavBarSize,
687    label_behavior: NavBarLabelBehavior,
688    indicator_style: NavBarIndicatorStyle,
689    safe_area: bool,
690}
691
692/// Create a navigation bar over `items`, with `selected` the current
693/// (app-confirmed) index. Fires `on_select(state, index)` on a release inside
694/// an item — a **controlled** component: `selected` is never mutated by this
695/// widget itself; the app must feed the confirmed index back in via the next
696/// rebuild.
697pub fn navigation_bar<State: 'static, F: Fn(&mut State, usize) + 'static>(
698    items: Vec<NavItem<State>>,
699    selected: usize,
700    on_select: F,
701) -> NavigationBarView<State> {
702    NavigationBarView {
703        items: items
704            .into_iter()
705            .map(NavItem::compose_badge)
706            .collect::<Vec<_>>(),
707        selected,
708        on_select: Rc::new(on_select),
709        size: NavBarSize::default(),
710        label_behavior: NavBarLabelBehavior::default(),
711        indicator_style: NavBarIndicatorStyle::default(),
712        safe_area: true,
713    }
714}
715
716/// PascalCase alias for [`navigation_bar`], matching the container view-fn
717/// vocabulary.
718#[allow(non_snake_case)]
719pub fn NavigationBar<State: 'static, F: Fn(&mut State, usize) + 'static>(
720    items: Vec<NavItem<State>>,
721    selected: usize,
722    on_select: F,
723) -> NavigationBarView<State> {
724    navigation_bar(items, selected, on_select)
725}
726
727impl<State: 'static> NavigationBarView<State> {
728    /// Container height variant (`M3ENavigationBar.size`).
729    pub fn size(mut self, size: NavBarSize) -> Self {
730        self.size = size;
731        self
732    }
733
734    /// When destination labels are painted
735    /// (`M3ENavigationBar.labelBehavior`).
736    pub fn label_behavior(mut self, behavior: NavBarLabelBehavior) -> Self {
737        self.label_behavior = behavior;
738        self
739    }
740
741    /// Selection indicator style (`M3ENavigationBar.indicatorStyle`).
742    pub fn indicator_style(mut self, style: NavBarIndicatorStyle) -> Self {
743        self.indicator_style = style;
744        self
745    }
746
747    /// Whether the bar consumes the bottom window inset itself, self-sizing
748    /// around it and painting its container fill through it — matching Flutter's
749    /// Material 3 NavigationBar (which reads `MediaQuery.padding.bottom` only;
750    /// horizontals belong to the Scaffold). Default `true`. The bar consumes the
751    /// **bottom** inset **only**; left/right display-cutout insets are the
752    /// caller's responsibility.
753    ///
754    /// For a bottom-docked bar respecting horizontal safe areas / landscape
755    /// cutouts, wrap the bar in `safe_area(bar).top(false).bottom(false)`
756    /// — the bottom-false leaves the bar's self-inset unconsumed by the safe
757    /// area, so the bar still paints under its gesture area and self-paints
758    /// the cutout band. For a bar embedded mid-screen (e.g. in a preview or
759    /// gallery), use `.safe_area(false)`.
760    ///
761    /// (`M3ENavigationBar.safeArea`)
762    pub fn safe_area(mut self, enabled: bool) -> Self {
763        self.safe_area = enabled;
764        self
765    }
766}
767
768/// Erase `on_select` into a per-item callback that always reports `idx`
769/// (mirrors [`frust::authoring::erase_callback_arg`], but with the index closed over
770/// rather than passed at call time — every item needs its *own* fixed index).
771fn item_on_select<State: 'static>(
772    on_select: &OnSelect<State>,
773    idx: usize,
774) -> frust::authoring::ErasedCallback {
775    let callback = on_select.clone();
776    Box::new(move |ctx: &mut EventCtx| {
777        let state = ctx.state_mut::<State>();
778        callback(state, idx);
779    })
780}
781
782/// One destination's retained content — a plain [`Widget`] (not
783/// `View`/[`AnyView`]-erased; see the [module docs](self)) wrapped in a
784/// [`ChildPod`] by [`NavigationBarWidget`] purely to share the multi-child
785/// routing helpers.
786struct NavItemWidget {
787    icon: Option<ChildPod>,
788    /// Present only while this destination paints its label (see
789    /// [`label_shown`]).
790    label: Option<ChildPod>,
791    /// The label text, retained for the semantics node's label (which carries
792    /// it whether or not the label is painted).
793    label_text: String,
794    selected: bool,
795    /// The newly-selected icon's scale pop; parked at its settled value except
796    /// while an incoming selection is springing (see [`ICON_POP_SPRING`]).
797    icon_pop: AnimationController,
798    on_select: frust::authoring::ErasedCallback,
799    /// Armed by a `Down`, cleared on `Up`/`Cancel` — see [`frust::button`]'s
800    /// identical fire-on-up-inside contract. There is no `pressed` visual to
801    /// track alongside it: the reference paints no ink splash here.
802    captured: bool,
803    /// This destination's indicator box, in item-local px, resolved in
804    /// `layout`. [`NavigationBarWidget`] reads the selected item's copy to
805    /// place every indicator style.
806    indicator_rect: Rect,
807}
808
809/// Build one item's retained [`ChildPod`] (wrapping a fresh [`NavItemWidget`]).
810fn build_item<State: 'static>(
811    item: &NavItem<State>,
812    selected: bool,
813    behavior: NavBarLabelBehavior,
814    on_select: &OnSelect<State>,
815    idx: usize,
816    ctx: &mut BuildCtx<'_>,
817) -> ChildPod {
818    let icon = item
819        .icon
820        .as_ref()
821        .map(|icon| frust::authoring::build_child(icon, ctx));
822    let label = label_shown(behavior, selected).then(|| {
823        let view = label_view::<State>(item.label.clone(), label_role(selected));
824        frust::authoring::build_child(&view, ctx)
825    });
826
827    let widget = NavItemWidget {
828        icon,
829        label,
830        label_text: item.label.clone(),
831        selected,
832        // No pop on first mount: only a *change* of selection pops
833        // (`_M3ENavIconScaleState.didUpdateWidget`).
834        icon_pop: settled_pop(),
835        on_select: item_on_select::<State>(on_select, idx),
836        captured: false,
837        indicator_rect: Rect::ZERO,
838    };
839    ChildPod::new(Box::new(widget))
840}
841
842impl Widget for NavItemWidget {
843    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
844        let width = if bc.max().width.is_finite() {
845            bc.max().width
846        } else {
847            0.0
848        };
849        // The bar always constrains an item tightly to its own height; an
850        // unbounded parent falls back to the default container height.
851        let height = if bc.max().height.is_finite() {
852            bc.max().height
853        } else {
854            NavBarSize::default().height()
855        };
856
857        let label_size = self.label.as_mut().map(|label| {
858            label.layout_child(ctx, &BoxConstraints::loose(Size::new(width, f64::INFINITY)))
859        });
860        // The reference centres the icon-plus-label column inside the bar's
861        // height (`Column(mainAxisAlignment: center)`), so a label-less
862        // destination centres its indicator box on its own.
863        let content_h = INDICATOR_H + label_size.map_or(0.0, |size| LABEL_GAP + size.height);
864        let top = ((height - content_h) / 2.0).max(0.0);
865
866        let pill_w = INDICATOR_W.min(width);
867        let pill_x = (width - pill_w) / 2.0;
868        self.indicator_rect = Rect::new(pill_x, top, pill_x + pill_w, top + INDICATOR_H);
869
870        if let Some(icon) = self.icon.as_mut() {
871            let icon_size =
872                icon.layout_child(ctx, &BoxConstraints::loose(Size::new(pill_w, INDICATOR_H)));
873            icon.set_origin(Point::new(
874                pill_x + (pill_w - icon_size.width) / 2.0,
875                top + (INDICATOR_H - icon_size.height) / 2.0,
876            ));
877        }
878        if let (Some(label), Some(size)) = (self.label.as_mut(), label_size) {
879            label.set_origin(Point::new(
880                (width - size.width) / 2.0,
881                top + INDICATOR_H + LABEL_GAP,
882            ));
883        }
884        bc.constrain(Size::new(width, height))
885    }
886
887    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
888        let reduce_motion = Theme::from_paint_ctx(ctx).is_some_and(|t| t.motion.reduce_motion);
889        if reduce_motion {
890            // A scale pop has no reduced-motion analogue: land it and stop
891            // asking for frames.
892            if self.icon_pop.is_animating() {
893                self.icon_pop = settled_pop();
894            }
895        } else if self.icon_pop.advance(ctx.frame_time()) {
896            ctx.request_frame();
897        }
898
899        if let Some(icon) = self.icon.as_mut() {
900            let scale = ICON_POP_FROM + (1.0 - ICON_POP_FROM) * self.icon_pop.value();
901            let popped = (scale - 1.0).abs() > ICON_POP_EPSILON;
902            if popped {
903                let center = (ctx.origin() + icon.origin().to_vec2()).to_vec2()
904                    + icon.size().to_vec2() / 2.0;
905                scene.push_transform(
906                    Affine::translate(center) * Affine::scale(scale) * Affine::translate(-center),
907                );
908            }
909            icon.paint_child(ctx, scene);
910            if popped {
911                scene.pop_transform();
912            }
913        }
914        if let Some(label) = self.label.as_mut() {
915            label.paint_child(ctx, scene);
916        }
917    }
918
919    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
920        let InputEvent::Pointer(p) = event else {
921            return EventResult::Ignored;
922        };
923        match p.phase {
924            PointerPhase::Down => {
925                if !presses(p) {
926                    return EventResult::Ignored;
927                }
928                self.captured = true;
929                ctx.capture_pointer();
930                EventResult::Handled
931            }
932            PointerPhase::Move => {
933                if !self.captured {
934                    return EventResult::Ignored;
935                }
936                EventResult::Handled
937            }
938            PointerPhase::Up => {
939                if !self.captured {
940                    return EventResult::Ignored;
941                }
942                if inside(p.position, ctx.size()) {
943                    (self.on_select)(ctx);
944                }
945                self.captured = false;
946                EventResult::Handled
947            }
948            PointerPhase::Cancel => {
949                if !self.captured {
950                    return EventResult::Ignored;
951                }
952                self.captured = false;
953                EventResult::Handled
954            }
955        }
956    }
957
958    fn semantics(&self, ctx: &mut SemanticsCtx) {
959        ctx.push_node(Role::Tab, |node| {
960            node.set_label(self.label_text.as_str());
961            node.set_selected(self.selected);
962        });
963    }
964
965    frust::authoring::visit_children!(icon, label);
966}
967
968/// Synthesize a [`PointerPhase::Cancel`] into a still-armed item pod (mirrors
969/// [`crate`]'s crate-private `cancel_pod`, reimplemented locally since a
970/// removed item's `NavItemWidget` isn't `AnyView`-wrapped and so can't go
971/// through [`frust::authoring::teardown_child`]'s `Box<dyn Widget>` downcast).
972fn cancel_item(pod: &mut ChildPod) {
973    let mut dummy_state = ();
974    let mut ctx = EventCtx::new(&mut dummy_state, pod.origin(), pod.size());
975    let cancel = InputEvent::Pointer(PointerEvent {
976        phase: PointerPhase::Cancel,
977        position: Point::ZERO,
978        button: PointerButton::Primary,
979    });
980    pod.event_child(&mut ctx, &cancel);
981}
982
983/// Tear down one item's icon and label children (the shared tail of a
984/// truncating rebuild and of `View::teardown`).
985fn teardown_item<State: 'static>(
986    item: &NavItem<State>,
987    selected: bool,
988    behavior: NavBarLabelBehavior,
989    widget: &mut NavItemWidget,
990    ctx: &mut BuildCtx<'_>,
991) {
992    if let (Some(icon_view), Some(icon_pod)) = (&item.icon, widget.icon.as_mut()) {
993        frust::authoring::teardown_child(icon_view, icon_pod, ctx);
994    }
995    if let (true, Some(label_pod)) = (label_shown(behavior, selected), widget.label.as_mut()) {
996        let view = label_view::<State>(item.label.clone(), label_role(selected));
997        frust::authoring::teardown_child(&view, label_pod, ctx);
998    }
999}
1000
1001/// The retained widget for a [`NavigationBarView`].
1002pub struct NavigationBarWidget {
1003    /// Each destination, in order — every pod wraps a [`NavItemWidget`].
1004    items: Vec<ChildPod>,
1005    /// The app-confirmed selection (see the [module docs](self)' controlled
1006    /// contract).
1007    selected: usize,
1008    size: NavBarSize,
1009    safe_area: bool,
1010    /// The bottom window inset consumed on the last `layout` pass — `0.0`
1011    /// when `safe_area` is disabled or no shell pushed a nonzero inset. Kept
1012    /// so `paint`/tests can read what `layout` consumed without recomputing
1013    /// it against a live `LayoutCtx`.
1014    bottom_inset: f64,
1015    indicator_style: NavBarIndicatorStyle,
1016    indicator: LiquidIndicator,
1017    /// Raised by `rebuild` when the selection moved, consumed by the next
1018    /// `layout` — which is where the destination's geometry is known and the
1019    /// travel therefore starts.
1020    selection_moved: bool,
1021}
1022
1023impl<State: 'static> View<State> for NavigationBarView<State> {
1024    type Element = NavigationBarWidget;
1025
1026    fn build(&self, ctx: &mut BuildCtx<'_>) -> NavigationBarWidget {
1027        let mut items = Vec::with_capacity(self.items.len());
1028        for (i, item) in self.items.iter().enumerate() {
1029            items.push(build_item(
1030                item,
1031                i == self.selected,
1032                self.label_behavior,
1033                &self.on_select,
1034                i,
1035                ctx,
1036            ));
1037        }
1038        NavigationBarWidget {
1039            items,
1040            selected: self.selected,
1041            size: self.size,
1042            safe_area: self.safe_area,
1043            bottom_inset: 0.0,
1044            indicator_style: self.indicator_style,
1045            indicator: LiquidIndicator::new(),
1046            selection_moved: false,
1047        }
1048    }
1049
1050    fn rebuild(
1051        &self,
1052        prev: &Self,
1053        element: &mut NavigationBarWidget,
1054        ctx: &mut BuildCtx<'_>,
1055    ) -> ChangeFlags {
1056        let mut flags = ChangeFlags::NONE;
1057        let common = prev.items.len().min(self.items.len());
1058
1059        for i in 0..common {
1060            let prev_item = &prev.items[i];
1061            let next_item = &self.items[i];
1062            let was_selected = i == prev.selected;
1063            let now_selected = i == self.selected;
1064            let pod = &mut element.items[i];
1065            let widget = pod
1066                .widget_mut()
1067                .downcast_mut::<NavItemWidget>()
1068                .expect("nav item pod holds a NavItemWidget");
1069
1070            match (&prev_item.icon, &next_item.icon) {
1071                (Some(p), Some(n)) => {
1072                    flags |= frust::authoring::rebuild_child(
1073                        p,
1074                        n,
1075                        widget.icon.as_mut().expect("icon pod present"),
1076                        ctx,
1077                    );
1078                }
1079                (None, Some(n)) => {
1080                    widget.icon = Some(frust::authoring::build_child(n, ctx));
1081                    flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1082                }
1083                (Some(p), None) => {
1084                    if let Some(mut old) = widget.icon.take() {
1085                        frust::authoring::teardown_child(p, &mut old, ctx);
1086                    }
1087                    flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1088                }
1089                (None, None) => {}
1090            }
1091
1092            let prev_role = label_role(was_selected);
1093            let next_role = label_role(now_selected);
1094            let was_shown = label_shown(prev.label_behavior, was_selected);
1095            let now_shown = label_shown(self.label_behavior, now_selected);
1096            widget.label_text = next_item.label.clone();
1097            match (was_shown, now_shown) {
1098                (true, true) => {
1099                    if (prev_item.label != next_item.label || prev_role != next_role)
1100                        && let Some(label) = widget.label.as_mut()
1101                    {
1102                        let prev_view = label_view::<State>(prev_item.label.clone(), prev_role);
1103                        let next_view = label_view::<State>(next_item.label.clone(), next_role);
1104                        flags |=
1105                            frust::authoring::rebuild_child(&prev_view, &next_view, label, ctx);
1106                    }
1107                }
1108                (false, true) => {
1109                    let view = label_view::<State>(next_item.label.clone(), next_role);
1110                    widget.label = Some(frust::authoring::build_child(&view, ctx));
1111                    flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1112                }
1113                (true, false) => {
1114                    if let Some(mut label) = widget.label.take() {
1115                        let view = label_view::<State>(prev_item.label.clone(), prev_role);
1116                        frust::authoring::teardown_child(&view, &mut label, ctx);
1117                    }
1118                    flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1119                }
1120                (false, false) => {}
1121            }
1122
1123            if was_selected != now_selected {
1124                widget.selected = now_selected;
1125                if now_selected {
1126                    widget.icon_pop = launched_pop();
1127                } else {
1128                    widget.icon_pop = settled_pop();
1129                }
1130                flags |= ChangeFlags::PAINT;
1131            }
1132
1133            // Closures aren't comparable — always reinstall the adapter.
1134            widget.on_select = item_on_select::<State>(&self.on_select, i);
1135        }
1136
1137        if self.items.len() > prev.items.len() {
1138            for (offset, item) in self.items[common..].iter().enumerate() {
1139                let idx = common + offset;
1140                element.items.push(build_item(
1141                    item,
1142                    idx == self.selected,
1143                    self.label_behavior,
1144                    &self.on_select,
1145                    idx,
1146                    ctx,
1147                ));
1148            }
1149            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1150        } else if self.items.len() < prev.items.len() {
1151            for (offset, item) in prev.items[common..].iter().enumerate() {
1152                let idx = common + offset;
1153                let pod = &mut element.items[idx];
1154                if pod.is_active() {
1155                    cancel_item(pod);
1156                    pod.set_active(false);
1157                }
1158                let widget = pod
1159                    .widget_mut()
1160                    .downcast_mut::<NavItemWidget>()
1161                    .expect("nav item pod holds a NavItemWidget");
1162                teardown_item(item, idx == prev.selected, prev.label_behavior, widget, ctx);
1163            }
1164            element.items.truncate(common);
1165            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1166        }
1167
1168        if prev.selected != self.selected {
1169            element.selected = self.selected;
1170            // The indicator's travel starts in `layout` (the pass that knows
1171            // where the destination *is*), so a moved selection must ask for
1172            // one — a bare repaint would leave the pill parked.
1173            element.selection_moved = true;
1174            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1175        }
1176        if prev.size != self.size {
1177            element.size = self.size;
1178            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1179        }
1180        if prev.safe_area != self.safe_area {
1181            element.safe_area = self.safe_area;
1182            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1183        }
1184        if prev.label_behavior != self.label_behavior {
1185            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1186        }
1187        if prev.indicator_style != self.indicator_style {
1188            element.indicator_style = self.indicator_style;
1189            flags |= ChangeFlags::PAINT;
1190        }
1191
1192        flags
1193    }
1194
1195    fn teardown(&self, element: &mut NavigationBarWidget, ctx: &mut BuildCtx<'_>) {
1196        for (i, (item, pod)) in self.items.iter().zip(element.items.iter_mut()).enumerate() {
1197            let widget = pod
1198                .widget_mut()
1199                .downcast_mut::<NavItemWidget>()
1200                .expect("nav item pod holds a NavItemWidget");
1201            teardown_item(item, i == self.selected, self.label_behavior, widget, ctx);
1202        }
1203    }
1204}
1205
1206impl NavigationBarWidget {
1207    /// The selected destination's indicator box in bar-local px, or `None`
1208    /// when the bar holds no items.
1209    fn selected_indicator_rect(&mut self) -> Option<Rect> {
1210        let last = self.items.len().checked_sub(1)?;
1211        let index = self.selected.min(last);
1212        let pod = self.items.get_mut(index)?;
1213        let origin = pod.origin();
1214        let local = pod
1215            .widget_mut()
1216            .downcast_mut::<NavItemWidget>()
1217            .expect("nav item pod holds a NavItemWidget")
1218            .indicator_rect;
1219        Some(local + origin.to_vec2())
1220    }
1221}
1222
1223impl Widget for NavigationBarWidget {
1224    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1225        let width = if bc.max().width.is_finite() {
1226            bc.max().width
1227        } else {
1228            0.0
1229        };
1230        let band = self.size.height();
1231        let inset = if self.safe_area {
1232            ctx.window_insets().padding().bottom
1233        } else {
1234            0.0
1235        };
1236        self.bottom_inset = inset;
1237        let height = band + inset;
1238        // Reduced motion turns the travel into the same jump every non-selection
1239        // geometry change takes.
1240        let reduce_motion = Theme::from_layout_ctx(ctx).is_some_and(|t| t.motion.reduce_motion);
1241        let travel = std::mem::take(&mut self.selection_moved)
1242            && self.indicator_style == NavBarIndicatorStyle::Pill
1243            && !reduce_motion;
1244
1245        let n = self.items.len();
1246        if n == 0 {
1247            return bc.constrain(Size::new(width, height));
1248        }
1249        let slot_w = width / n as f64;
1250        for (i, pod) in self.items.iter_mut().enumerate() {
1251            pod.layout_child(ctx, &BoxConstraints::tight(Size::new(slot_w, band)));
1252            pod.set_origin(Point::new(i as f64 * slot_w, 0.0));
1253        }
1254        if let Some(rest) = self.selected_indicator_rect() {
1255            self.indicator.sync(rest, travel);
1256        }
1257        bc.constrain(Size::new(width, height))
1258    }
1259
1260    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1261        let theme = Theme::from_paint_ctx(ctx);
1262        let (container_fill, indicator_fill) = resolve_colors(theme);
1263        let reduce_motion = theme.is_some_and(|t| t.motion.reduce_motion);
1264        scene.fill_rect(ctx.origin(), ctx.size(), container_fill);
1265
1266        match self.indicator_style {
1267            NavBarIndicatorStyle::Pill => {
1268                if reduce_motion {
1269                    self.indicator.settle();
1270                } else if self.indicator.advance(ctx.frame_time()) {
1271                    // The pill is paint-only geometry: no item's layout depends
1272                    // on it, so a plain frame request is enough.
1273                    ctx.request_frame();
1274                }
1275                if let Some(pill) = self.indicator.pill_rect() {
1276                    scene.fill_rounded_rect(
1277                        ctx.origin() + Vec2::new(pill.x0, pill.y0),
1278                        pill.size(),
1279                        self.indicator.radius(),
1280                        indicator_fill,
1281                    );
1282                }
1283            }
1284            NavBarIndicatorStyle::Underline => {
1285                if let Some(rest) = self.selected_indicator_rect() {
1286                    // A `Border(bottom:)` paints inside its box's bottom edge.
1287                    scene.fill_rect(
1288                        ctx.origin() + Vec2::new(rest.x0, rest.y1 - UNDERLINE_THICKNESS),
1289                        Size::new(rest.width(), UNDERLINE_THICKNESS),
1290                        indicator_fill,
1291                    );
1292                }
1293            }
1294            NavBarIndicatorStyle::None => {}
1295        }
1296
1297        for pod in &mut self.items {
1298            pod.paint_child(ctx, scene);
1299        }
1300    }
1301
1302    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1303        frust::authoring::route_event(&mut self.items, ctx, event)
1304    }
1305
1306    fn semantics(&self, ctx: &mut SemanticsCtx) {
1307        ctx.push_container(
1308            Role::TabList,
1309            |_| {},
1310            |ctx| {
1311                for pod in &self.items {
1312                    pod.semantics_child(ctx);
1313                }
1314            },
1315        );
1316    }
1317
1318    frust::authoring::visit_children!(items);
1319}
1320
1321#[cfg(test)]
1322mod tests {
1323    use super::*;
1324    use frust::authoring::text::TextContext;
1325    use frust::authoring::{BuildCtx, PointerButton, PointerEvent, WindowEdgeInsets, WindowInsets};
1326    use frust_widgets::test_support::{RecordingScene, leaf_any};
1327    use std::any::Any;
1328    use std::cell::Cell;
1329
1330    fn ctx(counter: &mut u64) -> BuildCtx<'_> {
1331        BuildCtx::new(counter)
1332    }
1333
1334    fn build(view: &NavigationBarView<()>) -> NavigationBarWidget {
1335        let mut counter = 0u64;
1336        View::<()>::build(view, &mut ctx(&mut counter))
1337    }
1338
1339    fn layout(w: &mut NavigationBarWidget, bc: &BoxConstraints) -> Size {
1340        let mut tcx = TextContext::new();
1341        let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), None);
1342        w.layout(&mut lctx, bc)
1343    }
1344
1345    fn layout_themed(w: &mut NavigationBarWidget, bc: &BoxConstraints, theme: &Theme) -> Size {
1346        let mut tcx = TextContext::new();
1347        let mut lctx =
1348            LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), Some(theme as &dyn Any));
1349        w.layout(&mut lctx, bc)
1350    }
1351
1352    /// Lay out under a window carrying `bottom` px of bottom system-bar inset
1353    /// and nothing else, the shape a shell pushes for a home-indicator/nav-bar
1354    /// occlusion.
1355    fn layout_with_bottom_inset(
1356        w: &mut NavigationBarWidget,
1357        bc: &BoxConstraints,
1358        bottom: f64,
1359    ) -> Size {
1360        let insets = WindowInsets::new(
1361            WindowEdgeInsets::new(0.0, 0.0, 0.0, bottom),
1362            WindowEdgeInsets::ZERO,
1363        );
1364        let mut tcx = TextContext::new();
1365        let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), None);
1366        lctx.with_window_insets(insets, |ctx| w.layout(ctx, bc))
1367    }
1368
1369    fn three_items() -> Vec<NavItem<()>> {
1370        vec![nav_item("Home"), nav_item("Search"), nav_item("Profile")]
1371    }
1372
1373    fn ft(ms: f64) -> FrameTime {
1374        FrameTime::from_nanos((ms * 1_000_000.0) as u64)
1375    }
1376
1377    /// Records the plain fills, rounded fills, and transforms a paint pass
1378    /// emits.
1379    #[derive(Default)]
1380    struct PillRecorder {
1381        rects: Vec<(Point, Size)>,
1382        rounded: Vec<(Point, Size, f64)>,
1383        transforms: Vec<Affine>,
1384        transform_pops: u32,
1385    }
1386
1387    impl PaintScene for PillRecorder {
1388        fn fill_rect(&mut self, origin: Point, size: Size, _color: Color) {
1389            self.rects.push((origin, size));
1390        }
1391        fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, _color: Color) {
1392            self.rounded.push((origin, size, radius));
1393        }
1394        fn draw_text(&mut self, _origin: Point, _text: &str) {}
1395        fn push_transform(&mut self, transform: Affine) {
1396            self.transforms.push(transform);
1397        }
1398        fn pop_transform(&mut self) {
1399            self.transform_pops += 1;
1400        }
1401    }
1402
1403    fn paint_at(w: &mut NavigationBarWidget, size: Size, time: FrameTime) -> (PillRecorder, bool) {
1404        let mut scene = PillRecorder::default();
1405        let mut pctx = PaintCtx::for_test(Point::ZERO, size, time);
1406        w.paint(&mut pctx, &mut scene);
1407        let needs_frame = pctx.needs_frame();
1408        (scene, needs_frame)
1409    }
1410
1411    fn paint_themed_at(
1412        w: &mut NavigationBarWidget,
1413        size: Size,
1414        time: FrameTime,
1415        theme: &Theme,
1416    ) -> (PillRecorder, bool) {
1417        let mut scene = PillRecorder::default();
1418        let mut pctx = PaintCtx::for_test(Point::ZERO, size, time).with_theme(theme);
1419        w.paint(&mut pctx, &mut scene);
1420        let needs_frame = pctx.needs_frame();
1421        (scene, needs_frame)
1422    }
1423
1424    // ---- Layout / sizes / label behaviors ---------------------------------
1425
1426    #[test]
1427    fn layout_divides_width_evenly_and_fills_height() {
1428        let view: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
1429        let mut w = build(&view);
1430        let size = layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
1431        assert_eq!(size, Size::new(300.0, NavBarSize::default().height()));
1432        assert_eq!(w.items[0].origin().x, 0.0);
1433        assert_eq!(w.items[1].origin().x, 100.0);
1434        assert_eq!(w.items[2].origin().x, 200.0);
1435        assert_eq!(
1436            w.items[0].size(),
1437            Size::new(100.0, NavBarSize::default().height())
1438        );
1439    }
1440
1441    #[test]
1442    fn the_two_sizes_carry_their_reference_container_heights() {
1443        assert_eq!(NavBarSize::Small.height(), HEIGHT_SMALL);
1444        assert_eq!(NavBarSize::Medium.height(), HEIGHT_MEDIUM);
1445        for (size, expected) in [
1446            (NavBarSize::Small, HEIGHT_SMALL),
1447            (NavBarSize::Medium, HEIGHT_MEDIUM),
1448        ] {
1449            let view: NavigationBarView<()> =
1450                navigation_bar(three_items(), 0, |_s: &mut (), _i| {}).size(size);
1451            let mut w = build(&view);
1452            let laid = layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
1453            assert_eq!(laid.height, expected, "{size:?}");
1454            assert_eq!(w.items[0].size().height, expected, "{size:?}");
1455        }
1456    }
1457
1458    #[test]
1459    fn default_size_is_medium() {
1460        let view: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
1461        let mut w = build(&view);
1462        let size = layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
1463        assert_eq!(size, Size::new(300.0, HEIGHT_MEDIUM));
1464        assert_eq!(w.items[0].size().height, HEIGHT_MEDIUM);
1465    }
1466
1467    // ---- Self-inset (`safe_area`) ------------------------------------------
1468
1469    #[test]
1470    fn safe_area_grows_the_bar_by_the_consumed_bottom_inset() {
1471        let view: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
1472        let mut w = build(&view);
1473        let size = layout_with_bottom_inset(
1474            &mut w,
1475            &BoxConstraints::loose(Size::new(300.0, 200.0)),
1476            34.0,
1477        );
1478        assert_eq!(
1479            size,
1480            Size::new(300.0, NavBarSize::default().height() + 34.0)
1481        );
1482        assert_eq!(w.bottom_inset, 34.0);
1483    }
1484
1485    #[test]
1486    fn items_stay_at_the_bare_band_height_under_a_self_consumed_inset() {
1487        let view: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
1488        let mut w = build(&view);
1489        layout_with_bottom_inset(
1490            &mut w,
1491            &BoxConstraints::loose(Size::new(300.0, 200.0)),
1492            34.0,
1493        );
1494        for item in &w.items {
1495            assert_eq!(
1496                item.origin().y,
1497                0.0,
1498                "items sit at the top of the taller bar"
1499            );
1500            assert_eq!(
1501                item.size().height,
1502                NavBarSize::default().height(),
1503                "items stay the bare band tall"
1504            );
1505        }
1506    }
1507
1508    #[test]
1509    fn indicator_geometry_is_unchanged_by_a_self_consumed_inset() {
1510        let view: NavigationBarView<()> = navigation_bar(three_items(), 1, |_s: &mut (), _i| {});
1511        let bc = BoxConstraints::loose(Size::new(300.0, 200.0));
1512
1513        let mut zero = build(&view);
1514        layout(&mut zero, &bc);
1515        let (zero_scene, _) = paint_at(
1516            &mut zero,
1517            Size::new(300.0, NavBarSize::default().height()),
1518            ft(0.0),
1519        );
1520
1521        let mut inset = build(&view);
1522        layout_with_bottom_inset(&mut inset, &bc, 34.0);
1523        let (inset_scene, _) = paint_at(
1524            &mut inset,
1525            Size::new(300.0, NavBarSize::default().height() + 34.0),
1526            ft(0.0),
1527        );
1528
1529        assert_eq!(
1530            zero_scene.rounded, inset_scene.rounded,
1531            "the pill's geometry is item-local and byte-identical for a nonzero inset"
1532        );
1533    }
1534
1535    #[test]
1536    fn safe_area_false_ignores_the_window_inset() {
1537        let view: NavigationBarView<()> =
1538            navigation_bar(three_items(), 0, |_s: &mut (), _i| {}).safe_area(false);
1539        let mut w = build(&view);
1540        let size = layout_with_bottom_inset(
1541            &mut w,
1542            &BoxConstraints::loose(Size::new(300.0, 200.0)),
1543            34.0,
1544        );
1545        assert_eq!(size, Size::new(300.0, NavBarSize::default().height()));
1546        assert_eq!(w.bottom_inset, 0.0, "an opted-out bar consumes nothing");
1547    }
1548
1549    #[test]
1550    fn container_fill_covers_the_full_band_plus_inset_size() {
1551        let view: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
1552        let mut w = build(&view);
1553        layout_with_bottom_inset(
1554            &mut w,
1555            &BoxConstraints::loose(Size::new(300.0, 200.0)),
1556            34.0,
1557        );
1558        let (scene, _) = paint_at(
1559            &mut w,
1560            Size::new(300.0, NavBarSize::default().height() + 34.0),
1561            ft(0.0),
1562        );
1563        assert_eq!(
1564            scene.rects[0],
1565            (
1566                Point::ZERO,
1567                Size::new(300.0, NavBarSize::default().height() + 34.0)
1568            ),
1569            "the container fill covers the band plus the consumed inset"
1570        );
1571    }
1572
1573    #[test]
1574    fn rebuild_flipping_safe_area_returns_layout() {
1575        let mut counter = 0u64;
1576        let prev: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
1577        let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
1578
1579        let next: NavigationBarView<()> =
1580            navigation_bar(three_items(), 0, |_s: &mut (), _i| {}).safe_area(false);
1581        let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
1582        assert!(
1583            flags.needs_layout(),
1584            "safe_area toggles the self-inset, a layout change"
1585        );
1586        assert!(!w.safe_area);
1587    }
1588
1589    #[test]
1590    fn label_behavior_decides_which_destinations_hold_a_label_child() {
1591        let cases = [
1592            (NavBarLabelBehavior::AlwaysShow, [true, true, true]),
1593            (NavBarLabelBehavior::OnlySelected, [false, true, false]),
1594            (NavBarLabelBehavior::AlwaysHide, [false, false, false]),
1595        ];
1596        for (behavior, expected) in cases {
1597            let view: NavigationBarView<()> =
1598                navigation_bar(three_items(), 1, |_s: &mut (), _i| {}).label_behavior(behavior);
1599            let mut w = build(&view);
1600            layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
1601            for (i, want) in expected.iter().enumerate() {
1602                let item = w.items[i]
1603                    .widget_mut()
1604                    .downcast_mut::<NavItemWidget>()
1605                    .unwrap();
1606                assert_eq!(item.label.is_some(), *want, "{behavior:?} item {i}");
1607                // The semantics label survives a hidden label either way.
1608                assert!(!item.label_text.is_empty());
1609            }
1610        }
1611    }
1612
1613    #[test]
1614    fn a_hidden_label_centres_the_indicator_box_on_its_own() {
1615        let hidden: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {})
1616            .label_behavior(NavBarLabelBehavior::AlwaysHide);
1617        let mut w = build(&hidden);
1618        layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
1619        let item = w.items[0]
1620            .widget_mut()
1621            .downcast_mut::<NavItemWidget>()
1622            .unwrap();
1623        let expected_top = (NavBarSize::default().height() - INDICATOR_H) / 2.0;
1624        assert!((item.indicator_rect.y0 - expected_top).abs() < 1e-9);
1625        assert_eq!(item.indicator_rect.width(), INDICATOR_W);
1626    }
1627
1628    #[test]
1629    fn changing_the_label_behavior_asks_for_a_relayout() {
1630        let mut counter = 0u64;
1631        let prev: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
1632        let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
1633        let next: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {})
1634            .label_behavior(NavBarLabelBehavior::AlwaysHide);
1635        let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
1636        assert!(flags.needs_layout(), "label presence is a layout input");
1637        let item = w.items[0]
1638            .widget_mut()
1639            .downcast_mut::<NavItemWidget>()
1640            .unwrap();
1641        assert!(item.label.is_none(), "the label child was torn down");
1642    }
1643
1644    // ---- Indicator styles --------------------------------------------------
1645
1646    #[test]
1647    fn the_three_indicator_styles_paint_a_pill_an_underline_or_nothing() {
1648        let bar = Size::new(300.0, HEIGHT_SMALL);
1649
1650        let pill: NavigationBarView<()> = navigation_bar(three_items(), 1, |_s: &mut (), _i| {});
1651        let mut w = build(&pill);
1652        layout(&mut w, &BoxConstraints::loose(bar));
1653        let (scene, _) = paint_at(&mut w, bar, ft(0.0));
1654        assert_eq!(scene.rects.len(), 1, "container fill only");
1655        assert_eq!(scene.rounded.len(), 1, "one stadium pill");
1656        let (origin, size, radius) = scene.rounded[0];
1657        assert_eq!(size, Size::new(INDICATOR_W, INDICATOR_H));
1658        assert_eq!(radius, INDICATOR_H / 2.0);
1659        // Item 1 spans x in [100, 200): its pill is centred at 150.
1660        assert!((origin.x + size.width / 2.0 - 150.0).abs() < 1e-9);
1661
1662        let underline: NavigationBarView<()> =
1663            navigation_bar(three_items(), 1, |_s: &mut (), _i| {})
1664                .indicator_style(NavBarIndicatorStyle::Underline);
1665        let mut w = build(&underline);
1666        layout(&mut w, &BoxConstraints::loose(bar));
1667        let (scene, _) = paint_at(&mut w, bar, ft(0.0));
1668        assert!(scene.rounded.is_empty(), "no pill in underline style");
1669        assert_eq!(scene.rects.len(), 2, "container plus underline");
1670        let (origin, size) = scene.rects[1];
1671        assert_eq!(size, Size::new(INDICATOR_W, UNDERLINE_THICKNESS));
1672        assert!((origin.x + size.width / 2.0 - 150.0).abs() < 1e-9);
1673
1674        let none: NavigationBarView<()> = navigation_bar(three_items(), 1, |_s: &mut (), _i| {})
1675            .indicator_style(NavBarIndicatorStyle::None);
1676        let mut w = build(&none);
1677        layout(&mut w, &BoxConstraints::loose(bar));
1678        let (scene, needs_frame) = paint_at(&mut w, bar, ft(0.0));
1679        assert!(scene.rounded.is_empty());
1680        assert_eq!(scene.rects.len(), 1, "container fill only");
1681        assert!(!needs_frame);
1682    }
1683
1684    // ---- The liquid indicator ---------------------------------------------
1685
1686    #[test]
1687    fn the_edge_springs_match_the_reference_preset_with_only_damping_overridden() {
1688        let reference = crate::MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT;
1689        assert_eq!(LEAD_SPRING.stiffness, reference.stiffness);
1690        assert_eq!(TRAIL_SPRING.stiffness, reference.stiffness);
1691        assert_eq!(ICON_POP_SPRING.stiffness, reference.stiffness);
1692        assert_eq!(LEAD_SPRING.mass, 1.0);
1693        assert_eq!(TRAIL_SPRING.mass, 1.0);
1694        assert_eq!(LEAD_SPRING.damping_ratio, 0.45);
1695        assert_eq!(TRAIL_SPRING.damping_ratio, 0.55);
1696        assert_eq!(ICON_POP_SPRING.damping_ratio, 0.5);
1697        // The ordering is what makes the bridge stretch at all: the lead edge
1698        // must be the less damped of the two.
1699        const {
1700            assert!(LEAD_SPRING.damping_ratio < TRAIL_SPRING.damping_ratio);
1701        }
1702    }
1703
1704    /// The two-phase timeline: a selection change stretches the pill into a
1705    /// bridge between the two destinations, then settles it onto the new one.
1706    #[test]
1707    fn the_pill_stretches_toward_the_new_destination_and_then_settles() {
1708        let bar = Size::new(300.0, HEIGHT_SMALL);
1709        let mut counter = 0u64;
1710        let prev: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
1711        let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
1712        layout(&mut w, &BoxConstraints::loose(bar));
1713        let (scene, needs_frame) = paint_at(&mut w, bar, ft(0.0));
1714        assert_eq!(scene.rounded[0].1.width, INDICATOR_W, "resting at item 0");
1715        assert!(!needs_frame, "a settled pill asks for no frames");
1716
1717        let next: NavigationBarView<()> = navigation_bar(three_items(), 2, |_s: &mut (), _i| {});
1718        View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
1719        layout(&mut w, &BoxConstraints::loose(bar));
1720        assert!(w.indicator.is_animating(), "the travel started at layout");
1721
1722        let mut widest: f64 = 0.0;
1723        let mut widest_at = 0usize;
1724        let mut lead_ahead = false;
1725        let mut settled_at = None;
1726        for frame in 0..240 {
1727            let (scene, needs_frame) = paint_at(&mut w, bar, ft(16.0 * frame as f64));
1728            let (origin, size, _) = scene.rounded[0];
1729            if size.width > widest {
1730                widest = size.width;
1731                widest_at = frame;
1732            }
1733            // Travelling right: the lead edge is the right one, so the pill's
1734            // right edge passes the destination's own before the left catches
1735            // up.
1736            if origin.x + size.width > 250.0 + INDICATOR_W / 2.0 - 1.0 && size.width > INDICATOR_W {
1737                lead_ahead = true;
1738            }
1739            if !needs_frame {
1740                settled_at = Some(frame);
1741                break;
1742            }
1743        }
1744        assert!(
1745            widest > INDICATOR_W + 1.0,
1746            "phase 1 never stretched: widest {widest} vs resting {INDICATOR_W}"
1747        );
1748        assert!(widest_at > 0, "the stretch builds up over frames");
1749        assert!(lead_ahead, "the leading edge reaches the new slot first");
1750
1751        let settled_at = settled_at.expect("the pill settles within 240 frames");
1752        assert!(settled_at > widest_at, "settling follows the stretch");
1753        let (scene, needs_frame) = paint_at(&mut w, bar, ft(16.0 * (settled_at + 1) as f64));
1754        assert!(!needs_frame, "a settled pill stops asking for frames");
1755        let (origin, size, _) = scene.rounded[0];
1756        assert!(
1757            (size.width - INDICATOR_W).abs() < 1e-6,
1758            "phase 2 must collapse the bridge back to the resting width"
1759        );
1760        // Item 2 spans x in [200, 300): its pill is centred at 250.
1761        assert!((origin.x + size.width / 2.0 - 250.0).abs() < 1e-6);
1762    }
1763
1764    /// `layout` re-enters on every full-tree layout pass, not just the one
1765    /// where the selection actually moved — an animating sibling's own
1766    /// `request_layout`, or any other app-side LAYOUT flag, produces exactly
1767    /// this shape: a relayout that reproduces the *same* resting geometry
1768    /// while a travel is already mid-flight. It must not snap the pill.
1769    #[test]
1770    fn a_same_geometry_relayout_mid_travel_does_not_snap_the_pill() {
1771        let bar = Size::new(300.0, HEIGHT_SMALL);
1772        let mut counter = 0u64;
1773        let prev: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
1774        let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
1775        layout(&mut w, &BoxConstraints::loose(bar));
1776        paint_at(&mut w, bar, ft(0.0));
1777
1778        let next: NavigationBarView<()> = navigation_bar(three_items(), 2, |_s: &mut (), _i| {});
1779        View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
1780        layout(&mut w, &BoxConstraints::loose(bar));
1781        assert!(w.indicator.is_animating(), "the travel started at layout");
1782
1783        // Run a few paint frames so the pill is genuinely mid-stretch, not
1784        // just launched.
1785        paint_at(&mut w, bar, ft(16.0));
1786        paint_at(&mut w, bar, ft(32.0));
1787        let (scene, _) = paint_at(&mut w, bar, ft(48.0));
1788        let mid_flight_width = scene.rounded[0].1.width;
1789        assert!(
1790            mid_flight_width > INDICATOR_W + 1.0,
1791            "must actually be stretched before the interleaved layout, got {mid_flight_width}"
1792        );
1793
1794        // An unrelated relayout — same items, same selection, same
1795        // constraints — reproduces the exact same resting rect. Pre-fix,
1796        // `sync`'s non-travel branch snapped unconditionally here.
1797        layout(&mut w, &BoxConstraints::loose(bar));
1798        assert!(
1799            w.indicator.is_animating(),
1800            "a same-geometry relayout mid-travel must not cancel the travel"
1801        );
1802        let (scene, _) = paint_at(&mut w, bar, ft(64.0));
1803        let width_after = scene.rounded[0].1.width;
1804        assert!(
1805            width_after > INDICATOR_W + 1.0,
1806            "the pill must still be mid-flight after the interleaved layout, \
1807             not snapped to rest (got {width_after})"
1808        );
1809
1810        // The interrupted travel still settles correctly afterward.
1811        let mut settled = false;
1812        for frame in 5..240 {
1813            let (scene, needs_frame) = paint_at(&mut w, bar, ft(16.0 * frame as f64));
1814            if !needs_frame {
1815                let (origin, size, _) = scene.rounded[0];
1816                assert!((size.width - INDICATOR_W).abs() < 1e-6);
1817                // Item 2 spans x in [200, 300): its pill is centred at 250.
1818                assert!((origin.x + size.width / 2.0 - 250.0).abs() < 1e-6);
1819                settled = true;
1820                break;
1821            }
1822        }
1823        assert!(
1824            settled,
1825            "the pill still settles within 240 frames despite the interleave"
1826        );
1827    }
1828
1829    #[test]
1830    fn a_width_change_jumps_the_pill_instead_of_travelling() {
1831        let mut counter = 0u64;
1832        let view: NavigationBarView<()> = navigation_bar(three_items(), 1, |_s: &mut (), _i| {});
1833        let mut w = View::<()>::build(&view, &mut ctx(&mut counter));
1834        layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
1835        layout(&mut w, &BoxConstraints::loose(Size::new(600.0, 200.0)));
1836        assert!(!w.indicator.is_animating(), "a relayout never travels");
1837        let pill = w.indicator.pill_rect().expect("laid out");
1838        assert!((pill.width() - INDICATOR_W).abs() < 1e-9);
1839        // Item 1 now spans x in [200, 400): centred at 300.
1840        assert!((pill.center().x - 300.0).abs() < 1e-9);
1841    }
1842
1843    #[test]
1844    fn reduce_motion_snaps_the_liquid_travel_and_asks_for_no_frames() {
1845        let bar = Size::new(300.0, HEIGHT_SMALL);
1846        let mut theme = crate::baseline();
1847        theme.motion.reduce_motion = true;
1848        let mut counter = 0u64;
1849        let prev: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
1850        let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
1851        layout_themed(&mut w, &BoxConstraints::loose(bar), &theme);
1852
1853        let next: NavigationBarView<()> = navigation_bar(three_items(), 2, |_s: &mut (), _i| {});
1854        View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
1855        layout_themed(&mut w, &BoxConstraints::loose(bar), &theme);
1856        assert!(!w.indicator.is_animating(), "reduce_motion never travels");
1857
1858        let (scene, needs_frame) = paint_themed_at(&mut w, bar, ft(0.0), &theme);
1859        assert!(!needs_frame, "reduce_motion asks for no continuation frame");
1860        let (origin, size, _) = scene.rounded[0];
1861        assert!((size.width - INDICATOR_W).abs() < 1e-9, "no stretch at all");
1862        assert!((origin.x + size.width / 2.0 - 250.0).abs() < 1e-9);
1863    }
1864
1865    // ---- Icon pop ----------------------------------------------------------
1866
1867    #[test]
1868    fn selecting_an_item_pops_its_icon_and_then_returns_to_rest() {
1869        let bar = Size::new(300.0, HEIGHT_SMALL);
1870        let mut counter = 0u64;
1871        let items = || {
1872            vec![
1873                nav_item::<()>("Home").icon(leaf_any(24.0, 24.0)),
1874                nav_item::<()>("Search").icon(leaf_any(24.0, 24.0)),
1875            ]
1876        };
1877        let prev: NavigationBarView<()> = navigation_bar(items(), 0, |_s: &mut (), _i| {});
1878        let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
1879        layout(&mut w, &BoxConstraints::loose(bar));
1880        let (scene, _) = paint_at(&mut w, bar, ft(0.0));
1881        assert!(
1882            scene.transforms.is_empty(),
1883            "a settled bar pushes no scale transform"
1884        );
1885
1886        let next: NavigationBarView<()> = navigation_bar(items(), 1, |_s: &mut (), _i| {});
1887        View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
1888        let item = w.items[1]
1889            .widget_mut()
1890            .downcast_mut::<NavItemWidget>()
1891            .unwrap();
1892        assert!(item.icon_pop.is_animating(), "the incoming icon pops");
1893        assert!(
1894            (item.icon_pop.value() - 0.0).abs() < 1e-9,
1895            "starts at 0.98x"
1896        );
1897
1898        layout(&mut w, &BoxConstraints::loose(bar));
1899        let mut settled = false;
1900        let mut scaled_frames = 0;
1901        let mut overshot = false;
1902        for frame in 0..240 {
1903            let (scene, needs_frame) = paint_at(&mut w, bar, ft(16.0 * frame as f64));
1904            assert_eq!(
1905                scene.transforms.len() as u32,
1906                scene.transform_pops,
1907                "every pushed transform is popped"
1908            );
1909            if let Some(t) = scene.transforms.first() {
1910                scaled_frames += 1;
1911                // A uniform scale's determinant is s².
1912                if t.determinant() > 1.0 {
1913                    overshot = true;
1914                }
1915            }
1916            if !needs_frame {
1917                settled = true;
1918                break;
1919            }
1920        }
1921        assert!(settled, "the pop settles");
1922        assert!(scaled_frames > 0, "the pop actually scaled the icon");
1923        assert!(overshot, "the pop overshoots past the resting scale");
1924        let item = w.items[1]
1925            .widget_mut()
1926            .downcast_mut::<NavItemWidget>()
1927            .unwrap();
1928        assert!((item.icon_pop.value() - 1.0).abs() < 1e-9, "rests at 1.0x");
1929    }
1930
1931    // ---- Badges ------------------------------------------------------------
1932
1933    #[test]
1934    fn a_badge_wraps_the_icon_without_moving_the_hit_target() {
1935        let bar = Size::new(300.0, NavBarSize::default().height());
1936        let bare: NavigationBarView<()> = navigation_bar(
1937            vec![nav_item("Home").icon(leaf_any(24.0, 24.0))],
1938            0,
1939            |_s: &mut (), _i| {},
1940        );
1941        let mut w = build(&bare);
1942        layout(&mut w, &BoxConstraints::loose(bar));
1943        let bare_icon = w.items[0]
1944            .widget_mut()
1945            .downcast_mut::<NavItemWidget>()
1946            .unwrap()
1947            .icon
1948            .as_ref()
1949            .expect("icon pod present")
1950            .size();
1951        assert_eq!(bare_icon, Size::new(24.0, 24.0));
1952
1953        let tapped: Rc<Cell<Option<usize>>> = Rc::new(Cell::new(None));
1954        let sink = tapped.clone();
1955        let badged: NavigationBarView<()> = navigation_bar(
1956            vec![
1957                nav_item("Home").icon(leaf_any(24.0, 24.0)).badge_count(3),
1958                nav_item("Search").icon(leaf_any(24.0, 24.0)).badge_dot(),
1959            ],
1960            0,
1961            move |_s: &mut (), i| sink.set(Some(i)),
1962        );
1963        let mut w = build(&badged);
1964        layout(&mut w, &BoxConstraints::loose(bar));
1965        let item = w.items[0]
1966            .widget_mut()
1967            .downcast_mut::<NavItemWidget>()
1968            .unwrap();
1969        let badged_icon = item.icon.as_ref().expect("icon pod present").size();
1970        assert!(
1971            badged_icon.width > bare_icon.width,
1972            "the badge composes onto the icon: {badged_icon:?}"
1973        );
1974        assert!(badged_icon.height <= INDICATOR_H, "still inside the box");
1975        // The item's own slot — and therefore its hit target — is untouched.
1976        assert_eq!(
1977            w.items[1].size(),
1978            Size::new(150.0, NavBarSize::default().height())
1979        );
1980
1981        let mut unit = ();
1982        let unit_any: &mut dyn Any = &mut unit;
1983        let mut ectx = EventCtx::new(
1984            unit_any,
1985            Point::ZERO,
1986            Size::new(300.0, NavBarSize::default().height()),
1987        );
1988        w.event(&mut ectx, &ev(PointerPhase::Down, 200.0, 20.0));
1989        w.event(&mut ectx, &ev(PointerPhase::Up, 200.0, 20.0));
1990        assert_eq!(
1991            tapped.get(),
1992            Some(1),
1993            "a badged bar still reports the tapped index"
1994        );
1995    }
1996
1997    // ---- Colors ------------------------------------------------------------
1998
1999    #[test]
2000    fn unthemed_paint_uses_fallback_container() {
2001        let view: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
2002        let mut w = build(&view);
2003        layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
2004        let mut scene = RecordingScene::default();
2005        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(300.0, HEIGHT_SMALL));
2006        w.paint(&mut pctx, &mut scene);
2007        assert_eq!(scene.rects[0].1, Size::new(300.0, HEIGHT_SMALL));
2008    }
2009
2010    #[test]
2011    fn themed_paint_resolves_surface_container_and_the_indicator_role() {
2012        let theme = crate::baseline();
2013        let view: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
2014        let mut w = build(&view);
2015        layout_themed(
2016            &mut w,
2017            &BoxConstraints::loose(Size::new(300.0, 200.0)),
2018            &theme,
2019        );
2020
2021        #[derive(Default)]
2022        struct ColorRecorder {
2023            colors: Vec<Color>,
2024            rounded: Vec<Color>,
2025        }
2026        impl PaintScene for ColorRecorder {
2027            fn fill_rect(&mut self, _o: Point, _s: Size, c: Color) {
2028                self.colors.push(c);
2029            }
2030            fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, c: Color) {
2031                self.rounded.push(c);
2032            }
2033            fn draw_text(&mut self, _o: Point, _t: &str) {}
2034        }
2035        let mut scene = ColorRecorder::default();
2036        let mut pctx = PaintCtx::for_test(Point::ZERO, Size::new(300.0, HEIGHT_SMALL), ft(0.0))
2037            .with_theme(&theme);
2038        w.paint(&mut pctx, &mut scene);
2039        assert_eq!(scene.colors[0], theme.scheme().surface_container);
2040        assert_eq!(scene.rounded[0], theme.scheme().secondary_container);
2041    }
2042
2043    // ---- Controlled semantics ---------------------------------------------
2044
2045    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
2046        InputEvent::Pointer(PointerEvent {
2047            phase,
2048            position: Point::new(x, y),
2049            button: PointerButton::Primary,
2050        })
2051    }
2052
2053    // `state` must be a concrete `&mut Vec<usize>`: it is erased to `&mut dyn
2054    // Any` and recovered via `state_mut::<Vec<usize>>()`, so a slice would fail
2055    // the downcast (mirrors `lib.rs`'s identical `route` helper).
2056    #[allow(clippy::ptr_arg)]
2057    fn dispatch(w: &mut NavigationBarWidget, state: &mut Vec<usize>, event: &InputEvent) {
2058        let state_any: &mut dyn Any = state;
2059        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(300.0, HEIGHT_SMALL));
2060        w.event(&mut ctx, event);
2061    }
2062
2063    #[test]
2064    fn tap_inside_an_item_reports_its_index_without_self_mutating() {
2065        let view: NavigationBarView<Vec<usize>> = navigation_bar(
2066            vec![nav_item("Home"), nav_item("Search"), nav_item("Profile")],
2067            0,
2068            |s: &mut Vec<usize>, i| s.push(i),
2069        );
2070        let mut counter = 0u64;
2071        let mut w = View::<Vec<usize>>::build(&view, &mut ctx(&mut counter));
2072        layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
2073
2074        let mut log: Vec<usize> = Vec::new();
2075        // Item 1 ("Search") occupies x in [100, 200).
2076        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 150.0, 20.0));
2077        dispatch(&mut w, &mut log, &ev(PointerPhase::Up, 150.0, 20.0));
2078        assert_eq!(log, vec![1]);
2079        // Controlled: the widget's own selection never moved.
2080        assert_eq!(w.selected, 0);
2081        let item = w.items[1]
2082            .widget_mut()
2083            .downcast_mut::<NavItemWidget>()
2084            .unwrap();
2085        assert!(!item.selected, "a tap alone never selects");
2086    }
2087
2088    #[test]
2089    fn rebuild_adopts_new_selected_index() {
2090        let mut counter = 0u64;
2091        let prev: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
2092        let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
2093        let widget0 = w.items[0]
2094            .widget_mut()
2095            .downcast_mut::<NavItemWidget>()
2096            .unwrap();
2097        assert!(widget0.selected);
2098
2099        let next: NavigationBarView<()> = navigation_bar(three_items(), 2, |_s: &mut (), _i| {});
2100        let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
2101        assert!(flags.needs_paint());
2102        assert!(
2103            flags.needs_layout(),
2104            "the pill's travel is resolved in layout"
2105        );
2106
2107        let widget0 = w.items[0]
2108            .widget_mut()
2109            .downcast_mut::<NavItemWidget>()
2110            .unwrap();
2111        assert!(!widget0.selected, "selection moved off item 0");
2112        let widget2 = w.items[2]
2113            .widget_mut()
2114            .downcast_mut::<NavItemWidget>()
2115            .unwrap();
2116        assert!(widget2.selected, "item 2 is now selected");
2117        assert_eq!(w.selected, 2);
2118        assert!(w.selection_moved, "the next layout starts the travel");
2119    }
2120
2121    #[test]
2122    fn growing_item_list_builds_a_fresh_unselected_item() {
2123        let mut counter = 0u64;
2124        let prev: NavigationBarView<()> = navigation_bar(
2125            vec![nav_item("Home"), nav_item("Search")],
2126            0,
2127            |_s: &mut (), _i| {},
2128        );
2129        let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
2130        assert_eq!(w.items.len(), 2);
2131
2132        let next: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
2133        let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
2134        assert_eq!(w.items.len(), 3);
2135        assert!(flags.needs_layout());
2136    }
2137
2138    #[test]
2139    fn shrinking_item_list_tears_down_the_removed_item() {
2140        let mut counter = 0u64;
2141        let prev: NavigationBarView<()> = navigation_bar(three_items(), 0, |_s: &mut (), _i| {});
2142        let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
2143
2144        let next: NavigationBarView<()> =
2145            navigation_bar(vec![nav_item("Home")], 0, |_s: &mut (), _i| {});
2146        let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
2147        assert_eq!(w.items.len(), 1);
2148        assert!(flags.needs_layout());
2149    }
2150
2151    #[test]
2152    fn a_selection_past_the_last_item_still_places_the_indicator() {
2153        let mut counter = 0u64;
2154        let prev: NavigationBarView<()> = navigation_bar(three_items(), 2, |_s: &mut (), _i| {});
2155        let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
2156        let next: NavigationBarView<()> =
2157            navigation_bar(vec![nav_item("Home")], 2, |_s: &mut (), _i| {});
2158        View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
2159        layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
2160        let pill = w.indicator.pill_rect().expect("clamped onto the last item");
2161        assert!((pill.center().x - 150.0).abs() < 1e-9);
2162    }
2163
2164    #[test]
2165    fn icon_slot_is_routed_and_laid_out_above_the_label() {
2166        let view: NavigationBarView<()> = navigation_bar(
2167            vec![nav_item("Home").icon(leaf_any(24.0, 24.0))],
2168            0,
2169            |_s: &mut (), _i| {},
2170        );
2171        let mut w = build(&view);
2172        layout(&mut w, &BoxConstraints::loose(Size::new(100.0, 200.0)));
2173        let widget0 = w.items[0]
2174            .widget_mut()
2175            .downcast_mut::<NavItemWidget>()
2176            .unwrap();
2177        let icon = widget0.icon.as_ref().expect("icon pod present");
2178        let label = widget0.label.as_ref().expect("label pod present");
2179        assert!(icon.origin().y < label.origin().y);
2180    }
2181
2182    #[test]
2183    fn semantics_yields_a_tablist_of_tabs_with_selection() {
2184        fn logic(_state: &mut ()) -> NavigationBarView<()> {
2185            navigation_bar(three_items(), 1, |_s: &mut (), _i| {})
2186        }
2187        let mut root: frust_core::RenderRoot<(), NavigationBarView<()>> =
2188            frust_core::RenderRoot::new();
2189        let mut state = ();
2190        root.rebuild(&mut logic, &mut state);
2191        let mut tcx = TextContext::new();
2192        root.layout_with_text(Size::new(300.0, HEIGHT_SMALL), &mut tcx as &mut dyn Any);
2193        let update = root.semantics();
2194
2195        let tablist = update
2196            .nodes
2197            .iter()
2198            .find(|(_, n)| n.role() == Role::TabList)
2199            .expect("a TabList container node is contributed");
2200        assert_eq!(tablist.1.children().len(), 3);
2201
2202        let tabs: Vec<_> = update
2203            .nodes
2204            .iter()
2205            .filter(|(_, n)| n.role() == Role::Tab)
2206            .collect();
2207        assert_eq!(tabs.len(), 3);
2208        let selected_tab = tabs
2209            .iter()
2210            .find(|(_, n)| n.label() == Some("Search"))
2211            .expect("the Search tab is present");
2212        assert_eq!(selected_tab.1.is_selected(), Some(true));
2213        let unselected_tab = tabs
2214            .iter()
2215            .find(|(_, n)| n.label() == Some("Home"))
2216            .expect("the Home tab is present");
2217        assert_eq!(unselected_tab.1.is_selected(), Some(false));
2218    }
2219
2220    #[test]
2221    fn a_hidden_label_still_reaches_the_semantics_node() {
2222        fn logic(_state: &mut ()) -> NavigationBarView<()> {
2223            navigation_bar(three_items(), 1, |_s: &mut (), _i| {})
2224                .label_behavior(NavBarLabelBehavior::AlwaysHide)
2225        }
2226        let mut root: frust_core::RenderRoot<(), NavigationBarView<()>> =
2227            frust_core::RenderRoot::new();
2228        let mut state = ();
2229        root.rebuild(&mut logic, &mut state);
2230        let mut tcx = TextContext::new();
2231        root.layout_with_text(Size::new(300.0, HEIGHT_SMALL), &mut tcx as &mut dyn Any);
2232        let update = root.semantics();
2233        let labelled = update
2234            .nodes
2235            .iter()
2236            .filter(|(_, n)| n.role() == Role::Tab && n.label().is_some())
2237            .count();
2238        assert_eq!(
2239            labelled, 3,
2240            "every tab keeps its label with no text painted"
2241        );
2242    }
2243
2244    #[cfg(feature = "bundled-fonts")]
2245    #[test]
2246    fn every_label_paints_in_roboto_flex_under_the_material_theme() {
2247        // Selected and unselected labels alike (they differ only in color
2248        // role) — see `crate::appbar::top::typeface_probe`.
2249        use crate::appbar::top::typeface_probe::{Face, assert_paints_only_in};
2250        let flex = crate::tokens::font_data()[0];
2251        let runs = assert_paints_only_in(
2252            "the navigation bar's labels",
2253            |_: &mut ()| navigation_bar(three_items(), 1, |_s: &mut (), _i| {}),
2254            crate::baseline(),
2255            &[flex],
2256            Size::new(300.0, HEIGHT_SMALL),
2257            Face {
2258                bytes: flex,
2259                name: "Roboto Flex",
2260            },
2261        );
2262        assert!(
2263            runs >= 3,
2264            "fixture sanity: expected a run per label, got {runs}"
2265        );
2266    }
2267}