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