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