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