Skip to main content

iced_cube/overlay/
toast.rs

1//! Transient notifications stacked in a corner of the window.
2//!
3//! Toasts usually come from outside the UI thread, so they arrive through a
4//! channel. [`subscription`] creates a bounded channel, emits
5//! [`Event::Ready`] with its [`Sender`] once, then delivers whatever
6//! producers send in batches as [`Event::Received`]. The app keeps the
7//! sender, hands clones to producers and feeds every [`Event`] into its
8//! [`State`]. [`timer`] ticks only while a visible toast is counting down.
9//!
10//! ```no_run
11//! use iced::widget::text;
12//! use iced::{Element, Subscription};
13//! use iced_cube::overlay::toast::{self, toasts};
14//!
15//! #[derive(Debug, Clone)]
16//! enum Message {
17//!     Toast(toast::Event),
18//! }
19//!
20//! #[derive(Default)]
21//! struct App {
22//!     toasts: toast::State,
23//!     sender: Option<toast::Sender>,
24//! }
25//!
26//! impl App {
27//!     fn update(&mut self, message: Message) {
28//!         let Message::Toast(event) = message;
29//!         if let Some(toast::Output::Ready(sender)) = self.toasts.update(event) {
30//!             self.sender = Some(sender);
31//!         }
32//!     }
33//!
34//!     fn view(&self) -> Element<'_, Message> {
35//!         toasts(&self.toasts, text("Content"))
36//!             .on_event(Message::Toast)
37//!             .into()
38//!     }
39//!
40//!     fn subscription(&self) -> Subscription<Message> {
41//!         Subscription::batch([toast::subscription(), toast::timer(&self.toasts)])
42//!             .map(Message::Toast)
43//!     }
44//! }
45//! ```
46
47use std::collections::VecDeque;
48
49use iced::futures::channel::mpsc;
50use iced::futures::{Stream, StreamExt, stream};
51use iced::keyboard::key::Named;
52use iced::time::{Duration, Instant};
53use iced::widget::{self, column, container, mouse_area, row, stack, text};
54use iced::{
55    Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Subscription, Theme,
56    Vector,
57};
58
59use crate::after_touch::after_touch;
60use crate::icon::{Glyph, themed};
61use crate::keys::{self, Chord, Keymap};
62use crate::primitives::button::{self, Size};
63use crate::primitives::icon_button::icon_button;
64use crate::solid::solid;
65use crate::theme::{Tokens, fade, mix, radius, space, text_size};
66
67/// How many toasts a producer can send before it has to wait.
68pub const CHANNEL_CAPACITY: usize = 32;
69/// The most toasts delivered in one [`Event::Received`].
70pub const BATCH_SIZE: usize = 16;
71/// How long a toast stays visible unless it sets its own duration.
72pub const DEFAULT_DURATION: Duration = Duration::from_secs(5);
73/// The shortest time a toast with an action stays visible, so there is
74/// time to read it and press the button.
75pub const ACTION_DURATION: Duration = Duration::from_secs(10);
76/// How many toasts are shown at once. The rest wait their turn.
77pub const DEFAULT_LIMIT: usize = 3;
78/// How often [`timer`] checks for expired toasts.
79pub const TICK: Duration = Duration::from_millis(250);
80
81/// The widest toasts get by default. A narrower window fits them to its
82/// width.
83pub const MAX_WIDTH: f32 = 356.0;
84/// The default space between the toasts and the window edges.
85pub const INSET: f32 = space::LG;
86
87/// The sending half of the toast channel. Clone it for each producer.
88pub type Sender = mpsc::Sender<Toast>;
89
90/// Colour and icon of a toast.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
92pub enum Variant {
93    #[default]
94    Default,
95    Success,
96    Destructive,
97}
98
99impl Variant {
100    pub const ALL: [Variant; 3] = [Variant::Default, Variant::Success, Variant::Destructive];
101
102    fn glyph(self) -> Option<Glyph> {
103        match self {
104            Variant::Default => None,
105            Variant::Success => Some(crate::lucide!(CircleCheck)),
106            Variant::Destructive => Some(crate::lucide!(CircleX)),
107        }
108    }
109}
110
111/// The corner or edge the toasts stack in.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
113pub enum Position {
114    TopLeft,
115    TopCenter,
116    TopRight,
117    BottomLeft,
118    BottomCenter,
119    #[default]
120    BottomRight,
121}
122
123impl Position {
124    pub const ALL: [Position; 6] = [
125        Position::TopLeft,
126        Position::TopCenter,
127        Position::TopRight,
128        Position::BottomLeft,
129        Position::BottomCenter,
130        Position::BottomRight,
131    ];
132
133    fn alignment(self) -> (Alignment, Alignment) {
134        match self {
135            Position::TopLeft => (Alignment::Start, Alignment::Start),
136            Position::TopCenter => (Alignment::Center, Alignment::Start),
137            Position::TopRight => (Alignment::End, Alignment::Start),
138            Position::BottomLeft => (Alignment::Start, Alignment::End),
139            Position::BottomCenter => (Alignment::Center, Alignment::End),
140            Position::BottomRight => (Alignment::End, Alignment::End),
141        }
142    }
143}
144
145/// One notification.
146#[derive(Debug, Clone, PartialEq)]
147pub struct Toast {
148    pub title: String,
149    pub description: Option<String>,
150    pub variant: Variant,
151    /// Label of the action button.
152    pub action: Option<String>,
153    /// How long the toast stays once shown. `None` keeps it until closed.
154    pub duration: Option<Duration>,
155}
156
157/// Creates a toast with a title that closes after [`DEFAULT_DURATION`].
158pub fn toast(title: impl Into<String>) -> Toast {
159    Toast {
160        title: title.into(),
161        description: None,
162        variant: Variant::default(),
163        action: None,
164        duration: Some(DEFAULT_DURATION),
165    }
166}
167
168impl Toast {
169    pub fn description(mut self, description: impl Into<String>) -> Self {
170        self.description = Some(description.into());
171        self
172    }
173
174    pub fn variant(mut self, variant: Variant) -> Self {
175        self.variant = variant;
176        self
177    }
178
179    /// Adds an action button. Pressing it closes the toast and returns
180    /// [`Output::Action`]. A toast with an action stays at least
181    /// [`ACTION_DURATION`] and shows ahead of toasts waiting their turn.
182    pub fn action(mut self, label: impl Into<String>) -> Self {
183        self.action = Some(label.into());
184        self
185    }
186
187    /// How long the toast stays visible once it is shown.
188    pub fn duration(mut self, duration: Duration) -> Self {
189        self.duration = Some(duration);
190        self
191    }
192
193    /// Keeps the toast until it is closed.
194    pub fn persistent(mut self) -> Self {
195        self.duration = None;
196        self
197    }
198
199    /// How long the toast stays once shown: its duration, but at least
200    /// [`ACTION_DURATION`] when it has an action. `None` keeps it until
201    /// it is closed.
202    pub fn lifetime(&self) -> Option<Duration> {
203        let duration = self.duration?;
204        if self.action.is_some() {
205            return Some(duration.max(ACTION_DURATION));
206        }
207        Some(duration)
208    }
209}
210
211/// Identifies a toast in a [`State`].
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
213pub struct Id(u64);
214
215/// Everything that changes the toast queue.
216#[derive(Debug, Clone)]
217pub enum Event {
218    /// The channel is open. Keep the sender and hand clones to producers.
219    Ready(Sender),
220    /// A batch of toasts from the channel, oldest first.
221    Received(Vec<Toast>),
222    Dismiss(Id),
223    Action(Id),
224    Tick(Instant),
225    /// The pointer is over the toasts. Their countdowns stop until
226    /// [`Event::Resume`].
227    Pause,
228    /// The pointer left the toasts. Their countdowns carry on.
229    Resume,
230}
231
232/// What the app may need to act on after an [`Event`]. `A` is the payload
233/// type of the [`State`].
234#[derive(Debug, Clone)]
235pub enum Output<A = ()> {
236    Ready(Sender),
237    /// The action button of this toast was pressed.
238    Action(Id, Toast),
239    /// The action button of a toast queued with [`State::push_with`] was
240    /// pressed. Handle the payload as the app's own message.
241    Payload(A),
242}
243
244#[derive(Debug, Clone)]
245struct Entry<A> {
246    id: Id,
247    toast: Toast,
248    payload: Option<A>,
249    expires_at: Option<Instant>,
250}
251
252/// The toast queue. The first [`limit`](State::limit) toasts are visible and
253/// counting down; the rest wait until a visible one closes.
254///
255/// A toast with an action goes ahead of every toast without one that is
256/// waiting, and when the visible toasts are full it takes the place of the
257/// newest visible toast without an action, which waits again and restarts
258/// its countdown when it comes back. While the pointer is over the toasts
259/// ([`Event::Pause`]) no countdown runs.
260///
261/// `A` is what action buttons hand back, usually the app's message, so an
262/// Undo toast can carry the message that undoes. A queue without payloads
263/// is `State<()>`, made with [`State::new`]; one with payloads is made with
264/// [`State::default`].
265#[derive(Debug, Clone)]
266pub struct State<A = ()> {
267    entries: VecDeque<Entry<A>>,
268    next_id: u64,
269    limit: usize,
270    paused: bool,
271    last_tick: Option<Instant>,
272}
273
274impl<A> Default for State<A> {
275    fn default() -> Self {
276        Self {
277            entries: VecDeque::new(),
278            next_id: 0,
279            limit: DEFAULT_LIMIT,
280            paused: false,
281            last_tick: None,
282        }
283    }
284}
285
286impl State {
287    pub fn new() -> Self {
288        Self::default()
289    }
290}
291
292impl<A> State<A> {
293    /// Sets how many toasts are visible at once. At least one is.
294    pub fn with_limit(mut self, limit: usize) -> Self {
295        self.limit = limit.max(1);
296        self
297    }
298
299    pub fn limit(&self) -> usize {
300        self.limit
301    }
302
303    /// Queues a toast and returns its id.
304    pub fn push(&mut self, toast: Toast) -> Id {
305        self.enqueue(toast, None)
306    }
307
308    /// Queues a toast whose action button hands back `payload` as
309    /// [`Output::Payload`], and returns its id.
310    pub fn push_with(&mut self, toast: Toast, payload: A) -> Id {
311        self.enqueue(toast, Some(payload))
312    }
313
314    fn enqueue(&mut self, toast: Toast, payload: Option<A>) -> Id {
315        let id = Id(self.next_id);
316        self.next_id += 1;
317        let entry = Entry {
318            id,
319            toast,
320            payload,
321            expires_at: None,
322        };
323        if entry.toast.action.is_some() {
324            self.insert_ahead(entry);
325        } else {
326            self.entries.push_back(entry);
327        }
328        id
329    }
330
331    /// Puts a toast with an action in view, or at the front of the waiting
332    /// toasts when every visible toast has an action too.
333    fn insert_ahead(&mut self, entry: Entry<A>) {
334        let limit = self.limit;
335        if self.entries.len() < limit {
336            self.entries.push_back(entry);
337            return;
338        }
339        let newest_plain = (0..limit)
340            .rev()
341            .find(|&index| self.entries[index].toast.action.is_none());
342        let Some(index) = newest_plain else {
343            let after_actions = self
344                .entries
345                .iter()
346                .rposition(|entry| entry.toast.action.is_some())
347                .map_or(0, |index| index + 1);
348            self.entries.insert(after_actions, entry);
349            return;
350        };
351        let Some(mut displaced) = self.entries.remove(index) else {
352            return;
353        };
354        displaced.expires_at = None;
355        self.entries.insert(limit - 1, entry);
356        self.entries.insert(limit, displaced);
357    }
358
359    /// Removes a toast, visible or waiting, dropping any payload.
360    pub fn dismiss(&mut self, id: Id) -> Option<Toast> {
361        self.remove(id).map(|entry| entry.toast)
362    }
363
364    fn remove(&mut self, id: Id) -> Option<Entry<A>> {
365        let index = self.entries.iter().position(|entry| entry.id == id)?;
366        let entry = self.entries.remove(index);
367        self.settle();
368        entry
369    }
370
371    /// With no toasts left under the pointer, nothing stays paused.
372    fn settle(&mut self) {
373        if self.entries.is_empty() {
374            self.paused = false;
375        }
376    }
377
378    /// Closes expired toasts and starts the countdown of newly visible ones.
379    /// While paused, the time since the last tick is added back to every
380    /// countdown instead. Returns the ids that closed.
381    pub fn tick(&mut self, now: Instant) -> Vec<Id> {
382        let last = self.last_tick.replace(now);
383        if self.paused
384            && let Some(last) = last
385        {
386            let paused_for = now.saturating_duration_since(last);
387            for entry in &mut self.entries {
388                entry.expires_at = entry.expires_at.map(|at| at + paused_for);
389            }
390        }
391
392        let mut expired = Vec::new();
393        self.entries.retain(|entry| {
394            let alive = entry.expires_at.is_none_or(|at| at > now);
395            if !alive {
396                expired.push(entry.id);
397            }
398            alive
399        });
400        self.settle();
401
402        let limit = self.limit;
403        for entry in self.entries.iter_mut().take(limit) {
404            if entry.expires_at.is_none() {
405                entry.expires_at = entry.toast.lifetime().map(|duration| now + duration);
406            }
407        }
408        expired
409    }
410
411    /// Whether the countdowns are stopped because the pointer is over the
412    /// toasts.
413    pub fn is_paused(&self) -> bool {
414        self.paused
415    }
416
417    pub fn update(&mut self, event: Event) -> Option<Output<A>> {
418        match event {
419            Event::Ready(sender) => Some(Output::Ready(sender)),
420            Event::Received(batch) => {
421                for toast in batch {
422                    let _ = self.push(toast);
423                }
424                None
425            }
426            Event::Dismiss(id) => {
427                let _ = self.dismiss(id);
428                None
429            }
430            Event::Action(id) => {
431                let entry = self.remove(id)?;
432                Some(match entry.payload {
433                    Some(payload) => Output::Payload(payload),
434                    None => Output::Action(id, entry.toast),
435                })
436            }
437            Event::Tick(now) => {
438                let _ = self.tick(now);
439                None
440            }
441            Event::Pause => {
442                self.paused = true;
443                None
444            }
445            Event::Resume => {
446                self.paused = false;
447                None
448            }
449        }
450    }
451
452    /// The visible toasts, top to bottom in queue order: oldest first, with
453    /// toasts that have an action moved ahead of waiting ones.
454    pub fn visible(&self) -> impl Iterator<Item = (Id, &Toast)> {
455        self.entries
456            .iter()
457            .take(self.limit)
458            .map(|entry| (entry.id, &entry.toast))
459    }
460
461    /// How many toasts are waiting behind the visible ones.
462    pub fn waiting(&self) -> usize {
463        self.entries.len().saturating_sub(self.limit)
464    }
465
466    pub fn len(&self) -> usize {
467        self.entries.len()
468    }
469
470    pub fn is_empty(&self) -> bool {
471        self.entries.is_empty()
472    }
473
474    /// Whether a visible toast still has to count down.
475    pub fn needs_tick(&self) -> bool {
476        self.visible().any(|(_, toast)| toast.lifetime().is_some())
477    }
478
479    /// Every queued toast id, visible first, in queue order.
480    pub fn ids(&self) -> impl Iterator<Item = Id> {
481        self.entries.iter().map(|entry| entry.id)
482    }
483}
484
485/// What a toast keyboard shortcut does.
486#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
487pub enum Action {
488    DismissLatest,
489    DismissAll,
490    ActivateLatest,
491}
492
493impl Action {
494    /// The events this action sends to `state`. Empty when there is
495    /// nothing to act on.
496    pub fn events<A>(self, state: &State<A>) -> Vec<Event> {
497        match self {
498            Action::DismissLatest => state
499                .visible()
500                .map(|(id, _)| id)
501                .max()
502                .map(Event::Dismiss)
503                .into_iter()
504                .collect(),
505            Action::DismissAll => state.ids().map(Event::Dismiss).collect(),
506            Action::ActivateLatest => state
507                .visible()
508                .filter(|(_, toast)| toast.action.is_some())
509                .map(|(id, _)| id)
510                .max()
511                .map(Event::Action)
512                .into_iter()
513                .collect(),
514        }
515    }
516}
517
518impl keys::Action for Action {
519    const ALL: &'static [Self] = &[
520        Action::DismissLatest,
521        Action::DismissAll,
522        Action::ActivateLatest,
523    ];
524
525    fn defaults() -> Keymap<Self> {
526        default_keymap()
527    }
528
529    fn name(self) -> &'static str {
530        match self {
531            Action::DismissLatest => "DismissLatest",
532            Action::DismissAll => "DismissAll",
533            Action::ActivateLatest => "ActivateLatest",
534        }
535    }
536
537    fn description(self) -> &'static str {
538        match self {
539            Action::DismissLatest => "Closes the newest visible toast.",
540            Action::DismissAll => "Closes every toast, including those waiting their turn.",
541            Action::ActivateLatest => {
542                "Presses the action button, such as Undo, of the newest visible toast that has one."
543            }
544        }
545    }
546}
547
548/// The default toast shortcuts:
549///
550/// | Keys | Action |
551/// | --- | --- |
552/// | `Escape` | [`Action::DismissLatest`] |
553/// | `Shift+Escape` | [`Action::DismissAll`] |
554/// | `Alt+Z` | [`Action::ActivateLatest`] |
555///
556/// Escape often closes dialogs and menus too, so resolve the toast keymap
557/// after any overlay that should take Escape first.
558pub fn default_keymap() -> Keymap<Action> {
559    Keymap::new()
560        .bind(Chord::named(Named::Escape), Action::DismissLatest)
561        .bind(Chord::named(Named::Escape).shift(), Action::DismissAll)
562        .bind(Chord::character('z').alt(), Action::ActivateLatest)
563}
564
565/// Owns the toast channel. Emits [`Event::Ready`] first, then batches.
566pub fn subscription() -> Subscription<Event> {
567    Subscription::run(stream)
568}
569
570/// The stream behind [`subscription`], for driving it without a runtime.
571pub fn stream() -> impl Stream<Item = Event> {
572    let (sender, receiver) = mpsc::channel(CHANNEL_CAPACITY);
573    stream::once(async move { Event::Ready(sender) }).chain(batches(receiver).map(Event::Received))
574}
575
576/// Groups whatever is already waiting in the channel into one batch.
577pub fn batches<T>(receiver: impl Stream<Item = T>) -> impl Stream<Item = Vec<T>> {
578    receiver.ready_chunks(BATCH_SIZE)
579}
580
581/// Ticks every [`TICK`] while a visible toast is counting down.
582pub fn timer<A>(state: &State<A>) -> Subscription<Event> {
583    if !state.needs_tick() {
584        return Subscription::none();
585    }
586    every(TICK).map(|_| Event::Tick(Instant::now()))
587}
588
589#[cfg(any(target_arch = "wasm32", feature = "tokio"))]
590fn every(period: Duration) -> Subscription<()> {
591    iced::time::every(period).map(|_| ())
592}
593
594/// A sleeping thread feeds a channel, so no async runtime timer is needed.
595#[cfg(not(any(target_arch = "wasm32", feature = "tokio")))]
596fn every(period: Duration) -> Subscription<()> {
597    Subscription::run_with(period, |period| {
598        let period = *period;
599        let (mut sender, receiver) = mpsc::channel(1);
600        std::thread::spawn(move || {
601            loop {
602                std::thread::sleep(period);
603                if sender
604                    .try_send(())
605                    .is_err_and(|error| error.is_disconnected())
606                {
607                    break;
608                }
609            }
610        });
611        receiver
612    })
613}
614
615/// The widget id of a toast's close button, for tests and focus.
616pub fn close_button_id(id: Id) -> widget::Id {
617    widget::Id::from(format!("iced-cube-toast-close-{}", id.0))
618}
619
620/// Renders the visible toasts of a [`State`] over some content.
621pub struct Toasts<'a, Message, A = ()> {
622    state: &'a State<A>,
623    content: Element<'a, Message>,
624    position: Position,
625    inset: Padding,
626    max_width: f32,
627    on_event: Option<Box<dyn Fn(Event) -> Message + 'a>>,
628}
629
630/// Stacks the toasts of `state` over `content`. Without
631/// [`on_event`](Toasts::on_event) the buttons render disabled.
632pub fn toasts<'a, Message, A>(
633    state: &'a State<A>,
634    content: impl Into<Element<'a, Message>>,
635) -> Toasts<'a, Message, A> {
636    Toasts {
637        state,
638        content: content.into(),
639        position: Position::default(),
640        inset: Padding::from(INSET),
641        max_width: MAX_WIDTH,
642        on_event: None,
643    }
644}
645
646impl<Message, A: std::fmt::Debug> std::fmt::Debug for Toasts<'_, Message, A> {
647    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
648        f.debug_struct("Toasts")
649            .field("state", self.state)
650            .field("position", &self.position)
651            .field("inset", &self.inset)
652            .field("max_width", &self.max_width)
653            .finish_non_exhaustive()
654    }
655}
656
657impl<'a, Message, A> Toasts<'a, Message, A> {
658    pub fn position(mut self, position: Position) -> Self {
659        self.position = position;
660        self
661    }
662
663    /// The space between the toasts and each window edge. Defaults to
664    /// [`INSET`] all round. Raise the bottom to keep toasts above a bottom
665    /// bar, such as `Padding::from(16).bottom(96)`.
666    pub fn inset(mut self, inset: impl Into<Padding>) -> Self {
667        self.inset = inset.into();
668        self
669    }
670
671    /// The widest the toasts get. They fill a narrower window up to its
672    /// inset, so on a phone a bottom-centre toast spans the screen like a
673    /// snackbar. Defaults to [`MAX_WIDTH`].
674    pub fn max_width(mut self, max_width: f32) -> Self {
675        self.max_width = max_width.max(0.0);
676        self
677    }
678
679    pub fn on_event(mut self, on_event: impl Fn(Event) -> Message + 'a) -> Self {
680        self.on_event = Some(Box::new(on_event));
681        self
682    }
683}
684
685impl<'a, Message: Clone + 'a, A> From<Toasts<'a, Message, A>> for Element<'a, Message> {
686    fn from(toasts: Toasts<'a, Message, A>) -> Self {
687        let Toasts {
688            state,
689            content,
690            position,
691            inset,
692            max_width,
693            on_event,
694        } = toasts;
695        let emit = |event: Event| on_event.as_ref().map(|on_event| on_event(event));
696
697        let cards = state.visible().map(|(id, toast)| card(id, toast, &emit));
698
699        let cards = column(cards)
700            .spacing(space::SM)
701            .width(Length::Fill)
702            .max_width(max_width);
703        // Hovering the toasts pauses their countdowns. A finger pauses them
704        // only while it is down.
705        let cards: Element<'a, Message> = match (emit(Event::Pause), emit(Event::Resume)) {
706            (Some(pause), Some(resume)) => {
707                after_touch(mouse_area(cards).on_enter(pause).on_exit(resume))
708            }
709            _ => cards.into(),
710        };
711
712        let (align_x, align_y) = position.alignment();
713        let layer = container(cards)
714            .padding(inset)
715            .width(Length::Fill)
716            .height(Length::Fill)
717            .align_x(align_x)
718            .align_y(align_y);
719
720        // The layer is always present so the content keeps its widget state
721        // when the first toast appears.
722        stack![content, layer]
723            .width(Length::Fill)
724            .height(Length::Fill)
725            .into()
726    }
727}
728
729fn card<'a, Message: Clone + 'a>(
730    id: Id,
731    toast: &'a Toast,
732    emit: &impl Fn(Event) -> Option<Message>,
733) -> Element<'a, Message> {
734    let variant = toast.variant;
735
736    let mut body = column![
737        text(&toast.title)
738            .size(text_size::SM)
739            .font(crate::theme::semibold())
740    ]
741    .spacing(2)
742    .width(Length::Fill);
743    if let Some(description) = &toast.description {
744        body = body.push(
745            text(description)
746                .size(text_size::SM)
747                .style(move |theme: &Theme| text::Style {
748                    color: Some(Tokens::of(theme).muted_foreground),
749                }),
750        );
751    }
752
753    let mut content = row![].spacing(space::MD).align_y(Alignment::Center);
754    if let Some(glyph) = variant.glyph() {
755        content = content.push(themed(glyph, 18.0, 1.0, move |theme| {
756            accent(&Tokens::of(theme), variant)
757        }));
758    }
759    content = content.push(body);
760    if let Some(label) = &toast.action {
761        content = content.push(
762            button::button(label.as_str())
763                .variant(button::Variant::Outline)
764                .size(Size::Sm)
765                .on_press_maybe(emit(Event::Action(id))),
766        );
767    }
768    content = content.push(
769        container(
770            icon_button(crate::lucide!(X))
771                .size(Size::Sm)
772                .on_press_maybe(emit(Event::Dismiss(id))),
773        )
774        .id(close_button_id(id)),
775    );
776
777    solid(
778        container(content)
779            .padding([space::MD, space::LG])
780            .width(Length::Fill)
781            .style(move |theme| style(&Tokens::of(theme), variant)),
782    )
783}
784
785/// The colour of a variant's icon and tint.
786pub fn accent(tokens: &Tokens, variant: Variant) -> Color {
787    match variant {
788        Variant::Default => tokens.foreground,
789        Variant::Success => tokens.success,
790        Variant::Destructive => tokens.destructive,
791    }
792}
793
794/// The card style of a toast.
795pub fn style(tokens: &Tokens, variant: Variant) -> container::Style {
796    let (background, border) = match variant {
797        Variant::Default => (tokens.background, tokens.border),
798        Variant::Success | Variant::Destructive => {
799            let accent = accent(tokens, variant);
800            (
801                mix(tokens.background, accent, 0.08),
802                mix(tokens.border, accent, 0.45),
803            )
804        }
805    };
806    let shadow_alpha = if tokens.is_dark { 0.5 } else { 0.12 };
807
808    container::Style {
809        background: Some(Background::Color(background)),
810        text_color: Some(tokens.foreground),
811        border: Border {
812            color: border,
813            width: 1.0,
814            radius: radius::LG.into(),
815        },
816        shadow: Shadow {
817            color: fade(Color::BLACK, shadow_alpha),
818            offset: Vector::new(0.0, 4.0),
819            blur_radius: 12.0,
820        },
821        snap: true,
822    }
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828    use crate::theme::{dark, light};
829    use futures::SinkExt;
830    use futures::executor::block_on;
831
832    fn titles(state: &State) -> Vec<&str> {
833        state
834            .visible()
835            .map(|(_, toast)| toast.title.as_str())
836            .collect()
837    }
838
839    #[test]
840    fn builder_defaults() {
841        let t = toast("Saved");
842        assert_eq!(t.title, "Saved");
843        assert_eq!(t.variant, Variant::Default);
844        assert_eq!(t.duration, Some(DEFAULT_DURATION));
845        assert!(t.description.is_none());
846        assert!(t.action.is_none());
847        assert!(toast("Sticky").persistent().duration.is_none());
848    }
849
850    #[test]
851    fn layer_defaults_keep_the_corner_and_options_override_them() {
852        let state: State = State::new();
853        let layer: Toasts<'_, (), ()> = toasts(&state, text("Content"));
854        assert_eq!(layer.position, Position::BottomRight);
855        assert_eq!(layer.inset, Padding::from(INSET));
856        assert_eq!(layer.max_width, MAX_WIDTH);
857        let layer: Toasts<'_, (), ()> = toasts(&state, text("Content"))
858            .inset(Padding::from(16).bottom(96))
859            .max_width(-1.0);
860        assert_eq!(layer.inset.bottom, 96.0);
861        assert_eq!(layer.max_width, 0.0);
862    }
863
864    #[test]
865    fn queue_keeps_arrival_order_and_unique_ids() {
866        let mut state = State::new();
867        let a = state.push(toast("a"));
868        let b = state.push(toast("b"));
869        let _ = state.update(Event::Received(vec![toast("c"), toast("d")]));
870
871        assert_ne!(a, b);
872        assert_eq!(state.len(), 4);
873        assert_eq!(titles(&state), ["a", "b", "c"]);
874        assert_eq!(state.waiting(), 1);
875    }
876
877    #[test]
878    fn limit_caps_visible_toasts_and_is_at_least_one() {
879        let mut state = State::new().with_limit(2);
880        for title in ["a", "b", "c"] {
881            let _ = state.push(toast(title));
882        }
883        assert_eq!(titles(&state), ["a", "b"]);
884        assert_eq!(State::new().with_limit(0).limit(), 1);
885    }
886
887    #[test]
888    fn toasts_expire_using_injected_time() {
889        let start = Instant::now();
890        let mut state = State::new();
891        let id = state.push(toast("a").duration(Duration::from_secs(2)));
892
893        assert!(state.tick(start).is_empty());
894        assert!(state.tick(start + Duration::from_millis(1999)).is_empty());
895        assert_eq!(state.tick(start + Duration::from_secs(2)), vec![id]);
896        assert!(state.is_empty());
897    }
898
899    #[test]
900    fn waiting_toasts_start_counting_when_shown() {
901        let start = Instant::now();
902        let mut state = State::new().with_limit(1);
903        let _ = state.push(toast("a").duration(Duration::from_secs(1)));
904        let _ = state.push(toast("b").duration(Duration::from_secs(1)));
905
906        let _ = state.tick(start);
907        let _ = state.tick(start + Duration::from_secs(1));
908        assert_eq!(titles(&state), ["b"]);
909
910        // "b" became visible at 1s, so it lives until 2s.
911        assert!(state.tick(start + Duration::from_millis(1500)).is_empty());
912        assert_eq!(state.tick(start + Duration::from_secs(2)).len(), 1);
913    }
914
915    #[test]
916    fn persistent_toasts_never_expire_and_need_no_timer() {
917        let start = Instant::now();
918        let mut state = State::new();
919        let _ = state.push(toast("a").persistent());
920        assert!(!state.needs_tick());
921
922        let _ = state.tick(start + Duration::from_secs(3600));
923        assert_eq!(state.len(), 1);
924    }
925
926    #[test]
927    fn empty_state_needs_no_timer() {
928        let mut state = State::new();
929        assert!(!state.needs_tick());
930        let _ = state.push(toast("a"));
931        assert!(state.needs_tick());
932    }
933
934    #[test]
935    fn dismiss_and_action_remove_the_toast() {
936        let mut state = State::new();
937        let a = state.push(toast("a"));
938        let b = state.push(toast("b").action("Undo"));
939
940        assert!(state.update(Event::Dismiss(a)).is_none());
941        let Some(Output::Action(id, toast)) = state.update(Event::Action(b)) else {
942            panic!("expected an action output");
943        };
944        assert_eq!(id, b);
945        assert_eq!(toast.action.as_deref(), Some("Undo"));
946        assert!(state.is_empty());
947        assert!(state.update(Event::Action(b)).is_none());
948    }
949
950    #[derive(Debug, Clone, PartialEq)]
951    enum Undo {
952        Restore(&'static str),
953    }
954
955    #[test]
956    fn an_action_hands_back_the_payload_it_was_queued_with() {
957        let mut state: State<Undo> = State::default();
958        let plain = state.push(toast("Saved").action("View"));
959        let undo = state.push_with(toast("Archived").action("Undo"), Undo::Restore("inbox"));
960        assert_eq!(state.len(), 2);
961
962        let Some(Output::Payload(payload)) = state.update(Event::Action(undo)) else {
963            panic!("expected the payload");
964        };
965        assert_eq!(payload, Undo::Restore("inbox"));
966        assert!(matches!(
967            state.update(Event::Action(plain)),
968            Some(Output::Action(id, _)) if id == plain
969        ));
970        assert!(state.is_empty());
971    }
972
973    #[test]
974    fn dismissing_drops_the_payload() {
975        let mut state: State<Undo> = State::default();
976        let undo = state.push_with(toast("Archived").action("Undo"), Undo::Restore("inbox"));
977        assert!(state.update(Event::Dismiss(undo)).is_none());
978        assert!(state.update(Event::Action(undo)).is_none());
979        let expired = state.push_with(
980            toast("Archived").duration(Duration::from_secs(1)),
981            Undo::Restore("inbox"),
982        );
983        let start = Instant::now();
984        let _ = state.tick(start);
985        assert_eq!(state.tick(start + Duration::from_secs(1)), vec![expired]);
986        assert!(state.update(Event::Action(expired)).is_none());
987    }
988
989    fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
990        let chord: Chord = chord.parse().unwrap();
991        keymap.resolve(chord.key(), chord.modifiers())
992    }
993
994    #[test]
995    fn default_keymap_and_overrides() {
996        let keymap = default_keymap();
997        assert_eq!(press(&keymap, "Escape"), Some(Action::DismissLatest));
998        assert_eq!(press(&keymap, "Shift+Escape"), Some(Action::DismissAll));
999        assert_eq!(press(&keymap, "Alt+Z"), Some(Action::ActivateLatest));
1000        assert_eq!(press(&keymap, "Ctrl+Escape"), None);
1001        assert_eq!(press(&keymap, "Z"), None);
1002        for action in <Action as keys::Action>::ALL {
1003            assert!(!keymap.chords(action).is_empty(), "{action:?}");
1004            assert!(keys::Action::description(*action).ends_with('.'));
1005        }
1006
1007        let custom = keymap
1008            .unbind(&"Shift+Escape".parse().unwrap())
1009            .bind("Ctrl+Shift+X".parse().unwrap(), Action::DismissAll);
1010        assert_eq!(press(&custom, "Shift+Escape"), None);
1011        assert_eq!(press(&custom, "ctrl+shift+x"), Some(Action::DismissAll));
1012    }
1013
1014    #[test]
1015    fn dismiss_latest_closes_the_newest_visible_toast() {
1016        let mut state = State::new().with_limit(2);
1017        let _ = state.push(toast("a"));
1018        let b = state.push(toast("b"));
1019        let _ = state.push(toast("c"));
1020
1021        let events = Action::DismissLatest.events(&state);
1022        assert!(matches!(events.as_slice(), [Event::Dismiss(id)] if *id == b));
1023        for event in events {
1024            let _ = state.update(event);
1025        }
1026        assert_eq!(titles(&state), ["a", "c"]);
1027        assert!(Action::DismissLatest.events(&State::new()).is_empty());
1028    }
1029
1030    #[test]
1031    fn action_toasts_last_at_least_the_action_duration() {
1032        assert_eq!(toast("a").action("Undo").lifetime(), Some(ACTION_DURATION));
1033        let long = Duration::from_secs(30);
1034        assert_eq!(
1035            toast("a").action("Undo").duration(long).lifetime(),
1036            Some(long)
1037        );
1038        assert_eq!(toast("a").action("Undo").persistent().lifetime(), None);
1039        assert_eq!(toast("a").lifetime(), Some(DEFAULT_DURATION));
1040
1041        let start = Instant::now();
1042        let mut state = State::new();
1043        let _ = state.push(toast("Deleted").action("Undo"));
1044        let _ = state.tick(start);
1045        assert!(state.tick(start + DEFAULT_DURATION).is_empty());
1046        assert_eq!(state.tick(start + ACTION_DURATION).len(), 1);
1047    }
1048
1049    #[test]
1050    fn an_action_toast_takes_the_place_of_the_newest_plain_one() {
1051        let start = Instant::now();
1052        let mut state = State::new();
1053        for title in ["a", "b", "c", "d"] {
1054            let _ = state.push(toast(title));
1055        }
1056        let _ = state.tick(start);
1057        let _ = state.push(toast("Undo me").action("Undo"));
1058        assert_eq!(titles(&state), ["a", "b", "Undo me"]);
1059        assert_eq!(state.waiting(), 2);
1060
1061        // "c" went back to waiting, so it counts down afresh once it shows.
1062        let _ = state.tick(start + Duration::from_secs(1));
1063        let _ = state.tick(start + DEFAULT_DURATION);
1064        assert_eq!(titles(&state), ["Undo me", "c", "d"]);
1065        assert!(state.tick(start + Duration::from_secs(9)).is_empty());
1066        assert_eq!(state.tick(start + DEFAULT_DURATION * 2).len(), 2);
1067    }
1068
1069    #[test]
1070    fn action_toasts_wait_ahead_of_plain_ones_when_every_visible_toast_has_an_action() {
1071        let mut state = State::new().with_limit(2);
1072        let _ = state.push(toast("plain"));
1073        let _ = state.push(toast("one").action("Undo"));
1074        let _ = state.push(toast("two").action("Undo"));
1075        assert_eq!(titles(&state), ["one", "two"]);
1076        let _ = state.push(toast("three").action("Undo"));
1077        let order: Vec<_> = state
1078            .entries
1079            .iter()
1080            .map(|entry| entry.toast.title.as_str())
1081            .collect();
1082        assert_eq!(order, ["one", "two", "three", "plain"]);
1083    }
1084
1085    #[test]
1086    fn a_plain_toast_with_room_just_joins_the_end() {
1087        let mut state = State::new();
1088        let _ = state.push(toast("one").action("Undo"));
1089        let _ = state.push(toast("plain"));
1090        let _ = state.push(toast("two").action("Undo"));
1091        assert_eq!(titles(&state), ["one", "plain", "two"]);
1092    }
1093
1094    #[test]
1095    fn hovering_pauses_every_countdown() {
1096        let start = Instant::now();
1097        let mut state = State::new();
1098        let id = state.push(toast("a").duration(Duration::from_secs(2)));
1099        let _ = state.tick(start);
1100
1101        let _ = state.update(Event::Pause);
1102        assert!(state.is_paused());
1103        assert!(state.needs_tick());
1104        assert!(state.tick(start + Duration::from_secs(1)).is_empty());
1105        assert!(state.tick(start + Duration::from_secs(10)).is_empty());
1106
1107        let _ = state.update(Event::Resume);
1108        assert!(!state.is_paused());
1109        // The pause began as the countdown started, so both seconds are left.
1110        let resumed = start + Duration::from_secs(10);
1111        assert!(state.tick(resumed + Duration::from_millis(1900)).is_empty());
1112        assert_eq!(state.tick(resumed + Duration::from_secs(2)), vec![id]);
1113    }
1114
1115    #[test]
1116    fn the_pause_ends_when_the_last_toast_closes() {
1117        let mut state = State::new();
1118        let id = state.push(toast("a"));
1119        let _ = state.update(Event::Pause);
1120        let _ = state.dismiss(id);
1121        assert!(!state.is_paused());
1122    }
1123
1124    #[test]
1125    fn activate_latest_presses_the_newest_visible_action() {
1126        let mut state: State<Undo> = State::default();
1127        assert!(Action::ActivateLatest.events(&state).is_empty());
1128        let _ = state.push(toast("plain"));
1129        assert!(Action::ActivateLatest.events(&state).is_empty());
1130
1131        let _ = state.push_with(toast("first").action("Undo"), Undo::Restore("a"));
1132        let second = state.push_with(toast("second").action("Undo"), Undo::Restore("b"));
1133        let _ = state.push(toast("newer plain"));
1134        let events = Action::ActivateLatest.events(&state);
1135        assert!(matches!(events.as_slice(), [Event::Action(id)] if *id == second));
1136
1137        let outputs: Vec<_> = events
1138            .into_iter()
1139            .filter_map(|event| state.update(event))
1140            .collect();
1141        assert!(matches!(
1142            outputs.as_slice(),
1143            [Output::Payload(Undo::Restore("b"))]
1144        ));
1145    }
1146
1147    #[test]
1148    fn dismiss_all_includes_waiting_toasts() {
1149        let mut state = State::new().with_limit(1);
1150        for title in ["a", "b", "c"] {
1151            let _ = state.push(toast(title));
1152        }
1153        let events = Action::DismissAll.events(&state);
1154        assert_eq!(events.len(), 3);
1155        for event in events {
1156            let _ = state.update(event);
1157        }
1158        assert!(state.is_empty());
1159        assert!(Action::DismissAll.events(&state).is_empty());
1160    }
1161
1162    #[test]
1163    fn ready_hands_the_sender_back() {
1164        let (sender, _receiver) = mpsc::channel(1);
1165        let output = State::new().update(Event::Ready(sender));
1166        assert!(matches!(output, Some(Output::Ready(_))));
1167    }
1168
1169    #[test]
1170    fn stream_emits_ready_first_then_batches() {
1171        let mut events = Box::pin(stream());
1172        let Some(Event::Ready(mut sender)) = block_on(events.next()) else {
1173            panic!("first event must be Ready");
1174        };
1175
1176        block_on(async {
1177            for title in ["a", "b", "c"] {
1178                sender.send(toast(title)).await.ok();
1179            }
1180        });
1181        let Some(Event::Received(batch)) = block_on(events.next()) else {
1182            panic!("expected a batch");
1183        };
1184        let titles: Vec<_> = batch.iter().map(|toast| toast.title.as_str()).collect();
1185        assert_eq!(titles, ["a", "b", "c"]);
1186    }
1187
1188    #[test]
1189    fn batches_split_bursts_at_batch_size() {
1190        let items = stream::iter(0..BATCH_SIZE + 3);
1191        let sizes: Vec<_> = block_on(batches(items).map(|batch| batch.len()).collect());
1192        assert_eq!(sizes, [BATCH_SIZE, 3]);
1193    }
1194
1195    #[test]
1196    fn default_style_uses_background_and_border_tokens() {
1197        for theme in [light(), dark()] {
1198            let tokens = Tokens::of(&theme);
1199            let style = style(&tokens, Variant::Default);
1200            assert_eq!(style.background, Some(Background::Color(tokens.background)));
1201            assert_eq!(style.border.color, tokens.border);
1202            assert_eq!(style.text_color, Some(tokens.foreground));
1203        }
1204    }
1205
1206    #[test]
1207    fn variants_are_tinted_differently_in_both_themes() {
1208        for theme in [light(), dark()] {
1209            let tokens = Tokens::of(&theme);
1210            let styles = Variant::ALL.map(|variant| style(&tokens, variant).border.color);
1211            assert_ne!(styles[0], styles[1]);
1212            assert_ne!(styles[1], styles[2]);
1213            assert_eq!(accent(&tokens, Variant::Destructive), tokens.destructive);
1214            assert_eq!(accent(&tokens, Variant::Success), tokens.success);
1215        }
1216    }
1217
1218    #[test]
1219    fn dark_shadow_is_stronger() {
1220        let light = style(&Tokens::of(&light()), Variant::Default)
1221            .shadow
1222            .color
1223            .a;
1224        let dark = style(&Tokens::of(&dark()), Variant::Default).shadow.color.a;
1225        assert!(dark > light);
1226    }
1227}