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