1use frus_core::{Color, Path, Point, Rect, Scene};
25use frus_layout::{Dimension, Style};
26
27use crate::container::Container;
28use crate::interaction::Status;
29use crate::theme::Theme;
30use crate::widget::Widget;
31
32const BAR_HEIGHT: f32 = 64.0;
34const NOTCH_MARGIN: f32 = 4.0;
36const SHOULDER: f32 = 15.0;
39const CLEARANCE: f32 = 1.0;
41
42pub struct BottomAppBar<Msg = crate::callback::Callback> {
44 children: Vec<Box<dyn Widget<Msg>>>,
45 color: Option<Color>,
46 height: f32,
47 padding: f32,
48 notch_margin: f32,
49 notch: Option<(f32, f32)>,
52}
53
54impl<Msg: Clone + 'static> Default for BottomAppBar<Msg> {
55 fn default() -> Self {
56 Self::new()
57 }
58}
59
60impl<Msg: Clone + 'static> BottomAppBar<Msg> {
61 pub fn new() -> Self {
63 Self {
64 children: Vec::new(),
65 color: None,
66 height: BAR_HEIGHT,
67 padding: 8.0,
68 notch_margin: NOTCH_MARGIN,
69 notch: None,
70 }
71 }
72
73 pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
75 self.children.clear();
76 self.children.push(Box::new(child));
77 self
78 }
79
80 pub fn color(mut self, color: Color) -> Self {
82 self.color = Some(color);
83 self
84 }
85
86 pub fn height(mut self, height: f32) -> Self {
88 self.height = height;
89 self
90 }
91
92 pub fn padding(mut self, padding: f32) -> Self {
94 self.padding = padding;
95 self
96 }
97
98 pub fn notch_margin(mut self, margin: f32) -> Self {
100 self.notch_margin = margin;
101 self
102 }
103
104 pub(crate) fn notched_at(mut self, centre_x: f32, radius: f32) -> Self {
107 self.notch = Some((centre_x, radius));
108 self
109 }
110
111 pub(crate) fn declared_height(&self) -> f32 {
114 self.height
115 }
116}
117
118pub fn notched_outline(host: Rect, centre_x: f32, radius: f32, guest_dy: f32) -> Path {
126 let (left, right) = (host.x, host.x + host.width);
127 let (top, bottom) = (host.y, host.y + host.height);
128 if radius <= 0.0 || guest_dy - radius >= host.height {
130 return Path::rect(host);
131 }
132
133 let r = radius;
136 let a = -r - CLEARANCE;
137 let b = top - (top + guest_dy);
138 let n2 = (b * b * r * r * (a * a + b * b - r * r)).max(0.0).sqrt();
139 let denom = a * a + b * b;
140 if denom.abs() < 1e-6 {
141 return Path::rect(host);
142 }
143 let p2x_a = ((a * r * r) - n2) / denom;
144 let p2x_b = ((a * r * r) + n2) / denom;
145 let p2y_a = (r * r - p2x_a * p2x_a).max(0.0).sqrt();
146 let p2y_b = (r * r - p2x_b * p2x_b).max(0.0).sqrt();
147 let cmp = if b < 0.0 { -1.0 } else { 1.0 };
148 let (p2x, p2y) = if cmp * p2y_a > cmp * p2y_b {
149 (p2x_a, p2y_a)
150 } else {
151 (p2x_b, p2y_b)
152 };
153
154 let cx = centre_x;
157 let cy = top + guest_dy;
158 let at = |x: f32, y: f32| Point::new(cx + x, cy + y);
159 let p0 = at(a - SHOULDER, b);
160 let p1 = at(a, b);
161 let p2 = at(p2x, p2y);
162 let p3 = at(-p2x, p2y);
163 let p4 = at(-a, b);
164 let p5 = at(-(a - SHOULDER), b);
165
166 let start = (p2.y - cy).atan2(p2.x - cx);
168 let end = (p3.y - cy).atan2(p3.x - cx);
169
170 Path::new()
171 .move_to(Point::new(left, top))
172 .line_to(p0)
173 .quad_to(p1, p2)
174 .arc_to(Point::new(cx, cy), r, start, end)
175 .quad_to(p4, p5)
176 .line_to(Point::new(right, top))
177 .line_to(Point::new(right, bottom))
178 .line_to(Point::new(left, bottom))
179 .close()
180}
181
182impl<Msg: Clone + 'static> Widget<Msg> for BottomAppBar<Msg> {
183 fn style(&self) -> Style {
187 let safe = crate::MediaQuery::of().padding;
193 Style {
194 height: Dimension::Length(self.height + safe.bottom),
195 padding: frus_core::Insets::new(
196 self.padding,
197 self.padding + safe.right,
198 self.padding + safe.bottom,
199 self.padding + safe.left,
200 ),
201 ..Default::default()
202 }
203 }
204
205 fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
207 crate::widget::FillAxes::WIDTH
208 }
209
210 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
211 &self.children
212 }
213
214 fn paint(&self, bounds: Rect, _status: Status, theme: &Theme, scene: &mut Scene) {
215 let color = self
217 .color
218 .or(theme.widgets.bottom_app_bar.color)
219 .unwrap_or(theme.scheme.surface_container);
220 match self.notch {
221 Some((centre_x, radius)) => {
222 let path =
223 notched_outline(bounds, bounds.x + centre_x, radius + self.notch_margin, 0.0);
224 scene.fill_path(&path, color);
225 }
226 None => scene.fill_rect(bounds, color),
227 }
228 }
229
230 fn on_click(&self) -> Option<Msg> {
231 None
232 }
233}
234
235pub fn bar_spacer<Msg: Clone + 'static>() -> Container<Msg> {
237 Container::new().flex(1.0)
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use frus_core::PathVerb;
244
245 const HOST: Rect = Rect {
246 x: 0.0,
247 y: 100.0,
248 width: 400.0,
249 height: 64.0,
250 };
251
252 #[test]
255 fn the_notch_clears_the_circle_it_was_cut_for() {
256 let (cx, r) = (320.0, 28.0);
257 let path = notched_outline(HOST, cx, r, 0.0);
258 let centre = Point::new(cx, HOST.y);
259 let mut nodes = 0;
260 for verb in path.verbs() {
261 let p = match verb {
262 PathVerb::MoveTo(p) | PathVerb::LineTo(p) => *p,
263 PathVerb::QuadTo { to, .. } | PathVerb::CubicTo { to, .. } => *to,
264 PathVerb::Close => continue,
265 };
266 nodes += 1;
267 assert!(
268 p.y >= HOST.y - 0.01 && p.y <= HOST.y + HOST.height + 0.01,
269 "node outside the bar: {p:?}"
270 );
271 let d = ((p.x - centre.x).powi(2) + (p.y - centre.y).powi(2)).sqrt();
272 assert!(
273 d >= r - 0.01,
274 "node inside the button's circle: {p:?} ({d})"
275 );
276 }
277 assert!(nodes > 6, "the notch adds curves, got {nodes} nodes");
278 }
279
280 #[test]
282 fn the_notch_is_centred_on_the_button() {
283 let (cx, r) = (200.0, 28.0);
284 let path = notched_outline(HOST, cx, r, 0.0);
285 let on_top: Vec<f32> = path
286 .verbs()
287 .iter()
288 .filter_map(|v| match v {
289 PathVerb::LineTo(p) | PathVerb::QuadTo { to: p, .. }
290 if (p.y - HOST.y).abs() < 0.01 =>
291 {
292 Some(p.x)
293 }
294 _ => None,
295 })
296 .collect();
297 let left = on_top.iter().cloned().fold(f32::MAX, f32::min);
299 let right = on_top
300 .iter()
301 .cloned()
302 .filter(|x| *x < HOST.width)
303 .fold(f32::MIN, f32::max);
304 assert!(
305 ((left + right) / 2.0 - cx).abs() < 0.5,
306 "shoulders at {left} and {right} are not centred on {cx}"
307 );
308 }
309
310 #[test]
314 fn the_bar_paints_behind_its_content() {
315 use crate::{build_ui, Container, Runtime, Size, Theme};
316 const MARK: Color = Color {
317 r: 1.0,
318 g: 0.0,
319 b: 1.0,
320 a: 1.0,
321 };
322 let bar = BottomAppBar::<()>::new()
323 .child(Container::new().width(80.0).height(30.0).color(MARK))
324 .notched_at(300.0, 28.0);
325 let ui = build_ui(
326 &bar,
327 Size::new(400.0, 64.0),
328 &Runtime::default(),
329 &Theme::default(),
330 );
331 let order: Vec<&str> = ui
332 .scene()
333 .primitives()
334 .iter()
335 .filter_map(|p| match p {
336 frus_core::Primitive::Path { .. } => Some("bar"),
337 frus_core::Primitive::Rect { color, .. } if *color == MARK => Some("content"),
338 _ => None,
339 })
340 .collect();
341 assert_eq!(order, vec!["bar", "content"], "the bar goes down first");
342 }
343
344 #[test]
346 fn without_a_notch_it_is_a_rectangle() {
347 assert_eq!(notched_outline(HOST, 200.0, 0.0, 0.0), Path::rect(HOST));
348 assert_eq!(notched_outline(HOST, 200.0, 10.0, 300.0), Path::rect(HOST));
349 }
350}