Skip to main content

frus_widgets/
navrail.rs

1//! [`NavigationRail`] and [`BottomBar`]: the two presentations of a single-selection
2//! **main navigation**. Same API (`new(selected, on_select).item(icon, label)`);
3//! [`crate::NavScaffold`] picks one or the other by size.
4//!
5//! A destination's icon is an [`IconData`] — one of the bundled set, or a caller's own —
6//! or a **text glyph**, an emoji or a Unicode character, which is all there was before
7//! milestone 472 brought an icon set. Both go through the same builder: `item` takes
8//! anything a [`DestinationIcon`] can be made from.
9//!
10//! A whole destination is also a value, [`NavigationDestination`], so that an application
11//! that adapts across widths declares its navigation **once** and hands the same list to
12//! whichever chrome the size class called for.
13//!
14//! The rail has three things a column of its own height can have and a single row
15//! cannot: an [extended](NavigationRail::extended) form, a slot
16//! [above](NavigationRail::leading) and [below](NavigationRail::trailing) the
17//! destinations, and a say in [where those destinations
18//! sit](NavigationRail::group_alignment) between its two ends.
19
20use std::cell::{OnceCell, RefCell};
21
22use frus_core::{Color, Insets, Point, Rect, ResolvedTextStyle, Scene, Size, TextStyle};
23use frus_layout::{Align, Dimension, FlexDirection, Justify, Style};
24
25use frus_core::ShapeBorder;
26
27use crate::icons::IconData;
28use crate::widgetstate::WidgetStateProperty;
29
30use crate::disabled::disabled_content;
31use crate::flex::Flex;
32use crate::interaction::Status;
33use crate::scroll::SingleChildScrollView;
34use crate::theme::Theme;
35use crate::widget::Widget;
36
37/// **When a navigation widget shows its destinations' labels.**
38///
39/// The reference keeps two names for one idea — `NavigationRailLabelType`
40/// (`navigation_rail.dart:1238`) and `NavigationDestinationLabelBehavior`
41/// (`navigation_bar.dart:342`) — and gives them **different defaults**, which is the part
42/// worth knowing: a rail shows no labels until asked, a bar shows all of them.
43///
44/// The reason is what each is for. A rail stands beside a page it does not own, and glyphs
45/// alone keep it narrow; a bar owns the bottom of the screen and has the room to say what
46/// its destinations are.
47#[derive(Copy, Clone, Debug, PartialEq, Eq)]
48pub enum RailLabels {
49    /// Never — glyphs alone. A rail's default.
50    None,
51    /// On the selected destination only, so the one that matters names itself.
52    Selected,
53    /// On every destination. A bar's default.
54    All,
55}
56
57impl RailLabels {
58    /// Whether destination `index` shows its label, `selected` being the live one.
59    fn shows(self, index: usize, selected: usize) -> bool {
60        match self {
61            RailLabels::None => false,
62            RailLabels::Selected => index == selected,
63            RailLabels::All => true,
64        }
65    }
66}
67
68/// Width of a vertical rail, in logical pixels.
69pub(crate) const RAIL_WIDTH: f32 = 80.0;
70/// Width of an **extended** rail (`navigation_rail.dart:1241`), where the labels stand
71/// beside the glyphs instead of under them.
72pub(crate) const EXTENDED_RAIL_WIDTH: f32 = 256.0;
73/// Height of a bottom navigation bar, in logical pixels.
74pub(crate) const BAR_HEIGHT: f32 = 60.0;
75const ITEM_HEIGHT: f32 = 58.0;
76/// The destinations' glyphs, at the reference's size (`navigation_bar.dart:1452`).
77const ICON_SIZE: f32 = 24.0;
78/// The air between a glyph and its label (`navigation_bar.dart:1483`).
79const LABEL_GAP: f32 = 4.0;
80/// The air between two destinations.
81const DESTINATION_GAP: f32 = 4.0;
82/// The air a slot keeps from the destinations (`navigation_rail.dart:1179`).
83const SLOT_GAP: f32 = 8.0;
84
85/// The item's label: what the caller said, else what the theme says, else the reference's
86/// — a Material 3 rail labels its destinations in `labelMedium`.
87///
88/// **Resolved once** so that the number the bar is measured with is the number the glyphs
89/// are drawn at. Resolving is the single place the reader's font setting is applied
90/// (milestone 403).
91fn label_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
92    over.or(theme.and_then(|t| t.widgets.nav_rail.label_text_style))
93        .unwrap_or_else(|| crate::theme::type_scale(theme).label_medium)
94        .resolved()
95}
96
97/// The notification count — `labelSmall`, the step [`crate::Badge`] already reads. See
98/// [`label_style`].
99fn badge_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
100    over.or(theme.and_then(|t| t.widgets.nav_rail.badge_text_style))
101        .unwrap_or_else(|| crate::theme::type_scale(theme).label_small)
102        .resolved()
103}
104
105/// The height an item needs: the constant, unless the icon and a label the reader asked to
106/// enlarge no longer fit inside it.
107///
108/// A destination with **no** label still keeps the floor: a rail whose rows shrank when the
109/// labels went away would move every destination the first time one was selected under
110/// [`RailLabels::Selected`].
111///
112/// `beside` is an [extended](NavigationRail::extended) rail's row, where the label stands
113/// next to the glyph rather than under it: the row is then as tall as the taller of the
114/// two, not as tall as both.
115fn item_height(floor: f32, label: Option<&ResolvedTextStyle>, beside: bool) -> f32 {
116    let icon = frus_text::line_height(ICON_SIZE);
117    let content = match label {
118        Some(label) if beside => icon.max(label.line_height()),
119        Some(label) => icon + LABEL_GAP + label.line_height(),
120        None => icon,
121    } + 8.0;
122    floor.max(content)
123}
124
125/// One navigation destination (glyph + label), painted according to its state.
126struct NavItem<Msg> {
127    icon: DestinationIcon,
128    label: String,
129    selected: bool,
130    /// Notification count (a dot on the icon). `0`/`None` = nothing.
131    badge: Option<u32>,
132    /// `true` = a rail item (fixed width); `false` = a bar item (flex).
133    rail: bool,
134    /// Whether this destination says what it is. See [`RailLabels`].
135    show_label: bool,
136    /// Whether the label stands **beside** the glyph rather than under it. See
137    /// [`NavigationRail::extended`].
138    extended: bool,
139    /// This destination cannot be reached. See [`NavigationRail::disabled`].
140    disabled: bool,
141    /// This destination's own indicator colour, over the theme's.
142    indicator_color: Option<Color>,
143    /// This destination's own indicator shape, over the theme's.
144    indicator_shape: Option<ShapeBorder>,
145    /// This destination's own highlight, per state, over the framework's state layer.
146    overlay_color: Option<WidgetStateProperty<Color>>,
147    /// Whether a selected destination gets an indicator behind its glyph at all. See
148    /// [`NavigationRail::use_indicator`].
149    use_indicator: bool,
150    /// The surface the destination **stands on**, when the widget carrying it was told
151    /// one. A state layer is a lerp from the ground toward the ink, so it has to be the
152    /// ground the caller actually painted.
153    ground: Option<Color>,
154    label_text_style: Option<TextStyle>,
155    badge_text_style: Option<TextStyle>,
156    /// Which destination this is, and how many there are — for what a reader hears, and
157    /// nothing else. See [`NavItem::semantics`].
158    index: usize,
159    count: usize,
160    message: Msg,
161}
162
163impl<Msg> NavItem<Msg> {
164    fn sizing(&self, theme: Option<&Theme>) -> Style {
165        let label = self
166            .show_label
167            .then(|| label_style(self.label_text_style, theme));
168        if self.rail {
169            Style {
170                width: Dimension::Length(if self.extended {
171                    EXTENDED_RAIL_WIDTH
172                } else {
173                    RAIL_WIDTH
174                }),
175                height: Dimension::Length(item_height(ITEM_HEIGHT, label.as_ref(), self.extended)),
176                ..Default::default()
177            }
178        } else {
179            // In a bar, the items share the width equally.
180            Style {
181                flex_grow: 1.0,
182                height: Dimension::Length(item_height(BAR_HEIGHT, label.as_ref(), false)),
183                ..Default::default()
184            }
185        }
186    }
187}
188
189impl<Msg: Clone> Widget<Msg> for NavItem<Msg> {
190    fn style(&self) -> Style {
191        self.sizing(None)
192    }
193
194    fn style_themed(&self, theme: &Theme) -> Style {
195        self.sizing(Some(theme))
196    }
197
198    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
199        &[]
200    }
201
202    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
203        let o = status.opacity;
204        // A drawn icon is a square of its own size; a glyph is measured at `exact`, so
205        // that it stays on its own grid while the label beside it follows the reader.
206        let t = &theme.widgets.nav_rail;
207        let icon_size = t.icon_size.unwrap_or(ICON_SIZE);
208        let label_s = label_style(self.label_text_style, Some(theme));
209        let icon_m = self.icon.measure(icon_size);
210        let label_m = self
211            .show_label
212            .then(|| frus_text::measure_resolved(&self.label, &label_s));
213        let gap = LABEL_GAP;
214        // **The column the glyph lives in**, which is not the row on an extended rail.
215        // There the label stands beside the glyph (`navigation_rail.dart:796`) and the
216        // glyph keeps the column it would have had unextended (`:753`) — so extending a
217        // rail does not move its destinations sideways, and the indicator stays around
218        // the glyph alone (`:756`) rather than growing to swallow the label.
219        let col = if self.extended {
220            RAIL_WIDTH.min(bounds.width)
221        } else {
222            bounds.width
223        };
224        // With no label the glyph centres on its own, rather than staying where it sat
225        // when there was one below it.
226        let stacked_h = icon_m.height + label_m.as_ref().map_or(0.0, |m| gap + m.height);
227        let top = bounds.y
228            + ((bounds.height
229                - if self.extended {
230                    icon_m.height
231                } else {
232                    stacked_h
233                })
234                * 0.5)
235                .max(0.0);
236
237        // Background pill: solid when selected, discreet on hover.
238        let pill_w = icon_m.width + 28.0;
239        let pill_h = icon_m.height + 8.0;
240        let pill = Rect::new(bounds.x + (col - pill_w) * 0.5, top - 4.0, pill_w, pill_h);
241        // **The ground this destination stands on**, which is what a state layer is
242        // measured from: the indicator when it has one, else the surface the rail or the
243        // bar painted under it — the caller's, the theme's, or the rung each of the two
244        // takes by default (milestone 427).
245        let ground = self.ground.or(if self.rail {
246            theme.widgets.nav_rail.background_color
247        } else {
248            theme.widgets.nav_rail.bar_background_color
249        });
250        let ground = ground.unwrap_or(if self.rail {
251            theme.scheme.surface
252        } else {
253            theme.scheme.surface_container
254        });
255        // The indicator is a **container**, not a wash: the reference fills it with an
256        // opaque `secondaryContainer` (`navigation_bar.dart:1463`,
257        // `navigation_rail.dart:1272`) where this painted `primary` at 16 %. A tint was
258        // the wrong role and the wrong kind of colour at once — a translucent fill blends
259        // in linear light here, so 16 % does not paint at 16 %, which is the trap
260        // milestone 329 resolved for the disabled tokens.
261        //
262        // The destination's own colour outranks the theme's (`navigation_rail.dart:1144`),
263        // which is how one entry in a list marks itself out from the rest.
264        let indicator = self
265            .indicator_color
266            .or(t.indicator_color)
267            .unwrap_or(theme.scheme.secondary_container);
268        // **And whether there is an indicator at all** (`navigation_rail.dart:310`).
269        // Turned off, the selected destination has no container behind it, so its ground
270        // is the rail's surface like everyone else's — which is what the state layer is
271        // then measured from.
272        let lit = self.selected && self.use_indicator;
273        let base = if lit { indicator } else { ground };
274        // **And the state layer over it, resolved opaquely.** This used to be `muted` at
275        // 12 % handed to the GPU as an alpha, which is three mistakes in one line: the
276        // wrong kind of colour (a translucent overlay blends in linear light here, so
277        // 12 % paints like a third), the wrong role (the reference's ink for this widget
278        // is `primary`, `navigation_rail.dart:946`), and the wrong number (12 % is the
279        // *splash*'s, `:943`; the hover's is smaller). [`Theme::state_layer`] is the one
280        // rule the rest of the framework already asks — a lerp from the ground toward the
281        // ink, in the space the tokens were written in — and it answers hover, focus and
282        // press at once, where this answered only hover.
283        //
284        // Nothing lights on a destination that cannot be reached: a state layer is the
285        // promise of an interaction, and there is none here (milestone 436).
286        //
287        // Unless the destination was given a colour of its own for the state it is in
288        // (`navigation_bar.dart:232`). The reference's overlay is a translucent colour
289        // painted over the ground, so resolved opaquely here its **alpha is how far** the
290        // ground moves and its colour is **where** it moves to — the same arithmetic
291        // `state_layer` does, with the caller's numbers instead of Material's.
292        let states = status
293            .states()
294            .set(crate::WidgetState::Selected, self.selected)
295            .set(crate::WidgetState::Disabled, self.disabled);
296        // The destination's own word, then the theme's, then nothing — and *nothing* is
297        // the state layer below. Three rungs, as everywhere else here; a property that
298        // matches no entry falls through to the next rung rather than answering with a
299        // default, which is what makes naming one state safe.
300        let told = self
301            .overlay_color
302            .as_ref()
303            .and_then(|own| own.resolve(states))
304            .or_else(|| t.overlay_color.as_ref().and_then(|w| w.resolve(states)));
305        let fill = if self.disabled {
306            base
307        } else if let Some(overlay) = told {
308            base.lerp(overlay.fade(1.0), overlay.a)
309        } else {
310            theme.state_layer(base, theme.scheme.primary, &status)
311        };
312        if lit || fill != base {
313            // **A pill, and it can say so** (`navigation_rail.dart:1148`). Until
314            // milestone 450 this was `pill_h * 0.5` — the arithmetic a stadium does, but
315            // written out here, so a caller who wanted a rounded box or a circle had
316            // nowhere to say it and the shape had to be inferred from a number.
317            let shape = self
318                .indicator_shape
319                .or(t.indicator_shape)
320                .unwrap_or_else(ShapeBorder::stadium);
321            scene.draw_shape(pill, shape, fill.fade(o));
322        }
323
324        // The glyph is drawn **on** the indicator and the label below it, so the two do
325        // not take the same colour when selected: the glyph is the indicator's content
326        // (`navigation_bar.dart:1456`) and the label is the surface's (`:1476`).
327        let icon_color = if self.disabled {
328            // **One rule for both halves** (`navigation_rail.dart:717`, `:723`):
329            // `on_surface` at 38 %, which this framework resolves opaque rather than
330            // handing the GPU an alpha — see [`crate::disabled_content`].
331            disabled_content(theme)
332        } else if self.selected {
333            t.selected_icon_color.unwrap_or(if self.use_indicator {
334                theme.scheme.on_secondary_container
335            } else {
336                // With no indicator behind it the glyph stands on the rail's own surface,
337                // and the indicator's content colour would be a colour for a ground that
338                // is not there. The reference's arrangement without one paints both the
339                // glyph and the label the **accent** (`navigation_rail.dart:1221`,
340                // `:1211`) — with nothing behind it, the destination has to say *this
341                // one* by itself.
342                theme.primary
343            })
344        } else {
345            t.unselected_icon_color
346                .unwrap_or(theme.scheme.on_surface_variant)
347        };
348        let label_color = if self.disabled {
349            disabled_content(theme)
350        } else if self.selected {
351            t.selected_label_color.unwrap_or(if self.use_indicator {
352                theme.scheme.on_surface
353            } else {
354                theme.primary
355            })
356        } else {
357            t.unselected_label_color.unwrap_or(if self.rail {
358                // The one place the reference answers differently for the two:
359                // `navigation_rail.dart:1251` against `navigation_bar.dart:1477`.
360                theme.scheme.on_surface
361            } else {
362                theme.scheme.on_surface_variant
363            })
364        };
365        self.icon.paint(
366            icon_size,
367            bounds.x + (col - icon_m.width) * 0.5,
368            top,
369            icon_color.fade(o),
370            theme,
371            scene,
372        );
373        if let Some(label_m) = &label_m {
374            // Beside the glyph's column on an extended rail, centred under it otherwise.
375            let position = if self.extended {
376                Point::new(
377                    bounds.x + col,
378                    bounds.y + ((bounds.height - label_m.height) * 0.5).max(0.0),
379                )
380            } else {
381                Point::new(
382                    bounds.x + (col - label_m.width) * 0.5,
383                    top + icon_m.height + gap,
384                )
385            };
386            scene.text(position, self.label.clone(), &label_s, label_color.fade(o));
387        }
388
389        // Notification dot, anchored to the top-right corner of the icon glyph.
390        if let Some(count) = self.badge.filter(|&n| n > 0) {
391            let text = if count > 99 {
392                "99+".to_string()
393            } else {
394                count.to_string()
395            };
396            let badge_s = badge_style(self.badge_text_style, Some(theme));
397            let m = frus_text::measure_resolved(&text, &badge_s);
398            let bw = (m.width + 8.0).max(m.height + 4.0);
399            let bh = m.height + 4.0;
400            let icon_right = bounds.x + (col + icon_m.width) * 0.5;
401            let bx = (icon_right - bw * 0.4).min(bounds.x + bounds.width - bw);
402            let by = top - bh * 0.35;
403            let rect = Rect::new(bx, by, bw, bh);
404            // A badge is a badge. This one used to carry a red of its own, on the
405            // reasoning that an alert dot reads as red whatever the theme — but the
406            // [`Badge`](crate::Badge) widget beside it already answers the same question
407            // from the scheme's `error`, and two badges in one framework painting
408            // different reds is the part that is actually wrong. Same roles, same theme,
409            // so recolouring one recolours both.
410            let fill = theme
411                .widgets
412                .badge
413                .background_color
414                .unwrap_or(theme.scheme.error);
415            let ink = theme
416                .widgets
417                .badge
418                .text_color
419                .unwrap_or(theme.scheme.on_error);
420            scene.draw_rect(rect, fill.fade(o), bh * 0.5, 0.0, Color::TRANSPARENT);
421            scene.text(
422                Point::new(bx + (bw - m.width) * 0.5, by + 2.0),
423                text,
424                &badge_s,
425                ink.fade(o),
426            );
427        }
428    }
429
430    /// A destination that cannot be reached emits nothing (`navigation_rail.dart:957`,
431    /// where the reference passes the ink well a null `onTap`).
432    fn on_click(&self) -> Option<Msg> {
433        (!self.disabled).then(|| self.message.clone())
434    }
435
436    /// And the keyboard skips it, as it skips a disabled button.
437    fn focusable(&self) -> bool {
438        !self.disabled
439    }
440
441    /// **What a reader hears.** Until milestone 467 the answer was *nothing*: a rail and a
442    /// bar are the primary navigation of an application and neither announced a single
443    /// destination, so a screen reader walking the shell found a row of unlabelled boxes
444    /// and no way to tell which screen was showing. Every visible part of it was right.
445    ///
446    /// The name, then where it sits in the list — the reference reads out "Home, Tab 1 of
447    /// 3" (`navigation_rail.dart:533`, `navigation_bar.dart:1020`), and the second half
448    /// is the part a list cannot supply on its own: someone hearing five names in a row
449    /// has no way to know how many are left. **The label is announced whether or not it is
450    /// drawn** — [`RailLabels::None`] is a decision about width, and a reader has no width.
451    ///
452    /// *Which one is live* survives being disabled: a reader who cannot switch is still
453    /// owed where they are.
454    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
455        let words = crate::localizations::of();
456        let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Tab)
457            .label(format!(
458                "{}, {}",
459                self.label,
460                words.tab_label(self.index + 1, self.count)
461            ))
462            .toggled(self.selected);
463        Some(if self.disabled {
464            semantics.disabled(true)
465        } else {
466            semantics.clickable()
467        })
468    }
469}
470
471/// **What a destination shows** above or beside its label: a drawn icon, or a text glyph.
472///
473/// Both, because both are real. An [`IconData`] is what an application reaches for now
474/// that there is an icon set — it scales, it takes the theme's colour, and it is the same
475/// mark at every size. A glyph is an emoji or a Unicode character, which is what a
476/// destination could carry before there was a set, and is still the shortest way to put a
477/// flag or a rocket in a bar.
478///
479/// Nothing has to name this type: [`From`] covers `&str`, `String`, `char` and
480/// [`IconData`], so `item(Icons::HOME, "Home")` and `item("🏠", "Home")` are both just
481/// calls to `item`.
482#[derive(Clone, Debug, PartialEq)]
483pub enum DestinationIcon {
484    /// A drawn icon — one of the bundled set, or one of the caller's own.
485    Icon(IconData),
486    /// A text glyph: an emoji, or a Unicode character.
487    Glyph(String),
488}
489
490impl Default for DestinationIcon {
491    /// An empty glyph — a destination whose icon nobody named draws nothing, rather than
492    /// standing in a placeholder mark of the framework's choosing.
493    fn default() -> Self {
494        DestinationIcon::Glyph(String::new())
495    }
496}
497
498impl From<IconData> for DestinationIcon {
499    fn from(icon: IconData) -> Self {
500        DestinationIcon::Icon(icon)
501    }
502}
503
504impl From<String> for DestinationIcon {
505    fn from(glyph: String) -> Self {
506        DestinationIcon::Glyph(glyph)
507    }
508}
509
510impl From<&str> for DestinationIcon {
511    fn from(glyph: &str) -> Self {
512        DestinationIcon::Glyph(glyph.to_string())
513    }
514}
515
516impl From<&String> for DestinationIcon {
517    fn from(glyph: &String) -> Self {
518        DestinationIcon::Glyph(glyph.clone())
519    }
520}
521
522impl From<char> for DestinationIcon {
523    fn from(glyph: char) -> Self {
524        DestinationIcon::Glyph(glyph.to_string())
525    }
526}
527
528impl DestinationIcon {
529    /// The box this mark occupies when drawn at `size`.
530    ///
531    /// A drawn icon is a square of its own size, by definition. A glyph is whatever the
532    /// font makes of it, which is why the two cannot share one number: an emoji is wider
533    /// than it is tall in some faces and narrower in others, and a bar whose pill was
534    /// sized from the icon grid rather than from the glyph would clip it.
535    pub(crate) fn measure(&self, size: f32) -> Size {
536        match self {
537            DestinationIcon::Icon(_) => Size::new(size, size),
538            DestinationIcon::Glyph(glyph) => {
539                frus_text::measure_resolved(glyph, &ResolvedTextStyle::exact(size))
540            }
541        }
542    }
543
544    /// Paints the mark with its top-left corner at `(x, y)`, in `color`.
545    pub(crate) fn paint(
546        &self,
547        size: f32,
548        x: f32,
549        y: f32,
550        color: Color,
551        theme: &Theme,
552        scene: &mut Scene,
553    ) {
554        match self {
555            DestinationIcon::Icon(icon) => {
556                scene.fill_path(&icon.placed(size, x, y, theme.direction), color);
557            }
558            DestinationIcon::Glyph(glyph) => {
559                if !glyph.is_empty() {
560                    scene.text(
561                        Point::new(x, y),
562                        glyph.clone(),
563                        &ResolvedTextStyle::exact(size),
564                        color,
565                    );
566                }
567            }
568        }
569    }
570}
571
572/// **A declared destination**: everything the caller said about one entry in the list —
573/// its mark, its name, the mark it swaps to while selected, a badge, a tooltip, and the
574/// decorations that belong to this entry rather than to the list.
575///
576/// It exists as a **value** because an application that adapts across widths has three
577/// chromes to feed and one navigation to describe. [`NavigationRail`], [`BottomBar`],
578/// [`NavigationDrawer`](crate::NavigationDrawer) and
579/// [`NavScaffold`](crate::NavScaffold) all take `destinations`, so the list is written
580/// once and cannot drift between the forms.
581///
582/// ```
583/// use frus_widgets::{Icons, NavigationDestination};
584///
585/// let inbox = NavigationDestination::new(Icons::MAIL_OUTLINE, "Inbox")
586///     .selected_icon(Icons::MAIL)
587///     .badge(3)
588///     .tooltip("Unread messages");
589/// assert_eq!(inbox.label(), "Inbox");
590/// ```
591#[derive(Clone, Default, Debug)]
592pub struct NavigationDestination {
593    /// The mark standing above or beside the label.
594    pub(crate) icon: DestinationIcon,
595    /// What the destination is called.
596    pub(crate) label: String,
597    /// The mark shown **while selected**, when it differs from the resting one
598    /// (`navigation_rail.dart:1132`).
599    pub(crate) selected_icon: Option<DestinationIcon>,
600    /// Notification count. `None` or `0` = nothing.
601    pub(crate) badge: Option<u32>,
602    /// What a pointer resting on this destination is told. A rail showing glyphs without
603    /// labels is the case this exists for (`navigation_rail.dart:1155`).
604    pub(crate) tooltip: Option<String>,
605    /// This destination cannot be reached (`navigation_rail.dart:1161`).
606    pub(crate) disabled: bool,
607    /// This destination's own indicator colour, over the theme's
608    /// (`navigation_rail.dart:1144`).
609    pub(crate) indicator_color: Option<Color>,
610    /// This destination's own indicator shape, over the theme's
611    /// (`navigation_rail.dart:1148`).
612    pub(crate) indicator_shape: Option<ShapeBorder>,
613    /// This destination's own highlight per state, over the framework's state layer
614    /// (`navigation_bar.dart:232`).
615    pub(crate) overlay_color: Option<WidgetStateProperty<Color>>,
616    /// The whole rectangular area behind this destination, when it was given one
617    /// (`navigation_drawer.dart:228`) — not its indicator.
618    ///
619    /// Only a [`NavigationDrawer`](crate::NavigationDrawer)'s rows read it: a rail's
620    /// destination is 80 pixels of an 80-pixel column and a bar's is a share of a row, so
621    /// there is no area behind either that is not the widget's own surface. It lives here
622    /// rather than on the drawer's own list because a shell forwards **one** declaration
623    /// to whichever form the window chose.
624    pub(crate) background: Option<Color>,
625}
626
627impl NavigationDestination {
628    /// A destination with nothing said about it but its mark and its name.
629    pub fn new(icon: impl Into<DestinationIcon>, label: impl Into<String>) -> Self {
630        Self {
631            icon: icon.into(),
632            label: label.into(),
633            ..Default::default()
634        }
635    }
636
637    /// The mark shown **while this destination is selected**, where it differs from the
638    /// resting one (`navigation_rail.dart:1132`).
639    ///
640    /// The convention the icon set is drawn for: an outline at rest, the filled twin when
641    /// selected — `Icons::FAVORITE_BORDER` and `Icons::FAVORITE`, or an outlined style
642    /// against the filled one. It is how a destination reads as selected without leaning
643    /// on colour alone, which is the difference between navigation a colour-blind reader
644    /// can use and navigation they cannot.
645    #[must_use]
646    pub fn selected_icon(mut self, icon: impl Into<DestinationIcon>) -> Self {
647        self.selected_icon = Some(icon.into());
648        self
649    }
650
651    /// A notification count on this destination. `0` shows nothing, so a count straight
652    /// out of a model needs no `if` around it.
653    #[must_use]
654    pub fn badge(mut self, count: u32) -> Self {
655        self.badge = Some(count);
656        self
657    }
658
659    /// What a pointer resting here is told (`navigation_rail.dart:1155`).
660    ///
661    /// A rail shows glyphs without labels by default, which is the case this is for: the
662    /// mark is all a destination says about itself, and a mark is not always enough.
663    #[must_use]
664    pub fn tooltip(mut self, message: impl Into<String>) -> Self {
665        self.tooltip = Some(message.into());
666        self
667    }
668
669    /// Marks this destination unreachable (`navigation_rail.dart:1161`): it takes the
670    /// disabled ink, nothing lights under the pointer, it emits no message, and the
671    /// keyboard steps over it.
672    #[must_use]
673    pub fn disabled(mut self) -> Self {
674        self.disabled = true;
675        self
676    }
677
678    /// This destination's own indicator colour, over the theme's
679    /// (`navigation_rail.dart:1144`).
680    #[must_use]
681    pub fn indicator_color(mut self, color: Color) -> Self {
682        self.indicator_color = Some(color);
683        self
684    }
685
686    /// This destination's own indicator shape, over the theme's
687    /// (`navigation_rail.dart:1148`).
688    #[must_use]
689    pub fn indicator_shape(mut self, shape: ShapeBorder) -> Self {
690        self.indicator_shape = Some(shape);
691        self
692    }
693
694    /// This destination's own highlight per state, over the framework's state layer
695    /// (`navigation_bar.dart:232`).
696    #[must_use]
697    pub fn overlay_color(mut self, overlay: WidgetStateProperty<Color>) -> Self {
698        self.overlay_color = Some(overlay);
699        self
700    }
701
702    /// The whole area behind this destination, edge to edge — read by a
703    /// [`NavigationDrawer`](crate::NavigationDrawer)'s rows and by nothing else
704    /// (`navigation_drawer.dart:228`).
705    #[must_use]
706    pub fn tile_color(mut self, color: Color) -> Self {
707        self.background = Some(color);
708        self
709    }
710
711    /// What this destination is called.
712    pub fn label(&self) -> &str {
713        &self.label
714    }
715
716    /// The mark to draw in this state: the selected one when there is one and the
717    /// destination is selected, the resting one otherwise.
718    pub(crate) fn glyph(&self, selected: bool) -> &DestinationIcon {
719        match &self.selected_icon {
720            Some(icon) if selected => icon,
721            _ => &self.icon,
722        }
723    }
724}
725
726/// **How a list of destinations is being presented** — everything an item needs that is
727/// the same for every item in the list.
728///
729/// One argument rather than six: the two widgets pass the same thing, and a function taking
730/// four booleans and two colours in a row is a function whose call sites are read by
731/// counting commas.
732#[derive(Copy, Clone)]
733struct Presentation {
734    /// `true` = a rail's items (fixed width); `false` = a bar's (they share the row).
735    rail: bool,
736    labels: RailLabels,
737    extended: bool,
738    /// The surface the destinations stand on, when the widget carrying them was told one.
739    ground: Option<Color>,
740    /// Whether a selected destination gets an indicator at all.
741    use_indicator: bool,
742    label_text_style: Option<TextStyle>,
743    badge_text_style: Option<TextStyle>,
744}
745
746/// Builds the navigation items from the declared destinations.
747fn build_items<Msg: Clone + 'static>(
748    items: &[NavigationDestination],
749    selected: usize,
750    on_select: &dyn Fn(usize) -> Msg,
751    how: Presentation,
752) -> Vec<Box<dyn Widget<Msg>>> {
753    items
754        .iter()
755        .enumerate()
756        .map(|(i, item)| {
757            let is_selected = i == selected;
758            let nav = NavItem {
759                icon: item.glyph(is_selected).clone(),
760                label: item.label.clone(),
761                selected: is_selected,
762                badge: item.badge,
763                rail: how.rail,
764                // An extended rail labels **every** destination
765                // (`navigation_rail.dart:219`): the label has its own room there, so
766                // there is nothing for a mode to trade away.
767                show_label: how.extended || how.labels.shows(i, selected),
768                extended: how.extended,
769                disabled: item.disabled,
770                indicator_color: item.indicator_color,
771                indicator_shape: item.indicator_shape,
772                overlay_color: item.overlay_color.clone(),
773                use_indicator: how.use_indicator,
774                ground: how.ground,
775                label_text_style: how.label_text_style,
776                badge_text_style: how.badge_text_style,
777                index: i,
778                count: items.len(),
779                message: on_select(i),
780            };
781            // A destination with a hint is wrapped in the widget that shows hints, rather
782            // than growing a second implementation of one inside the item. The wrapper
783            // forwards the item's own structure, so a bar's destinations still share the
784            // row whether or not they were given a tooltip.
785            match &item.tooltip {
786                Some(message) => Box::new(crate::Tooltip::new(message.clone()).child(nav)),
787                None => Box::new(nav) as Box<dyn Widget<Msg>>,
788            }
789        })
790        .collect()
791}
792
793/// A **vertical** navigation rail (tablet / desktop).
794pub struct NavigationRail<Msg = crate::callback::Callback> {
795    selected: usize,
796    on_select: Box<dyn Fn(usize) -> Msg>,
797    items: Vec<NavigationDestination>,
798    label_text_style: Option<TextStyle>,
799    badge_text_style: Option<TextStyle>,
800    background: Option<Color>,
801    labels: RailLabels,
802    extended: bool,
803    group_alignment: f32,
804    /// See [`Self::main_axis_alignment`]. `Some` fills the group, which is what makes it
805    /// override `group_alignment`.
806    main_axis_alignment: Option<Justify>,
807    /// See [`Self::scrollable`].
808    scrollable: bool,
809    /// See [`Self::use_indicator`]; `None` = the theme's, then the framework's `true`.
810    use_indicator: Option<bool>,
811    /// See [`Self::elevation`]; `None` = the theme's, then the reference's `0`.
812    elevation: Option<f32>,
813    leading: RefCell<Option<Box<dyn Widget<Msg>>>>,
814    trailing: RefCell<Option<Box<dyn Widget<Msg>>>>,
815    leading_at_top: bool,
816    trailing_at_bottom: bool,
817    /// The assembled subtree — see [`Self::assemble`]. Built on the **first read**
818    /// rather than as the builders run, because assembling it consumes the slots and a
819    /// builder can still arrive after the one that set them.
820    built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
821}
822
823impl<Msg: Clone + 'static> NavigationRail<Msg> {
824    /// Creates a rail: `selected` = the active index, `on_select(i)` on click.
825    pub fn new(selected: usize, on_select: impl Fn(usize) -> Msg + 'static) -> Self {
826        Self {
827            selected,
828            on_select: Box::new(on_select),
829            items: Vec::new(),
830            label_text_style: None,
831            badge_text_style: None,
832            background: None,
833            // The reference's default for a **rail** (`navigation_rail.dart:1238`), which
834            // is not the one it gives a bar. A rail stands beside a page it does not own
835            // and glyphs alone keep it narrow.
836            labels: RailLabels::None,
837            extended: false,
838            // Against the top (`navigation_rail.dart:1237`).
839            group_alignment: -1.0,
840            main_axis_alignment: None,
841            scrollable: false,
842            use_indicator: None,
843            elevation: None,
844            leading: RefCell::new(None),
845            trailing: RefCell::new(None),
846            // **The reference's asymmetry** (`navigation_rail.dart:112`, `:113`): the
847            // leading slot is chrome at the top of the rail, the trailing one is the tail
848            // of the list of destinations and travels with it.
849            leading_at_top: true,
850            trailing_at_bottom: false,
851            built: OnceCell::new(),
852        }
853    }
854
855    /// When the destinations say what they are. [`RailLabels::None`] by default, as the
856    /// reference's rail does.
857    ///
858    /// Silent on an [extended](Self::extended) rail, which labels every destination. The
859    /// two are different label **layouts** rather than modes of one another, and the
860    /// reference forbids the combination outright (`navigation_rail.dart:121`).
861    #[must_use]
862    pub fn labels(mut self, labels: RailLabels) -> Self {
863        self.labels = labels;
864        self.rebuild();
865        self
866    }
867
868    /// **The wide form**: 256 across instead of 80, with every label beside its glyph
869    /// instead of under it (`navigation_rail.dart:131`).
870    ///
871    /// The glyphs keep the 80-pixel column they had, so extending a rail widens it and
872    /// moves nothing: the destinations stay on the line they were on and the label opens
873    /// out to the side of them. A rail with room for words is what a desktop window has
874    /// and a tablet in portrait does not, which is why this is a property and not a size
875    /// class — the caller knows which it is building.
876    #[must_use]
877    pub fn extended(mut self, extended: bool) -> Self {
878        self.extended = extended;
879        self.rebuild();
880        self
881    }
882
883    /// **Where the destinations sit** between the rail's top and bottom: `-1.0` against
884    /// the top (the default), `0.0` centred, `1.0` against the bottom — and every value
885    /// in between (`navigation_rail.dart:205`).
886    ///
887    /// Continuous rather than three names, as the reference's is: a rail whose
888    /// destinations sit a third of the way down is a thing an application asks for, and a
889    /// three-valued enum would have to be replaced the first time one did.
890    ///
891    /// It moves the **group**, which is the destinations plus whichever slot is not
892    /// pinned. See [`Self::leading_at_top`].
893    #[must_use]
894    pub fn group_alignment(mut self, alignment: f32) -> Self {
895        self.group_alignment = alignment.clamp(-1.0, 1.0);
896        self.rebuild();
897        self
898    }
899
900    /// The slot **above** the destinations, where an application puts a floating action
901    /// button or a menu button (`navigation_rail.dart:145`).
902    #[must_use]
903    pub fn leading(self, widget: impl Widget<Msg> + 'static) -> Self {
904        self.leading_boxed(Box::new(widget))
905    }
906
907    /// [`Self::leading`], for a slot that is already boxed.
908    #[must_use]
909    pub fn leading_boxed(mut self, widget: Box<dyn Widget<Msg>>) -> Self {
910        *self.leading.borrow_mut() = Some(widget);
911        self.rebuild();
912        self
913    }
914
915    /// The slot **below** the destinations (`navigation_rail.dart:156`) — an account
916    /// switcher, a settings button, the end of the list rather than chrome above it.
917    #[must_use]
918    pub fn trailing(self, widget: impl Widget<Msg> + 'static) -> Self {
919        self.trailing_boxed(Box::new(widget))
920    }
921
922    /// [`Self::trailing`], for a slot that is already boxed.
923    #[must_use]
924    pub fn trailing_boxed(mut self, widget: Box<dyn Widget<Msg>>) -> Self {
925        *self.trailing.borrow_mut() = Some(widget);
926        self.rebuild();
927        self
928    }
929
930    /// **How the destinations are placed along the rail**, when there is room to spare
931    /// (`navigation_rail.dart:371`).
932    ///
933    /// It is the flexbox question — start, centre, end, or one of the three ways of
934    /// spreading the spare room between them — asked of the group rather than of the rail
935    /// around it, so `SpaceEvenly` puts equal room between every destination and at both
936    /// ends.
937    ///
938    /// Saying this **overrides [`Self::group_alignment`]**, and not by a rule: the group
939    /// fills the rail to distribute the room, so there is no room left outside it for an
940    /// alignment to work in. The reference reaches the same place the same way
941    /// (`navigation_rail.dart:501`).
942    #[must_use]
943    pub fn main_axis_alignment(mut self, alignment: Justify) -> Self {
944        self.main_axis_alignment = Some(alignment);
945        self.rebuild();
946        self
947    }
948
949    /// **A rail too short for its destinations scrolls them** (`navigation_rail.dart:353`)
950    /// rather than running them off the bottom of the screen.
951    ///
952    /// The pinned slots stay put — that is what pinning them is for — and what scrolls is
953    /// the group: the destinations, plus whichever of [`Self::leading`] and
954    /// [`Self::trailing`] was left in it.
955    ///
956    /// Like [`Self::main_axis_alignment`], this leaves [`Self::group_alignment`] with
957    /// nothing to do: a viewport that fills the rail has no spare room to place a group
958    /// in. A rail scrolls because it has less room than it needs, which is the case where
959    /// an alignment had no room to work in either.
960    #[must_use]
961    pub fn scrollable(mut self) -> Self {
962        self.scrollable = true;
963        self.rebuild();
964        self
965    }
966
967    /// **Whether the selected destination gets an indicator behind its glyph**
968    /// (`navigation_rail.dart:310`). On unless said otherwise, as it is over there.
969    ///
970    /// Turning it off is not just removing a shape. With nothing behind it the glyph
971    /// stands on the rail's own surface, so the indicator's content colour would be a
972    /// colour for a ground that is not there — the selected destination takes the
973    /// **accent** instead, glyph and label both, which is the arrangement the reference
974    /// keeps for exactly this case (`navigation_rail.dart:1221`, `:1211`).
975    #[must_use]
976    pub fn use_indicator(mut self, use_indicator: bool) -> Self {
977        self.use_indicator = Some(use_indicator);
978        self.rebuild();
979        self
980    }
981
982    /// **How far off the page the rail sits** (`navigation_rail.dart:192`). Flat by
983    /// default, as the reference's is (`:1236`).
984    ///
985    /// A raised rail drops its hairline: a shadow and a rule are two ways of saying the
986    /// same thing, and a widget that draws both says it twice — the lesson
987    /// [`Card`](crate::Card) already carries.
988    #[must_use]
989    pub fn elevation(mut self, elevation: f32) -> Self {
990        self.elevation = Some(elevation);
991        self
992    }
993
994    /// Whether the leading slot is **pinned to the top** instead of travelling with the
995    /// destinations. `true` by default (`navigation_rail.dart:112`), which is why
996    /// [`Self::group_alignment`] leaves it where it is.
997    #[must_use]
998    pub fn leading_at_top(mut self, pinned: bool) -> Self {
999        self.leading_at_top = pinned;
1000        self.rebuild();
1001        self
1002    }
1003
1004    /// Whether the trailing slot is **pinned to the bottom**. `false` by default
1005    /// (`navigation_rail.dart:113`), the other half of the reference's asymmetry: a
1006    /// leading slot is chrome at the top of the rail, a trailing one is the tail of the
1007    /// list of destinations and moves when the list does.
1008    #[must_use]
1009    pub fn trailing_at_bottom(mut self, pinned: bool) -> Self {
1010        self.trailing_at_bottom = pinned;
1011        self.rebuild();
1012        self
1013    }
1014
1015    /// Adds a destination (glyph + label).
1016    pub fn item(mut self, icon: impl Into<DestinationIcon>, label: impl Into<String>) -> Self {
1017        self.items.push(NavigationDestination::new(icon, label));
1018        self.rebuild();
1019        self
1020    }
1021
1022    /// Adds a whole list of destinations **declared elsewhere** — one navigation, handed
1023    /// to whichever chrome the width called for.
1024    ///
1025    /// This is the reason [`NavigationDestination`] is a value. An application that shows
1026    /// a bar when narrow and a rail when wide used to declare its destinations twice, and
1027    /// the two drifted; now it declares them once.
1028    ///
1029    /// ```
1030    /// use frus_widgets::{Icons, NavigationDestination, NavigationRail};
1031    ///
1032    /// let places = vec![
1033    ///     NavigationDestination::new(Icons::FAVORITE_BORDER, "Saved")
1034    ///         .selected_icon(Icons::FAVORITE),
1035    ///     NavigationDestination::new(Icons::MAIL_OUTLINE, "Inbox").badge(3),
1036    /// ];
1037    /// let _rail = NavigationRail::new(0, |i| i).destinations(places);
1038    /// ```
1039    #[must_use]
1040    pub fn destinations(
1041        mut self,
1042        destinations: impl IntoIterator<Item = NavigationDestination>,
1043    ) -> Self {
1044        self.items.extend(destinations);
1045        self.rebuild();
1046        self
1047    }
1048
1049    /// Adds a destination declared **in full** — what a shell hands over, having taken
1050    /// the caller's decorations itself.
1051    pub(crate) fn destination(mut self, destination: NavigationDestination) -> Self {
1052        self.items.push(destination);
1053        self.rebuild();
1054        self
1055    }
1056
1057    /// Adds a notification count to the **last** destination.
1058    pub fn badge(self, count: u32) -> Self {
1059        self.decorate(|last| last.badge = Some(count))
1060    }
1061    /// What a pointer resting on the **last** destination is told
1062    /// (`navigation_rail.dart:1155`).
1063    ///
1064    /// The case this exists for is a rail: it shows glyphs without labels by default, so
1065    /// the mark is all a destination says about itself, and a mark is not always enough.
1066    #[must_use]
1067    pub fn tooltip(self, message: impl Into<String>) -> Self {
1068        let message = message.into();
1069        self.decorate(move |last| last.tooltip = Some(message))
1070    }
1071
1072    /// The glyph the **last** destination shows while it is selected, where that differs
1073    /// from its resting one (`navigation_rail.dart:1132`).
1074    ///
1075    /// The reference pairs a stroked icon with its filled version, which is how a
1076    /// selected destination reads as selected without leaning on colour alone.
1077    #[must_use]
1078    pub fn selected_icon(self, icon: impl Into<DestinationIcon>) -> Self {
1079        let icon = icon.into();
1080        self.decorate(move |last| last.selected_icon = Some(icon))
1081    }
1082
1083    /// Marks the **last** destination inaccessible (`navigation_rail.dart:1161`): its
1084    /// glyph and its label take the disabled ink, nothing lights under the pointer, it
1085    /// emits no message, and the keyboard steps over it.
1086    #[must_use]
1087    pub fn disabled(self) -> Self {
1088        self.decorate(|last| last.disabled = true)
1089    }
1090
1091    /// The **last** destination's own indicator colour, over the theme's
1092    /// (`navigation_rail.dart:1144`) — how one entry marks itself out from the rest.
1093    #[must_use]
1094    pub fn indicator_color(self, color: Color) -> Self {
1095        self.decorate(move |last| last.indicator_color = Some(color))
1096    }
1097
1098    /// The **last** destination's own indicator shape, over the theme's
1099    /// (`navigation_rail.dart:1148`).
1100    ///
1101    /// A pill unless something says otherwise, which is what the reference's default
1102    /// `StadiumBorder` is — and what this painted, as a radius worked out from the
1103    /// height, until it could say the word.
1104    #[must_use]
1105    pub fn indicator_shape(self, shape: ShapeBorder) -> Self {
1106        self.decorate(move |last| last.indicator_shape = Some(shape))
1107    }
1108
1109    /// The **last** destination's own highlight, per state
1110    /// (`navigation_bar.dart:232`): the colour that marks it as focused, hovered or
1111    /// pressed, instead of the framework's own state layer.
1112    ///
1113    /// The resolved colour is an **overlay** as the reference means it: its alpha is how
1114    /// far the ground moves towards it, and its colour is where the ground moves to. A
1115    /// state the property says nothing about falls back to the state layer, so naming one
1116    /// state does not silence the rest.
1117    #[must_use]
1118    pub fn overlay_color(self, overlay: WidgetStateProperty<Color>) -> Self {
1119        self.decorate(move |last| last.overlay_color = Some(overlay))
1120    }
1121
1122    /// Applies `f` to the destination just added. Silent when there is none, which is the
1123    /// shape `badge` has always had.
1124    fn decorate(mut self, f: impl FnOnce(&mut NavigationDestination)) -> Self {
1125        if let Some(last) = self.items.last_mut() {
1126            f(last);
1127            self.rebuild();
1128        }
1129        self
1130    }
1131
1132    /// The destinations' labels, over the theme's and the reference's.
1133    #[must_use]
1134    pub fn label_text_style(mut self, style: TextStyle) -> Self {
1135        self.label_text_style = Some(style);
1136        self.rebuild();
1137        self
1138    }
1139
1140    /// The notification counts, over the theme's and the reference's.
1141    #[must_use]
1142    pub fn badge_text_style(mut self, style: TextStyle) -> Self {
1143        self.badge_text_style = Some(style);
1144        self.rebuild();
1145        self
1146    }
1147
1148    /// The rail's surface. Unset, the theme's, then the scheme's `surface` — where the
1149    /// reference puts a rail (`navigation_rail.dart:1202`), a rung below the bottom bar.
1150    #[must_use]
1151    pub fn background(mut self, color: Color) -> Self {
1152        self.background = Some(color);
1153        // The destinations read it too, since milestone 437: a state layer is a lerp from
1154        // the ground toward the ink, and this is the ground.
1155        self.rebuild();
1156        self
1157    }
1158
1159    /// Throws the assembled subtree away, so that the next read of it is built from
1160    /// everything the builders have said by then and they stay order-independent.
1161    fn rebuild(&mut self) {
1162        self.built.take();
1163    }
1164
1165    /// **How wide the rail asks to be**: [`RAIL_WIDTH`], or [`EXTENDED_RAIL_WIDTH`] when
1166    /// the labels have moved out beside the glyphs. Without the leading intrusion, which
1167    /// the rail adds on top of this and consumes on its parent's behalf.
1168    ///
1169    /// A shell that puts something else beside a rail has to know how much of the window
1170    /// the rail took. Asking the rail is the only answer that stays right: reading the
1171    /// constant is right until the caller extends it, and then it is 176 pixels wrong.
1172    pub(crate) fn declared_width(&self) -> f32 {
1173        if self.extended {
1174            EXTENDED_RAIL_WIDTH
1175        } else {
1176            RAIL_WIDTH
1177        }
1178    }
1179
1180    /// The rail's subtree, in the reference's shape (`navigation_rail.dart:559`):
1181    ///
1182    /// ```text
1183    /// leading      if it is pinned to the top
1184    /// spacer       (1 + alignment) / 2
1185    /// group        an unpinned leading slot, the destinations, an unpinned trailing one
1186    /// spacer       (1 - alignment) / 2
1187    /// trailing     if it is pinned to the bottom
1188    /// ```
1189    ///
1190    /// **The two spacers are what makes the alignment continuous.** A `justify` offers
1191    /// three positions; two flexible boxes whose grow factors add up to one offer every
1192    /// position between them, and the ends of the range are the same three. The
1193    /// destinations' own spacing belongs to the group they are in rather than to the
1194    /// rail, because a gap on the rail would also push the spacers away from the group
1195    /// and move the top-aligned default four pixels down.
1196    fn assemble(&self) -> Vec<Box<dyn Widget<Msg>>> {
1197        // Taking the slots is why this runs once: a `Box<dyn Widget>` cannot be cloned,
1198        // so assembling **consumes** them. A widget tree is rebuilt from `view` rather
1199        // than mutated, so once per instance and once per frame are the same thing — the
1200        // reasoning [`Widget::build_themed`] is written on.
1201        let leading = self.leading.borrow_mut().take();
1202        let trailing = self.trailing.borrow_mut().take();
1203
1204        let mut out: Vec<Box<dyn Widget<Msg>>> = Vec::new();
1205        let mut group = Flex::<Msg>::column()
1206            .align(Align::Center)
1207            .gap(DESTINATION_GAP);
1208        // `main_axis_alignment` distributes the spare room **between** the destinations,
1209        // so the group has to have that room: it fills, where otherwise it is as tall as
1210        // what is in it (`navigation_rail.dart:501`).
1211        if let Some(alignment) = self.main_axis_alignment {
1212            group = group.justify(alignment).flex(1.0);
1213        }
1214
1215        if let Some(leading) = leading {
1216            let slot = Flex::<Msg>::column()
1217                .align(Align::Center)
1218                .padding_each(0.0, 0.0, SLOT_GAP, 0.0)
1219                .child_boxed(leading);
1220            if self.leading_at_top {
1221                out.push(Box::new(slot));
1222            } else {
1223                group = group.child(slot);
1224            }
1225        }
1226        for item in build_items(
1227            &self.items,
1228            self.selected,
1229            &*self.on_select,
1230            Presentation {
1231                rail: true,
1232                labels: self.labels,
1233                extended: self.extended,
1234                ground: self.background,
1235                use_indicator: self.use_indicator.unwrap_or(true),
1236                label_text_style: self.label_text_style,
1237                badge_text_style: self.badge_text_style,
1238            },
1239        ) {
1240            group = group.child_boxed(item);
1241        }
1242        let mut pinned = None;
1243        match trailing {
1244            Some(trailing) if self.trailing_at_bottom => pinned = Some(trailing),
1245            Some(trailing) => group = group.child_boxed(trailing),
1246            None => {}
1247        }
1248
1249        // A group that **fills** — because it was told to scroll, or told to spread its
1250        // destinations — leaves no room outside itself, so the two spacers that place it
1251        // would have nothing to place and would take room from it if they tried.
1252        if self.scrollable {
1253            out.push(Box::new(
1254                SingleChildScrollView::<Msg>::new().flex(1.0).child(group),
1255            ));
1256        } else if self.main_axis_alignment.is_some() {
1257            out.push(Box::new(group));
1258        } else {
1259            let a = self.group_alignment;
1260            out.push(Box::new(Flex::<Msg>::column().flex((1.0 + a) * 0.5)));
1261            out.push(Box::new(group));
1262            out.push(Box::new(Flex::<Msg>::column().flex((1.0 - a) * 0.5)));
1263        }
1264        if let Some(trailing) = pinned {
1265            out.push(trailing);
1266        }
1267        out
1268    }
1269}
1270
1271impl<Msg: Clone + 'static> Widget<Msg> for NavigationRail<Msg> {
1272    /// The rail's box: its column of destinations, plus the intrusions it was **told**
1273    /// about.
1274    ///
1275    /// The rail consumes them; its parent does not (milestone 420). The reference keeps
1276    /// its safe area inside the `Material` (`navigation_rail.dart:553`) and takes the
1277    /// **leading** side, the top and the bottom — never the trailing one, which is where
1278    /// the body is. So the rail's surface, and the rule down its edge, run the full
1279    /// height of the screen while the destinations stay clear of the notch and the
1280    /// gesture bar.
1281    fn style(&self) -> Style {
1282        let safe = crate::MediaQuery::of().padding;
1283        Style {
1284            width: Dimension::Length(self.declared_width() + safe.left),
1285            flex_direction: FlexDirection::Column,
1286            align: Align::Center,
1287            padding: Insets::new(8.0 + safe.top, 0.0, 8.0 + safe.bottom, safe.left),
1288            // No gap: see [`Self::assemble`]. The destinations are spaced inside the
1289            // group, so that the boxes placing that group are not spaced away from it.
1290            ..Default::default()
1291        }
1292    }
1293
1294    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1295        self.built.get_or_init(|| self.assemble())
1296    }
1297
1298    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
1299        let o = status.opacity;
1300        // The rail's own surface. It had none until milestone 427 and showed whatever was
1301        // behind it; the reference gives it `surface` (`navigation_rail.dart:1202`).
1302        let fill = self
1303            .background
1304            .or(theme.widgets.nav_rail.background_color)
1305            .unwrap_or(theme.scheme.surface);
1306
1307        // **How far off the page it sits** (`navigation_rail.dart:192`), flat unless
1308        // asked, as the reference's default is (`:1236`). The shadow reads on the inner
1309        // side — the rail runs the height of the screen, so the top and bottom of it are
1310        // off it.
1311        let depth = self.elevation.or(theme.widgets.nav_rail.elevation);
1312        let depth = depth.unwrap_or(0.0);
1313        if depth > 0.0 {
1314            let blur = depth * 4.0 + 8.0;
1315            scene.shadow(
1316                Rect::new(
1317                    bounds.x - blur,
1318                    bounds.y + depth * 2.0 - blur,
1319                    bounds.width + 2.0 * blur,
1320                    bounds.height + 2.0 * blur,
1321                ),
1322                theme.scheme.shadow.with_alpha(0.30).fade(o),
1323                frus_core::BorderRadius::uniform(blur),
1324                blur,
1325            );
1326        }
1327
1328        scene.fill_rect(bounds, fill.fade(o));
1329
1330        // The rule down the inner edge — **and only when there is no shadow**. A raised
1331        // surface and a hairline are two ways of saying the same thing, and drawing both
1332        // says it twice: the mash-up [`Card`](crate::Card) was taken apart for.
1333        if depth <= 0.0 {
1334            let x = bounds.x + bounds.width - 1.0;
1335            scene.fill_rect(
1336                Rect::new(x, bounds.y, 1.0, bounds.height),
1337                theme.scheme.outline_variant.fade(o),
1338            );
1339        }
1340    }
1341
1342    fn on_click(&self) -> Option<Msg> {
1343        None
1344    }
1345}
1346
1347/// A **horizontal** navigation bar at the bottom (phone).
1348pub struct BottomBar<Msg = crate::callback::Callback> {
1349    selected: usize,
1350    on_select: Box<dyn Fn(usize) -> Msg>,
1351    items: Vec<NavigationDestination>,
1352    label_text_style: Option<TextStyle>,
1353    badge_text_style: Option<TextStyle>,
1354    background: Option<Color>,
1355    labels: RailLabels,
1356    children: Vec<Box<dyn Widget<Msg>>>,
1357}
1358
1359impl<Msg: Clone + 'static> BottomBar<Msg> {
1360    /// Creates a bar: `selected` = the active index, `on_select(i)` on click.
1361    pub fn new(selected: usize, on_select: impl Fn(usize) -> Msg + 'static) -> Self {
1362        Self {
1363            selected,
1364            on_select: Box::new(on_select),
1365            items: Vec::new(),
1366            label_text_style: None,
1367            badge_text_style: None,
1368            background: None,
1369            // A **bar** shows them all (`navigation_bar.dart:1388`), which is the other
1370            // half of the reference's answer: a bar owns the bottom of the screen and has
1371            // the room to say what its destinations are.
1372            labels: RailLabels::All,
1373            children: Vec::new(),
1374        }
1375    }
1376
1377    /// When the destinations say what they are. [`RailLabels::All`] by default, as the
1378    /// reference's bar does.
1379    #[must_use]
1380    pub fn labels(mut self, labels: RailLabels) -> Self {
1381        self.labels = labels;
1382        self.rebuild();
1383        self
1384    }
1385
1386    /// Adds a destination (mark + label).
1387    pub fn item(mut self, icon: impl Into<DestinationIcon>, label: impl Into<String>) -> Self {
1388        self.items.push(NavigationDestination::new(icon, label));
1389        self.rebuild();
1390        self
1391    }
1392
1393    /// Adds a whole list of destinations **declared elsewhere** — one navigation, handed
1394    /// to whichever chrome the width called for.
1395    ///
1396    /// This is the reason [`NavigationDestination`] is a value. An application that shows
1397    /// a bar when narrow and a rail when wide used to declare its destinations twice, and
1398    /// the two drifted; now it declares them once.
1399    ///
1400    /// ```
1401    /// use frus_widgets::{Icons, NavigationDestination, NavigationRail};
1402    ///
1403    /// let places = vec![
1404    ///     NavigationDestination::new(Icons::FAVORITE_BORDER, "Saved")
1405    ///         .selected_icon(Icons::FAVORITE),
1406    ///     NavigationDestination::new(Icons::MAIL_OUTLINE, "Inbox").badge(3),
1407    /// ];
1408    /// let _rail = NavigationRail::new(0, |i| i).destinations(places);
1409    /// ```
1410    #[must_use]
1411    pub fn destinations(
1412        mut self,
1413        destinations: impl IntoIterator<Item = NavigationDestination>,
1414    ) -> Self {
1415        self.items.extend(destinations);
1416        self.rebuild();
1417        self
1418    }
1419
1420    /// Adds a destination declared **in full** — what a shell hands over, having taken
1421    /// the caller's decorations itself.
1422    pub(crate) fn destination(mut self, destination: NavigationDestination) -> Self {
1423        self.items.push(destination);
1424        self.rebuild();
1425        self
1426    }
1427
1428    /// Adds a notification count to the **last** destination.
1429    pub fn badge(self, count: u32) -> Self {
1430        self.decorate(|last| last.badge = Some(count))
1431    }
1432    /// What a pointer resting on the **last** destination is told
1433    /// (`navigation_rail.dart:1155`).
1434    ///
1435    /// The case this exists for is a rail: it shows glyphs without labels by default, so
1436    /// the mark is all a destination says about itself, and a mark is not always enough.
1437    #[must_use]
1438    pub fn tooltip(self, message: impl Into<String>) -> Self {
1439        let message = message.into();
1440        self.decorate(move |last| last.tooltip = Some(message))
1441    }
1442
1443    /// The glyph the **last** destination shows while it is selected, where that differs
1444    /// from its resting one (`navigation_rail.dart:1132`).
1445    ///
1446    /// The reference pairs a stroked icon with its filled version, which is how a
1447    /// selected destination reads as selected without leaning on colour alone.
1448    #[must_use]
1449    pub fn selected_icon(self, icon: impl Into<DestinationIcon>) -> Self {
1450        let icon = icon.into();
1451        self.decorate(move |last| last.selected_icon = Some(icon))
1452    }
1453
1454    /// Marks the **last** destination inaccessible (`navigation_rail.dart:1161`): its
1455    /// glyph and its label take the disabled ink, nothing lights under the pointer, it
1456    /// emits no message, and the keyboard steps over it.
1457    #[must_use]
1458    pub fn disabled(self) -> Self {
1459        self.decorate(|last| last.disabled = true)
1460    }
1461
1462    /// The **last** destination's own indicator colour, over the theme's
1463    /// (`navigation_rail.dart:1144`) — how one entry marks itself out from the rest.
1464    #[must_use]
1465    pub fn indicator_color(self, color: Color) -> Self {
1466        self.decorate(move |last| last.indicator_color = Some(color))
1467    }
1468
1469    /// The **last** destination's own indicator shape, over the theme's
1470    /// (`navigation_rail.dart:1148`).
1471    ///
1472    /// A pill unless something says otherwise, which is what the reference's default
1473    /// `StadiumBorder` is — and what this painted, as a radius worked out from the
1474    /// height, until it could say the word.
1475    #[must_use]
1476    pub fn indicator_shape(self, shape: ShapeBorder) -> Self {
1477        self.decorate(move |last| last.indicator_shape = Some(shape))
1478    }
1479
1480    /// The **last** destination's own highlight, per state
1481    /// (`navigation_bar.dart:232`): the colour that marks it as focused, hovered or
1482    /// pressed, instead of the framework's own state layer.
1483    ///
1484    /// The resolved colour is an **overlay** as the reference means it: its alpha is how
1485    /// far the ground moves towards it, and its colour is where the ground moves to. A
1486    /// state the property says nothing about falls back to the state layer, so naming one
1487    /// state does not silence the rest.
1488    #[must_use]
1489    pub fn overlay_color(self, overlay: WidgetStateProperty<Color>) -> Self {
1490        self.decorate(move |last| last.overlay_color = Some(overlay))
1491    }
1492
1493    /// Applies `f` to the destination just added. Silent when there is none, which is the
1494    /// shape `badge` has always had.
1495    fn decorate(mut self, f: impl FnOnce(&mut NavigationDestination)) -> Self {
1496        if let Some(last) = self.items.last_mut() {
1497            f(last);
1498            self.rebuild();
1499        }
1500        self
1501    }
1502
1503    /// The destinations' labels, over the theme's and the reference's.
1504    #[must_use]
1505    pub fn label_text_style(mut self, style: TextStyle) -> Self {
1506        self.label_text_style = Some(style);
1507        self.rebuild();
1508        self
1509    }
1510
1511    /// The notification counts, over the theme's and the reference's.
1512    #[must_use]
1513    pub fn badge_text_style(mut self, style: TextStyle) -> Self {
1514        self.badge_text_style = Some(style);
1515        self.rebuild();
1516        self
1517    }
1518
1519    /// The bar's surface. Unset, the theme's, then the scheme's `surface_container` —
1520    /// where the reference puts a navigation bar (`navigation_bar.dart:1440`), a rung
1521    /// above the rail.
1522    #[must_use]
1523    pub fn background(mut self, color: Color) -> Self {
1524        self.background = Some(color);
1525        // The destinations read it too, since milestone 437: a state layer is a lerp from
1526        // the ground toward the ink, and this is the ground.
1527        self.rebuild();
1528        self
1529    }
1530
1531    /// Carries the current destinations *and* styles into the items, so that the builders
1532    /// are order-independent.
1533    fn rebuild(&mut self) {
1534        self.children = build_items(
1535            &self.items,
1536            self.selected,
1537            &*self.on_select,
1538            Presentation {
1539                rail: false,
1540                labels: self.labels,
1541                // A bar is one row: it has no extended form, and no room for one.
1542                extended: false,
1543                ground: self.background,
1544                // A bar's indicator is not optional: the reference gives `useIndicator`
1545                // to the rail alone (`navigation_rail.dart:310`), a bar's destinations
1546                // sitting side by side with nothing else to tell them apart.
1547                use_indicator: true,
1548                label_text_style: self.label_text_style,
1549                badge_text_style: self.badge_text_style,
1550            },
1551        );
1552    }
1553}
1554
1555impl<Msg> BottomBar<Msg> {
1556    /// The bar's box: a row of destinations, plus the intrusions it was **told** about.
1557    ///
1558    /// The bar consumes them; its parent does not (milestone 418). The reference wraps
1559    /// the row in a safe area and leaves the `Material` **outside** it
1560    /// (`navigation_bar.dart:285`), so the background runs behind the gesture bar and
1561    /// only the destinations are held clear of it. Padding the whole bar from outside
1562    /// gives the opposite picture: a bar that stops short of the edge with a strip of
1563    /// the screen behind it showing through.
1564    ///
1565    /// The top intrusion is never consumed here. A shell removes it before handing the
1566    /// slot over (`scaffold.dart:3169`), and a bar along the bottom of a screen has
1567    /// nothing above it to keep clear of anyway.
1568    fn sizing(&self, theme: Option<&Theme>) -> Style {
1569        // The bar keeps the height a labelled destination needs as soon as **any** of
1570        // them is labelled: under [`RailLabels::Selected`] only one is at a time, and a
1571        // bar that resized as the selection moved would shift the page under it.
1572        let label =
1573            (self.labels != RailLabels::None).then(|| label_style(self.label_text_style, theme));
1574        let safe = crate::MediaQuery::of().padding;
1575        Style {
1576            height: Dimension::Length(item_height(BAR_HEIGHT, label.as_ref(), false) + safe.bottom),
1577            padding: Insets::new(0.0, safe.right, safe.bottom, safe.left),
1578            flex_direction: FlexDirection::Row,
1579            justify: Justify::SpaceAround,
1580            align: Align::Stretch,
1581            ..Default::default()
1582        }
1583    }
1584}
1585
1586impl<Msg: Clone> Widget<Msg> for BottomBar<Msg> {
1587    fn style(&self) -> Style {
1588        self.sizing(None)
1589    }
1590
1591    fn style_themed(&self, theme: &Theme) -> Style {
1592        self.sizing(Some(theme))
1593    }
1594
1595    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1596        &self.children
1597    }
1598
1599    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
1600        // The bar's own surface, a rung above the rail's: it stands on the page rather
1601        // than beside it (`navigation_bar.dart:1440`).
1602        let fill = self
1603            .background
1604            .or(theme.widgets.nav_rail.bar_background_color)
1605            .unwrap_or(theme.scheme.surface_container);
1606        scene.fill_rect(bounds, fill.fade(status.opacity));
1607
1608        // Horizontal separator on the top edge.
1609        scene.fill_rect(
1610            Rect::new(bounds.x, bounds.y, bounds.width, 1.0),
1611            theme.scheme.outline_variant.fade(status.opacity),
1612        );
1613    }
1614
1615    fn on_click(&self) -> Option<Msg> {
1616        None
1617    }
1618}
1619
1620#[cfg(test)]
1621mod tests {
1622    use super::*;
1623
1624    #[derive(Clone, Debug, PartialEq)]
1625    enum Msg {
1626        Go(usize),
1627    }
1628
1629    /// The **surface each of the two navigation widgets stands on**.
1630    ///
1631    /// Neither painted one until milestone 427: they drew a hairline and let whatever was
1632    /// behind them show through, so a bar sitting on a page was the page with a line above
1633    /// it. The reference gives the rail `surface` (`navigation_rail.dart:1202`) and the bar
1634    /// `surface_container` (`navigation_bar.dart:1440`) — a rung apart, because a bar
1635    /// stands *on* the page and a rail stands beside it.
1636    #[test]
1637    fn a_bar_and_a_rail_each_paint_the_rung_they_stand_on() {
1638        let theme = Theme::default();
1639        let bar = BottomBar::new(0, Msg::Go).item("H", "Home");
1640        let rail = NavigationRail::new(0, Msg::Go).item("H", "Home");
1641        let bar_box = Rect::new(0.0, 0.0, 320.0, BAR_HEIGHT);
1642        let rail_box = Rect::new(0.0, 0.0, RAIL_WIDTH, 600.0);
1643
1644        assert_eq!(
1645            surface_of(&bar, bar_box, &theme),
1646            Some(theme.scheme.surface_container),
1647            "a bar stands on the page"
1648        );
1649        assert_eq!(
1650            surface_of(&rail, rail_box, &theme),
1651            Some(theme.scheme.surface),
1652            "a rail stands beside it"
1653        );
1654        assert_ne!(
1655            theme.scheme.surface_container, theme.scheme.surface,
1656            "the two rungs have to be tellable apart for the assertions above to mean \
1657             anything"
1658        );
1659    }
1660
1661    /// The caller outranks the theme and the theme outranks the rung — both surfaces.
1662    #[test]
1663    fn the_caller_and_the_theme_outrank_the_rung() {
1664        let mut theme = Theme::default();
1665        theme.widgets.nav_rail.background_color = Some(Color::rgb8(1, 2, 3));
1666        theme.widgets.nav_rail.bar_background_color = Some(Color::rgb8(4, 5, 6));
1667        let bar_box = Rect::new(0.0, 0.0, 320.0, BAR_HEIGHT);
1668        let rail_box = Rect::new(0.0, 0.0, RAIL_WIDTH, 600.0);
1669
1670        let bar = BottomBar::new(0, Msg::Go).item("H", "Home");
1671        let rail = NavigationRail::new(0, Msg::Go).item("H", "Home");
1672        assert_eq!(
1673            surface_of(&bar, bar_box, &theme),
1674            Some(Color::rgb8(4, 5, 6))
1675        );
1676        assert_eq!(
1677            surface_of(&rail, rail_box, &theme),
1678            Some(Color::rgb8(1, 2, 3))
1679        );
1680
1681        let told = Color::rgb8(7, 8, 9);
1682        let bar = BottomBar::new(0, Msg::Go)
1683            .item("H", "Home")
1684            .background(told);
1685        let rail = NavigationRail::new(0, Msg::Go)
1686            .item("H", "Home")
1687            .background(told);
1688        assert_eq!(surface_of(&bar, bar_box, &theme), Some(told));
1689        assert_eq!(surface_of(&rail, rail_box, &theme), Some(told));
1690    }
1691
1692    /// The colour of the first rect covering the whole box — the widget's own surface,
1693    /// drawn before the hairline that sits on one edge of it.
1694    fn surface_of(widget: &dyn Widget<Msg>, bounds: Rect, theme: &Theme) -> Option<Color> {
1695        let mut scene = Scene::new();
1696        widget.paint(bounds, Status::default(), theme, &mut scene);
1697        scene.primitives().iter().find_map(|p| match p {
1698            frus_core::Primitive::Rect { rect, color, .. }
1699                if rect.width == bounds.width && rect.height == bounds.height =>
1700            {
1701                Some(*color)
1702            }
1703            _ => None,
1704        })
1705    }
1706
1707    /// One destination, painted.
1708    fn destination(rail: bool, selected: bool, badge: Option<u32>, theme: &Theme) -> Scene {
1709        labelled(rail, selected, badge, true, theme)
1710    }
1711
1712    /// The same, saying whether the destination shows its label.
1713    fn labelled(
1714        rail: bool,
1715        selected: bool,
1716        badge: Option<u32>,
1717        show_label: bool,
1718        theme: &Theme,
1719    ) -> Scene {
1720        painted(rail, selected, badge, show_label, false, theme)
1721    }
1722
1723    /// The same again, saying whether the rail it stands in is **extended** — which
1724    /// widens its box and moves its label beside the glyph.
1725    fn painted(
1726        rail: bool,
1727        selected: bool,
1728        badge: Option<u32>,
1729        show_label: bool,
1730        extended: bool,
1731        theme: &Theme,
1732    ) -> Scene {
1733        let item = row(rail, show_label, extended, None);
1734        let width = if extended {
1735            EXTENDED_RAIL_WIDTH
1736        } else {
1737            RAIL_WIDTH
1738        };
1739        let mut scene = Scene::new();
1740        Widget::<Msg>::paint(
1741            &NavItem {
1742                selected,
1743                badge,
1744                ..item
1745            },
1746            Rect::new(0.0, 0.0, width, ITEM_HEIGHT),
1747            Status {
1748                opacity: 1.0,
1749                ..Default::default()
1750            },
1751            theme,
1752            &mut scene,
1753        );
1754        scene
1755    }
1756
1757    use crate::interaction::Interaction;
1758
1759    /// **A destination can carry a colour per state** (`navigation_bar.dart:232`), which
1760    /// nothing here could say until milestone 447: the framework's rule was one lerp
1761    /// towards `primary` at three fixed opacities, right for a state layer and no answer
1762    /// at all for a caller who wants a particular colour in a particular state.
1763    ///
1764    /// The resolved colour is an overlay as the reference means it: its alpha is how far
1765    /// the ground moves, its colour is where it moves to.
1766    #[test]
1767    fn a_destination_may_carry_a_colour_per_state() {
1768        let theme = Theme::dark();
1769        let told = Color::rgb8(10, 200, 90).fade(0.5);
1770        let item = NavItem::<Msg> {
1771            overlay_color: Some(WidgetStateProperty::new().when(crate::WidgetState::Hovered, told)),
1772            ..row(true, true, false, None)
1773        };
1774        let ground = theme.scheme.surface;
1775
1776        let hovered = pill_of(&item, Interaction::Hovered, &theme);
1777        assert_eq!(
1778            hovered,
1779            Some(ground.lerp(told.fade(1.0), told.a)),
1780            "the caller's colour, at the caller's strength"
1781        );
1782        assert_ne!(
1783            hovered,
1784            Some(theme.state_layer(
1785                ground,
1786                theme.scheme.primary,
1787                &Status {
1788                    interaction: Interaction::Hovered,
1789                    hover_progress: 1.0,
1790                    ..Default::default()
1791                }
1792            )),
1793            "and not the framework's own"
1794        );
1795    }
1796
1797    /// And **a state the property says nothing about falls back** to the state layer.
1798    /// Naming one state is not a way of silencing the rest — a property that answers
1799    /// `None` means *say nothing*, as the reference's nullable properties do.
1800    #[test]
1801    fn an_unnamed_state_still_gets_the_framework_s_layer() {
1802        let theme = Theme::dark();
1803        let item = NavItem::<Msg> {
1804            overlay_color: Some(WidgetStateProperty::new().when(
1805                crate::WidgetState::Pressed,
1806                Color::rgb8(10, 200, 90).fade(0.5),
1807            )),
1808            ..row(true, true, false, None)
1809        };
1810        let plain = NavItem::<Msg> {
1811            overlay_color: None,
1812            ..row(true, true, false, None)
1813        };
1814        assert_eq!(
1815            pill_of(&item, Interaction::Hovered, &theme),
1816            pill_of(&plain, Interaction::Hovered, &theme),
1817            "hovered was never named, so the state layer answers"
1818        );
1819        assert_ne!(
1820            pill_of(&item, Interaction::Pressed, &theme),
1821            pill_of(&plain, Interaction::Pressed, &theme),
1822            "and held, which was named, does not"
1823        );
1824    }
1825
1826    /// **And a theme can hold one**, which is where the reference keeps most of them
1827    /// (`NavigationBarThemeData.overlayColor`, and every button theme). It could not
1828    /// until milestone 448: `WidgetThemes` derived `Copy`, and a property owns a `Vec`.
1829    ///
1830    /// Three rungs, as everywhere else here — the destination's word, then the theme's,
1831    /// then the framework's state layer.
1832    #[test]
1833    fn a_theme_answers_for_every_destination_and_one_may_still_answer_for_itself() {
1834        let told = Color::rgb8(10, 200, 90).fade(0.5);
1835        let mine = Color::rgb8(200, 10, 90).fade(0.25);
1836        let mut theme = Theme::dark();
1837        theme.widgets.nav_rail.overlay_color =
1838            Some(WidgetStateProperty::new().when(crate::WidgetState::Hovered, told));
1839        let ground = theme.scheme.surface;
1840
1841        let plain = row(true, true, false, None);
1842        assert_eq!(
1843            pill_of(&plain, Interaction::Hovered, &theme),
1844            Some(ground.lerp(told.fade(1.0), told.a)),
1845            "a destination that said nothing takes the theme's word"
1846        );
1847
1848        let own = NavItem::<Msg> {
1849            overlay_color: Some(WidgetStateProperty::new().when(crate::WidgetState::Hovered, mine)),
1850            ..row(true, true, false, None)
1851        };
1852        assert_eq!(
1853            pill_of(&own, Interaction::Hovered, &theme),
1854            Some(ground.lerp(mine.fade(1.0), mine.a)),
1855            "and one that said something outranks it"
1856        );
1857    }
1858
1859    /// A state **neither of them named** still falls through to the state layer: a
1860    /// property that matches no entry says nothing, and saying nothing is not an answer.
1861    #[test]
1862    fn a_state_neither_rung_names_falls_through() {
1863        let mut theme = Theme::dark();
1864        theme.widgets.nav_rail.overlay_color = Some(WidgetStateProperty::new().when(
1865            crate::WidgetState::Pressed,
1866            Color::rgb8(10, 200, 90).fade(0.5),
1867        ));
1868        let bare = Theme::dark();
1869        let item = row(true, true, false, None);
1870        assert_eq!(
1871            pill_of(&item, Interaction::Hovered, &theme),
1872            pill_of(&item, Interaction::Hovered, &bare),
1873            "hovered was never named by either rung"
1874        );
1875    }
1876
1877    /// **A selected destination's indicator is a pill, and now it can say so**
1878    /// (`navigation_rail.dart:1148`).
1879    ///
1880    /// It painted one before — as `pill_h * 0.5`, the arithmetic a stadium does written
1881    /// out at the call site. A caller who wanted a rounded box, or a circle, had nowhere
1882    /// to put the word, and the shape had to be read back out of a number.
1883    #[test]
1884    fn an_indicator_is_a_pill_unless_it_is_told_otherwise() {
1885        let theme = Theme::dark();
1886        let pill = indicator(&row_selected(None), &theme).expect("a selected destination");
1887        assert!(
1888            (pill.1.top_left - pill.0.height * 0.5).abs() < 0.01,
1889            "a stadium's radius is half its short side: {pill:?}"
1890        );
1891
1892        // A caller's own shape, over it.
1893        let boxed = indicator(&row_selected(Some(ShapeBorder::rounded(4.0))), &theme)
1894            .expect("still painted");
1895        assert_eq!(boxed.1, frus_core::BorderRadius::uniform(4.0));
1896
1897        // And a circle takes a square out of the middle rather than filling the box.
1898        let round =
1899            indicator(&row_selected(Some(ShapeBorder::circle())), &theme).expect("still painted");
1900        assert_eq!(
1901            round.0.width, round.0.height,
1902            "a circle is square: {round:?}"
1903        );
1904        assert!(
1905            round.0.width < pill.0.width,
1906            "and narrower than the pill it replaced"
1907        );
1908    }
1909
1910    /// And the **theme** answers for every destination at once, under each one's own word
1911    /// — the three rungs this framework resolves everything on.
1912    #[test]
1913    fn a_theme_shapes_every_indicator_and_one_may_still_differ() {
1914        let mut theme = Theme::dark();
1915        theme.widgets.nav_rail.indicator_shape = Some(ShapeBorder::rounded(2.0));
1916        assert_eq!(
1917            indicator(&row_selected(None), &theme).unwrap().1,
1918            frus_core::BorderRadius::uniform(2.0),
1919            "the theme's word"
1920        );
1921        assert_eq!(
1922            indicator(&row_selected(Some(ShapeBorder::rounded(9.0))), &theme)
1923                .unwrap()
1924                .1,
1925            frus_core::BorderRadius::uniform(9.0),
1926            "and one destination still answers for itself"
1927        );
1928    }
1929
1930    /// A selected destination, with an indicator shape or without one.
1931    fn row_selected(shape: Option<ShapeBorder>) -> NavItem<Msg> {
1932        NavItem::<Msg> {
1933            selected: true,
1934            indicator_shape: shape,
1935            ..row(true, true, false, None)
1936        }
1937    }
1938
1939    /// The box and the corners the indicator was actually painted with.
1940    fn indicator(item: &NavItem<Msg>, theme: &Theme) -> Option<(Rect, frus_core::BorderRadius)> {
1941        let mut scene = Scene::new();
1942        Widget::<Msg>::paint(
1943            item,
1944            Rect::new(0.0, 0.0, RAIL_WIDTH, ITEM_HEIGHT),
1945            Status {
1946                opacity: 1.0,
1947                ..Default::default()
1948            },
1949            theme,
1950            &mut scene,
1951        );
1952        scene.primitives().iter().find_map(|p| match p {
1953            frus_core::Primitive::Rect { rect, radius, .. } if rect.height < ITEM_HEIGHT => {
1954                Some((*rect, *radius))
1955            }
1956            _ => None,
1957        })
1958    }
1959
1960    /// The pill this destination paints under its glyph, in a given interaction — or
1961    /// `None` when it paints none at all, which is what a resting unselected one does.
1962    fn pill_of(item: &NavItem<Msg>, interaction: Interaction, theme: &Theme) -> Option<Color> {
1963        let mut scene = Scene::new();
1964        Widget::<Msg>::paint(
1965            item,
1966            Rect::new(0.0, 0.0, RAIL_WIDTH, ITEM_HEIGHT),
1967            Status {
1968                opacity: 1.0,
1969                interaction,
1970                hover_progress: 1.0,
1971                press_progress: if interaction == Interaction::Pressed {
1972                    1.0
1973                } else {
1974                    0.0
1975                },
1976                ..Default::default()
1977            },
1978            theme,
1979            &mut scene,
1980        );
1981        scene.primitives().iter().find_map(|p| match p {
1982            frus_core::Primitive::Rect { rect, color, .. } if rect.height < ITEM_HEIGHT => {
1983                Some(*color)
1984            }
1985            _ => None,
1986        })
1987    }
1988
1989    /// One destination, unpainted — the thing whose style the layout reads.
1990    fn row(
1991        rail: bool,
1992        show_label: bool,
1993        extended: bool,
1994        label_style: Option<TextStyle>,
1995    ) -> NavItem<Msg> {
1996        NavItem::<Msg> {
1997            icon: "H".into(),
1998            label: "Home".into(),
1999            selected: false,
2000            badge: None,
2001            rail,
2002            show_label,
2003            extended,
2004            disabled: false,
2005            indicator_color: None,
2006            indicator_shape: None,
2007            overlay_color: None,
2008            use_indicator: true,
2009            ground: None,
2010            label_text_style: label_style,
2011            badge_text_style: None,
2012            index: 0,
2013            count: 1,
2014            message: Msg::Go(0),
2015        }
2016    }
2017
2018    /// Every text primitive in a scene, with where it was drawn.
2019    fn placed(scene: &Scene) -> Vec<(String, Point)> {
2020        scene
2021            .primitives()
2022            .iter()
2023            .filter_map(|p| match p {
2024                frus_core::Primitive::Text { text, position, .. } => {
2025                    Some((text.clone(), *position))
2026                }
2027                _ => None,
2028            })
2029            .collect()
2030    }
2031
2032    fn rects(scene: &Scene) -> Vec<Color> {
2033        scene
2034            .primitives()
2035            .iter()
2036            .filter_map(|p| match p {
2037                frus_core::Primitive::Rect { color, .. } => Some(*color),
2038                _ => None,
2039            })
2040            .collect()
2041    }
2042
2043    fn texts(scene: &Scene) -> Vec<Color> {
2044        scene
2045            .primitives()
2046            .iter()
2047            .filter_map(|p| match p {
2048                frus_core::Primitive::Text { color, .. } => Some(*color),
2049                _ => None,
2050            })
2051            .collect()
2052    }
2053
2054    /// A destination's colours, each against the role the reference names.
2055    ///
2056    /// The indicator is the one that mattered most: it was `primary` at 16 %, which was
2057    /// the wrong **role** and the wrong **kind** of colour at once — a translucent fill
2058    /// blends in linear light here, so 16 % never painted at 16 %.
2059    #[test]
2060    fn a_destination_takes_the_roles_the_reference_names() {
2061        let theme = Theme::default();
2062
2063        let on = destination(false, true, None, &theme);
2064        assert_eq!(
2065            rects(&on).first().copied(),
2066            Some(theme.scheme.secondary_container),
2067            "the indicator is an opaque container (`navigation_bar.dart:1463`)"
2068        );
2069        assert_eq!(
2070            texts(&on),
2071            vec![theme.scheme.on_secondary_container, theme.scheme.on_surface],
2072            "the glyph is the indicator's content, the label is the surface's"
2073        );
2074        assert_ne!(
2075            theme.scheme.on_secondary_container, theme.scheme.on_surface,
2076            "the two have to differ for that split to be worth making"
2077        );
2078
2079        let off = destination(false, false, None, &theme);
2080        assert!(rects(&off).is_empty(), "nothing behind an unselected one");
2081        assert_eq!(
2082            texts(&off),
2083            vec![
2084                theme.scheme.on_surface_variant,
2085                theme.scheme.on_surface_variant
2086            ]
2087        );
2088    }
2089
2090    /// The one question the reference answers differently for the two widgets: an
2091    /// unselected label is `on_surface` on a rail (`navigation_rail.dart:1251`) and
2092    /// `on_surface_variant` on a bar (`navigation_bar.dart:1477`).
2093    #[test]
2094    fn a_rail_and_a_bar_part_company_on_one_colour() {
2095        let theme = Theme::default();
2096        let rail = texts(&destination(true, false, None, &theme));
2097        let bar = texts(&destination(false, false, None, &theme));
2098        assert_eq!(rail[1], theme.scheme.on_surface);
2099        assert_eq!(bar[1], theme.scheme.on_surface_variant);
2100        assert_eq!(rail[0], bar[0], "and agree on the glyph");
2101    }
2102
2103    /// **One badge, one theme.** The rail drew its own red on the reasoning that an alert
2104    /// dot reads as red whatever the theme; the [`Badge`](crate::Badge) widget beside it
2105    /// already answered the same question from the scheme. Two badges in one framework
2106    /// painting different reds was the part that was actually wrong.
2107    #[test]
2108    fn the_rail_s_badge_is_the_badge_widget_s_badge() {
2109        let mut theme = Theme::default();
2110        let scene = destination(false, false, Some(3), &theme);
2111        assert_eq!(rects(&scene).first().copied(), Some(theme.scheme.error));
2112        assert_eq!(
2113            texts(&scene).last().copied(),
2114            Some(theme.scheme.on_error),
2115            "the count is what is legible on it"
2116        );
2117
2118        let told = Color::rgb8(1, 2, 3);
2119        theme.widgets.badge.background_color = Some(told);
2120        assert_eq!(
2121            rects(&destination(false, false, Some(3), &theme))
2122                .first()
2123                .copied(),
2124            Some(told),
2125            "and recolouring badges recolours this one too"
2126        );
2127    }
2128
2129    /// Which destinations name themselves, under each of the three modes.
2130    fn labelled_indices(labels: RailLabels, count: usize, selected: usize) -> Vec<usize> {
2131        (0..count).filter(|&i| labels.shows(i, selected)).collect()
2132    }
2133
2134    /// The three modes, and the two **different defaults** the reference gives the two
2135    /// widgets (`navigation_rail.dart:1238` against `navigation_bar.dart:1388`).
2136    ///
2137    /// The asymmetry is the part worth holding onto: a rail stands beside a page it does
2138    /// not own and glyphs alone keep it narrow, a bar owns the bottom of the screen and
2139    /// has the room to say what its destinations are.
2140    #[test]
2141    fn a_rail_and_a_bar_start_from_opposite_defaults() {
2142        assert_eq!(
2143            labelled_indices(RailLabels::None, 3, 1),
2144            Vec::<usize>::new()
2145        );
2146        assert_eq!(labelled_indices(RailLabels::Selected, 3, 1), vec![1]);
2147        assert_eq!(labelled_indices(RailLabels::All, 3, 1), vec![0, 1, 2]);
2148
2149        let rail = NavigationRail::new(0, Msg::Go).item("H", "Home");
2150        let bar = BottomBar::new(0, Msg::Go).item("H", "Home");
2151        assert_eq!(
2152            rail.labels,
2153            RailLabels::None,
2154            "a rail says nothing until asked"
2155        );
2156        assert_eq!(bar.labels, RailLabels::All, "a bar says everything");
2157    }
2158
2159    /// A destination with no label paints no label — and centres the glyph on its own
2160    /// rather than leaving it where it sat when there was something below it.
2161    #[test]
2162    fn a_silent_destination_centres_its_glyph() {
2163        let theme = Theme::default();
2164        let with = labelled(true, false, None, true, &theme);
2165        let without = labelled(true, false, None, false, &theme);
2166
2167        let glyph_y = |scene: &Scene| {
2168            scene.primitives().iter().find_map(|p| match p {
2169                frus_core::Primitive::Text { position, .. } => Some(position.y),
2170                _ => None,
2171            })
2172        };
2173        assert_eq!(
2174            without
2175                .primitives()
2176                .iter()
2177                .filter(|p| matches!(p, frus_core::Primitive::Text { .. }))
2178                .count(),
2179            1,
2180            "the glyph and nothing else"
2181        );
2182        assert_eq!(texts(&with).len(), 2, "the glyph and its label");
2183        assert!(
2184            glyph_y(&without) > glyph_y(&with),
2185            "and the glyph moved down into the room the label was using"
2186        );
2187    }
2188
2189    /// The row keeps its height when the label goes.
2190    ///
2191    /// Under [`RailLabels::Selected`] exactly one destination is labelled at a time, so a
2192    /// row that shrank without one would move every destination in the rail the first
2193    /// time the selection changed.
2194    #[test]
2195    fn a_row_does_not_shrink_when_its_label_goes() {
2196        let theme = Theme::default();
2197        let height = |show_label: bool| {
2198            let item = row(true, show_label, false, None);
2199            match Widget::<Msg>::style_themed(&item, &theme).height {
2200                Dimension::Length(h) => h,
2201                other => panic!("a rail row names its height, got {other:?}"),
2202            }
2203        };
2204        assert_eq!(height(true), height(false));
2205    }
2206
2207    /// The one rectangle a destination paints under itself, if it paints one.
2208    fn layer(rail: &NavigationRail<Msg>, status: Status, theme: &Theme) -> Option<Color> {
2209        rects(&paint_destination(rail, 0, status, theme))
2210            .first()
2211            .copied()
2212    }
2213
2214    /// **A state layer is opaque, and it is a lerp from the ground** (milestone 437).
2215    ///
2216    /// This painted `muted` at 12 % handed to the GPU as an alpha, which is three
2217    /// mistakes in one line: the wrong *kind* of colour, since a translucent overlay
2218    /// blends in linear light here and 12 % paints like a third (milestone 329); the
2219    /// wrong *role*, the reference's ink for this widget being `primary`
2220    /// (`navigation_rail.dart:946`); and the wrong *number*, 12 % being the splash's
2221    /// (`:943`) rather than the hover's.
2222    #[test]
2223    fn a_destination_s_state_layer_is_opaque_and_starts_from_the_ground() {
2224        let theme = Theme::default();
2225        let rail = NavigationRail::new(9, Msg::Go).item("H", "Home");
2226        let painted = layer(&rail, hovered(), &theme).expect("a hovered destination lights");
2227        assert_eq!(painted.a, 1.0, "resolved here, not handed over as an alpha");
2228        assert_eq!(
2229            painted,
2230            theme.state_layer(theme.scheme.surface, theme.scheme.primary, &hovered()),
2231            "the framework's one state rule, over the rail's own rung"
2232        );
2233        assert_eq!(
2234            layer(&rail, live(), &theme),
2235            None,
2236            "and nothing at rest, which is what it has always drawn"
2237        );
2238    }
2239
2240    /// It starts from the ground the widget **actually painted**: the two widgets stand on
2241    /// different rungs (milestone 427), and either can be told a third colour.
2242    #[test]
2243    fn the_layer_starts_from_the_ground_the_widget_painted() {
2244        let theme = Theme::default();
2245        let rail = NavigationRail::new(9, Msg::Go).item("H", "Home");
2246        let on_rail = layer(&rail, hovered(), &theme).expect("lit");
2247
2248        let mut bar_scene = Scene::new();
2249        let bar = BottomBar::new(9, Msg::Go).item("H", "Home");
2250        Widget::<Msg>::children(&bar)[0].paint(
2251            Rect::new(0.0, 0.0, RAIL_WIDTH, BAR_HEIGHT),
2252            hovered(),
2253            &theme,
2254            &mut bar_scene,
2255        );
2256        let on_bar = rects(&bar_scene).first().copied().expect("lit");
2257        assert_ne!(
2258            on_rail, on_bar,
2259            "a bar stands a rung above a rail, so its layer starts higher"
2260        );
2261
2262        let told = Color::rgb8(120, 20, 20);
2263        let painted = NavigationRail::new(9, Msg::Go)
2264            .item("H", "Home")
2265            .background(told);
2266        assert_eq!(
2267            layer(&painted, hovered(), &theme),
2268            Some(theme.state_layer(told, theme.scheme.primary, &hovered())),
2269            "and a rail told a colour is the colour its destinations stand on"
2270        );
2271    }
2272
2273    /// **A selected destination lights too.** The old branch answered hover only when
2274    /// there was no indicator, so the one destination a pointer is most likely to be over
2275    /// was the one that never responded.
2276    #[test]
2277    fn a_selected_destination_lights_under_the_pointer_too() {
2278        let theme = Theme::default();
2279        let rail = NavigationRail::new(0, Msg::Go).item("H", "Home");
2280        let resting = layer(&rail, live(), &theme).expect("the indicator");
2281        let lit = layer(&rail, hovered(), &theme).expect("the indicator, plus the layer");
2282        assert_eq!(resting, theme.scheme.secondary_container);
2283        assert_ne!(lit, resting, "the indicator takes the layer over it");
2284        assert_eq!(
2285            lit,
2286            theme.state_layer(
2287                theme.scheme.secondary_container,
2288                theme.scheme.primary,
2289                &hovered()
2290            )
2291        );
2292    }
2293
2294    /// And **focus and press** light it as well, which nothing did before: the old line
2295    /// read `hover_progress` alone, where the theme's rule answers all three states.
2296    #[test]
2297    fn focus_and_press_light_it_as_well() {
2298        let theme = Theme::default();
2299        let rail = NavigationRail::new(9, Msg::Go).item("H", "Home");
2300        let focused = Status {
2301            focus_progress: 1.0,
2302            ..live()
2303        };
2304        let pressed = Status {
2305            press_progress: 1.0,
2306            ..live()
2307        };
2308        assert!(layer(&rail, focused, &theme).is_some(), "focus");
2309        assert!(layer(&rail, pressed, &theme).is_some(), "press");
2310        assert!(
2311            layer(&rail, live(), &theme).is_none(),
2312            "and still nothing at rest"
2313        );
2314    }
2315
2316    /// Nothing lights on a destination that cannot be reached, in any of the three states:
2317    /// a state layer is the promise of an interaction, and there is none here.
2318    #[test]
2319    fn a_disabled_destination_lights_in_no_state_at_all() {
2320        let theme = Theme::default();
2321        let rail = NavigationRail::new(9, Msg::Go).item("H", "Home").disabled();
2322        for status in [
2323            hovered(),
2324            Status {
2325                focus_progress: 1.0,
2326                ..live()
2327            },
2328            Status {
2329                press_progress: 1.0,
2330                ..live()
2331            },
2332        ] {
2333            assert_eq!(layer(&rail, status, &theme), None);
2334        }
2335    }
2336
2337    /// **The wide form.** An extended rail puts each label beside its glyph rather than
2338    /// under it (`navigation_rail.dart:796`), and the glyph keeps the 80-pixel column it
2339    /// had (`:753`) — so extending a rail widens it and moves nothing.
2340    #[test]
2341    fn an_extended_rail_puts_the_label_beside_the_glyph() {
2342        let theme = Theme::default();
2343        let under = placed(&painted(true, false, None, true, false, &theme));
2344        let beside = placed(&painted(true, false, None, true, true, &theme));
2345        assert_eq!(under.len(), 2, "the glyph and its label");
2346        assert_eq!(beside.len(), 2);
2347
2348        assert!(
2349            under[1].1.y > under[0].1.y + frus_text::line_height(ICON_SIZE),
2350            "unextended, the label is clear of the glyph's line"
2351        );
2352        assert!(
2353            beside[1].1.x >= RAIL_WIDTH,
2354            "extended, the label starts where the glyph's column ends, not at x = {}",
2355            beside[1].1.x
2356        );
2357        assert!(
2358            beside[1].1.y > beside[0].1.y
2359                && beside[1].1.y < beside[0].1.y + frus_text::line_height(ICON_SIZE),
2360            "extended, the two stand on one line: {} against {}",
2361            beside[1].1.y,
2362            beside[0].1.y
2363        );
2364        assert!(
2365            (beside[0].1.x - under[0].1.x).abs() < 0.01,
2366            "and the glyph kept its column: {} against {}",
2367            beside[0].1.x,
2368            under[0].1.x
2369        );
2370    }
2371
2372    /// An extended row is **wider, not taller**: the label left the column, so the row is
2373    /// as tall as the taller of the two rather than as tall as both.
2374    ///
2375    /// Asked with a label big enough to beat the row's constant floor, which is what
2376    /// makes the two numbers differ at all.
2377    #[test]
2378    fn an_extended_row_is_wider_and_not_taller() {
2379        let theme = Theme::default();
2380        let big = Some(TextStyle {
2381            size: Some(40.0),
2382            ..Default::default()
2383        });
2384        let size = |extended: bool| {
2385            let style = Widget::<Msg>::style_themed(&row(true, true, extended, big), &theme);
2386            match (style.width, style.height) {
2387                (Dimension::Length(w), Dimension::Length(h)) => (w, h),
2388                other => panic!("a rail row names both of its sides, got {other:?}"),
2389            }
2390        };
2391        let (narrow, tall) = size(false);
2392        let (wide, short) = size(true);
2393        assert_eq!(narrow, RAIL_WIDTH);
2394        assert_eq!(wide, EXTENDED_RAIL_WIDTH);
2395        assert!(
2396            short < tall,
2397            "the label moved out of the column and the row did not follow it: {short} \
2398             against {tall}"
2399        );
2400    }
2401
2402    /// And it labels **every** destination, whatever the rail's label mode says
2403    /// (`navigation_rail.dart:219`): the label has room of its own there, so there is
2404    /// nothing for a mode to trade away.
2405    #[test]
2406    fn an_extended_rail_labels_every_destination() {
2407        let theme = Theme::default();
2408        let rail = NavigationRail::new(0, Msg::Go)
2409            .item("H", "Home")
2410            .item("S", "Search")
2411            .labels(RailLabels::None)
2412            .extended(true);
2413        for destination in destinations(&rail) {
2414            let mut scene = Scene::new();
2415            destination.paint(
2416                Rect::new(0.0, 0.0, EXTENDED_RAIL_WIDTH, ITEM_HEIGHT),
2417                Status::default(),
2418                &theme,
2419                &mut scene,
2420            );
2421            assert_eq!(
2422                placed(&scene).len(),
2423                2,
2424                "an extended destination says what it is even under RailLabels::None"
2425            );
2426        }
2427    }
2428
2429    /// The rail, in a window tall enough for its destinations to have somewhere to go.
2430    fn glyph_ys(rail: NavigationRail<Msg>) -> Vec<f32> {
2431        marks(rail)
2432            .into_iter()
2433            .filter(|(text, _)| text == "H" || text == "S")
2434            .map(|(_, y)| y)
2435            .collect()
2436    }
2437
2438    /// Everything one chrome painted, for a scene a test can ask questions of.
2439    fn scene_of(widget: impl Widget<Msg> + 'static, w: f32, h: f32) -> Vec<frus_core::Primitive> {
2440        let root = crate::Flex::row().width(w).height(h).child(widget);
2441        crate::build_ui(
2442            &root as &dyn Widget<Msg>,
2443            crate::Size::new(w, h),
2444            &crate::Runtime::default(),
2445            &Theme::default(),
2446        )
2447        .scene()
2448        .primitives()
2449        .to_vec()
2450    }
2451
2452    /// How many filled paths a scene holds — a drawn icon is one, and a glyph is none.
2453    fn drawn_icons(prims: &[frus_core::Primitive]) -> usize {
2454        prims
2455            .iter()
2456            .filter(|p| matches!(p, frus_core::Primitive::Path { fill: Some(_), .. }))
2457            .count()
2458    }
2459
2460    /// A destination is a **value**, and says what it was told.
2461    #[test]
2462    fn a_destination_carries_what_it_was_given() {
2463        let inbox = NavigationDestination::new(crate::Icons::MAIL_OUTLINE, "Inbox")
2464            .selected_icon(crate::Icons::MAIL)
2465            .badge(3)
2466            .tooltip("Unread messages")
2467            .disabled();
2468        assert_eq!(inbox.label(), "Inbox");
2469        assert_eq!(inbox.badge, Some(3));
2470        assert_eq!(inbox.tooltip.as_deref(), Some("Unread messages"));
2471        assert!(inbox.disabled);
2472        // The resting mark and the selected one are different marks, which is the whole
2473        // point of having both.
2474        assert_eq!(
2475            inbox.glyph(false),
2476            &DestinationIcon::Icon(crate::Icons::MAIL_OUTLINE)
2477        );
2478        assert_eq!(
2479            inbox.glyph(true),
2480            &DestinationIcon::Icon(crate::Icons::MAIL)
2481        );
2482        // With no selected mark, both states are the resting one.
2483        let plain = NavigationDestination::new("H", "Home");
2484        assert_eq!(plain.glyph(true), plain.glyph(false));
2485    }
2486
2487    /// A mark is an icon **or** a glyph, and neither has to be named at the call site.
2488    #[test]
2489    fn a_mark_is_an_icon_or_a_glyph() {
2490        assert_eq!(
2491            DestinationIcon::from(crate::Icons::HOME),
2492            DestinationIcon::Icon(crate::Icons::HOME)
2493        );
2494        assert_eq!(
2495            DestinationIcon::from("🏠"),
2496            DestinationIcon::Glyph("🏠".to_string())
2497        );
2498        assert_eq!(
2499            DestinationIcon::from('★'),
2500            DestinationIcon::Glyph("★".to_string())
2501        );
2502        assert_eq!(
2503            DestinationIcon::from("H".to_string()),
2504            DestinationIcon::Glyph("H".to_string())
2505        );
2506        // A drawn icon is a square of its own size; a glyph is whatever the font makes of
2507        // it. The two cannot share one number, which is why `measure` asks the mark.
2508        let square = DestinationIcon::from(crate::Icons::HOME).measure(24.0);
2509        assert_eq!((square.width, square.height), (24.0, 24.0));
2510        // An unnamed mark draws nothing rather than a placeholder of our choosing.
2511        let nothing = DestinationIcon::default().measure(24.0);
2512        assert_eq!(nothing.width, 0.0);
2513    }
2514
2515    /// The destinations reach the scene as **paths** when they are drawn icons, and as
2516    /// text when they are glyphs. This is the test that says an icon set arrived: before
2517    /// it, a destination could only be a character.
2518    #[test]
2519    fn a_drawn_icon_is_painted_as_a_path_and_a_glyph_as_text() {
2520        let drawn = scene_of(
2521            BottomBar::new(0, Msg::Go)
2522                .item(crate::Icons::HOME, "Home")
2523                .item(crate::Icons::SEARCH, "Search"),
2524            320.0,
2525            BAR_HEIGHT,
2526        );
2527        assert_eq!(drawn_icons(&drawn), 2, "two destinations, two drawn icons");
2528
2529        let glyphs = scene_of(
2530            BottomBar::new(0, Msg::Go)
2531                .item("H", "Home")
2532                .item("S", "Search"),
2533            320.0,
2534            BAR_HEIGHT,
2535        );
2536        assert_eq!(drawn_icons(&glyphs), 0, "a glyph is text, not a path");
2537    }
2538
2539    /// **The convention the icon set is drawn for**: an outline at rest, the filled twin
2540    /// when selected. Selecting a different destination changes which mark is painted,
2541    /// which is what makes navigation readable without leaning on colour alone.
2542    #[test]
2543    fn a_selected_destination_shows_its_selected_mark() {
2544        let bar = |selected: usize| {
2545            BottomBar::new(selected, Msg::Go)
2546                .item("h", "Home")
2547                .selected_icon("H")
2548                .item("s", "Search")
2549                .selected_icon("S")
2550        };
2551        let marks = |selected: usize| {
2552            scene_of(bar(selected), 320.0, BAR_HEIGHT)
2553                .iter()
2554                .filter_map(|p| match p {
2555                    frus_core::Primitive::Text { text, .. } if text.len() == 1 => {
2556                        Some(text.clone())
2557                    }
2558                    _ => None,
2559                })
2560                .collect::<Vec<_>>()
2561        };
2562        assert_eq!(marks(0), vec!["H".to_string(), "s".to_string()]);
2563        assert_eq!(marks(1), vec!["h".to_string(), "S".to_string()]);
2564    }
2565
2566    /// **One list, three chromes.** The reason a destination is a value at all: an
2567    /// application that shows a bar when narrow and a rail when wide declares its
2568    /// navigation once, and the two cannot drift.
2569    #[test]
2570    fn one_list_drives_a_bar_and_a_rail_alike() {
2571        let places = || {
2572            vec![
2573                NavigationDestination::new(crate::Icons::HOME, "Home"),
2574                NavigationDestination::new(crate::Icons::SEARCH, "Search").badge(2),
2575            ]
2576        };
2577        let bar = scene_of(
2578            BottomBar::new(1, Msg::Go).destinations(places()),
2579            320.0,
2580            BAR_HEIGHT,
2581        );
2582        let rail = scene_of(
2583            NavigationRail::new(1, Msg::Go)
2584                .labels(RailLabels::All)
2585                .destinations(places()),
2586            RAIL_WIDTH,
2587            600.0,
2588        );
2589        // Both drew both marks…
2590        assert_eq!(drawn_icons(&bar), 2);
2591        assert_eq!(drawn_icons(&rail), 2);
2592        // …both names, and both the same badge.
2593        let words = |prims: &[frus_core::Primitive]| {
2594            prims
2595                .iter()
2596                .filter_map(|p| match p {
2597                    frus_core::Primitive::Text { text, .. } => Some(text.clone()),
2598                    _ => None,
2599                })
2600                .collect::<Vec<_>>()
2601        };
2602        assert_eq!(words(&bar), words(&rail));
2603        assert!(
2604            words(&bar).contains(&"2".to_string()),
2605            "the badge travelled"
2606        );
2607    }
2608
2609    /// A tooltip is a wrapper, and a wrapper must not change the shape of what it wraps:
2610    /// a bar's destinations share the row whether or not they were given one.
2611    #[test]
2612    fn a_tooltip_does_not_move_the_destination_it_describes() {
2613        let without = scene_of(
2614            BottomBar::new(0, Msg::Go)
2615                .item("H", "Home")
2616                .item("S", "Search"),
2617            320.0,
2618            BAR_HEIGHT,
2619        );
2620        let with = scene_of(
2621            BottomBar::new(0, Msg::Go)
2622                .item("H", "Home")
2623                .tooltip("Go home")
2624                .item("S", "Search")
2625                .tooltip("Find something"),
2626            320.0,
2627            BAR_HEIGHT,
2628        );
2629        let marks = |prims: &[frus_core::Primitive]| {
2630            prims
2631                .iter()
2632                .filter_map(|p| match p {
2633                    frus_core::Primitive::Text { text, position, .. } if text.len() == 1 => {
2634                        Some((text.clone(), position.x, position.y))
2635                    }
2636                    _ => None,
2637                })
2638                .collect::<Vec<_>>()
2639        };
2640        assert_eq!(
2641            marks(&without),
2642            marks(&with),
2643            "a hint moved the destinations it was meant to describe"
2644        );
2645    }
2646
2647    /// Every glyph the rail painted, top to bottom, as `(text, y)`.
2648    fn marks(rail: NavigationRail<Msg>) -> Vec<(String, f32)> {
2649        const TALL: f32 = 600.0;
2650        let root = crate::Flex::row().width(200.0).height(TALL).child(rail);
2651        let ui = crate::build_ui(
2652            &root as &dyn Widget<Msg>,
2653            crate::Size::new(200.0, TALL),
2654            &crate::Runtime::default(),
2655            &Theme::default(),
2656        );
2657        let mut found: Vec<(String, f32)> = ui
2658            .scene()
2659            .primitives()
2660            .iter()
2661            .filter_map(|p| match p {
2662                frus_core::Primitive::Text { text, position, .. } => {
2663                    Some((text.clone(), position.y))
2664                }
2665                _ => None,
2666            })
2667            .collect();
2668        found.sort_by(|a, b| a.1.partial_cmp(&b.1).expect("no NaN in a laid-out rail"));
2669        found
2670    }
2671
2672    /// **Where the destinations sit**, anywhere between the rail's two ends
2673    /// (`navigation_rail.dart:205`).
2674    ///
2675    /// Continuous, which is the part a three-valued enum could not have done: `-0.5`
2676    /// lands between the top and the middle rather than at one of them.
2677    #[test]
2678    fn the_group_travels_between_the_rail_s_ends() {
2679        let at = |alignment: f32| {
2680            glyph_ys(
2681                NavigationRail::new(0, Msg::Go)
2682                    .item("H", "Home")
2683                    .item("S", "Search")
2684                    .group_alignment(alignment),
2685            )
2686        };
2687        let top = at(-1.0);
2688        let quarter = at(-0.5);
2689        let middle = at(0.0);
2690        let bottom = at(1.0);
2691        assert!(
2692            top[0] < quarter[0] && quarter[0] < middle[0] && middle[0] < bottom[0],
2693            "the group did not travel: {top:?} {quarter:?} {middle:?} {bottom:?}"
2694        );
2695        assert!(
2696            (bottom[1] - bottom[0] - (top[1] - top[0])).abs() < 0.01,
2697            "the group changed shape on the way: {top:?} against {bottom:?}"
2698        );
2699    }
2700
2701    /// **A pinned slot does not travel with the group.** The reference pins the leading
2702    /// slot to the top and lets the trailing one travel (`navigation_rail.dart:148`,
2703    /// `:158`): a leading slot is chrome at the top of the rail, a trailing one is the
2704    /// tail of the list of destinations.
2705    #[test]
2706    fn the_leading_slot_stays_where_the_trailing_one_travels() {
2707        let at = |alignment: f32| {
2708            marks(
2709                NavigationRail::new(0, Msg::Go)
2710                    .leading(crate::text("L"))
2711                    .trailing(crate::text("T"))
2712                    .item("H", "Home")
2713                    .group_alignment(alignment),
2714            )
2715        };
2716        let (up, down) = (at(-1.0), at(1.0));
2717        let (top, bottom) = (y_of(&up), y_of(&down));
2718        assert!(
2719            (top("L") - bottom("L")).abs() < 0.01,
2720            "the leading slot moved with the group: {} against {}",
2721            top("L"),
2722            bottom("L")
2723        );
2724        assert!(
2725            bottom("T") > top("T"),
2726            "the trailing slot did not travel: {} against {}",
2727            bottom("T"),
2728            top("T")
2729        );
2730        assert!(
2731            bottom("T") > bottom("H"),
2732            "and it is still the tail of the list"
2733        );
2734        assert!(top("L") < top("H"), "the leading slot is above them");
2735    }
2736
2737    /// And **which** of the two is pinned is the caller's to say, both ways round.
2738    #[test]
2739    fn and_which_of_them_is_pinned_can_be_swapped() {
2740        let at = |alignment: f32| {
2741            marks(
2742                NavigationRail::new(0, Msg::Go)
2743                    .leading(crate::text("L"))
2744                    .trailing(crate::text("T"))
2745                    .leading_at_top(false)
2746                    .trailing_at_bottom(true)
2747                    .item("H", "Home")
2748                    .group_alignment(alignment),
2749            )
2750        };
2751        let (up, down) = (at(-1.0), at(1.0));
2752        let (top, bottom) = (y_of(&up), y_of(&down));
2753        assert!(
2754            bottom("L") > top("L"),
2755            "an unpinned leading slot travels with the group"
2756        );
2757        assert!(
2758            (top("T") - bottom("T")).abs() < 0.01,
2759            "a pinned trailing slot does not: {} against {}",
2760            top("T"),
2761            bottom("T")
2762        );
2763        assert!(
2764            top("T") > top("H"),
2765            "and it stays below the destinations either way"
2766        );
2767    }
2768
2769    /// Reads one glyph's `y` out of what [`marks`] collected.
2770    fn y_of(found: &[(String, f32)]) -> impl Fn(&str) -> f32 + '_ {
2771        move |glyph| {
2772            found
2773                .iter()
2774                .find(|(text, _)| text == glyph)
2775                .unwrap_or_else(|| panic!("{glyph} was never painted, only {found:?}"))
2776                .1
2777        }
2778    }
2779
2780    /// A [`Status`] a destination is actually being painted under, rather than the
2781    /// default's zero opacity.
2782    fn live() -> Status {
2783        Status {
2784            opacity: 1.0,
2785            ..Default::default()
2786        }
2787    }
2788
2789    /// The same, with the pointer over it.
2790    fn hovered() -> Status {
2791        Status {
2792            hover_progress: 1.0,
2793            ..live()
2794        }
2795    }
2796
2797    /// Paints destination `index` of `rail`, in a rail-sized box.
2798    fn paint_destination(
2799        rail: &NavigationRail<Msg>,
2800        index: usize,
2801        status: Status,
2802        theme: &Theme,
2803    ) -> Scene {
2804        let mut scene = Scene::new();
2805        destinations(rail)[index].paint(
2806            Rect::new(0.0, 0.0, RAIL_WIDTH, ITEM_HEIGHT),
2807            status,
2808            theme,
2809            &mut scene,
2810        );
2811        scene
2812    }
2813
2814    /// **A destination that cannot be reached says so, four ways** (milestone 436).
2815    ///
2816    /// The reference gives the glyph and the label one rule — `on_surface` at 38 %
2817    /// (`navigation_rail.dart:717`, `:723`) — and hands the ink well a null `onTap`
2818    /// (`:957`). The hover and the focus follow from there rather than from a fifth
2819    /// property: a hover is the promise of a click, and there is no click here.
2820    #[test]
2821    fn a_destination_that_cannot_be_reached_says_so() {
2822        let theme = Theme::default();
2823        // 9 with two destinations: nothing selected, so no indicator confuses the count.
2824        let rail = NavigationRail::new(9, Msg::Go)
2825            .labels(RailLabels::All)
2826            .item("H", "Home")
2827            .disabled();
2828        let item = &destinations(&rail)[0];
2829        assert_eq!(item.on_click(), None, "it emits nothing");
2830        assert!(!item.focusable(), "and the keyboard steps over it");
2831
2832        let ink = disabled_content(&theme);
2833        assert_eq!(
2834            texts(&paint_destination(&rail, 0, live(), &theme)),
2835            vec![ink, ink],
2836            "one rule for the glyph and for the label"
2837        );
2838        assert!(
2839            rects(&paint_destination(&rail, 0, hovered(), &theme)).is_empty(),
2840            "and nothing lights under the pointer"
2841        );
2842
2843        // Which means something only if a live one does all four.
2844        let live_rail = NavigationRail::new(9, Msg::Go)
2845            .labels(RailLabels::All)
2846            .item("H", "Home");
2847        let live_item = &destinations(&live_rail)[0];
2848        assert_eq!(live_item.on_click(), Some(Msg::Go(0)));
2849        assert!(live_item.focusable());
2850        assert_ne!(
2851            texts(&paint_destination(&live_rail, 0, live(), &theme))[0],
2852            ink
2853        );
2854        assert!(!rects(&paint_destination(&live_rail, 0, hovered(), &theme)).is_empty());
2855    }
2856
2857    /// **A selected destination can show a different glyph** (`navigation_rail.dart:1132`).
2858    ///
2859    /// The reference pairs a stroked icon with its filled version, which is how a
2860    /// selected destination reads as selected without leaning on colour alone.
2861    #[test]
2862    fn a_selected_destination_can_show_a_different_glyph() {
2863        let theme = Theme::default();
2864        let glyph = |selected: usize| {
2865            let rail = NavigationRail::new(selected, Msg::Go)
2866                .item("H", "Home")
2867                .selected_icon("\u{2605}")
2868                .item("S", "Search");
2869            placed(&paint_destination(&rail, 0, live(), &theme))[0]
2870                .0
2871                .clone()
2872        };
2873        assert_eq!(glyph(1), "H", "at rest, the one it was given");
2874        assert_eq!(glyph(0), "\u{2605}", "selected, the other one");
2875
2876        // A destination that names no second glyph keeps its first in both states.
2877        let plain = |selected: usize| {
2878            let rail = NavigationRail::new(selected, Msg::Go).item("H", "Home");
2879            placed(&paint_destination(&rail, 0, live(), &theme))[0]
2880                .0
2881                .clone()
2882        };
2883        assert_eq!(plain(0), plain(1));
2884    }
2885
2886    /// **A destination's own indicator colour outranks the theme's**
2887    /// (`navigation_rail.dart:1144`) — how one entry marks itself out from the rest.
2888    #[test]
2889    fn a_destination_can_carry_its_own_indicator_colour() {
2890        let mut theme = Theme::default();
2891        theme.widgets.nav_rail.indicator_color = Some(Color::rgb8(1, 2, 3));
2892        let told = Color::rgb8(4, 5, 6);
2893
2894        let mine = NavigationRail::new(0, Msg::Go)
2895            .item("H", "Home")
2896            .indicator_color(told);
2897        assert_eq!(
2898            rects(&paint_destination(&mine, 0, live(), &theme))
2899                .first()
2900                .copied(),
2901            Some(told)
2902        );
2903
2904        let theirs = NavigationRail::new(0, Msg::Go).item("H", "Home");
2905        assert_eq!(
2906            rects(&paint_destination(&theirs, 0, live(), &theme))
2907                .first()
2908                .copied(),
2909            Some(Color::rgb8(1, 2, 3)),
2910            "and the theme's when the destination says nothing"
2911        );
2912    }
2913
2914    /// The rail's destinations, which since milestone 433 sit inside the **group** that
2915    /// [`NavigationRail::group_alignment`] moves rather than directly under the rail.
2916    /// **A rail too short for its destinations scrolls them**
2917    /// (`navigation_rail.dart:353`) rather than running them off the bottom.
2918    ///
2919    /// The pinned slots stay put and the group goes in a viewport that fills what is
2920    /// left, which is why the two spacers that would otherwise place it are gone: a
2921    /// viewport filling the rail has no spare room to be placed in.
2922    #[test]
2923    fn a_short_rail_scrolls_its_destinations() {
2924        let plain = NavigationRail::new(0, Msg::Go)
2925            .item("H", "Home")
2926            .item("S", "Saved");
2927        assert!(
2928            !Widget::<Msg>::children(&plain)
2929                .iter()
2930                .any(|c| c.debug_name() == "SingleChildScrollView"),
2931            "a rail does not scroll unless asked"
2932        );
2933        assert_eq!(
2934            Widget::<Msg>::children(&plain).len(),
2935            3,
2936            "two spacers around the group"
2937        );
2938
2939        let scrolling = NavigationRail::new(0, Msg::Go)
2940            .item("H", "Home")
2941            .item("S", "Saved")
2942            .scrollable();
2943        let children = Widget::<Msg>::children(&scrolling);
2944        assert_eq!(
2945            children.len(),
2946            1,
2947            "the viewport fills, so nothing places it"
2948        );
2949        assert_eq!(children[0].debug_name(), "SingleChildScrollView");
2950        // And the destinations are still in there, one level down.
2951        assert!(
2952            children[0]
2953                .children()
2954                .iter()
2955                .any(|c| c.children().iter().any(|d| d.debug_name() == "NavItem")),
2956            "the group went into the viewport"
2957        );
2958    }
2959
2960    /// **Spreading the destinations replaces the alignment** (`navigation_rail.dart:362`),
2961    /// and not by a rule: the group fills the rail to have room to spread them in, so
2962    /// there is none left outside it for `group_alignment` to work in — which is how the
2963    /// reference arrives at the same sentence (`:501`).
2964    #[test]
2965    fn spreading_the_destinations_replaces_the_alignment() {
2966        let spread = NavigationRail::new(0, Msg::Go)
2967            .item("H", "Home")
2968            .item("S", "Saved")
2969            .group_alignment(0.0)
2970            .main_axis_alignment(Justify::SpaceEvenly);
2971        let children = Widget::<Msg>::children(&spread);
2972        assert_eq!(children.len(), 1, "no spacers left to fight the group");
2973        assert!(
2974            children[0]
2975                .children()
2976                .iter()
2977                .any(|c| c.debug_name() == "NavItem"),
2978            "and the group itself is what fills"
2979        );
2980        assert_eq!(children[0].style().justify, Justify::SpaceEvenly);
2981        assert_eq!(
2982            children[0].style().flex_grow,
2983            1.0,
2984            "it has to fill to spread"
2985        );
2986    }
2987
2988    /// **Without an indicator the selected destination says so in the accent**
2989    /// (`navigation_rail.dart:1221`, `:1211`).
2990    ///
2991    /// Turning the indicator off is not just removing a shape: with nothing behind it the
2992    /// glyph stands on the rail's own surface, and the indicator's content colour would
2993    /// be a colour for a ground that is not there.
2994    #[test]
2995    fn a_rail_without_an_indicator_says_so_in_the_accent() {
2996        let theme = Theme::default();
2997        let ink = |use_indicator: bool| {
2998            let rail = NavigationRail::new(0, Msg::Go)
2999                .item("H", "Home")
3000                .labels(RailLabels::All)
3001                .use_indicator(use_indicator);
3002            let item = &destinations(&rail)[0];
3003            let mut scene = Scene::new();
3004            item.paint(
3005                Rect::new(0.0, 0.0, RAIL_WIDTH, ITEM_HEIGHT),
3006                Status {
3007                    opacity: 1.0,
3008                    ..Default::default()
3009                },
3010                &theme,
3011                &mut scene,
3012            );
3013            let pills = scene
3014                .primitives()
3015                .iter()
3016                .filter(|p| matches!(p, frus_core::Primitive::Rect { .. }))
3017                .count();
3018            let inks: Vec<Color> = scene
3019                .primitives()
3020                .iter()
3021                .filter_map(|p| match p {
3022                    frus_core::Primitive::Text { color, .. } => Some(*color),
3023                    _ => None,
3024                })
3025                .collect();
3026            (pills, inks)
3027        };
3028
3029        let (with, on_indicator) = ink(true);
3030        assert_eq!(with, 1, "the indicator is drawn");
3031        assert_eq!(on_indicator[0], theme.scheme.on_secondary_container);
3032
3033        let (without, on_surface) = ink(false);
3034        assert_eq!(without, 0, "and it is not, when it was turned off");
3035        assert_eq!(on_surface[0], theme.primary, "the glyph carries the accent");
3036        assert_eq!(
3037            on_surface[1], theme.primary,
3038            "and so does the label (`navigation_rail.dart:1211`)"
3039        );
3040    }
3041
3042    /// **A raised rail drops its rule** (`navigation_rail.dart:192`). A shadow and a
3043    /// hairline are two ways of saying the same thing, and drawing both says it twice.
3044    #[test]
3045    fn a_raised_rail_drops_its_rule() {
3046        let theme = Theme::default();
3047        let painted = |rail: &NavigationRail<Msg>| {
3048            let mut scene = Scene::new();
3049            Widget::<Msg>::paint(
3050                rail,
3051                Rect::new(0.0, 0.0, RAIL_WIDTH, 400.0),
3052                Status {
3053                    opacity: 1.0,
3054                    ..Default::default()
3055                },
3056                &theme,
3057                &mut scene,
3058            );
3059            // A shadow is a rect with a blurred edge — see `Scene::shadow`.
3060            let shadows = scene
3061                .primitives()
3062                .iter()
3063                .filter(|p| match p {
3064                    frus_core::Primitive::Rect { blur, .. } => *blur > 0.0,
3065                    _ => false,
3066                })
3067                .count();
3068            let rules = scene
3069                .primitives()
3070                .iter()
3071                .filter(|p| match p {
3072                    frus_core::Primitive::Rect { rect, .. } => rect.width == 1.0,
3073                    _ => false,
3074                })
3075                .count();
3076            (shadows, rules)
3077        };
3078
3079        let flat = NavigationRail::new(0, Msg::Go).item("H", "Home");
3080        assert_eq!(painted(&flat), (0, 1), "flat by default, and ruled");
3081        let raised = NavigationRail::new(0, Msg::Go)
3082            .item("H", "Home")
3083            .elevation(3.0);
3084        assert_eq!(painted(&raised), (1, 0), "raised, and the rule is gone");
3085    }
3086
3087    fn destinations(rail: &NavigationRail<Msg>) -> &[Box<dyn Widget<Msg>>] {
3088        // By what the children **are**, not by whether they are clickable: a rail whose
3089        // only destination is disabled has nothing clickable in it (milestone 436).
3090        Widget::<Msg>::children(rail)
3091            .iter()
3092            .find(|child| child.children().iter().any(|c| c.debug_name() == "NavItem"))
3093            .expect("the rail assembles its destinations into one group")
3094            .children()
3095    }
3096
3097    #[test]
3098    fn rail_items_emit_index_and_track_selection() {
3099        let rail = NavigationRail::new(1, Msg::Go)
3100            .item("H", "Home")
3101            .item("S", "Search")
3102            .item("P", "Profile");
3103        let children = destinations(&rail);
3104        assert_eq!(children.len(), 3);
3105        assert_eq!(children[2].on_click(), Some(Msg::Go(2)));
3106    }
3107
3108    #[test]
3109    fn badge_decorates_last_item_and_paints_counter() {
3110        let rail = NavigationRail::new(0, Msg::Go)
3111            .item("H", "Home")
3112            .item("M", "Mail")
3113            .badge(5);
3114        let children = destinations(&rail);
3115        // The badge paints a dot + the count text on the targeted item.
3116        let mut scene = Scene::new();
3117        children[1].paint(
3118            Rect::new(0.0, 0.0, RAIL_WIDTH, ITEM_HEIGHT),
3119            Status::default(),
3120            &Theme::default(),
3121            &mut scene,
3122        );
3123        assert!(scene
3124            .primitives()
3125            .iter()
3126            .any(|p| matches!(p, frus_core::Primitive::Text { text, .. } if text == "5")));
3127        // The item without a badge does not paint that count.
3128        let mut bare = Scene::new();
3129        children[0].paint(
3130            Rect::new(0.0, 0.0, RAIL_WIDTH, ITEM_HEIGHT),
3131            Status::default(),
3132            &Theme::default(),
3133            &mut bare,
3134        );
3135        assert!(!bare
3136            .primitives()
3137            .iter()
3138            .any(|p| matches!(p, frus_core::Primitive::Text { text, .. } if text == "5")));
3139    }
3140
3141    #[test]
3142    fn badge_over_99_is_capped() {
3143        let bar = BottomBar::new(0, Msg::Go).item("M", "Mail").badge(150);
3144        let children = Widget::<Msg>::children(&bar);
3145        let mut scene = Scene::new();
3146        children[0].paint(
3147            Rect::new(0.0, 0.0, 80.0, BAR_HEIGHT),
3148            Status::default(),
3149            &Theme::default(),
3150            &mut scene,
3151        );
3152        assert!(scene
3153            .primitives()
3154            .iter()
3155            .any(|p| matches!(p, frus_core::Primitive::Text { text, .. } if text == "99+")));
3156    }
3157
3158    /// **The bar consumes what it is told about** (milestone 418). Its parent used to
3159    /// pad it from outside, which put the bar's surface above the gesture bar rather
3160    /// than behind it; the reference keeps the safe area inside the `Material`
3161    /// (`navigation_bar.dart:285`) and the bar grows by the intrusion instead.
3162    #[test]
3163    fn a_bottom_bar_consumes_the_intrusion_it_was_told_about() {
3164        use crate::{MediaQuery, Size};
3165        const GESTURE: f32 = 24.0;
3166        let bar = BottomBar::new(0, Msg::Go).item("H", "Home");
3167        let bare = match Widget::<Msg>::style(&bar).height {
3168            Dimension::Length(h) => h,
3169            other => panic!("a bar declares a height, not {other:?}"),
3170        };
3171        let told = MediaQuery::new(Size::new(400.0, 800.0))
3172            .with_insets(frus_core::WindowInsets::bars(Insets::new(
3173                0.0, 0.0, GESTURE, 0.0,
3174            )))
3175            .scope(|| Widget::<Msg>::style(&bar));
3176        match told.height {
3177            Dimension::Length(h) => assert!(
3178                (h - (bare + GESTURE)).abs() < 0.01,
3179                "the bar did not grow by the intrusion: {h} vs {bare}"
3180            ),
3181            other => panic!("a bar declares a height, not {other:?}"),
3182        }
3183        // And it is the **content** that is held clear, not the box: the padding is what
3184        // keeps the destinations off the edge while the surface reaches it.
3185        assert!(
3186            (told.padding.bottom - GESTURE).abs() < 0.01,
3187            "the destinations were not held clear: {:?}",
3188            told.padding
3189        );
3190        // Never the top: a shell removes it before handing the slot over, and a bar at
3191        // the bottom of a screen has nothing above it to avoid.
3192        assert_eq!(told.padding.top, 0.0);
3193    }
3194
3195    #[test]
3196    fn bottom_bar_items_are_flexible() {
3197        let bar = BottomBar::new(0, Msg::Go)
3198            .item("H", "Home")
3199            .item("S", "Search");
3200        let children = Widget::<Msg>::children(&bar);
3201        assert_eq!(children.len(), 2);
3202        // A bar item: shares the width (flex_grow > 0), no fixed width.
3203        assert_eq!(Widget::<Msg>::style(&*children[0]).flex_grow, 1.0);
3204        assert_eq!(children[1].on_click(), Some(Msg::Go(1)));
3205    }
3206}