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, 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
65pub const CHANNEL_CAPACITY: usize = 32;
67pub const BATCH_SIZE: usize = 16;
69pub const DEFAULT_DURATION: Duration = Duration::from_secs(5);
71pub const ACTION_DURATION: Duration = Duration::from_secs(10);
74pub const DEFAULT_LIMIT: usize = 3;
76pub const TICK: Duration = Duration::from_millis(250);
78
79const WIDTH: f32 = 356.0;
80
81pub type Sender = mpsc::Sender<Toast>;
83
84#[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#[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#[derive(Debug, Clone, PartialEq)]
141pub struct Toast {
142 pub title: String,
143 pub description: Option<String>,
144 pub variant: Variant,
145 pub action: Option<String>,
147 pub duration: Option<Duration>,
149}
150
151pub 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 pub fn action(mut self, label: impl Into<String>) -> Self {
177 self.action = Some(label.into());
178 self
179 }
180
181 pub fn duration(mut self, duration: Duration) -> Self {
183 self.duration = Some(duration);
184 self
185 }
186
187 pub fn persistent(mut self) -> Self {
189 self.duration = None;
190 self
191 }
192
193 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
207pub struct Id(u64);
208
209#[derive(Debug, Clone)]
211pub enum Event {
212 Ready(Sender),
214 Received(Vec<Toast>),
216 Dismiss(Id),
217 Action(Id),
218 Tick(Instant),
219 Pause,
222 Resume,
224}
225
226#[derive(Debug, Clone)]
229pub enum Output<A = ()> {
230 Ready(Sender),
231 Action(Id, Toast),
233 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#[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 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 pub fn push(&mut self, toast: Toast) -> Id {
299 self.enqueue(toast, None)
300 }
301
302 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 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 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 fn settle(&mut self) {
367 if self.entries.is_empty() {
368 self.paused = false;
369 }
370 }
371
372 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 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 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 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 pub fn needs_tick(&self) -> bool {
470 self.visible().any(|(_, toast)| toast.lifetime().is_some())
471 }
472
473 pub fn ids(&self) -> impl Iterator<Item = Id> {
475 self.entries.iter().map(|entry| entry.id)
476 }
477}
478
479#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
481pub enum Action {
482 DismissLatest,
483 DismissAll,
484 ActivateLatest,
485}
486
487impl Action {
488 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
542pub 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
559pub fn subscription() -> Subscription<Event> {
561 Subscription::run(stream)
562}
563
564pub 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
570pub fn batches<T>(receiver: impl Stream<Item = T>) -> impl Stream<Item = Vec<T>> {
572 receiver.ready_chunks(BATCH_SIZE)
573}
574
575pub 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#[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
609pub fn close_button_id(id: Id) -> widget::Id {
611 widget::Id::from(format!("iced-cube-toast-close-{}", id.0))
612}
613
614pub 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
622pub 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 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 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
749pub 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
758pub 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 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 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 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}