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