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, opaque, row, stack, text};
54use iced::{
55    Alignment, Background, Border, Color, Element, Length, Shadow, Subscription, Theme, Vector,
56};
57
58use crate::icon::{Glyph, tinted};
59use crate::keys::{self, Chord, Keymap};
60use crate::primitives::button::{self, Size};
61use crate::theme::{Tokens, fade, mix, radius, space, text_size};
62
63/// How many toasts a producer can send before it has to wait.
64pub const CHANNEL_CAPACITY: usize = 32;
65/// The most toasts delivered in one [`Event::Received`].
66pub const BATCH_SIZE: usize = 16;
67/// How long a toast stays visible unless it sets its own duration.
68pub const DEFAULT_DURATION: Duration = Duration::from_secs(5);
69/// How many toasts are shown at once. The rest wait their turn.
70pub const DEFAULT_LIMIT: usize = 3;
71/// How often [`timer`] checks for expired toasts.
72pub const TICK: Duration = Duration::from_millis(250);
73
74const WIDTH: f32 = 356.0;
75
76/// The sending half of the toast channel. Clone it for each producer.
77pub type Sender = mpsc::Sender<Toast>;
78
79/// Colour and icon of a toast.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
81pub enum Variant {
82    #[default]
83    Default,
84    Success,
85    Destructive,
86}
87
88impl Variant {
89    pub const ALL: [Variant; 3] = [Variant::Default, Variant::Success, Variant::Destructive];
90
91    fn glyph(self) -> Option<Glyph> {
92        match self {
93            Variant::Default => None,
94            Variant::Success => Some(crate::lucide!(CircleCheck)),
95            Variant::Destructive => Some(crate::lucide!(CircleX)),
96        }
97    }
98}
99
100/// The corner or edge the toasts stack in.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
102pub enum Position {
103    TopLeft,
104    TopCenter,
105    TopRight,
106    BottomLeft,
107    BottomCenter,
108    #[default]
109    BottomRight,
110}
111
112impl Position {
113    pub const ALL: [Position; 6] = [
114        Position::TopLeft,
115        Position::TopCenter,
116        Position::TopRight,
117        Position::BottomLeft,
118        Position::BottomCenter,
119        Position::BottomRight,
120    ];
121
122    fn alignment(self) -> (Alignment, Alignment) {
123        match self {
124            Position::TopLeft => (Alignment::Start, Alignment::Start),
125            Position::TopCenter => (Alignment::Center, Alignment::Start),
126            Position::TopRight => (Alignment::End, Alignment::Start),
127            Position::BottomLeft => (Alignment::Start, Alignment::End),
128            Position::BottomCenter => (Alignment::Center, Alignment::End),
129            Position::BottomRight => (Alignment::End, Alignment::End),
130        }
131    }
132}
133
134/// One notification.
135#[derive(Debug, Clone, PartialEq)]
136pub struct Toast {
137    pub title: String,
138    pub description: Option<String>,
139    pub variant: Variant,
140    /// Label of the action button.
141    pub action: Option<String>,
142    /// How long the toast stays once shown. `None` keeps it until closed.
143    pub duration: Option<Duration>,
144}
145
146/// Creates a toast with a title that closes after [`DEFAULT_DURATION`].
147pub fn toast(title: impl Into<String>) -> Toast {
148    Toast {
149        title: title.into(),
150        description: None,
151        variant: Variant::default(),
152        action: None,
153        duration: Some(DEFAULT_DURATION),
154    }
155}
156
157impl Toast {
158    pub fn description(mut self, description: impl Into<String>) -> Self {
159        self.description = Some(description.into());
160        self
161    }
162
163    pub fn variant(mut self, variant: Variant) -> Self {
164        self.variant = variant;
165        self
166    }
167
168    /// Adds an action button. Pressing it closes the toast and returns
169    /// [`Output::Action`].
170    pub fn action(mut self, label: impl Into<String>) -> Self {
171        self.action = Some(label.into());
172        self
173    }
174
175    /// How long the toast stays visible once it is shown.
176    pub fn duration(mut self, duration: Duration) -> Self {
177        self.duration = Some(duration);
178        self
179    }
180
181    /// Keeps the toast until it is closed.
182    pub fn persistent(mut self) -> Self {
183        self.duration = None;
184        self
185    }
186}
187
188/// Identifies a toast in a [`State`].
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
190pub struct Id(u64);
191
192/// Everything that changes the toast queue.
193#[derive(Debug, Clone)]
194pub enum Event {
195    /// The channel is open. Keep the sender and hand clones to producers.
196    Ready(Sender),
197    /// A batch of toasts from the channel, oldest first.
198    Received(Vec<Toast>),
199    Dismiss(Id),
200    Action(Id),
201    Tick(Instant),
202}
203
204/// What the app may need to act on after an [`Event`].
205#[derive(Debug, Clone)]
206pub enum Output {
207    Ready(Sender),
208    /// The action button of this toast was pressed.
209    Action(Id, Toast),
210}
211
212#[derive(Debug, Clone)]
213struct Entry {
214    id: Id,
215    toast: Toast,
216    expires_at: Option<Instant>,
217}
218
219/// The toast queue. The first [`limit`](State::limit) toasts are visible and
220/// counting down; the rest wait until a visible one closes.
221#[derive(Debug, Clone)]
222pub struct State {
223    entries: VecDeque<Entry>,
224    next_id: u64,
225    limit: usize,
226}
227
228impl Default for State {
229    fn default() -> Self {
230        Self {
231            entries: VecDeque::new(),
232            next_id: 0,
233            limit: DEFAULT_LIMIT,
234        }
235    }
236}
237
238impl State {
239    pub fn new() -> Self {
240        Self::default()
241    }
242
243    /// Sets how many toasts are visible at once. At least one is.
244    pub fn with_limit(mut self, limit: usize) -> Self {
245        self.limit = limit.max(1);
246        self
247    }
248
249    pub fn limit(&self) -> usize {
250        self.limit
251    }
252
253    /// Queues a toast and returns its id.
254    pub fn push(&mut self, toast: Toast) -> Id {
255        let id = Id(self.next_id);
256        self.next_id += 1;
257        self.entries.push_back(Entry {
258            id,
259            toast,
260            expires_at: None,
261        });
262        id
263    }
264
265    /// Removes a toast, visible or waiting.
266    pub fn dismiss(&mut self, id: Id) -> Option<Toast> {
267        let index = self.entries.iter().position(|entry| entry.id == id)?;
268        self.entries.remove(index).map(|entry| entry.toast)
269    }
270
271    /// Closes expired toasts and starts the countdown of newly visible ones.
272    /// Returns the ids that closed.
273    pub fn tick(&mut self, now: Instant) -> Vec<Id> {
274        let mut expired = Vec::new();
275        self.entries.retain(|entry| {
276            let alive = entry.expires_at.is_none_or(|at| at > now);
277            if !alive {
278                expired.push(entry.id);
279            }
280            alive
281        });
282
283        let limit = self.limit;
284        for entry in self.entries.iter_mut().take(limit) {
285            if entry.expires_at.is_none() {
286                entry.expires_at = entry.toast.duration.map(|duration| now + duration);
287            }
288        }
289        expired
290    }
291
292    pub fn update(&mut self, event: Event) -> Option<Output> {
293        match event {
294            Event::Ready(sender) => Some(Output::Ready(sender)),
295            Event::Received(batch) => {
296                for toast in batch {
297                    let _ = self.push(toast);
298                }
299                None
300            }
301            Event::Dismiss(id) => {
302                let _ = self.dismiss(id);
303                None
304            }
305            Event::Action(id) => self.dismiss(id).map(|toast| Output::Action(id, toast)),
306            Event::Tick(now) => {
307                let _ = self.tick(now);
308                None
309            }
310        }
311    }
312
313    /// The visible toasts, oldest first.
314    pub fn visible(&self) -> impl Iterator<Item = (Id, &Toast)> {
315        self.entries
316            .iter()
317            .take(self.limit)
318            .map(|entry| (entry.id, &entry.toast))
319    }
320
321    /// How many toasts are waiting behind the visible ones.
322    pub fn waiting(&self) -> usize {
323        self.entries.len().saturating_sub(self.limit)
324    }
325
326    pub fn len(&self) -> usize {
327        self.entries.len()
328    }
329
330    pub fn is_empty(&self) -> bool {
331        self.entries.is_empty()
332    }
333
334    /// Whether a visible toast still has to count down.
335    pub fn needs_tick(&self) -> bool {
336        self.visible().any(|(_, toast)| toast.duration.is_some())
337    }
338
339    /// Every queued toast id, visible first, oldest first.
340    pub fn ids(&self) -> impl Iterator<Item = Id> {
341        self.entries.iter().map(|entry| entry.id)
342    }
343}
344
345/// What a toast keyboard shortcut does.
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
347pub enum Action {
348    DismissLatest,
349    DismissAll,
350}
351
352impl Action {
353    /// The events this action sends to `state`. Empty when nothing is queued.
354    pub fn events(self, state: &State) -> Vec<Event> {
355        match self {
356            Action::DismissLatest => state
357                .visible()
358                .last()
359                .map(|(id, _)| Event::Dismiss(id))
360                .into_iter()
361                .collect(),
362            Action::DismissAll => state.ids().map(Event::Dismiss).collect(),
363        }
364    }
365}
366
367impl keys::Action for Action {
368    const ALL: &'static [Self] = &[Action::DismissLatest, Action::DismissAll];
369
370    fn defaults() -> Keymap<Self> {
371        default_keymap()
372    }
373
374    fn name(self) -> &'static str {
375        match self {
376            Action::DismissLatest => "DismissLatest",
377            Action::DismissAll => "DismissAll",
378        }
379    }
380
381    fn description(self) -> &'static str {
382        match self {
383            Action::DismissLatest => "Closes the newest visible toast.",
384            Action::DismissAll => "Closes every toast, including those waiting their turn.",
385        }
386    }
387}
388
389/// The default toast shortcuts:
390///
391/// | Keys | Action |
392/// | --- | --- |
393/// | `Escape` | [`Action::DismissLatest`] |
394/// | `Shift+Escape` | [`Action::DismissAll`] |
395///
396/// Escape often closes dialogs and menus too, so resolve the toast keymap
397/// after any overlay that should take Escape first.
398pub fn default_keymap() -> Keymap<Action> {
399    Keymap::new()
400        .bind(Chord::named(Named::Escape), Action::DismissLatest)
401        .bind(Chord::named(Named::Escape).shift(), Action::DismissAll)
402}
403
404/// Owns the toast channel. Emits [`Event::Ready`] first, then batches.
405pub fn subscription() -> Subscription<Event> {
406    Subscription::run(stream)
407}
408
409/// The stream behind [`subscription`], for driving it without a runtime.
410pub fn stream() -> impl Stream<Item = Event> {
411    let (sender, receiver) = mpsc::channel(CHANNEL_CAPACITY);
412    stream::once(async move { Event::Ready(sender) }).chain(batches(receiver).map(Event::Received))
413}
414
415/// Groups whatever is already waiting in the channel into one batch.
416pub fn batches<T>(receiver: impl Stream<Item = T>) -> impl Stream<Item = Vec<T>> {
417    receiver.ready_chunks(BATCH_SIZE)
418}
419
420/// Ticks every [`TICK`] while a visible toast is counting down.
421pub fn timer(state: &State) -> Subscription<Event> {
422    if !state.needs_tick() {
423        return Subscription::none();
424    }
425    every(TICK).map(|_| Event::Tick(Instant::now()))
426}
427
428#[cfg(any(target_arch = "wasm32", feature = "tokio"))]
429fn every(period: Duration) -> Subscription<()> {
430    iced::time::every(period).map(|_| ())
431}
432
433/// A sleeping thread feeds a channel, so no async runtime timer is needed.
434#[cfg(not(any(target_arch = "wasm32", feature = "tokio")))]
435fn every(period: Duration) -> Subscription<()> {
436    Subscription::run_with(period, |period| {
437        let period = *period;
438        let (mut sender, receiver) = mpsc::channel(1);
439        std::thread::spawn(move || {
440            loop {
441                std::thread::sleep(period);
442                if sender
443                    .try_send(())
444                    .is_err_and(|error| error.is_disconnected())
445                {
446                    break;
447                }
448            }
449        });
450        receiver
451    })
452}
453
454/// The widget id of a toast's close button, for tests and focus.
455pub fn close_button_id(id: Id) -> widget::Id {
456    widget::Id::from(format!("iced-cube-toast-close-{}", id.0))
457}
458
459/// Renders the visible toasts of a [`State`] over some content.
460pub struct Toasts<'a, Message> {
461    state: &'a State,
462    content: Element<'a, Message>,
463    position: Position,
464    on_event: Option<Box<dyn Fn(Event) -> Message + 'a>>,
465}
466
467/// Stacks the toasts of `state` over `content`. Without
468/// [`on_event`](Toasts::on_event) the buttons render disabled.
469pub fn toasts<'a, Message>(
470    state: &'a State,
471    content: impl Into<Element<'a, Message>>,
472) -> Toasts<'a, Message> {
473    Toasts {
474        state,
475        content: content.into(),
476        position: Position::default(),
477        on_event: None,
478    }
479}
480
481impl<Message> std::fmt::Debug for Toasts<'_, Message> {
482    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
483        f.debug_struct("Toasts")
484            .field("state", self.state)
485            .field("position", &self.position)
486            .finish_non_exhaustive()
487    }
488}
489
490impl<'a, Message> Toasts<'a, Message> {
491    pub fn position(mut self, position: Position) -> Self {
492        self.position = position;
493        self
494    }
495
496    pub fn on_event(mut self, on_event: impl Fn(Event) -> Message + 'a) -> Self {
497        self.on_event = Some(Box::new(on_event));
498        self
499    }
500}
501
502impl<'a, Message: Clone + 'a> From<Toasts<'a, Message>> for Element<'a, Message> {
503    fn from(toasts: Toasts<'a, Message>) -> Self {
504        let Toasts {
505            state,
506            content,
507            position,
508            on_event,
509        } = toasts;
510        let emit = |event: Event| on_event.as_ref().map(|on_event| on_event(event));
511
512        let cards = state.visible().map(|(id, toast)| card(id, toast, &emit));
513
514        let (align_x, align_y) = position.alignment();
515        let layer = container(column(cards).spacing(space::SM).width(WIDTH))
516            .padding(space::LG)
517            .width(Length::Fill)
518            .height(Length::Fill)
519            .align_x(align_x)
520            .align_y(align_y);
521
522        // The layer is always present so the content keeps its widget state
523        // when the first toast appears.
524        stack![content, layer]
525            .width(Length::Fill)
526            .height(Length::Fill)
527            .into()
528    }
529}
530
531fn card<'a, Message: Clone + 'a>(
532    id: Id,
533    toast: &'a Toast,
534    emit: &impl Fn(Event) -> Option<Message>,
535) -> Element<'a, Message> {
536    let variant = toast.variant;
537
538    let mut body = column![text(&toast.title).size(text_size::SM).font(iced::Font {
539        weight: iced::font::Weight::Semibold,
540        ..iced::Font::DEFAULT
541    })]
542    .spacing(2)
543    .width(Length::Fill);
544    if let Some(description) = &toast.description {
545        body = body.push(
546            text(description)
547                .size(text_size::SM)
548                .style(move |theme: &Theme| text::Style {
549                    color: Some(Tokens::of(theme).muted_foreground),
550                }),
551        );
552    }
553
554    let mut content = row![].spacing(space::MD).align_y(Alignment::Center);
555    if let Some(glyph) = variant.glyph() {
556        content = content.push(tinted(glyph, 18.0, None).style(move |theme: &Theme, _| {
557            widget::svg::Style {
558                color: Some(accent(&Tokens::of(theme), variant)),
559            }
560        }));
561    }
562    content = content.push(body);
563    if let Some(label) = &toast.action {
564        content = content.push(
565            button::button(label.as_str())
566                .variant(button::Variant::Outline)
567                .size(Size::Sm)
568                .on_press_maybe(emit(Event::Action(id))),
569        );
570    }
571    content = content.push(
572        container(
573            button::icon_button(crate::lucide!(X))
574                .size(Size::Sm)
575                .on_press_maybe(emit(Event::Dismiss(id))),
576        )
577        .id(close_button_id(id)),
578    );
579
580    opaque(
581        container(content)
582            .padding([space::MD, space::LG])
583            .width(Length::Fill)
584            .style(move |theme| style(&Tokens::of(theme), variant)),
585    )
586}
587
588/// The colour of a variant's icon and tint.
589pub fn accent(tokens: &Tokens, variant: Variant) -> Color {
590    match variant {
591        Variant::Default => tokens.foreground,
592        Variant::Success => tokens.success,
593        Variant::Destructive => tokens.destructive,
594    }
595}
596
597/// The card style of a toast.
598pub fn style(tokens: &Tokens, variant: Variant) -> container::Style {
599    let (background, border) = match variant {
600        Variant::Default => (tokens.background, tokens.border),
601        Variant::Success | Variant::Destructive => {
602            let accent = accent(tokens, variant);
603            (
604                mix(tokens.background, accent, 0.08),
605                mix(tokens.border, accent, 0.45),
606            )
607        }
608    };
609    let shadow_alpha = if tokens.is_dark { 0.5 } else { 0.12 };
610
611    container::Style {
612        background: Some(Background::Color(background)),
613        text_color: Some(tokens.foreground),
614        border: Border {
615            color: border,
616            width: 1.0,
617            radius: radius::LG.into(),
618        },
619        shadow: Shadow {
620            color: fade(Color::BLACK, shadow_alpha),
621            offset: Vector::new(0.0, 4.0),
622            blur_radius: 12.0,
623        },
624        snap: true,
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631    use crate::theme::{dark, light};
632    use futures::SinkExt;
633    use futures::executor::block_on;
634
635    fn titles(state: &State) -> Vec<&str> {
636        state
637            .visible()
638            .map(|(_, toast)| toast.title.as_str())
639            .collect()
640    }
641
642    #[test]
643    fn builder_defaults() {
644        let t = toast("Saved");
645        assert_eq!(t.title, "Saved");
646        assert_eq!(t.variant, Variant::Default);
647        assert_eq!(t.duration, Some(DEFAULT_DURATION));
648        assert!(t.description.is_none());
649        assert!(t.action.is_none());
650        assert!(toast("Sticky").persistent().duration.is_none());
651    }
652
653    #[test]
654    fn queue_keeps_arrival_order_and_unique_ids() {
655        let mut state = State::new();
656        let a = state.push(toast("a"));
657        let b = state.push(toast("b"));
658        let _ = state.update(Event::Received(vec![toast("c"), toast("d")]));
659
660        assert_ne!(a, b);
661        assert_eq!(state.len(), 4);
662        assert_eq!(titles(&state), ["a", "b", "c"]);
663        assert_eq!(state.waiting(), 1);
664    }
665
666    #[test]
667    fn limit_caps_visible_toasts_and_is_at_least_one() {
668        let mut state = State::new().with_limit(2);
669        for title in ["a", "b", "c"] {
670            let _ = state.push(toast(title));
671        }
672        assert_eq!(titles(&state), ["a", "b"]);
673        assert_eq!(State::new().with_limit(0).limit(), 1);
674    }
675
676    #[test]
677    fn toasts_expire_using_injected_time() {
678        let start = Instant::now();
679        let mut state = State::new();
680        let id = state.push(toast("a").duration(Duration::from_secs(2)));
681
682        assert!(state.tick(start).is_empty());
683        assert!(state.tick(start + Duration::from_millis(1999)).is_empty());
684        assert_eq!(state.tick(start + Duration::from_secs(2)), vec![id]);
685        assert!(state.is_empty());
686    }
687
688    #[test]
689    fn waiting_toasts_start_counting_when_shown() {
690        let start = Instant::now();
691        let mut state = State::new().with_limit(1);
692        let _ = state.push(toast("a").duration(Duration::from_secs(1)));
693        let _ = state.push(toast("b").duration(Duration::from_secs(1)));
694
695        let _ = state.tick(start);
696        let _ = state.tick(start + Duration::from_secs(1));
697        assert_eq!(titles(&state), ["b"]);
698
699        // "b" became visible at 1s, so it lives until 2s.
700        assert!(state.tick(start + Duration::from_millis(1500)).is_empty());
701        assert_eq!(state.tick(start + Duration::from_secs(2)).len(), 1);
702    }
703
704    #[test]
705    fn persistent_toasts_never_expire_and_need_no_timer() {
706        let start = Instant::now();
707        let mut state = State::new();
708        let _ = state.push(toast("a").persistent());
709        assert!(!state.needs_tick());
710
711        let _ = state.tick(start + Duration::from_secs(3600));
712        assert_eq!(state.len(), 1);
713    }
714
715    #[test]
716    fn empty_state_needs_no_timer() {
717        let mut state = State::new();
718        assert!(!state.needs_tick());
719        let _ = state.push(toast("a"));
720        assert!(state.needs_tick());
721    }
722
723    #[test]
724    fn dismiss_and_action_remove_the_toast() {
725        let mut state = State::new();
726        let a = state.push(toast("a"));
727        let b = state.push(toast("b").action("Undo"));
728
729        assert!(state.update(Event::Dismiss(a)).is_none());
730        let Some(Output::Action(id, toast)) = state.update(Event::Action(b)) else {
731            panic!("expected an action output");
732        };
733        assert_eq!(id, b);
734        assert_eq!(toast.action.as_deref(), Some("Undo"));
735        assert!(state.is_empty());
736        assert!(state.update(Event::Action(b)).is_none());
737    }
738
739    fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
740        let chord: Chord = chord.parse().unwrap();
741        keymap.resolve(chord.key(), chord.modifiers())
742    }
743
744    #[test]
745    fn default_keymap_and_overrides() {
746        let keymap = default_keymap();
747        assert_eq!(press(&keymap, "Escape"), Some(Action::DismissLatest));
748        assert_eq!(press(&keymap, "Shift+Escape"), Some(Action::DismissAll));
749        assert_eq!(press(&keymap, "Ctrl+Escape"), None);
750
751        let custom = keymap
752            .unbind(&"Shift+Escape".parse().unwrap())
753            .bind("Ctrl+Shift+X".parse().unwrap(), Action::DismissAll);
754        assert_eq!(press(&custom, "Shift+Escape"), None);
755        assert_eq!(press(&custom, "ctrl+shift+x"), Some(Action::DismissAll));
756    }
757
758    #[test]
759    fn dismiss_latest_closes_the_newest_visible_toast() {
760        let mut state = State::new().with_limit(2);
761        let _ = state.push(toast("a"));
762        let b = state.push(toast("b"));
763        let _ = state.push(toast("c"));
764
765        let events = Action::DismissLatest.events(&state);
766        assert!(matches!(events.as_slice(), [Event::Dismiss(id)] if *id == b));
767        for event in events {
768            let _ = state.update(event);
769        }
770        assert_eq!(titles(&state), ["a", "c"]);
771        assert!(Action::DismissLatest.events(&State::new()).is_empty());
772    }
773
774    #[test]
775    fn dismiss_all_includes_waiting_toasts() {
776        let mut state = State::new().with_limit(1);
777        for title in ["a", "b", "c"] {
778            let _ = state.push(toast(title));
779        }
780        let events = Action::DismissAll.events(&state);
781        assert_eq!(events.len(), 3);
782        for event in events {
783            let _ = state.update(event);
784        }
785        assert!(state.is_empty());
786        assert!(Action::DismissAll.events(&state).is_empty());
787    }
788
789    #[test]
790    fn ready_hands_the_sender_back() {
791        let (sender, _receiver) = mpsc::channel(1);
792        let output = State::new().update(Event::Ready(sender));
793        assert!(matches!(output, Some(Output::Ready(_))));
794    }
795
796    #[test]
797    fn stream_emits_ready_first_then_batches() {
798        let mut events = Box::pin(stream());
799        let Some(Event::Ready(mut sender)) = block_on(events.next()) else {
800            panic!("first event must be Ready");
801        };
802
803        block_on(async {
804            for title in ["a", "b", "c"] {
805                sender.send(toast(title)).await.ok();
806            }
807        });
808        let Some(Event::Received(batch)) = block_on(events.next()) else {
809            panic!("expected a batch");
810        };
811        let titles: Vec<_> = batch.iter().map(|toast| toast.title.as_str()).collect();
812        assert_eq!(titles, ["a", "b", "c"]);
813    }
814
815    #[test]
816    fn batches_split_bursts_at_batch_size() {
817        let items = stream::iter(0..BATCH_SIZE + 3);
818        let sizes: Vec<_> = block_on(batches(items).map(|batch| batch.len()).collect());
819        assert_eq!(sizes, [BATCH_SIZE, 3]);
820    }
821
822    #[test]
823    fn default_style_uses_background_and_border_tokens() {
824        for theme in [light(), dark()] {
825            let tokens = Tokens::of(&theme);
826            let style = style(&tokens, Variant::Default);
827            assert_eq!(style.background, Some(Background::Color(tokens.background)));
828            assert_eq!(style.border.color, tokens.border);
829            assert_eq!(style.text_color, Some(tokens.foreground));
830        }
831    }
832
833    #[test]
834    fn variants_are_tinted_differently_in_both_themes() {
835        for theme in [light(), dark()] {
836            let tokens = Tokens::of(&theme);
837            let styles = Variant::ALL.map(|variant| style(&tokens, variant).border.color);
838            assert_ne!(styles[0], styles[1]);
839            assert_ne!(styles[1], styles[2]);
840            assert_eq!(accent(&tokens, Variant::Destructive), tokens.destructive);
841            assert_eq!(accent(&tokens, Variant::Success), tokens.success);
842        }
843    }
844
845    #[test]
846    fn dark_shadow_is_stronger() {
847        let light = style(&Tokens::of(&light()), Variant::Default)
848            .shadow
849            .color
850            .a;
851        let dark = style(&Tokens::of(&dark()), Variant::Default).shadow.color.a;
852        assert!(dark > light);
853    }
854}