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