Skip to main content

frust_widgets/
scaffold.rs

1//! Scaffold layout container: the four fixed, named chrome slots most screens
2//! assemble around — `app_bar`, `body`, `fab`, `bottom_bar` — Flutter's
3//! `Scaffold` shape, theme-agnostic (a design system's own bar/nav-bar/FAB
4//! widgets plug into the slots from app code; this crate depends on none of
5//! them).
6//!
7//! Only `body` is required ([`scaffold`]); the other three default absent and
8//! contribute nothing to layout, paint, or the accessibility tree.
9//!
10//! # R-B4-inset — a Scaffold consumes no window inset itself
11//!
12//! The `app_bar` slot is measured at its own natural height *with the
13//! status-bar inset already inside it*: a self-sizing bar widget reads
14//! `ctx.window_insets().padding().top` in its own `layout` and grows by that
15//! amount (`frust_glyph::appbar::AppBarWidget::layout` is the shipped
16//! instance). [`ScaffoldWidget`] never pre-insets this slot — doing so would
17//! double-consume the same inset. A bar that does **not** self-inset is
18//! wrapped by its own author in [`crate::safe_area`], not compensated for
19//! here. `body` is likewise never pre-insetted.
20//!
21//! **`bottom_bar` mirrors the same convention deliberately**, rather than
22//! being pre-inset by the bottom window inset the way the v1-design sketch
23//! insetted a bare `fab`: it is laid out exactly like `app_bar` (tight width,
24//! loose height) and is expected to self-size for `ctx.window_insets()
25//! .padding().bottom` the same way a self-insetting `app_bar` does for the
26//! top — one inset-consumption convention, two edges. A `bottom_bar` that
27//! does not self-inset wraps itself in `safe_area(...).bottom(true)`.
28//!
29//! `fab` is the one slot the Scaffold *does* inset on the caller's behalf,
30//! because nothing else does it and a floating action button has no natural
31//! edge to author that logic against: it is aligned within the same box
32//! `body` occupies (so `body_behind_app_bar` moves both together), inset by
33//! [`ScaffoldView::fab_margin`] on every edge, *plus* the window's bottom
34//! inset **only when no `bottom_bar` is present** — a `bottom_bar` has
35//! already consumed that inset self-sizing per the paragraph above, so
36//! adding it again would double-consume exactly the way a non-self-insetting
37//! `app_bar` would. The practical effect matches Flutter: a FAB floats above
38//! a bottom bar when both are present, and sits `fab_margin` off the raw
39//! window edge (system-inset-aware) when there is no bottom bar to define
40//! that edge instead.
41//!
42//! # Deferred past v1
43//!
44//! A drawer slot and a snackbar/toast host are **not** part of this widget —
45//! an app composes those separately (`overlay_host`, a design system's own
46//! toast/sheet widgets) rather than through the Scaffold.
47
48use frust_core::{
49    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
50    LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
51};
52use kurbo::{Point, Size};
53use peniko::Color;
54
55use crate::Alignment;
56
57/// Default [`ScaffoldView::fab_margin`] (logical px).
58///
59/// **Community-approximate**: Material's 16dp FAB screen-edge margin is a
60/// common layout convention, not an independently-verified published spec —
61/// the same value `plugins/material/src/fab_menu.rs`'s `EDGE_MARGIN` cites
62/// for the same reason.
63const DEFAULT_FAB_MARGIN: f64 = 16.0;
64
65/// A declarative scaffold. See the [module docs](self).
66pub struct ScaffoldView<State: 'static> {
67    body: AnyView<State>,
68    app_bar: Option<AnyView<State>>,
69    bottom_bar: Option<AnyView<State>>,
70    fab: Option<AnyView<State>>,
71    fab_margin: f64,
72    fab_alignment: Alignment,
73    body_behind_app_bar: bool,
74    background: Option<Color>,
75}
76
77/// Scaffold `body` under no chrome — attach `app_bar`/`bottom_bar`/`fab` with
78/// the builder methods below.
79pub fn scaffold<State: 'static, V: View<State>>(body: V) -> ScaffoldView<State> {
80    ScaffoldView {
81        body: any(body),
82        app_bar: None,
83        bottom_bar: None,
84        fab: None,
85        fab_margin: DEFAULT_FAB_MARGIN,
86        // Flutter's `endFloat` default. `Alignment` has no logical start/end
87        // concept (the framework carries none anywhere yet), so this is the
88        // physical bottom-right corner.
89        fab_alignment: Alignment::BOTTOM_RIGHT,
90        body_behind_app_bar: false,
91        background: None,
92    }
93}
94
95impl<State: 'static> ScaffoldView<State> {
96    /// Attach a top app bar. Self-sizes; see [R-B4-inset](self#r-b4-inset--a-scaffold-consumes-no-window-inset-itself).
97    pub fn app_bar(mut self, bar: AnyView<State>) -> Self {
98        self.app_bar = Some(bar);
99        self
100    }
101
102    /// Conditionally attach a top app bar — `None` clears it. The common
103    /// shape for chrome derived from route metadata that isn't always
104    /// present.
105    pub fn app_bar_opt(mut self, bar: Option<AnyView<State>>) -> Self {
106        self.app_bar = bar;
107        self
108    }
109
110    /// Attach a bottom bar. Self-sizes for the bottom window inset the same
111    /// way `app_bar` self-sizes for the top one — see the [module docs](self).
112    pub fn bottom_bar(mut self, bar: AnyView<State>) -> Self {
113        self.bottom_bar = Some(bar);
114        self
115    }
116
117    /// Conditionally attach a bottom bar — `None` clears it. Mirrors
118    /// [`ScaffoldView::app_bar_opt`].
119    pub fn bottom_bar_opt(mut self, bar: Option<AnyView<State>>) -> Self {
120        self.bottom_bar = bar;
121        self
122    }
123
124    /// Attach a floating action button.
125    pub fn fab(mut self, fab: AnyView<State>) -> Self {
126        self.fab = Some(fab);
127        self
128    }
129
130    /// The FAB's inset from every edge of its aligning box (default 16.0,
131    /// [`DEFAULT_FAB_MARGIN`]).
132    pub fn fab_margin(mut self, margin: f64) -> Self {
133        self.fab_margin = margin;
134        self
135    }
136
137    /// Where the FAB sits within its aligning box (default
138    /// [`Alignment::BOTTOM_RIGHT`]).
139    pub fn fab_alignment(mut self, alignment: Alignment) -> Self {
140        self.fab_alignment = alignment;
141        self
142    }
143
144    /// Body runs full-height under the bar (the bar floats). Default `false`
145    /// (Flutter's `extendBodyBehindAppBar`, inverted default). Does not
146    /// affect `bottom_bar` — the body always stops above one when present;
147    /// v1 ships no matching `extendBody` toggle.
148    pub fn body_behind_app_bar(mut self, enabled: bool) -> Self {
149        self.body_behind_app_bar = enabled;
150        self
151    }
152
153    /// Optional flat fill painted behind every slot. No theme read — the
154    /// Scaffold stays theme-agnostic; a themed app supplies its own
155    /// `Theme`-resolved color.
156    pub fn background(mut self, color: Color) -> Self {
157        self.background = Some(color);
158        self
159    }
160}
161
162/// The retained widget for a [`ScaffoldView`].
163pub struct ScaffoldWidget {
164    body: ChildPod,
165    app_bar: Option<ChildPod>,
166    bottom_bar: Option<ChildPod>,
167    fab: Option<ChildPod>,
168    fab_margin: f64,
169    fab_alignment: Alignment,
170    body_behind_app_bar: bool,
171    background: Option<Color>,
172}
173
174/// Reconcile one optional named slot (`app_bar`/`bottom_bar`/`fab`) the same
175/// way [`crate::SizedBoxView`]'s single optional child does: build on
176/// appearance, tear down on disappearance, reconcile in place otherwise.
177/// Shared here because the Scaffold carries three such slots.
178fn rebuild_optional_slot<State: 'static>(
179    prev: &Option<AnyView<State>>,
180    next: &Option<AnyView<State>>,
181    slot: &mut Option<ChildPod>,
182    ctx: &mut BuildCtx<'_>,
183) -> ChangeFlags {
184    match (prev, next, &mut *slot) {
185        (Some(prev_view), Some(next_view), Some(pod)) => {
186            crate::authoring::rebuild_child(prev_view, next_view, pod, ctx)
187        }
188        (None, Some(next_view), _) => {
189            *slot = Some(crate::authoring::build_child(next_view, ctx));
190            ChangeFlags::LAYOUT | ChangeFlags::PAINT
191        }
192        (Some(prev_view), None, Some(pod)) => {
193            crate::authoring::teardown_child(prev_view, pod, ctx);
194            *slot = None;
195            ChangeFlags::LAYOUT | ChangeFlags::PAINT
196        }
197        _ => ChangeFlags::NONE,
198    }
199}
200
201impl<State: 'static> View<State> for ScaffoldView<State> {
202    type Element = ScaffoldWidget;
203
204    fn build(&self, ctx: &mut BuildCtx<'_>) -> ScaffoldWidget {
205        ScaffoldWidget {
206            body: crate::authoring::build_child(&self.body, ctx),
207            app_bar: self
208                .app_bar
209                .as_ref()
210                .map(|v| crate::authoring::build_child(v, ctx)),
211            bottom_bar: self
212                .bottom_bar
213                .as_ref()
214                .map(|v| crate::authoring::build_child(v, ctx)),
215            fab: self
216                .fab
217                .as_ref()
218                .map(|v| crate::authoring::build_child(v, ctx)),
219            fab_margin: self.fab_margin,
220            fab_alignment: self.fab_alignment,
221            body_behind_app_bar: self.body_behind_app_bar,
222            background: self.background,
223        }
224    }
225
226    fn rebuild(
227        &self,
228        prev: &Self,
229        element: &mut ScaffoldWidget,
230        ctx: &mut BuildCtx<'_>,
231    ) -> ChangeFlags {
232        let mut flags = ChangeFlags::NONE;
233        flags |= crate::authoring::rebuild_child(&prev.body, &self.body, &mut element.body, ctx);
234        flags |= rebuild_optional_slot(&prev.app_bar, &self.app_bar, &mut element.app_bar, ctx);
235        flags |= rebuild_optional_slot(
236            &prev.bottom_bar,
237            &self.bottom_bar,
238            &mut element.bottom_bar,
239            ctx,
240        );
241        flags |= rebuild_optional_slot(&prev.fab, &self.fab, &mut element.fab, ctx);
242
243        if prev.fab_margin != self.fab_margin
244            || prev.fab_alignment != self.fab_alignment
245            || prev.body_behind_app_bar != self.body_behind_app_bar
246        {
247            element.fab_margin = self.fab_margin;
248            element.fab_alignment = self.fab_alignment;
249            element.body_behind_app_bar = self.body_behind_app_bar;
250            flags |= ChangeFlags::LAYOUT;
251        }
252        if prev.background != self.background {
253            element.background = self.background;
254            flags |= ChangeFlags::PAINT;
255        }
256        flags
257    }
258
259    fn teardown(&self, element: &mut ScaffoldWidget, ctx: &mut BuildCtx<'_>) {
260        crate::authoring::teardown_child(&self.body, &mut element.body, ctx);
261        if let (Some(view), Some(pod)) = (&self.app_bar, &mut element.app_bar) {
262            crate::authoring::teardown_child(view, pod, ctx);
263        }
264        if let (Some(view), Some(pod)) = (&self.bottom_bar, &mut element.bottom_bar) {
265            crate::authoring::teardown_child(view, pod, ctx);
266        }
267        if let (Some(view), Some(pod)) = (&self.fab, &mut element.fab) {
268            crate::authoring::teardown_child(view, pod, ctx);
269        }
270    }
271}
272
273/// Map an alignment component in `-1.0..=1.0` to a `0.0..=1.0` fraction of
274/// free space — the same mapping [`crate::Align`] uses internally, duplicated
275/// here rather than shared (each container keeps its own copy of small
276/// positioning predicates, `docs/CODE_STANDARDS.md`'s Interaction Semantics
277/// convention for `presses`-shaped helpers).
278fn fraction(component: f64) -> f64 {
279    (component + 1.0) / 2.0
280}
281
282impl Widget for ScaffoldWidget {
283    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
284        let width = if bc.max().width.is_finite() {
285            bc.max().width
286        } else {
287            bc.min().width
288        };
289        let bounded_height = bc.max().height.is_finite();
290        let outer_height = bc.max().height;
291
292        // (1) App bar: tight width, loose (unbounded) height — self-sizes,
293        // including any top inset it reads itself (R-B4-inset).
294        let bar_bc = BoxConstraints::new(Size::new(width, 0.0), Size::new(width, f64::INFINITY));
295        let bar_height = match &mut self.app_bar {
296            Some(pod) => {
297                let size = pod.layout_child(ctx, &bar_bc);
298                pod.set_origin(Point::ZERO);
299                size.height
300            }
301            None => 0.0,
302        };
303
304        // Bottom bar: the same shape, mirrored — self-sizes for the bottom
305        // inset (module docs). Origin is set below, once the total height is
306        // known.
307        let bottom_bar_height = match &mut self.bottom_bar {
308            Some(pod) => pod.layout_child(ctx, &bar_bc).height,
309            None => 0.0,
310        };
311
312        // (2) Body: the remaining space between the bars, full height under
313        // `body_behind_app_bar` — never behind `bottom_bar` (v1 has no
314        // matching toggle for that edge, module docs).
315        let body_top = if self.body_behind_app_bar {
316            0.0
317        } else {
318            bar_height
319        };
320        let body_bc = if bounded_height {
321            let body_height = (outer_height - body_top - bottom_bar_height).max(0.0);
322            BoxConstraints::tight(Size::new(width, body_height))
323        } else {
324            BoxConstraints::new(Size::new(width, 0.0), Size::new(width, f64::INFINITY))
325        };
326        let body_size = self.body.layout_child(ctx, &body_bc);
327        self.body.set_origin(Point::new(0.0, body_top));
328
329        let total_height = if bounded_height {
330            outer_height
331        } else {
332            body_top + body_size.height + bottom_bar_height
333        };
334
335        if let Some(pod) = &mut self.bottom_bar {
336            pod.set_origin(Point::new(0.0, (total_height - bottom_bar_height).max(0.0)));
337        }
338
339        // (3) FAB: loose, aligned within the same box the body occupies —
340        // see R-B4-inset's fab paragraph for the bottom-inset/bottom_bar
341        // interplay this implements.
342        let bottom_bar_present = self.bottom_bar.is_some();
343        if let Some(pod) = &mut self.fab {
344            let fab_size =
345                pod.layout_child(ctx, &BoxConstraints::loose(Size::new(width, total_height)));
346            let extra_bottom = if bottom_bar_present {
347                0.0
348            } else {
349                ctx.window_insets().padding().bottom
350            };
351            let box_top = body_top + self.fab_margin;
352            let box_left = self.fab_margin;
353            let box_right = (width - self.fab_margin).max(box_left);
354            let box_bottom =
355                (total_height - bottom_bar_height - self.fab_margin - extra_bottom).max(box_top);
356
357            let free_w = (box_right - box_left - fab_size.width).max(0.0);
358            let free_h = (box_bottom - box_top - fab_size.height).max(0.0);
359            let x = box_left + free_w * fraction(self.fab_alignment.x);
360            let y = box_top + free_h * fraction(self.fab_alignment.y);
361            pod.set_origin(Point::new(x, y));
362        }
363
364        bc.constrain(Size::new(width, total_height))
365    }
366
367    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
368        if let Some(color) = self.background {
369            scene.fill_rect(ctx.origin(), ctx.size(), color);
370        }
371        // Paint order (bottom to top): body, app bar, bottom bar, fab — the
372        // fab floats above everything, including a bottom bar.
373        self.body.paint_child(ctx, scene);
374        if let Some(pod) = &mut self.app_bar {
375            pod.paint_child(ctx, scene);
376        }
377        if let Some(pod) = &mut self.bottom_bar {
378            pod.paint_child(ctx, scene);
379        }
380        if let Some(pod) = &mut self.fab {
381            pod.paint_child(ctx, scene);
382        }
383    }
384
385    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
386        // Same z-order as `paint`, last = topmost — the four slots aren't a
387        // homogeneous `Vec<ChildPod>`, so `authoring::route_event` (which
388        // needs a contiguous `&mut [ChildPod]`) doesn't apply; `route_slots`
389        // below mirrors its broadcast/focus/capture/hit-test contract over
390        // these four discontiguous named fields instead.
391        let mut slots: [Option<&mut ChildPod>; 4] = [
392            Some(&mut self.body),
393            self.app_bar.as_mut(),
394            self.bottom_bar.as_mut(),
395            self.fab.as_mut(),
396        ];
397        route_slots(&mut slots, ctx, event)
398    }
399
400    fn semantics(&self, ctx: &mut SemanticsCtx) {
401        // Transparent structural container: forward every present slot, in
402        // the same paint order as above.
403        self.body.semantics_child(ctx);
404        if let Some(pod) = &self.app_bar {
405            pod.semantics_child(ctx);
406        }
407        if let Some(pod) = &self.bottom_bar {
408            pod.semantics_child(ctx);
409        }
410        if let Some(pod) = &self.fab {
411            pod.semantics_child(ctx);
412        }
413    }
414
415    crate::authoring::visit_children!(body, app_bar, bottom_bar, fab);
416}
417
418/// Whether `event` is the phase that auto-releases a recorded capture
419/// (`Up`/`Cancel`) — mirrors `authoring`'s private helper of the same shape;
420/// duplicated rather than exported, per the crate's per-container-copy
421/// convention for small routing predicates (see [`fraction`]'s doc).
422fn releases_capture(event: &InputEvent) -> bool {
423    matches!(
424        event,
425        InputEvent::Pointer(p) if matches!(p.phase, PointerPhase::Up | PointerPhase::Cancel)
426    )
427}
428
429/// Whether `event` is a pointer `Down` — the phase that opens a capture and
430/// triggers blur-on-outside-tap below.
431fn is_pointer_down(event: &InputEvent) -> bool {
432    matches!(
433        event,
434        InputEvent::Pointer(p) if matches!(p.phase, PointerPhase::Down)
435    )
436}
437
438/// Route an event across the scaffold's four fixed named slots, in `slots`'
439/// z-order (last = topmost) — the same broadcast/focus-routed/capture/
440/// hit-test contract as [`crate::authoring::route_event`], adapted to a
441/// discontiguous set of named fields (that helper needs one contiguous
442/// `&mut [ChildPod]`, which four separately-typed struct fields cannot
443/// supply).
444fn route_slots(
445    slots: &mut [Option<&mut ChildPod>; 4],
446    ctx: &mut EventCtx<'_>,
447    event: &InputEvent,
448) -> EventResult {
449    if event.is_broadcast() {
450        for slot in slots.iter_mut() {
451            if let Some(pod) = slot.as_deref_mut() {
452                pod.event_child(ctx, event);
453            }
454        }
455        return EventResult::Ignored;
456    }
457    if event.is_focus_routed() {
458        for slot in slots.iter_mut() {
459            if let Some(pod) = slot.as_deref_mut()
460                && pod.is_focused()
461            {
462                return pod.event_child(ctx, event);
463            }
464        }
465        return EventResult::Ignored;
466    }
467    for slot in slots.iter_mut() {
468        if let Some(pod) = slot.as_deref_mut()
469            && pod.is_active()
470        {
471            let result = pod.event_child(ctx, event);
472            if releases_capture(event) {
473                pod.set_active(false);
474            }
475            return result;
476        }
477    }
478
479    let position = event.position();
480    let mut handled = EventResult::Ignored;
481    let mut kept_focus: Option<usize> = None;
482    for (i, slot) in slots.iter_mut().enumerate().rev() {
483        if let Some(pod) = slot.as_deref_mut()
484            && pod.contains(position)
485            && pod.event_child(ctx, event) == EventResult::Handled
486        {
487            if pod.is_focused() {
488                kept_focus = Some(i);
489            }
490            handled = EventResult::Handled;
491            break;
492        }
493    }
494    if is_pointer_down(event) {
495        for (i, slot) in slots.iter_mut().enumerate() {
496            if Some(i) != kept_focus
497                && let Some(pod) = slot.as_deref_mut()
498                && pod.is_focused()
499            {
500                pod.set_focused(false);
501            }
502        }
503    }
504    handled
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use crate::test_support::leaf;
511    use frust_core::BuildCtx;
512
513    fn build<S: 'static>(view: &ScaffoldView<S>) -> ScaffoldWidget {
514        let mut counter = 0u64;
515        view.build(&mut BuildCtx::new(&mut counter))
516    }
517
518    #[test]
519    fn body_only_fills_the_whole_box() {
520        let view: ScaffoldView<()> = scaffold(leaf(50.0, 50.0));
521        let mut w = build(&view);
522        let mut lctx = LayoutCtx::new();
523        let size = w.layout(&mut lctx, &BoxConstraints::tight(Size::new(300.0, 400.0)));
524        assert_eq!(size, Size::new(300.0, 400.0));
525        assert_eq!(w.body.origin(), Point::ZERO);
526        assert_eq!(w.body.size(), Size::new(300.0, 400.0));
527        assert!(w.app_bar.is_none());
528        assert!(w.bottom_bar.is_none());
529        assert!(w.fab.is_none());
530    }
531
532    #[test]
533    fn app_bar_offsets_the_body_by_its_own_height() {
534        let view: ScaffoldView<()> = scaffold(leaf(50.0, 50.0)).app_bar(any(leaf(0.0, 56.0)));
535        let mut w = build(&view);
536        let mut lctx = LayoutCtx::new();
537        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(300.0, 400.0)));
538        assert_eq!(w.app_bar.as_ref().unwrap().origin(), Point::ZERO);
539        assert_eq!(w.app_bar.as_ref().unwrap().size().height, 56.0);
540        assert_eq!(w.body.origin(), Point::new(0.0, 56.0));
541        assert_eq!(w.body.size(), Size::new(300.0, 344.0));
542    }
543
544    #[test]
545    fn body_behind_app_bar_gives_the_body_full_height() {
546        let view: ScaffoldView<()> = scaffold(leaf(50.0, 50.0))
547            .app_bar(any(leaf(0.0, 56.0)))
548            .body_behind_app_bar(true);
549        let mut w = build(&view);
550        let mut lctx = LayoutCtx::new();
551        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(300.0, 400.0)));
552        assert_eq!(w.body.origin(), Point::ZERO);
553        assert_eq!(w.body.size(), Size::new(300.0, 400.0));
554    }
555
556    #[test]
557    fn bottom_bar_sits_at_the_bottom_edge_and_body_stops_above_it() {
558        let view: ScaffoldView<()> = scaffold(leaf(50.0, 50.0)).bottom_bar(any(leaf(0.0, 48.0)));
559        let mut w = build(&view);
560        let mut lctx = LayoutCtx::new();
561        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(300.0, 400.0)));
562        let bar = w.bottom_bar.as_ref().unwrap();
563        assert_eq!(bar.size().height, 48.0);
564        assert_eq!(bar.origin(), Point::new(0.0, 400.0 - 48.0));
565        assert_eq!(w.body.origin(), Point::ZERO);
566        assert_eq!(w.body.size(), Size::new(300.0, 400.0 - 48.0));
567    }
568
569    #[test]
570    fn fab_defaults_to_bottom_right_inset_by_margin() {
571        let view: ScaffoldView<()> = scaffold(leaf(50.0, 50.0)).fab(any(leaf(56.0, 56.0)));
572        let mut w = build(&view);
573        let mut lctx = LayoutCtx::new();
574        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(300.0, 400.0)));
575        let fab = w.fab.as_ref().unwrap();
576        // No window inset in `LayoutCtx::new()`'s default; no bottom bar —
577        // just `fab_margin` off each edge.
578        assert_eq!(
579            fab.origin(),
580            Point::new(300.0 - 16.0 - 56.0, 400.0 - 16.0 - 56.0)
581        );
582    }
583
584    #[test]
585    fn fab_floats_above_a_present_bottom_bar_with_no_double_inset() {
586        let view: ScaffoldView<()> = scaffold(leaf(50.0, 50.0))
587            .bottom_bar(any(leaf(0.0, 48.0)))
588            .fab(any(leaf(56.0, 56.0)));
589        let mut w = build(&view);
590        let mut lctx = LayoutCtx::new();
591        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(300.0, 400.0)));
592        let fab = w.fab.as_ref().unwrap();
593        // The fab's box stops above the 48px bottom bar; margin only (no
594        // window inset added on top of the bar's own self-sizing).
595        assert_eq!(
596            fab.origin(),
597            Point::new(300.0 - 16.0 - 56.0, 400.0 - 48.0 - 16.0 - 56.0)
598        );
599    }
600
601    #[test]
602    fn all_four_slots_together() {
603        let view: ScaffoldView<()> = scaffold(leaf(50.0, 50.0))
604            .app_bar(any(leaf(0.0, 56.0)))
605            .bottom_bar(any(leaf(0.0, 48.0)))
606            .fab(any(leaf(56.0, 56.0)));
607        let mut w = build(&view);
608        let mut lctx = LayoutCtx::new();
609        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(300.0, 400.0)));
610        assert_eq!(w.body.origin(), Point::new(0.0, 56.0));
611        assert_eq!(w.body.size(), Size::new(300.0, 400.0 - 56.0 - 48.0));
612        assert_eq!(
613            w.bottom_bar.as_ref().unwrap().origin(),
614            Point::new(0.0, 400.0 - 48.0)
615        );
616        assert_eq!(
617            w.fab.as_ref().unwrap().origin(),
618            Point::new(300.0 - 16.0 - 56.0, 400.0 - 48.0 - 16.0 - 56.0)
619        );
620    }
621}