Skip to main content

qframe/widgets/
toast.rs

1//! Toasts: short notifications that stack in a screen corner and go away on their own.
2//!
3//! Applications show a toast by returning [`Command::toast`](crate::runtime::Command::toast)
4//! from `update`. The runtime owns the stack, because a toast outlives the view that asked for
5//! it, counts down while the application is idle and must be drawn above every layer. The
6//! application only hears back through the toast's action message.
7//!
8//! A toast never covers an open modal layer, such as a dialog or the command palette: the stack
9//! keeps to the rows between its corner and the dialog's surface, and a toast that finds no
10//! room there waits until it does, when the dialog closes or the screen grows. A toast's time
11//! runs only while it is on screen, so a waiting toast is not missed.
12
13use std::time::Duration;
14
15use super::Spinner;
16use super::cells;
17use super::close_mark;
18use crate::animation::AnimationName;
19use crate::color::Rgb;
20use crate::geometry::{Rect, clamp_u16};
21use crate::motion::{Easing, steps};
22use crate::style::CellStyle;
23use crate::text;
24use crate::widget::{Key, PaintCx, Widget, WidgetId};
25
26/// What a toast reports; picks its status colour and icon.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum ToastKind {
29    /// Something finished well.
30    Success,
31    /// Something needs attention.
32    Warning,
33    /// Something failed.
34    Danger,
35    /// Neutral news.
36    #[default]
37    Info,
38}
39
40impl ToastKind {
41    /// Every kind.
42    pub const ALL: [Self; 4] = [Self::Success, Self::Warning, Self::Danger, Self::Info];
43
44    /// A short name, e.g. for settings screens.
45    #[must_use]
46    pub fn name(self) -> &'static str {
47        match self {
48            Self::Success => "success",
49            Self::Warning => "warning",
50            Self::Danger => "danger",
51            Self::Info => "info",
52        }
53    }
54
55    fn icon(self) -> &'static str {
56        match self {
57            Self::Danger => "error",
58            other => other.name(),
59        }
60    }
61}
62
63/// The screen corner toasts stack in.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
65pub enum Corner {
66    /// Top right; the newest toast is on top.
67    TopRight,
68    /// Bottom right; the newest toast is at the bottom.
69    #[default]
70    BottomRight,
71    /// Bottom left; the newest toast is at the bottom.
72    BottomLeft,
73    /// Top left; the newest toast is on top.
74    TopLeft,
75}
76
77impl Corner {
78    /// Every corner.
79    pub const ALL: [Self; 4] = [Self::TopRight, Self::BottomRight, Self::BottomLeft, Self::TopLeft];
80
81    /// A short name, e.g. for settings screens.
82    #[must_use]
83    pub fn name(self) -> &'static str {
84        match self {
85            Self::TopRight => "top-right",
86            Self::BottomRight => "bottom-right",
87            Self::BottomLeft => "bottom-left",
88            Self::TopLeft => "top-left",
89        }
90    }
91
92    fn right(self) -> bool {
93        matches!(self, Self::TopRight | Self::BottomRight)
94    }
95
96    fn bottom(self) -> bool {
97        matches!(self, Self::BottomRight | Self::BottomLeft)
98    }
99}
100
101/// How long a toast stays when nothing else is said.
102const DEFAULT_DURATION: Duration = Duration::from_secs(5);
103
104/// How long a toast with an action stays, so there is time to reach the button.
105const ACTION_DURATION: Duration = Duration::from_secs(8);
106
107/// Widest a toast grows, in cells.
108const MAX_WIDTH: u16 = 56;
109
110/// Cells from the inner left edge of a toast to its text: a cell before the icon, the icon
111/// (`icon_width` cells) and two cells after it.
112fn text_indent(icon_width: u16) -> u16 {
113    icon_width.saturating_add(3)
114}
115
116/// A notification: a status marker and icon, a title, an optional body and action, and the
117/// shared three-cell close mark at the end of the title row.
118///
119/// Only a press on the close mark dismisses a toast; a press on the rest of it does nothing unless
120/// [`on_press`](Self::on_press) gives it a message, e.g. to open where the news came from.
121///
122/// Style keys: `toast` (`bg`, `padding`) with `hover` while the pointer is on a pressable toast,
123/// `toast-title`, `toast-body`, `toast-action` with `active` on a raised toast and `hover`, and
124/// `close-mark` (see tabs). The marker and icon use the `success`, `warning`,
125/// `danger` and `info` colours; an animated icon uses `spinner` with the kind as its variant.
126pub struct Toast<Msg> {
127    kind: ToastKind,
128    title: String,
129    body: Option<String>,
130    action: Option<(String, Msg)>,
131    duration: Option<Duration>,
132    key: Option<String>,
133    icon_motion: Option<AnimationName>,
134    /// Makes the message a press on the toast sends; called on every press, because the toast stays.
135    on_press: Option<Box<dyn Fn() -> Msg>>,
136}
137
138impl<Msg> Toast<Msg> {
139    /// A toast of `kind` saying `title`.
140    #[must_use]
141    pub fn new(kind: ToastKind, title: impl Into<String>) -> Self {
142        Self {
143            kind,
144            title: title.into(),
145            body: None,
146            action: None,
147            duration: None,
148            key: None,
149            icon_motion: None,
150            on_press: None,
151        }
152    }
153
154    /// A success toast.
155    #[must_use]
156    pub fn success(title: impl Into<String>) -> Self {
157        Self::new(ToastKind::Success, title)
158    }
159
160    /// A warning toast.
161    #[must_use]
162    pub fn warning(title: impl Into<String>) -> Self {
163        Self::new(ToastKind::Warning, title)
164    }
165
166    /// A danger toast.
167    #[must_use]
168    pub fn danger(title: impl Into<String>) -> Self {
169        Self::new(ToastKind::Danger, title)
170    }
171
172    /// An info toast.
173    #[must_use]
174    pub fn info(title: impl Into<String>) -> Self {
175        Self::new(ToastKind::Info, title)
176    }
177
178    /// A faint line of detail under the title, wrapped to the toast width.
179    #[must_use]
180    pub fn body(mut self, body: impl Into<String>) -> Self {
181        self.body = Some(body.into());
182        self
183    }
184
185    /// A button on the title row; clicking it sends `message` and dismisses the toast.
186    #[must_use]
187    pub fn action(mut self, label: impl Into<String>, message: Msg) -> Self {
188        self.action = Some((label.into(), message));
189        self
190    }
191
192    /// How long the toast stays while the pointer is not on it. Default: 5 s, or 8 s with an
193    /// action.
194    #[must_use]
195    pub fn duration(mut self, duration: Duration) -> Self {
196        self.duration = Some(duration);
197        self
198    }
199
200    /// Names the toast: showing another toast with the same key replaces it, and
201    /// [`Command::dismiss_toast`](crate::runtime::Command::dismiss_toast) removes it.
202    #[must_use]
203    pub fn key(mut self, key: impl Into<String>) -> Self {
204        self.key = Some(key.into());
205        self
206    }
207
208    /// Plays a one-cell [`Spinner`] animation in the icon cell instead of the kind's icon, in
209    /// the kind's colour, e.g. [`SpinnerStyle::Pulse`](super::SpinnerStyle::Pulse) while something
210    /// is still running. It takes a spinner style or the name of any
211    /// [cell animation](crate::animation), such as one a theme defines. With reduced motion the
212    /// kind's icon stands still instead. Replace the toast through its [`key`](Self::key) with one
213    /// without animation when the work is done.
214    ///
215    /// ```
216    /// use qframe::prelude::*;
217    /// use qframe::widgets::{SpinnerStyle, Toast};
218    ///
219    /// let running: Toast<()> = Toast::info("Deploying api-gateway").icon_motion(SpinnerStyle::Pulse).key("deploy");
220    /// let done: Toast<()> = Toast::success("Deployed api-gateway").key("deploy");
221    /// let _ = Command::batch([Command::toast(running), Command::toast(done)]);
222    /// ```
223    #[must_use]
224    pub fn icon_motion(mut self, animation: impl Into<AnimationName>) -> Self {
225        self.icon_motion = Some(animation.into());
226        self
227    }
228}
229
230impl<Msg: 'static> Toast<Msg> {
231    /// The same toast sending `map(message)` for its action and presses.
232    pub(crate) fn map<B: 'static>(self, map: std::sync::Arc<dyn Fn(Msg) -> B + Send + Sync>) -> Toast<B> {
233        let action = self.action.map(|(label, message)| (label, map(message)));
234        let on_press = self.on_press.map(|press| Box::new(move || map(press())) as Box<dyn Fn() -> B>);
235        Toast {
236            kind: self.kind,
237            title: self.title,
238            body: self.body,
239            action,
240            duration: self.duration,
241            key: self.key,
242            icon_motion: self.icon_motion,
243            on_press,
244        }
245    }
246}
247
248impl<Msg: Clone + 'static> Toast<Msg> {
249    /// Makes the toast pressable: a press anywhere on it but its action and close mark sends
250    /// `message`, e.g. to open the log or the page the news came from. The toast stays; only the
251    /// close mark dismisses it. While the pointer is on it the toast rises one tone.
252    ///
253    /// ```
254    /// use qframe::prelude::*;
255    /// use qframe::widgets::Toast;
256    ///
257    /// #[derive(Clone)]
258    /// enum Msg {
259    ///     OpenDeployLog,
260    /// }
261    ///
262    /// let toast: Toast<Msg> = Toast::success("Deployed api-gateway").on_press(Msg::OpenDeployLog);
263    /// let _ = Command::toast(toast);
264    /// ```
265    #[must_use]
266    pub fn on_press(mut self, message: Msg) -> Self {
267        self.on_press = Some(Box::new(move || message.clone()));
268        self
269    }
270}
271
272struct Entry<Msg> {
273    toast: Toast<Msg>,
274    id: WidgetId,
275    remaining: Duration,
276    /// When the countdown was last advanced; `None` until first painted.
277    ticked: Option<Duration>,
278    shown_at: Option<Duration>,
279    leaving: bool,
280    left_at: Option<Duration>,
281    /// Found no room in the last frame: not drawn, its time stopped and its entrance still to
282    /// come.
283    waiting: bool,
284    rect: Rect,
285    action_rect: Rect,
286    close_rect: Rect,
287}
288
289/// What a press on the toast stack did.
290pub(crate) enum ToastPress<Msg> {
291    /// The press landed on a toast that has nothing to do with it; it goes no further.
292    Held,
293    /// The press on a close mark dismissed its toast.
294    Dismissed,
295    /// The press chose a toast's action, which also dismissed it.
296    Action(Msg),
297    /// The press landed on a pressable toast, which stays.
298    Pressed(Msg),
299}
300
301/// The toasts the runtime shows.
302pub(crate) struct ToastStack<Msg> {
303    entries: Vec<Entry<Msg>>,
304    corner: Corner,
305    next_id: u64,
306}
307
308impl<Msg> Default for ToastStack<Msg> {
309    fn default() -> Self {
310        Self { entries: Vec::new(), corner: Corner::default(), next_id: 0 }
311    }
312}
313
314impl<Msg> ToastStack<Msg> {
315    /// Adds `toast`, replacing a visible toast with the same key in place.
316    pub(crate) fn push(&mut self, toast: Toast<Msg>) {
317        let remaining =
318            toast.duration.unwrap_or(if toast.action.is_some() { ACTION_DURATION } else { DEFAULT_DURATION });
319        if let Some(key) = &toast.key
320            && let Some(entry) = self.entries.iter_mut().find(|e| !e.leaving && e.toast.key.as_ref() == Some(key))
321        {
322            entry.toast = toast;
323            entry.remaining = remaining;
324            return;
325        }
326        self.next_id += 1;
327        let id = WidgetId::ROOT.child(&Key::Named(format!("quvyta.toast.{}", self.next_id)), "Toast");
328        self.entries.push(Entry {
329            toast,
330            id,
331            remaining,
332            ticked: None,
333            shown_at: None,
334            leaving: false,
335            left_at: None,
336            waiting: false,
337            rect: Rect::default(),
338            action_rect: Rect::default(),
339            close_rect: Rect::default(),
340        });
341    }
342
343    /// Starts removing the toast named `key`.
344    pub(crate) fn dismiss(&mut self, key: &str) {
345        for entry in self.entries.iter_mut().filter(|e| e.toast.key.as_deref() == Some(key)) {
346            entry.leaving = true;
347        }
348    }
349
350    /// Chooses where toasts stack.
351    pub(crate) fn set_corner(&mut self, corner: Corner) {
352        self.corner = corner;
353    }
354
355    /// Handles a pointer press at a cell; `None` when no toast is there. Only the close mark and
356    /// the action dismiss; the rest of a toast sends its `on_press` message, if any.
357    pub(crate) fn press(&mut self, x: i32, y: i32) -> Option<ToastPress<Msg>> {
358        let entry = self.entries.iter_mut().rev().find(|e| !e.leaving && e.rect.contains(x, y))?;
359        if entry.close_rect.contains(x, y) {
360            entry.leaving = true;
361            return Some(ToastPress::Dismissed);
362        }
363        if entry.action_rect.contains(x, y)
364            && let Some((_, message)) = entry.toast.action.take()
365        {
366            entry.leaving = true;
367            return Some(ToastPress::Action(message));
368        }
369        Some(entry.toast.on_press.as_ref().map_or(ToastPress::Held, |message| ToastPress::Pressed(message())))
370    }
371
372    /// Counts down, lays out and draws the stack over everything else but open modal layers,
373    /// which it keeps clear of.
374    pub(crate) fn paint(&mut self, cx: &mut PaintCx<'_>) {
375        let now = cx.now();
376        let enter = cx.env().theme().motion().enter;
377        let reduced = cx.reduced_motion();
378        let pointer = cx.pointer_anywhere();
379        for entry in &mut self.entries {
380            if entry.waiting {
381                // Nobody saw it yet, so its time has not started.
382                continue;
383            }
384            let hovered = pointer.is_some_and(|(x, y)| entry.rect.contains(x, y));
385            let since = entry.ticked.unwrap_or(now);
386            if !hovered && !entry.leaving {
387                entry.remaining = entry.remaining.saturating_sub(now.saturating_sub(since));
388            }
389            entry.ticked = Some(now);
390            entry.shown_at.get_or_insert(now);
391            if entry.remaining.is_zero() {
392                entry.leaving = true;
393            }
394            if entry.leaving && entry.left_at.is_none() {
395                entry.left_at = Some(now);
396            }
397        }
398        // A toast dismissed while it waited was never seen, so it goes without sliding out.
399        self.entries.retain(|entry| {
400            !(entry.waiting && entry.leaving) && entry.left_at.is_none_or(|left| !reduced && now < left + enter)
401        });
402
403        let screen = cx.clip();
404        let style = cx.style("toast", None, &[]);
405        let padding = style.padding();
406        let width = MAX_WIDTH.min(screen.width.saturating_sub(4));
407        let corner = self.corner;
408        let x = if corner.right() { screen.right() - 2 - i32::from(width) } else { screen.x + 2 };
409        let modals = cx.modal_surfaces(screen);
410        let (top_limit, bottom_limit) = room(screen, Rect::new(x, screen.y, width, screen.height), corner, &modals);
411        let mut y = if corner.bottom() { bottom_limit - 1 } else { top_limit + 1 };
412        let mut placing = width >= 12;
413        // Newest nearest the corner.
414        for index in (0..self.entries.len()).rev() {
415            let icon_width = text::width(&cx.env().icons().glyph(self.entries[index].toast.kind.icon()));
416            let body_width = width.saturating_sub(padding.horizontal().saturating_add(text_indent(icon_width)));
417            let body_lines =
418                self.entries[index].toast.body.as_deref().map_or(0, |body| text::wrap(body, body_width).len());
419            let height = cells::sum([padding.vertical(), 1, clamp_u16(i32::try_from(body_lines).unwrap_or(0))]);
420            let top = if corner.bottom() { y - i32::from(height) } else { y };
421            // The first toast without room waits, and so does every older one, so the stack
422            // keeps its order.
423            placing = placing && top >= top_limit && top + i32::from(height) <= bottom_limit;
424            let entry = &mut self.entries[index];
425            if !placing {
426                if !entry.waiting {
427                    entry.waiting = true;
428                    entry.ticked = None;
429                    entry.shown_at = None;
430                    entry.rect = Rect::default();
431                }
432                continue;
433            }
434            if entry.waiting {
435                // Its time starts now, and it slides in as if it had just been shown.
436                entry.waiting = false;
437                entry.ticked = Some(now);
438                entry.shown_at = Some(now);
439            }
440            // Slides in from the screen edge over `motion.enter`, and back out when leaving.
441            let arrived = cx.progress_since(entry.shown_at.unwrap_or(now), enter, Easing::EaseOut);
442            let gone = entry.left_at.map_or(0.0, |left| cx.progress_since(left, enter, Easing::EaseIn));
443            let presence = (arrived - gone).clamp(0.0, 1.0);
444            let offset = i32::from(steps(1.0 - presence, width + 2));
445            let shift = if corner.right() { offset } else { -offset };
446            entry.rect = Rect::new(x, top, width, height);
447            paint_entry(cx, entry, Rect::new(x + shift, top, width, height), presence);
448            if !entry.leaving {
449                cx.register_hit_as(entry.rect, entry.id);
450                if !pointer.is_some_and(|(px, py)| entry.rect.contains(px, py)) {
451                    cx.request_frame_in(entry.remaining);
452                }
453            }
454            y = if corner.bottom() { top - 1 } else { top + i32::from(height) + 1 };
455        }
456    }
457}
458
459/// The rows toasts in `column` may use, as a top and an exclusive bottom: the whole `screen`,
460/// cut back to the side of every modal surface that shares columns with the stack where the
461/// corner is. A row stays free between a surface and the toasts, as between two toasts.
462fn room(screen: Rect, column: Rect, corner: Corner, modals: &[Rect]) -> (i32, i32) {
463    let (mut top, mut bottom) = (screen.y, screen.bottom());
464    for modal in modals.iter().filter(|modal| modal.x < column.right() && column.x < modal.right()) {
465        if corner.bottom() {
466            top = top.max(modal.bottom() + 1);
467        } else {
468            bottom = bottom.min(modal.y - 1);
469        }
470    }
471    (top, bottom)
472}
473
474fn paint_entry<Msg>(cx: &mut PaintCx<'_>, entry: &mut Entry<Msg>, rect: Rect, presence: f32) {
475    let pointer = cx.pointer_anywhere();
476    // Only a pressable toast answers the pointer; the close mark and action light on their own.
477    let raised = entry.toast.on_press.is_some() && pointer.is_some_and(|(x, y)| entry.rect.contains(x, y));
478    let states = if raised { vec![crate::theme::State::Hover] } else { Vec::new() };
479    let style = cx.style("toast", None, &states);
480    let padding = style.padding();
481    let background = style.text().bg.unwrap_or_else(|| cx.color("overlay"));
482    // A toast floats over whatever the screen shows there and keeps apart from it; see
483    // `PaintCx::floating`. The lift is known before painting, so text arrives from the surface
484    // as it will show.
485    let grounds = cx.grounds_around(rect);
486    let lift = cx.lift_for(rect, &grounds, Some(background));
487    let surface = lift.map_or(background, |lift| lift.apply(background));
488    // Colours arrive with the cells: text blends from the surface as the toast slides in.
489    let blend = |color: Option<Rgb>| color.map(|c| surface.mix(c, presence));
490    let status = cx.color(entry.toast.kind.name());
491    cx.clear(rect, background);
492    cx.fill(Rect::new(rect.x, rect.y, 1, rect.height), background.mix(status, presence));
493
494    let inner = rect.inset(padding);
495    let content_x = inner.x + 1;
496    let icon = cx.env().icons().glyph(entry.toast.kind.icon()).into_owned();
497    let icon_width = text::width(&icon);
498    match entry.toast.icon_motion.clone().filter(|_| !cx.reduced_motion()) {
499        Some(animation) => {
500            // The spinner draws itself in the kind's colour; the cell then arrives with the toast
501            // like every other colour on it.
502            let cell = Rect::new(content_x, inner.y, 1, 1);
503            let spinner = Spinner::new().animation(animation).variant(entry.toast.kind.name());
504            Widget::<()>::paint(&spinner, cx, cell);
505            cx.tint(cell, background, 1.0 - presence);
506        }
507        None => {
508            let style = CellStyle { fg: blend(Some(status)), ..CellStyle::default() };
509            cx.text(content_x, inner.y, &icon, style, icon_width);
510        }
511    }
512    // The text keeps its column whether the icon moves or not, so a keyed toast that settles
513    // from a spinner to its icon does not jump.
514    let text_x = inner.x + i32::from(text_indent(icon_width));
515
516    // Where the stack is laid out, as opposed to where the slide draws it: presses land there.
517    let slid = rect.x - entry.rect.x;
518    // The close mark ends on the first cell of the right padding, as on tabs. It keeps its resting
519    // whisper on the toast and lights only under the pointer, so it never looks like the target
520    // of a press elsewhere on the toast.
521    let mark_x = inner.right() - i32::from(close_mark::WIDTH) + 1;
522    let mark = close_mark::paint(cx, mark_x, inner.y, false);
523    cx.tint(mark, background, 1.0 - presence);
524    entry.close_rect = Rect::new(mark.x - slid, mark.y, mark.width, 1);
525    // One cell of surface between the mark and whatever comes before it.
526    let mut right = mark_x - 1;
527    entry.action_rect = Rect::default();
528    if let Some((label, _)) = &entry.toast.action {
529        let label_width = text::width(label).saturating_add(2);
530        let action = Rect::new(right - i32::from(label_width), inner.y, label_width, 1);
531        let target = Rect::new(action.x - slid, action.y, action.width, 1);
532        // On a raised toast the button climbs with it, so it stays a step above its surface.
533        let mut states = if raised { vec![crate::theme::State::Active] } else { Vec::new() };
534        if pointer.is_some_and(|(x, y)| target.contains(x, y)) {
535            states.push(crate::theme::State::Hover);
536        }
537        let action_style = cx.style("toast-action", None, &states).text();
538        if let Some(bg) = action_style.bg {
539            cx.fill(action, background.mix(bg, presence));
540        }
541        cx.text(
542            action.x + 1,
543            action.y,
544            label,
545            CellStyle { fg: blend(action_style.fg), bg: None, ..action_style },
546            label_width,
547        );
548        entry.action_rect = target;
549        right = action.x - 2;
550    }
551
552    let title_style = cx.style("toast-title", None, &[]).text();
553    let budget = clamp_u16(right - text_x);
554    let title = text::truncate(&entry.toast.title, budget).into_owned();
555    cx.text(text_x, inner.y, &title, CellStyle { fg: blend(title_style.fg), bg: None, ..title_style }, budget);
556    if let Some(body) = &entry.toast.body {
557        let body_style = cx.style("toast-body", None, &[]).text();
558        let body_width = clamp_u16(inner.right() - text_x);
559        for (row, line) in text::wrap(body, body_width).iter().enumerate() {
560            let y = inner.y + 1 + i32::try_from(row).unwrap_or(0);
561            cx.text(text_x, y, line, CellStyle { fg: blend(body_style.fg), bg: None, ..body_style }, body_width);
562        }
563    }
564    if let Some(lift) = lift {
565        cx.lift(rect, lift);
566    }
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572    use crate::runtime::{App, Command, Harness};
573    use crate::widget::{Length, View};
574    use crate::widgets::{Button, SpinnerStyle, Text};
575
576    #[derive(Default)]
577    struct Demo {
578        undone: u32,
579        pressed: u32,
580        opened: u32,
581    }
582
583    #[derive(Clone)]
584    enum Msg {
585        Deployed,
586        Failed,
587        Progress(u32),
588        Finish,
589        Undo,
590        Corner,
591        Press,
592        Loading(SpinnerStyle),
593        Loaded,
594        Pressable,
595        OpenLog,
596    }
597
598    impl App for Demo {
599        type Msg = Msg;
600        fn update(&mut self, msg: Msg) -> Command<Msg> {
601            match msg {
602                Msg::Deployed => Command::toast(Toast::success("Deployed api-gateway").body("v2.14.0 is live")),
603                Msg::Failed => Command::toast(Toast::danger("Build failed").action("Undo", Msg::Undo)),
604                Msg::Progress(n) => Command::toast(Toast::info(format!("Uploading {n}%")).key("upload")),
605                Msg::Finish => Command::dismiss_toast("upload"),
606                Msg::Undo => {
607                    self.undone += 1;
608                    Command::none()
609                }
610                Msg::Corner => Command::toast_corner(Corner::TopLeft),
611                Msg::Loading(style) => Command::toast(Toast::info("Uploading backup").icon_motion(style).key("upload")),
612                Msg::Loaded => Command::toast(Toast::success("Backup uploaded").key("upload")),
613                Msg::Press => {
614                    self.pressed += 1;
615                    Command::none()
616                }
617                Msg::Pressable => Command::toast(
618                    Toast::success("Deployed api-gateway").action("Undo", Msg::Undo).on_press(Msg::OpenLog),
619                ),
620                Msg::OpenLog => {
621                    self.opened += 1;
622                    Command::none()
623                }
624            }
625        }
626        fn view(&self, ui: &mut View<'_, Msg>) {
627            ui.column(|ui| {
628                ui.add(Text::new("dashboard"));
629                ui.add(Button::new("Refresh").on_press(Msg::Press)).width(Length::Cells(60));
630            });
631        }
632    }
633
634    #[test]
635    fn slides_in_at_the_bottom_right_and_leaves_after_its_duration() {
636        let mut h = Harness::new(Demo::default(), 60, 10);
637        h.send(Msg::Deployed);
638        assert!(!h.screen().contains("Deployed"), "starts beyond the edge: {}", h.screen());
639        h.advance(Duration::from_millis(200));
640        let screen = h.screen();
641        let lines: Vec<&str> = screen.lines().collect();
642        assert_eq!(lines[6], "     ✓  Deployed api-gateway                           ×", "{screen}");
643        assert_eq!(lines[7], "        v2.14.0 is live");
644        let theme = h.env().theme();
645        assert_eq!(h.bg(2, 6), theme.color("success"));
646        assert_eq!(h.fg(5, 6), theme.color("success"));
647        assert_eq!(h.bg(20, 5), theme.color("overlay"));
648        h.advance(Duration::from_secs(5));
649        h.advance(Duration::from_millis(200));
650        assert!(!h.screen().contains("Deployed"), "{}", h.screen());
651    }
652
653    #[test]
654    fn hovering_pauses_the_countdown() {
655        let mut h = Harness::new(Demo::default(), 60, 10);
656        h.set_reduced_motion(true).send(Msg::Deployed);
657        h.hover(30, 6).advance(Duration::from_secs(10));
658        assert!(h.screen().contains("Deployed"));
659        h.hover(0, 0).advance(Duration::from_secs(4));
660        assert!(h.screen().contains("Deployed"));
661        h.advance(Duration::from_secs(2));
662        assert!(!h.screen().contains("Deployed"));
663    }
664
665    #[test]
666    fn the_action_sends_its_message_and_dismisses_without_reaching_below() {
667        let mut h = Harness::new(Demo::default(), 60, 10);
668        h.set_reduced_motion(true).send(Msg::Failed);
669        h.click_text("Undo");
670        assert_eq!((h.app().undone, h.app().pressed), (1, 0));
671        assert!(!h.screen().contains("Build failed"));
672        h.send(Msg::Failed).send(Msg::Deployed);
673        let screen = h.screen();
674        let failed = h.find("Build failed").expect("stacked");
675        let deployed = h.find("Deployed").expect("newest");
676        assert!(failed.1 < deployed.1, "newest is nearest the corner: {screen}");
677        h.click_text("Undo");
678        assert!(!h.screen().contains("Build failed"));
679        assert!(h.screen().contains("Deployed"), "only the toast whose action was pressed left");
680        assert_eq!(h.app().undone, 2);
681    }
682
683    #[test]
684    fn a_press_on_the_body_neither_dismisses_nor_reaches_below() {
685        let mut h = Harness::new(Demo::default(), 60, 10);
686        h.set_reduced_motion(true).send(Msg::Deployed);
687        h.click_text("Deployed api-gateway").click_text("v2.14.0").click(3, 6).click(50, 7);
688        assert!(h.screen().contains("Deployed api-gateway"), "{}", h.screen());
689        assert_eq!((h.app().pressed, h.app().opened), (0, 0), "the presses stayed on the toast");
690        h.hover(0, 0).advance(Duration::from_secs(6));
691        assert!(!h.screen().contains("Deployed"), "the toast still leaves on its own");
692    }
693
694    #[test]
695    fn a_pressable_toast_sends_its_message_stays_and_rises_under_the_pointer() {
696        let mut h = Harness::new(Demo::default(), 60, 10);
697        h.set_reduced_motion(true).send(Msg::Pressable);
698        let theme = h.env().theme().clone();
699        let (x, y) = h.find("Deployed").expect("toast");
700        let (cx, cy) = (u16::try_from(x).unwrap_or(0), u16::try_from(y).unwrap_or(0));
701        assert_eq!(h.bg(cx, cy), theme.color("overlay"), "at rest a pressable toast looks like any other");
702        h.hover(x, y);
703        assert_eq!(h.bg(cx, cy), theme.color("active"), "under the pointer it rises one step");
704        let (ux, uy) = h.find("Undo").expect("action");
705        let action = theme.style("toast-action", None, &[crate::theme::State::Active]).paint("bg");
706        let action = action.map(|paint| paint.at(0.0));
707        assert_eq!(h.bg(u16::try_from(ux).unwrap_or(0), u16::try_from(uy).unwrap_or(0)), action);
708        assert_ne!(action, theme.color("active"), "the action stays a step above the raised toast");
709        let (mx, my) = h.find("×").expect("close mark");
710        let (mx, my) = (u16::try_from(mx).unwrap_or(0), u16::try_from(my).unwrap_or(0));
711        assert_eq!(h.fg(mx, my), mark_rest(&theme), "the close mark keeps its whisper");
712
713        h.click(x, y).click(x, y);
714        assert_eq!((h.app().opened, h.app().pressed, h.app().undone), (2, 0, 0));
715        assert!(h.screen().contains("Deployed"), "a press on the body leaves the toast");
716        h.click_text("Undo");
717        assert_eq!((h.app().opened, h.app().undone), (2, 1), "the action is its own target");
718        assert!(!h.screen().contains("Deployed"));
719
720        h.send(Msg::Pressable).click(i32::from(mx), i32::from(my));
721        assert!(!h.screen().contains("Deployed"), "the close mark only closes");
722        assert_eq!(h.app().opened, 2);
723    }
724
725    fn mark_rest(theme: &crate::theme::Theme) -> Option<Rgb> {
726        theme.style("close-mark", None, &[]).paint("fg").map(|paint| paint.at(0.0))
727    }
728
729    #[test]
730    fn the_close_mark_is_the_shared_three_cells_and_dismisses() {
731        let mut h = Harness::new(Demo::default(), 60, 10);
732        h.set_reduced_motion(true).send(Msg::Deployed);
733        let theme = h.env().theme().clone();
734        let mark = |states: &[crate::theme::State], key: &str| {
735            theme.style("close-mark", None, states).paint(key).map(|paint| paint.at(0.0))
736        };
737        let rest = mark_rest(&theme);
738        let lit = mark(&[crate::theme::State::Hover], "bg");
739        assert_eq!(h.screen().lines().nth(6).map(|line| line.chars().nth(55)), Some(Some('×')));
740        assert_eq!(h.fg(55, 6), rest, "a whisper while the toast is left alone");
741        for (x, y) in [(30, 6), (8, 7), (53, 6), (57, 6), (55, 7)] {
742            h.hover(x, y);
743            assert_eq!(h.fg(55, 6), rest, "pointing at the toast at {x},{y} leaves the glyph alone");
744            assert_eq!([h.bg(54, 6), h.bg(55, 6), h.bg(56, 6)], [theme.color("overlay"); 3], "nothing lit");
745        }
746        h.hover(56, 6);
747        let cells = [h.bg(54, 6), h.bg(55, 6), h.bg(56, 6)];
748        assert_eq!(cells, [lit, lit, lit], "the three cells light together, as on tabs");
749        assert_ne!(h.fg(55, 6), rest, "and the glyph with them");
750        assert_eq!(h.bg(53, 6), theme.color("overlay"));
751        assert_eq!(h.bg(57, 6), theme.color("overlay"), "the lit mark keeps one cell of toast after it");
752        h.click(53, 6).click(57, 6);
753        assert!(h.screen().contains("Deployed"), "the cells beside the mark do not close");
754        for x in [54, 55, 56] {
755            if x > 54 {
756                h.send(Msg::Deployed);
757            }
758            h.click(x, 6);
759            assert!(!h.screen().contains("Deployed"), "a press on mark cell {x} dismisses");
760        }
761        assert_eq!(h.app().pressed, 0, "the press stays on the toast");
762    }
763
764    #[test]
765    fn an_action_keeps_a_cell_of_surface_before_the_close_mark() {
766        let mut h = Harness::new(Demo::default(), 60, 10);
767        h.set_reduced_motion(true).send(Msg::Failed);
768        let (x, y) = h.find("Undo").expect("action");
769        let line = h.screen().lines().nth(usize::try_from(y).unwrap_or(0)).unwrap_or_default().to_owned();
770        assert!(line.ends_with("Undo   ×"), "{line:?}");
771        let theme = h.env().theme().clone();
772        assert_eq!(h.bg(u16::try_from(x + 5).unwrap_or(0), 7), theme.color("overlay"), "the gap");
773    }
774
775    #[test]
776    fn an_animated_icon_plays_in_the_kind_colour_and_settles_into_the_kind_icon() {
777        let mut h = Harness::new(Demo::default(), 60, 10);
778        h.send(Msg::Loading(SpinnerStyle::Dots)).advance(Duration::from_millis(200));
779        let theme = h.env().theme().clone();
780        let row = |h: &Harness<Demo>| h.screen().lines().nth(7).unwrap_or_default().to_owned();
781        let first = row(&h);
782        let frame = first.chars().nth(5);
783        let dots = h.env().icons().animation(SpinnerStyle::Dots.animation()).expect("built in");
784        let frames: Vec<&str> = dots.frames().iter().map(|dots| dots.glyph(h.env().icons().mode())).collect();
785        assert!(frame.is_some_and(|frame| frames.contains(&frame.to_string().as_str())), "{first}");
786        let text: String = first.chars().skip(8).collect();
787        assert_eq!(text, format!("Uploading backup{}×", " ".repeat(31)), "text in its usual column");
788        assert_eq!(h.fg(5, 7), theme.color("info"), "the spinner takes the kind's colour");
789        h.advance(Duration::from_millis(80));
790        assert_ne!(row(&h).chars().nth(5), frame, "it moves");
791
792        h.send(Msg::Loaded).advance(Duration::from_millis(10));
793        let done = row(&h);
794        assert_eq!(done.chars().nth(5), Some('✓'), "the keyed toast settles into its icon: {done}");
795        assert_eq!(done.chars().nth(8), Some('B'), "the text did not move");
796        assert_eq!(h.fg(5, 7), theme.color("success"));
797    }
798
799    #[test]
800    fn an_animated_icon_blends_in_with_the_toast_and_reduced_motion_stands_still() {
801        let mut h = Harness::new(Demo::default(), 60, 10);
802        h.send(Msg::Loading(SpinnerStyle::Dots)).advance(Duration::from_millis(1));
803        h.advance(Duration::from_millis(60));
804        let (x, y) = h.find("Uploading").expect("mid-slide");
805        let (x, y) = (u16::try_from(x - 3).unwrap_or(0), u16::try_from(y).unwrap_or(0));
806        let theme = h.env().theme().clone();
807        let (fg, info, overlay) = (h.fg(x, y), theme.color("info"), theme.color("overlay"));
808        assert_ne!(fg, info, "mid-slide the spinner has not reached its colour yet");
809        assert_ne!(fg, overlay, "but it is on its way");
810        let title_full = theme.style("toast-title", None, &[]).paint("fg").map(|paint| paint.at(0.0));
811        assert_ne!(h.fg(x + 3, y), title_full, "arriving with the title, which blends the same way");
812
813        let mut still = Harness::new(Demo::default(), 60, 10);
814        still.set_reduced_motion(true).send(Msg::Loading(SpinnerStyle::Pulse));
815        let line = still.screen().lines().nth(7).unwrap_or_default().to_owned();
816        assert_eq!(line.chars().nth(5), Some('ℹ'), "reduced motion shows the kind's icon: {line}");
817        let before = still.screen();
818        still.advance(Duration::from_millis(900));
819        assert_eq!(still.screen(), before);
820        assert_eq!(still.fg(5, 7), theme.color("info"));
821    }
822
823    #[test]
824    fn keyed_toasts_update_in_place_and_are_dismissed_by_key() {
825        let mut h = Harness::new(Demo::default(), 60, 10);
826        h.set_reduced_motion(true).send(Msg::Progress(10)).send(Msg::Progress(60));
827        let screen = h.screen();
828        assert!(screen.contains("Uploading 60%") && !screen.contains("Uploading 10%"), "{screen}");
829        h.send(Msg::Finish);
830        assert!(!h.screen().contains("Uploading"));
831    }
832
833    #[test]
834    fn corner_can_move_to_the_top_left() {
835        let mut h = Harness::new(Demo::default(), 60, 10);
836        h.set_reduced_motion(true).send(Msg::Corner).send(Msg::Deployed);
837        assert_eq!(h.find("Deployed"), Some((8, 2)));
838    }
839}