Skip to main content

frus_widgets/
navscaffold.rs

1//! [`NavScaffold`]: the **adaptive** navigation scaffold. It reads the [`SizeClass`] and
2//! gives each of its three bands its own presentation of the primary navigation, the body
3//! filling the rest. This is where the size class drives the screen's *structure*.
4//!
5//! | class | navigation |
6//! |---|---|
7//! | `Compact` | a bottom bar |
8//! | `Medium` | a rail, glyphs alone |
9//! | `Expanded` | an [extended](NavigationRail::extended) rail, labels beside the glyphs |
10//! | `Expanded`, [asked](NavScaffold::nav_drawer) | a [`NavigationDrawer`] docked on the leading edge |
11//!
12//! The fourth row is opt-in because the reference gives **two** answers for the widest
13//! band and they are both right. Material's adaptive guidance puts a navigation drawer
14//! there; the framework's own adaptive study puts an extended rail
15//! (`reply/adaptive_nav.dart:97`). The rail is the safer default — it costs 256 pixels
16//! against the drawer's 304 and it needs no header to look finished — so it stays the
17//! default, and an application with more destinations than a rail can hold says so.
18
19use frus_core::{Color, Rect, Scene, SizeClass};
20use frus_layout::{FlexDirection, Style};
21
22use crate::flex::Flex;
23use crate::interaction::Status;
24use crate::navdrawer::NavigationDrawer;
25use crate::navrail::{
26    BottomBar, DestinationIcon, NavigationDestination, NavigationRail, RailLabels,
27};
28use crate::theme::Theme;
29use crate::widget::Widget;
30
31/// What a caller wants done to the rail this shell built for it. See
32/// [`NavScaffold::rail`].
33type RailConfig<Msg> = Box<dyn FnOnce(NavigationRail<Msg>) -> NavigationRail<Msg>>;
34
35/// The same, for the drawer. See [`NavScaffold::nav_drawer`].
36type DrawerConfig<Msg> = Box<dyn FnOnce(NavigationDrawer<Msg>) -> NavigationDrawer<Msg>>;
37
38/// Adaptive navigation shell: a bottom bar, a rail, or an extended rail, by size.
39pub struct NavScaffold<Msg = crate::callback::Callback> {
40    class: SizeClass,
41    selected: usize,
42    on_select: Option<Box<dyn Fn(usize) -> Msg>>,
43    destinations: Vec<NavigationDestination>,
44    labels: Option<RailLabels>,
45    rail: Option<RailConfig<Msg>>,
46    /// Whether the widest band gets a drawer rather than an extended rail. See
47    /// [`NavScaffold::nav_drawer`].
48    drawer: Option<DrawerConfig<Msg>>,
49    children: Vec<Box<dyn Widget<Msg>>>,
50}
51
52impl<Msg> NavScaffold<Msg> {
53    /// Whether the navigation is a bottom bar rather than a rail. Free of the bounds the
54    /// builders carry, since the layout asks it too.
55    fn compact(&self) -> bool {
56        self.class == SizeClass::Compact
57    }
58}
59
60impl<Msg: Clone + 'static> NavScaffold<Msg> {
61    /// Creates a scaffold: `class` decides the presentation, `selected` the active
62    /// destination, and `on_select(i)` is emitted when a destination is chosen.
63    pub fn new(
64        class: SizeClass,
65        selected: usize,
66        on_select: impl Fn(usize) -> Msg + 'static,
67    ) -> Self {
68        Self {
69            class,
70            selected,
71            on_select: Some(Box::new(on_select)),
72            destinations: Vec::new(),
73            labels: None,
74            rail: None,
75            drawer: None,
76            children: Vec::new(),
77        }
78    }
79
80    /// Adds a destination, a glyph plus a label. Call this **before** [`body`].
81    ///
82    /// [`body`]: NavScaffold::body
83    pub fn destination(
84        mut self,
85        icon: impl Into<DestinationIcon>,
86        label: impl Into<String>,
87    ) -> Self {
88        self.describing("destination");
89        self.destinations
90            .push(NavigationDestination::new(icon, label));
91        self
92    }
93
94    /// Adds a whole list of destinations **declared elsewhere**. Call this **before**
95    /// [`body`].
96    ///
97    /// This is the shape the scaffold was waiting for: it already picks the bar, the rail
98    /// or the drawer by width, and now the caller writes the navigation once and lets it.
99    ///
100    /// ```
101    /// use frus_core::SizeClass;
102    /// use frus_widgets::{Icons, NavScaffold, NavigationDestination};
103    ///
104    /// let places = vec![
105    ///     NavigationDestination::new(Icons::FAVORITE_BORDER, "Saved")
106    ///         .selected_icon(Icons::FAVORITE),
107    ///     NavigationDestination::new(Icons::MAIL_OUTLINE, "Inbox").badge(3),
108    /// ];
109    /// let _shell = NavScaffold::new(SizeClass::Compact, 0, |i| i)
110    ///     .destinations(places)
111    ///     .body(frus_widgets::text("…"));
112    /// ```
113    ///
114    /// [`body`]: NavScaffold::body
115    #[must_use]
116    pub fn destinations(
117        mut self,
118        destinations: impl IntoIterator<Item = NavigationDestination>,
119    ) -> Self {
120        self.describing("destinations");
121        self.destinations.extend(destinations);
122        self
123    }
124
125    /// What a pointer resting on the **last** destination is told. See
126    /// [`NavigationRail::tooltip`].
127    #[must_use]
128    pub fn tooltip(self, message: impl Into<String>) -> Self {
129        self.describing("tooltip");
130        let message = message.into();
131        self.decorate(move |last| last.tooltip = Some(message))
132    }
133
134    /// Adds a notification count to the **last** destination.
135    pub fn badge(self, count: u32) -> Self {
136        self.describing("badge");
137        self.decorate(|last| last.badge = Some(crate::navrail::DestinationBadge::Count(count)))
138    }
139
140    /// Adds a dot to the **last** destination (milestone 707).
141    pub fn badge_dot(self) -> Self {
142        self.describing("badge_dot");
143        self.decorate(|last| last.badge = Some(crate::navrail::DestinationBadge::Dot))
144    }
145
146    /// The glyph the **last** destination shows while it is selected, where that differs
147    /// from its resting one. See [`NavigationRail::selected_icon`].
148    pub fn selected_icon(self, icon: impl Into<DestinationIcon>) -> Self {
149        self.describing("selected_icon");
150        let icon = icon.into();
151        self.decorate(move |last| last.selected_icon = Some(icon))
152    }
153
154    /// Marks the **last** destination inaccessible. See [`NavigationRail::disabled`].
155    pub fn disabled(self) -> Self {
156        self.describing("disabled");
157        self.decorate(|last| last.disabled = true)
158    }
159
160    /// The **last** destination's own indicator colour, over the theme's. See
161    /// [`NavigationRail::indicator_color`].
162    pub fn indicator_color(self, color: Color) -> Self {
163        self.describing("indicator_color");
164        self.decorate(move |last| last.indicator_color = Some(color))
165    }
166
167    /// Applies `f` to the destination just added. Silent when there is none.
168    fn decorate(mut self, f: impl FnOnce(&mut NavigationDestination)) -> Self {
169        if let Some(last) = self.destinations.last_mut() {
170            f(last);
171        }
172        self
173    }
174
175    /// **When the destinations say what they are**, whichever widget the class chose.
176    /// Unsaid, each keeps its own default — a bar labels everything, a plain rail
177    /// nothing, an extended rail everything (milestones 432 and 433).
178    pub fn nav_labels(mut self, labels: RailLabels) -> Self {
179        self.describing("nav_labels");
180        self.labels = Some(labels);
181        self
182    }
183
184    /// **What to do to the rail** once the shell has built it — the door
185    /// [`Scaffold::rail`](crate::Scaffold::rail) opens on the fixed shell, and the way to
186    /// decline this one's extended form: `.rail(|rail| rail.extended(false))`.
187    ///
188    /// It runs last, after the destinations and after [`Self::nav_labels`], and is silent
189    /// when the class chose a bottom bar.
190    pub fn rail(
191        mut self,
192        configure: impl FnOnce(NavigationRail<Msg>) -> NavigationRail<Msg> + 'static,
193    ) -> Self {
194        self.describing("rail");
195        self.rail = Some(Box::new(configure));
196        self
197    }
198
199    /// **Give the widest band a drawer** instead of an extended rail, and say what to do
200    /// to it once the shell has built it — the door [`Self::rail`] opens on the other two
201    /// forms. `.nav_drawer(|drawer| drawer.header(title))` is the ordinary use: a drawer
202    /// with nothing above its destinations is a rail that spent 48 more pixels.
203    ///
204    /// It is silent below `Expanded`. A drawer takes 304 pixels of a window and the two
205    /// narrower bands do not have them to give — which is the whole reason there are three
206    /// forms — so a shell that swapped one in at `Medium` would be answering a question
207    /// about taste with the body's width.
208    pub fn nav_drawer(
209        mut self,
210        configure: impl FnOnce(NavigationDrawer<Msg>) -> NavigationDrawer<Msg> + 'static,
211    ) -> Self {
212        self.describing("nav_drawer");
213        self.drawer = Some(Box::new(configure));
214        self
215    }
216
217    /// Everything above **describes** the navigation, and [`Self::body`] is what builds
218    /// it. Saying so afterwards is a mistake with no effect, so it says which one.
219    fn describing(&self, what: &str) {
220        assert!(
221            self.on_select.is_some(),
222            "{what}() describes the navigation and has to come before body(), which \
223             builds it"
224        );
225    }
226
227    /// Sets the body and **finalises** the scaffold; call it last. The body fills the
228    /// space left beside, or above, the navigation.
229    pub fn body(mut self, body: impl Widget<Msg> + 'static) -> Self {
230        let on_select = self.on_select.take().expect("body called exactly once");
231        let destinations = std::mem::take(&mut self.destinations);
232        let body_pane: Box<dyn Widget<Msg>> = Box::new(Flex::column().flex(1.0).child(body));
233
234        // Only one arm runs, so `on_select` is moved exactly once.
235        let nav: Box<dyn Widget<Msg>> =
236            if self.class == SizeClass::Expanded && self.drawer.is_some() {
237                let configure = self.drawer.take().expect("just checked");
238                let mut drawer =
239                    NavigationDrawer::new(self.selected, on_select).width(crate::DRAWER_WIDTH);
240                for destination in destinations {
241                    drawer = drawer.destination(destination);
242                }
243                // `nav_labels` says nothing here on purpose: a drawer's destinations are named
244                // or they are not destinations. The three modes exist because a rail and a bar
245                // have to trade the words for room, and a drawer never makes that trade.
246                Box::new(configure(drawer))
247            } else if self.compact() {
248                let mut bar = BottomBar::new(self.selected, on_select);
249                for destination in destinations {
250                    bar = bar.destination(destination);
251                }
252                if let Some(labels) = self.labels {
253                    bar = bar.labels(labels);
254                }
255                Box::new(bar)
256            } else {
257                let mut rail = NavigationRail::new(self.selected, on_select);
258                for destination in destinations {
259                    rail = rail.destination(destination);
260                }
261                // **The third band gets the third presentation.** A window past the expanded
262                // threshold has room for the words, and the reference's own adaptive study
263                // spends it: `extended: !isTablet` on the desktop navigation
264                // (`reply/adaptive_nav.dart:97`) — a bar below `medium`, a plain rail at
265                // `medium`, an extended rail above it. This shell had two answers for three
266                // bands, and gave the widest window the tablet's rail.
267                rail = rail.extended(self.class == SizeClass::Expanded);
268                if let Some(labels) = self.labels {
269                    rail = rail.labels(labels);
270                }
271                if let Some(configure) = self.rail.take() {
272                    rail = configure(rail);
273                }
274                Box::new(rail)
275            };
276
277        // Compact puts the body above and the bar below; otherwise the rail is on the
278        // left and the body on the right.
279        self.children = if self.compact() {
280            vec![body_pane, nav]
281        } else {
282            vec![nav, body_pane]
283        };
284        self
285    }
286}
287
288impl<Msg: Clone> Widget<Msg> for NavScaffold<Msg> {
289    /// It asks to fill **both** axes rather than declaring `100%` on either — see
290    /// [`FillAxes`](crate::widget::FillAxes). A percentage resolves against the parent's
291    /// *resolved* size, which a parent that shrink-wraps has not got yet, so a shell
292    /// nested in one vanished entirely (milestone 404).
293    fn style(&self) -> Style {
294        Style {
295            flex_direction: if self.compact() {
296                FlexDirection::Column
297            } else {
298                FlexDirection::Row
299            },
300            ..Default::default()
301        }
302    }
303
304    /// A shell takes everything it is offered, on both axes.
305    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
306        crate::widget::FillAxes::BOTH
307    }
308
309    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
310        &self.children
311    }
312
313    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
314
315    fn on_click(&self) -> Option<Msg> {
316        None
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use crate::Container;
324
325    #[derive(Clone, Debug, PartialEq)]
326    enum Msg {
327        Go(usize),
328    }
329
330    fn scaffold(class: SizeClass) -> NavScaffold<Msg> {
331        NavScaffold::new(class, 0, Msg::Go)
332            .destination("H", "Home")
333            .destination("S", "Settings")
334            .body(Container::new())
335    }
336
337    #[test]
338    fn compact_puts_body_first_then_bottom_bar_in_a_column() {
339        let s = scaffold(SizeClass::Compact);
340        assert_eq!(
341            Widget::<Msg>::style(&s).flex_direction,
342            FlexDirection::Column
343        );
344        // [body, bar]: navigation is the last child, at the bottom.
345        assert_eq!(Widget::<Msg>::children(&s).len(), 2);
346    }
347
348    /// The width the navigation declared, whichever of the three it is.
349    fn nav_width(s: &NavScaffold<Msg>) -> frus_layout::Dimension {
350        Widget::<Msg>::style(&*Widget::<Msg>::children(s)[0]).width
351    }
352
353    /// **The widest band can be asked for the third form.** It is not the default — see
354    /// [`NavScaffold::nav_drawer`] — so this is the only test that will notice if the
355    /// answer silently becomes one.
356    #[test]
357    fn the_widest_band_can_be_asked_for_a_drawer() {
358        let s = NavScaffold::new(SizeClass::Expanded, 0, Msg::Go)
359            .destination("H", "Home")
360            .destination("S", "Settings")
361            .nav_drawer(|drawer| drawer)
362            .body(Container::new());
363        assert_eq!(Widget::<Msg>::style(&s).flex_direction, FlexDirection::Row);
364        assert_eq!(
365            nav_width(&s),
366            frus_layout::Dimension::Length(crate::DRAWER_WIDTH),
367            "a drawer, not the extended rail this band gives by default"
368        );
369        assert_eq!(
370            nav_width(&scaffold(SizeClass::Expanded)),
371            frus_layout::Dimension::Length(crate::navrail::EXTENDED_RAIL_WIDTH),
372            "and unasked, the band is unchanged"
373        );
374    }
375
376    /// **And the two narrower ones decline.** A drawer takes 304 pixels of a window that
377    /// has 600, and a shell that handed them over would be answering a question about
378    /// taste with the body's width.
379    #[test]
380    fn a_narrower_band_keeps_the_form_that_fits() {
381        let asked = |class| {
382            NavScaffold::new(class, 0, Msg::Go)
383                .destination("H", "Home")
384                .nav_drawer(|drawer| drawer)
385                .body(Container::new())
386        };
387        assert_eq!(
388            nav_width(&asked(SizeClass::Medium)),
389            frus_layout::Dimension::Length(crate::navrail::RAIL_WIDTH),
390            "medium keeps its rail"
391        );
392        let compact = asked(SizeClass::Compact);
393        assert_eq!(
394            Widget::<Msg>::style(&compact).flex_direction,
395            FlexDirection::Column,
396            "and compact keeps its bar, at the bottom"
397        );
398    }
399
400    /// The closure runs **after** the shell has built the drawer, so it has the last word
401    /// — the same contract [`NavScaffold::rail`] has.
402    #[test]
403    fn what_the_caller_says_about_the_drawer_comes_last() {
404        let s = NavScaffold::new(SizeClass::Expanded, 0, Msg::Go)
405            .destination("H", "Home")
406            .nav_drawer(|drawer| drawer.width(200.0))
407            .body(Container::new());
408        assert_eq!(nav_width(&s), frus_layout::Dimension::Length(200.0));
409    }
410
411    #[test]
412    fn expanded_puts_rail_first_then_body_in_a_row() {
413        let s = scaffold(SizeClass::Expanded);
414        assert_eq!(Widget::<Msg>::style(&s).flex_direction, FlexDirection::Row);
415        // The rail (1st child) has a fixed width; the body takes the rest.
416        assert_eq!(
417            nav_width(&s),
418            frus_layout::Dimension::Length(crate::navrail::EXTENDED_RAIL_WIDTH)
419        );
420    }
421
422    /// **Three bands, three presentations** (milestone 435).
423    ///
424    /// The shell had two answers for three classes, so the widest window got the
425    /// tablet's rail. The reference's own adaptive study spends the room instead —
426    /// `extended: !isTablet` on its desktop navigation (`reply/adaptive_nav.dart:97`):
427    /// a bar below `medium`, a plain rail at `medium`, an extended rail above it.
428    #[test]
429    fn each_of_the_three_classes_gets_its_own_presentation() {
430        assert_eq!(
431            Widget::<Msg>::style(&scaffold(SizeClass::Compact)).flex_direction,
432            FlexDirection::Column,
433            "compact: a bar under the body"
434        );
435        assert_eq!(
436            nav_width(&scaffold(SizeClass::Medium)),
437            frus_layout::Dimension::Length(crate::navrail::RAIL_WIDTH),
438            "medium: a rail, glyphs alone"
439        );
440        assert_eq!(
441            nav_width(&scaffold(SizeClass::Expanded)),
442            frus_layout::Dimension::Length(crate::navrail::EXTENDED_RAIL_WIDTH),
443            "expanded: an extended rail, labels beside the glyphs"
444        );
445    }
446
447    /// And the caller has the last word on it, through the same door the fixed shell
448    /// opens: a window may be wide and still want its 176 pixels back.
449    #[test]
450    fn the_extended_rail_can_be_declined() {
451        let s = NavScaffold::new(SizeClass::Expanded, 0, Msg::Go)
452            .destination("H", "Home")
453            .rail(|rail| rail.extended(false))
454            .body(Container::new());
455        assert_eq!(
456            nav_width(&s),
457            frus_layout::Dimension::Length(crate::navrail::RAIL_WIDTH)
458        );
459    }
460
461    /// The label mode reaches whichever widget the class chose.
462    #[test]
463    fn the_label_mode_reaches_the_widget_the_class_chose() {
464        let says_home = |class, labels: Option<RailLabels>| {
465            let mut s = NavScaffold::new(class, 0, Msg::Go).destination("H", "Home");
466            if let Some(labels) = labels {
467                s = s.nav_labels(labels);
468            }
469            let s = s.body(Container::new());
470            let ui = crate::build_ui(
471                &s as &dyn Widget<Msg>,
472                frus_core::Size::new(900.0, 600.0),
473                &crate::Runtime::default(),
474                &Theme::default(),
475            );
476            ui.scene()
477                .primitives()
478                .iter()
479                .any(|p| matches!(p, frus_core::Primitive::Text { text, .. } if text == "Home"))
480        };
481        assert!(
482            !says_home(SizeClass::Medium, None),
483            "a plain rail says nothing until asked"
484        );
485        assert!(says_home(SizeClass::Medium, Some(RailLabels::All)));
486        assert!(!says_home(SizeClass::Compact, Some(RailLabels::None)));
487    }
488
489    /// Everything that **describes** the navigation has to come before the `body` that
490    /// **builds** it. It used to be silently ignored, which is a bug that looks like a
491    /// property that does not work.
492    #[test]
493    #[should_panic(expected = "has to come before body()")]
494    fn describing_the_navigation_after_the_body_is_refused() {
495        let _ = NavScaffold::new(SizeClass::Expanded, 0, Msg::Go)
496            .destination("H", "Home")
497            .body(Container::<Msg>::new())
498            .destination("S", "Settings");
499    }
500}