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<crate::navrail::DestinationBadge>,
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        // A dot is a dot, drawn as the [`Badge`](crate::Badge) draws one, centred on the row
317        // where the count would end (milestone 707).
318        if self.badge == Some(crate::navrail::DestinationBadge::Dot) {
319            let size = theme.widgets.badge.small_size.unwrap_or(6.0);
320            let right = (pill.x + pill.width) - BADGE_TRAIL;
321            let at = Rect::new(
322                right - size,
323                inner.y + (inner.height - size) * 0.5,
324                size,
325                size,
326            );
327            let fill = theme
328                .widgets
329                .badge
330                .background_color
331                .unwrap_or(theme.scheme.error);
332            scene.draw_rect(at, fill.fade(o), size * 0.5, 0.0, Color::TRANSPARENT);
333        }
334        let count = match self.badge {
335            Some(crate::navrail::DestinationBadge::Count(count)) if count > 0 => Some(count),
336            _ => None,
337        };
338        if let Some(count) = count {
339            // Written as a [`Badge`](crate::Badge) writes a count: `999+` past 999.
340            let text = crate::badge::count_label(count, 999);
341            let badge_s = badge_style(self.badge_text_style, Some(theme));
342            let m = frus_text::measure_resolved(&text, &badge_s);
343            let right = (pill.x + pill.width) - BADGE_TRAIL;
344            scene.text(
345                Point::new(right - m.width, inner.y + (inner.height - m.height) * 0.5),
346                text,
347                &badge_s,
348                ink.fade(o),
349            );
350        }
351    }
352
353    /// A destination that cannot be reached emits nothing.
354    fn on_click(&self) -> Option<Msg> {
355        (!self.disabled).then(|| self.message.clone())
356    }
357
358    /// And the keyboard skips it.
359    fn focusable(&self) -> bool {
360        !self.disabled
361    }
362
363    /// **What a reader hears**: the destination's name, then where it sits in the list.
364    ///
365    /// The reference reads out "Home, Tab 1 of 3" (`navigation_drawer.dart:464`), and the
366    /// second half is the part a list cannot supply on its own — someone hearing five
367    /// names in a row has no way to know how many are left. The role is the tab's, as
368    /// [`crate::Tabs`] already uses, and *which one is live* survives being disabled: a
369    /// reader who cannot switch is still owed where they are.
370    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
371        let words = crate::localizations::of();
372        let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Tab)
373            .label(format!(
374                "{}, {}",
375                self.label,
376                words.tab_label(self.index + 1, self.count)
377            ))
378            .toggled(self.selected);
379        Some(if self.disabled {
380            semantics.disabled(true)
381        } else {
382            semantics.clickable()
383        })
384    }
385}
386
387/// A **panel of destinations**: the navigation's widest form.
388pub struct NavigationDrawer<Msg = crate::callback::Callback> {
389    selected: usize,
390    on_select: Box<dyn Fn(usize) -> Msg>,
391    /// Destinations and whatever the caller put between them, in order.
392    entries: RefCell<Vec<Entry<Msg>>>,
393    header: RefCell<Option<Box<dyn Widget<Msg>>>>,
394    footer: RefCell<Option<Box<dyn Widget<Msg>>>>,
395    width: Option<f32>,
396    background: Option<Color>,
397    elevation: Option<f32>,
398    shadow_color: Option<Color>,
399    indicator_color: Option<Color>,
400    indicator_shape: Option<ShapeBorder>,
401    indicator_size: Option<(f32, f32)>,
402    tile_height: Option<f32>,
403    tile_padding: Option<Insets>,
404    icon_size: Option<f32>,
405    label_text_style: Option<TextStyle>,
406    badge_text_style: Option<TextStyle>,
407    built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
408}
409
410impl<Msg: Clone + 'static> NavigationDrawer<Msg> {
411    /// Creates a drawer: `selected` is the live destination and `on_select(i)` is emitted
412    /// when one is chosen.
413    ///
414    /// An index outside the list leaves every destination unselected, which is what the
415    /// reference's `-1` is for (`navigation_drawer.dart:138`) and what a screen showing
416    /// none of them wants.
417    pub fn new(selected: usize, on_select: impl Fn(usize) -> Msg + 'static) -> Self {
418        Self {
419            selected,
420            on_select: Box::new(on_select),
421            entries: RefCell::new(Vec::new()),
422            header: RefCell::new(None),
423            footer: RefCell::new(None),
424            width: None,
425            background: None,
426            elevation: None,
427            shadow_color: None,
428            indicator_color: None,
429            indicator_shape: None,
430            indicator_size: None,
431            tile_height: None,
432            tile_padding: None,
433            icon_size: None,
434            label_text_style: None,
435            badge_text_style: None,
436            built: OnceCell::new(),
437        }
438    }
439
440    /// Adds a destination: a glyph and a name.
441    pub fn item(self, icon: impl Into<DestinationIcon>, label: impl Into<String>) -> Self {
442        self.destination(NavigationDestination::new(icon, label))
443    }
444
445    /// Adds a destination declared elsewhere — what [`crate::NavScaffold`] forwards, so
446    /// that one list feeds all three forms.
447    pub(crate) fn destination(mut self, destination: NavigationDestination) -> Self {
448        self.entries
449            .borrow_mut()
450            .push(Entry::Stop(Box::new(destination)));
451        self.rebuild();
452        self
453    }
454
455    /// Puts **anything else** in the list at this point — a heading, a
456    /// [`Divider`](crate::Divider), a spacer (`navigation_drawer.dart:120`).
457    ///
458    /// It takes no destination index, so the destinations either side of it keep the
459    /// numbers they would have had.
460    pub fn child(self, child: impl Widget<Msg> + 'static) -> Self {
461        self.child_boxed(Box::new(child))
462    }
463
464    /// [`Self::child`], for a widget already boxed.
465    pub fn child_boxed(mut self, child: Box<dyn Widget<Msg>>) -> Self {
466        self.entries.borrow_mut().push(Entry::Other(child));
467        self.rebuild();
468        self
469    }
470
471    /// Adds a whole list of destinations **declared elsewhere** — one navigation, handed
472    /// to whichever chrome the width called for.
473    ///
474    /// This is the reason [`NavigationDestination`] is a value. An application that shows
475    /// a bar when narrow and a rail when wide used to declare its destinations twice, and
476    /// the two drifted; now it declares them once.
477    ///
478    /// ```
479    /// use frus_widgets::{Icons, NavigationDestination, NavigationDrawer};
480    ///
481    /// let places = vec![
482    ///     NavigationDestination::new(Icons::FAVORITE_BORDER, "Saved")
483    ///         .selected_icon(Icons::FAVORITE),
484    ///     NavigationDestination::new(Icons::MAIL_OUTLINE, "Inbox").badge(3),
485    /// ];
486    /// let _drawer = NavigationDrawer::new(0, |i| i).destinations(places);
487    /// ```
488    #[must_use]
489    pub fn destinations(
490        mut self,
491        destinations: impl IntoIterator<Item = NavigationDestination>,
492    ) -> Self {
493        self.entries
494            .borrow_mut()
495            .extend(destinations.into_iter().map(|d| Entry::Stop(Box::new(d))));
496        self.rebuild();
497        self
498    }
499
500    /// A notification count on the **last** destination.
501    pub fn badge(self, count: u32) -> Self {
502        self.decorate(|last| last.badge = Some(crate::navrail::DestinationBadge::Count(count)))
503    }
504
505    /// Adds a dot to the **last** destination (milestone 707).
506    pub fn badge_dot(self) -> Self {
507        self.decorate(|last| last.badge = Some(crate::navrail::DestinationBadge::Dot))
508    }
509    /// What a pointer resting on the **last** destination is told
510    /// (`navigation_rail.dart:1155`).
511    ///
512    /// The case this exists for is a rail: it shows glyphs without labels by default, so
513    /// the mark is all a destination says about itself, and a mark is not always enough.
514    #[must_use]
515    pub fn tooltip(self, message: impl Into<String>) -> Self {
516        let message = message.into();
517        self.decorate(move |last| last.tooltip = Some(message))
518    }
519
520    /// The glyph the **last** destination shows while it is selected
521    /// (`navigation_drawer.dart:248`).
522    pub fn selected_icon(self, icon: impl Into<DestinationIcon>) -> Self {
523        let icon = icon.into();
524        self.decorate(move |last| last.selected_icon = Some(icon))
525    }
526
527    /// Marks the **last** destination unreachable (`navigation_drawer.dart:260`).
528    pub fn disabled(self) -> Self {
529        self.decorate(|last| last.disabled = true)
530    }
531
532    /// The area behind the **last** destination, edge to edge — not its indicator
533    /// (`navigation_drawer.dart:228`).
534    pub fn tile_color(self, color: Color) -> Self {
535        self.decorate(move |last| last.background = Some(color))
536    }
537
538    /// The **last** destination's own indicator colour, over the drawer's.
539    pub fn destination_indicator_color(self, color: Color) -> Self {
540        self.decorate(move |last| last.indicator_color = Some(color))
541    }
542
543    /// The **last** destination's own indicator shape, over the drawer's.
544    pub fn destination_indicator_shape(self, shape: ShapeBorder) -> Self {
545        self.decorate(move |last| last.indicator_shape = Some(shape))
546    }
547
548    /// The **last** destination's own highlight per state, over the framework's state
549    /// layer.
550    pub fn destination_overlay_color(self, overlay: WidgetStateProperty<Color>) -> Self {
551        self.decorate(move |last| last.overlay_color = Some(overlay))
552    }
553
554    /// Applies `f` to the destination most recently added; silent when there is none.
555    fn decorate(mut self, f: impl FnOnce(&mut NavigationDestination)) -> Self {
556        {
557            let mut entries = self.entries.borrow_mut();
558            if let Some(Entry::Stop(last)) = entries
559                .iter_mut()
560                .rev()
561                .find(|e| matches!(e, Entry::Stop(_)))
562            {
563                f(last);
564            }
565        }
566        self.rebuild();
567        self
568    }
569
570    /// A widget above the destinations, outside the part that scrolls
571    /// (`navigation_drawer.dart:127`) — a title, an account row, a logo.
572    pub fn header(self, header: impl Widget<Msg> + 'static) -> Self {
573        self.header_boxed(Box::new(header))
574    }
575
576    /// [`Self::header`], for a widget already boxed.
577    pub fn header_boxed(mut self, header: Box<dyn Widget<Msg>>) -> Self {
578        *self.header.borrow_mut() = Some(header);
579        self.rebuild();
580        self
581    }
582
583    /// A widget below the destinations, outside the part that scrolls
584    /// (`navigation_drawer.dart:132`) — settings, a sign-out, a version string.
585    pub fn footer(self, footer: impl Widget<Msg> + 'static) -> Self {
586        self.footer_boxed(Box::new(footer))
587    }
588
589    /// [`Self::footer`], for a widget already boxed.
590    pub fn footer_boxed(mut self, footer: Box<dyn Widget<Msg>>) -> Self {
591        *self.footer.borrow_mut() = Some(footer);
592        self.rebuild();
593        self
594    }
595
596    /// How wide the panel asks to be. Unset, it takes the width it is offered — which is
597    /// what a [`Drawer`](crate::Drawer) gives it, so a panel's width stays the panel's
598    /// business and this is for one standing on its own.
599    pub fn width(mut self, width: f32) -> Self {
600        self.width = Some(width);
601        self
602    }
603
604    /// The panel's surface. Unset, the theme's, then the scheme's `surface_container_low`
605    /// (`navigation_drawer.dart:740`).
606    pub fn background_color(mut self, color: Color) -> Self {
607        self.background = Some(color);
608        self.rebuild();
609        self
610    }
611
612    /// How far off the page the panel sits. Unset, the reference's `1` — which on its own
613    /// casts nothing: see [`shadow_color`](Self::shadow_color).
614    pub fn elevation(mut self, elevation: f32) -> Self {
615        self.elevation = Some(elevation);
616        self
617    }
618
619    /// The shadow's colour. **Transparent by default**, as the reference's Material 3
620    /// drawer is (its defaults, line 746): the panel's tone carries its height, and a
621    /// shadow appears only where a colour is named — alpha included.
622    pub fn shadow_color(mut self, color: Color) -> Self {
623        self.shadow_color = Some(color);
624        self
625    }
626
627    /// The pill behind the selected destination. Unset, the scheme's
628    /// `secondary_container`.
629    pub fn indicator_color(mut self, color: Color) -> Self {
630        self.indicator_color = Some(color);
631        self.rebuild();
632        self
633    }
634
635    /// That pill's shape. Unset, a stadium (`navigation_drawer.dart:731`).
636    pub fn indicator_shape(mut self, shape: ShapeBorder) -> Self {
637        self.indicator_shape = Some(shape);
638        self.rebuild();
639        self
640    }
641
642    /// That pill's box, `(width, height)`. The width is a **ceiling**: a pill never grows
643    /// past the room its tile has. Unset, the reference's `336 x 56`.
644    pub fn indicator_size(mut self, width: f32, height: f32) -> Self {
645        self.indicator_size = Some((width, height));
646        self.rebuild();
647        self
648    }
649
650    /// A tile's height. Unset, `56`.
651    pub fn tile_height(mut self, height: f32) -> Self {
652        self.tile_height = Some(height);
653        self.rebuild();
654        self
655    }
656
657    /// The room either side of a tile. Unset, `12` left and right
658    /// (`navigation_drawer.dart:69`).
659    pub fn tile_padding(mut self, padding: Insets) -> Self {
660        self.tile_padding = Some(padding);
661        self.rebuild();
662        self
663    }
664
665    /// The destinations' glyphs. Unset, `24`.
666    pub fn icon_size(mut self, size: f32) -> Self {
667        self.icon_size = Some(size);
668        self.rebuild();
669        self
670    }
671
672    /// The destinations' labels. Unset, `labelLarge`.
673    pub fn label_text_style(mut self, style: TextStyle) -> Self {
674        self.label_text_style = Some(style);
675        self.rebuild();
676        self
677    }
678
679    /// The count a destination carries. Unset, `labelSmall`.
680    pub fn badge_text_style(mut self, style: TextStyle) -> Self {
681        self.badge_text_style = Some(style);
682        self.rebuild();
683        self
684    }
685
686    /// Throws the assembled subtree away, so that the next read of it sees everything the
687    /// builders have said and their order cannot change the answer.
688    fn rebuild(&mut self) {
689        self.built.take();
690    }
691
692    /// The subtree: the header, the destinations in a list that scrolls, the footer.
693    ///
694    /// The reference's `ListView` is why the middle scrolls and the two ends do not
695    /// (`navigation_drawer.dart:194`): a drawer with twelve destinations on a short window
696    /// must not push its footer off the bottom, and a header that scrolled away would take
697    /// the account it names with it.
698    fn assemble(&self) -> Vec<Box<dyn Widget<Msg>>> {
699        let entries = std::mem::take(&mut *self.entries.borrow_mut());
700        let count = entries
701            .iter()
702            .filter(|e| matches!(e, Entry::Stop(_)))
703            .count();
704
705        let mut list = Flex::<Msg>::column();
706        let mut index = 0;
707        for entry in entries {
708            match entry {
709                Entry::Other(child) => list = list.child_boxed(child),
710                Entry::Stop(stop) => {
711                    let selected = index == self.selected;
712                    let tooltip = stop.tooltip.clone();
713                    let tile = DrawerTile {
714                        icon: stop.glyph(selected).clone(),
715                        label: stop.label.clone(),
716                        badge: stop.badge,
717                        selected,
718                        disabled: stop.disabled,
719                        index,
720                        count,
721                        indicator_color: stop.indicator_color.or(self.indicator_color),
722                        indicator_shape: stop.indicator_shape.or(self.indicator_shape),
723                        overlay_color: stop.overlay_color.clone(),
724                        ground: self.background,
725                        background: stop.background,
726                        tile_height: self.tile_height,
727                        tile_padding: self.tile_padding,
728                        indicator_size: self.indicator_size,
729                        icon_size: self.icon_size,
730                        label_text_style: self.label_text_style,
731                        badge_text_style: self.badge_text_style,
732                        message: (self.on_select)(index),
733                    };
734                    // A row that was given a hint is wrapped in the widget that shows
735                    // hints, rather than growing a second one inside the tile.
736                    list = match tooltip {
737                        Some(message) => list.child(crate::Tooltip::new(message).child(tile)),
738                        None => list.child(tile),
739                    };
740                    index += 1;
741                }
742            }
743        }
744
745        let mut out: Vec<Box<dyn Widget<Msg>>> = Vec::new();
746        if let Some(header) = self.header.borrow_mut().take() {
747            out.push(header);
748        }
749        out.push(Box::new(
750            SingleChildScrollView::<Msg>::new().flex(1.0).child(list),
751        ));
752        if let Some(footer) = self.footer.borrow_mut().take() {
753            out.push(footer);
754        }
755        out
756    }
757}
758
759impl<Msg: Clone + 'static> Widget<Msg> for NavigationDrawer<Msg> {
760    /// A column, clear of the intrusion at the top.
761    ///
762    /// The reference wraps its content in a `SafeArea` with `bottom: false`
763    /// (`navigation_drawer.dart:189`): the notch is kept off the header, and the gesture
764    /// bar is left to whatever the panel stands in — the shell that already consumed it.
765    fn style(&self) -> Style {
766        let safe = crate::MediaQuery::of().padding;
767        Style {
768            width: self.width.map_or(Dimension::Auto, Dimension::Length),
769            flex_direction: FlexDirection::Column,
770            padding: Insets::new(safe.top, 0.0, 0.0, 0.0),
771            ..Default::default()
772        }
773    }
774
775    /// It fills the panel it was put in, on both axes unless it was given a width.
776    fn fill_axes(&self, _theme: &Theme) -> FillAxes {
777        if self.width.is_some() {
778            FillAxes::HEIGHT
779        } else {
780            FillAxes::BOTH
781        }
782    }
783
784    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
785        self.built.get_or_init(|| self.assemble())
786    }
787
788    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
789        let o = status.opacity;
790        let t = &theme.widgets.nav_drawer;
791        let depth = self.elevation.or(t.elevation).unwrap_or(ELEVATION);
792        // Drawn the way `Dialog` draws one, and for the same reason only where a colour is
793        // named: the reference keeps the elevation and makes the shadow transparent
794        // (its navigation drawer defaults, lines 729 and 746).
795        let shadow = self
796            .shadow_color
797            .or(t.shadow_color)
798            .unwrap_or(Color::TRANSPARENT);
799        if depth > 0.0 && shadow.a > 0.0 {
800            // The reference's shadows for this height (milestone 606).
801            frus_core::paint_elevation(
802                scene,
803                bounds,
804                frus_core::BorderRadius::ZERO,
805                depth,
806                shadow.fade(o),
807            );
808        }
809        let fill = self
810            .background
811            .or(t.background_color)
812            .unwrap_or(theme.scheme.surface_container_low);
813        scene.fill_rect(bounds, fill.fade(o));
814    }
815
816    fn on_click(&self) -> Option<Msg> {
817        None
818    }
819}
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824    use crate::divider::Divider;
825    use crate::text::Text;
826
827    #[derive(Clone, Debug, PartialEq)]
828    enum Msg {
829        Go(usize),
830    }
831
832    /// Three destinations, the middle one selected, with a rule between the second and
833    /// the third.
834    fn drawer() -> NavigationDrawer<Msg> {
835        NavigationDrawer::new(1, Msg::Go)
836            .item("I", "Inbox")
837            .item("S", "Starred")
838            .child(Divider::new())
839            .item("T", "Trash")
840    }
841
842    /// Every tile in a built drawer, in order — found the way the walk finds anything,
843    /// by descending. There is no downcast on the trait, and a test that reached for one
844    /// would be checking a shape the framework does not have.
845    fn tiles(drawer: &NavigationDrawer<Msg>) -> Vec<&dyn Widget<Msg>> {
846        fn walk<'a>(node: &'a dyn Widget<Msg>, out: &mut Vec<&'a dyn Widget<Msg>>) {
847            if node.debug_name() == "DrawerTile" {
848                out.push(node);
849            }
850            for child in node.children() {
851                walk(&**child, out);
852            }
853        }
854        let mut out = Vec::new();
855        for child in Widget::<Msg>::children(drawer) {
856            walk(&**child, &mut out);
857        }
858        out
859    }
860
861    fn scene_of(widget: &dyn Widget<Msg>, bounds: Rect, theme: &Theme) -> Scene {
862        let mut scene = Scene::new();
863        widget.paint(
864            bounds,
865            Status {
866                opacity: 1.0,
867                ..Default::default()
868            },
869            theme,
870            &mut scene,
871        );
872        scene
873    }
874
875    fn texts(scene: &Scene) -> Vec<(String, Color)> {
876        scene
877            .primitives()
878            .iter()
879            .filter_map(|p| match p {
880                frus_core::Primitive::Text { text, color, .. } => Some((text.clone(), *color)),
881                _ => None,
882            })
883            .collect()
884    }
885
886    /// **A destination's index counts destinations.**
887    ///
888    /// The rule the reference spends a second walk of its child list on
889    /// (`navigation_drawer.dart:180`), and the one that breaks silently: put a rule
890    /// between two groups of destinations and every destination below it answers with the
891    /// wrong screen. Nothing about the picture would say so.
892    #[test]
893    fn a_rule_between_destinations_takes_no_number() {
894        let drawer = drawer();
895        let tiles = tiles(&drawer);
896        assert_eq!(
897            tiles.len(),
898            3,
899            "three destinations, and the rule is not one"
900        );
901        assert_eq!(
902            tiles
903                .iter()
904                .filter_map(|t| t.on_click())
905                .collect::<Vec<_>>(),
906            vec![Msg::Go(0), Msg::Go(1), Msg::Go(2)],
907            "the third destination is 2, not 3"
908        );
909        assert_eq!(
910            tiles
911                .iter()
912                .map(|t| t.semantics().expect("announced").toggled)
913                .collect::<Vec<_>>(),
914            vec![
915                frus_core::Toggled::False,
916                frus_core::Toggled::True,
917                frus_core::Toggled::False
918            ],
919            "and the middle one is the live one"
920        );
921    }
922
923    /// And what a reader hears counts them the same way.
924    #[test]
925    fn a_reader_is_told_which_of_how_many() {
926        let drawer = drawer();
927        let heard: Vec<String> = tiles(&drawer)
928            .iter()
929            .filter_map(|t| t.semantics()?.label)
930            .collect();
931        assert_eq!(
932            heard,
933            vec![
934                "Inbox, Tab 1 of 3".to_string(),
935                "Starred, Tab 2 of 3".to_string(),
936                "Trash, Tab 3 of 3".to_string(),
937            ],
938            "the rule between them is not a destination, so it is not counted either"
939        );
940    }
941
942    /// One tile on its own, to be painted at a chosen width.
943    fn lone_tile(selected: bool) -> DrawerTile<Msg> {
944        DrawerTile {
945            icon: "I".into(),
946            label: "Inbox".into(),
947            badge: None,
948            selected,
949            disabled: false,
950            index: 0,
951            count: 1,
952            indicator_color: None,
953            indicator_shape: None,
954            overlay_color: None,
955            ground: None,
956            background: None,
957            tile_height: None,
958            tile_padding: None,
959            indicator_size: None,
960            icon_size: None,
961            label_text_style: None,
962            badge_text_style: None,
963            message: Msg::Go(0),
964        }
965    }
966
967    /// **The indicator's width is a ceiling.** The reference asks for 336 inside a panel
968    /// 304 wide, and what is drawn is the narrower of the two — otherwise the pill hangs
969    /// out past the panel's edge in every application that never set a width, which is
970    /// all of them.
971    #[test]
972    fn the_pill_never_grows_past_the_room_the_tile_has() {
973        let theme = Theme::default();
974        let tile = lone_tile(true);
975        let pill_width = |width: f32| {
976            let scene = scene_of(&tile, Rect::new(0.0, 0.0, width, TILE_HEIGHT), &theme);
977            scene
978                .primitives()
979                .iter()
980                .find_map(|p| match p {
981                    frus_core::Primitive::Rect { rect, .. } => Some(rect.width),
982                    _ => None,
983                })
984                .expect("a selected destination has an indicator")
985        };
986
987        assert_eq!(
988            pill_width(304.0),
989            304.0 - 24.0,
990            "in a 304-wide panel the pill takes what is left after the padding"
991        );
992        assert_eq!(
993            pill_width(600.0),
994            INDICATOR_WIDTH,
995            "and stops at its declared width once there is room for it"
996        );
997    }
998
999    /// **Both halves take the indicator's colour**, which is the one thing a drawer's row
1000    /// does differently from a rail's: there the label sits below the pill on the rail's
1001    /// own surface, here the pill is the row.
1002    #[test]
1003    fn a_selected_row_paints_its_glyph_and_its_label_the_same() {
1004        let theme = Theme::default();
1005        let scene = scene_of(
1006            &lone_tile(true),
1007            Rect::new(0.0, 0.0, 360.0, TILE_HEIGHT),
1008            &theme,
1009        );
1010        let painted = texts(&scene);
1011        assert_eq!(painted.len(), 2, "a glyph and a label");
1012        assert_eq!(
1013            painted[0].1, painted[1].1,
1014            "the glyph and the label stand on the same indicator"
1015        );
1016        assert_eq!(painted[0].1, theme.scheme.on_secondary_container);
1017
1018        let scene = scene_of(
1019            &lone_tile(false),
1020            Rect::new(0.0, 0.0, 360.0, TILE_HEIGHT),
1021            &theme,
1022        );
1023        assert_eq!(texts(&scene)[0].1, theme.scheme.on_surface_variant);
1024    }
1025
1026    /// A destination that cannot be reached is inert to the tap and to the tab, and says
1027    /// so rather than falling silent.
1028    #[test]
1029    fn a_destination_that_cannot_be_reached_answers_nowhere() {
1030        let drawer = NavigationDrawer::new(0, Msg::Go)
1031            .item("I", "Inbox")
1032            .item("A", "Archive")
1033            .disabled();
1034        let tiles = tiles(&drawer);
1035        let off = *tiles.last().expect("two of them");
1036        assert_eq!(off.on_click(), None, "the tap goes nowhere");
1037        assert!(!off.focusable(), "and the tab skips it");
1038        let semantics = off.semantics().expect("it is still announced");
1039        assert!(semantics.disabled, "and it is announced as unavailable");
1040        assert!(!semantics.clickable);
1041        assert_eq!(
1042            tiles[0].on_click(),
1043            Some(Msg::Go(0)),
1044            "its neighbour is untouched"
1045        );
1046    }
1047
1048    /// Nothing lights on a destination that cannot be reached: a state layer is the
1049    /// promise of an interaction and there is none here.
1050    #[test]
1051    fn a_destination_that_cannot_be_reached_does_not_light_up() {
1052        let theme = Theme::default();
1053        let mut tile = lone_tile(false);
1054        tile.disabled = true;
1055        let mut scene = Scene::new();
1056        Widget::<Msg>::paint(
1057            &tile,
1058            Rect::new(0.0, 0.0, 360.0, TILE_HEIGHT),
1059            Status {
1060                opacity: 1.0,
1061                hover_progress: 1.0,
1062                ..Default::default()
1063            },
1064            &theme,
1065            &mut scene,
1066        );
1067        assert!(
1068            !scene
1069                .primitives()
1070                .iter()
1071                .any(|p| matches!(p, frus_core::Primitive::Rect { .. })),
1072            "an unreachable destination has nothing to say to a pointer resting on it"
1073        );
1074    }
1075
1076    /// The header and the footer are outside the part that scrolls, so a long list cannot
1077    /// push either of them off the panel.
1078    #[test]
1079    fn the_two_ends_do_not_scroll() {
1080        let drawer = drawer()
1081            .header(Text::new("Mailbox"))
1082            .footer(Text::new("v1"));
1083        let children = Widget::<Msg>::children(&drawer);
1084        assert_eq!(children.len(), 3, "header, list, footer");
1085        assert_eq!(
1086            children[1].debug_name(),
1087            "SingleChildScrollView",
1088            "and only the middle one scrolls"
1089        );
1090    }
1091
1092    /// **The builders are order-independent**, which is what throwing the assembled
1093    /// subtree away on every builder buys: a colour named after the destinations still
1094    /// reaches them.
1095    #[test]
1096    fn saying_it_afterwards_says_it() {
1097        let theme = Theme::default();
1098        let ink = Color::rgb8(200, 30, 30);
1099        let before = NavigationDrawer::new(0, Msg::Go)
1100            .indicator_color(ink)
1101            .item("I", "Inbox");
1102        let after = NavigationDrawer::new(0, Msg::Go)
1103            .item("I", "Inbox")
1104            .indicator_color(ink);
1105        let fill = |drawer: &NavigationDrawer<Msg>| {
1106            let found = tiles(drawer);
1107            let scene = scene_of(found[0], Rect::new(0.0, 0.0, 360.0, TILE_HEIGHT), &theme);
1108            scene
1109                .primitives()
1110                .iter()
1111                .find_map(|p| match p {
1112                    frus_core::Primitive::Rect { color, .. } => Some(*color),
1113                    _ => None,
1114                })
1115                .expect("the selected destination has an indicator")
1116        };
1117        assert_eq!(fill(&before), ink);
1118        assert_eq!(fill(&after), ink, "the order the builders were called in");
1119    }
1120
1121    /// A tile keeps its declared height until the reader's own type outgrows it, at which
1122    /// point the row grows rather than clipping the label.
1123    #[test]
1124    fn a_row_grows_before_it_clips() {
1125        let theme = Theme::default();
1126        let plain_drawer = drawer();
1127        let plain = tiles(&plain_drawer)[0].style_themed(&theme).height;
1128        assert_eq!(plain, Dimension::Length(TILE_HEIGHT));
1129
1130        let big = drawer().label_text_style(TextStyle::new(48.0));
1131        let grown = tiles(&big)[0].style_themed(&theme).height;
1132        assert!(
1133            matches!(grown, Dimension::Length(h) if h > TILE_HEIGHT),
1134            "the row makes room for the type rather than cutting it: {grown:?}"
1135        );
1136    }
1137
1138    /// The panel's surface is a rung above a rail's: a drawer is a sheet over the page,
1139    /// not a strip beside it.
1140    #[test]
1141    fn the_panel_stands_a_rung_above_a_rail() {
1142        let theme = Theme::default();
1143        let scene = scene_of(&drawer(), Rect::new(0.0, 0.0, 304.0, 600.0), &theme);
1144        let surface = scene
1145            .primitives()
1146            .iter()
1147            .rev()
1148            .find_map(|p| match p {
1149                frus_core::Primitive::Rect { color, blur, .. } if *blur == 0.0 => Some(*color),
1150                _ => None,
1151            })
1152            .expect("the panel paints one");
1153        assert_eq!(surface, theme.scheme.surface_container_low);
1154        assert_ne!(
1155            theme.scheme.surface_container_low, theme.scheme.surface,
1156            "the two rungs have to be tellable apart for that to mean anything"
1157        );
1158    }
1159
1160    /// **A drawer's height is its tone, not a shadow.** The reference's navigation drawer
1161    /// defaults keep an elevation of 1 and make the shadow transparent (lines 729, 746);
1162    /// this painted a black one at 30 % under every drawer. A colour named — on the drawer
1163    /// or on the theme — brings one back, in that colour.
1164    #[test]
1165    fn a_drawer_casts_no_shadow_until_a_colour_is_named() {
1166        let bounds = Rect::new(0.0, 0.0, 304.0, 600.0);
1167        let shadows = |drawer: &NavigationDrawer<Msg>, theme: &Theme| {
1168            crate::shadowprobe::colour(scene_of(drawer, bounds, theme).primitives())
1169        };
1170        let theme = Theme::default();
1171        assert!(shadows(&drawer(), &theme).is_none(), "none by default");
1172
1173        let named = Color::rgba(0.0, 0.0, 0.0, 0.25);
1174        assert_eq!(shadows(&drawer().shadow_color(named), &theme), Some(named));
1175        let mut themed = Theme::default();
1176        themed.widgets.nav_drawer.shadow_color = Some(named);
1177        assert_eq!(shadows(&drawer(), &themed), Some(named), "or on the theme");
1178        assert!(
1179            shadows(&drawer().shadow_color(named).elevation(0.0), &theme).is_none(),
1180            "and a flat drawer has no height to cast it from"
1181        );
1182    }
1183}