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