1use frus_core::{Color, Insets, Rect, Scene, TextStyle};
26use frus_layout::{Align, Dimension, FlexDirection, Justify, Style};
27
28use crate::interaction::Status;
29use crate::theme::Theme;
30use crate::widget::Widget;
31
32pub const BANNER_ELEVATION: f32 = 0.0;
39pub const BANNER_MIN_ACTION_BAR_HEIGHT: f32 = 52.0;
41const ACTION_GAP: f32 = 8.0;
43const LEADING_GAP: f32 = 16.0;
45const RAISED_MARGIN: f32 = 10.0;
47
48fn default_padding(single_row: bool) -> Insets {
51 if single_row {
52 Insets::new(2.0, 0.0, 0.0, 16.0)
53 } else {
54 Insets::new(24.0, 16.0, 4.0, 16.0)
55 }
56}
57
58fn content_style(over: Option<TextStyle>, theme: Option<&Theme>) -> TextStyle {
60 over.or(theme.and_then(|t| t.widgets.banner.content_text_style))
61 .unwrap_or_else(|| crate::theme::type_scale(theme).body_medium)
62}
63
64pub struct MaterialBanner<Msg = crate::callback::Callback> {
70 content: Option<Box<dyn Widget<Msg>>>,
71 content_text_style: Option<TextStyle>,
72 leading: Option<Box<dyn Widget<Msg>>>,
73 actions: Vec<Box<dyn Widget<Msg>>>,
74 background: Option<Color>,
75 surface_tint: Option<Color>,
76 shadow_color: Option<Color>,
77 divider_color: Option<Color>,
78 elevation: Option<f32>,
79 padding: Option<Insets>,
80 margin: Option<Insets>,
81 leading_padding: Option<Insets>,
82 force_actions_below: bool,
83 min_action_bar_height: f32,
84}
85
86impl<Msg: Clone + 'static> MaterialBanner<Msg> {
87 pub fn new(content: impl Into<String>) -> Self {
90 Self::with_content(crate::Text::new(content))
91 }
92
93 pub fn with_content(content: impl Widget<Msg> + 'static) -> Self {
95 Self {
96 content: Some(Box::new(content)),
97 content_text_style: None,
98 leading: None,
99 actions: Vec::new(),
100 background: None,
101 surface_tint: None,
102 shadow_color: None,
103 divider_color: None,
104 elevation: None,
105 padding: None,
106 margin: None,
107 leading_padding: None,
108 force_actions_below: false,
109 min_action_bar_height: BANNER_MIN_ACTION_BAR_HEIGHT,
110 }
111 }
112
113 #[must_use]
115 pub fn leading(mut self, leading: impl Widget<Msg> + 'static) -> Self {
116 self.leading = Some(Box::new(leading));
117 self
118 }
119
120 #[must_use]
123 pub fn action(mut self, action: impl Widget<Msg> + 'static) -> Self {
124 self.actions.push(Box::new(action));
125 self
126 }
127
128 #[must_use]
130 pub fn force_actions_below(mut self, force: bool) -> Self {
131 self.force_actions_below = force;
132 self
133 }
134
135 #[must_use]
137 pub fn content_text_style(mut self, style: TextStyle) -> Self {
138 self.content_text_style = Some(style);
139 self
140 }
141
142 #[must_use]
144 pub fn background(mut self, color: Color) -> Self {
145 self.background = Some(color);
146 self
147 }
148
149 #[must_use]
152 pub fn surface_tint(mut self, color: Color) -> Self {
153 self.surface_tint = Some(color);
154 self
155 }
156
157 #[must_use]
159 pub fn shadow_color(mut self, color: Color) -> Self {
160 self.shadow_color = Some(color);
161 self
162 }
163
164 #[must_use]
167 pub fn divider_color(mut self, color: Color) -> Self {
168 self.divider_color = Some(color);
169 self
170 }
171
172 #[must_use]
180 pub fn elevation(mut self, elevation: f32) -> Self {
181 self.elevation = Some(elevation);
182 self
183 }
184
185 #[must_use]
187 pub fn padding(mut self, padding: Insets) -> Self {
188 self.padding = Some(padding);
189 self
190 }
191
192 #[must_use]
194 pub fn margin(mut self, margin: Insets) -> Self {
195 self.margin = Some(margin);
196 self
197 }
198
199 #[must_use]
201 pub fn leading_padding(mut self, padding: Insets) -> Self {
202 self.leading_padding = Some(padding);
203 self
204 }
205
206 #[must_use]
208 pub fn min_action_bar_height(mut self, height: f32) -> Self {
209 self.min_action_bar_height = height;
210 self
211 }
212
213 pub fn build(self) -> Box<dyn Widget<Msg>> {
220 Box::new(crate::ThemeBuilder::boxed(move |theme| {
221 self.build_with(theme)
222 }))
223 }
224
225 fn build_with(self, theme: &Theme) -> Box<dyn Widget<Msg>> {
226 let t = theme.widgets.banner;
227 let MaterialBanner {
228 content,
229 content_text_style,
230 leading,
231 actions,
232 background,
233 surface_tint,
234 shadow_color,
235 divider_color,
236 elevation,
237 padding,
238 margin,
239 leading_padding,
240 force_actions_below,
241 min_action_bar_height,
242 } = self;
243
244 let single_row = actions.len() == 1 && !force_actions_below;
248 let padding = padding.or(t.padding).unwrap_or(default_padding(single_row));
249 let leading_padding = leading_padding.or(t.leading_padding).unwrap_or(Insets::new(
250 0.0,
251 LEADING_GAP,
252 0.0,
253 0.0,
254 ));
255 let elevation = elevation.or(t.elevation).unwrap_or(BANNER_ELEVATION);
256
257 let actions_bar = |actions: Vec<Box<dyn Widget<Msg>>>| {
258 let mut row = crate::OverflowBar::new()
263 .alignment(Justify::End)
264 .spacing(ACTION_GAP)
265 .overflow_alignment(Align::End);
266 for action in actions {
267 row = row.child_boxed(action);
268 }
269 crate::ConstrainedBox::new(
274 crate::Flex::row()
275 .justify(Justify::End)
276 .align(Align::Center)
277 .padding_each(0.0, ACTION_GAP, 0.0, ACTION_GAP)
278 .child(row),
279 )
280 .min_height(min_action_bar_height)
281 };
282
283 let mut message = crate::Flex::row().align(Align::Center);
286 if let Some(leading) = leading {
287 message = message.child(
288 crate::Flex::row()
289 .padding_each(
290 leading_padding.top,
291 leading_padding.right,
292 leading_padding.bottom,
293 leading_padding.left,
294 )
295 .child_boxed(leading),
296 );
297 }
298 if let Some(content) = content {
299 message = message.child(
300 crate::Expanded::new(crate::Themed::tweak(
301 {
302 let style = content_style(content_text_style, Some(theme));
303 move |t: &mut Theme| t.widgets.text.style = style
304 },
305 content,
306 ))
307 .flex(1.0),
308 );
309 }
310 let mut actions = actions;
311 if single_row {
312 message = message.child(actions_bar(std::mem::take(&mut actions)));
313 }
314
315 let mut column = crate::Flex::column().child(
321 crate::Flex::column()
322 .padding_each(padding.top, padding.right, padding.bottom, padding.left)
323 .child(message),
324 );
325 if !actions.is_empty() {
326 column = column.child(actions_bar(actions));
327 }
328 if elevation == 0.0 {
331 let mut divider = crate::Divider::new().height(crate::DIVIDER_THICKNESS);
335 if let Some(color) = divider_color.or(t.divider_color) {
336 divider = divider.color(color);
337 }
338 column = column.child(divider);
339 }
340
341 let margin = margin.or(t.margin).unwrap_or(if elevation > 0.0 {
342 Insets::new(0.0, 0.0, RAISED_MARGIN, 0.0)
343 } else {
344 Insets::ZERO
345 });
346
347 Box::new(BannerSurface {
348 children: vec![Box::new(column)],
349 background: background.or(t.color),
350 surface_tint: surface_tint.or(t.surface_tint),
351 shadow_color: shadow_color.or(t.shadow_color),
352 elevation,
353 margin,
354 })
355 }
356}
357
358struct BannerSurface<Msg> {
361 children: Vec<Box<dyn Widget<Msg>>>,
362 background: Option<Color>,
363 surface_tint: Option<Color>,
364 shadow_color: Option<Color>,
365 elevation: f32,
366 margin: Insets,
367}
368
369impl<Msg> BannerSurface<Msg> {
370 fn background(&self, theme: &Theme) -> Color {
373 let base = self
374 .background
375 .unwrap_or(theme.scheme.surface_container_low);
376 match self.surface_tint {
377 Some(tint) => base.surface_tint(tint, self.elevation),
378 None => base,
379 }
380 }
381}
382
383impl<Msg: Clone> Widget<Msg> for BannerSurface<Msg> {
384 fn style(&self) -> Style {
385 Style {
386 flex_direction: FlexDirection::Column,
387 height: Dimension::Auto,
388 margin: self.margin,
389 ..Default::default()
390 }
391 }
392
393 fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
395 crate::widget::FillAxes::WIDTH
396 }
397
398 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
399 &self.children
400 }
401
402 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
403 let o = status.opacity;
404 if self.elevation > 0.0 {
405 if let Some(shadow) = self.shadow_color.filter(|c| c.a > 0.0) {
406 let blur = self.elevation * 4.0 + 8.0;
407 scene.shadow(
408 Rect::new(
409 bounds.x - blur,
410 bounds.y + self.elevation * 2.0 - blur,
411 bounds.width + 2.0 * blur,
412 bounds.height + 2.0 * blur,
413 ),
414 shadow.fade(o),
415 frus_core::BorderRadius::ZERO.inflate(blur),
416 blur,
417 );
418 }
419 }
420 scene.fill_rect(bounds, self.background(theme).fade(o));
421 }
422
423 fn on_click(&self) -> Option<Msg> {
424 None
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431 use crate::{build_ui, dsl::button, Runtime, Size};
432
433 #[derive(Clone, Debug, PartialEq)]
434 enum Msg {
435 Save,
436 Discard,
437 }
438
439 fn rects(banner: &dyn Widget<Msg>) -> Vec<(frus_core::Rect, Color)> {
440 build_ui(
441 banner,
442 Size::new(400.0, 200.0),
443 &Runtime::default(),
444 &Theme::default(),
445 )
446 .scene()
447 .primitives()
448 .iter()
449 .filter_map(|p| match p {
450 frus_core::Primitive::Rect { rect, color, .. } => Some((*rect, *color)),
451 _ => None,
452 })
453 .collect()
454 }
455
456 #[test]
459 fn a_second_action_moves_the_bar_onto_its_own_line() {
460 let height = |actions: usize| {
461 let mut banner = MaterialBanner::<Msg>::new("You have unsaved changes.")
462 .action(button("Save", Msg::Save));
463 if actions > 1 {
464 banner = banner.action(button("Discard", Msg::Discard));
465 }
466 let tree = banner.build();
467 build_ui(
468 tree.as_ref(),
469 Size::new(400.0, 200.0),
470 &Runtime::default(),
471 &Theme::default(),
472 )
473 .scene()
474 .primitives()
475 .iter()
476 .filter_map(|p| match p {
477 frus_core::Primitive::Rect { rect, .. } if rect.width > 300.0 => Some(rect.height),
478 _ => None,
479 })
480 .fold(0.0_f32, f32::max)
481 };
482 assert!(
483 height(2) > height(1),
484 "two actions did not take a line of their own: {} vs {}",
485 height(2),
486 height(1)
487 );
488 }
489
490 #[test]
493 fn a_single_action_can_be_forced_onto_its_own_line() {
494 let height = |forced: bool| {
495 let tree = MaterialBanner::<Msg>::new("You have unsaved changes.")
496 .action(button("Save", Msg::Save))
497 .force_actions_below(forced)
498 .build();
499 build_ui(
500 tree.as_ref(),
501 Size::new(400.0, 200.0),
502 &Runtime::default(),
503 &Theme::default(),
504 )
505 .scene()
506 .primitives()
507 .iter()
508 .filter_map(|p| match p {
509 frus_core::Primitive::Rect { rect, .. } if rect.width > 300.0 => Some(rect.height),
510 _ => None,
511 })
512 .fold(0.0_f32, f32::max)
513 };
514 assert!(height(true) > height(false));
515 }
516
517 #[test]
520 fn the_rule_is_drawn_only_where_there_is_no_height_to_show() {
521 let rule = |elevation: f32| {
522 let tree = MaterialBanner::<Msg>::new("Message")
523 .action(button("Save", Msg::Save))
524 .elevation(elevation)
525 .divider_color(Color::rgb8(255, 0, 255))
526 .build();
527 rects(tree.as_ref())
528 .into_iter()
529 .any(|(_, c)| c == Color::rgb8(255, 0, 255))
530 };
531 assert!(rule(0.0), "a flat banner draws no rule");
532 assert!(
533 !rule(1.0),
534 "a raised banner drew a rule as well as a shadow"
535 );
536 }
537
538 #[test]
542 fn the_message_takes_what_is_left_of_the_line() {
543 const WIDTH: f32 = 400.0;
544 let tree = MaterialBanner::<Msg>::new("A message")
545 .action(button("Save", Msg::Save))
546 .build();
547 let ui = build_ui(
548 tree.as_ref(),
549 Size::new(WIDTH, 200.0),
550 &Runtime::default(),
551 &Theme::default(),
552 );
553 let box_of = |wanted: &str| {
554 ui.scene()
555 .primitives()
556 .iter()
557 .find_map(|p| match p {
558 frus_core::Primitive::Text { text, bounds, .. } if text == wanted => {
559 Some(*bounds)
560 }
561 _ => None,
562 })
563 .expect("drawn")
564 };
565 let message = box_of("A message");
566 let action = box_of("Save");
567 assert!(
568 message.x + message.width < action.x,
569 "the action is not past the message: {message:?} {action:?}"
570 );
571 assert!(
574 action.x + action.width > WIDTH - 48.0,
575 "the action did not reach the trailing edge: {action:?}"
576 );
577 }
578
579 #[test]
585 fn an_untold_banner_is_flat_with_a_rule_and_no_margin() {
586 let theme = Theme::default();
587 let marker = Color::rgb8(0, 255, 0);
588 let laid = |banner: MaterialBanner<Msg>| {
589 let column = crate::Flex::column()
590 .child_boxed(banner.action(button("Save", Msg::Save)).build())
591 .child(
592 crate::Container::<Msg>::new()
593 .width(10.0)
594 .height(4.0)
595 .color(marker),
596 );
597 let painted = rects(&column);
598 let surface = painted
599 .iter()
600 .find(|(r, c)| *c == theme.scheme.surface_container_low && r.width > 300.0)
601 .expect("the banner's surface")
602 .0;
603 let below = painted
604 .iter()
605 .find(|(_, c)| *c == marker)
606 .expect("the marker")
607 .0;
608 let rule = painted
609 .iter()
610 .any(|(_, c)| *c == theme.scheme.outline_variant);
611 (below.y - (surface.y + surface.height), rule)
612 };
613 let (margin, rule) = laid(MaterialBanner::new("Message"));
614 assert!(
615 margin.abs() < 0.5,
616 "no margin under a flat banner: {margin}"
617 );
618 assert!(rule, "and the rule in its place");
619
620 let (margin, rule) = laid(MaterialBanner::new("Message").elevation(1.0));
625 assert!(
626 margin >= RAISED_MARGIN - 0.5,
627 "a lifted one keeps a margin: {margin}"
628 );
629 assert!(!rule, "and no rule");
630 }
631}