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(count))
138    }
139
140    /// The glyph the **last** destination shows while it is selected, where that differs
141    /// from its resting one. See [`NavigationRail::selected_icon`].
142    pub fn selected_icon(self, icon: impl Into<DestinationIcon>) -> Self {
143        self.describing("selected_icon");
144        let icon = icon.into();
145        self.decorate(move |last| last.selected_icon = Some(icon))
146    }
147
148    /// Marks the **last** destination inaccessible. See [`NavigationRail::disabled`].
149    pub fn disabled(self) -> Self {
150        self.describing("disabled");
151        self.decorate(|last| last.disabled = true)
152    }
153
154    /// The **last** destination's own indicator colour, over the theme's. See
155    /// [`NavigationRail::indicator_color`].
156    pub fn indicator_color(self, color: Color) -> Self {
157        self.describing("indicator_color");
158        self.decorate(move |last| last.indicator_color = Some(color))
159    }
160
161    /// Applies `f` to the destination just added. Silent when there is none.
162    fn decorate(mut self, f: impl FnOnce(&mut NavigationDestination)) -> Self {
163        if let Some(last) = self.destinations.last_mut() {
164            f(last);
165        }
166        self
167    }
168
169    /// **When the destinations say what they are**, whichever widget the class chose.
170    /// Unsaid, each keeps its own default — a bar labels everything, a plain rail
171    /// nothing, an extended rail everything (milestones 432 and 433).
172    pub fn nav_labels(mut self, labels: RailLabels) -> Self {
173        self.describing("nav_labels");
174        self.labels = Some(labels);
175        self
176    }
177
178    /// **What to do to the rail** once the shell has built it — the door
179    /// [`Scaffold::rail`](crate::Scaffold::rail) opens on the fixed shell, and the way to
180    /// decline this one's extended form: `.rail(|rail| rail.extended(false))`.
181    ///
182    /// It runs last, after the destinations and after [`Self::nav_labels`], and is silent
183    /// when the class chose a bottom bar.
184    pub fn rail(
185        mut self,
186        configure: impl FnOnce(NavigationRail<Msg>) -> NavigationRail<Msg> + 'static,
187    ) -> Self {
188        self.describing("rail");
189        self.rail = Some(Box::new(configure));
190        self
191    }
192
193    /// **Give the widest band a drawer** instead of an extended rail, and say what to do
194    /// to it once the shell has built it — the door [`Self::rail`] opens on the other two
195    /// forms. `.nav_drawer(|drawer| drawer.header(title))` is the ordinary use: a drawer
196    /// with nothing above its destinations is a rail that spent 48 more pixels.
197    ///
198    /// It is silent below `Expanded`. A drawer takes 304 pixels of a window and the two
199    /// narrower bands do not have them to give — which is the whole reason there are three
200    /// forms — so a shell that swapped one in at `Medium` would be answering a question
201    /// about taste with the body's width.
202    pub fn nav_drawer(
203        mut self,
204        configure: impl FnOnce(NavigationDrawer<Msg>) -> NavigationDrawer<Msg> + 'static,
205    ) -> Self {
206        self.describing("nav_drawer");
207        self.drawer = Some(Box::new(configure));
208        self
209    }
210
211    /// Everything above **describes** the navigation, and [`Self::body`] is what builds
212    /// it. Saying so afterwards is a mistake with no effect, so it says which one.
213    fn describing(&self, what: &str) {
214        assert!(
215            self.on_select.is_some(),
216            "{what}() describes the navigation and has to come before body(), which \
217             builds it"
218        );
219    }
220
221    /// Sets the body and **finalises** the scaffold; call it last. The body fills the
222    /// space left beside, or above, the navigation.
223    pub fn body(mut self, body: impl Widget<Msg> + 'static) -> Self {
224        let on_select = self.on_select.take().expect("body called exactly once");
225        let destinations = std::mem::take(&mut self.destinations);
226        let body_pane: Box<dyn Widget<Msg>> = Box::new(Flex::column().flex(1.0).child(body));
227
228        // Only one arm runs, so `on_select` is moved exactly once.
229        let nav: Box<dyn Widget<Msg>> =
230            if self.class == SizeClass::Expanded && self.drawer.is_some() {
231                let configure = self.drawer.take().expect("just checked");
232                let mut drawer =
233                    NavigationDrawer::new(self.selected, on_select).width(crate::DRAWER_WIDTH);
234                for destination in destinations {
235                    drawer = drawer.destination(destination);
236                }
237                // `nav_labels` says nothing here on purpose: a drawer's destinations are named
238                // or they are not destinations. The three modes exist because a rail and a bar
239                // have to trade the words for room, and a drawer never makes that trade.
240                Box::new(configure(drawer))
241            } else if self.compact() {
242                let mut bar = BottomBar::new(self.selected, on_select);
243                for destination in destinations {
244                    bar = bar.destination(destination);
245                }
246                if let Some(labels) = self.labels {
247                    bar = bar.labels(labels);
248                }
249                Box::new(bar)
250            } else {
251                let mut rail = NavigationRail::new(self.selected, on_select);
252                for destination in destinations {
253                    rail = rail.destination(destination);
254                }
255                // **The third band gets the third presentation.** A window past the expanded
256                // threshold has room for the words, and the reference's own adaptive study
257                // spends it: `extended: !isTablet` on the desktop navigation
258                // (`reply/adaptive_nav.dart:97`) — a bar below `medium`, a plain rail at
259                // `medium`, an extended rail above it. This shell had two answers for three
260                // bands, and gave the widest window the tablet's rail.
261                rail = rail.extended(self.class == SizeClass::Expanded);
262                if let Some(labels) = self.labels {
263                    rail = rail.labels(labels);
264                }
265                if let Some(configure) = self.rail.take() {
266                    rail = configure(rail);
267                }
268                Box::new(rail)
269            };
270
271        // Compact puts the body above and the bar below; otherwise the rail is on the
272        // left and the body on the right.
273        self.children = if self.compact() {
274            vec![body_pane, nav]
275        } else {
276            vec![nav, body_pane]
277        };
278        self
279    }
280}
281
282impl<Msg: Clone> Widget<Msg> for NavScaffold<Msg> {
283    /// It asks to fill **both** axes rather than declaring `100%` on either — see
284    /// [`FillAxes`](crate::widget::FillAxes). A percentage resolves against the parent's
285    /// *resolved* size, which a parent that shrink-wraps has not got yet, so a shell
286    /// nested in one vanished entirely (milestone 404).
287    fn style(&self) -> Style {
288        Style {
289            flex_direction: if self.compact() {
290                FlexDirection::Column
291            } else {
292                FlexDirection::Row
293            },
294            ..Default::default()
295        }
296    }
297
298    /// A shell takes everything it is offered, on both axes.
299    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
300        crate::widget::FillAxes::BOTH
301    }
302
303    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
304        &self.children
305    }
306
307    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
308
309    fn on_click(&self) -> Option<Msg> {
310        None
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::Container;
318
319    #[derive(Clone, Debug, PartialEq)]
320    enum Msg {
321        Go(usize),
322    }
323
324    fn scaffold(class: SizeClass) -> NavScaffold<Msg> {
325        NavScaffold::new(class, 0, Msg::Go)
326            .destination("H", "Home")
327            .destination("S", "Settings")
328            .body(Container::new())
329    }
330
331    #[test]
332    fn compact_puts_body_first_then_bottom_bar_in_a_column() {
333        let s = scaffold(SizeClass::Compact);
334        assert_eq!(
335            Widget::<Msg>::style(&s).flex_direction,
336            FlexDirection::Column
337        );
338        // [body, bar]: navigation is the last child, at the bottom.
339        assert_eq!(Widget::<Msg>::children(&s).len(), 2);
340    }
341
342    /// The width the navigation declared, whichever of the three it is.
343    fn nav_width(s: &NavScaffold<Msg>) -> frus_layout::Dimension {
344        Widget::<Msg>::style(&*Widget::<Msg>::children(s)[0]).width
345    }
346
347    /// **The widest band can be asked for the third form.** It is not the default — see
348    /// [`NavScaffold::nav_drawer`] — so this is the only test that will notice if the
349    /// answer silently becomes one.
350    #[test]
351    fn the_widest_band_can_be_asked_for_a_drawer() {
352        let s = NavScaffold::new(SizeClass::Expanded, 0, Msg::Go)
353            .destination("H", "Home")
354            .destination("S", "Settings")
355            .nav_drawer(|drawer| drawer)
356            .body(Container::new());
357        assert_eq!(Widget::<Msg>::style(&s).flex_direction, FlexDirection::Row);
358        assert_eq!(
359            nav_width(&s),
360            frus_layout::Dimension::Length(crate::DRAWER_WIDTH),
361            "a drawer, not the extended rail this band gives by default"
362        );
363        assert_eq!(
364            nav_width(&scaffold(SizeClass::Expanded)),
365            frus_layout::Dimension::Length(crate::navrail::EXTENDED_RAIL_WIDTH),
366            "and unasked, the band is unchanged"
367        );
368    }
369
370    /// **And the two narrower ones decline.** A drawer takes 304 pixels of a window that
371    /// has 600, and a shell that handed them over would be answering a question about
372    /// taste with the body's width.
373    #[test]
374    fn a_narrower_band_keeps_the_form_that_fits() {
375        let asked = |class| {
376            NavScaffold::new(class, 0, Msg::Go)
377                .destination("H", "Home")
378                .nav_drawer(|drawer| drawer)
379                .body(Container::new())
380        };
381        assert_eq!(
382            nav_width(&asked(SizeClass::Medium)),
383            frus_layout::Dimension::Length(crate::navrail::RAIL_WIDTH),
384            "medium keeps its rail"
385        );
386        let compact = asked(SizeClass::Compact);
387        assert_eq!(
388            Widget::<Msg>::style(&compact).flex_direction,
389            FlexDirection::Column,
390            "and compact keeps its bar, at the bottom"
391        );
392    }
393
394    /// The closure runs **after** the shell has built the drawer, so it has the last word
395    /// — the same contract [`NavScaffold::rail`] has.
396    #[test]
397    fn what_the_caller_says_about_the_drawer_comes_last() {
398        let s = NavScaffold::new(SizeClass::Expanded, 0, Msg::Go)
399            .destination("H", "Home")
400            .nav_drawer(|drawer| drawer.width(200.0))
401            .body(Container::new());
402        assert_eq!(nav_width(&s), frus_layout::Dimension::Length(200.0));
403    }
404
405    #[test]
406    fn expanded_puts_rail_first_then_body_in_a_row() {
407        let s = scaffold(SizeClass::Expanded);
408        assert_eq!(Widget::<Msg>::style(&s).flex_direction, FlexDirection::Row);
409        // The rail (1st child) has a fixed width; the body takes the rest.
410        assert_eq!(
411            nav_width(&s),
412            frus_layout::Dimension::Length(crate::navrail::EXTENDED_RAIL_WIDTH)
413        );
414    }
415
416    /// **Three bands, three presentations** (milestone 435).
417    ///
418    /// The shell had two answers for three classes, so the widest window got the
419    /// tablet's rail. The reference's own adaptive study spends the room instead —
420    /// `extended: !isTablet` on its desktop navigation (`reply/adaptive_nav.dart:97`):
421    /// a bar below `medium`, a plain rail at `medium`, an extended rail above it.
422    #[test]
423    fn each_of_the_three_classes_gets_its_own_presentation() {
424        assert_eq!(
425            Widget::<Msg>::style(&scaffold(SizeClass::Compact)).flex_direction,
426            FlexDirection::Column,
427            "compact: a bar under the body"
428        );
429        assert_eq!(
430            nav_width(&scaffold(SizeClass::Medium)),
431            frus_layout::Dimension::Length(crate::navrail::RAIL_WIDTH),
432            "medium: a rail, glyphs alone"
433        );
434        assert_eq!(
435            nav_width(&scaffold(SizeClass::Expanded)),
436            frus_layout::Dimension::Length(crate::navrail::EXTENDED_RAIL_WIDTH),
437            "expanded: an extended rail, labels beside the glyphs"
438        );
439    }
440
441    /// And the caller has the last word on it, through the same door the fixed shell
442    /// opens: a window may be wide and still want its 176 pixels back.
443    #[test]
444    fn the_extended_rail_can_be_declined() {
445        let s = NavScaffold::new(SizeClass::Expanded, 0, Msg::Go)
446            .destination("H", "Home")
447            .rail(|rail| rail.extended(false))
448            .body(Container::new());
449        assert_eq!(
450            nav_width(&s),
451            frus_layout::Dimension::Length(crate::navrail::RAIL_WIDTH)
452        );
453    }
454
455    /// The label mode reaches whichever widget the class chose.
456    #[test]
457    fn the_label_mode_reaches_the_widget_the_class_chose() {
458        let says_home = |class, labels: Option<RailLabels>| {
459            let mut s = NavScaffold::new(class, 0, Msg::Go).destination("H", "Home");
460            if let Some(labels) = labels {
461                s = s.nav_labels(labels);
462            }
463            let s = s.body(Container::new());
464            let ui = crate::build_ui(
465                &s as &dyn Widget<Msg>,
466                frus_core::Size::new(900.0, 600.0),
467                &crate::Runtime::default(),
468                &Theme::default(),
469            );
470            ui.scene()
471                .primitives()
472                .iter()
473                .any(|p| matches!(p, frus_core::Primitive::Text { text, .. } if text == "Home"))
474        };
475        assert!(
476            !says_home(SizeClass::Medium, None),
477            "a plain rail says nothing until asked"
478        );
479        assert!(says_home(SizeClass::Medium, Some(RailLabels::All)));
480        assert!(!says_home(SizeClass::Compact, Some(RailLabels::None)));
481    }
482
483    /// Everything that **describes** the navigation has to come before the `body` that
484    /// **builds** it. It used to be silently ignored, which is a bug that looks like a
485    /// property that does not work.
486    #[test]
487    #[should_panic(expected = "has to come before body()")]
488    fn describing_the_navigation_after_the_body_is_refused() {
489        let _ = NavScaffold::new(SizeClass::Expanded, 0, Msg::Go)
490            .destination("H", "Home")
491            .body(Container::<Msg>::new())
492            .destination("S", "Settings");
493    }
494}