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}