frus_widgets/scaffold.rs
1//! [`Scaffold`]: the **screen shell** of frus — the central coordinator of a
2//! Material screen structure.
3//!
4//! The developer declares **slots** (app bar, body, navigation, drawer, FAB, modal
5//! sheet); the Scaffold assembles them correctly — the **app bar pinned** at the
6//! top, the **body** filling the middle, **navigation** in a bottom bar (or a
7//! rail, if one is asked for), all of it **respecting the safe area**
8//! (system insets). One piece of code, with no branching on mobile vs desktop.
9//!
10//! ```ignore
11//! Scaffold::new() // the surface's size and intrusions,
12//! // read from the ambient description
13//! .background(theme.background)
14//! .app_bar(appbar) // pinned at the top
15//! .body(content) // fills what the bars leave
16//! .nav(app.section, Msg::SetSection) // destinations, in a bottom bar
17//! .destination("✔", "Tasks").badge(3)
18//! .destination("▦", "Stats")
19//! .rail(|rail| rail.extended(true)) // when the navigation is a rail
20
21//! .drawer(menu, app.menu_open, Msg::ToggleMenu) // leading edge
22//! .end_drawer(filters, app.drawer_open, Msg::ToggleDrawer)
23//! .persistent_footer(row![cancel, save]) // never scrolls away
24//! .fab_location(FabLocation::EndFloat) // or docked, at either end
25//! .fab(fab_button("+", Msg::AddTodo)) // floating action button
26//! .bottom_sheet(sheet, app.sheet_open, Msg::ToggleSheet)
27//! .build()
28//! ```
29//!
30//! **Nobody hands it the screen.** The size and the intrusions come from
31//! [`MediaQuery::of`]: an application does not measure the window, subtract the notch
32//! and the bars, and carry the remainder down to every widget that might want it. It
33//! says `Scaffold::new()`. That is the reference's arrangement — its `Scaffold` takes
34//! no size either — and it is not a convenience: a number that travels by hand gets
35//! dropped, and the failure is a screen laid out to the wrong width.
36//!
37//! **What the body is given.** The body gets what the bars leave it: it starts below
38//! the app bar, stops above the bottom bar and the persistent footer, and is shortened
39//! by the soft keyboard so that a field at the end of a form is not covered by it. Each
40//! of those is a decision a screen may reverse —
41//! [`Scaffold::extend_body_behind_app_bar`], [`Scaffold::extend_body`] and
42//! [`Scaffold::resize_to_avoid_bottom_inset`].
43//!
44//! **And what it is told.** The system's own intrusions — the notch, the gesture bar —
45//! are *described* to the body, not spent on its behalf. With nothing below it the body
46//! reaches the screen's edge, and the description it is handed says the gesture bar is
47//! there; a body that must be held clear of it says [`SafeArea`](crate::SafeArea) and is
48//! answered. That is the reference's arrangement, and it is what a background, a hero
49//! image, or a list that should scroll *under* the gesture bar needs — a shell that spent
50//! the intrusion for them made all three impossible, and made every screen pay for the
51//! notch whether it wanted the room or not.
52//!
53//! It is only ever the **body**. A bar, a rail or a footer put in a slot consumes what it
54//! is told about, so the chrome keeps clear of the intrusions without a screen saying
55//! anything at all.
56//!
57//! **The body does not scroll.** It is placed in the room the bars leave and that is
58//! all; a screen that needs to scroll puts a scrolling widget in the body, and picks
59//! which one. That is not a limitation the shell happens to have — it is the whole
60//! decision. A shell that scrolls everything decides for every screen at once: a
61//! centred empty state cannot be centred any more, a screen with its own list ends up
62//! with a scroller inside a scroller, and a screen with nothing to scroll still
63//! reports itself scrollable to the gesture arena. Scrolling is a property of the
64//! content, and the content is the screen's.
65
66use frus_core::{Color, Insets, WindowInsets};
67use frus_layout::Justify;
68
69use crate::bottomappbar::BottomAppBar;
70use crate::container::Container;
71use crate::flex::Flex;
72use crate::media::MediaQuery;
73use crate::navrail::{BottomBar, NavigationDestination, NavigationRail, RailLabels, BAR_HEIGHT};
74use crate::stack::Stack;
75use crate::widget::Widget;
76
77/// The FAB's margin from the edge, and from the bottom bar.
78const FAB_MARGIN: f32 = 16.0;
79/// The padding around the persistent footer's row.
80const FOOTER_PAD: f32 = 12.0;
81/// The height a floating action button is assumed to have, absent
82/// [`Scaffold::fab_size`]. The conventional Material diameter.
83const FAB_SIZE: f32 = 56.0;
84
85/// What a caller wants done to the [`NavigationRail`] the shell built for it, once it is
86/// built and before it is measured. See [`Scaffold::rail`].
87type RailConfig<Msg> = Box<dyn FnOnce(NavigationRail<Msg>) -> NavigationRail<Msg>>;
88
89/// Where a [`Scaffold`]'s navigation destinations are drawn.
90///
91/// A **fixed** choice: whichever is asked for is what gets drawn, at every width. A
92/// `Scaffold` never changes its own layout, which is the point of this type existing
93/// (milestone 305).
94///
95/// Until then the scaffold measured its own width and swapped a bottom bar for a side
96/// rail above a threshold, with no way to ask it not to. Rotating a phone to landscape
97/// crosses that threshold — so the navigation moved from the bottom of the screen to
98/// the left edge because the user turned their hand, and nothing in the application
99/// had asked for that or could prevent it.
100///
101/// The reference does not do this either: its screen shell has one navigation slot, at
102/// the bottom, and a rail is a separate widget placed by whoever wants one. Adapting
103/// is a design decision, and it belongs to the application.
104///
105/// For navigation that **does** follow the size class, reach for
106/// [`NavScaffold`](crate::NavScaffold) — a separate shell whose whole purpose is that,
107/// and which says so in its name.
108#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
109pub enum NavPlacement {
110 /// A bottom bar, at every width. The default.
111 #[default]
112 Bottom,
113 /// A vertical rail on the leading edge, at every width.
114 Rail,
115}
116
117/// Where the floating action button sits.
118///
119/// Two questions, and they are independent: which end of the row, and whether the
120/// button **floats** clear of the bottom bar or **docks** astride its top edge. The
121/// docked placements are the ones a notched bottom bar is cut for.
122#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
123pub enum FabLocation {
124 /// Leading end, clear of the bar.
125 StartFloat,
126 /// Centred, clear of the bar.
127 CenterFloat,
128 /// Trailing end, clear of the bar — the default, and where a thumb reaches.
129 #[default]
130 EndFloat,
131 /// Leading end, centred on the bar's top edge.
132 StartDocked,
133 /// Centred on the bar's top edge, horizontally centred.
134 CenterDocked,
135 /// Trailing end, centred on the bar's top edge.
136 EndDocked,
137}
138
139impl FabLocation {
140 /// Whether the button straddles the bar's top edge rather than floating above it.
141 pub fn docked(self) -> bool {
142 matches!(
143 self,
144 FabLocation::StartDocked | FabLocation::CenterDocked | FabLocation::EndDocked
145 )
146 }
147
148 /// Which end of the row it goes to.
149 pub fn justify(self) -> Justify {
150 match self {
151 FabLocation::StartFloat | FabLocation::StartDocked => Justify::Start,
152 FabLocation::CenterFloat | FabLocation::CenterDocked => Justify::Center,
153 FabLocation::EndFloat | FabLocation::EndDocked => Justify::End,
154 }
155 }
156}
157
158/// The screen shell. A fluent builder finished by [`Scaffold::build`].
159pub struct Scaffold<Msg = crate::callback::Callback> {
160 width: f32,
161 height: f32,
162 insets: Insets,
163 view_insets: Insets,
164 /// The intrusions ignoring the keyboard; see [`Scaffold::window_insets`].
165 view_padding: Insets,
166 resize_to_avoid_bottom_inset: bool,
167 extend_body: bool,
168 extend_body_behind_app_bar: bool,
169 background: Option<Color>,
170 app_bar: Option<Box<dyn Widget<Msg>>>,
171 body: Option<Box<dyn Widget<Msg>>>,
172 selected: usize,
173 on_select: Option<Box<dyn Fn(usize) -> Msg>>,
174 destinations: Vec<NavigationDestination>,
175 nav_placement: NavPlacement,
176 nav_labels: Option<RailLabels>,
177 /// What the caller wants done to the rail once the shell has built it. `None` leaves
178 /// the rail the shell would have built anyway.
179 rail: Option<RailConfig<Msg>>,
180 drawer: Option<(Box<dyn Widget<Msg>>, bool, Msg)>,
181 end_drawer: Option<(Box<dyn Widget<Msg>>, bool, Msg)>,
182 bottom_app_bar: Option<BottomAppBar<Msg>>,
183 fab: Option<Box<dyn Widget<Msg>>>,
184 fab_location: FabLocation,
185 fab_size: f32,
186 persistent_footer: Option<Box<dyn Widget<Msg>>>,
187 persistent_footer_alignment: Justify,
188 persistent_footer_divider: bool,
189 persistent_footer_color: Option<Color>,
190 bottom_sheet: Option<(Box<dyn Widget<Msg>>, bool, Msg)>,
191 primary: bool,
192 drawer_scrim_color: Option<Color>,
193 drawer_barrier_dismissible: bool,
194}
195
196impl<Msg: Clone + 'static> Default for Scaffold<Msg> {
197 /// The same as [`Scaffold::new`] — a shell for the surface being built for, with
198 /// every slot empty.
199 fn default() -> Self {
200 Self::new()
201 }
202}
203
204impl<Msg: Clone + 'static> Scaffold<Msg> {
205 /// Creates a shell for **the surface it is being built for**.
206 ///
207 /// The size and the intrusions both come from [`MediaQuery::of`]: the application
208 /// does not measure the screen, subtract the notch and the bars from it, and hand
209 /// the remainder down: it says `Scaffold::new()` and the shell keeps its own slots
210 /// clear of whatever the platform reported. That is the reference's arrangement,
211 /// where `Scaffold` reads `MediaQuery.of(context)` and takes no size at all.
212 ///
213 /// Outside any surface description — a unit test building a shell on its own —
214 /// the size is zero, and [`Scaffold::size`] is how a test says how big the screen
215 /// is. Wrapping the build in `MediaQuery::new(size).scope(..)` says it once for
216 /// everything inside, which is usually what a test wants.
217 ///
218 /// The navigation is a **bottom bar** whatever the width; ask for
219 /// [`Scaffold::nav_placement`] to have it anywhere else.
220 pub fn new() -> Self {
221 let surface = MediaQuery::of();
222 Self {
223 width: surface.size.width,
224 height: surface.size.height,
225 insets: surface.padding,
226 view_insets: surface.view_insets,
227 view_padding: surface.view_padding,
228 resize_to_avoid_bottom_inset: true,
229 extend_body: false,
230 extend_body_behind_app_bar: false,
231 background: None,
232 app_bar: None,
233 body: None,
234 selected: 0,
235 on_select: None,
236 destinations: Vec::new(),
237 nav_placement: NavPlacement::default(),
238 // **Not a default of its own.** Left unsaid, each of the two navigation
239 // widgets keeps the one the reference gives it — a rail labels nothing, a
240 // bar labels everything (milestone 432) — and collapsing them to one answer
241 // here would quietly undo that.
242 nav_labels: None,
243 rail: None,
244 drawer: None,
245 end_drawer: None,
246 bottom_app_bar: None,
247 fab: None,
248 fab_location: FabLocation::default(),
249 fab_size: FAB_SIZE,
250 persistent_footer: None,
251 persistent_footer_alignment: Justify::End,
252 // The reference's footer carries a one-pixel line along its top by default
253 // — `Divider.createBorderSide` — and ours had none.
254 persistent_footer_divider: true,
255 persistent_footer_color: None,
256 bottom_sheet: None,
257 primary: true,
258 drawer_scrim_color: None,
259 drawer_barrier_dismissible: true,
260 }
261 }
262
263 /// The surface's size, in logical pixels — an **override**.
264 ///
265 /// [`Scaffold::new`] already takes it from [`MediaQuery::of`]. This is for the two
266 /// cases that are not the whole screen: a shell laid into a sub-region of one, and
267 /// a test that would rather state a size than install a description for it.
268 pub fn size(mut self, width: f32, height: f32) -> Self {
269 self.width = width;
270 self.height = height;
271 self
272 }
273
274 /// The **permanent** safe area (system bars, notch): the Scaffold keeps the slots
275 /// clear of it.
276 ///
277 /// This is the padding half of [`WindowInsets`]. To let the Scaffold lift its
278 /// content off the soft keyboard as well, give it both halves with
279 /// [`Scaffold::window_insets`].
280 pub fn insets(mut self, insets: Insets) -> Self {
281 self.insets = insets;
282 self
283 }
284
285 /// The window insets, **both** kinds: the permanent padding and the transient
286 /// area the keyboard covers.
287 ///
288 /// The two are kept apart because only one of them may be ignored:
289 /// [`Scaffold::resize_to_avoid_bottom_inset`] decides whether the keyboard pushes
290 /// the content up, and no setting lets content sit under the navigation bar.
291 pub fn window_insets(mut self, insets: WindowInsets) -> Self {
292 self.insets = insets.padding;
293 self.view_insets = insets.view_insets;
294 self.view_padding = insets.view_padding;
295 self
296 }
297
298 /// Whether the body is lifted clear of the soft keyboard (`true` by default).
299 ///
300 /// Set it to `false` when the screen would rather be covered than shortened — a
301 /// full-bleed image or a map, where losing half the height is worse than losing
302 /// the bottom of it. The permanent insets are unaffected: content never goes
303 /// under the navigation bar.
304 pub fn resize_to_avoid_bottom_inset(mut self, resize: bool) -> Self {
305 self.resize_to_avoid_bottom_inset = resize;
306 self
307 }
308
309 /// Lets the body run **under** the bottom bar and the persistent footer rather
310 /// than stopping above them.
311 ///
312 /// For a bar that is translucent or notched, so that what scrolls past is seen
313 /// through it. The body's own content then needs bottom padding of its own, or
314 /// its last row hides behind the bar.
315 pub fn extend_body(mut self, extend: bool) -> Self {
316 self.extend_body = extend;
317 self
318 }
319
320 /// Lets the body run **under** the app bar, its top aligned with the top of the
321 /// screen rather than with the bottom of the bar.
322 ///
323 /// For a translucent bar over an image or a hero header. Same warning as
324 /// [`Scaffold::extend_body`]: the content behind the bar is the body's business.
325 pub fn extend_body_behind_app_bar(mut self, extend: bool) -> Self {
326 self.extend_body_behind_app_bar = extend;
327 self
328 }
329
330 /// Background color, spread edge to edge, including under the system bars.
331 pub fn background(mut self, color: Color) -> Self {
332 self.background = Some(color);
333 self
334 }
335
336 /// The application bar, pinned at the top.
337 pub fn app_bar(mut self, widget: impl Widget<Msg> + 'static) -> Self {
338 self.app_bar = Some(Box::new(widget));
339 self
340 }
341
342 /// The screen's body: it **fills** the space between the bars.
343 ///
344 /// It does **not** scroll. A body that may be taller than the room it is given
345 /// goes inside a scrolling widget the screen chooses — [`SingleChildScrollView`](crate::SingleChildScrollView)
346 /// for a page that occasionally overflows, [`ListView`](crate::ListView) for a long one:
347 ///
348 /// ```ignore
349 /// .body(SingleChildScrollView::new().flex(1.0).child(form))
350 /// ```
351 ///
352 /// Left plain, the body is positioned at the top of that room, so a body that
353 /// wants all of it says so — `.flex(1.0)`, or a `Flex` that centres its content.
354 ///
355 /// **The room includes the system's intrusions when nothing else holds them off.**
356 /// With a bottom bar or a footer below it, they keep the body clear of the gesture
357 /// bar; with neither, the body reaches the screen's edge and is *told* the intrusion
358 /// is there. A body whose content must not sit under it wraps in
359 /// [`SafeArea`](crate::SafeArea):
360 ///
361 /// ```ignore
362 /// .body(SafeArea::new(form))
363 /// ```
364 pub fn body(mut self, widget: impl Widget<Msg> + 'static) -> Self {
365 self.body = Some(Box::new(widget));
366 self
367 }
368
369 /// Enables navigation: `selected` = the active destination, `on_select(i)`
370 /// emitted on choice. Then add [`Scaffold::destination`]s.
371 ///
372 /// The destinations go in a **bottom bar** unless
373 /// [`Scaffold::nav_placement`] says otherwise.
374 pub fn nav(mut self, selected: usize, on_select: impl Fn(usize) -> Msg + 'static) -> Self {
375 self.selected = selected;
376 self.on_select = Some(Box::new(on_select));
377 self
378 }
379
380 /// Where the destinations are drawn: a bottom bar (the default) or a rail.
381 ///
382 /// Fixed either way — the scaffold will not move the navigation because the
383 /// window changed size. For that, use [`NavScaffold`](crate::NavScaffold).
384 pub fn nav_placement(mut self, placement: NavPlacement) -> Self {
385 self.nav_placement = placement;
386 self
387 }
388
389 /// **When the destinations say what they are**, whichever of the two widgets ends
390 /// up carrying them. See [`RailLabels`].
391 ///
392 /// Unsaid, each keeps the default the reference gives it: a rail shows no labels, a
393 /// bar shows them all (milestone 432).
394 pub fn nav_labels(mut self, labels: RailLabels) -> Self {
395 self.nav_labels = Some(labels);
396 self
397 }
398
399 /// **What to do to the rail** once the shell has built it from the destinations —
400 /// `.rail(|rail| rail.extended(true).group_alignment(0.0))`.
401 ///
402 /// The shell builds the navigation itself, which is what makes it a shell: an
403 /// application says `.destination("✔", "Tasks")` and never sees the widget. That
404 /// left everything a rail can do and a bar cannot — its extended form, where its
405 /// destinations sit, the slots above and below them — reachable only by building the
406 /// rail by hand and giving up the shell. This is the door: the shell builds the rail,
407 /// hands it over, and takes back whatever comes out.
408 ///
409 /// It runs **last**, after the destinations and after [`Self::nav_labels`], so it has
410 /// the final word on both. Silent when the navigation is a bottom bar, which has none
411 /// of these properties to set.
412 ///
413 /// The shell then measures the rail it was handed rather than assuming the width it
414 /// started with — an extended rail is 256 wide, and everything the shell puts beside
415 /// it has to know.
416 pub fn rail(
417 mut self,
418 configure: impl FnOnce(NavigationRail<Msg>) -> NavigationRail<Msg> + 'static,
419 ) -> Self {
420 self.rail = Some(Box::new(configure));
421 self
422 }
423
424 /// Adds a navigation destination (mark + label).
425 pub fn destination(
426 mut self,
427 icon: impl Into<crate::navrail::DestinationIcon>,
428 label: impl Into<String>,
429 ) -> Self {
430 self.destinations
431 .push(NavigationDestination::new(icon, label));
432 self
433 }
434
435 /// Adds a whole list of destinations **declared elsewhere** — the same list a rail, a
436 /// drawer or a [`NavScaffold`](crate::NavScaffold) would take, so an application
437 /// describes its navigation once.
438 ///
439 /// ```
440 /// use frus_widgets::{Icons, NavigationDestination, Scaffold};
441 ///
442 /// let places = vec![
443 /// NavigationDestination::new(Icons::FAVORITE_BORDER, "Saved")
444 /// .selected_icon(Icons::FAVORITE),
445 /// NavigationDestination::new(Icons::MAIL_OUTLINE, "Inbox").badge(3),
446 /// ];
447 /// let _shell = Scaffold::new()
448 /// .body(frus_widgets::text("…"))
449 /// .nav(0, |i| i)
450 /// .destinations(places)
451 /// .build();
452 /// ```
453 #[must_use]
454 pub fn destinations(
455 mut self,
456 destinations: impl IntoIterator<Item = NavigationDestination>,
457 ) -> Self {
458 self.destinations.extend(destinations);
459 self
460 }
461
462 /// What a pointer resting on the **last** destination is told. See
463 /// [`NavigationRail::tooltip`].
464 #[must_use]
465 pub fn tooltip(self, message: impl Into<String>) -> Self {
466 let message = message.into();
467 self.decorate(move |last| last.tooltip = Some(message))
468 }
469
470 /// A notification count on the **last** destination.
471 pub fn badge(self, count: u32) -> Self {
472 self.decorate(|last| last.badge = Some(count))
473 }
474
475 /// The glyph the **last** destination shows while it is selected, where that differs
476 /// from its resting one. See [`NavigationRail::selected_icon`].
477 pub fn selected_icon(self, icon: impl Into<crate::navrail::DestinationIcon>) -> Self {
478 let icon = icon.into();
479 self.decorate(move |last| last.selected_icon = Some(icon))
480 }
481
482 /// Marks the **last** destination inaccessible. See [`NavigationRail::disabled`].
483 pub fn disabled(self) -> Self {
484 self.decorate(|last| last.disabled = true)
485 }
486
487 /// The **last** destination's own indicator colour, over the theme's. See
488 /// [`NavigationRail::indicator_color`].
489 pub fn indicator_color(self, color: Color) -> Self {
490 self.decorate(move |last| last.indicator_color = Some(color))
491 }
492
493 /// Applies `f` to the destination just added. Silent when there is none.
494 fn decorate(mut self, f: impl FnOnce(&mut NavigationDestination)) -> Self {
495 if let Some(last) = self.destinations.last_mut() {
496 f(last);
497 }
498 self
499 }
500
501 /// A modal side drawer on the **leading** edge (left): `panel` = the content,
502 /// `open` = expanded, `toggle` = the toggle message (the button, and a click on
503 /// the scrim).
504 ///
505 /// The usual home of the navigation on a phone. A screen may have this one and
506 /// [`Scaffold::end_drawer`] at once — they are different drawers with different
507 /// jobs, not a choice of side.
508 pub fn drawer(mut self, panel: impl Widget<Msg> + 'static, open: bool, toggle: Msg) -> Self {
509 self.drawer = Some((Box::new(panel), open, toggle));
510 self
511 }
512
513 /// A modal side drawer (right edge): `panel` = the content, `open` = expanded,
514 /// `toggle` = the toggle message (the button, and a click on the scrim).
515 pub fn end_drawer(
516 mut self,
517 panel: impl Widget<Msg> + 'static,
518 open: bool,
519 toggle: Msg,
520 ) -> Self {
521 self.end_drawer = Some((Box::new(panel), open, toggle));
522 self
523 }
524
525 /// A floating action button, anchored bottom-right, above the bottom bar.
526 ///
527 /// It rides in a full-screen `Stack` layer over the shell. That layer does not
528 /// swallow what is under it: only a widget that asks for clicks is registered as
529 /// a target, so the transparent remainder of the layer is not one. (This carried
530 /// an "experimental — intercepts the bottom half of the screen" warning for a
531 /// long time; a test now says otherwise.)
532 pub fn fab(mut self, widget: impl Widget<Msg> + 'static) -> Self {
533 self.fab = Some(Box::new(widget));
534 self
535 }
536
537 /// A **bottom app bar** in place of the navigation one: the actions belonging to
538 /// the screen you are on, rather than a choice of screen.
539 ///
540 /// Taken by its own type, not as an opaque widget, so that the scaffold can cut
541 /// the bar's notch once it knows where it is putting the FAB. A docked button and
542 /// a bar that does not know about it would sit on top of each other.
543 ///
544 /// A scaffold given both this and [`Scaffold::destination`]s keeps the navigation
545 /// — being able to leave the screen outranks the actions on it.
546 pub fn bottom_app_bar(mut self, bar: BottomAppBar<Msg>) -> Self {
547 self.bottom_app_bar = Some(bar);
548 self
549 }
550
551 /// Where the FAB sits: which end of the row, and whether it floats clear of the
552 /// bottom bar or docks astride its top edge. [`FabLocation::EndFloat`] by default.
553 pub fn fab_location(mut self, location: FabLocation) -> Self {
554 self.fab_location = location;
555 self
556 }
557
558 /// The height the FAB is taken to have when it is **docked**, since that
559 /// placement puts its centre on the bar's edge and so has to know how tall it is.
560 /// 56 px — the conventional diameter — unless said otherwise.
561 ///
562 /// A declared number rather than a measured one, and that is a divergence worth
563 /// naming: the scaffold is handed the button as an opaque widget it cannot
564 /// measure. A button of another size docks correctly once it says so.
565 pub fn fab_size(mut self, size: f32) -> Self {
566 self.fab_size = size;
567 self
568 }
569
570 /// A strip pinned **between the body and the bottom bar**, always visible: the
571 /// screen's committing actions (Save, Cancel), which must not be scrolled away.
572 ///
573 /// Unlike the FAB it is not an overlay — it takes its own height out of the body,
574 /// which is the point: it is part of the screen, not floating over it.
575 pub fn persistent_footer(mut self, widget: impl Widget<Msg> + 'static) -> Self {
576 self.persistent_footer = Some(Box::new(widget));
577 self
578 }
579
580 /// How the persistent footer is aligned in its row ([`Justify::End`] by default,
581 /// the conventional place for a confirming action).
582 pub fn persistent_footer_alignment(mut self, alignment: Justify) -> Self {
583 self.persistent_footer_alignment = alignment;
584 self
585 }
586
587 /// A modal sheet sliding up from the bottom.
588 pub fn bottom_sheet(
589 mut self,
590 panel: impl Widget<Msg> + 'static,
591 open: bool,
592 toggle: Msg,
593 ) -> Self {
594 self.bottom_sheet = Some((Box::new(panel), open, toggle));
595 self
596 }
597
598 /// Whether the shell sits at the **top of the screen**, so that its app bar is the
599 /// thing that absorbs the status bar. `true` by default.
600 ///
601 /// `false` says something else is above it — a shell nested in a page, a second one
602 /// beside the first — and the app bar then takes only its own height, with the top
603 /// intrusion left to whatever is actually up there.
604 ///
605 /// **This is both halves of the reference's switch.** There, `Scaffold.primary` makes
606 /// the slot tall enough and `AppBar.primary` is what actually pads, the bar wrapping
607 /// itself in a `SafeArea` — a split that works because its slots are built lazily
608 /// under a description the shell controls. Ours are handed a widget that is already
609 /// built, so the shell insets the slot from outside and there is one switch, not two.
610 /// Milestone 394 records what a builder-based slot would change about that.
611 pub fn primary(mut self, primary: bool) -> Self {
612 self.primary = primary;
613 self
614 }
615
616 /// The line along the **top of the persistent footer**. Drawn by default, as the
617 /// reference draws it — its footer is a container decorated with a one-pixel top
618 /// border unless the caller replaces the decoration.
619 ///
620 /// The line is a [`Divider`](crate::Divider), so its colour and its thickness follow
621 /// the theme like every other one, and a caller who wants neither says `false`.
622 pub fn persistent_footer_divider(mut self, divider: bool) -> Self {
623 self.persistent_footer_divider = divider;
624 self
625 }
626
627 /// The background behind the persistent footer. Unset, it is the shell's own — the
628 /// footer is part of the screen, not a bar laid on it.
629 pub fn persistent_footer_color(mut self, color: Color) -> Self {
630 self.persistent_footer_color = Some(color);
631 self
632 }
633
634 /// The scrim behind an open drawer, **alpha included**: the transparency of a scrim
635 /// *is* its colour, so [`Color::TRANSPARENT`] darkens nothing and an opaque value
636 /// hides the screen behind it.
637 ///
638 /// Both drawers take it, as the reference's single `drawerScrimColor` does.
639 pub fn drawer_scrim_color(mut self, color: Color) -> Self {
640 self.drawer_scrim_color = Some(color);
641 self
642 }
643
644 /// Whether a click on the scrim closes the drawer. `true` by default, and the
645 /// reference's `drawerBarrierDismissible` is the same switch.
646 ///
647 /// `false` is for a drawer holding something that must be answered — the way out is
648 /// then a control inside the panel, and the screen behind stays unreachable.
649 pub fn drawer_barrier_dismissible(mut self, dismissible: bool) -> Self {
650 self.drawer_barrier_dismissible = dismissible;
651 self
652 }
653
654 /// Assembles the shell into a widget ready to display.
655 pub fn build(self) -> Box<dyn Widget<Msg>> {
656 let Scaffold {
657 width,
658 height,
659 insets,
660 background,
661 app_bar,
662 body,
663 selected,
664 on_select,
665 destinations,
666 nav_placement,
667 nav_labels,
668 rail: configure_rail,
669 drawer,
670 end_drawer,
671 bottom_app_bar,
672 fab,
673 fab_location,
674 fab_size,
675 persistent_footer,
676 persistent_footer_alignment,
677 persistent_footer_divider,
678 persistent_footer_color,
679 bottom_sheet,
680 primary,
681 drawer_scrim_color,
682 drawer_barrier_dismissible,
683 view_insets,
684 view_padding,
685 resize_to_avoid_bottom_inset,
686 extend_body,
687 extend_body_behind_app_bar,
688 } = self;
689
690 // Where the navigation goes is what the caller asked for, and nothing else.
691 // This used to be `SizeClass::from_width(width)`, which is how a phone
692 // turned to landscape moved its own navigation (milestone 305).
693 let rail_nav = nav_placement == NavPlacement::Rail;
694 let bg = background.unwrap_or(Color::TRANSPARENT);
695 let has_nav = !destinations.is_empty();
696 let body_widget = body.unwrap_or_else(|| Box::new(Container::new()));
697
698 // Navigation: a bottom bar, or a side rail if one was asked for.
699 //
700 // **How wide the rail came out**, which is not a constant since milestone 433: a
701 // caller can extend it, and everything the shell puts beside it — the persistent
702 // footer's row, for one — is laid out against this number.
703 let mut rail_width = 0.0;
704 let nav: Option<Box<dyn Widget<Msg>>> = if has_nav {
705 let on_select =
706 on_select.expect("nav(selected, on_select) is required with destinations");
707 if !rail_nav {
708 let mut bar = BottomBar::new(selected, on_select);
709 for destination in &destinations {
710 bar = bar.destination(destination.clone());
711 }
712 if let Some(labels) = nav_labels {
713 bar = bar.labels(labels);
714 }
715 Some(Box::new(bar))
716 } else {
717 let mut rail = NavigationRail::new(selected, on_select);
718 for destination in &destinations {
719 rail = rail.destination(destination.clone());
720 }
721 if let Some(labels) = nav_labels {
722 rail = rail.labels(labels);
723 }
724 // The caller's word is the last one, and the measurement is taken after
725 // it rather than before.
726 if let Some(configure) = configure_rail {
727 rail = configure(rail);
728 }
729 rail_width = rail.declared_width();
730 Some(Box::new(rail))
731 }
732 } else {
733 None
734 };
735
736 // Where the button's centre falls, in the window's own coordinates. Needed
737 // before the bottom bar is built, because a notched bar is cut around it.
738 let fab_centre_x = match fab_location.justify() {
739 Justify::Start => insets.left + FAB_MARGIN + fab_size / 2.0,
740 Justify::Center => width / 2.0,
741 _ => width - insets.right - FAB_MARGIN - fab_size / 2.0,
742 };
743
744 // A bottom app bar takes the bottom slot when there is no navigation in it —
745 // being able to leave the screen outranks the actions on it. Its notch is cut
746 // here, since the scaffold is the only party that knows both positions.
747 let mut bottom_bar_height = 0.0;
748 let nav: Option<Box<dyn Widget<Msg>>> = match (nav, bottom_app_bar) {
749 (Some(nav), _) => Some(nav),
750 (None, Some(bar)) => {
751 bottom_bar_height = bar.declared_height();
752 let bar = if fab.is_some() && fab_location.docked() {
753 // In the bar's own coordinates, which are the window's: since
754 // milestone 418 the bottom slot spans the full width and consumes
755 // the side intrusions itself, so its origin is no longer held off
756 // by `insets.left` and the notch is no longer moved back by it.
757 bar.notched_at(fab_centre_x, fab_size / 2.0)
758 } else {
759 bar
760 };
761 Some(Box::new(bar))
762 }
763 (None, None) => None,
764 };
765 let has_nav = has_nav || bottom_bar_height > 0.0;
766
767 // How far the bottom-most slot is held off the edge. The keyboard is the only
768 // inset a screen may decline: `view_insets.bottom` measures the occlusion from
769 // the window edge, bar included, so the two combine with `max` and never add up.
770 // Declining, the screen has said the keyboard is an **overlay** — so the
771 // geometry it wants is the keyboard-free one. `insets.bottom` is zero while the
772 // keyboard covers the navigation bar (the bar being covered, nothing has to
773 // avoid it), and reading it here would drop the bottom bar and the floating
774 // button onto the window's edge the moment a field was tapped, then lift them
775 // back when the keyboard closed. `view_padding` is the intrusion that does not
776 // move, which is exactly what "ignore the keyboard" means.
777 //
778 // It is worked out here, above the footer, because the footer is one of the
779 // slots it falls to (milestone 419).
780 let bottom_clear = if resize_to_avoid_bottom_inset {
781 insets.bottom.max(view_insets.bottom)
782 } else {
783 view_padding.bottom
784 };
785 // Whether something below the footer already holds the edge off. A rail is
786 // beside the body, not beneath it, so it holds nothing off.
787 let bar_below_body = has_nav && !rail_nav;
788 // How far the bottom bar reaches up, which the body needs when it is told to run
789 // under it and the floating button needs to sit on its edge.
790 let nav_h = if bottom_bar_height > 0.0 {
791 bottom_bar_height
792 } else if !rail_nav && has_nav {
793 BAR_HEIGHT
794 } else {
795 0.0
796 };
797
798 // The persistent footer: its own row, aligned as asked, kept clear of the side
799 // insets. It sits between the body and the bottom bar and never scrolls.
800 let footer: Option<Box<dyn Widget<Msg>>> = persistent_footer.map(|widget| {
801 // The row is **given** the width it has to fill. A row that hugged its
802 // content would leave the alignment nothing to distribute, and every
803 // footer would sit at the leading edge whatever it was asked for.
804 let row_width =
805 (width - insets.left - insets.right - rail_width - FOOTER_PAD * 2.0).max(0.0);
806 let row = Flex::row()
807 .width(row_width)
808 .justify(persistent_footer_alignment)
809 .child(widget);
810 // **The decoration outside, the safe area inside.** The reference's footer
811 // is a decorated container — background, and a one-pixel top border unless
812 // the caller replaces it — wrapping a `SafeArea(top: false)`
813 // (`scaffold.dart:3133`). So the line and the background run the full width
814 // of the screen and the *content* is what keeps clear of the intrusions; a
815 // border inset by the notch would be a rule that stops short of the edge it
816 // is ruling off.
817 //
818 // It is a **real safe area** since milestone 419, not a padding worked out
819 // here, and that is what fixed the bottom: this used to pass zero, so a
820 // footer with nothing under it left its buttons sitting on the gesture bar.
821 // The shell holds the clearance for whatever is bottom-most, and with a
822 // footer there, that is the footer.
823 let mut stack = Flex::column();
824 if persistent_footer_divider {
825 stack = stack.child(crate::Divider::new());
826 }
827 stack = stack.child(
828 crate::SafeArea::new(Container::new().padding(FOOTER_PAD).child(row))
829 .edges(crate::Edges::ALL.without_top()),
830 );
831 let mut decorated = Container::new().child(stack);
832 if let Some(color) = persistent_footer_color {
833 decorated = decorated.color(color);
834 }
835 // The description the slot is handed: the top removed, the sides as the
836 // shell resolved them, and the bottom only when nothing below it holds the
837 // edge off — which is what the reference's `removeBottomPadding:
838 // bottomNavigationBar != null` says (`scaffold.dart:3158`).
839 // Beside a rail the leading intrusion is the rail's — it is inside the
840 // rail's own box, and the footer sits in the column to its right (milestone
841 // 420). `row_width` above already reads it that way: subtracting the rail's
842 // bare width *and* `insets.left` is the same number as subtracting the rail's
843 // box, which is the two added together since the rail took the intrusion.
844 let left = if rail_nav { 0.0 } else { insets.left };
845 let right = insets.right;
846 let bottom = if bar_below_body { 0.0 } else { bottom_clear };
847 Box::new(crate::MediaScope::tweak(
848 move |mq: &mut crate::MediaQuery| {
849 mq.padding.top = 0.0;
850 mq.padding.left = left;
851 mq.padding.right = right;
852 mq.padding.bottom = bottom;
853 },
854 decorated,
855 )) as Box<dyn Widget<Msg>>
856 });
857
858 // **The body is told, not padded** (milestone 421), the last of the four slots.
859 // The shell used to apply the side intrusions to its content and tell it nothing,
860 // so a `SafeArea` inside a body read the **ambient** description and padded for
861 // intrusions that had already been dealt with — the status bar twice over under an
862 // app bar, and the sides twice over anywhere.
863 //
864 // The reference lays its body out full width and hands the slot a description
865 // (`scaffold.dart:3019`): the sides kept, the top removed when there is an app bar,
866 // the bottom removed when something below it holds the edge off, and the keyboard
867 // removed when the layout has already shortened the body for it.
868 //
869 // Which is also to say: **a body that wants the notch avoided says so.** The slot
870 // is now told the truth about its edges, and `SafeArea` is what reads it.
871 //
872 // It is still **not** wrapped in a scroller: a pane rather than a viewport —
873 // whether this screen scrolls is the screen's to say, and it says it by what it
874 // puts here.
875 let bar_over_body = extend_body_behind_app_bar && app_bar.is_some();
876 // The top is the app bar's when there is one in front of the body. Behind it
877 // (`extend_body_behind_app_bar`) the body faces the status bar itself again, which
878 // is the `max(padding.top, appBarHeight)` half of the reference's `_BodyBuilder`
879 // (`scaffold.dart:973`) — the bar's own height is not in it yet.
880 let body_top = if app_bar.is_some() && !bar_over_body {
881 0.0
882 } else {
883 insets.top
884 };
885 // And the bottom, which changed hands at milestone 423. The shell shortens the
886 // body for the **keyboard** and for the widgets below it, **never for the gesture
887 // bar**: the reference's `minInsets.bottom` is `resize ? viewInsets.bottom : 0`
888 // (`scaffold.dart:3220`), so a body with nothing under it reaches the screen's edge
889 // and is **told** what is there. With a bar or a footer under it they hold the edge
890 // off and the body is told nothing, which is the reference's `removeBottomPadding:
891 // bottomNavigationBar != null || persistentFooterButtons != null`.
892 //
893 // Told to run **under** them, it faces the further of two things: the intrusion, or
894 // how far the slots it runs under reach. That is `_BodyBuilder`'s
895 // `max(padding.bottom, bottomWidgetsHeight)` (`scaffold.dart:969`) — with the
896 // footer's own height still missing from the second term, since nothing measures it.
897 let below_body = bar_below_body || footer.is_some();
898 let body_bottom = if extend_body {
899 insets.bottom.max(nav_h)
900 } else if below_body {
901 0.0
902 } else {
903 insets.bottom
904 };
905 let (body_left, body_right) = (insets.left, insets.right);
906 let body_keyboard = resize_to_avoid_bottom_inset;
907 let body_pane = Flex::column().flex(1.0).child(crate::MediaScope::tweak(
908 move |mq: &mut crate::MediaQuery| {
909 mq.padding.top = body_top;
910 mq.padding.left = body_left;
911 mq.padding.right = body_right;
912 mq.padding.bottom = body_bottom;
913 // `removeBottomInset: _resizeToAvoidBottomInset` — the column has already
914 // shortened the body for the keyboard, so there is nothing left to avoid.
915 if body_keyboard {
916 mq.view_insets.bottom = 0.0;
917 }
918 },
919 body_widget,
920 ));
921
922 // Whether the bottom clearance falls to the body — and **which** clearance.
923 //
924 // The keyboard, and nothing else (milestone 423). It is taken as a **sibling**
925 // rather than as padding inside the body: the room the body is given shrinks, so a
926 // scrolling body scrolls within what is left instead of running under the keyboard.
927 // The gesture bar is *not* here any more; it reaches the body as a description, and
928 // a body that wants to be held clear of it says `SafeArea` — which is what the
929 // reference does, `minInsets.bottom` being the keyboard alone.
930 //
931 // With a bar or a footer below it they hold the edge off, and a body told to run
932 // under them has asked for the room.
933 let body_owns_bottom = !extend_body && footer.is_none() && !bar_below_body;
934 let keyboard_clear = if resize_to_avoid_bottom_inset {
935 view_insets.bottom
936 } else {
937 0.0
938 };
939 let body_spacer = body_owns_bottom && keyboard_clear > 0.0;
940
941 // Which slots the body must make room for, and which it runs under. A slot that
942 // is extended behind moves out of the body's column and into an overlay layer
943 // drawn on top of it — the same widget either way, in one place or the other.
944 //
945 // The bottom slots only move on a **compact** layout, where the bottom bar
946 // actually is one. Wide, the navigation is a rail beside the body and the
947 // overlay would span the rail as well as the content; a footer there stays in
948 // the column, which is what it should do anyway when nothing is under it.
949 let bottom_over_body = extend_body && !rail_nav && (footer.is_some() || nav.is_some());
950 // **Only a primary shell absorbs the status bar.** One nested in a page, or
951 // sitting beside another, takes the bar's own height and leaves the top
952 // intrusion to whatever is actually above it. The reference computes the same
953 // thing in the same place: `primary ? MediaQuery.paddingOf(context).top : 0.0`.
954 // The slot is handed a **description**, not a padding (milestone 417). An
955 // `AppBar` consumes the intrusion itself when it is `primary`, exactly as the
956 // reference's does — the shell's job is to say what there is to consume.
957 //
958 // It says it even when the answer is "the same as outside", because the shell's
959 // idea of its intrusions is not always the ambient one: `Scaffold::insets` is an
960 // explicit override, and a slot told nothing would read past it to the surface and
961 // pad by something the shell had already decided against. The left is per call —
962 // beside a rail, the rail has taken it.
963 let bar_top = if primary { insets.top } else { 0.0 };
964 let bar_right = insets.right;
965 let app_bar_pad = move |bar: Box<dyn Widget<Msg>>, left: f32| -> Box<dyn Widget<Msg>> {
966 Box::new(crate::MediaScope::tweak(
967 move |mq: &mut crate::MediaQuery| {
968 mq.padding.top = bar_top;
969 mq.padding.left = left;
970 mq.padding.right = bar_right;
971 },
972 bar,
973 ))
974 };
975 // **The bottom slot is told, not padded** (milestone 418) — the same split
976 // milestone 417 made at the top of the screen. The reference hands this slot a
977 // description with the **top** intrusion removed and the bottom one left in
978 // (`scaffold.dart:3167`), and the bar consumes what it is told about, inside its
979 // own surface: `NavigationBar` wraps its row in a safe area and leaves the
980 // `Material` outside it (`navigation_bar.dart:285`), and so does `BottomAppBar`
981 // (`bottom_app_bar.dart:230`). That is what makes a bar's background run behind
982 // the gesture bar instead of stopping short above it, with a strip of the
983 // scaffold showing through — which is what a padding from outside produced.
984 //
985 // `bottom_clear` is already the number the reference arrives at through
986 // `maintainBottomViewPadding: !resizeToAvoidBottomInset`, worked out above.
987 let nav_left = insets.left;
988 let nav_right = insets.right;
989 let nav_pad = move |n: Box<dyn Widget<Msg>>| -> Box<dyn Widget<Msg>> {
990 Box::new(crate::MediaScope::tweak(
991 move |mq: &mut crate::MediaQuery| {
992 mq.padding.top = 0.0;
993 mq.padding.left = nav_left;
994 mq.padding.right = nav_right;
995 mq.padding.bottom = bottom_clear;
996 },
997 n,
998 ))
999 };
1000
1001 // The pinned shell: app bar · body · footer · (bottom bar | rail).
1002 let mut app_bar = app_bar;
1003 let mut footer = footer;
1004 let mut nav = nav;
1005 let main: Box<dyn Widget<Msg>> = if !rail_nav {
1006 let mut col = Flex::column().width(width).height(height);
1007 if !bar_over_body {
1008 if let Some(bar) = app_bar.take() {
1009 col = col.child(app_bar_pad(bar, insets.left));
1010 }
1011 }
1012 col = col.child(body_pane);
1013 if body_spacer {
1014 col = col.child(Container::new().height(keyboard_clear));
1015 }
1016 if !bottom_over_body {
1017 if let Some(f) = footer.take() {
1018 col = col.child(f);
1019 }
1020 if let Some(n) = nav.take() {
1021 col = col.child(nav_pad(n));
1022 }
1023 }
1024 Box::new(col)
1025 } else {
1026 let mut row = Flex::row().width(width).height(height);
1027 if let Some(n) = nav.take() {
1028 // The rail is a sibling, never an overlay: `extend_body` speaks of the
1029 // bottom bar, and a body sliding under a side rail is nobody's design.
1030 //
1031 // Told, not padded (milestone 420): the trailing side is the body's, so
1032 // it is removed; the leading side, the top and the bottom are the rail's
1033 // to consume, which is the set the reference's own safe area takes
1034 // (`navigation_rail.dart:556`). The rule down the rail's edge then runs
1035 // the full height of the screen instead of stopping at the notch.
1036 let (rail_top, rail_left) = (insets.top, insets.left);
1037 row = row.child(crate::MediaScope::tweak(
1038 move |mq: &mut crate::MediaQuery| {
1039 mq.padding.top = rail_top;
1040 mq.padding.right = 0.0;
1041 mq.padding.left = rail_left;
1042 mq.padding.bottom = bottom_clear;
1043 },
1044 n,
1045 ));
1046 }
1047 let mut content = Flex::column().flex(1.0);
1048 if !bar_over_body {
1049 if let Some(bar) = app_bar.take() {
1050 content = content.child(app_bar_pad(bar, 0.0));
1051 }
1052 }
1053 content = content.child(body_pane);
1054 if body_spacer {
1055 content = content.child(Container::new().height(keyboard_clear));
1056 }
1057 if !bottom_over_body {
1058 if let Some(f) = footer.take() {
1059 content = content.child(f);
1060 }
1061 }
1062 row = row.child(content);
1063 Box::new(row)
1064 };
1065
1066 // Whatever the body was told to run under is drawn over it, in a layer of its
1067 // own: the bar at the top, the footer and the bar at the bottom, a spring
1068 // between. Nothing here needs a measurement — the spring takes what is left.
1069 let main: Box<dyn Widget<Msg>> = if app_bar.is_some() || footer.is_some() || nav.is_some() {
1070 let mut over = Flex::column().width(width).height(height);
1071 if let Some(bar) = app_bar {
1072 over = over.child(app_bar_pad(bar, insets.left));
1073 }
1074 over = over.child(Container::new().flex(1.0));
1075 if let Some(f) = footer {
1076 over = over.child(f);
1077 }
1078 if let Some(n) = nav {
1079 over = over.child(nav_pad(n));
1080 }
1081 Box::new(
1082 Stack::new()
1083 .width(width)
1084 .height(height)
1085 .layer(main)
1086 .layer(over),
1087 )
1088 } else {
1089 main
1090 };
1091
1092 // The FAB, in a layer of its own over the shell, at the corner it was given.
1093 let mut content: Box<dyn Widget<Msg>> = main;
1094 if let Some(fab) = fab {
1095 // Where the body stops: the top of the bottom bar, which is what both
1096 // vertical placements are measured from.
1097 let content_bottom = bottom_clear + nav_h;
1098 let fab_bottom = if fab_location.docked() {
1099 // Docked: the button's **centre** on that edge, straddling the bar.
1100 (content_bottom - fab_size / 2.0).max(0.0)
1101 } else {
1102 // Floating: clear of the edge by the usual margin.
1103 content_bottom + FAB_MARGIN
1104 };
1105 let (left_pad, right_pad) = match fab_location.justify() {
1106 Justify::Start => (insets.left + FAB_MARGIN, 0.0),
1107 Justify::End => (0.0, insets.right + FAB_MARGIN),
1108 _ => (0.0, 0.0),
1109 };
1110 let fab_layer = Flex::column()
1111 .width(width)
1112 .height(height)
1113 .justify(Justify::End)
1114 .child(
1115 Flex::row().justify(fab_location.justify()).child(
1116 Container::new()
1117 .padding_each(0.0, right_pad, fab_bottom, left_pad)
1118 .child(fab),
1119 ),
1120 );
1121 content = Box::new(
1122 Stack::new()
1123 .width(width)
1124 .height(height)
1125 .layer(content)
1126 .layer(fab_layer),
1127 );
1128 }
1129
1130 // What this shell knows, for the slots that cannot see it (milestone 422). Taken
1131 // before the drawers are consumed below, since the message is what a bar needs.
1132 let mut info = crate::ScaffoldInfo::default();
1133 if let Some((_, _, toggle)) = &drawer {
1134 info = info.with_drawer(toggle.clone());
1135 }
1136 if let Some((_, _, toggle)) = &end_drawer {
1137 info = info.with_end_drawer(toggle.clone());
1138 }
1139
1140 // The modal drawers, then the modal sheet, wrap the shell as overlays. The
1141 // leading drawer goes on first so that the trailing one, wrapping it, is the
1142 // outer layer — with both open the end drawer is the one on top, which is the
1143 // one the user opened last.
1144 let dress = |mut side: crate::Drawer<Msg>, toggle: Msg| {
1145 if drawer_barrier_dismissible {
1146 side = side.on_dismiss(toggle);
1147 }
1148 if let Some(color) = drawer_scrim_color {
1149 side = side.scrim_color(color);
1150 }
1151 side
1152 };
1153 if let Some((panel, open, toggle)) = drawer {
1154 content = Box::new(
1155 dress(crate::Drawer::new(open), toggle)
1156 .panel(panel)
1157 .body(content),
1158 );
1159 }
1160 if let Some((panel, open, toggle)) = end_drawer {
1161 content = Box::new(
1162 dress(crate::Drawer::new(open).right(), toggle)
1163 .panel(panel)
1164 .body(content),
1165 );
1166 }
1167 if let Some((panel, open, toggle)) = bottom_sheet {
1168 content = Box::new(
1169 crate::BottomSheet::new(open)
1170 .on_dismiss(toggle)
1171 .sheet(panel)
1172 .body(content),
1173 );
1174 }
1175
1176 // A full-window background (edge to edge) giving the slots a definite size.
1177 let shell: Box<dyn Widget<Msg>> = Box::new(
1178 Container::new()
1179 .width(width)
1180 .height(height)
1181 .color(bg)
1182 .child(content),
1183 );
1184 // **And the shell says what it is** (milestone 422). Its slots were handed to it
1185 // already built and cannot see the screen they stand on; this is how a bar with no
1186 // leading of its own learns that there is a drawer, and what opens it. It wraps the
1187 // whole shell rather than the app-bar slot alone, because the reference's
1188 // `Scaffold.of` is readable from anywhere below the scaffold and not only from the
1189 // bar (`scaffold.dart:3232`).
1190 if info.has_drawer() || info.has_end_drawer() {
1191 Box::new(crate::ScaffoldScope::new(info, shell))
1192 } else {
1193 shell
1194 }
1195 }
1196}
1197
1198/// The floating action button the [`Scaffold`] examples show: **one character**, round,
1199/// wired to a message.
1200///
1201/// It is a two-line shorthand for [`FloatingActionButton`](crate::FloatingActionButton),
1202/// which is the widget and where every property lives. Until milestone 464 it was not a
1203/// shorthand for anything: it returned a filled [`Button`](crate::Button), so a screen's
1204/// most prominent control took `primary` on `on_primary` where the reference's floating
1205/// action button takes `primary_container` on `on_primary_container`.
1206///
1207/// **Round**, and not only because the convention is round: a docked button sits in the
1208/// circular notch a [`BottomAppBar`](crate::BottomAppBar) cuts for it, and the reference's
1209/// own sixteen-pixel corner would leave four of them hanging over the bar. A stadium
1210/// rather than `FAB_SIZE / 2`, which is the same number written out and only right while
1211/// the button is exactly that tall.
1212pub fn fab_button<Msg: Clone + 'static>(
1213 label: impl Into<String>,
1214 message: Msg,
1215) -> crate::FloatingActionButton<Msg> {
1216 crate::FloatingActionButton::glyph(label)
1217 .shape(frus_core::ShapeBorder::stadium())
1218 .on_press(message)
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223 use super::*;
1224 // Named by the tests alone since milestone 434: the shell asks the rail how wide it
1225 // came out rather than reading the constant, because a rail can be extended.
1226 use crate::navrail::{EXTENDED_RAIL_WIDTH, RAIL_WIDTH};
1227 use crate::{build_ui, dsl::button, dsl::text, Runtime, Size, Theme};
1228
1229 #[derive(Clone, Debug, PartialEq)]
1230 enum Msg {
1231 Go(usize),
1232 Drawer,
1233 Add,
1234 }
1235
1236 /// **The shell takes its size and its intrusions from the surface, unasked.**
1237 ///
1238 /// Milestone 393: nothing passes a `Scaffold` the screen. It reads the description
1239 /// the framework installed, which is where the size, the notch and the bars all
1240 /// already are. The same shell built under two different surfaces comes out at two
1241 /// different sizes, and neither call said a number.
1242 #[test]
1243 fn the_shell_takes_its_size_and_its_intrusions_from_the_surface() {
1244 let bars = WindowInsets::bars(Insets::new(40.0, 0.0, 30.0, 0.0));
1245 let build = |size: Size| {
1246 let surface = MediaQuery::new(size).with_insets(bars);
1247 let tree = surface.scope(|| {
1248 Scaffold::<Msg>::new()
1249 // A real bar, because since milestone 417 the shell hands this slot a
1250 // *description* rather than a padding: an `AppBar` consumes the status
1251 // bar itself, as the reference's does, and a bare box in this slot is a
1252 // bare box that does not.
1253 .app_bar(
1254 crate::AppBar::<Msg>::new()
1255 .title(crate::Text::new("bar"))
1256 .height(56.0)
1257 .build(),
1258 )
1259 .body(Container::new().flex(1.0).child(text("body")))
1260 .build()
1261 });
1262 build_ui(tree.as_ref(), size, &Runtime::default(), &Theme::default())
1263 };
1264 // The **app bar**, which is the slot the intrusion is the shell's to handle.
1265 // A `Scaffold` does not pad its body for the status bar and neither does the
1266 // reference's — `contentTop` there is the app bar's height, zero without one,
1267 // and a body that wants the notch avoided says `SafeArea` itself.
1268 let top_of_body = |ui: &crate::Ui<Msg>| {
1269 ui.scene()
1270 .primitives()
1271 .iter()
1272 .find_map(|p| match p {
1273 crate::Primitive::Text { position, .. } => Some(position.y),
1274 _ => None,
1275 })
1276 .expect("the body is drawn")
1277 };
1278 let phone = build(Size::new(400.0, 800.0));
1279 let tablet = build(Size::new(1000.0, 700.0));
1280 // Kept clear of the status bar on both, without being told there was one.
1281 assert!(top_of_body(&phone) >= 40.0, "the phone runs under the bar");
1282 assert!(
1283 top_of_body(&tablet) >= 40.0,
1284 "the tablet runs under the bar"
1285 );
1286 // And the shell really did read two different surfaces.
1287 let widest = |ui: &crate::Ui<Msg>| {
1288 ui.scene()
1289 .primitives()
1290 .iter()
1291 .filter_map(|p| match p {
1292 crate::Primitive::Rect { rect, .. } => Some(rect.width),
1293 _ => None,
1294 })
1295 .fold(0.0_f32, f32::max)
1296 };
1297 assert!(
1298 widest(&tablet) > widest(&phone) + 500.0,
1299 "the same code, two surfaces, two widths"
1300 );
1301 }
1302
1303 fn scaffold(width: f32, height: f32) -> Box<dyn Widget<Msg>> {
1304 Scaffold::new()
1305 .size(width, height)
1306 .insets(Insets::new(40.0, 0.0, 30.0, 0.0))
1307 .background(Color::rgb(0.1, 0.1, 0.1))
1308 .app_bar(text("Title").size(20.0))
1309 .body(text("Body").size(16.0))
1310 .nav(0, Msg::Go)
1311 .destination("H", "Home")
1312 .destination("S", "Stats")
1313 .end_drawer(text("Menu"), false, Msg::Drawer)
1314 .fab(button("+", Msg::Add))
1315 .build()
1316 }
1317
1318 #[test]
1319 fn assembles_at_compact_and_expanded_without_panic() {
1320 for w in [400.0_f32, 1000.0_f32] {
1321 let s = scaffold(w, 800.0);
1322 let ui = build_ui(
1323 s.as_ref(),
1324 Size::new(w, 800.0),
1325 &Runtime::default(),
1326 &Theme::default(),
1327 );
1328 assert!(
1329 !ui.scene().primitives().is_empty(),
1330 "empty scene for width={w}"
1331 );
1332 }
1333 }
1334
1335 /// A colour no theme uses, so the marked slot can be picked out of the scene.
1336 const MARK: Color = Color {
1337 r: 1.0,
1338 g: 0.0,
1339 b: 1.0,
1340 a: 1.0,
1341 };
1342 const W: f32 = 400.0;
1343 const H: f32 = 800.0;
1344
1345 /// A block painted in [`MARK`], to stand for a slot's content.
1346 fn marked<M: Clone + 'static>(height: f32) -> Container<M> {
1347 Container::new().height(height).color(MARK)
1348 }
1349
1350 /// A body that **fills** the room it is given, which is how that room is measured.
1351 ///
1352 /// Before milestone 321 these tests passed an over-tall block and read the *clip*
1353 /// instead: the body was wrapped in a scroller, so what showed of an oversized block
1354 /// was the viewport. Now that the scaffold places the body rather than scrolling it,
1355 /// nothing clips, and an over-tall block would simply paint past the bottom of the
1356 /// screen and measure nothing. Asking to fill is both the honest measurement and the
1357 /// documented way for a body to take all of its room.
1358 fn filling<M: Clone + 'static>() -> Container<M> {
1359 Container::new().flex(1.0).color(MARK)
1360 }
1361
1362 /// Every marked rectangle in the assembled scaffold, top to bottom, **as seen**:
1363 /// clipped to the box it was drawn in.
1364 fn marks(scaffold: Box<dyn Widget<Msg>>) -> Vec<frus_core::Rect> {
1365 let ui = build_ui(
1366 scaffold.as_ref(),
1367 Size::new(W, H),
1368 &Runtime::default(),
1369 &Theme::default(),
1370 );
1371 let mut found: Vec<frus_core::Rect> = ui
1372 .scene()
1373 .primitives()
1374 .iter()
1375 .filter_map(|p| match p {
1376 frus_core::Primitive::Rect {
1377 rect, color, clip, ..
1378 } if *color == MARK => Some(rect.intersect(*clip)),
1379 _ => None,
1380 })
1381 .collect();
1382 found.sort_by(|a, b| a.y.total_cmp(&b.y));
1383 found
1384 }
1385
1386 /// The window insets of a phone with a keyboard up: bars top and bottom, and the
1387 /// keyboard occluding 300 px from the window's edge.
1388 fn keyboard_up() -> WindowInsets {
1389 WindowInsets::from_baseline(
1390 Insets::new(40.0, 0.0, 30.0, 0.0),
1391 Insets::new(40.0, 0.0, 300.0, 0.0),
1392 )
1393 }
1394
1395 /// The body is lifted clear of the keyboard — and only if it is asked to be.
1396 #[test]
1397 fn the_keyboard_shortens_the_body_unless_the_screen_declines() {
1398 let lifted = marks(
1399 Scaffold::new()
1400 .size(W, H)
1401 .window_insets(keyboard_up())
1402 .body(filling())
1403 .build(),
1404 );
1405 assert_eq!(lifted.len(), 1);
1406 assert!(
1407 (lifted[0].y + lifted[0].height - (H - 300.0)).abs() < 1.0,
1408 "the body must stop at the keyboard: {:?}",
1409 lifted[0]
1410 );
1411
1412 let covered = marks(
1413 Scaffold::new()
1414 .size(W, H)
1415 .window_insets(keyboard_up())
1416 .resize_to_avoid_bottom_inset(false)
1417 .body(filling())
1418 .build(),
1419 );
1420 // Declined: the screen has said the keyboard is an **overlay**, so nothing
1421 // shortens the body at all and it keeps the whole window — `minInsets.bottom` is
1422 // zero when `resizeToAvoidBottomInset` is false, and the permanent bottom bar was
1423 // never in that number (milestone 423). It reaches the body as a description, and
1424 // a body that wants to be clear of it says `SafeArea`.
1425 assert!(
1426 (covered[0].y + covered[0].height - H).abs() < 1.0,
1427 "the body must run under the keyboard: {:?}",
1428 covered[0]
1429 );
1430 }
1431
1432 /// Whether the finger finds anything to scroll, asked of the registry the gesture
1433 /// arena actually consults.
1434 fn scrollable_under_the_middle(scaffold: Box<dyn Widget<Msg>>) -> bool {
1435 build_ui(
1436 scaffold.as_ref(),
1437 Size::new(W, H),
1438 &Runtime::default(),
1439 &Theme::default(),
1440 )
1441 .scroll_hit(frus_core::Point::new(W / 2.0, H / 2.0))
1442 .is_some()
1443 }
1444
1445 /// **The body is not a scroller.** A screen with nothing to scroll must offer the
1446 /// gesture arena nothing to scroll, and a screen that wants to scroll says so.
1447 ///
1448 /// This is the test the device asked for. A shell that wrapped every body registered
1449 /// a scrollable area on every screen, so a page whose content fitted still answered
1450 /// the finger — and, before milestone 316 taught the arena to decline, still lit an
1451 /// end-of-list glow on a page that had no end to reach. Fixing the arena stopped the
1452 /// glow; this stops the phantom scroller that fed it.
1453 #[test]
1454 fn a_body_that_does_not_ask_to_scroll_is_not_scrollable() {
1455 assert!(
1456 !scrollable_under_the_middle(Scaffold::new().size(W, H).body(filling()).build()),
1457 "a plain body must not register a scrollable area"
1458 );
1459 assert!(
1460 !scrollable_under_the_middle(Scaffold::new().size(W, H).body(marked(2000.0)).build()),
1461 "not even one taller than the screen — overflowing is not the same as scrolling"
1462 );
1463 assert!(
1464 scrollable_under_the_middle(
1465 Scaffold::new()
1466 .size(W, H)
1467 .body(
1468 crate::SingleChildScrollView::new()
1469 .flex(1.0)
1470 .child(marked::<Msg>(2000.0))
1471 )
1472 .build()
1473 ),
1474 "and a body that asks for a scroller gets one"
1475 );
1476 }
1477
1478 /// **A body alone reaches the screen's edge, and is told what is there** (milestone
1479 /// 423).
1480 ///
1481 /// The reference shortens its body for the keyboard and for the widgets below it,
1482 /// **never for the gesture bar**: `minInsets.bottom` is `resize ? viewInsets.bottom : 0`
1483 /// (`scaffold.dart:3220`). So a plain body runs to the edge — which is what a
1484 /// background, a hero image, or a list that should scroll *under* the gesture bar
1485 /// wants — and a body that must be held clear of it says `SafeArea`.
1486 #[test]
1487 fn a_body_alone_reaches_the_edge_and_is_told_what_is_there() {
1488 const BOTTOM: f32 = 30.0;
1489 let size = Size::new(W, H);
1490 let surface = MediaQuery::new(size)
1491 .with_insets(WindowInsets::bars(Insets::new(40.0, 0.0, BOTTOM, 0.0)));
1492
1493 // Nothing below it and nothing asked for: it is the whole screen.
1494 let bare = marked_under(surface, size, || {
1495 Scaffold::new().size(W, H).body(filling::<Msg>()).build()
1496 });
1497 assert!(
1498 (bare.y + bare.height - H).abs() < 1.0,
1499 "the body stopped short of the edge: {bare:?}"
1500 );
1501
1502 // And a body that must be clear of the gesture bar says so, and is answered —
1503 // which it could not be before, the description having said the bottom was
1504 // already dealt with.
1505 let asked = marked_under(surface, size, || {
1506 Scaffold::new()
1507 .size(W, H)
1508 .body(crate::SafeArea::new(filling::<Msg>()))
1509 .build()
1510 });
1511 assert!(
1512 (asked.y + asked.height - (H - BOTTOM)).abs() < 1.0,
1513 "a body that asked to be held clear of the gesture bar was not: {asked:?}"
1514 );
1515 }
1516
1517 /// `extend_body` is the difference between stopping above the bar and running
1518 /// under it; the bar is drawn on top either way.
1519 #[test]
1520 fn an_extended_body_runs_under_the_bottom_bar() {
1521 let stops = marks(
1522 Scaffold::new()
1523 .size(W, H)
1524 .body(filling())
1525 .nav(0, Msg::Go)
1526 .destination("H", "Home")
1527 .build(),
1528 );
1529 let runs_under = marks(
1530 Scaffold::new()
1531 .size(W, H)
1532 .extend_body(true)
1533 .body(filling())
1534 .nav(0, Msg::Go)
1535 .destination("H", "Home")
1536 .build(),
1537 );
1538 assert!(
1539 (runs_under[0].height - H).abs() < 1.0,
1540 "an extended body takes the whole height: {:?}",
1541 runs_under[0]
1542 );
1543 assert!(
1544 runs_under[0].height > stops[0].height + BAR_HEIGHT - 1.0,
1545 "it must gain the bar's height: {:?} vs {:?}",
1546 runs_under[0],
1547 stops[0]
1548 );
1549 }
1550
1551 /// `extend_body_behind_app_bar` aligns the body with the top of the screen rather
1552 /// than with the bottom of the bar.
1553 #[test]
1554 fn an_extended_body_starts_above_the_app_bar() {
1555 let below = marks(
1556 Scaffold::new()
1557 .size(W, H)
1558 .insets(Insets::new(40.0, 0.0, 0.0, 0.0))
1559 .app_bar(crate::AppBar::<Msg>::new().height(56.0).build())
1560 .body(marked(200.0))
1561 .build(),
1562 );
1563 assert!(below[0].y >= 96.0, "below the bar: {:?}", below[0]);
1564
1565 let behind = marks(
1566 Scaffold::new()
1567 .size(W, H)
1568 .insets(Insets::new(40.0, 0.0, 0.0, 0.0))
1569 .extend_body_behind_app_bar(true)
1570 .app_bar(crate::AppBar::<Msg>::new().height(56.0).build())
1571 .body(marked(200.0))
1572 .build(),
1573 );
1574 assert!(
1575 behind[0].y < 1.0,
1576 "behind the bar, from the very top: {:?}",
1577 behind[0]
1578 );
1579 }
1580
1581 /// The footer is between the body and the bar, and it does not scroll away: it
1582 /// takes its height out of the body rather than floating over it.
1583 #[test]
1584 fn the_persistent_footer_sits_between_the_body_and_the_bar() {
1585 let rects = marks(
1586 Scaffold::new()
1587 .size(W, H)
1588 .body(filling())
1589 .persistent_footer(marked::<Msg>(40.0).width(100.0))
1590 .nav(0, Msg::Go)
1591 .destination("H", "Home")
1592 .build(),
1593 );
1594 assert_eq!(rects.len(), 2, "body and footer: {rects:?}");
1595 let (body, footer) = (rects[0], rects[1]);
1596 assert!(
1597 footer.y >= body.y + body.height - 1.0,
1598 "the footer is below the body, not over it: {rects:?}"
1599 );
1600 assert!(
1601 footer.y + footer.height <= H - BAR_HEIGHT + 1.0,
1602 "and above the bottom bar: {rects:?}"
1603 );
1604 }
1605
1606 /// The footer's alignment is honoured, which means the row it sits in must be
1607 /// **given** its width — a row that hugged its content would put every footer at
1608 /// the leading edge whatever it was asked for.
1609 #[test]
1610 fn the_persistent_footer_is_aligned_where_it_was_asked_to_be() {
1611 let at = |alignment| {
1612 marks(
1613 Scaffold::new()
1614 .size(W, H)
1615 .body(Container::<Msg>::new())
1616 .persistent_footer_alignment(alignment)
1617 .persistent_footer(marked::<Msg>(40.0).width(100.0))
1618 .build(),
1619 )[0]
1620 .x
1621 };
1622 assert!(at(Justify::Start) < 20.0, "start: {}", at(Justify::Start));
1623 assert!(at(Justify::End) > W - 120.0, "end: {}", at(Justify::End));
1624 let centre = at(Justify::Center);
1625 assert!((centre - (W - 100.0) / 2.0).abs() < 2.0, "centre: {centre}");
1626 }
1627
1628 /// The FAB goes to the end it was given, and a docked one straddles the bar's
1629 /// top edge instead of floating clear of it.
1630 #[test]
1631 fn the_fab_goes_where_it_was_placed() {
1632 let at = |location| {
1633 let scaffold = Scaffold::new()
1634 .size(W, H)
1635 .body(Container::<Msg>::new())
1636 .nav(0, Msg::Go)
1637 .destination("H", "Home")
1638 .fab_location(location)
1639 .fab_size(56.0)
1640 .fab(marked::<Msg>(56.0).width(56.0))
1641 .build();
1642 marks(scaffold)[0]
1643 };
1644
1645 // Horizontally: the three ends, in order and distinct.
1646 let start = at(FabLocation::StartFloat);
1647 let centre = at(FabLocation::CenterFloat);
1648 let end = at(FabLocation::EndFloat);
1649 assert!(
1650 start.x < centre.x && centre.x < end.x,
1651 "{start:?} {centre:?} {end:?}"
1652 );
1653 assert!(
1654 (start.x - FAB_MARGIN).abs() < 1.0,
1655 "leading margin: {start:?}"
1656 );
1657 assert!(
1658 (end.x + end.width - (W - FAB_MARGIN)).abs() < 1.0,
1659 "trailing margin: {end:?}"
1660 );
1661 assert!(
1662 (centre.x + centre.width / 2.0 - W / 2.0).abs() < 1.0,
1663 "centred: {centre:?}"
1664 );
1665
1666 // Vertically: floating clears the bar; docked has its centre on the bar's edge.
1667 let bar_top = H - BAR_HEIGHT;
1668 assert!(
1669 (end.y + end.height - (bar_top - FAB_MARGIN)).abs() < 1.0,
1670 "floating clear of the bar: {end:?}"
1671 );
1672 let docked = at(FabLocation::EndDocked);
1673 assert!(
1674 (docked.y + docked.height / 2.0 - bar_top).abs() < 1.0,
1675 "centred on the bar's top edge: {docked:?}"
1676 );
1677 }
1678
1679 /// A bottom app bar takes the bottom slot, the docked button lands on its top
1680 /// edge, and the bar is cut to receive it — the three facts that have to agree.
1681 #[test]
1682 fn a_docked_button_is_received_by_the_bar_it_sits_on() {
1683 use crate::BottomAppBar;
1684 let bar_height = 64.0;
1685 let scaffold = Scaffold::new()
1686 .size(W, H)
1687 .body(Container::<Msg>::new())
1688 .bottom_app_bar(BottomAppBar::new().height(bar_height).color(MARK))
1689 .fab_location(FabLocation::EndDocked)
1690 .fab_size(56.0)
1691 .fab(Container::<Msg>::new().width(56.0).height(56.0))
1692 .build();
1693 let ui = build_ui(
1694 scaffold.as_ref(),
1695 Size::new(W, H),
1696 &Runtime::default(),
1697 &Theme::default(),
1698 );
1699 // The bar paints itself as a **path**, not a rectangle: that is the notch.
1700 let outline = ui
1701 .scene()
1702 .primitives()
1703 .iter()
1704 .find_map(|p| match p {
1705 frus_core::Primitive::Path { path, fill, .. } if *fill == Some(MARK) => {
1706 Some(path.clone())
1707 }
1708 _ => None,
1709 })
1710 .expect("a notched bar paints an outline");
1711
1712 let bar_top = H - bar_height;
1713 let fab_centre_x = W - FAB_MARGIN - 28.0;
1714 // No node of the outline intrudes into the button's circle.
1715 for verb in outline.verbs() {
1716 let p = match verb {
1717 frus_core::PathVerb::MoveTo(p) | frus_core::PathVerb::LineTo(p) => *p,
1718 frus_core::PathVerb::QuadTo { to, .. }
1719 | frus_core::PathVerb::CubicTo { to, .. } => *to,
1720 frus_core::PathVerb::Close => continue,
1721 };
1722 let d = ((p.x - fab_centre_x).powi(2) + (p.y - bar_top).powi(2)).sqrt();
1723 assert!(d >= 27.9, "the bar cuts into the button: {p:?} ({d})");
1724 }
1725 }
1726
1727 /// The first marked rectangle of a scaffold built under `surface`.
1728 fn marked_under(
1729 surface: MediaQuery,
1730 size: Size,
1731 build: impl FnOnce() -> Box<dyn Widget<Msg>>,
1732 ) -> frus_core::Rect {
1733 let ui = surface.scope(|| {
1734 let tree = build();
1735 build_ui(tree.as_ref(), size, &Runtime::default(), &Theme::default())
1736 });
1737 ui.scene()
1738 .primitives()
1739 .iter()
1740 .find_map(|p| match p {
1741 frus_core::Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
1742 _ => None,
1743 })
1744 .expect("the body is drawn")
1745 }
1746
1747 /// Every message a click anywhere in `tree` would emit, the deferred subtrees built
1748 /// first — an app bar is composed there, which is the whole point here.
1749 fn click_messages(tree: &dyn Widget<Msg>) -> Vec<Msg> {
1750 crate::build_deferred(tree, &Theme::default(), &crate::Runtime::default());
1751 fn walk(widget: &dyn Widget<Msg>, out: &mut Vec<Msg>) {
1752 if let Some(message) = widget.on_click() {
1753 out.push(message);
1754 }
1755 for child in widget.children() {
1756 walk(child.as_ref(), out);
1757 }
1758 }
1759 let mut found = Vec::new();
1760 walk(tree, &mut found);
1761 found
1762 }
1763
1764 /// **What the shell knows and the bar does not** (milestone 422).
1765 ///
1766 /// An `AppBar` is handed to its `Scaffold` already built, so it cannot see the screen
1767 /// it is about to stand on. The reference's reads it from the context: a bar with no
1768 /// `leading` of its own, on a screen that has a drawer, grows the button that opens it
1769 /// (`app_bar.dart:1010`), and one with nothing at its trailing end grows the button for
1770 /// an end drawer (`app_bar.dart:1113`).
1771 ///
1772 /// Counted rather than searched for, because a closed drawer has a dismiss target of
1773 /// its own carrying the same message: what this asserts is **one more** click that
1774 /// opens the drawer, which is the button.
1775 #[test]
1776 fn a_bar_grows_the_buttons_for_drawers_it_could_not_see() {
1777 let bar = |leading: bool, actions: bool| {
1778 let tree = Scaffold::new()
1779 .size(W, H)
1780 .body(Container::<Msg>::new())
1781 .drawer(text("Menu"), false, Msg::Drawer)
1782 .end_drawer(text("Filters"), false, Msg::Add)
1783 .app_bar(
1784 crate::AppBar::<Msg>::new()
1785 .title(crate::Text::new("Title"))
1786 .automatically_imply_leading(leading)
1787 .automatically_imply_actions(actions)
1788 .build(),
1789 )
1790 .build();
1791 click_messages(tree.as_ref())
1792 };
1793 let count = |found: &[Msg], wanted: &Msg| found.iter().filter(|m| *m == wanted).count();
1794
1795 let implied = bar(true, true);
1796 let asked_not_to = bar(false, false);
1797 assert_eq!(
1798 count(&implied, &Msg::Drawer),
1799 count(&asked_not_to, &Msg::Drawer) + 1,
1800 "the bar did not grow the button for the drawer it stands over"
1801 );
1802 assert_eq!(
1803 count(&implied, &Msg::Add),
1804 count(&asked_not_to, &Msg::Add) + 1,
1805 "the bar did not grow the button for the end drawer"
1806 );
1807 }
1808
1809 /// And it implies nothing where there is nothing to imply, or where the caller has
1810 /// already filled the slot. The implied button **fills an empty end**; it is never
1811 /// added beside what the bar was given.
1812 #[test]
1813 fn a_bar_implies_a_button_only_into_an_empty_slot() {
1814 let without_a_drawer = click_messages(
1815 Scaffold::new()
1816 .size(W, H)
1817 .body(Container::<Msg>::new())
1818 .app_bar(
1819 crate::AppBar::<Msg>::new()
1820 .title(crate::Text::new("Title"))
1821 .build(),
1822 )
1823 .build()
1824 .as_ref(),
1825 );
1826 assert!(
1827 !without_a_drawer.contains(&Msg::Drawer),
1828 "a bar on a screen with no drawer grew a button for one"
1829 );
1830
1831 // A leading of its own, and an action of its own: neither slot is empty, so
1832 // neither is filled. The drawer's own dismiss target is the only `Drawer` left,
1833 // and the caller's own action is the only `Go(1)`.
1834 let filled = click_messages(
1835 Scaffold::new()
1836 .size(W, H)
1837 .body(Container::<Msg>::new())
1838 .drawer(text("Menu"), false, Msg::Drawer)
1839 .end_drawer(text("Filters"), false, Msg::Add)
1840 .app_bar(
1841 crate::AppBar::<Msg>::new()
1842 .title(crate::Text::new("Title"))
1843 .leading(button("Back", Msg::Go(1)))
1844 .action("Save", Msg::Go(2))
1845 .build(),
1846 )
1847 .build()
1848 .as_ref(),
1849 );
1850 let bare = click_messages(
1851 Scaffold::new()
1852 .size(W, H)
1853 .body(Container::<Msg>::new())
1854 .drawer(text("Menu"), false, Msg::Drawer)
1855 .end_drawer(text("Filters"), false, Msg::Add)
1856 .body(Container::<Msg>::new())
1857 .build()
1858 .as_ref(),
1859 );
1860 let count = |found: &[Msg], wanted: &Msg| found.iter().filter(|m| *m == wanted).count();
1861 assert_eq!(
1862 count(&filled, &Msg::Drawer),
1863 count(&bare, &Msg::Drawer),
1864 "a bar with its own leading grew a menu button beside it"
1865 );
1866 assert_eq!(
1867 count(&filled, &Msg::Add),
1868 count(&bare, &Msg::Add),
1869 "a bar with its own action grew an end-drawer button beside it"
1870 );
1871 }
1872
1873 /// **A `SafeArea` in the body padded for a status bar the app bar had already taken**
1874 /// (milestone 421).
1875 ///
1876 /// The shell told the body nothing, so a safe area inside it read the **ambient**
1877 /// description — the shell's whole notch — and held its content off by it a second
1878 /// time, below a bar that was already clear of it. The reference removes the top from
1879 /// that slot's description whenever there is an app bar (`scaffold.dart:3030`).
1880 #[test]
1881 fn a_safe_area_in_the_body_is_told_what_the_bar_already_took() {
1882 const TOP: f32 = 40.0;
1883 const BAR: f32 = 56.0;
1884 let size = Size::new(W, H);
1885 let surface =
1886 MediaQuery::new(size).with_insets(WindowInsets::bars(Insets::new(TOP, 0.0, 0.0, 0.0)));
1887 let top_of_content = |with_bar: bool| {
1888 marked_under(surface, size, || {
1889 let mut scaffold = Scaffold::new().size(W, H);
1890 if with_bar {
1891 scaffold = scaffold.app_bar(
1892 crate::AppBar::<Msg>::new()
1893 .title(crate::Text::new("bar"))
1894 .height(BAR)
1895 .build(),
1896 );
1897 }
1898 scaffold
1899 .body(crate::SafeArea::new(marked::<Msg>(20.0).width(20.0)))
1900 .build()
1901 })
1902 .y
1903 };
1904 // No bar: the body faces the status bar, and its safe area is what holds it off.
1905 assert!(
1906 (top_of_content(false) - TOP).abs() < 0.5,
1907 "a body alone must clear the status bar itself: {}",
1908 top_of_content(false)
1909 );
1910 // A bar: it stands `BAR + TOP` tall, having taken the notch (milestone 417), and
1911 // the body's safe area is told there is nothing left to take. Padding twice put
1912 // the content a whole notch lower.
1913 assert!(
1914 (top_of_content(true) - (BAR + TOP)).abs() < 0.5,
1915 "the body padded for a notch the bar had already taken: {}",
1916 top_of_content(true)
1917 );
1918 }
1919
1920 /// **The body is told about the cutout beside it, not padded for it** (milestone 421).
1921 ///
1922 /// The reference lays its body out full width and keeps the side intrusions in the
1923 /// description it hands the slot (`scaffold.dart:3029`). So a body that says nothing
1924 /// reaches the screen's edge — which is what a background, a hero image or a list's
1925 /// own scrollbar wants — and a body that says `SafeArea` is held clear of it.
1926 #[test]
1927 fn a_body_is_told_about_the_cutout_beside_it_rather_than_padded_for_it() {
1928 const CUTOUT: f32 = 48.0;
1929 let size = Size::new(W, H);
1930 let surface = MediaQuery::new(size)
1931 .with_insets(WindowInsets::bars(Insets::new(0.0, 0.0, 0.0, CUTOUT)));
1932 let bare = marked_under(surface, size, || {
1933 Scaffold::new()
1934 .size(W, H)
1935 .body(marked::<Msg>(20.0).width(20.0))
1936 .build()
1937 });
1938 assert!(
1939 bare.x.abs() < 0.5,
1940 "the body was padded for the cutout instead of told about it: {bare:?}"
1941 );
1942 let asked = marked_under(surface, size, || {
1943 Scaffold::new()
1944 .size(W, H)
1945 .body(crate::SafeArea::new(marked::<Msg>(20.0).width(20.0)))
1946 .build()
1947 });
1948 assert!(
1949 (asked.x - CUTOUT).abs() < 0.5,
1950 "the body was not told about the cutout: {asked:?}"
1951 );
1952 }
1953
1954 /// **A footer alone held nothing off the bottom edge** (milestone 419).
1955 ///
1956 /// The shell leaves the bottom clearance to whatever is bottom-most, and with a
1957 /// footer there it is the footer's. But the footer only ever consumed the *sides* —
1958 /// the bottom it passed itself was a literal zero — so its buttons sat on the gesture
1959 /// bar. The reference removes the bottom intrusion from that slot **only when a
1960 /// navigation bar is below it** (`scaffold.dart:3158`); with nothing below, the
1961 /// footer's own `SafeArea(top: false)` takes it.
1962 ///
1963 /// And the decoration stays outside that safe area, so the rule and the background
1964 /// still run to the screen's edge. A footer that padded its own box would be a rule
1965 /// stopping short of the edge it is ruling off.
1966 #[test]
1967 fn a_footer_holds_the_bottom_edge_off_unless_a_bar_below_it_does() {
1968 const GESTURE: f32 = 24.0;
1969 /// The footer's content, in a colour the decoration does not use.
1970 const CONTENT: Color = Color {
1971 r: 0.0,
1972 g: 1.0,
1973 b: 1.0,
1974 a: 1.0,
1975 };
1976 let size = Size::new(W, H);
1977 let footer = |with_bar: bool| {
1978 let surface = MediaQuery::new(size)
1979 .with_insets(WindowInsets::bars(Insets::new(0.0, 0.0, GESTURE, 0.0)));
1980 let ui = surface.scope(|| {
1981 let mut scaffold = Scaffold::new()
1982 .size(W, H)
1983 .body(Container::<Msg>::new().flex(1.0))
1984 .persistent_footer_color(MARK)
1985 .persistent_footer(
1986 Container::<Msg>::new()
1987 .width(100.0)
1988 .height(40.0)
1989 .color(CONTENT),
1990 );
1991 if with_bar {
1992 scaffold = scaffold.nav(0, Msg::Go).destination("H", "Home");
1993 }
1994 let tree = scaffold.build();
1995 build_ui(tree.as_ref(), size, &Runtime::default(), &Theme::default())
1996 });
1997 let find = |wanted: Color| {
1998 ui.scene()
1999 .primitives()
2000 .iter()
2001 .find_map(|p| match p {
2002 frus_core::Primitive::Rect { rect, color, .. } if *color == wanted => {
2003 Some(*rect)
2004 }
2005 _ => None,
2006 })
2007 .expect("the footer is drawn")
2008 };
2009 (find(MARK), find(CONTENT))
2010 };
2011
2012 // Alone: the background reaches the window's edge, the buttons stop above the
2013 // gesture bar.
2014 let (decoration, buttons) = footer(false);
2015 assert!(
2016 (decoration.y + decoration.height - H).abs() < 0.5,
2017 "the footer's background stops short of the edge: {decoration:?}"
2018 );
2019 assert!(
2020 buttons.y + buttons.height <= H - GESTURE + 0.5,
2021 "the footer's buttons sit on the gesture bar: {buttons:?}"
2022 );
2023
2024 // With a bar below it, the bar holds the edge off and the footer must not pad
2025 // for it a second time: its buttons stop a plain footer padding from its own
2026 // bottom edge, and no further.
2027 let (decoration, buttons) = footer(true);
2028 let gap = (decoration.y + decoration.height) - (buttons.y + buttons.height);
2029 assert!(
2030 gap <= FOOTER_PAD + 0.5,
2031 "the footer padded for an intrusion the bar below it already holds off: {gap}"
2032 );
2033 }
2034
2035 /// **The bottom slot is told, not padded** (milestone 418), and the difference is
2036 /// one you can see. The reference puts the safe area *inside* the shape that carries
2037 /// the colour (`bottom_app_bar.dart:230`), so a bar's surface runs behind the gesture
2038 /// bar and only its actions are held clear of it. Padded from outside, the surface
2039 /// stopped short of the screen's edge and a strip of the scaffold showed through
2040 /// underneath it.
2041 #[test]
2042 fn a_bottom_bar_s_surface_runs_behind_the_gesture_bar() {
2043 use crate::BottomAppBar;
2044 const GESTURE: f32 = 24.0;
2045 const BAR: f32 = 64.0;
2046 let size = Size::new(W, H);
2047 let surface = MediaQuery::new(size)
2048 .with_insets(WindowInsets::bars(Insets::new(0.0, 0.0, GESTURE, 0.0)));
2049 let ui = surface.scope(|| {
2050 let tree = Scaffold::new()
2051 .size(W, H)
2052 .body(Container::<Msg>::new())
2053 .bottom_app_bar(
2054 BottomAppBar::new()
2055 .height(BAR)
2056 .padding(0.0)
2057 .color(MARK)
2058 .child(marked::<Msg>(20.0)),
2059 )
2060 .build();
2061 build_ui(tree.as_ref(), size, &Runtime::default(), &Theme::default())
2062 });
2063 let mut rects: Vec<frus_core::Rect> = ui
2064 .scene()
2065 .primitives()
2066 .iter()
2067 .filter_map(|p| match p {
2068 frus_core::Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
2069 _ => None,
2070 })
2071 .collect();
2072 rects.sort_by(|a, b| a.height.total_cmp(&b.height));
2073 let (content, bar) = (rects[0], rects[rects.len() - 1]);
2074 // The surface: it took the intrusion into itself and reaches the window's edge.
2075 assert!(
2076 (bar.height - (BAR + GESTURE)).abs() < 0.5,
2077 "the bar did not consume what it was told about: {bar:?}"
2078 );
2079 assert!(
2080 (bar.y + bar.height - H).abs() < 0.5,
2081 "the bar's surface stops short of the edge: {bar:?}"
2082 );
2083 // Its content: inside that surface, clear of the gesture bar.
2084 assert!(
2085 content.y + content.height <= H - GESTURE + 0.5,
2086 "the actions run under the gesture bar: {content:?}"
2087 );
2088 }
2089
2090 /// The notch is cut in the **bar's own** coordinates, and since milestone 418 those
2091 /// are the window's: the bottom slot spans the full width and consumes the side
2092 /// intrusions itself, so its box is no longer held off by the left one and the notch
2093 /// must no longer be moved back by it. With no side intrusion the two readings agree
2094 /// and the mistake hides, which is why this test puts a cutout down one edge.
2095 #[test]
2096 fn a_notch_stays_under_its_button_beside_a_cutout() {
2097 use crate::BottomAppBar;
2098 const CUTOUT: f32 = 48.0;
2099 const BAR: f32 = 64.0;
2100 const FAB: f32 = 56.0;
2101 let size = Size::new(W, H);
2102 let surface = MediaQuery::new(size)
2103 .with_insets(WindowInsets::bars(Insets::new(0.0, 0.0, 0.0, CUTOUT)));
2104 let ui = surface.scope(|| {
2105 let tree = Scaffold::new()
2106 .size(W, H)
2107 .body(Container::<Msg>::new())
2108 .bottom_app_bar(BottomAppBar::new().height(BAR).color(MARK))
2109 .fab_location(FabLocation::StartDocked)
2110 .fab_size(FAB)
2111 .fab(Container::<Msg>::new().width(FAB).height(FAB))
2112 .build();
2113 build_ui(tree.as_ref(), size, &Runtime::default(), &Theme::default())
2114 });
2115 let outline = ui
2116 .scene()
2117 .primitives()
2118 .iter()
2119 .find_map(|p| match p {
2120 frus_core::Primitive::Path { path, fill, .. } if *fill == Some(MARK) => {
2121 Some(path.clone())
2122 }
2123 _ => None,
2124 })
2125 .expect("a notched bar paints an outline");
2126 // Where the button is: past the cutout, then the usual margin.
2127 let fab_centre_x = CUTOUT + FAB_MARGIN + FAB / 2.0;
2128 let bar_top = H - BAR;
2129 // Every node the notch put below the top edge belongs to the notch, so every one
2130 // of them is within the notch's own reach of the button's centre. Cut at the old
2131 // place these sit a whole cutout away.
2132 let mut dipped = 0;
2133 for verb in outline.verbs() {
2134 let p = match verb {
2135 frus_core::PathVerb::MoveTo(p) | frus_core::PathVerb::LineTo(p) => *p,
2136 frus_core::PathVerb::QuadTo { to, .. }
2137 | frus_core::PathVerb::CubicTo { to, .. } => *to,
2138 frus_core::PathVerb::Close => continue,
2139 };
2140 if p.y > bar_top + 1.0 && p.y < bar_top + BAR - 1.0 {
2141 dipped += 1;
2142 assert!(
2143 (p.x - fab_centre_x).abs() <= FAB,
2144 "the notch was cut away from its button: {p:?} (centre {fab_centre_x})"
2145 );
2146 }
2147 }
2148 assert!(dipped > 0, "no notch was cut at all");
2149 }
2150
2151 /// Two drawers, two edges, and a screen may have both.
2152 /// **Only a primary shell absorbs the status bar.**
2153 ///
2154 /// The reference's `primary` decides whether the app bar's height is the bar's own
2155 /// or the bar's plus the top intrusion (`scaffold.dart:3049`). A shell nested in a
2156 /// page has something else above it, and adding the notch there would inset for it
2157 /// twice. Same shell, same surface, one switch, and the bar's title moves by exactly
2158 /// the intrusion.
2159 ///
2160 /// Since milestone 417 the switch works the way the reference's does — through the
2161 /// **description** the slot is handed, not through a padding applied from outside. A
2162 /// non-primary shell tells its bar's subtree that there is no status bar; the bar,
2163 /// which is what actually pads, then has nothing to pad by.
2164 #[test]
2165 fn only_a_primary_shell_absorbs_the_status_bar() {
2166 const TOP: f32 = 40.0;
2167 let top_of_bar = |primary: bool| {
2168 let size = Size::new(400.0, 800.0);
2169 let surface = MediaQuery::new(size)
2170 .with_insets(WindowInsets::bars(Insets::new(TOP, 0.0, 0.0, 0.0)));
2171 let tree = surface.scope(|| {
2172 Scaffold::<Msg>::new()
2173 .primary(primary)
2174 .app_bar(
2175 crate::AppBar::<Msg>::new()
2176 .title(crate::Text::new("bar"))
2177 .height(56.0)
2178 .build(),
2179 )
2180 .body(Container::new().flex(1.0))
2181 .build()
2182 });
2183 let ui = build_ui(tree.as_ref(), size, &Runtime::default(), &Theme::default());
2184 ui.scene()
2185 .primitives()
2186 .iter()
2187 .find_map(|p| match p {
2188 crate::Primitive::Text { position, .. } => Some(position.y),
2189 _ => None,
2190 })
2191 .expect("the bar is drawn")
2192 };
2193 let inset = top_of_bar(true) - top_of_bar(false);
2194 assert!(
2195 (inset - TOP).abs() < 0.5,
2196 "primary should hold the bar off by exactly the intrusion, moved by {inset}"
2197 );
2198 }
2199
2200 /// **The footer carries a line along its top, and it can be taken off.**
2201 ///
2202 /// The reference's footer is a container decorated with a one-pixel top border
2203 /// (`Divider.createBorderSide`, `scaffold.dart:3136`) unless the caller replaces the
2204 /// decoration. Ours drew none until milestone 394.
2205 #[test]
2206 fn the_persistent_footer_is_ruled_off_from_the_body() {
2207 let lines = |divider: bool| {
2208 let size = Size::new(400.0, 800.0);
2209 let tree = MediaQuery::new(size).scope(|| {
2210 Scaffold::<Msg>::new()
2211 .persistent_footer_divider(divider)
2212 .persistent_footer(button("Save", Msg::Add))
2213 .body(Container::new().flex(1.0))
2214 .build()
2215 });
2216 let ui = build_ui(tree.as_ref(), size, &Runtime::default(), &Theme::default());
2217 // A divider is the only thing here a pixel tall and the width of the shell.
2218 ui.scene()
2219 .primitives()
2220 .iter()
2221 .filter(|p| {
2222 matches!(p, crate::Primitive::Rect { rect, .. }
2223 if rect.height <= 1.5 && rect.width > 300.0)
2224 })
2225 .count()
2226 };
2227 assert_eq!(lines(true), 1, "ruled off by default, as the reference is");
2228 assert_eq!(lines(false), 0, "and the caller may say no");
2229 }
2230
2231 /// **A drawer whose scrim does not dismiss it.**
2232 ///
2233 /// The reference's `drawerBarrierDismissible`. `false` is for a panel holding
2234 /// something that has to be answered: the way out is a control inside it, and the
2235 /// screen behind stays unreachable.
2236 #[test]
2237 fn a_drawer_may_refuse_to_close_on_its_scrim() {
2238 let dismisses = |dismissible: bool| {
2239 let size = Size::new(400.0, 800.0);
2240 let tree = MediaQuery::new(size).scope(|| {
2241 Scaffold::<Msg>::new()
2242 .drawer_barrier_dismissible(dismissible)
2243 .drawer(text("Menu"), true, Msg::Drawer)
2244 .body(Container::new().flex(1.0))
2245 .build()
2246 });
2247 let ui = build_ui(tree.as_ref(), size, &Runtime::default(), &Theme::default());
2248 // A click far from the panel, on the scrim.
2249 ui.hit(crate::Point::new(380.0, 400.0))
2250 .and_then(|id| ui.msg_for(id))
2251 };
2252 assert_eq!(dismisses(true), Some(Msg::Drawer), "the scrim closes it");
2253 assert_eq!(dismisses(false), None, "and here it does not");
2254 }
2255
2256 #[test]
2257 fn the_leading_drawer_opens_on_the_left() {
2258 let rects = marks(
2259 Scaffold::new()
2260 .size(W, H)
2261 .body(Container::<Msg>::new())
2262 .drawer(marked::<Msg>(100.0), true, Msg::Drawer)
2263 .end_drawer(marked::<Msg>(100.0), false, Msg::Drawer)
2264 .build(),
2265 );
2266 assert_eq!(rects.len(), 1, "only the open one is drawn: {rects:?}");
2267 assert!(rects[0].x < 1.0, "docked to the left edge: {:?}", rects[0]);
2268 }
2269
2270 /// An overlay layer — the FAB's, and now the extended body's chrome — must not
2271 /// swallow the clicks of what is under it where it draws nothing.
2272 #[test]
2273 fn an_overlay_layer_does_not_swallow_the_body_s_clicks() {
2274 let scaffold = Scaffold::new()
2275 .size(W, H)
2276 .body(button("Tap me", Msg::Add).size(16.0))
2277 .extend_body(true)
2278 .nav(0, Msg::Go)
2279 .destination("H", "Home")
2280 .fab(button("+", Msg::Add))
2281 .build();
2282 let ui = build_ui(
2283 scaffold.as_ref(),
2284 Size::new(W, H),
2285 &Runtime::default(),
2286 &Theme::default(),
2287 );
2288 let target = ui
2289 .hit(frus_core::Point::new(40.0, 20.0))
2290 .expect("the body's button must be reachable under two overlay layers");
2291 assert_eq!(ui.msg_for(target), Some(Msg::Add));
2292 }
2293
2294 /// **The shell forwards what a destination says about itself** (milestone 436). A
2295 /// property the navigation widgets have and the shell drops is a property most
2296 /// applications do not have, which is milestone 434's lesson applied one level down.
2297 #[test]
2298 fn the_shell_forwards_a_disabled_destination() {
2299 let shell = Scaffold::new()
2300 .size(W, H)
2301 .body(Container::<Msg>::new())
2302 .nav(0, Msg::Go)
2303 .destination("H", "Home")
2304 .destination("S", "Stats")
2305 .disabled()
2306 .build();
2307 let ui = build_ui(
2308 shell.as_ref(),
2309 Size::new(W, H),
2310 &Runtime::default(),
2311 &Theme::default(),
2312 );
2313 let msg_at = |x: f32| {
2314 ui.hit(frus_core::Point::new(x, H - 30.0))
2315 .and_then(|target| ui.msg_for(target))
2316 };
2317 assert_eq!(
2318 msg_at(W * 0.25),
2319 Some(Msg::Go(0)),
2320 "the live destination is where this test thinks it is"
2321 );
2322 assert_eq!(msg_at(W * 0.75), None, "and the disabled one emits nothing");
2323 }
2324
2325 /// **The shell can say what kind of rail it wants** (milestone 434).
2326 ///
2327 /// It builds the navigation itself, which is what makes it a shell — and until now
2328 /// that meant everything a rail can do and a bar cannot was reachable only by giving
2329 /// the shell up and building the rail by hand.
2330 #[test]
2331 fn the_shell_can_ask_for_an_extended_rail() {
2332 let body_x = |placement, extended: bool| {
2333 marks(
2334 Scaffold::new()
2335 .size(W, H)
2336 .body(filling::<Msg>())
2337 .nav(0, Msg::Go)
2338 .nav_placement(placement)
2339 .destination("H", "Home")
2340 .rail(move |rail| rail.extended(extended))
2341 .build(),
2342 )[0]
2343 .x
2344 };
2345 assert!(
2346 (body_x(NavPlacement::Rail, false) - RAIL_WIDTH).abs() < 0.01,
2347 "a rail is 80 wide: {}",
2348 body_x(NavPlacement::Rail, false)
2349 );
2350 assert!(
2351 (body_x(NavPlacement::Rail, true) - EXTENDED_RAIL_WIDTH).abs() < 0.01,
2352 "an extended one is 256, and the body starts after it: {}",
2353 body_x(NavPlacement::Rail, true)
2354 );
2355 assert_eq!(
2356 body_x(NavPlacement::Bottom, true),
2357 0.0,
2358 "and the door is shut when the navigation is a bar, which has none of it"
2359 );
2360 }
2361
2362 /// **What the shell puts beside a rail has to know how wide the rail came out.**
2363 ///
2364 /// The persistent footer's row is *given* its width, so that the alignment has
2365 /// something to distribute. That width was `window - RAIL_WIDTH - padding`, read off
2366 /// the constant — which is right until a caller extends the rail, and then it is 176
2367 /// pixels too wide and an end-aligned footer is pushed clean off the screen. The
2368 /// shell asks the rail it was handed instead.
2369 #[test]
2370 fn a_footer_beside_an_extended_rail_stays_on_the_screen() {
2371 let footer_x = |extended: bool| {
2372 marks(
2373 Scaffold::new()
2374 .size(W, H)
2375 .body(Container::<Msg>::new())
2376 .nav(0, Msg::Go)
2377 .nav_placement(NavPlacement::Rail)
2378 .destination("H", "Home")
2379 .persistent_footer_alignment(Justify::End)
2380 .persistent_footer(marked::<Msg>(40.0).width(100.0))
2381 .rail(move |rail| rail.extended(extended))
2382 .build(),
2383 )[0]
2384 .x
2385 };
2386 assert!(
2387 footer_x(true) + 100.0 <= W,
2388 "the footer was pushed off the screen: {}",
2389 footer_x(true)
2390 );
2391 // And it lands in the *same* place either way: the footer's row ends where the
2392 // window does, whatever the rail took off the front of it.
2393 assert!(
2394 (footer_x(true) - footer_x(false)).abs() < 0.01,
2395 "{} against {}",
2396 footer_x(true),
2397 footer_x(false)
2398 );
2399 }
2400
2401 /// The label mode reaches **whichever** of the two widgets the placement chose, and
2402 /// saying nothing leaves each of them on the default the reference gives it
2403 /// (milestone 432) rather than collapsing the two onto one answer.
2404 #[test]
2405 fn the_shell_hands_the_label_mode_on() {
2406 let says_home = |placement, labels: Option<RailLabels>| {
2407 let mut shell = Scaffold::new()
2408 .size(W, H)
2409 .body(Container::<Msg>::new())
2410 .nav(0, Msg::Go)
2411 .nav_placement(placement)
2412 .destination("H", "Home");
2413 if let Some(labels) = labels {
2414 shell = shell.nav_labels(labels);
2415 }
2416 let shell = shell.build();
2417 let ui = build_ui(
2418 shell.as_ref(),
2419 Size::new(W, H),
2420 &Runtime::default(),
2421 &Theme::default(),
2422 );
2423 ui.scene()
2424 .primitives()
2425 .iter()
2426 .any(|p| matches!(p, frus_core::Primitive::Text { text, .. } if text == "Home"))
2427 };
2428 assert!(
2429 !says_home(NavPlacement::Rail, None),
2430 "a rail says nothing until it is asked"
2431 );
2432 assert!(
2433 says_home(NavPlacement::Bottom, None),
2434 "a bar says everything"
2435 );
2436 assert!(says_home(NavPlacement::Rail, Some(RailLabels::All)));
2437 assert!(!says_home(NavPlacement::Bottom, Some(RailLabels::None)));
2438 }
2439
2440 /// Where the navigation is drawn: `(min x, max y)` of the destinations' **glyphs**.
2441 ///
2442 /// The glyph rather than the label, since milestone 432: a rail shows no labels
2443 /// unless it is asked to, so a helper that looked for them stopped finding the
2444 /// navigation it was measuring. The glyph is the one thing every destination paints
2445 /// in every mode.
2446 fn nav_extent(root: &dyn Widget<Msg>, width: f32, height: f32) -> (f32, f32) {
2447 let ui = build_ui(
2448 root,
2449 Size::new(width, height),
2450 &Runtime::default(),
2451 &Theme::default(),
2452 );
2453 let mut leftmost = f32::MAX;
2454 let mut lowest: f32 = 0.0;
2455 for primitive in ui.scene().primitives() {
2456 // The glyphs are what identify the navigation: "H" and "S" are in the
2457 // destinations and nowhere else in the test's scaffold.
2458 if let frus_core::Primitive::Text { text, position, .. } = primitive {
2459 if text == "H" || text == "S" {
2460 leftmost = leftmost.min(position.x);
2461 lowest = lowest.max(position.y);
2462 }
2463 }
2464 }
2465 assert!(leftmost < f32::MAX, "the destinations were never painted");
2466 (leftmost, lowest)
2467 }
2468
2469 /// The default, and the whole point of milestone 305: a wide window does **not**
2470 /// move the navigation. A phone turned to landscape crosses the size-class
2471 /// threshold, and its bottom bar has to stay at the bottom.
2472 #[test]
2473 fn a_wide_window_keeps_the_navigation_at_the_bottom() {
2474 // 900 × 420: landscape, comfortably past the Compact threshold.
2475 let (_, lowest) = nav_extent(scaffold(900.0, 420.0).as_ref(), 900.0, 420.0);
2476 assert!(
2477 lowest > 420.0 * 0.6,
2478 "the destinations were painted at y = {lowest}, which is not a bottom bar"
2479 );
2480 }
2481
2482 /// And the same tree at the same width, having asked for a rail, puts them against
2483 /// the leading edge instead. The behaviour is not gone — it is opted into.
2484 #[test]
2485 fn a_rail_is_drawn_when_it_is_asked_for() {
2486 let railed = Scaffold::new()
2487 .size(900.0, 420.0)
2488 .app_bar(text("Title").size(20.0))
2489 .body(text("Body").size(16.0))
2490 .nav(0, Msg::Go)
2491 .nav_placement(NavPlacement::Rail)
2492 .destination("H", "Home")
2493 .destination("S", "Stats")
2494 .build();
2495 let (leftmost, lowest) = nav_extent(railed.as_ref(), 900.0, 420.0);
2496 assert!(
2497 leftmost < RAIL_WIDTH,
2498 "a rail sits against the leading edge, not at x = {leftmost}"
2499 );
2500 assert!(
2501 lowest < 420.0 * 0.6,
2502 "a rail stacks from the top, not at y = {lowest}"
2503 );
2504 }
2505
2506 /// **The rail is told, not padded** (milestone 420).
2507 ///
2508 /// The reference keeps its safe area inside the `Material`
2509 /// (`navigation_rail.dart:553`) and takes the **leading** side, the top and the
2510 /// bottom — never the trailing one, which is where the body is. So the rail's box
2511 /// swallows the cutout, the rule down its edge runs the full height of the screen,
2512 /// and the destinations are what stays clear of the notch and the gesture bar.
2513 ///
2514 /// Padded from outside, the rail was a shorter box floated inside the intrusions and
2515 /// the rule stopped at the notch — a rule that does not reach the edge it is ruling.
2516 #[test]
2517 fn a_rail_rules_the_full_height_and_holds_its_destinations_clear() {
2518 const TOP: f32 = 40.0;
2519 const BOTTOM: f32 = 30.0;
2520 const CUTOUT: f32 = 24.0;
2521 const WIDE: f32 = 900.0;
2522 const TALL: f32 = 420.0;
2523 let size = Size::new(WIDE, TALL);
2524 let surface = MediaQuery::new(size)
2525 .with_insets(WindowInsets::bars(Insets::new(TOP, 0.0, BOTTOM, CUTOUT)));
2526 let ui = surface.scope(|| {
2527 let tree = Scaffold::new()
2528 .size(WIDE, TALL)
2529 .body(Container::<Msg>::new().flex(1.0))
2530 .nav(0, Msg::Go)
2531 .nav_placement(NavPlacement::Rail)
2532 .destination("H", "Home")
2533 .destination("S", "Stats")
2534 .build();
2535 build_ui(tree.as_ref(), size, &Runtime::default(), &Theme::default())
2536 });
2537 // The rule: the one-pixel column down the rail's trailing edge.
2538 let rule = ui
2539 .scene()
2540 .primitives()
2541 .iter()
2542 .find_map(|p| match p {
2543 frus_core::Primitive::Rect { rect, .. }
2544 if rect.width <= 1.5 && rect.height > TALL / 2.0 =>
2545 {
2546 Some(*rect)
2547 }
2548 _ => None,
2549 })
2550 .expect("the rail rules itself off from the body");
2551 assert!(
2552 rule.y.abs() < 0.5 && (rule.y + rule.height - TALL).abs() < 0.5,
2553 "the rule stops at the intrusions: {rule:?}"
2554 );
2555 assert!(
2556 (rule.x - (CUTOUT + RAIL_WIDTH - 1.0)).abs() < 0.5,
2557 "the rail did not take the cutout into its own box: {rule:?}"
2558 );
2559 // The destinations: clear of the cutout beside them and the bars above and below.
2560 let (mut left, mut top, mut bottom) = (f32::MAX, f32::MAX, f32::MIN);
2561 for p in ui.scene().primitives() {
2562 if let frus_core::Primitive::Text {
2563 position, size: em, ..
2564 } = p
2565 {
2566 left = left.min(position.x);
2567 top = top.min(position.y);
2568 bottom = bottom.max(position.y + em);
2569 }
2570 }
2571 assert!(
2572 left >= CUTOUT - 0.5,
2573 "a destination sits in the cutout: {left}"
2574 );
2575 assert!(
2576 top >= TOP - 0.5,
2577 "a destination sits under the status bar: {top}"
2578 );
2579 assert!(
2580 bottom <= TALL - BOTTOM + 0.5,
2581 "a destination sits on the gesture bar: {bottom}"
2582 );
2583 }
2584
2585 /// A narrow window with a rail asked for still gets a rail: the placement is a
2586 /// decision, not a hint the scaffold may overrule.
2587 #[test]
2588 fn a_rail_survives_a_narrow_window() {
2589 let railed = Scaffold::new()
2590 .size(360.0, 800.0)
2591 .body(text("Body").size(16.0))
2592 .nav(0, Msg::Go)
2593 .nav_placement(NavPlacement::Rail)
2594 .destination("H", "Home")
2595 .destination("S", "Stats")
2596 .build();
2597 let (leftmost, _) = nav_extent(railed.as_ref(), 360.0, 800.0);
2598 assert!(leftmost < RAIL_WIDTH, "leftmost = {leftmost}");
2599 }
2600
2601 #[test]
2602 fn compact_pins_bottom_bar_near_the_bottom() {
2603 // The bottom bar is pinned at the bottom: primitives are painted in the low
2604 // band (y ≥ 700), above the bottom inset (30), not in the middle.
2605 let s = scaffold(400.0, 800.0);
2606 let ui = build_ui(
2607 s.as_ref(),
2608 Size::new(400.0, 800.0),
2609 &Runtime::default(),
2610 &Theme::default(),
2611 );
2612 let pinned_low = ui.scene().primitives().iter().any(|p| match p {
2613 frus_core::Primitive::Rect { rect, .. } => rect.y >= 700.0 && rect.height < 100.0,
2614 frus_core::Primitive::Text { position, .. }
2615 | frus_core::Primitive::RichText { position, .. } => position.y >= 700.0,
2616 frus_core::Primitive::Path { .. }
2617 | frus_core::Primitive::Image { .. }
2618 | frus_core::Primitive::Layer { .. } => false,
2619 });
2620 assert!(pinned_low, "the bottom bar must be pinned in the low band");
2621 }
2622}