Skip to main content

frus_widgets/
toast.rs

1//! [`SnackBar`]: a transient notification (a styled card). The *system* around it
2//! (stacking, the auto-dismiss timer) is the application's responsibility,
3//! typically through a timed `Command`.
4
5use std::collections::VecDeque;
6
7use frus_core::{
8    BorderRadius, Color, Insets, Point, Rect, ResolvedTextStyle, Role, Scene, SemanticsProperties,
9    ShapeBorder, TextStyle,
10};
11use frus_layout::{Align, Dimension, Justify, Style};
12
13use crate::iconbutton::{ICON_BUTTON_ICON_SIZE, ICON_BUTTON_SIZE};
14use crate::interaction::Status;
15use crate::theme::Theme;
16use crate::widget::Widget;
17
18/// A snack bar's corner and how far off the page it sits (`snack_bar.dart:983`, `:980`).
19pub const SNACK_BAR_RADIUS: f32 = 4.0;
20pub const SNACK_BAR_ELEVATION: f32 = 6.0;
21/// The stripe for a **success**.
22///
23/// Every other colour here is a role. This one cannot be: Material 3's scheme carries
24/// `error` and nothing that means *it worked*, so a success has no role to reach for and
25/// a framework that shipped no colour at all would be shipping no success variant. It is
26/// this crate's own, and [`SnackBarTheme::success_color`] is where an application replaces
27/// it.
28const SUCCESS_ACCENT: Color = Color::rgb(70.0 / 255.0, 190.0 / 255.0, 120.0 / 255.0);
29
30/// A bar's horizontal padding, which is **not the same for both behaviours**
31/// (`snack_bar.dart:687`): a fixed bar spans the page and holds its text further in; a
32/// floating one is already held off the edges by its own margin.
33const PAD_X_FIXED: f32 = 24.0;
34const PAD_X_FLOATING: f32 = 16.0;
35/// What a **floating** bar keeps clear of the page (`snack_bar.dart:989`). A fixed one
36/// keeps nothing clear: it is fixed to the edge, which is what fixed means.
37const INSET_PADDING: Insets = Insets::new(5.0, 15.0, 10.0, 15.0);
38const PAD_Y: f32 = 12.0;
39const ACCENT: f32 = 4.0;
40/// The action button (Material's "UNDO"): padding and height.
41const ACTION_PAD_X: f32 = 12.0;
42const ACTION_GAP: f32 = 8.0;
43const ACTION_H: f32 = 32.0;
44/// The air either side of the close icon. The reference's is a **twelfth** of the bar's
45/// horizontal padding (`snack_bar.dart:698`), which is as near to nothing as a margin
46/// gets — the cross is meant to sit at the very end of the bar. It follows the padding,
47/// so it is not the same number for both behaviours either.
48fn close_margin(pad_x: f32) -> f32 {
49    pad_x / 12.0
50}
51/// The default label a reader hears on it, from the table in force
52/// (`snack_bar.dart:709`) — see [`crate::localizations`]. A caller may still say its own,
53/// which is what [`SnackBar::close_icon_label`] is for.
54fn close_label() -> String {
55    crate::localizations::of().close_button_label().to_string()
56}
57
58/// The message's style: what the caller said, else what the theme says, else the
59/// reference's — a snackbar's content is `bodyMedium`.
60///
61/// **Resolved once**, so that the number the box is measured with is the number the glyphs
62/// are drawn at. Resolving is the single place the reader's font setting is applied
63/// (milestone 403); a size that never passes through it is a size the reader cannot change.
64fn content_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
65    over.or(theme.and_then(|t| t.widgets.snack_bar.content_text_style))
66        .unwrap_or_else(|| crate::theme::type_scale(theme).body_medium)
67        .resolved()
68}
69
70/// The action's style — the reference's is `labelLarge`. See [`content_style`].
71fn action_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
72    over.or(theme.and_then(|t| t.widgets.snack_bar.action_text_style))
73        .unwrap_or_else(|| crate::theme::type_scale(theme).label_large)
74        .resolved()
75}
76
77/// The width an action label needs, its own padding included.
78fn action_width(label: &str, style: &ResolvedTextStyle) -> f32 {
79    (frus_text::measure_resolved(label, style).width + ACTION_PAD_X * 2.0).ceil()
80}
81
82/// **Where a snack bar sits, and therefore what it looks like** (`snack_bar_theme.dart:27`).
83///
84/// The reference treats this as one question with consequences rather than a bag of
85/// settings: a bar fixed to the bottom of the page and a bar floating above it differ in
86/// their corners, their padding and the room they keep clear, and they differ that way
87/// because of where they are.
88#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
89pub enum SnackBarBehavior {
90    /// **Fixed to the bottom of the page**: square corners, flush to the edges, its text
91    /// held further in. The reference's default (`snack_bar.dart:986`).
92    #[default]
93    Fixed,
94    /// **Floating above the page**: rounded, held clear of the edges by its own margin,
95    /// and drawn over everything rather than pushing anything up.
96    Floating,
97}
98
99/// The nature of a notification (its accent color).
100#[derive(Copy, Clone, Debug, PartialEq, Eq)]
101pub enum SnackBarKind {
102    /// Neutral information.
103    Info,
104    /// Something went well.
105    Success,
106    /// Something went wrong.
107    Error,
108}
109
110/// A transient notification, with an optional **action** (Material Snackbar style:
111/// "UNDO"). The action is a text button on the right that emits a message on click.
112pub struct SnackBar<Msg = crate::callback::Callback> {
113    text: String,
114    kind: SnackBarKind,
115    /// Where it sits; `None` follows the theme, which follows the reference's `Fixed`.
116    behavior: Option<SnackBarBehavior>,
117    /// What shape it is; `None` follows the theme, then the behaviour's own corner.
118    shape: Option<ShapeBorder>,
119    /// A floating bar's own width, over the room it is given. Floating only.
120    width: Option<f32>,
121    /// A floating bar's own margin, over `insetPadding`. Floating only.
122    margin: Option<Insets>,
123    content_text_style: Option<TextStyle>,
124    action_text_style: Option<TextStyle>,
125    background: Option<Color>,
126    text_color: Option<Color>,
127    action_text_color: Option<Color>,
128    accent: Option<Color>,
129    /// How far off the page it sits; `None` follows the theme, then the reference's 6.
130    elevation: Option<f32>,
131    /// The action's label and message, kept **beside** the child rather than only inside
132    /// it: the width it reserves is a measurement, and a measurement cannot be taken in a
133    /// builder, before any theme exists to say what type it is in.
134    action: Option<(String, Msg)>,
135    /// What a click on the cross emits, when there is one. See [`SnackBar::close_icon`].
136    close: Option<Msg>,
137    close_icon_color: Option<Color>,
138    close_label: Option<String>,
139    /// The trailing controls, in order: the action, then the cross.
140    children: Vec<Box<dyn Widget<Msg>>>,
141}
142
143impl<Msg: Clone + 'static> SnackBar<Msg> {
144    /// Creates an informational notification.
145    pub fn new(text: impl Into<String>) -> Self {
146        Self {
147            text: text.into(),
148            kind: SnackBarKind::Info,
149            behavior: None,
150            shape: None,
151            width: None,
152            margin: None,
153            content_text_style: None,
154            action_text_style: None,
155            background: None,
156            text_color: None,
157            action_text_color: None,
158            accent: None,
159            elevation: None,
160            action: None,
161            close: None,
162            close_icon_color: None,
163            close_label: None,
164            children: Vec::new(),
165        }
166    }
167
168    /// **Where the bar sits** (`snack_bar.dart:405`), over the theme's answer and the
169    /// reference's `Fixed`.
170    ///
171    /// It is one question with consequences, not a setting: see [`SnackBarBehavior`].
172    #[must_use]
173    pub fn behavior(mut self, behavior: SnackBarBehavior) -> Self {
174        self.behavior = Some(behavior);
175        self
176    }
177
178    /// **What shape it is** (`snack_bar.dart:798`), over the theme's.
179    ///
180    /// Milestone 446 established that a **fixed** bar has no corner at all and a floating
181    /// one rounds at 4, and milestone 451 resolved a shape for it — from the theme only.
182    /// A caller could not name one, and the resolution was reading the theme's answer in
183    /// the slot reserved for the caller's. Both are fixed here.
184    #[must_use]
185    pub fn shape(mut self, shape: ShapeBorder) -> Self {
186        self.shape = Some(shape);
187        self
188    }
189
190    /// **A floating bar's own width** (`snack_bar.dart:329`), instead of the room it is
191    /// given less its margin.
192    ///
193    /// Floating only, as in the reference: a fixed bar spans the page by definition, and
194    /// a width would be a contradiction rather than an override. Ignored under `Fixed`.
195    #[must_use]
196    pub fn width(mut self, width: f32) -> Self {
197        self.width = Some(width);
198        self
199    }
200
201    /// **A floating bar's own margin** (`snack_bar.dart:347`), over `insetPadding`.
202    ///
203    /// Floating only, for the same reason as [`SnackBar::width`]: what a fixed bar keeps
204    /// clear of the page is nothing, which is what fixed means. Ignored under `Fixed`.
205    #[must_use]
206    pub fn margin(mut self, margin: Insets) -> Self {
207        self.margin = Some(margin);
208        self
209    }
210
211    /// The bar's surface, over the theme's and the scheme's `inverse_surface`.
212    #[must_use]
213    pub fn background(mut self, color: Color) -> Self {
214        self.background = Some(color);
215        self
216    }
217
218    /// **How far off the page it sits**, over the theme's and the reference's 6 (its
219    /// snack bar build, line 794). `0.0` casts no shadow.
220    #[must_use]
221    pub fn elevation(mut self, elevation: f32) -> Self {
222        self.elevation = Some(elevation);
223        self
224    }
225
226    /// The message's colour, over the theme's and the scheme's `on_inverse_surface`.
227    #[must_use]
228    pub fn text_color(mut self, color: Color) -> Self {
229        self.text_color = Some(color);
230        self
231    }
232
233    /// The action's colour, over the theme's and the scheme's `inverse_primary`.
234    #[must_use]
235    pub fn action_text_color(mut self, color: Color) -> Self {
236        self.action_text_color = Some(color);
237        self.rebuild_controls();
238        self
239    }
240
241    /// The stripe down the leading edge, over the theme's and the kind's.
242    #[must_use]
243    pub fn accent(mut self, color: Color) -> Self {
244        self.accent = Some(color);
245        self
246    }
247
248    /// The message's type, over the theme's and the reference's.
249    #[must_use]
250    pub fn content_text_style(mut self, style: TextStyle) -> Self {
251        self.content_text_style = Some(style);
252        self
253    }
254
255    /// The action's type, over the theme's and the reference's.
256    #[must_use]
257    pub fn action_text_style(mut self, style: TextStyle) -> Self {
258        self.action_text_style = Some(style);
259        self.rebuild_controls();
260        self
261    }
262
263    /// The success variant.
264    pub fn success(mut self) -> Self {
265        self.kind = SnackBarKind::Success;
266        self
267    }
268
269    /// The error variant.
270    pub fn error(mut self) -> Self {
271        self.kind = SnackBarKind::Error;
272        self
273    }
274
275    /// Adds an **action button** (an uppercased label, Material style) that emits `message`
276    /// on click — typically "UNDO", to undo whatever triggered the notification.
277    pub fn action(mut self, label: impl Into<String>, message: Msg) -> Self {
278        self.action = Some((label.into().to_uppercase(), message));
279        self.rebuild_controls();
280        self
281    }
282
283    /// Carries the current action *and the current style* into the child.
284    ///
285    /// **The cross at the end of the bar** (`snack_bar.dart:700`), emitting `message`.
286    ///
287    /// The reference's property is a `bool`, because there a `ScaffoldMessenger` owns the
288    /// bar and the button can call `hideCurrentSnackBar` on it. Here the application owns
289    /// the queue — [`SnackBarQueue`] is deliberately application-side — so a bool would
290    /// draw a cross that does nothing, and a button that cannot say what it does is worse
291    /// than no button. It takes the message instead.
292    #[must_use]
293    pub fn close_icon(mut self, message: impl Into<Msg>) -> Self {
294        self.close = Some(message.into());
295        self.rebuild_controls();
296        self
297    }
298
299    /// The cross's colour, over the theme's and the scheme's `on_inverse_surface`
300    /// (`snack_bar.dart:995`).
301    #[must_use]
302    pub fn close_icon_color(mut self, color: Color) -> Self {
303        self.close_icon_color = Some(color);
304        self.rebuild_controls();
305        self
306    }
307
308    /// What a reader hears on the cross, over the word the table in force would give
309    /// (`snack_bar.dart:709`). See [`crate::localizations`].
310    #[must_use]
311    pub fn close_icon_label(mut self, label: impl Into<String>) -> Self {
312        self.close_label = Some(label.into());
313        self.rebuild_controls();
314        self
315    }
316
317    /// Called by every builder that touches them, so the builders are order-independent:
318    /// `.action(…)` then `.action_text_style(…)` and the reverse describe the same
319    /// notification, which a caller is entitled to assume and would otherwise have to
320    /// discover.
321    fn rebuild_controls(&mut self) {
322        self.children = Vec::new();
323        if let Some((label, message)) = &self.action {
324            self.children.push(Box::new(ActionButton {
325                label: label.clone(),
326                text_style: self.action_text_style,
327                color: self.action_text_color,
328                message: message.clone(),
329            }));
330        }
331        // **After** the action, which is the order the reference builds them in
332        // (`snack_bar.dart:742`): the cross is the last thing on the line, so the action
333        // keeps the place a reader looks for it whether or not there is a cross.
334        if let Some(message) = &self.close {
335            self.children.push(Box::new(CloseButton {
336                color: self.close_icon_color,
337                label: self.close_label.clone().unwrap_or_else(close_label),
338                message: message.clone(),
339            }));
340        }
341    }
342}
343
344impl<Msg> SnackBar<Msg> {
345    fn sizing(&self, theme: Option<&Theme>) -> Style {
346        let measured =
347            frus_text::measure_resolved(&self.text, &content_style(self.content_text_style, theme));
348        let action_w = self.action.as_ref().map_or(0.0, |(label, _)| {
349            action_width(label, &action_style(self.action_text_style, theme)) + ACTION_GAP
350        });
351        let pad_x = self.pad_x(theme);
352        let close_margin = close_margin(pad_x);
353        let close_w = self
354            .close
355            .as_ref()
356            .map_or(0.0, |_| ICON_BUTTON_SIZE + close_margin * 2.0);
357        // The bar is at least as tall as the tallest thing in it. A cross is a 40-pixel
358        // box where the action is 32, so a bar sized for the action alone would have cut
359        // the top and bottom off it.
360        let floor = if self.close.is_some() {
361            ICON_BUTTON_SIZE
362        } else {
363            ACTION_H
364        };
365        // A floating bar may be given a width of its own (`snack_bar.dart:329`); a fixed
366        // one may not, and says so by ignoring it rather than by asserting, this being a
367        // builder and not a debug-mode framework.
368        let asked = match self.placing(theme) {
369            SnackBarBehavior::Floating => {
370                self.width.or(theme.and_then(|t| t.widgets.snack_bar.width))
371            }
372            SnackBarBehavior::Fixed => None,
373        };
374        let mut style = Style {
375            width: Dimension::Length(
376                asked
377                    .unwrap_or((measured.width + pad_x * 2.0 + ACCENT + action_w + close_w).ceil()),
378            ),
379            height: Dimension::Length((measured.height + PAD_Y * 2.0).max(floor).ceil()),
380            // What it keeps clear of the page belongs to the **bar**, which is where the
381            // reference keeps it (`snack_bar.dart:823`) — not to whatever is hosting it.
382            margin: self.inset(theme),
383            ..Default::default()
384        };
385        // With controls: place them on the right, vertically centred, the cross tight
386        // against the end of the bar.
387        if !self.children.is_empty() {
388            style.justify = Justify::End;
389            style.align = Align::Center;
390            style.gap = close_margin;
391            style.padding = Insets::new(
392                0.0,
393                if self.close.is_some() {
394                    close_margin
395                } else {
396                    pad_x
397                },
398                0.0,
399                0.0,
400            );
401        }
402        style
403    }
404
405    /// Where this bar sits: its own word, then the theme's, then the reference's
406    /// `Fixed` (`snack_bar.dart:986`).
407    fn placing(&self, theme: Option<&Theme>) -> SnackBarBehavior {
408        self.behavior
409            .or(theme.and_then(|t| t.widgets.snack_bar.behavior))
410            .unwrap_or_default()
411    }
412
413    /// The horizontal padding that placing implies (`snack_bar.dart:687`).
414    fn pad_x(&self, theme: Option<&Theme>) -> f32 {
415        match self.placing(theme) {
416            SnackBarBehavior::Fixed => PAD_X_FIXED,
417            SnackBarBehavior::Floating => PAD_X_FLOATING,
418        }
419    }
420
421    /// What it keeps clear of the page: nothing when fixed; its own word, the theme's,
422    /// then `insetPadding` when floating (`snack_bar.dart:989`).
423    fn inset(&self, theme: Option<&Theme>) -> Insets {
424        match self.placing(theme) {
425            SnackBarBehavior::Fixed => Insets::ZERO,
426            SnackBarBehavior::Floating => self
427                .margin
428                .or(theme.and_then(|t| t.widgets.snack_bar.inset_padding))
429                .unwrap_or(INSET_PADDING),
430        }
431    }
432
433    /// The stripe's colour: the caller's word, then the theme's, then the kind's.
434    ///
435    /// Two of the three kinds now name a role. The third cannot — see [`SUCCESS_ACCENT`].
436    fn accent_color(&self, theme: &Theme) -> Color {
437        if let Some(color) = self.accent.or(theme.widgets.snack_bar.accent_color) {
438            return color;
439        }
440        match self.kind {
441            // The accent stands on the **inverted** surface, so it is the inverted
442            // accent — `primary` there is the pair the scheme guarantees nothing about.
443            SnackBarKind::Info => theme.scheme.inverse_primary,
444            SnackBarKind::Success => theme
445                .widgets
446                .snack_bar
447                .success_color
448                .unwrap_or(SUCCESS_ACCENT),
449            SnackBarKind::Error => theme.scheme.error,
450        }
451    }
452}
453
454impl<Msg: Clone> Widget<Msg> for SnackBar<Msg> {
455    fn style(&self) -> Style {
456        self.sizing(None)
457    }
458
459    fn style_themed(&self, theme: &Theme) -> Style {
460        self.sizing(Some(theme))
461    }
462
463    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
464        &self.children
465    }
466
467    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
468        let o = status.opacity;
469        let t = &theme.widgets.snack_bar;
470        // **A fixed bar has no shape.** The reference passes `shape` at all only when the
471        // bar is floating (`snack_bar.dart:798`), and a `Material` with no shape has
472        // square corners — a bar flush against three edges of the page has nothing to
473        // round, and rounding it would leave four slivers of page showing through.
474        let shape = crate::resolve_shape(
475            self.shape,
476            t.shape,
477            t.radius.map(BorderRadius::uniform),
478            ShapeBorder::rounded(match self.placing(Some(theme)) {
479                SnackBarBehavior::Fixed => 0.0,
480                SnackBarBehavior::Floating => SNACK_BAR_RADIUS,
481            }),
482        );
483        // The shadow is a rectangle whatever the bar is.
484        let radius = shape
485            .as_rounded(bounds)
486            .map(|(_, radius)| radius)
487            .unwrap_or(BorderRadius::ZERO);
488        let elevation = self
489            .elevation
490            .or(t.elevation)
491            .unwrap_or(SNACK_BAR_ELEVATION);
492        // A notification is **inverted**: it is not a card on the page, it is a bar that
493        // stands out from it (`snack_bar.dart:949`). The scheme has carried the pair for
494        // this since it was written, and said so in its own documentation.
495        let fill = self
496            .background
497            .or(t.background_color)
498            .unwrap_or(theme.scheme.inverse_surface);
499        // A bar on the page casts nothing: a shadow with no blur is a hard dark box.
500        if elevation > 0.0 {
501            // The reference's shadows for this height (milestone 606).
502            frus_core::paint_elevation(
503                scene,
504                bounds,
505                radius,
506                elevation,
507                theme.scheme.shadow.fade(o),
508            );
509        }
510        // No border: the inverted surface is what separates the bar from the page, and a
511        // rule round it would be edging a thing that is already distinct.
512        scene.draw_shape(bounds, shape, fill.fade(o));
513        // The stripe down the leading edge is this crate's own — the reference's bar has
514        // one look and no kinds — so it is drawn inside the corner rather than over it.
515        scene.draw_rect(
516            Rect::new(bounds.x, bounds.y, ACCENT, bounds.height),
517            self.accent_color(theme).fade(o),
518            0.0,
519            0.0,
520            Color::TRANSPARENT,
521        );
522        scene.text(
523            Point::new(
524                bounds.x + ACCENT + self.pad_x(Some(theme)),
525                bounds.y + PAD_Y,
526            ),
527            self.text.clone(),
528            &content_style(self.content_text_style, Some(theme)),
529            self.text_color
530                .or(t.text_color)
531                .unwrap_or(theme.scheme.on_inverse_surface)
532                .fade(o),
533        );
534    }
535
536    fn on_click(&self) -> Option<Msg> {
537        None
538    }
539}
540
541/// A notification's action button (uppercased text, accent color), clickable.
542struct ActionButton<Msg> {
543    label: String,
544    text_style: Option<TextStyle>,
545    /// The caller's colour, carried down from the bar so the two are said in one place.
546    color: Option<Color>,
547    message: Msg,
548}
549
550impl<Msg> ActionButton<Msg> {
551    /// `inverse_primary` (`snack_bar.dart:965`): the accent as it must be drawn on an
552    /// inverted surface.
553    fn color(&self, theme: &Theme) -> Color {
554        self.color
555            .or(theme.widgets.snack_bar.action_text_color)
556            .unwrap_or(theme.scheme.inverse_primary)
557    }
558}
559
560impl<Msg> ActionButton<Msg> {
561    fn sizing(&self, theme: Option<&Theme>) -> Style {
562        let style = action_style(self.text_style, theme);
563        Style {
564            width: Dimension::Length(action_width(&self.label, &style)),
565            height: Dimension::Length(frus_text::line_box(ACTION_H, &style, 0.0)),
566            ..Default::default()
567        }
568    }
569}
570
571impl<Msg: Clone> Widget<Msg> for ActionButton<Msg> {
572    fn style(&self) -> Style {
573        self.sizing(None)
574    }
575
576    fn style_themed(&self, theme: &Theme) -> Style {
577        self.sizing(Some(theme))
578    }
579
580    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
581        &[]
582    }
583
584    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
585        let o = status.opacity;
586        // Hover/focus background (a baked state layer: invisible at rest, tinted on
587        // interaction). It is layered on the **inverted** surface the button stands on,
588        // not on the page's — a state layer mixed from the wrong ground reads as a patch
589        // of the wrong colour rather than as a tint.
590        let label = self.color(theme);
591        let ground = theme
592            .widgets
593            .snack_bar
594            .background_color
595            .unwrap_or(theme.scheme.inverse_surface);
596        let bg = theme.state_layer(ground, label, &status);
597        scene.draw_rect(bounds, bg.fade(o), theme.radius, 0.0, Color::TRANSPARENT);
598        let style = action_style(self.text_style, Some(theme));
599        let w = frus_text::measure_resolved(&self.label, &style).width;
600        scene.text(
601            Point::new(
602                bounds.x + (bounds.width - w) * 0.5,
603                bounds.y + (bounds.height - style.line_height()) * 0.5,
604            ),
605            self.label.clone(),
606            &style,
607            label.fade(o),
608        );
609    }
610
611    fn on_click(&self) -> Option<Msg> {
612        Some(self.message.clone())
613    }
614
615    fn focusable(&self) -> bool {
616        true
617    }
618
619    fn semantics(&self) -> Option<SemanticsProperties> {
620        Some(
621            SemanticsProperties::new(Role::Button)
622                .label(self.label.clone())
623                .clickable(),
624        )
625    }
626}
627
628/// A notification's **close** button: the cross at the end of the bar
629/// (`snack_bar.dart:700`).
630struct CloseButton<Msg> {
631    /// The caller's colour, carried down from the bar so the two are said in one place.
632    color: Option<Color>,
633    label: String,
634    message: Msg,
635}
636
637impl<Msg> CloseButton<Msg> {
638    /// `on_inverse_surface` (`snack_bar.dart:995`): the cross stands on the inverted
639    /// surface, so it takes the ink that surface guarantees is legible.
640    fn color(&self, theme: &Theme) -> Color {
641        self.color
642            .or(theme.widgets.snack_bar.close_icon_color)
643            .unwrap_or(theme.scheme.on_inverse_surface)
644    }
645}
646
647impl<Msg: Clone> Widget<Msg> for CloseButton<Msg> {
648    fn style(&self) -> Style {
649        Style {
650            width: Dimension::Length(ICON_BUTTON_SIZE),
651            height: Dimension::Length(ICON_BUTTON_SIZE),
652            ..Default::default()
653        }
654    }
655
656    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
657        &[]
658    }
659
660    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
661        let o = status.opacity;
662        let ink = self.color(theme);
663        // Grounded on the **inverted** surface the bar painted, not on the page's, for
664        // the reason [`ActionButton`] gives: a state layer mixed from the wrong ground
665        // reads as a patch of the wrong colour rather than as a tint. It is also why this
666        // is not a plain [`crate::IconButton`], which grounds a standard one on nothing.
667        let ground = theme
668            .widgets
669            .snack_bar
670            .background_color
671            .unwrap_or(theme.scheme.inverse_surface);
672        let bg = theme.state_layer(ground, ink, &status);
673        scene.draw_rect(
674            bounds,
675            bg.fade(o),
676            bounds.height.min(bounds.width) * 0.5,
677            0.0,
678            Color::TRANSPARENT,
679        );
680        let size = ICON_BUTTON_ICON_SIZE;
681        let path = crate::Icons::CLOSE.placed(
682            size,
683            bounds.x + (bounds.width - size) * 0.5,
684            bounds.y + (bounds.height - size) * 0.5,
685            theme.direction,
686        );
687        scene.fill_path(&path, ink.fade(o));
688    }
689
690    fn on_click(&self) -> Option<Msg> {
691        Some(self.message.clone())
692    }
693
694    fn focusable(&self) -> bool {
695        true
696    }
697
698    fn semantics(&self) -> Option<SemanticsProperties> {
699        Some(
700            SemanticsProperties::new(Role::Button)
701                .label(self.label.clone())
702                .clickable(),
703        )
704    }
705}
706
707/// A **notification queue** — pure, application-side (in the spirit of [`crate::form::Form`]).
708///
709/// Only one notification is visible at a time; the others wait. The application calls
710/// [`tick`](Self::tick) on every frame (with the elapsed time) to **expire** the current
711/// notification and present the next — Material-style auto-dismiss with no timer on the
712/// widget side. [`dismiss`](Self::dismiss) closes the current one immediately (a click on
713/// the action or the cross). Generic over the payload `T` (at minimum the text; often the
714/// kind and the action message too).
715pub struct SnackBarQueue<T> {
716    /// `(payload, seconds left, leaving)`; the front one is the notification on display.
717    /// The "leaving" flag lets the host play an **exit transition** (a fade) before the
718    /// removal (see [`start_leaving`](Self::start_leaving) / [`is_leaving`](Self::is_leaving)).
719    items: VecDeque<(T, f32, bool)>,
720}
721
722impl<T> Default for SnackBarQueue<T> {
723    fn default() -> Self {
724        Self {
725            items: VecDeque::new(),
726        }
727    }
728}
729
730impl<T> SnackBarQueue<T> {
731    /// An empty queue.
732    pub fn new() -> Self {
733        Self::default()
734    }
735
736    /// Adds a notification that will stay visible `seconds` seconds once **at the front**.
737    pub fn push(&mut self, item: T, seconds: f32) {
738        self.items.push_back((item, seconds.max(0.0), false));
739    }
740
741    /// The notification currently visible (the front of the queue), if there is one.
742    pub fn current(&self) -> Option<&T> {
743        self.items.front().map(|(item, _, _)| item)
744    }
745
746    /// Runs `dt` seconds down on the notification at the front; if its time is up, it is
747    /// removed (the next one starts its own countdown). Returns `true` if the visible
748    /// notification **changed** (an expiry) — useful to request another render.
749    pub fn tick(&mut self, dt: f32) -> bool {
750        let Some(front) = self.items.front_mut() else {
751            return false;
752        };
753        front.1 -= dt;
754        if front.1 <= 0.0 {
755            self.items.pop_front();
756            true
757        } else {
758            false
759        }
760    }
761
762    /// Marks the current notification as **leaving**: the host can then play its exit
763    /// transition (a fade) before the application removes it (via [`dismiss`](Self::dismiss)).
764    pub fn start_leaving(&mut self) {
765        if let Some(front) = self.items.front_mut() {
766            front.2 = true;
767        }
768    }
769
770    /// Is the current notification **leaving**? (a disappearing fade is under way.)
771    pub fn is_leaving(&self) -> bool {
772        self.items.front().is_some_and(|(_, _, leaving)| *leaving)
773    }
774
775    /// Closes the current notification at once (action, cross, end of exit); returns its payload.
776    pub fn dismiss(&mut self) -> Option<T> {
777        self.items.pop_front().map(|(item, _, _)| item)
778    }
779
780    /// `true` when no notification is pending.
781    pub fn is_empty(&self) -> bool {
782        self.items.is_empty()
783    }
784
785    /// Number of queued notifications (the visible one + those pending).
786    pub fn len(&self) -> usize {
787        self.items.len()
788    }
789}
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794    use frus_core::Primitive;
795
796    #[derive(Clone, Debug, PartialEq)]
797    enum Msg {
798        Undo,
799        Dismiss,
800    }
801
802    /// Everything one notification paints, in one place.
803    fn painted(bar: &SnackBar<()>, theme: &Theme) -> Scene {
804        let mut scene = Scene::new();
805        Widget::<()>::paint(
806            bar,
807            Rect::new(0.0, 0.0, 160.0, 44.0),
808            Status::default(),
809            theme,
810            &mut scene,
811        );
812        scene
813    }
814
815    /// The corner of the first crisp rectangle covering the whole box.
816    fn corner_of(scene: &Scene) -> Option<f32> {
817        scene.primitives().iter().find_map(|p| match p {
818            Primitive::Rect {
819                rect, radius, blur, ..
820            } if *blur == 0.0 && rect.width == 160.0 => Some(radius.top_left),
821            _ => None,
822        })
823    }
824
825    /// Where the message starts, horizontally.
826    fn text_x(scene: &Scene) -> Option<f32> {
827        scene.primitives().iter().find_map(|p| match p {
828            Primitive::Text { position, .. } => Some(position.x),
829            _ => None,
830        })
831    }
832
833    /// **A bar is fixed unless it is told otherwise** (`snack_bar.dart:986`), and a fixed
834    /// bar has **no corners**: the reference passes a shape at all only when the bar is
835    /// floating (`:798`), and a bar flush against three edges of the page has nothing to
836    /// round — rounding it would leave four slivers of page showing through.
837    ///
838    /// This rounded every bar, and held its text in by a floating bar's 16 whichever it
839    /// was (`:687`).
840    #[test]
841    fn a_bar_is_fixed_by_default_and_has_no_corners() {
842        let theme = Theme::default();
843        let bar = SnackBar::<Msg>::new("Saved");
844        let scene = painted(&SnackBar::<()>::new("Saved"), &theme);
845        assert_eq!(corner_of(&scene), Some(0.0), "square, being flush");
846        assert_eq!(
847            text_x(&scene),
848            Some(ACCENT + PAD_X_FIXED),
849            "and its text held in by a fixed bar's padding"
850        );
851        assert_eq!(
852            Widget::<Msg>::style_themed(&bar, &theme).margin,
853            Insets::ZERO,
854            "a fixed bar keeps nothing clear of the page: that is what fixed means"
855        );
856    }
857
858    /// **A floating bar rounds, and keeps clear of the page** by its own margin
859    /// (`snack_bar.dart:989`, `:823`) — which belongs to the bar and not to whatever is
860    /// hosting it.
861    #[test]
862    fn a_floating_bar_rounds_and_keeps_clear_of_the_page() {
863        let theme = Theme::default();
864        let scene = painted(
865            &SnackBar::<()>::new("Saved").behavior(SnackBarBehavior::Floating),
866            &theme,
867        );
868        assert_eq!(corner_of(&scene), Some(SNACK_BAR_RADIUS));
869        assert_eq!(
870            text_x(&scene),
871            Some(ACCENT + PAD_X_FLOATING),
872            "and its text is held in less, the margin having held it in already"
873        );
874
875        let bar = SnackBar::<Msg>::new("Saved").behavior(SnackBarBehavior::Floating);
876        assert_eq!(
877            Widget::<Msg>::style_themed(&bar, &theme).margin,
878            INSET_PADDING
879        );
880        let own = Insets::uniform(4.0);
881        assert_eq!(
882            Widget::<Msg>::style_themed(
883                &SnackBar::<Msg>::new("Saved")
884                    .behavior(SnackBarBehavior::Floating)
885                    .margin(own),
886                &theme
887            )
888            .margin,
889            own,
890            "and a caller may say what it keeps clear"
891        );
892    }
893
894    /// **A width, and a margin, are a floating bar's alone** (`snack_bar.dart:678`): a
895    /// fixed bar spans the page by definition, so both would contradict the behaviour
896    /// rather than override it. The reference asserts; a builder can only ignore.
897    #[test]
898    fn a_width_is_a_floating_bar_s_alone() {
899        let theme = Theme::default();
900        let floating = SnackBar::<Msg>::new("Saved")
901            .behavior(SnackBarBehavior::Floating)
902            .width(300.0);
903        assert_eq!(width_of(&floating), 300.0);
904
905        let fixed = SnackBar::<Msg>::new("Saved")
906            .width(300.0)
907            .margin(Insets::uniform(9.0));
908        assert_ne!(
909            width_of(&fixed),
910            300.0,
911            "a fixed bar spans what it is given"
912        );
913        assert_eq!(
914            Widget::<Msg>::style_themed(&fixed, &theme).margin,
915            Insets::ZERO,
916            "and keeps nothing clear, whatever it was told"
917        );
918    }
919
920    /// And the **theme** answers for a whole application's bars, as it does for every
921    /// other widget here: the caller's word, then the theme's, then the reference's.
922    #[test]
923    fn a_theme_places_every_bar_at_once() {
924        let mut theme = Theme::default();
925        theme.widgets.snack_bar.behavior = Some(SnackBarBehavior::Floating);
926        let scene = {
927            let mut scene = Scene::new();
928            Widget::<()>::paint(
929                &SnackBar::<()>::new("Saved"),
930                Rect::new(0.0, 0.0, 160.0, 44.0),
931                Status::default(),
932                &theme,
933                &mut scene,
934            );
935            scene
936        };
937        assert_eq!(
938            corner_of(&scene),
939            Some(SNACK_BAR_RADIUS),
940            "the theme's word"
941        );
942        assert_eq!(
943            Widget::<Msg>::style_themed(&SnackBar::<Msg>::new("Saved"), &theme).margin,
944            INSET_PADDING
945        );
946        assert_eq!(
947            Widget::<Msg>::style_themed(
948                &SnackBar::<Msg>::new("Saved").behavior(SnackBarBehavior::Fixed),
949                &theme
950            )
951            .margin,
952            Insets::ZERO,
953            "and one bar may still answer for itself"
954        );
955    }
956
957    /// The colour of the first crisp rectangle covering the whole box: the bar's surface,
958    /// drawn before the stripe that sits on one edge of it.
959    fn surface_of(scene: &Scene) -> Option<Color> {
960        scene.primitives().iter().find_map(|p| match p {
961            Primitive::Rect {
962                rect, color, blur, ..
963            } if *blur == 0.0 && rect.width == 160.0 => Some(*color),
964            _ => None,
965        })
966    }
967
968    fn text_color(scene: &Scene) -> Option<Color> {
969        scene.primitives().iter().find_map(|p| match p {
970            Primitive::Text { color, .. } => Some(*color),
971            _ => None,
972        })
973    }
974
975    /// The width a notification asks for.
976    fn width_of(bar: &SnackBar<Msg>) -> f32 {
977        match Widget::<Msg>::style_themed(bar, &Theme::default()).width {
978            Dimension::Length(w) => w,
979            other => panic!("a notification names its width, got {other:?}"),
980        }
981    }
982
983    /// The height a notification asks for.
984    fn height_of(bar: &SnackBar<Msg>) -> f32 {
985        match Widget::<Msg>::style_themed(bar, &Theme::default()).height {
986            Dimension::Length(h) => h,
987            other => panic!("a notification names its height, got {other:?}"),
988        }
989    }
990
991    /// Paints one of a notification's trailing controls, in its own box.
992    fn control(bar: &SnackBar<Msg>, index: usize, status: Status, theme: &Theme) -> Scene {
993        let mut scene = Scene::new();
994        Widget::<Msg>::children(bar)[index].paint(
995            Rect::new(0.0, 0.0, ICON_BUTTON_SIZE, ICON_BUTTON_SIZE),
996            status,
997            theme,
998            &mut scene,
999        );
1000        scene
1001    }
1002
1003    /// **A close icon is a button that says what it does** (`snack_bar.dart:700`).
1004    ///
1005    /// The reference's property is a `bool`, because a `ScaffoldMessenger` owns the bar
1006    /// there and the cross can hide it. Here the application owns the queue, so a bool
1007    /// would draw a cross that does nothing.
1008    #[test]
1009    fn a_close_icon_is_a_button_that_says_what_it_does() {
1010        let bar = SnackBar::new("Message archived").close_icon(Msg::Dismiss);
1011        let children = Widget::<Msg>::children(&bar);
1012        assert_eq!(children.len(), 1, "the cross, and nothing else");
1013        assert_eq!(children[0].on_click(), Some(Msg::Dismiss));
1014        assert!(children[0].focusable(), "and the keyboard can reach it");
1015
1016        let heard = |bar: &SnackBar<Msg>| {
1017            Widget::<Msg>::children(bar)[0]
1018                .semantics()
1019                .and_then(|s| s.label)
1020                .expect("a cross with no name is a cross nobody can use")
1021        };
1022        assert_eq!(heard(&bar), close_label());
1023        assert_eq!(
1024            heard(
1025                &SnackBar::new("x")
1026                    .close_icon(Msg::Dismiss)
1027                    .close_icon_label("Fermer")
1028            ),
1029            "Fermer",
1030            "the caller is the only one who can translate it"
1031        );
1032    }
1033
1034    /// **The cross comes after the action** (`snack_bar.dart:742`), so the action keeps
1035    /// the place a reader looks for it whether or not there is a cross.
1036    #[test]
1037    fn the_cross_comes_after_the_action() {
1038        let bar = SnackBar::new("Message archived")
1039            .close_icon(Msg::Dismiss)
1040            .action("Undo", Msg::Undo);
1041        let children = Widget::<Msg>::children(&bar);
1042        assert_eq!(children.len(), 2);
1043        assert_eq!(children[0].on_click(), Some(Msg::Undo));
1044        assert_eq!(children[1].on_click(), Some(Msg::Dismiss));
1045    }
1046
1047    /// And the bar makes room for it: wider by the cross's box, and **at least as tall**
1048    /// as it. A bar sized for the action alone is 32 high, and the cross is 40.
1049    #[test]
1050    fn a_bar_with_a_cross_makes_room_for_it() {
1051        let bare = SnackBar::new("Saved");
1052        let crossed = SnackBar::new("Saved").close_icon(Msg::Dismiss);
1053        assert!(
1054            (width_of(&crossed)
1055                - width_of(&bare)
1056                - (ICON_BUTTON_SIZE + close_margin(PAD_X_FIXED) * 2.0))
1057                .abs()
1058                < 1.0,
1059            "{} against {}",
1060            width_of(&crossed),
1061            width_of(&bare)
1062        );
1063        // Asked with type small enough that the message alone does not already make the
1064        // bar tall enough, which is what makes the floor a floor rather than a comment.
1065        let small = TextStyle {
1066            size: Some(8.0),
1067            ..Default::default()
1068        };
1069        let tiny = SnackBar::new("Saved")
1070            .close_icon(Msg::Dismiss)
1071            .content_text_style(small);
1072        assert!(
1073            height_of(&SnackBar::new("Saved").content_text_style(small)) < ICON_BUTTON_SIZE,
1074            "the premise: a bar of small type is shorter than a cross"
1075        );
1076        assert!(
1077            height_of(&tiny) >= ICON_BUTTON_SIZE,
1078            "the cross would have been cut off: {}",
1079            height_of(&tiny)
1080        );
1081    }
1082
1083    /// The cross takes the ink that is legible on the bar (`snack_bar.dart:995`), and the
1084    /// caller and the theme each outrank the scheme.
1085    #[test]
1086    fn the_cross_takes_the_ink_that_is_legible_on_the_bar() {
1087        let mut theme = Theme::default();
1088        let ink = |bar: &SnackBar<Msg>, theme: &Theme| {
1089            control(bar, 0, Status::default(), theme)
1090                .primitives()
1091                .iter()
1092                .find_map(|p| match p {
1093                    Primitive::Path { fill, .. } => *fill,
1094                    _ => None,
1095                })
1096                .expect("the cross is drawn")
1097        };
1098        let bar = SnackBar::new("x").close_icon(Msg::Dismiss);
1099        assert_eq!(ink(&bar, &theme), theme.scheme.on_inverse_surface);
1100
1101        theme.widgets.snack_bar.close_icon_color = Some(Color::rgb8(1, 2, 3));
1102        assert_eq!(ink(&bar, &theme), Color::rgb8(1, 2, 3));
1103
1104        let told = SnackBar::new("x")
1105            .close_icon(Msg::Dismiss)
1106            .close_icon_color(Color::rgb8(4, 5, 6));
1107        assert_eq!(ink(&told, &theme), Color::rgb8(4, 5, 6));
1108    }
1109
1110    /// **Its state layer is grounded on the bar**, not on the page. A layer mixed from
1111    /// the wrong ground reads as a patch of the wrong colour rather than as a tint —
1112    /// which is also why this is not a plain `IconButton`, whose standard variant grounds
1113    /// on nothing at all.
1114    #[test]
1115    fn the_cross_grounds_its_state_layer_on_the_bar() {
1116        let theme = Theme::default();
1117        let bar = SnackBar::new("x").close_icon(Msg::Dismiss);
1118        let hovered = Status {
1119            opacity: 1.0,
1120            hover_progress: 1.0,
1121            ..Default::default()
1122        };
1123        let fill = control(&bar, 0, hovered, &theme)
1124            .primitives()
1125            .iter()
1126            .find_map(|p| match p {
1127                Primitive::Rect { color, .. } => Some(*color),
1128                _ => None,
1129            })
1130            .expect("a hovered cross lights");
1131        assert_eq!(fill.a, 1.0, "resolved here, not handed over as an alpha");
1132        assert_eq!(
1133            fill,
1134            theme.state_layer(
1135                theme.scheme.inverse_surface,
1136                theme.scheme.on_inverse_surface,
1137                &hovered
1138            )
1139        );
1140    }
1141
1142    #[test]
1143    fn paints_accent_and_text() {
1144        let scene = painted(&SnackBar::<()>::new("Saved").success(), &Theme::default());
1145        assert!(scene
1146            .primitives()
1147            .iter()
1148            .any(|p| matches!(p, Primitive::Rect { color, .. } if *color == SUCCESS_ACCENT)));
1149        assert!(scene
1150            .primitives()
1151            .iter()
1152            .any(|p| matches!(p, Primitive::Text { text, .. } if text == "Saved")));
1153    }
1154
1155    /// A notification is **inverted**: it does not sit on the page, it stands out from it
1156    /// (`snack_bar.dart:949`). The scheme has carried the pair for exactly this since it
1157    /// was written, saying so in its own documentation, and the bar never used it.
1158    #[test]
1159    fn a_notification_stands_out_rather_than_sitting_on_the_page() {
1160        let theme = Theme::default();
1161        let scene = painted(&SnackBar::<()>::new("Saved"), &theme);
1162        assert_eq!(
1163            surface_of(&scene),
1164            Some(theme.scheme.inverse_surface),
1165            "not a card on the page"
1166        );
1167        assert_ne!(
1168            theme.scheme.inverse_surface, theme.surface,
1169            "and the two have to be tellable apart for that to mean anything"
1170        );
1171        assert_eq!(
1172            text_color(&scene),
1173            Some(theme.scheme.on_inverse_surface),
1174            "with the message that is legible on it"
1175        );
1176    }
1177
1178    /// The action takes the one role whose whole reason for existing is being legible on
1179    /// an inverted surface (`snack_bar.dart:965`).
1180    #[test]
1181    fn the_action_takes_the_inverted_accent() {
1182        let theme = Theme::default();
1183        let bar = SnackBar::<()>::new("Deleted").action("Undo", ());
1184        let kids = Widget::<()>::children(&bar);
1185        let mut scene = Scene::new();
1186        kids[0].paint(
1187            Rect::new(0.0, 0.0, 60.0, ACTION_H),
1188            Status::default(),
1189            &theme,
1190            &mut scene,
1191        );
1192        assert_eq!(text_color(&scene), Some(theme.scheme.inverse_primary));
1193        assert_ne!(
1194            theme.scheme.inverse_primary, theme.primary,
1195            "which is not the page's accent, and is the point"
1196        );
1197    }
1198
1199    /// Two of the three kinds name a role now. The third cannot — Material 3 carries
1200    /// `error` and nothing that means *it worked* — so it keeps a colour of this crate's
1201    /// own, and the theme is where an application replaces it.
1202    #[test]
1203    fn the_kinds_name_a_role_wherever_one_exists() {
1204        let mut theme = Theme::default();
1205        assert_eq!(
1206            SnackBar::<()>::new("x").accent_color(&theme),
1207            theme.scheme.inverse_primary
1208        );
1209        assert_eq!(
1210            SnackBar::<()>::new("x").error().accent_color(&theme),
1211            theme.scheme.error
1212        );
1213        assert_eq!(
1214            SnackBar::<()>::new("x").success().accent_color(&theme),
1215            SUCCESS_ACCENT
1216        );
1217
1218        let told = Color::rgb8(1, 2, 3);
1219        theme.widgets.snack_bar.success_color = Some(told);
1220        assert_eq!(
1221            SnackBar::<()>::new("x").success().accent_color(&theme),
1222            told,
1223            "the one colour without a role is the one a theme most needs to reach"
1224        );
1225        assert_eq!(
1226            SnackBar::<()>::new("x").accent(told).accent_color(&theme),
1227            told,
1228            "and the caller outranks all three kinds"
1229        );
1230    }
1231
1232    #[test]
1233    fn action_is_clickable_and_uppercased() {
1234        // Without an action: no children.
1235        let plain = SnackBar::<Msg>::new("Item deleted");
1236        assert!(Widget::<Msg>::children(&plain).is_empty());
1237        // With an action: an uppercased button that emits the message.
1238        let toast = SnackBar::new("Item deleted").action("Undo", Msg::Undo);
1239        let kids = Widget::<Msg>::children(&toast);
1240        assert_eq!(kids.len(), 1);
1241        assert_eq!(kids[0].on_click(), Some(Msg::Undo));
1242        assert!(kids[0].focusable());
1243    }
1244
1245    #[test]
1246    fn queue_shows_one_at_a_time_and_expires() {
1247        let mut q: SnackBarQueue<&str> = SnackBarQueue::new();
1248        assert!(q.is_empty());
1249        q.push("first", 3.0);
1250        q.push("second", 3.0);
1251        assert_eq!(q.len(), 2);
1252        assert_eq!(q.current(), Some(&"first"), "the front is the visible one");
1253        // The countdown only touches the front.
1254        assert!(!q.tick(1.0));
1255        assert_eq!(q.current(), Some(&"first"));
1256        // Expiry → the next one takes over.
1257        assert!(q.tick(2.5), "a change at expiry");
1258        assert_eq!(q.current(), Some(&"second"));
1259        // A manual close (action or cross).
1260        assert_eq!(q.dismiss(), Some("second"));
1261        assert!(q.is_empty());
1262        assert!(!q.tick(1.0), "an empty queue: nothing changes");
1263    }
1264
1265    #[test]
1266    fn leaving_phase_precedes_dismissal() {
1267        let mut q: SnackBarQueue<&str> = SnackBarQueue::new();
1268        assert!(!q.is_leaving(), "empty queue: nothing leaving");
1269        q.push("hello", 3.0);
1270        assert!(!q.is_leaving(), "on display: not leaving yet");
1271        // Trigger the exit (fade) without removing it right away.
1272        q.start_leaving();
1273        assert!(q.is_leaving(), "leaving");
1274        assert_eq!(q.current(), Some(&"hello"), "still visible while leaving");
1275        // Then the actual removal.
1276        assert_eq!(q.dismiss(), Some("hello"));
1277        assert!(!q.is_leaving());
1278        assert!(q.is_empty());
1279    }
1280
1281    /// **A bar can be told its own height, and a flat one casts nothing.** The reference's
1282    /// snack bar build takes the widget's, the theme's, then six (line 794); here it was the
1283    /// theme's or six with no word for the bar itself, and the shadow was drawn whatever it
1284    /// came to — at nought, an unblurred box of shadow the size of the bar.
1285    #[test]
1286    fn a_bar_takes_its_own_elevation_and_casts_nothing_at_nought() {
1287        // The height each shadow was cast from (milestone 606). A bar at nought casts none.
1288        let shadows = |bar: &SnackBar<()>, theme: &Theme| {
1289            crate::shadowprobe::heights(painted(bar, theme).primitives())
1290        };
1291        let theme = Theme::default();
1292        assert_eq!(
1293            shadows(&SnackBar::new("Saved"), &theme),
1294            vec![SNACK_BAR_ELEVATION]
1295        );
1296        assert_eq!(
1297            shadows(&SnackBar::new("Saved").elevation(3.0), &theme),
1298            vec![3.0]
1299        );
1300        assert!(
1301            shadows(&SnackBar::new("Saved").elevation(0.0), &theme).is_empty(),
1302            "a bar on the page casts nothing"
1303        );
1304
1305        let mut flat = Theme::default();
1306        flat.widgets.snack_bar.elevation = Some(0.0);
1307        assert!(
1308            shadows(&SnackBar::new("Saved"), &flat).is_empty(),
1309            "nor one the theme put there"
1310        );
1311        assert_eq!(
1312            shadows(&SnackBar::new("Saved").elevation(2.0), &flat),
1313            vec![2.0],
1314            "the bar's word over the theme's"
1315        );
1316    }
1317}