1use 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
43pub 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
51pub 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#[allow(non_snake_case)]
66pub fn BottomAppBar<State: 'static>() -> BottomAppBarView<State> {
67 bottom_app_bar()
68}
69
70impl<State: 'static> BottomAppBarView<State> {
71 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 pub fn fab(mut self, fab: impl View<State>) -> Self {
82 self.fab = Some(AnyView::new(fab));
83 self
84 }
85
86 pub fn background(mut self, color: Color) -> Self {
89 self.background = Some(color);
90 self
91 }
92
93 pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
96 self.semantic_label = Some(label.into());
97 self
98 }
99}
100
101fn 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
113pub struct BottomAppBarWidget {
115 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 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 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 assert_eq!(
267 w.slots[3].origin().x + w.slots[3].size().width,
268 400.0 - PAD_X
269 );
270 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 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}