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