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