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) -> frus_core::InsetsGeometry {
51    // Directional, as the reference's are: the start is the right in a right-to-left script
52    // (milestone 588).
53    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
60/// What the banner says: `bodyMedium` (`banner.dart:519`).
61fn 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
66/// A message across the top of the screen with the actions that answer it.
67///
68/// It sits **in the flow**, not over it: a banner is put above the content it is about,
69/// usually as the first child of a screen's column. Nothing hides it on a timer, which is
70/// the whole difference from a snack bar — an action does.
71pub 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    /// A banner saying `content`. **Add at least one action**: a banner stays until one is
90    /// taken, and the reference makes them required for that reason.
91    pub fn new(content: impl Into<String>) -> Self {
92        Self::with_content(crate::Text::new(content))
93    }
94
95    /// The same, with a widget in place of the text.
96    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    /// A glyph before the message.
116    #[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    /// Adds an action. **One** action sits on the message's own line; two or more take a
123    /// line of their own (`banner.dart:348`).
124    #[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    /// Puts the actions on a line of their own even when there is only one.
131    #[must_use]
132    pub fn force_actions_below(mut self, force: bool) -> Self {
133        self.force_actions_below = force;
134        self
135    }
136
137    /// What the message is set in. `bodyMedium` by default.
138    #[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    /// The banner's colour. A low container tone by default.
145    #[must_use]
146    pub fn background(mut self, color: Color) -> Self {
147        self.background = Some(color);
148        self
149    }
150
151    /// What the surface is tinted towards for its elevation. Unset, nothing is — the
152    /// reference's Material 3 answer (`banner.dart:513`).
153    #[must_use]
154    pub fn surface_tint(mut self, color: Color) -> Self {
155        self.surface_tint = Some(color);
156        self
157    }
158
159    /// The shadow's colour. Unset, there is none.
160    #[must_use]
161    pub fn shadow_color(mut self, color: Color) -> Self {
162        self.shadow_color = Some(color);
163        self
164    }
165
166    /// The rule along the bottom, which is drawn **only when the banner is flat** — see
167    /// [`Self::elevation`].
168    #[must_use]
169    pub fn divider_color(mut self, color: Color) -> Self {
170        self.divider_color = Some(color);
171        self
172    }
173
174    /// How far off the page it sits. `0` by default — see [`BANNER_ELEVATION`].
175    ///
176    /// It decides two other things, which is why it is worth naming: a banner off the page
177    /// keeps a 10-pixel margin under it so its shadow has room, and a banner **flat** on
178    /// the page draws a rule along its bottom edge instead (`banner.dart:425`). Height or
179    /// a line, never both — the same either-or `Card` makes between a shadow and a
180    /// hairline.
181    #[must_use]
182    pub fn elevation(mut self, elevation: f32) -> Self {
183        self.elevation = Some(elevation);
184        self
185    }
186
187    /// The padding around the message row.
188    #[must_use]
189    pub fn padding(mut self, padding: Insets) -> Self {
190        self.padding = Some(padding);
191        self
192    }
193
194    /// The margin around the banner.
195    #[must_use]
196    pub fn margin(mut self, margin: Insets) -> Self {
197        self.margin = Some(margin);
198        self
199    }
200
201    /// What the leading slot is held off the message by.
202    #[must_use]
203    pub fn leading_padding(mut self, padding: Insets) -> Self {
204        self.leading_padding = Some(padding);
205        self
206    }
207
208    /// The shortest the actions bar gets. `52` by default.
209    #[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    /// Assembles the banner.
216    ///
217    /// Deferred to a [`ThemeBuilder`](crate::ThemeBuilder): whether the actions sit on the
218    /// message's line decides **which children exist and in what order**, and the padding
219    /// and the rule are the theme's to answer, so the composition cannot happen before the
220    /// theme is known.
221    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        // **One action goes on the message's line; two do not.** The reference's rule, and
247        // the reason its padding has two shapes: the tucked-in bar needs almost no top
248        // padding, the one below needs the message to breathe above it.
249        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        // Between the leading slot and the message: on the end side (milestone 588).
255        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            // **A bar, not a row** (`banner.dart:359`), for the reason a dialog's actions
263            // are one: a row neither wraps nor shrinks, so two actions that stopped
264            // fitting — a long label, or a reader's font size — were drawn past the end
265            // of the banner rather than under one another.
266            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            // A **floor**, not a height: the reference constrains the bar's minimum
274            // (`banner.dart:361`) so a taller action is not squeezed into 52 pixels. An
275            // imposed height would clip one, which is the failure a floor is written to
276            // avoid.
277            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        // The message row: the leading slot, then the message taking what is left, then
288        // the actions when there is one of them.
289        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        // **The padding is a column, not a row.** A row's child sits on its main axis and
315        // takes the width of its own content, so the message row inside would hug and the
316        // `Expanded` in it would have nothing to expand into — the action would land beside
317        // the words instead of at the far end of the line. A column stretches its child
318        // across, which is what puts the row on the banner's full width.
319        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        // **A rule only when the banner is flat.** Off the page it casts a shadow instead,
325        // and a banner with both would be saying its height twice.
326        if elevation == 0.0 {
327            // The reference's `Divider(height: 0)` means the line with no air around
328            // it. Here the height **is** the room the separator takes, so zero takes none
329            // and draws nothing: the flush hairline is `height == thickness`.
330            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
354/// The banner's surface: a flat block of colour with the margin around it, and a shadow
355/// when it is off the page.
356struct 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    /// A banner sits just off the page, on the ladder's low rung
367    /// (`banner.dart:510`).
368    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    /// The width it was **offered**, not the width its parent came out at.
390    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                // The reference's shadows for this height (milestone 606).
403                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    /// **One action rides on the message's line; two take a line of their own.** The
449    /// reference's rule (`banner.dart:348`), and the reason its padding has two shapes.
450    #[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    /// The same switch, said the other way round: forcing the actions below makes a single
483    /// action behave like two.
484    #[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    /// **A rule only when the banner is flat.** Off the page it casts a shadow instead,
510    /// and one with both would be saying its height twice (`banner.dart:425`).
511    #[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    /// The message takes the room the leading and the actions leave, rather than the width
531    /// of its own words — which is what `Expanded` is for and what a banner needs, since
532    /// the single action sits at the **far end** of the message's line.
533    #[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        // And it is at the far end: within the bar's own padding of the banner's edge,
564        // rather than sitting wherever a hugging message left it.
565        assert!(
566            action.x + action.width > WIDTH - 48.0,
567            "the action did not reach the trailing edge: {action:?}"
568        );
569    }
570
571    /// **A banner nobody lifted is flat** (the reference's banner build, line 375). Its Material 3
572    /// defaults say 1, and its build never reads that figure: the elevation is the
573    /// widget's, then the theme's, then nought. So an untold banner draws its rule and
574    /// keeps no margin under it — where this one kept ten pixels for a shadow it had no
575    /// colour for, and no rule.
576    #[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        // A lifted one keeps a margin and loses the rule. Not asserted to the pixel: it
613        // comes out at twenty rather than the reference's ten, because the theme builder
614        // the banner is assembled in hands the layout its child's margin and the child
615        // keeps it too. That is the builder's, and older than this.
616        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}