Skip to main content

frus_widgets/
scaffold.rs

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