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