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