Skip to main content

frus_widgets/
navdrawer.rs

1//! [`NavigationDrawer`]: the **third** presentation of the primary navigation, beside
2//! [`NavigationRail`](crate::NavigationRail) and [`BottomBar`](crate::BottomBar).
3//!
4//! A rail trades words for width and a bar trades height for reach. A drawer trades
5//! nothing: it is the one form with room for a full label on every destination, a heading
6//! above them and a footer below, which is why an application with more than five
7//! destinations has nowhere else to put them.
8//!
9//! It is the **content** of a side panel, not the panel: [`Drawer`](crate::Drawer) is the
10//! shell that slides, scrims and docks, and this is what goes inside it.
11//!
12//! ```ignore
13//! Drawer::new(app.menu_open)
14//!     .on_dismiss(Msg::CloseMenu)
15//!     .panel(
16//!         NavigationDrawer::new(app.tab, Msg::Go)
17//!             .header(Text::new("Mailbox"))
18//!             .item("in", "Inbox").badge(12)
19//!             .item("st", "Starred")
20//!             .child(Divider::new())
21//!             .item("tr", "Trash"),
22//!     )
23//!     .body(page)
24//! ```
25//!
26//! **A destination's index counts destinations**, not children: the divider above is one
27//! of the drawer's children and none of its destinations, so `Trash` is destination 2 and
28//! the message it emits says 2 (`navigation_drawer.dart:180`). That is why the reference
29//! walks its child list twice, and why [`child`] exists here rather than a caller building
30//! the column itself.
31//!
32//! Everything it paints is a default and nothing is a rule: the surface, how far off the
33//! page it sits, the indicator's colour, shape and size, the height of a tile and the room
34//! either side of it — each resolved from the instance, then
35//! [`NavDrawerTheme`](crate::widgettheme::NavDrawerTheme), then the scheme's role.
36//!
37//! [`child`]: NavigationDrawer::child
38
39use std::cell::{OnceCell, RefCell};
40
41use frus_core::{Color, Insets, Point, Rect, ResolvedTextStyle, Scene, ShapeBorder, TextStyle};
42use frus_layout::{Dimension, FlexDirection, Style};
43
44use crate::disabled::disabled_content;
45use crate::flex::Flex;
46use crate::interaction::Status;
47use crate::navrail::{DestinationIcon, NavigationDestination};
48use crate::scroll::SingleChildScrollView;
49use crate::theme::Theme;
50use crate::widget::{FillAxes, Widget};
51use crate::widgetstate::WidgetStateProperty;
52
53/// A tile's height (`navigation_drawer.dart:730`).
54const TILE_HEIGHT: f32 = 56.0;
55/// The room either side of a tile (`navigation_drawer.dart:69`).
56const TILE_PADDING: Insets = Insets::new(0.0, 12.0, 0.0, 12.0);
57/// The selected indicator's box (`navigation_drawer.dart:732`). The width is a **ceiling**
58/// — see the paint.
59const INDICATOR_WIDTH: f32 = 336.0;
60const INDICATOR_HEIGHT: f32 = 56.0;
61/// From the tile's leading edge to the glyph (`navigation_drawer.dart:398`).
62const ICON_LEAD: f32 = 16.0;
63/// And from the glyph to the label (`:400`).
64const ICON_GAP: f32 = 12.0;
65/// The glyph's own box (`navigation_drawer.dart:755`).
66const ICON_SIZE: f32 = 24.0;
67/// How far the notification count sits from the trailing edge.
68const BADGE_TRAIL: f32 = 24.0;
69/// How far off the page the panel sits (`navigation_drawer.dart:729`).
70const ELEVATION: f32 = 1.0;
71
72/// The destinations' type — `labelLarge` (`navigation_drawer.dart:768`), where a rail's is
73/// `labelMedium`. A drawer's label has a line to itself and can afford the larger step.
74fn label_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
75    over.or(theme.and_then(|t| t.widgets.nav_drawer.label_text_style))
76        .unwrap_or_else(|| crate::theme::type_scale(theme).label_large)
77        .resolved()
78}
79
80/// The notification count's type — `labelSmall`, the step [`crate::Badge`] reads.
81fn badge_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
82    over.or(theme.and_then(|t| t.widgets.nav_drawer.badge_text_style))
83        .unwrap_or_else(|| crate::theme::type_scale(theme).label_small)
84        .resolved()
85}
86
87/// **What the caller put in the drawer**, in the order they put it there.
88///
89/// A drawer's children are a *mixed* list in the reference (`navigation_drawer.dart:122`):
90/// destinations, and whatever else an application wants between them. Keeping the two in
91/// one list rather than two is what makes a divider land where it was written instead of
92/// after the last destination.
93enum Entry<Msg> {
94    /// A destination, which takes the next index. Boxed: a declared destination is a
95    /// wide value — two marks, three optional colours, a shape and two strings — and the
96    /// other arm is one pointer, so the list would otherwise pay a destination's width
97    /// for every divider in it.
98    Stop(Box<NavigationDestination>),
99    /// Anything else — a heading, a rule, a spacer. It takes no index.
100    Other(Box<dyn Widget<Msg>>),
101}
102
103/// **One destination**, as a full-width row: the indicator behind it, the glyph, the
104/// label, and the count if it has one.
105///
106/// This is not a rail's row with different numbers. There the indicator is a pill **around
107/// the glyph** and the label sits below it on the rail's own surface, which is why the two
108/// take different colours when selected. Here the indicator is the row, so the label
109/// stands on it and takes the indicator's content colour like the glyph does
110/// (`navigation_drawer.dart:773` against `navigation_rail.dart:1251`).
111struct DrawerTile<Msg> {
112    icon: DestinationIcon,
113    label: String,
114    badge: Option<u32>,
115    selected: bool,
116    disabled: bool,
117    /// Which destination this is, and how many there are — for what a reader hears.
118    index: usize,
119    count: usize,
120    /// This destination's own indicator colour, over the drawer's, over the theme's.
121    indicator_color: Option<Color>,
122    indicator_shape: Option<ShapeBorder>,
123    overlay_color: Option<WidgetStateProperty<Color>>,
124    /// The surface the row stands on, so a state layer is measured from the colour that
125    /// was actually painted under it.
126    ground: Option<Color>,
127    /// The whole rectangular area behind this one destination, when it was given one
128    /// (`navigation_drawer.dart:228`). Not the indicator — that is the field above.
129    background: Option<Color>,
130    tile_height: Option<f32>,
131    tile_padding: Option<Insets>,
132    indicator_size: Option<(f32, f32)>,
133    icon_size: Option<f32>,
134    label_text_style: Option<TextStyle>,
135    badge_text_style: Option<TextStyle>,
136    message: Msg,
137}
138
139impl<Msg> DrawerTile<Msg> {
140    /// The room either side, resolved.
141    fn padding(&self, theme: Option<&Theme>) -> Insets {
142        self.tile_padding
143            .or(theme.and_then(|t| t.widgets.nav_drawer.tile_padding))
144            .unwrap_or(TILE_PADDING)
145    }
146
147    /// The glyph's box, resolved.
148    fn glyph_size(&self, theme: Option<&Theme>) -> f32 {
149        self.icon_size
150            .or(theme.and_then(|t| t.widgets.nav_drawer.icon_size))
151            .unwrap_or(ICON_SIZE)
152    }
153
154    /// A tile keeps its declared height unless the reader enlarged the type past it: a row
155    /// that clipped its own label would be a constant winning an argument with the
156    /// system's font size. The same floor a rail's rows keep.
157    fn sizing(&self, theme: Option<&Theme>) -> Style {
158        let pad = self.padding(theme);
159        let floor = self
160            .tile_height
161            .or(theme.and_then(|t| t.widgets.nav_drawer.tile_height))
162            .unwrap_or(TILE_HEIGHT);
163        let icon = frus_text::line_height(self.glyph_size(theme));
164        let label = label_style(self.label_text_style, theme).line_height();
165        Style {
166            height: Dimension::Length(floor.max(icon.max(label) + 8.0) + pad.top + pad.bottom),
167            ..Default::default()
168        }
169    }
170}
171
172impl<Msg: Clone> Widget<Msg> for DrawerTile<Msg> {
173    fn style(&self) -> Style {
174        self.sizing(None)
175    }
176
177    fn style_themed(&self, theme: &Theme) -> Style {
178        self.sizing(Some(theme))
179    }
180
181    /// A row takes the width the drawer offers, and the **whole** width answers a tap even
182    /// where the indicator is narrower than it — the reference's ink well wraps the stack,
183    /// not the indicator (`navigation_drawer.dart:375`).
184    fn fill_axes(&self, _theme: &Theme) -> FillAxes {
185        FillAxes::WIDTH
186    }
187
188    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
189        &[]
190    }
191
192    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
193        let o = status.opacity;
194        let t = &theme.widgets.nav_drawer;
195        let pad = self.padding(Some(theme));
196
197        // **The whole rectangular area behind the destination** — outside the padding, so
198        // it runs edge to edge (`navigation_drawer.dart:226`). Painted first: everything
199        // else stands on it.
200        if let Some(background) = self.background {
201            scene.fill_rect(bounds, background.fade(o));
202        }
203
204        let inner = Rect::new(
205            bounds.x + pad.left,
206            bounds.y + pad.top,
207            (bounds.width - pad.left - pad.right).max(0.0),
208            (bounds.height - pad.top - pad.bottom).max(0.0),
209        );
210
211        // **The indicator's declared width is a ceiling, not a promise.** The reference
212        // asks for 336 inside a panel that is 304 wide by default, and the stack's
213        // constraints settle it: what is drawn is the narrower of the two. Reading the
214        // constant as a width would push the pill past the panel's edge in every
215        // application that never changed the drawer's width — which is all of them.
216        let (want_w, want_h) = self
217            .indicator_size
218            .or(t.indicator_size)
219            .unwrap_or((INDICATOR_WIDTH, INDICATOR_HEIGHT));
220        let ind_w = want_w.min(inner.width);
221        let ind_h = want_h.min(inner.height);
222        let pill = Rect::new(
223            inner.x + (inner.width - ind_w) * 0.5,
224            inner.y + (inner.height - ind_h) * 0.5,
225            ind_w,
226            ind_h,
227        );
228
229        // The ground a state layer is measured from: what was actually painted under this
230        // row — this destination's own colour if it has one, else the drawer's.
231        let ground = self
232            .background
233            .or(self.ground)
234            .or(t.background_color)
235            .unwrap_or(theme.scheme.surface_container_low);
236        let indicator = self
237            .indicator_color
238            .or(t.indicator_color)
239            .unwrap_or(theme.scheme.secondary_container);
240        let base = if self.selected { indicator } else { ground };
241
242        let states = status
243            .states()
244            .set(crate::WidgetState::Selected, self.selected)
245            .set(crate::WidgetState::Disabled, self.disabled);
246        // The destination's word, then the theme's, then the framework's state layer.
247        // Nothing lights on a destination that cannot be reached: a state layer is the
248        // promise of an interaction, and there is none here.
249        let told = self
250            .overlay_color
251            .as_ref()
252            .and_then(|own| own.resolve(states))
253            .or_else(|| t.overlay_color.as_ref().and_then(|w| w.resolve(states)));
254        let fill = if self.disabled {
255            base
256        } else if let Some(overlay) = told {
257            base.lerp(overlay.fade(1.0), overlay.a)
258        } else {
259            theme.state_layer(base, theme.scheme.primary, &status)
260        };
261        if self.selected || fill != base {
262            let shape = self
263                .indicator_shape
264                .or(t.indicator_shape)
265                .unwrap_or_else(ShapeBorder::stadium);
266            scene.draw_shape(pill, shape, fill.fade(o));
267        }
268
269        // **One colour for both halves.** The glyph and the label are both on the
270        // indicator here, so both take its content colour when selected
271        // (`navigation_drawer.dart:759` and `:773`) — the one thing a drawer's row does
272        // differently from a rail's, and it follows from the indicator's size rather than
273        // from a separate decision.
274        let ink = if self.disabled {
275            disabled_content(theme)
276        } else if self.selected {
277            t.selected_color
278                .unwrap_or(theme.scheme.on_secondary_container)
279        } else {
280            t.unselected_color
281                .unwrap_or(theme.scheme.on_surface_variant)
282        };
283
284        let size = self.glyph_size(Some(theme));
285        let label_s = label_style(self.label_text_style, Some(theme));
286        let icon_m = self.icon.measure(size);
287        let label_m = frus_text::measure_resolved(&self.label, &label_s);
288
289        // The row starts at the tile's leading edge, not the indicator's: in a drawer wide
290        // enough for the full 336 the pill is centred and the glyph is not. And the label
291        // starts a fixed distance in, from the glyph's **box** rather than from the ink it
292        // happened to put in it — otherwise a column of destinations has its labels at as
293        // many different offsets as it has glyph widths.
294        let x = inner.x + ICON_LEAD;
295        self.icon.paint(
296            size,
297            x,
298            inner.y + (inner.height - icon_m.height) * 0.5,
299            ink.fade(o),
300            theme,
301            scene,
302        );
303        scene.text(
304            Point::new(
305                x + size + ICON_GAP,
306                inner.y + (inner.height - label_m.height) * 0.5,
307            ),
308            self.label.clone(),
309            &label_s,
310            ink.fade(o),
311        );
312
313        // The count, at the trailing end of the row. A rail hangs it off the glyph because
314        // an 80-pixel column has nowhere else; here the row is wide and it goes where the
315        // eye finishes reading.
316        if let Some(count) = self.badge.filter(|&n| n > 0) {
317            let text = if count > 99 {
318                "99+".to_string()
319            } else {
320                count.to_string()
321            };
322            let badge_s = badge_style(self.badge_text_style, Some(theme));
323            let m = frus_text::measure_resolved(&text, &badge_s);
324            let right = (pill.x + pill.width) - BADGE_TRAIL;
325            scene.text(
326                Point::new(right - m.width, inner.y + (inner.height - m.height) * 0.5),
327                text,
328                &badge_s,
329                ink.fade(o),
330            );
331        }
332    }
333
334    /// A destination that cannot be reached emits nothing.
335    fn on_click(&self) -> Option<Msg> {
336        (!self.disabled).then(|| self.message.clone())
337    }
338
339    /// And the keyboard skips it.
340    fn focusable(&self) -> bool {
341        !self.disabled
342    }
343
344    /// **What a reader hears**: the destination's name, then where it sits in the list.
345    ///
346    /// The reference reads out "Home, Tab 1 of 3" (`navigation_drawer.dart:464`), and the
347    /// second half is the part a list cannot supply on its own — someone hearing five
348    /// names in a row has no way to know how many are left. The role is the tab's, as
349    /// [`crate::Tabs`] already uses, and *which one is live* survives being disabled: a
350    /// reader who cannot switch is still owed where they are.
351    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
352        let words = crate::localizations::of();
353        let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Tab)
354            .label(format!(
355                "{}, {}",
356                self.label,
357                words.tab_label(self.index + 1, self.count)
358            ))
359            .toggled(self.selected);
360        Some(if self.disabled {
361            semantics.disabled(true)
362        } else {
363            semantics.clickable()
364        })
365    }
366}
367
368/// A **panel of destinations**: the navigation's widest form.
369pub struct NavigationDrawer<Msg = crate::callback::Callback> {
370    selected: usize,
371    on_select: Box<dyn Fn(usize) -> Msg>,
372    /// Destinations and whatever the caller put between them, in order.
373    entries: RefCell<Vec<Entry<Msg>>>,
374    header: RefCell<Option<Box<dyn Widget<Msg>>>>,
375    footer: RefCell<Option<Box<dyn Widget<Msg>>>>,
376    width: Option<f32>,
377    background: Option<Color>,
378    elevation: Option<f32>,
379    shadow_color: Option<Color>,
380    indicator_color: Option<Color>,
381    indicator_shape: Option<ShapeBorder>,
382    indicator_size: Option<(f32, f32)>,
383    tile_height: Option<f32>,
384    tile_padding: Option<Insets>,
385    icon_size: Option<f32>,
386    label_text_style: Option<TextStyle>,
387    badge_text_style: Option<TextStyle>,
388    built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
389}
390
391impl<Msg: Clone + 'static> NavigationDrawer<Msg> {
392    /// Creates a drawer: `selected` is the live destination and `on_select(i)` is emitted
393    /// when one is chosen.
394    ///
395    /// An index outside the list leaves every destination unselected, which is what the
396    /// reference's `-1` is for (`navigation_drawer.dart:138`) and what a screen showing
397    /// none of them wants.
398    pub fn new(selected: usize, on_select: impl Fn(usize) -> Msg + 'static) -> Self {
399        Self {
400            selected,
401            on_select: Box::new(on_select),
402            entries: RefCell::new(Vec::new()),
403            header: RefCell::new(None),
404            footer: RefCell::new(None),
405            width: None,
406            background: None,
407            elevation: None,
408            shadow_color: None,
409            indicator_color: None,
410            indicator_shape: None,
411            indicator_size: None,
412            tile_height: None,
413            tile_padding: None,
414            icon_size: None,
415            label_text_style: None,
416            badge_text_style: None,
417            built: OnceCell::new(),
418        }
419    }
420
421    /// Adds a destination: a glyph and a name.
422    pub fn item(self, icon: impl Into<DestinationIcon>, label: impl Into<String>) -> Self {
423        self.destination(NavigationDestination::new(icon, label))
424    }
425
426    /// Adds a destination declared elsewhere — what [`crate::NavScaffold`] forwards, so
427    /// that one list feeds all three forms.
428    pub(crate) fn destination(mut self, destination: NavigationDestination) -> Self {
429        self.entries
430            .borrow_mut()
431            .push(Entry::Stop(Box::new(destination)));
432        self.rebuild();
433        self
434    }
435
436    /// Puts **anything else** in the list at this point — a heading, a
437    /// [`Divider`](crate::Divider), a spacer (`navigation_drawer.dart:120`).
438    ///
439    /// It takes no destination index, so the destinations either side of it keep the
440    /// numbers they would have had.
441    pub fn child(self, child: impl Widget<Msg> + 'static) -> Self {
442        self.child_boxed(Box::new(child))
443    }
444
445    /// [`Self::child`], for a widget already boxed.
446    pub fn child_boxed(mut self, child: Box<dyn Widget<Msg>>) -> Self {
447        self.entries.borrow_mut().push(Entry::Other(child));
448        self.rebuild();
449        self
450    }
451
452    /// Adds a whole list of destinations **declared elsewhere** — one navigation, handed
453    /// to whichever chrome the width called for.
454    ///
455    /// This is the reason [`NavigationDestination`] is a value. An application that shows
456    /// a bar when narrow and a rail when wide used to declare its destinations twice, and
457    /// the two drifted; now it declares them once.
458    ///
459    /// ```
460    /// use frus_widgets::{Icons, NavigationDestination, NavigationDrawer};
461    ///
462    /// let places = vec![
463    ///     NavigationDestination::new(Icons::FAVORITE_BORDER, "Saved")
464    ///         .selected_icon(Icons::FAVORITE),
465    ///     NavigationDestination::new(Icons::MAIL_OUTLINE, "Inbox").badge(3),
466    /// ];
467    /// let _drawer = NavigationDrawer::new(0, |i| i).destinations(places);
468    /// ```
469    #[must_use]
470    pub fn destinations(
471        mut self,
472        destinations: impl IntoIterator<Item = NavigationDestination>,
473    ) -> Self {
474        self.entries
475            .borrow_mut()
476            .extend(destinations.into_iter().map(|d| Entry::Stop(Box::new(d))));
477        self.rebuild();
478        self
479    }
480
481    /// A notification count on the **last** destination.
482    pub fn badge(self, count: u32) -> Self {
483        self.decorate(|last| last.badge = Some(count))
484    }
485    /// What a pointer resting on the **last** destination is told
486    /// (`navigation_rail.dart:1155`).
487    ///
488    /// The case this exists for is a rail: it shows glyphs without labels by default, so
489    /// the mark is all a destination says about itself, and a mark is not always enough.
490    #[must_use]
491    pub fn tooltip(self, message: impl Into<String>) -> Self {
492        let message = message.into();
493        self.decorate(move |last| last.tooltip = Some(message))
494    }
495
496    /// The glyph the **last** destination shows while it is selected
497    /// (`navigation_drawer.dart:248`).
498    pub fn selected_icon(self, icon: impl Into<DestinationIcon>) -> Self {
499        let icon = icon.into();
500        self.decorate(move |last| last.selected_icon = Some(icon))
501    }
502
503    /// Marks the **last** destination unreachable (`navigation_drawer.dart:260`).
504    pub fn disabled(self) -> Self {
505        self.decorate(|last| last.disabled = true)
506    }
507
508    /// The area behind the **last** destination, edge to edge — not its indicator
509    /// (`navigation_drawer.dart:228`).
510    pub fn tile_color(self, color: Color) -> Self {
511        self.decorate(move |last| last.background = Some(color))
512    }
513
514    /// The **last** destination's own indicator colour, over the drawer's.
515    pub fn destination_indicator_color(self, color: Color) -> Self {
516        self.decorate(move |last| last.indicator_color = Some(color))
517    }
518
519    /// The **last** destination's own indicator shape, over the drawer's.
520    pub fn destination_indicator_shape(self, shape: ShapeBorder) -> Self {
521        self.decorate(move |last| last.indicator_shape = Some(shape))
522    }
523
524    /// The **last** destination's own highlight per state, over the framework's state
525    /// layer.
526    pub fn destination_overlay_color(self, overlay: WidgetStateProperty<Color>) -> Self {
527        self.decorate(move |last| last.overlay_color = Some(overlay))
528    }
529
530    /// Applies `f` to the destination most recently added; silent when there is none.
531    fn decorate(mut self, f: impl FnOnce(&mut NavigationDestination)) -> Self {
532        {
533            let mut entries = self.entries.borrow_mut();
534            if let Some(Entry::Stop(last)) = entries
535                .iter_mut()
536                .rev()
537                .find(|e| matches!(e, Entry::Stop(_)))
538            {
539                f(last);
540            }
541        }
542        self.rebuild();
543        self
544    }
545
546    /// A widget above the destinations, outside the part that scrolls
547    /// (`navigation_drawer.dart:127`) — a title, an account row, a logo.
548    pub fn header(self, header: impl Widget<Msg> + 'static) -> Self {
549        self.header_boxed(Box::new(header))
550    }
551
552    /// [`Self::header`], for a widget already boxed.
553    pub fn header_boxed(mut self, header: Box<dyn Widget<Msg>>) -> Self {
554        *self.header.borrow_mut() = Some(header);
555        self.rebuild();
556        self
557    }
558
559    /// A widget below the destinations, outside the part that scrolls
560    /// (`navigation_drawer.dart:132`) — settings, a sign-out, a version string.
561    pub fn footer(self, footer: impl Widget<Msg> + 'static) -> Self {
562        self.footer_boxed(Box::new(footer))
563    }
564
565    /// [`Self::footer`], for a widget already boxed.
566    pub fn footer_boxed(mut self, footer: Box<dyn Widget<Msg>>) -> Self {
567        *self.footer.borrow_mut() = Some(footer);
568        self.rebuild();
569        self
570    }
571
572    /// How wide the panel asks to be. Unset, it takes the width it is offered — which is
573    /// what a [`Drawer`](crate::Drawer) gives it, so a panel's width stays the panel's
574    /// business and this is for one standing on its own.
575    pub fn width(mut self, width: f32) -> Self {
576        self.width = Some(width);
577        self
578    }
579
580    /// The panel's surface. Unset, the theme's, then the scheme's `surface_container_low`
581    /// (`navigation_drawer.dart:740`).
582    pub fn background_color(mut self, color: Color) -> Self {
583        self.background = Some(color);
584        self.rebuild();
585        self
586    }
587
588    /// How far off the page the panel sits. Unset, the reference's `1` — which on its own
589    /// casts nothing: see [`shadow_color`](Self::shadow_color).
590    pub fn elevation(mut self, elevation: f32) -> Self {
591        self.elevation = Some(elevation);
592        self
593    }
594
595    /// The shadow's colour. **Transparent by default**, as the reference's Material 3
596    /// drawer is (its defaults, line 746): the panel's tone carries its height, and a
597    /// shadow appears only where a colour is named — alpha included.
598    pub fn shadow_color(mut self, color: Color) -> Self {
599        self.shadow_color = Some(color);
600        self
601    }
602
603    /// The pill behind the selected destination. Unset, the scheme's
604    /// `secondary_container`.
605    pub fn indicator_color(mut self, color: Color) -> Self {
606        self.indicator_color = Some(color);
607        self.rebuild();
608        self
609    }
610
611    /// That pill's shape. Unset, a stadium (`navigation_drawer.dart:731`).
612    pub fn indicator_shape(mut self, shape: ShapeBorder) -> Self {
613        self.indicator_shape = Some(shape);
614        self.rebuild();
615        self
616    }
617
618    /// That pill's box, `(width, height)`. The width is a **ceiling**: a pill never grows
619    /// past the room its tile has. Unset, the reference's `336 x 56`.
620    pub fn indicator_size(mut self, width: f32, height: f32) -> Self {
621        self.indicator_size = Some((width, height));
622        self.rebuild();
623        self
624    }
625
626    /// A tile's height. Unset, `56`.
627    pub fn tile_height(mut self, height: f32) -> Self {
628        self.tile_height = Some(height);
629        self.rebuild();
630        self
631    }
632
633    /// The room either side of a tile. Unset, `12` left and right
634    /// (`navigation_drawer.dart:69`).
635    pub fn tile_padding(mut self, padding: Insets) -> Self {
636        self.tile_padding = Some(padding);
637        self.rebuild();
638        self
639    }
640
641    /// The destinations' glyphs. Unset, `24`.
642    pub fn icon_size(mut self, size: f32) -> Self {
643        self.icon_size = Some(size);
644        self.rebuild();
645        self
646    }
647
648    /// The destinations' labels. Unset, `labelLarge`.
649    pub fn label_text_style(mut self, style: TextStyle) -> Self {
650        self.label_text_style = Some(style);
651        self.rebuild();
652        self
653    }
654
655    /// The count a destination carries. Unset, `labelSmall`.
656    pub fn badge_text_style(mut self, style: TextStyle) -> Self {
657        self.badge_text_style = Some(style);
658        self.rebuild();
659        self
660    }
661
662    /// Throws the assembled subtree away, so that the next read of it sees everything the
663    /// builders have said and their order cannot change the answer.
664    fn rebuild(&mut self) {
665        self.built.take();
666    }
667
668    /// The subtree: the header, the destinations in a list that scrolls, the footer.
669    ///
670    /// The reference's `ListView` is why the middle scrolls and the two ends do not
671    /// (`navigation_drawer.dart:194`): a drawer with twelve destinations on a short window
672    /// must not push its footer off the bottom, and a header that scrolled away would take
673    /// the account it names with it.
674    fn assemble(&self) -> Vec<Box<dyn Widget<Msg>>> {
675        let entries = std::mem::take(&mut *self.entries.borrow_mut());
676        let count = entries
677            .iter()
678            .filter(|e| matches!(e, Entry::Stop(_)))
679            .count();
680
681        let mut list = Flex::<Msg>::column();
682        let mut index = 0;
683        for entry in entries {
684            match entry {
685                Entry::Other(child) => list = list.child_boxed(child),
686                Entry::Stop(stop) => {
687                    let selected = index == self.selected;
688                    let tooltip = stop.tooltip.clone();
689                    let tile = DrawerTile {
690                        icon: stop.glyph(selected).clone(),
691                        label: stop.label.clone(),
692                        badge: stop.badge,
693                        selected,
694                        disabled: stop.disabled,
695                        index,
696                        count,
697                        indicator_color: stop.indicator_color.or(self.indicator_color),
698                        indicator_shape: stop.indicator_shape.or(self.indicator_shape),
699                        overlay_color: stop.overlay_color.clone(),
700                        ground: self.background,
701                        background: stop.background,
702                        tile_height: self.tile_height,
703                        tile_padding: self.tile_padding,
704                        indicator_size: self.indicator_size,
705                        icon_size: self.icon_size,
706                        label_text_style: self.label_text_style,
707                        badge_text_style: self.badge_text_style,
708                        message: (self.on_select)(index),
709                    };
710                    // A row that was given a hint is wrapped in the widget that shows
711                    // hints, rather than growing a second one inside the tile.
712                    list = match tooltip {
713                        Some(message) => list.child(crate::Tooltip::new(message).child(tile)),
714                        None => list.child(tile),
715                    };
716                    index += 1;
717                }
718            }
719        }
720
721        let mut out: Vec<Box<dyn Widget<Msg>>> = Vec::new();
722        if let Some(header) = self.header.borrow_mut().take() {
723            out.push(header);
724        }
725        out.push(Box::new(
726            SingleChildScrollView::<Msg>::new().flex(1.0).child(list),
727        ));
728        if let Some(footer) = self.footer.borrow_mut().take() {
729            out.push(footer);
730        }
731        out
732    }
733}
734
735impl<Msg: Clone + 'static> Widget<Msg> for NavigationDrawer<Msg> {
736    /// A column, clear of the intrusion at the top.
737    ///
738    /// The reference wraps its content in a `SafeArea` with `bottom: false`
739    /// (`navigation_drawer.dart:189`): the notch is kept off the header, and the gesture
740    /// bar is left to whatever the panel stands in — the shell that already consumed it.
741    fn style(&self) -> Style {
742        let safe = crate::MediaQuery::of().padding;
743        Style {
744            width: self.width.map_or(Dimension::Auto, Dimension::Length),
745            flex_direction: FlexDirection::Column,
746            padding: Insets::new(safe.top, 0.0, 0.0, 0.0),
747            ..Default::default()
748        }
749    }
750
751    /// It fills the panel it was put in, on both axes unless it was given a width.
752    fn fill_axes(&self, _theme: &Theme) -> FillAxes {
753        if self.width.is_some() {
754            FillAxes::HEIGHT
755        } else {
756            FillAxes::BOTH
757        }
758    }
759
760    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
761        self.built.get_or_init(|| self.assemble())
762    }
763
764    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
765        let o = status.opacity;
766        let t = &theme.widgets.nav_drawer;
767        let depth = self.elevation.or(t.elevation).unwrap_or(ELEVATION);
768        // Drawn the way `Dialog` draws one, and for the same reason only where a colour is
769        // named: the reference keeps the elevation and makes the shadow transparent
770        // (its navigation drawer defaults, lines 729 and 746).
771        let shadow = self
772            .shadow_color
773            .or(t.shadow_color)
774            .unwrap_or(Color::TRANSPARENT);
775        if depth > 0.0 && shadow.a > 0.0 {
776            // The reference's shadows for this height (milestone 606).
777            frus_core::paint_elevation(
778                scene,
779                bounds,
780                frus_core::BorderRadius::ZERO,
781                depth,
782                shadow.fade(o),
783            );
784        }
785        let fill = self
786            .background
787            .or(t.background_color)
788            .unwrap_or(theme.scheme.surface_container_low);
789        scene.fill_rect(bounds, fill.fade(o));
790    }
791
792    fn on_click(&self) -> Option<Msg> {
793        None
794    }
795}
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800    use crate::divider::Divider;
801    use crate::text::Text;
802
803    #[derive(Clone, Debug, PartialEq)]
804    enum Msg {
805        Go(usize),
806    }
807
808    /// Three destinations, the middle one selected, with a rule between the second and
809    /// the third.
810    fn drawer() -> NavigationDrawer<Msg> {
811        NavigationDrawer::new(1, Msg::Go)
812            .item("I", "Inbox")
813            .item("S", "Starred")
814            .child(Divider::new())
815            .item("T", "Trash")
816    }
817
818    /// Every tile in a built drawer, in order — found the way the walk finds anything,
819    /// by descending. There is no downcast on the trait, and a test that reached for one
820    /// would be checking a shape the framework does not have.
821    fn tiles(drawer: &NavigationDrawer<Msg>) -> Vec<&dyn Widget<Msg>> {
822        fn walk<'a>(node: &'a dyn Widget<Msg>, out: &mut Vec<&'a dyn Widget<Msg>>) {
823            if node.debug_name() == "DrawerTile" {
824                out.push(node);
825            }
826            for child in node.children() {
827                walk(&**child, out);
828            }
829        }
830        let mut out = Vec::new();
831        for child in Widget::<Msg>::children(drawer) {
832            walk(&**child, &mut out);
833        }
834        out
835    }
836
837    fn scene_of(widget: &dyn Widget<Msg>, bounds: Rect, theme: &Theme) -> Scene {
838        let mut scene = Scene::new();
839        widget.paint(
840            bounds,
841            Status {
842                opacity: 1.0,
843                ..Default::default()
844            },
845            theme,
846            &mut scene,
847        );
848        scene
849    }
850
851    fn texts(scene: &Scene) -> Vec<(String, Color)> {
852        scene
853            .primitives()
854            .iter()
855            .filter_map(|p| match p {
856                frus_core::Primitive::Text { text, color, .. } => Some((text.clone(), *color)),
857                _ => None,
858            })
859            .collect()
860    }
861
862    /// **A destination's index counts destinations.**
863    ///
864    /// The rule the reference spends a second walk of its child list on
865    /// (`navigation_drawer.dart:180`), and the one that breaks silently: put a rule
866    /// between two groups of destinations and every destination below it answers with the
867    /// wrong screen. Nothing about the picture would say so.
868    #[test]
869    fn a_rule_between_destinations_takes_no_number() {
870        let drawer = drawer();
871        let tiles = tiles(&drawer);
872        assert_eq!(
873            tiles.len(),
874            3,
875            "three destinations, and the rule is not one"
876        );
877        assert_eq!(
878            tiles
879                .iter()
880                .filter_map(|t| t.on_click())
881                .collect::<Vec<_>>(),
882            vec![Msg::Go(0), Msg::Go(1), Msg::Go(2)],
883            "the third destination is 2, not 3"
884        );
885        assert_eq!(
886            tiles
887                .iter()
888                .map(|t| t.semantics().expect("announced").toggled)
889                .collect::<Vec<_>>(),
890            vec![
891                frus_core::Toggled::False,
892                frus_core::Toggled::True,
893                frus_core::Toggled::False
894            ],
895            "and the middle one is the live one"
896        );
897    }
898
899    /// And what a reader hears counts them the same way.
900    #[test]
901    fn a_reader_is_told_which_of_how_many() {
902        let drawer = drawer();
903        let heard: Vec<String> = tiles(&drawer)
904            .iter()
905            .filter_map(|t| t.semantics()?.label)
906            .collect();
907        assert_eq!(
908            heard,
909            vec![
910                "Inbox, Tab 1 of 3".to_string(),
911                "Starred, Tab 2 of 3".to_string(),
912                "Trash, Tab 3 of 3".to_string(),
913            ],
914            "the rule between them is not a destination, so it is not counted either"
915        );
916    }
917
918    /// One tile on its own, to be painted at a chosen width.
919    fn lone_tile(selected: bool) -> DrawerTile<Msg> {
920        DrawerTile {
921            icon: "I".into(),
922            label: "Inbox".into(),
923            badge: None,
924            selected,
925            disabled: false,
926            index: 0,
927            count: 1,
928            indicator_color: None,
929            indicator_shape: None,
930            overlay_color: None,
931            ground: None,
932            background: None,
933            tile_height: None,
934            tile_padding: None,
935            indicator_size: None,
936            icon_size: None,
937            label_text_style: None,
938            badge_text_style: None,
939            message: Msg::Go(0),
940        }
941    }
942
943    /// **The indicator's width is a ceiling.** The reference asks for 336 inside a panel
944    /// 304 wide, and what is drawn is the narrower of the two — otherwise the pill hangs
945    /// out past the panel's edge in every application that never set a width, which is
946    /// all of them.
947    #[test]
948    fn the_pill_never_grows_past_the_room_the_tile_has() {
949        let theme = Theme::default();
950        let tile = lone_tile(true);
951        let pill_width = |width: f32| {
952            let scene = scene_of(&tile, Rect::new(0.0, 0.0, width, TILE_HEIGHT), &theme);
953            scene
954                .primitives()
955                .iter()
956                .find_map(|p| match p {
957                    frus_core::Primitive::Rect { rect, .. } => Some(rect.width),
958                    _ => None,
959                })
960                .expect("a selected destination has an indicator")
961        };
962
963        assert_eq!(
964            pill_width(304.0),
965            304.0 - 24.0,
966            "in a 304-wide panel the pill takes what is left after the padding"
967        );
968        assert_eq!(
969            pill_width(600.0),
970            INDICATOR_WIDTH,
971            "and stops at its declared width once there is room for it"
972        );
973    }
974
975    /// **Both halves take the indicator's colour**, which is the one thing a drawer's row
976    /// does differently from a rail's: there the label sits below the pill on the rail's
977    /// own surface, here the pill is the row.
978    #[test]
979    fn a_selected_row_paints_its_glyph_and_its_label_the_same() {
980        let theme = Theme::default();
981        let scene = scene_of(
982            &lone_tile(true),
983            Rect::new(0.0, 0.0, 360.0, TILE_HEIGHT),
984            &theme,
985        );
986        let painted = texts(&scene);
987        assert_eq!(painted.len(), 2, "a glyph and a label");
988        assert_eq!(
989            painted[0].1, painted[1].1,
990            "the glyph and the label stand on the same indicator"
991        );
992        assert_eq!(painted[0].1, theme.scheme.on_secondary_container);
993
994        let scene = scene_of(
995            &lone_tile(false),
996            Rect::new(0.0, 0.0, 360.0, TILE_HEIGHT),
997            &theme,
998        );
999        assert_eq!(texts(&scene)[0].1, theme.scheme.on_surface_variant);
1000    }
1001
1002    /// A destination that cannot be reached is inert to the tap and to the tab, and says
1003    /// so rather than falling silent.
1004    #[test]
1005    fn a_destination_that_cannot_be_reached_answers_nowhere() {
1006        let drawer = NavigationDrawer::new(0, Msg::Go)
1007            .item("I", "Inbox")
1008            .item("A", "Archive")
1009            .disabled();
1010        let tiles = tiles(&drawer);
1011        let off = *tiles.last().expect("two of them");
1012        assert_eq!(off.on_click(), None, "the tap goes nowhere");
1013        assert!(!off.focusable(), "and the tab skips it");
1014        let semantics = off.semantics().expect("it is still announced");
1015        assert!(semantics.disabled, "and it is announced as unavailable");
1016        assert!(!semantics.clickable);
1017        assert_eq!(
1018            tiles[0].on_click(),
1019            Some(Msg::Go(0)),
1020            "its neighbour is untouched"
1021        );
1022    }
1023
1024    /// Nothing lights on a destination that cannot be reached: a state layer is the
1025    /// promise of an interaction and there is none here.
1026    #[test]
1027    fn a_destination_that_cannot_be_reached_does_not_light_up() {
1028        let theme = Theme::default();
1029        let mut tile = lone_tile(false);
1030        tile.disabled = true;
1031        let mut scene = Scene::new();
1032        Widget::<Msg>::paint(
1033            &tile,
1034            Rect::new(0.0, 0.0, 360.0, TILE_HEIGHT),
1035            Status {
1036                opacity: 1.0,
1037                hover_progress: 1.0,
1038                ..Default::default()
1039            },
1040            &theme,
1041            &mut scene,
1042        );
1043        assert!(
1044            !scene
1045                .primitives()
1046                .iter()
1047                .any(|p| matches!(p, frus_core::Primitive::Rect { .. })),
1048            "an unreachable destination has nothing to say to a pointer resting on it"
1049        );
1050    }
1051
1052    /// The header and the footer are outside the part that scrolls, so a long list cannot
1053    /// push either of them off the panel.
1054    #[test]
1055    fn the_two_ends_do_not_scroll() {
1056        let drawer = drawer()
1057            .header(Text::new("Mailbox"))
1058            .footer(Text::new("v1"));
1059        let children = Widget::<Msg>::children(&drawer);
1060        assert_eq!(children.len(), 3, "header, list, footer");
1061        assert_eq!(
1062            children[1].debug_name(),
1063            "SingleChildScrollView",
1064            "and only the middle one scrolls"
1065        );
1066    }
1067
1068    /// **The builders are order-independent**, which is what throwing the assembled
1069    /// subtree away on every builder buys: a colour named after the destinations still
1070    /// reaches them.
1071    #[test]
1072    fn saying_it_afterwards_says_it() {
1073        let theme = Theme::default();
1074        let ink = Color::rgb8(200, 30, 30);
1075        let before = NavigationDrawer::new(0, Msg::Go)
1076            .indicator_color(ink)
1077            .item("I", "Inbox");
1078        let after = NavigationDrawer::new(0, Msg::Go)
1079            .item("I", "Inbox")
1080            .indicator_color(ink);
1081        let fill = |drawer: &NavigationDrawer<Msg>| {
1082            let found = tiles(drawer);
1083            let scene = scene_of(found[0], Rect::new(0.0, 0.0, 360.0, TILE_HEIGHT), &theme);
1084            scene
1085                .primitives()
1086                .iter()
1087                .find_map(|p| match p {
1088                    frus_core::Primitive::Rect { color, .. } => Some(*color),
1089                    _ => None,
1090                })
1091                .expect("the selected destination has an indicator")
1092        };
1093        assert_eq!(fill(&before), ink);
1094        assert_eq!(fill(&after), ink, "the order the builders were called in");
1095    }
1096
1097    /// A tile keeps its declared height until the reader's own type outgrows it, at which
1098    /// point the row grows rather than clipping the label.
1099    #[test]
1100    fn a_row_grows_before_it_clips() {
1101        let theme = Theme::default();
1102        let plain_drawer = drawer();
1103        let plain = tiles(&plain_drawer)[0].style_themed(&theme).height;
1104        assert_eq!(plain, Dimension::Length(TILE_HEIGHT));
1105
1106        let big = drawer().label_text_style(TextStyle::new(48.0));
1107        let grown = tiles(&big)[0].style_themed(&theme).height;
1108        assert!(
1109            matches!(grown, Dimension::Length(h) if h > TILE_HEIGHT),
1110            "the row makes room for the type rather than cutting it: {grown:?}"
1111        );
1112    }
1113
1114    /// The panel's surface is a rung above a rail's: a drawer is a sheet over the page,
1115    /// not a strip beside it.
1116    #[test]
1117    fn the_panel_stands_a_rung_above_a_rail() {
1118        let theme = Theme::default();
1119        let scene = scene_of(&drawer(), Rect::new(0.0, 0.0, 304.0, 600.0), &theme);
1120        let surface = scene
1121            .primitives()
1122            .iter()
1123            .rev()
1124            .find_map(|p| match p {
1125                frus_core::Primitive::Rect { color, blur, .. } if *blur == 0.0 => Some(*color),
1126                _ => None,
1127            })
1128            .expect("the panel paints one");
1129        assert_eq!(surface, theme.scheme.surface_container_low);
1130        assert_ne!(
1131            theme.scheme.surface_container_low, theme.scheme.surface,
1132            "the two rungs have to be tellable apart for that to mean anything"
1133        );
1134    }
1135
1136    /// **A drawer's height is its tone, not a shadow.** The reference's navigation drawer
1137    /// defaults keep an elevation of 1 and make the shadow transparent (lines 729, 746);
1138    /// this painted a black one at 30 % under every drawer. A colour named — on the drawer
1139    /// or on the theme — brings one back, in that colour.
1140    #[test]
1141    fn a_drawer_casts_no_shadow_until_a_colour_is_named() {
1142        let bounds = Rect::new(0.0, 0.0, 304.0, 600.0);
1143        let shadows = |drawer: &NavigationDrawer<Msg>, theme: &Theme| {
1144            crate::shadowprobe::colour(scene_of(drawer, bounds, theme).primitives())
1145        };
1146        let theme = Theme::default();
1147        assert!(shadows(&drawer(), &theme).is_none(), "none by default");
1148
1149        let named = Color::rgba(0.0, 0.0, 0.0, 0.25);
1150        assert_eq!(shadows(&drawer().shadow_color(named), &theme), Some(named));
1151        let mut themed = Theme::default();
1152        themed.widgets.nav_drawer.shadow_color = Some(named);
1153        assert_eq!(shadows(&drawer(), &themed), Some(named), "or on the theme");
1154        assert!(
1155            shadows(&drawer().shadow_color(named).elevation(0.0), &theme).is_none(),
1156            "and a flat drawer has no height to cast it from"
1157        );
1158    }
1159}