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