Skip to main content

frust_material/appbar/
bottom.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
2// `lib/components/app_bars/m3e_app_bars.dart`'s `M3EAppBar.bottom` constructor
3// and its `_buildBottom` helper. See `super`'s module docs for the family's
4// porting decisions.
5
6//! The bottom app bar ([`bottom_app_bar`]): an action row docked to the bottom
7//! edge with an optional prominent/FAB slot at the trailing edge. Read
8//! [`super`]'s module docs first for the shared slot contract and metrics.
9//!
10//! Layout mirrors upstream's own `Row`: the actions run from the leading edge
11//! in reading order, a spacer eats the rest, and the fab slot (if any) sits
12//! flush against the trailing edge, everything vertically centered in the
13//! [`AppBarMetrics::bottom_height`] band. The fab slot is an opaque
14//! caller-supplied view like every other slot here — typically a real
15//! [`fn@crate::fab`], to which this widget adds no chrome of its own.
16//!
17//! **No cutout/notch.** Upstream's bottom bar has none — it is a plain
18//! `Material` with the FAB inside the same row (`_buildBottom`), not Flutter's
19//! own `BottomAppBar(shape: NotchedShape)`. This port carries that shape over
20//! rather than inventing a notch the reference never draws.
21//!
22//! The band is density-independent: `M3EAppBar.bottom` pins `density: regular`
23//! and reads `bottomHeight` straight off the theme, so there is no compact
24//! bottom bar to port. Its container role is `colors.surface_container`
25//! (upstream's `bottomBackgroundColor`), one step up from the top bar's
26//! `colors.surface`, and its corners are square with no shape-family knob —
27//! again upstream's own fixed choices for this constructor.
28
29use frust::Theme;
30use frust::authoring::{
31    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
32    LayoutCtx, PaintCtx, PaintScene, Role, SemanticsCtx, View, ViewSeq, Widget, build_child,
33    rebuild_children, route_event, teardown_child, visit_children,
34};
35use kurbo::{Point, Size};
36use peniko::Color;
37
38use super::{
39    AppBarDensity, AppBarMetrics, GAP, PAD_X, finite_or_zero, push_bar_semantics,
40    resolve_bottom_container,
41};
42
43/// A declarative M3 bottom app bar. See the [module docs](self).
44pub struct BottomAppBarView<State: 'static> {
45    actions: Vec<AnyView<State>>,
46    fab: Option<AnyView<State>>,
47    background: Option<Color>,
48    semantic_label: Option<String>,
49}
50
51/// Create a bottom app bar with no actions and no fab slot (attach them with
52/// [`BottomAppBarView::actions`]/[`BottomAppBarView::fab`]) — upstream's
53/// `M3EAppBar.bottom`.
54pub fn bottom_app_bar<State: 'static>() -> BottomAppBarView<State> {
55    BottomAppBarView {
56        actions: Vec::new(),
57        fab: None,
58        background: None,
59        semantic_label: None,
60    }
61}
62
63/// PascalCase alias for [`bottom_app_bar`], matching the container view-fn
64/// vocabulary.
65#[allow(non_snake_case)]
66pub fn BottomAppBar<State: 'static>() -> BottomAppBarView<State> {
67    bottom_app_bar()
68}
69
70impl<State: 'static> BottomAppBarView<State> {
71    /// Attach the action slots, in reading order from the leading edge. Tint is
72    /// each supplied view's own responsibility — see [`super`]'s slot contract.
73    pub fn actions<M>(mut self, actions: impl ViewSeq<State, M>) -> Self {
74        self.actions.clear();
75        actions.extend_views(&mut self.actions);
76        self
77    }
78
79    /// Attach the prominent/FAB slot at the trailing edge
80    /// (`floatingActionButton`).
81    pub fn fab(mut self, fab: impl View<State>) -> Self {
82        self.fab = Some(AnyView::new(fab));
83        self
84    }
85
86    /// Override the container fill, winning over the theme role and its
87    /// fallback alike.
88    pub fn background(mut self, color: Color) -> Self {
89        self.background = Some(color);
90        self
91    }
92
93    /// Label the bar's accessibility container. Unlabelled by default — a
94    /// bottom bar owns no title text to borrow one from.
95    pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
96        self.semantic_label = Some(label.into());
97        self
98    }
99}
100
101/// Collect `view`'s actions followed by its fab slot (if any) into one ordered
102/// slice of [`AnyView`] references — the order the pods, hit-testing, and
103/// semantics all keep.
104fn slot_views<State: 'static>(view: &BottomAppBarView<State>) -> Vec<&AnyView<State>> {
105    let mut views = Vec::with_capacity(view.actions.len() + usize::from(view.fab.is_some()));
106    views.extend(view.actions.iter());
107    if let Some(fab) = &view.fab {
108        views.push(fab);
109    }
110    views
111}
112
113/// The retained widget for a [`BottomAppBarView`].
114pub struct BottomAppBarWidget {
115    /// Every action, then the fab slot (if any).
116    slots: Vec<ChildPod>,
117    action_count: usize,
118    has_fab: bool,
119    background: Option<Color>,
120    label: Option<String>,
121}
122
123impl<State: 'static> View<State> for BottomAppBarView<State> {
124    type Element = BottomAppBarWidget;
125
126    fn build(&self, ctx: &mut BuildCtx<'_>) -> BottomAppBarWidget {
127        let slots = slot_views(self)
128            .into_iter()
129            .map(|view| build_child(view, ctx))
130            .collect();
131        BottomAppBarWidget {
132            slots,
133            action_count: self.actions.len(),
134            has_fab: self.fab.is_some(),
135            background: self.background,
136            label: self.semantic_label.clone(),
137        }
138    }
139
140    fn rebuild(
141        &self,
142        prev: &Self,
143        element: &mut BottomAppBarWidget,
144        ctx: &mut BuildCtx<'_>,
145    ) -> ChangeFlags {
146        let mut flags = rebuild_children(
147            &slot_views(prev),
148            &slot_views(self),
149            &mut element.slots,
150            ctx,
151            |view: &&AnyView<State>| *view,
152            |_| None,
153        );
154
155        if element.action_count != self.actions.len() {
156            element.action_count = self.actions.len();
157            flags |= ChangeFlags::LAYOUT;
158        }
159        let has_fab = self.fab.is_some();
160        if element.has_fab != has_fab {
161            element.has_fab = has_fab;
162            flags |= ChangeFlags::LAYOUT;
163        }
164        if element.background != self.background {
165            element.background = self.background;
166            flags |= ChangeFlags::PAINT;
167        }
168        element.label = self.semantic_label.clone();
169        flags
170    }
171
172    fn teardown(&self, element: &mut BottomAppBarWidget, ctx: &mut BuildCtx<'_>) {
173        for (view, pod) in slot_views(self).into_iter().zip(element.slots.iter_mut()) {
174            teardown_child(view, pod, ctx);
175        }
176    }
177}
178
179impl Widget for BottomAppBarWidget {
180    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
181        let width = finite_or_zero(bc.max().width);
182        let band = AppBarMetrics::for_density(AppBarDensity::Regular).bottom_height;
183        let slot_bc = BoxConstraints::loose(Size::new(f64::INFINITY, band));
184
185        let mut left = PAD_X;
186        for pod in self.slots[..self.action_count].iter_mut() {
187            let size = pod.layout_child(ctx, &slot_bc);
188            pod.set_origin(Point::new(left, (band - size.height) / 2.0));
189            left += size.width + GAP;
190        }
191
192        // The fab slot hugs the trailing edge, whatever the action row's own
193        // width came to (upstream's `Spacer` between the two).
194        if self.has_fab {
195            let pod = &mut self.slots[self.action_count];
196            let size = pod.layout_child(ctx, &slot_bc);
197            pod.set_origin(Point::new(
198                width - PAD_X - size.width,
199                (band - size.height) / 2.0,
200            ));
201        }
202
203        bc.constrain(Size::new(width, band))
204    }
205
206    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
207        let fill = self
208            .background
209            .unwrap_or_else(|| resolve_bottom_container(Theme::from_paint_ctx(ctx)));
210        scene.fill_rect(ctx.origin(), ctx.size(), fill);
211        for pod in &mut self.slots {
212            pod.paint_child(ctx, scene);
213        }
214    }
215
216    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
217        route_event(&mut self.slots, ctx, event)
218    }
219
220    fn semantics(&self, ctx: &mut SemanticsCtx) {
221        push_bar_semantics(ctx, Role::Toolbar, self.label.as_deref(), &self.slots);
222    }
223
224    visit_children!(slots);
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use frust_widgets::test_support::{RecordingScene, leaf_any};
231
232    /// The band every assertion below is written against.
233    const BAND: f64 = 80.0;
234
235    fn ctx(counter: &mut u64) -> BuildCtx<'_> {
236        BuildCtx::new(counter)
237    }
238
239    fn build(view: &BottomAppBarView<()>) -> BottomAppBarWidget {
240        let mut counter = 0u64;
241        View::<()>::build(view, &mut ctx(&mut counter))
242    }
243
244    fn layout(w: &mut BottomAppBarWidget, bc: &BoxConstraints) -> Size {
245        let mut lctx = LayoutCtx::new();
246        w.layout(&mut lctx, bc)
247    }
248
249    #[test]
250    fn actions_run_from_the_leading_edge_and_the_fab_hugs_the_trailing_one() {
251        let view: BottomAppBarView<()> = bottom_app_bar()
252            .actions(vec![
253                leaf_any(24.0, 24.0),
254                leaf_any(24.0, 24.0),
255                leaf_any(24.0, 24.0),
256            ])
257            .fab(leaf_any(56.0, 56.0));
258        let mut w = build(&view);
259        let size = layout(&mut w, &BoxConstraints::loose(Size::new(400.0, 200.0)));
260        assert_eq!(size, Size::new(400.0, BAND));
261
262        assert_eq!(w.slots[0].origin().x, PAD_X);
263        assert_eq!(w.slots[1].origin().x, PAD_X + 24.0 + GAP);
264        assert_eq!(w.slots[2].origin().x, PAD_X + 2.0 * (24.0 + GAP));
265        // The fab is the last pod, flush against the trailing edge.
266        assert_eq!(
267            w.slots[3].origin().x + w.slots[3].size().width,
268            400.0 - PAD_X
269        );
270        // Everything is centered in the band.
271        assert_eq!(w.slots[0].origin().y, (BAND - 24.0) / 2.0);
272        assert_eq!(w.slots[3].origin().y, (BAND - 56.0) / 2.0);
273    }
274
275    #[test]
276    fn a_bar_without_a_fab_still_spans_the_width() {
277        let view: BottomAppBarView<()> = bottom_app_bar().actions(vec![leaf_any(24.0, 24.0)]);
278        let mut w = build(&view);
279        let size = layout(&mut w, &BoxConstraints::loose(Size::new(320.0, 200.0)));
280        assert_eq!(size, Size::new(320.0, BAND));
281        assert_eq!(w.slots.len(), 1);
282    }
283
284    #[test]
285    fn unthemed_paint_uses_the_fallback_container() {
286        let view: BottomAppBarView<()> = bottom_app_bar();
287        let mut w = build(&view);
288        layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
289        let mut scene = RecordingScene::default();
290        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(300.0, BAND));
291        w.paint(&mut pctx, &mut scene);
292        assert_eq!(scene.rects[0].1, Size::new(300.0, BAND));
293    }
294
295    #[test]
296    fn themed_paint_resolves_surface_container() {
297        struct ColorRecorder {
298            colors: Vec<Color>,
299        }
300        impl PaintScene for ColorRecorder {
301            fn fill_rect(&mut self, _o: Point, _s: Size, c: Color) {
302                self.colors.push(c);
303            }
304            fn draw_text(&mut self, _o: Point, _t: &str) {}
305        }
306
307        let theme = crate::baseline();
308        let view: BottomAppBarView<()> = bottom_app_bar();
309        let mut w = build(&view);
310        layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
311        let mut scene = ColorRecorder { colors: Vec::new() };
312        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(300.0, BAND)).with_theme(&theme);
313        w.paint(&mut pctx, &mut scene);
314        assert_eq!(scene.colors[0], theme.scheme().surface_container);
315    }
316
317    #[test]
318    fn rebuild_adopts_a_new_fab_slot() {
319        let mut counter = 0u64;
320        let prev: BottomAppBarView<()> = bottom_app_bar().actions(vec![leaf_any(24.0, 24.0)]);
321        let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
322        assert!(!w.has_fab);
323
324        let next: BottomAppBarView<()> = bottom_app_bar()
325            .actions(vec![leaf_any(24.0, 24.0)])
326            .fab(leaf_any(56.0, 56.0));
327        let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
328        assert!(flags.needs_layout());
329        assert!(w.has_fab);
330        assert_eq!(w.slots.len(), 2);
331    }
332
333    #[test]
334    fn semantics_node_is_a_toolbar_forwarding_every_slot() {
335        // A leaf without its own `semantics()` override (e.g. `leaf_any`)
336        // contributes no node — use `Text` children instead, so the "forwards
337        // every slot" assertion is meaningful (mirrors `toolbar.rs`'s identical
338        // semantics-test note).
339        fn logic(_state: &mut ()) -> BottomAppBarView<()> {
340            bottom_app_bar()
341                .actions(vec![frust::authoring::any::<(), _>(frust::text("One"))])
342                .fab(frust::authoring::any::<(), _>(frust::text("Two")))
343        }
344        let mut root: frust_core::RenderRoot<(), BottomAppBarView<()>> =
345            frust_core::RenderRoot::new();
346        let mut state = ();
347        root.rebuild(&mut logic, &mut state);
348        let mut tcx = frust::authoring::text::TextContext::new();
349        root.layout_with_text(Size::new(300.0, BAND), &mut tcx as &mut dyn std::any::Any);
350        let update = root.semantics();
351        let (_, node) = update
352            .nodes
353            .iter()
354            .find(|(_, n)| n.role() == Role::Toolbar)
355            .expect("a Toolbar node is contributed");
356        assert_eq!(node.children().len(), 2, "actions + fab forwarded");
357    }
358}