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