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}