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