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