Skip to main content

frus_widgets/
banner.rs

1//! [`MaterialBanner`]: a **message across the top of the screen**, with the actions that
2//! answer it, staying until one of them is taken.
3//!
4//! The third of the three the reference has for saying something to the reader, and each
5//! is a different promise about how long it stays:
6//!
7//! | | how long | where |
8//! |---|---|---|
9//! | [`SnackBar`](crate::SnackBar) | a few seconds | over the bottom |
10//! | `MaterialBanner` | until an action is taken | in the flow, above the content |
11//! | [`AlertDialog`](crate::AlertDialog) | until it is answered | over everything, with a barrier |
12//!
13//! It is the one [`Alert`](crate::Alert) resembles without being: an `Alert` has kinds and
14//! an accent bar and no actions, which is a callout. A banner **requires** actions —
15//! `required this.actions` (`banner.dart:109`) — because a message that stays until it is
16//! dismissed and offers no way to dismiss it stays for ever.
17//!
18//! ```ignore
19//! MaterialBanner::new("You have unsaved changes.")
20//!     .leading(Icon::new(Icons::WARNING))
21//!     .action(button("Discard", Msg::Discard))
22//!     .action(button("Save", Msg::Save))
23//! ```
24
25use 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
32/// How far off the page a banner sits when nobody says: **`0`**, flat.
33///
34/// The reference's Material 3 banner defaults say `1` (line 503), but its build reads
35/// only the widget's elevation and the theme's before falling to nought
36/// (line 375), so the defaults' figure is never used. What a reader sees is a flat
37/// banner, with the rule under it and no margin, and that is what this is.
38pub const BANNER_ELEVATION: f32 = 0.0;
39/// The shortest an actions bar gets (`banner.dart:123`).
40pub const BANNER_MIN_ACTION_BAR_HEIGHT: f32 = 52.0;
41/// The gap between two actions, and the padding around their bar (`banner.dart:363`).
42const ACTION_GAP: f32 = 8.0;
43/// What the leading slot is held off the content by (`banner.dart:358`).
44const LEADING_GAP: f32 = 16.0;
45/// The margin under a banner that is off the page at all (`banner.dart:377`).
46const RAISED_MARGIN: f32 = 10.0;
47
48/// The banner's padding, which depends on **where the actions went**
49/// (`banner.dart:352`): tucked into the row beside the content, or on a line of their own.
50fn 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
58/// What the banner says: `bodyMedium` (`banner.dart:519`).
59fn 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
64/// A message across the top of the screen with the actions that answer it.
65///
66/// It sits **in the flow**, not over it: a banner is put above the content it is about,
67/// usually as the first child of a screen's column. Nothing hides it on a timer, which is
68/// the whole difference from a snack bar — an action does.
69pub 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    /// A banner saying `content`. **Add at least one action**: a banner stays until one is
88    /// taken, and the reference makes them required for that reason.
89    pub fn new(content: impl Into<String>) -> Self {
90        Self::with_content(crate::Text::new(content))
91    }
92
93    /// The same, with a widget in place of the text.
94    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    /// A glyph before the message.
114    #[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    /// Adds an action. **One** action sits on the message's own line; two or more take a
121    /// line of their own (`banner.dart:348`).
122    #[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    /// Puts the actions on a line of their own even when there is only one.
129    #[must_use]
130    pub fn force_actions_below(mut self, force: bool) -> Self {
131        self.force_actions_below = force;
132        self
133    }
134
135    /// What the message is set in. `bodyMedium` by default.
136    #[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    /// The banner's colour. A low container tone by default.
143    #[must_use]
144    pub fn background(mut self, color: Color) -> Self {
145        self.background = Some(color);
146        self
147    }
148
149    /// What the surface is tinted towards for its elevation. Unset, nothing is — the
150    /// reference's Material 3 answer (`banner.dart:513`).
151    #[must_use]
152    pub fn surface_tint(mut self, color: Color) -> Self {
153        self.surface_tint = Some(color);
154        self
155    }
156
157    /// The shadow's colour. Unset, there is none.
158    #[must_use]
159    pub fn shadow_color(mut self, color: Color) -> Self {
160        self.shadow_color = Some(color);
161        self
162    }
163
164    /// The rule along the bottom, which is drawn **only when the banner is flat** — see
165    /// [`Self::elevation`].
166    #[must_use]
167    pub fn divider_color(mut self, color: Color) -> Self {
168        self.divider_color = Some(color);
169        self
170    }
171
172    /// How far off the page it sits. `0` by default — see [`BANNER_ELEVATION`].
173    ///
174    /// It decides two other things, which is why it is worth naming: a banner off the page
175    /// keeps a 10-pixel margin under it so its shadow has room, and a banner **flat** on
176    /// the page draws a rule along its bottom edge instead (`banner.dart:425`). Height or
177    /// a line, never both — the same either-or `Card` makes between a shadow and a
178    /// hairline.
179    #[must_use]
180    pub fn elevation(mut self, elevation: f32) -> Self {
181        self.elevation = Some(elevation);
182        self
183    }
184
185    /// The padding around the message row.
186    #[must_use]
187    pub fn padding(mut self, padding: Insets) -> Self {
188        self.padding = Some(padding);
189        self
190    }
191
192    /// The margin around the banner.
193    #[must_use]
194    pub fn margin(mut self, margin: Insets) -> Self {
195        self.margin = Some(margin);
196        self
197    }
198
199    /// What the leading slot is held off the message by.
200    #[must_use]
201    pub fn leading_padding(mut self, padding: Insets) -> Self {
202        self.leading_padding = Some(padding);
203        self
204    }
205
206    /// The shortest the actions bar gets. `52` by default.
207    #[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    /// Assembles the banner.
214    ///
215    /// Deferred to a [`ThemeBuilder`](crate::ThemeBuilder): whether the actions sit on the
216    /// message's line decides **which children exist and in what order**, and the padding
217    /// and the rule are the theme's to answer, so the composition cannot happen before the
218    /// theme is known.
219    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        // **One action goes on the message's line; two do not.** The reference's rule, and
245        // the reason its padding has two shapes: the tucked-in bar needs almost no top
246        // padding, the one below needs the message to breathe above it.
247        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            // **A bar, not a row** (`banner.dart:359`), for the reason a dialog's actions
259            // are one: a row neither wraps nor shrinks, so two actions that stopped
260            // fitting — a long label, or a reader's font size — were drawn past the end
261            // of the banner rather than under one another.
262            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            // A **floor**, not a height: the reference constrains the bar's minimum
270            // (`banner.dart:361`) so a taller action is not squeezed into 52 pixels. An
271            // imposed height would clip one, which is the failure a floor is written to
272            // avoid.
273            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        // The message row: the leading slot, then the message taking what is left, then
284        // the actions when there is one of them.
285        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        // **The padding is a column, not a row.** A row's child sits on its main axis and
316        // takes the width of its own content, so the message row inside would hug and the
317        // `Expanded` in it would have nothing to expand into — the action would land beside
318        // the words instead of at the far end of the line. A column stretches its child
319        // across, which is what puts the row on the banner's full width.
320        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        // **A rule only when the banner is flat.** Off the page it casts a shadow instead,
329        // and a banner with both would be saying its height twice.
330        if elevation == 0.0 {
331            // The reference's `Divider(height: 0)` means the line with no air around
332            // it. Here the height **is** the room the separator takes, so zero takes none
333            // and draws nothing: the flush hairline is `height == thickness`.
334            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
358/// The banner's surface: a flat block of colour with the margin around it, and a shadow
359/// when it is off the page.
360struct 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    /// A banner sits just off the page, on the ladder's low rung
371    /// (`banner.dart:510`).
372    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    /// The width it was **offered**, not the width its parent came out at.
394    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    /// **One action rides on the message's line; two take a line of their own.** The
457    /// reference's rule (`banner.dart:348`), and the reason its padding has two shapes.
458    #[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    /// The same switch, said the other way round: forcing the actions below makes a single
491    /// action behave like two.
492    #[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    /// **A rule only when the banner is flat.** Off the page it casts a shadow instead,
518    /// and one with both would be saying its height twice (`banner.dart:425`).
519    #[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    /// The message takes the room the leading and the actions leave, rather than the width
539    /// of its own words — which is what `Expanded` is for and what a banner needs, since
540    /// the single action sits at the **far end** of the message's line.
541    #[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        // And it is at the far end: within the bar's own padding of the banner's edge,
572        // rather than sitting wherever a hugging message left it.
573        assert!(
574            action.x + action.width > WIDTH - 48.0,
575            "the action did not reach the trailing edge: {action:?}"
576        );
577    }
578
579    /// **A banner nobody lifted is flat** (the reference's banner build, line 375). Its Material 3
580    /// defaults say 1, and its build never reads that figure: the elevation is the
581    /// widget's, then the theme's, then nought. So an untold banner draws its rule and
582    /// keeps no margin under it — where this one kept ten pixels for a shadow it had no
583    /// colour for, and no rule.
584    #[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        // A lifted one keeps a margin and loses the rule. Not asserted to the pixel: it
621        // comes out at twenty rather than the reference's ten, because the theme builder
622        // the banner is assembled in hands the layout its child's margin and the child
623        // keeps it too. That is the builder's, and older than this.
624        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}