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<V: View<State>>(mut self, bar: V) -> Self {
98        self.app_bar = Some(any(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. A bare `None` needs the slot's view type spelled out, e.g.
105    /// `.app_bar_opt(None::<AnyView<State>>)`.
106    ///
107    /// ```
108    /// use frust_widgets::{ScaffoldView, scaffold, text};
109    /// # fn demo(title: Option<&str>) -> ScaffoldView<()> {
110    /// scaffold(text("body")).app_bar_opt(title.map(text))
111    /// # }
112    /// # let _ = demo(Some("Inbox"));
113    /// ```
114    pub fn app_bar_opt<V: View<State>>(mut self, bar: Option<V>) -> Self {
115        self.app_bar = bar.map(any);
116        self
117    }
118
119    /// Attach a bottom bar. Self-sizes for the bottom window inset the same
120    /// way `app_bar` self-sizes for the top one — see the [module docs](self).
121    pub fn bottom_bar<V: View<State>>(mut self, bar: V) -> Self {
122        self.bottom_bar = Some(any(bar));
123        self
124    }
125
126    /// Conditionally attach a bottom bar — `None` clears it. Mirrors
127    /// [`ScaffoldView::app_bar_opt`].
128    pub fn bottom_bar_opt<V: View<State>>(mut self, bar: Option<V>) -> Self {
129        self.bottom_bar = bar.map(any);
130        self
131    }
132
133    /// Attach a floating action button.
134    pub fn fab<V: View<State>>(mut self, fab: V) -> Self {
135        self.fab = Some(any(fab));
136        self
137    }
138
139    /// The FAB's inset from every edge of its aligning box (default 16.0,
140    /// [`DEFAULT_FAB_MARGIN`]).
141    pub fn fab_margin(mut self, margin: f64) -> Self {
142        self.fab_margin = margin;
143        self
144    }
145
146    /// Where the FAB sits within its aligning box (default
147    /// [`Alignment::BOTTOM_RIGHT`]).
148    pub fn fab_alignment(mut self, alignment: Alignment) -> Self {
149        self.fab_alignment = alignment;
150        self
151    }
152
153    /// Body runs full-height under the bar (the bar floats). Default `false`
154    /// (Flutter's `extendBodyBehindAppBar`, inverted default). Does not
155    /// affect `bottom_bar` — the body always stops above one when present;
156    /// v1 ships no matching `extendBody` toggle.
157    pub fn body_behind_app_bar(mut self, enabled: bool) -> Self {
158        self.body_behind_app_bar = enabled;
159        self
160    }
161
162    /// Optional flat fill painted behind every slot. No theme read — the
163    /// Scaffold stays theme-agnostic; a themed app supplies its own
164    /// `Theme`-resolved color.
165    pub fn background(mut self, color: Color) -> Self {
166        self.background = Some(color);
167        self
168    }
169}
170
171/// The retained widget for a [`ScaffoldView`].
172pub struct ScaffoldWidget {
173    body: ChildPod,
174    app_bar: Option<ChildPod>,
175    bottom_bar: Option<ChildPod>,
176    fab: Option<ChildPod>,
177    fab_margin: f64,
178    fab_alignment: Alignment,
179    body_behind_app_bar: bool,
180    background: Option<Color>,
181}
182
183/// Reconcile one optional named slot (`app_bar`/`bottom_bar`/`fab`) the same
184/// way [`crate::SizedBoxView`]'s single optional child does: build on
185/// appearance, tear down on disappearance, reconcile in place otherwise.
186/// Shared here because the Scaffold carries three such slots.
187fn rebuild_optional_slot<State: 'static>(
188    prev: &Option<AnyView<State>>,
189    next: &Option<AnyView<State>>,
190    slot: &mut Option<ChildPod>,
191    ctx: &mut BuildCtx<'_>,
192) -> ChangeFlags {
193    match (prev, next, &mut *slot) {
194        (Some(prev_view), Some(next_view), Some(pod)) => {
195            crate::authoring::rebuild_child(prev_view, next_view, pod, ctx)
196        }
197        (None, Some(next_view), _) => {
198            *slot = Some(crate::authoring::build_child(next_view, ctx));
199            ChangeFlags::LAYOUT | ChangeFlags::PAINT
200        }
201        (Some(prev_view), None, Some(pod)) => {
202            crate::authoring::teardown_child(prev_view, pod, ctx);
203            *slot = None;
204            ChangeFlags::LAYOUT | ChangeFlags::PAINT
205        }
206        _ => ChangeFlags::NONE,
207    }
208}
209
210impl<State: 'static> View<State> for ScaffoldView<State> {
211    type Element = ScaffoldWidget;
212
213    fn build(&self, ctx: &mut BuildCtx<'_>) -> ScaffoldWidget {
214        ScaffoldWidget {
215            body: crate::authoring::build_child(&self.body, ctx),
216            app_bar: self
217                .app_bar
218                .as_ref()
219                .map(|v| crate::authoring::build_child(v, ctx)),
220            bottom_bar: self
221                .bottom_bar
222                .as_ref()
223                .map(|v| crate::authoring::build_child(v, ctx)),
224            fab: self
225                .fab
226                .as_ref()
227                .map(|v| crate::authoring::build_child(v, ctx)),
228            fab_margin: self.fab_margin,
229            fab_alignment: self.fab_alignment,
230            body_behind_app_bar: self.body_behind_app_bar,
231            background: self.background,
232        }
233    }
234
235    fn rebuild(
236        &self,
237        prev: &Self,
238        element: &mut ScaffoldWidget,
239        ctx: &mut BuildCtx<'_>,
240    ) -> ChangeFlags {
241        let mut flags = ChangeFlags::NONE;
242        flags |= crate::authoring::rebuild_child(&prev.body, &self.body, &mut element.body, ctx);
243        flags |= rebuild_optional_slot(&prev.app_bar, &self.app_bar, &mut element.app_bar, ctx);
244        flags |= rebuild_optional_slot(
245            &prev.bottom_bar,
246            &self.bottom_bar,
247            &mut element.bottom_bar,
248            ctx,
249        );
250        flags |= rebuild_optional_slot(&prev.fab, &self.fab, &mut element.fab, ctx);
251
252        if prev.fab_margin != self.fab_margin
253            || prev.fab_alignment != self.fab_alignment
254            || prev.body_behind_app_bar != self.body_behind_app_bar
255        {
256            element.fab_margin = self.fab_margin;
257            element.fab_alignment = self.fab_alignment;
258            element.body_behind_app_bar = self.body_behind_app_bar;
259            flags |= ChangeFlags::LAYOUT;
260        }
261        if prev.background != self.background {
262            element.background = self.background;
263            flags |= ChangeFlags::PAINT;
264        }
265        flags
266    }
267
268    fn teardown(&self, element: &mut ScaffoldWidget, ctx: &mut BuildCtx<'_>) {
269        crate::authoring::teardown_child(&self.body, &mut element.body, ctx);
270        if let (Some(view), Some(pod)) = (&self.app_bar, &mut element.app_bar) {
271            crate::authoring::teardown_child(view, pod, ctx);
272        }
273        if let (Some(view), Some(pod)) = (&self.bottom_bar, &mut element.bottom_bar) {
274            crate::authoring::teardown_child(view, pod, ctx);
275        }
276        if let (Some(view), Some(pod)) = (&self.fab, &mut element.fab) {
277            crate::authoring::teardown_child(view, pod, ctx);
278        }
279    }
280}
281
282/// Map an alignment component in `-1.0..=1.0` to a `0.0..=1.0` fraction of
283/// free space — the same mapping [`crate::Align`] uses internally, duplicated
284/// here rather than shared (each container keeps its own copy of small
285/// positioning predicates, `docs/CODE_STANDARDS.md`'s Interaction Semantics
286/// convention for `presses`-shaped helpers).
287fn fraction(component: f64) -> f64 {
288    (component + 1.0) / 2.0
289}
290
291impl Widget for ScaffoldWidget {
292    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
293        let width = if bc.max().width.is_finite() {
294            bc.max().width
295        } else {
296            bc.min().width
297        };
298        let bounded_height = bc.max().height.is_finite();
299        let outer_height = bc.max().height;
300
301        // (1) App bar: tight width, loose (unbounded) height — self-sizes,
302        // including any top inset it reads itself (R-B4-inset).
303        let bar_bc = BoxConstraints::new(Size::new(width, 0.0), Size::new(width, f64::INFINITY));
304        let bar_height = match &mut self.app_bar {
305            Some(pod) => {
306                let size = pod.layout_child(ctx, &bar_bc);
307                pod.set_origin(Point::ZERO);
308                size.height
309            }
310            None => 0.0,
311        };
312
313        // Bottom bar: the same shape, mirrored — self-sizes for the bottom
314        // inset (module docs). Origin is set below, once the total height is
315        // known.
316        let bottom_bar_height = match &mut self.bottom_bar {
317            Some(pod) => pod.layout_child(ctx, &bar_bc).height,
318            None => 0.0,
319        };
320
321        // (2) Body: the remaining space between the bars, full height under
322        // `body_behind_app_bar` — never behind `bottom_bar` (v1 has no
323        // matching toggle for that edge, module docs).
324        let body_top = if self.body_behind_app_bar {
325            0.0
326        } else {
327            bar_height
328        };
329        let body_bc = if bounded_height {
330            let body_height = (outer_height - body_top - bottom_bar_height).max(0.0);
331            BoxConstraints::tight(Size::new(width, body_height))
332        } else {
333            BoxConstraints::new(Size::new(width, 0.0), Size::new(width, f64::INFINITY))
334        };
335        let body_size = self.body.layout_child(ctx, &body_bc);
336        self.body.set_origin(Point::new(0.0, body_top));
337
338        let total_height = if bounded_height {
339            outer_height
340        } else {
341            body_top + body_size.height + bottom_bar_height
342        };
343
344        if let Some(pod) = &mut self.bottom_bar {
345            pod.set_origin(Point::new(0.0, (total_height - bottom_bar_height).max(0.0)));
346        }
347
348        // (3) FAB: loose, aligned within the same box the body occupies —
349        // see R-B4-inset's fab paragraph for the bottom-inset/bottom_bar
350        // interplay this implements.
351        let bottom_bar_present = self.bottom_bar.is_some();
352        if let Some(pod) = &mut self.fab {
353            let fab_size =
354                pod.layout_child(ctx, &BoxConstraints::loose(Size::new(width, total_height)));
355            let extra_bottom = if bottom_bar_present {
356                0.0
357            } else {
358                ctx.window_insets().padding().bottom
359            };
360            let box_top = body_top + self.fab_margin;
361            let box_left = self.fab_margin;
362            let box_right = (width - self.fab_margin).max(box_left);
363            let box_bottom =
364                (total_height - bottom_bar_height - self.fab_margin - extra_bottom).max(box_top);
365
366            let free_w = (box_right - box_left - fab_size.width).max(0.0);
367            let free_h = (box_bottom - box_top - fab_size.height).max(0.0);
368            let x = box_left + free_w * fraction(self.fab_alignment.x);
369            let y = box_top + free_h * fraction(self.fab_alignment.y);
370            pod.set_origin(Point::new(x, y));
371        }
372
373        bc.constrain(Size::new(width, total_height))
374    }
375
376    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
377        if let Some(color) = self.background {
378            scene.fill_rect(ctx.origin(), ctx.size(), color);
379        }
380        // Paint order (bottom to top): body, app bar, bottom bar, fab — the
381        // fab floats above everything, including a bottom bar.
382        self.body.paint_child(ctx, scene);
383        if let Some(pod) = &mut self.app_bar {
384            pod.paint_child(ctx, scene);
385        }
386        if let Some(pod) = &mut self.bottom_bar {
387            pod.paint_child(ctx, scene);
388        }
389        if let Some(pod) = &mut self.fab {
390            pod.paint_child(ctx, scene);
391        }
392    }
393
394    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
395        // Same z-order as `paint`, last = topmost — the four slots aren't a
396        // homogeneous `Vec<ChildPod>`, so `authoring::route_event` (which
397        // needs a contiguous `&mut [ChildPod]`) doesn't apply; `route_slots`
398        // below mirrors its broadcast/focus/capture/hit-test contract over
399        // these four discontiguous named fields instead.
400        let mut slots: [Option<&mut ChildPod>; 4] = [
401            Some(&mut self.body),
402            self.app_bar.as_mut(),
403            self.bottom_bar.as_mut(),
404            self.fab.as_mut(),
405        ];
406        route_slots(&mut slots, ctx, event)
407    }
408
409    fn semantics(&self, ctx: &mut SemanticsCtx) {
410        // Transparent structural container: forward every present slot, in
411        // the same paint order as above.
412        self.body.semantics_child(ctx);
413        if let Some(pod) = &self.app_bar {
414            pod.semantics_child(ctx);
415        }
416        if let Some(pod) = &self.bottom_bar {
417            pod.semantics_child(ctx);
418        }
419        if let Some(pod) = &self.fab {
420            pod.semantics_child(ctx);
421        }
422    }
423
424    crate::authoring::visit_children!(body, app_bar, bottom_bar, fab);
425}
426
427/// Whether `event` is the phase that auto-releases a recorded capture
428/// (`Up`/`Cancel`) — mirrors `authoring`'s private helper of the same shape;
429/// duplicated rather than exported, per the crate's per-container-copy
430/// convention for small routing predicates (see [`fraction`]'s doc).
431fn releases_capture(event: &InputEvent) -> bool {
432    matches!(
433        event,
434        InputEvent::Pointer(p) if matches!(p.phase, PointerPhase::Up | PointerPhase::Cancel)
435    )
436}
437
438/// Whether `event` is a pointer `Down` — the phase that opens a capture and
439/// triggers blur-on-outside-tap below.
440fn is_pointer_down(event: &InputEvent) -> bool {
441    matches!(
442        event,
443        InputEvent::Pointer(p) if matches!(p.phase, PointerPhase::Down)
444    )
445}
446
447/// Route an event across the scaffold's four fixed named slots, in `slots`'
448/// z-order (last = topmost) — the same broadcast/focus-routed/capture/
449/// hit-test contract as [`crate::authoring::route_event`], adapted to a
450/// discontiguous set of named fields (that helper needs one contiguous
451/// `&mut [ChildPod]`, which four separately-typed struct fields cannot
452/// supply).
453fn route_slots(
454    slots: &mut [Option<&mut ChildPod>; 4],
455    ctx: &mut EventCtx<'_>,
456    event: &InputEvent,
457) -> EventResult {
458    if event.is_broadcast() {
459        for slot in slots.iter_mut() {
460            if let Some(pod) = slot.as_deref_mut() {
461                pod.event_child(ctx, event);
462            }
463        }
464        return EventResult::Ignored;
465    }
466    if event.is_focus_routed() {
467        for slot in slots.iter_mut() {
468            if let Some(pod) = slot.as_deref_mut()
469                && pod.is_focused()
470            {
471                return pod.event_child(ctx, event);
472            }
473        }
474        return EventResult::Ignored;
475    }
476    for slot in slots.iter_mut() {
477        if let Some(pod) = slot.as_deref_mut()
478            && pod.is_active()
479        {
480            let result = pod.event_child(ctx, event);
481            if releases_capture(event) {
482                pod.set_active(false);
483            }
484            return result;
485        }
486    }
487
488    let position = event.position();
489    let mut handled = EventResult::Ignored;
490    let mut kept_focus: Option<usize> = None;
491    for (i, slot) in slots.iter_mut().enumerate().rev() {
492        if let Some(pod) = slot.as_deref_mut()
493            && pod.contains(position)
494            && pod.event_child(ctx, event) == EventResult::Handled
495        {
496            if pod.is_focused() {
497                kept_focus = Some(i);
498            }
499            handled = EventResult::Handled;
500            break;
501        }
502    }
503    if is_pointer_down(event) {
504        for (i, slot) in slots.iter_mut().enumerate() {
505            if Some(i) != kept_focus
506                && let Some(pod) = slot.as_deref_mut()
507                && pod.is_focused()
508            {
509                pod.set_focused(false);
510            }
511        }
512    }
513    handled
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use crate::test_support::leaf;
520    use frust_core::BuildCtx;
521
522    fn build<S: 'static>(view: &ScaffoldView<S>) -> ScaffoldWidget {
523        let mut counter = 0u64;
524        view.build(&mut BuildCtx::new(&mut counter))
525    }
526
527    #[test]
528    fn body_only_fills_the_whole_box() {
529        let view: ScaffoldView<()> = scaffold(leaf(50.0, 50.0));
530        let mut w = build(&view);
531        let mut lctx = LayoutCtx::new();
532        let size = w.layout(&mut lctx, &BoxConstraints::tight(Size::new(300.0, 400.0)));
533        assert_eq!(size, Size::new(300.0, 400.0));
534        assert_eq!(w.body.origin(), Point::ZERO);
535        assert_eq!(w.body.size(), Size::new(300.0, 400.0));
536        assert!(w.app_bar.is_none());
537        assert!(w.bottom_bar.is_none());
538        assert!(w.fab.is_none());
539    }
540
541    #[test]
542    fn app_bar_offsets_the_body_by_its_own_height() {
543        let view: ScaffoldView<()> = scaffold(leaf(50.0, 50.0)).app_bar(leaf(0.0, 56.0));
544        let mut w = build(&view);
545        let mut lctx = LayoutCtx::new();
546        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(300.0, 400.0)));
547        assert_eq!(w.app_bar.as_ref().unwrap().origin(), Point::ZERO);
548        assert_eq!(w.app_bar.as_ref().unwrap().size().height, 56.0);
549        assert_eq!(w.body.origin(), Point::new(0.0, 56.0));
550        assert_eq!(w.body.size(), Size::new(300.0, 344.0));
551    }
552
553    #[test]
554    fn body_behind_app_bar_gives_the_body_full_height() {
555        let view: ScaffoldView<()> = scaffold(leaf(50.0, 50.0))
556            .app_bar(leaf(0.0, 56.0))
557            .body_behind_app_bar(true);
558        let mut w = build(&view);
559        let mut lctx = LayoutCtx::new();
560        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(300.0, 400.0)));
561        assert_eq!(w.body.origin(), Point::ZERO);
562        assert_eq!(w.body.size(), Size::new(300.0, 400.0));
563    }
564
565    #[test]
566    fn bottom_bar_sits_at_the_bottom_edge_and_body_stops_above_it() {
567        let view: ScaffoldView<()> = scaffold(leaf(50.0, 50.0)).bottom_bar(leaf(0.0, 48.0));
568        let mut w = build(&view);
569        let mut lctx = LayoutCtx::new();
570        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(300.0, 400.0)));
571        let bar = w.bottom_bar.as_ref().unwrap();
572        assert_eq!(bar.size().height, 48.0);
573        assert_eq!(bar.origin(), Point::new(0.0, 400.0 - 48.0));
574        assert_eq!(w.body.origin(), Point::ZERO);
575        assert_eq!(w.body.size(), Size::new(300.0, 400.0 - 48.0));
576    }
577
578    #[test]
579    fn fab_defaults_to_bottom_right_inset_by_margin() {
580        let view: ScaffoldView<()> = scaffold(leaf(50.0, 50.0)).fab(leaf(56.0, 56.0));
581        let mut w = build(&view);
582        let mut lctx = LayoutCtx::new();
583        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(300.0, 400.0)));
584        let fab = w.fab.as_ref().unwrap();
585        // No window inset in `LayoutCtx::new()`'s default; no bottom bar —
586        // just `fab_margin` off each edge.
587        assert_eq!(
588            fab.origin(),
589            Point::new(300.0 - 16.0 - 56.0, 400.0 - 16.0 - 56.0)
590        );
591    }
592
593    #[test]
594    fn fab_floats_above_a_present_bottom_bar_with_no_double_inset() {
595        let view: ScaffoldView<()> = scaffold(leaf(50.0, 50.0))
596            .bottom_bar(leaf(0.0, 48.0))
597            .fab(leaf(56.0, 56.0));
598        let mut w = build(&view);
599        let mut lctx = LayoutCtx::new();
600        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(300.0, 400.0)));
601        let fab = w.fab.as_ref().unwrap();
602        // The fab's box stops above the 48px bottom bar; margin only (no
603        // window inset added on top of the bar's own self-sizing).
604        assert_eq!(
605            fab.origin(),
606            Point::new(300.0 - 16.0 - 56.0, 400.0 - 48.0 - 16.0 - 56.0)
607        );
608    }
609
610    #[test]
611    fn all_four_slots_together() {
612        let view: ScaffoldView<()> = scaffold(leaf(50.0, 50.0))
613            .app_bar(leaf(0.0, 56.0))
614            .bottom_bar(leaf(0.0, 48.0))
615            .fab(leaf(56.0, 56.0));
616        let mut w = build(&view);
617        let mut lctx = LayoutCtx::new();
618        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(300.0, 400.0)));
619        assert_eq!(w.body.origin(), Point::new(0.0, 56.0));
620        assert_eq!(w.body.size(), Size::new(300.0, 400.0 - 56.0 - 48.0));
621        assert_eq!(
622            w.bottom_bar.as_ref().unwrap().origin(),
623            Point::new(0.0, 400.0 - 48.0)
624        );
625        assert_eq!(
626            w.fab.as_ref().unwrap().origin(),
627            Point::new(300.0 - 16.0 - 56.0, 400.0 - 48.0 - 16.0 - 56.0)
628        );
629    }
630}