1pub mod body;
38pub mod ext;
39pub mod host;
40pub mod registry;
41pub mod surface;
42
43use std::cell::Cell;
44use std::rc::Rc;
45use std::time::Duration;
46
47use teksilo_core::widget::{EventContext, Widget};
48use teksilo_core::window::TeksiloWindowId;
49
50pub use body::TOAST_BODY_COLLAPSED_LINES;
51pub use ext::EventContextToastExt;
52pub use host::{ToastHost, ToastInstallOptions};
53pub use registry::ToastRegistry;
54pub use surface::ToastSurface;
55pub use teksilo_core::styles::{ToastPriority, ToastStyleConfig};
56
57pub use teksilo_core::styles::BannerSeverity as ToastSeverity;
61use teksilo_i18n::LocalizedString;
62
63pub const DEFAULT_TOAST_AUTO_DISMISS: Duration = Duration::from_secs(10);
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
73pub enum ToastDismissCause {
74 Timeout,
76 ActionInvoked,
78 CloseClicked,
80 EscapePressed,
82 Programmatic,
84 HostShutdown,
86 SlotPoolFull,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
106pub struct ToastAudience(u64);
107
108impl ToastAudience {
109 pub fn new(id: u64) -> Self {
113 Self(id)
114 }
115
116 pub fn raw(&self) -> u64 {
118 self.0
119 }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
139pub enum ToastRoute {
140 Window(TeksiloWindowId),
146 Audience(ToastAudience),
148 Broadcast,
150}
151
152#[derive(Debug, Clone, Default)]
158pub enum ToastActionStyle {
159 #[default]
162 Link,
163 Button {
167 variant: crate::button::ButtonVariant,
170 },
171}
172
173pub type ToastActionCallback = Rc<dyn Fn(&mut EventContext)>;
177
178pub struct ToastAction {
181 label: LocalizedString,
182 on_invoke: ToastActionCallback,
183 style: ToastActionStyle,
184 closes_toast: bool,
185 shortcut_id: Option<String>,
186 tooltip: Option<LocalizedString>,
187}
188
189impl ToastAction {
190 pub fn new(
193 label: impl Into<LocalizedString>,
194 on_invoke: impl Fn(&mut EventContext) + 'static,
195 ) -> Self {
196 let ls: LocalizedString = label.into();
197 Self {
198 label: ls,
199 on_invoke: Rc::new(on_invoke),
200 style: ToastActionStyle::default(),
201 closes_toast: true,
202 shortcut_id: None,
203 tooltip: None,
204 }
205 }
206
207 pub fn primary(
210 label: impl Into<LocalizedString>,
211 on_invoke: impl Fn(&mut EventContext) + 'static,
212 ) -> Self {
213 Self::new(label, on_invoke).style(ToastActionStyle::Button {
214 variant: crate::button::ButtonVariant::Filled,
215 })
216 }
217
218 pub fn destructive(
221 label: impl Into<LocalizedString>,
222 on_invoke: impl Fn(&mut EventContext) + 'static,
223 ) -> Self {
224 Self::new(label, on_invoke).style(ToastActionStyle::Button {
225 variant: crate::button::ButtonVariant::Destructive,
226 })
227 }
228
229 pub fn style(mut self, style: ToastActionStyle) -> Self {
231 self.style = style;
232 self
233 }
234
235 pub fn closes_toast(mut self, closes: bool) -> Self {
240 self.closes_toast = closes;
241 self
242 }
243
244 pub fn shortcut_id(mut self, id: impl Into<String>) -> Self {
251 self.shortcut_id = Some(id.into());
252 self
253 }
254
255 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
257 self.tooltip = Some(text.into());
258 self
259 }
260
261 pub fn label(&self) -> String {
263 self.label.resolve_now()
264 }
265 pub fn style_ref(&self) -> &ToastActionStyle {
267 &self.style
268 }
269 pub fn closes_toast_flag(&self) -> bool {
271 self.closes_toast
272 }
273 pub fn shortcut_id_ref(&self) -> Option<&str> {
275 self.shortcut_id.as_deref()
276 }
277 pub(crate) fn label_ls(&self) -> LocalizedString {
280 self.label.clone()
281 }
282
283 pub fn tooltip_ref(&self) -> Option<&LocalizedString> {
285 self.tooltip.as_ref()
286 }
287 pub fn callback(&self) -> ToastActionCallback {
289 self.on_invoke.clone()
290 }
291}
292
293impl std::fmt::Debug for ToastAction {
294 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295 f.debug_struct("ToastAction")
296 .field("label", &self.label)
297 .field("style", &self.style)
298 .field("closes_toast", &self.closes_toast)
299 .field("shortcut_id", &self.shortcut_id)
300 .finish()
301 }
302}
303
304#[derive(Clone)]
316pub struct ToastHandle {
317 inner: Rc<ToastHandleInner>,
318}
319
320pub(crate) struct ToastHandleInner {
321 pub(crate) entry_id: u64,
322 pub(crate) dismissed: Cell<bool>,
327 pub(crate) registry: registry::ToastRegistry,
330}
331
332impl ToastHandle {
333 pub(crate) fn new(inner: ToastHandleInner) -> Self {
334 Self {
335 inner: Rc::new(inner),
336 }
337 }
338
339 pub fn entry_id(&self) -> u64 {
344 self.inner.entry_id
345 }
346
347 pub fn is_alive(&self) -> bool {
351 if self.inner.dismissed.get() {
352 return false;
353 }
354 self.inner.registry.is_entry_alive(self.inner.entry_id)
355 }
356
357 pub fn dismiss(&self, ctx: &mut EventContext) {
361 if self.inner.dismissed.get() {
362 return;
363 }
364 self.inner.registry.dismiss_entry(
365 self.inner.entry_id,
366 ToastDismissCause::Programmatic,
367 ctx,
368 );
369 }
370}
371
372impl std::fmt::Debug for ToastHandle {
373 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
374 f.debug_struct("ToastHandle")
375 .field("entry_id", &self.inner.entry_id)
376 .field("dismissed", &self.inner.dismissed.get())
377 .finish()
378 }
379}
380
381pub type ToastDismissCallback = Rc<dyn Fn(ToastDismissCause, &mut EventContext)>;
387
388pub struct Toast {
396 pub(crate) severity: ToastSeverity,
397 pub(crate) title: LocalizedString,
398 pub(crate) body: Option<LocalizedString>,
399 pub(crate) leading: Option<Box<dyn Widget>>,
400 pub(crate) actions: Vec<ToastAction>,
401 pub(crate) auto_dismiss_after: Option<Duration>,
402 pub(crate) priority: ToastPriority,
403 pub(crate) id: Option<String>,
404 pub(crate) on_click: Option<Rc<dyn Fn(&mut EventContext)>>,
405 pub(crate) on_dismiss: Option<ToastDismissCallback>,
406 pub(crate) announcement: Option<LocalizedString>,
407 pub(crate) show_close_button: bool,
408 pub(crate) closable_on_escape: bool,
409 pub(crate) archive: bool,
410 pub(crate) style_override: Option<teksilo_core::styles::SharedToastStyle>,
411 pub(crate) target: Option<ToastRoute>,
417}
418
419impl std::fmt::Debug for Toast {
420 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
421 f.debug_struct("Toast")
422 .field("severity", &self.severity)
423 .field("title", &self.title)
424 .field("body", &self.body)
425 .field("priority", &self.priority)
426 .field("id", &self.id)
427 .field("auto_dismiss_after", &self.auto_dismiss_after)
428 .field("actions_count", &self.actions.len())
429 .field("target", &self.target)
430 .finish()
431 }
432}
433
434impl Toast {
435 fn build_with_severity(severity: ToastSeverity, title: impl Into<LocalizedString>) -> Self {
436 let ls: LocalizedString = title.into();
437 Self {
438 severity,
439 title: ls,
440 body: None,
441 leading: None,
442 actions: Vec::new(),
443 auto_dismiss_after: Some(DEFAULT_TOAST_AUTO_DISMISS),
444 priority: ToastPriority::Normal,
445 id: None,
446 on_click: None,
447 on_dismiss: None,
448 announcement: None,
449 show_close_button: true,
450 closable_on_escape: true,
451 archive: true,
452 style_override: None,
453 target: None,
454 }
455 }
456
457 pub fn info(title: impl Into<LocalizedString>) -> Self {
461 Self::build_with_severity(ToastSeverity::Info, title)
462 }
463 pub fn success(title: impl Into<LocalizedString>) -> Self {
465 Self::build_with_severity(ToastSeverity::Success, title)
466 }
467 pub fn warning(title: impl Into<LocalizedString>) -> Self {
469 Self::build_with_severity(ToastSeverity::Warning, title)
470 }
471 pub fn error(title: impl Into<LocalizedString>) -> Self {
473 Self::build_with_severity(ToastSeverity::Error, title)
474 }
475 pub fn loading(title: impl Into<LocalizedString>) -> Self {
481 Self::build_with_severity(ToastSeverity::Info, title)
482 .persistent()
483 .leading(crate::spinner::Spinner::new(16.0))
484 }
485
486 pub fn body(mut self, text: impl Into<LocalizedString>) -> Self {
492 let ls: LocalizedString = text.into();
493 self.body = Some(ls);
494 self
495 }
496
497 pub fn leading(mut self, widget: impl Widget + 'static) -> Self {
501 self.leading = Some(Box::new(widget));
502 self
503 }
504
505 pub fn action(mut self, action: ToastAction) -> Self {
509 self.actions.push(action);
510 self
511 }
512 pub fn primary_action(
515 self,
516 label: impl Into<LocalizedString>,
517 on_invoke: impl Fn(&mut EventContext) + 'static,
518 ) -> Self {
519 self.action(ToastAction::primary(label, on_invoke))
520 }
521
522 pub fn auto_dismiss_after(mut self, duration: Duration) -> Self {
527 self.auto_dismiss_after = Some(duration);
528 self
529 }
530 pub fn persistent(mut self) -> Self {
534 self.auto_dismiss_after = None;
535 self
536 }
537 pub fn priority(mut self, priority: ToastPriority) -> Self {
540 self.priority = priority;
541 self
542 }
543
544 pub fn id(mut self, id: impl Into<String>) -> Self {
581 self.id = Some(id.into());
582 self
583 }
584
585 pub fn on_click(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
590 self.on_click = Some(Rc::new(f));
591 self
592 }
593 pub fn on_dismiss(
597 mut self,
598 f: impl Fn(ToastDismissCause, &mut EventContext) + 'static,
599 ) -> Self {
600 self.on_dismiss = Some(Rc::new(f));
601 self
602 }
603 pub fn show_close_button(mut self, show: bool) -> Self {
605 self.show_close_button = show;
606 self
607 }
608 pub fn closable_on_escape(mut self, allow: bool) -> Self {
612 self.closable_on_escape = allow;
613 self
614 }
615
616 pub fn announcement(mut self, text: impl Into<LocalizedString>) -> Self {
622 let ls: LocalizedString = text.into();
623 self.announcement = Some(ls);
624 self
625 }
626
627 pub fn archive(mut self, archive: bool) -> Self {
634 self.archive = archive;
635 self
636 }
637
638 pub fn style(mut self, style: impl teksilo_core::styles::ToastStyle) -> Self {
643 self.style_override = Some(Rc::new(style));
644 self
645 }
646
647 pub fn target(mut self, audience: ToastAudience) -> Self {
654 self.target = Some(ToastRoute::Audience(audience));
655 self
656 }
657
658 pub fn broadcast(mut self) -> Self {
663 self.target = Some(ToastRoute::Broadcast);
664 self
665 }
666
667 pub fn present(self, ctx: &mut EventContext) -> ToastHandle {
676 EventContextToastExt::show_toast(ctx, self)
677 }
678}
679
680#[cfg(test)]
681mod tests {
682 use super::*;
683 use teksilo_i18n::lit;
684
685 #[test]
686 fn severity_constructors_round_trip() {
687 assert_eq!(Toast::info(lit!("x")).severity, ToastSeverity::Info);
688 assert_eq!(Toast::success(lit!("x")).severity, ToastSeverity::Success);
689 assert_eq!(Toast::warning(lit!("x")).severity, ToastSeverity::Warning);
690 assert_eq!(Toast::error(lit!("x")).severity, ToastSeverity::Error);
691 }
692
693 #[test]
694 fn defaults_match_documented_values() {
695 let t = Toast::info(lit!("hello"));
696 assert_eq!(t.auto_dismiss_after, Some(DEFAULT_TOAST_AUTO_DISMISS));
697 assert_eq!(t.priority, ToastPriority::Normal);
698 assert!(t.show_close_button);
699 assert!(t.closable_on_escape);
700 assert!(t.archive);
701 assert!(t.body.is_none());
702 assert!(t.actions.is_empty());
703 assert!(t.on_dismiss.is_none());
704 assert!(t.on_click.is_none());
705 }
706
707 #[test]
708 fn persistent_clears_auto_dismiss() {
709 let t = Toast::error(lit!("boom")).persistent();
710 assert!(t.auto_dismiss_after.is_none());
711 }
712
713 #[test]
714 fn loading_is_info_persistent() {
715 let t = Toast::loading(lit!("Uploading"));
716 assert_eq!(t.severity, ToastSeverity::Info);
717 assert!(
718 t.auto_dismiss_after.is_none(),
719 "loading is persistent by default"
720 );
721 assert!(t.leading.is_some(), "loading sets a Spinner leading widget");
722 }
723
724 #[test]
725 fn action_primary_uses_filled_button() {
726 let a = ToastAction::primary(lit!("Retry"), |_| {}).style(ToastActionStyle::Button {
727 variant: crate::button::ButtonVariant::Filled,
728 });
729 match a.style_ref() {
730 ToastActionStyle::Button {
731 variant: crate::button::ButtonVariant::Filled,
732 } => {}
733 other => panic!("expected Button {{ Filled }}, got {other:?}"),
734 }
735 assert!(a.closes_toast_flag(), "actions close the toast by default");
736 }
737
738 #[test]
739 fn action_closes_toast_can_be_disabled() {
740 let a = ToastAction::new(lit!("Toggle"), |_| {}).closes_toast(false);
741 assert!(!a.closes_toast_flag());
742 }
743
744 fn fresh_registry() -> ToastRegistry {
749 ToastRegistry::new(host::ToastInstallOptions::default())
750 }
751
752 fn small_registry(max_visible: usize) -> ToastRegistry {
753 ToastRegistry::new(host::ToastInstallOptions {
754 max_visible,
755 ..host::ToastInstallOptions::default()
756 })
757 }
758
759 #[test]
760 fn enqueue_creates_live_entry() {
761 let r = fresh_registry();
762 let (h, _overflow) = r.enqueue(Toast::info(lit!("Saved")));
763 assert!(h.entry_id() > 0);
764 assert_eq!(r.live_count(), 1);
765 }
766
767 #[test]
768 fn enqueue_returns_distinct_ids() {
769 let r = fresh_registry();
770 let (h1, _) = r.enqueue(Toast::info(lit!("a")));
771 let (h2, _) = r.enqueue(Toast::info(lit!("b")));
772 let (h3, _) = r.enqueue(Toast::info(lit!("c")));
773 assert!(h1.entry_id() != h2.entry_id());
774 assert!(h2.entry_id() != h3.entry_id());
775 assert_eq!(r.live_count(), 3);
776 }
777
778 #[test]
779 fn slot_pool_overflow_drops_normal_priority() {
780 let r = small_registry(2);
781 let (_h1, _) = r.enqueue(Toast::info(lit!("a")));
782 let (_h2, _) = r.enqueue(Toast::info(lit!("b")));
783 assert_eq!(r.live_count(), 2);
784 let (h3, overflow) = r.enqueue(Toast::info(lit!("c")));
785 assert_eq!(
786 r.live_count(),
787 2,
788 "third Normal-priority toast must be dropped when pool is full"
789 );
790 assert!(!h3.is_alive(), "overflow handle should not be alive");
793 assert!(overflow.is_none());
795 }
796
797 #[test]
798 fn slot_pool_overflow_fires_on_dismiss_for_normal_drop() {
799 use std::cell::Cell;
800 let r = small_registry(2);
801 let (_h1, _) = r.enqueue(Toast::info(lit!("a")));
802 let (_h2, _) = r.enqueue(Toast::info(lit!("b")));
803 let fired = Rc::new(Cell::new(false));
804 let fired_clone = fired.clone();
805 let (_h3, overflow) = r.enqueue(Toast::info(lit!("c")).on_dismiss(move |cause, _ctx| {
806 assert_eq!(cause, ToastDismissCause::SlotPoolFull);
807 fired_clone.set(true);
808 }));
809 let (_cause, cb) = overflow.expect("overflow callback present");
813 let _ = cb;
818 assert!(!fired.get(), "callback fires only when ext invokes it");
821 }
822
823 #[test]
824 fn high_priority_evicts_oldest_normal_when_full() {
825 let r = small_registry(2);
826 let (h_a, _) = r.enqueue(Toast::info(lit!("a")));
827 let (h_b, _) = r.enqueue(Toast::info(lit!("b")));
828 let oldest_normal_id = h_a.entry_id();
829 let newer_normal_id = h_b.entry_id();
830 let (h_high, _) = r.enqueue(Toast::info(lit!("urgent")).priority(ToastPriority::High));
831 let live_ids = r.live_entry_ids();
832 assert!(
833 !live_ids.contains(&oldest_normal_id),
834 "oldest Normal must be evicted to make room for High"
835 );
836 assert!(live_ids.contains(&newer_normal_id));
837 assert!(live_ids.contains(&h_high.entry_id()));
838 assert_eq!(r.live_count(), 2);
839 }
840
841 #[test]
842 fn tick_timers_decrements_and_dismisses_on_expiry() {
843 let r = fresh_registry();
844 let (h, _) =
845 r.enqueue(Toast::info(lit!("fast")).auto_dismiss_after(Duration::from_millis(500)));
846 let id = h.entry_id();
847
848 let any_expired = r.tick_timers(Duration::from_millis(200), false);
850 assert!(!any_expired);
851 assert!(r.live_entry_ids().contains(&id));
852
853 let any_expired = r.tick_timers(Duration::from_millis(350), false);
855 assert!(any_expired);
856 assert!(!r.live_entry_ids().contains(&id));
857 }
858
859 #[test]
860 fn paused_tick_does_not_decrement() {
861 let r = fresh_registry();
862 let (h, _) =
863 r.enqueue(Toast::info(lit!("slow")).auto_dismiss_after(Duration::from_millis(300)));
864 for _ in 0..10 {
866 let any_expired = r.tick_timers(Duration::from_millis(100), true);
867 assert!(!any_expired);
868 }
869 assert!(r.live_entry_ids().contains(&h.entry_id()));
870 }
871
872 #[test]
873 fn persistent_toast_never_expires() {
874 let r = fresh_registry();
875 let (h, _) = r.enqueue(Toast::error(lit!("sticky")).persistent());
876 for _ in 0..50 {
877 let any_expired = r.tick_timers(Duration::from_secs(1), false);
878 assert!(!any_expired);
879 }
880 assert!(r.live_entry_ids().contains(&h.entry_id()));
881 }
882
883 #[test]
884 fn has_running_timers_gates_the_idle_frame_loop() {
885 let r = fresh_registry();
891
892 assert!(!r.has_running_timers());
894
895 let (sticky, _) = r.enqueue(Toast::error(lit!("sticky")).persistent());
897 assert!(!r.has_running_timers());
898
899 let (timed, _) =
901 r.enqueue(Toast::info(lit!("timed")).auto_dismiss_after(Duration::from_millis(500)));
902 assert!(r.has_running_timers());
903
904 let expired = r.tick_timers(Duration::from_millis(600), false);
907 assert!(expired);
908 assert!(!r.live_entry_ids().contains(&timed.entry_id()));
909 assert!(r.live_entry_ids().contains(&sticky.entry_id()));
910 assert!(!r.has_running_timers());
911 }
912
913 #[test]
914 fn loading_constructor_is_persistent_with_spinner_leading() {
915 let r = fresh_registry();
916 let (h, _) = r.enqueue(Toast::loading(lit!("Uploading")));
917 r.with_entry(h.entry_id(), |e| {
918 assert!(e.time_left.is_none(), "loading toasts are persistent");
919 assert!(
920 e.leading.is_some(),
921 "loading toasts carry a Spinner leading"
922 );
923 assert_eq!(e.severity, ToastSeverity::Info);
924 })
925 .unwrap();
926 }
927
928 #[test]
929 fn version_signal_bumps_on_enqueue_and_dismiss() {
930 let r = fresh_registry();
931 let initial = r.version_signal().get();
932 let (_h1, _) = r.enqueue(Toast::info(lit!("a")));
933 let after_show = r.version_signal().get();
934 assert_ne!(initial, after_show, "version bumps on enqueue");
935
936 r.tick_timers(Duration::ZERO, false); let (h2, _) =
939 r.enqueue(Toast::info(lit!("b")).auto_dismiss_after(Duration::from_millis(1)));
940 let _ = h2;
941 let pre_dismiss = r.version_signal().get();
942 r.tick_timers(Duration::from_millis(10), false);
943 let post_dismiss = r.version_signal().get();
944 assert_ne!(pre_dismiss, post_dismiss, "version bumps on timer dismiss");
945 }
946
947 #[test]
948 fn registry_with_archive_mirrors_pushes() {
949 use crate::notification::NotificationArchiveModel;
950 let archive = std::rc::Rc::new(NotificationArchiveModel::in_memory());
951 let registry =
952 ToastRegistry::with_archive(host::ToastInstallOptions::default(), archive.clone());
953 let (_h1, _) = registry.enqueue(Toast::error(lit!("Build failed")));
954 let (_h2, _) = registry.enqueue(Toast::success(lit!("Deploy ok")));
955 assert_eq!(archive.entries().len(), 2);
957 assert_eq!(
959 archive.entries().with_item(0, |e| e.title.clone()),
960 Some("Deploy ok".into())
961 );
962 assert_eq!(archive.unread_count().get(), 2);
963 }
964
965 #[test]
966 fn registry_archive_false_skips_mirroring() {
967 use crate::notification::NotificationArchiveModel;
968 let archive = std::rc::Rc::new(NotificationArchiveModel::in_memory());
969 let registry =
970 ToastRegistry::with_archive(host::ToastInstallOptions::default(), archive.clone());
971 let (_archived, _) = registry.enqueue(Toast::info(lit!("logged")));
973 let (_silent, _) = registry.enqueue(Toast::info(lit!("Copied!")).archive(false));
975 assert_eq!(archive.entries().len(), 1);
976 assert_eq!(
977 archive.entries().with_item(0, |e| e.title.clone()),
978 Some("logged".into())
979 );
980 }
981
982 #[test]
983 fn registry_with_id_updates_live_entry_in_place_keeping_entry_id() {
984 let r = fresh_registry();
985 let (first, _) = r.enqueue(Toast::loading(lit!("Uploading 1 of 7…")).id("upload"));
986 assert_eq!(r.live_count(), 1);
987 let first_entry_id = first.entry_id();
988
989 let (second, _) = r.enqueue(Toast::loading(lit!("Uploading 4 of 7…")).id("upload"));
990 assert_eq!(r.live_count(), 1, "live entry count stays at 1");
992 assert_eq!(
993 second.entry_id(),
994 first_entry_id,
995 "update returns the same entry_id — the original handle stays valid"
996 );
997
998 assert!(first.is_alive());
1001 assert!(second.is_alive());
1002 }
1003
1004 #[test]
1005 fn registry_in_place_update_reflects_new_title_body() {
1006 let r = fresh_registry();
1007 let (h, _) = r.enqueue(Toast::info(lit!("Saving")).id("save"));
1008 let _ = r.enqueue(
1009 Toast::success(lit!("Saved!"))
1010 .id("save")
1011 .body(lit!("Written 1.2 MB to disk.")),
1012 );
1013 r.with_entry(h.entry_id(), |e| {
1014 assert_eq!(e.title.resolve_now(), "Saved!", "title updated in place");
1015 assert_eq!(
1016 e.body.as_ref().map(|b| b.resolve_now()).as_deref(),
1017 Some("Written 1.2 MB to disk."),
1018 "body updated in place"
1019 );
1020 assert_eq!(e.severity, ToastSeverity::Success, "severity updated");
1021 })
1022 .unwrap();
1023 }
1024
1025 #[test]
1026 fn registry_in_place_update_resets_auto_dismiss_timer() {
1027 let r = fresh_registry();
1028 let (h, _) = r.enqueue(
1029 Toast::info(lit!("slow"))
1030 .id("ticker")
1031 .auto_dismiss_after(Duration::from_millis(500)),
1032 );
1033 r.tick_timers(Duration::from_millis(450), false);
1035 let _ = r.enqueue(
1037 Toast::info(lit!("slow #2"))
1038 .id("ticker")
1039 .auto_dismiss_after(Duration::from_millis(500)),
1040 );
1041 let any_expired = r.tick_timers(Duration::from_millis(100), false);
1043 assert!(
1044 !any_expired,
1045 "timer reset on update — entry must survive a tick that would have expired the original"
1046 );
1047 assert!(h.is_alive());
1048 }
1049
1050 #[test]
1051 fn registry_in_place_update_preserves_leading_when_not_provided() {
1052 let r = fresh_registry();
1057 let (h, _) = r.enqueue(Toast::loading(lit!("step 1")).id("upload"));
1058 let has_spinner_initially = r.with_entry(h.entry_id(), |e| e.leading.is_some()).unwrap();
1062 assert!(has_spinner_initially, "loading toast carries a Spinner");
1063
1064 let _ = r.enqueue(Toast::info(lit!("step 2")).id("upload"));
1066 let still_has_spinner = r.with_entry(h.entry_id(), |e| e.leading.is_some()).unwrap();
1067 assert!(
1068 still_has_spinner,
1069 "in-place update with no .leading(...) preserves the existing leading widget"
1070 );
1071 }
1072
1073 #[test]
1074 fn registry_in_place_update_preserves_on_dismiss_when_not_provided() {
1075 let r = fresh_registry();
1082 let (h, _) = r.enqueue(
1083 Toast::info(lit!("step 1"))
1084 .id("preserve-on-dismiss")
1085 .on_dismiss(|_cause, _ctx| {}),
1086 );
1087 assert!(
1089 r.with_entry(h.entry_id(), |e| e.on_dismiss.is_some())
1090 .unwrap(),
1091 "original entry has on_dismiss attached"
1092 );
1093
1094 let _ = r.enqueue(Toast::success(lit!("step 2")).id("preserve-on-dismiss"));
1096 assert!(
1097 r.with_entry(h.entry_id(), |e| e.on_dismiss.is_some())
1098 .unwrap(),
1099 "in-place update with no .on_dismiss(...) preserves the existing callback"
1100 );
1101
1102 let _ = r.enqueue(
1106 Toast::info(lit!("step 3"))
1107 .id("preserve-on-dismiss")
1108 .on_dismiss(|_cause, _ctx| {}),
1109 );
1110 assert!(
1111 r.with_entry(h.entry_id(), |e| e.on_dismiss.is_some())
1112 .unwrap(),
1113 "in-place update WITH new on_dismiss installs the replacement"
1114 );
1115 }
1116
1117 #[test]
1118 fn registry_in_place_update_without_id_appends_normally() {
1119 let r = fresh_registry();
1120 let _ = r.enqueue(Toast::info(lit!("a")));
1121 let _ = r.enqueue(Toast::info(lit!("b")));
1122 assert_eq!(r.live_count(), 2);
1124 }
1125
1126 #[test]
1127 fn registry_in_place_update_distinct_ids_do_not_collide() {
1128 let r = fresh_registry();
1129 let _ = r.enqueue(Toast::info(lit!("upload")).id("upload"));
1130 let _ = r.enqueue(Toast::info(lit!("download")).id("download"));
1131 assert_eq!(r.live_count(), 2);
1133 let _ = r.enqueue(Toast::success(lit!("Uploaded!")).id("upload"));
1135 assert_eq!(
1136 r.live_count(),
1137 2,
1138 "still two entries after upload-only update"
1139 );
1140 }
1141
1142 #[test]
1143 fn registry_with_id_merges_into_archive_in_place() {
1144 use crate::notification::NotificationArchiveModel;
1145 let archive = std::rc::Rc::new(NotificationArchiveModel::in_memory());
1146 let registry =
1147 ToastRegistry::with_archive(host::ToastInstallOptions::default(), archive.clone());
1148 let (_a, _) = registry.enqueue(Toast::info(lit!("Uploading 1 of 7")).id("upload"));
1149 assert_eq!(archive.entries().len(), 1);
1150 let (_b, _) = registry.enqueue(Toast::info(lit!("Uploading 4 of 7")).id("upload"));
1151 assert_eq!(archive.entries().len(), 1);
1153 let merged = archive.entries().with_item(0, |e| e.clone()).unwrap();
1154 assert_eq!(merged.title, "Uploading 4 of 7");
1155 assert_eq!(merged.updates.len(), 1);
1156 }
1157
1158 #[test]
1159 fn registry_without_archive_does_not_panic() {
1160 let registry = ToastRegistry::new(host::ToastInstallOptions::default());
1163 let (_h, _) = registry.enqueue(Toast::info(lit!("no archive here")));
1164 assert!(registry.archive().is_none());
1165 }
1166
1167 #[test]
1168 fn registry_archive_intent_name_survives_on_action() {
1169 use crate::notification::{ArchivedActionStyle, NotificationArchiveModel};
1170 let archive = std::rc::Rc::new(NotificationArchiveModel::in_memory());
1171 let registry =
1172 ToastRegistry::with_archive(host::ToastInstallOptions::default(), archive.clone());
1173 let (_h, _) = registry
1174 .enqueue(Toast::error(lit!("Build failed")).action(
1175 ToastAction::primary(lit!("Retry"), |_| {}).shortcut_id("app.build.retry"),
1176 ));
1177 let entry = archive.entries().with_item(0, |e| e.clone()).unwrap();
1178 assert_eq!(entry.actions.len(), 1);
1179 assert_eq!(entry.actions[0].label, "Retry");
1180 assert_eq!(
1181 entry.actions[0].intent_name.as_deref(),
1182 Some("app.build.retry")
1183 );
1184 assert_eq!(entry.actions[0].style, ArchivedActionStyle::PrimaryButton);
1185 assert!(entry.actions[0].closes_on_invoke);
1186 }
1187
1188 #[test]
1189 fn archive_flag_is_captured_on_entry() {
1190 let r = fresh_registry();
1191 let (h_noarchive, _) = r.enqueue(Toast::info(lit!("Copied!")).archive(false));
1192 let archived = r.with_entry(h_noarchive.entry_id(), |e| e.archive).unwrap();
1193 assert!(!archived);
1194
1195 let (h_archived, _) = r.enqueue(Toast::error(lit!("Build failed")));
1196 let archived = r.with_entry(h_archived.entry_id(), |e| e.archive).unwrap();
1197 assert!(archived, "archive defaults to true");
1198 }
1199
1200 #[test]
1201 fn registry_mirrors_the_resolved_route_onto_the_archived_entry() {
1202 use crate::notification::NotificationArchiveModel;
1216 let archive = std::rc::Rc::new(NotificationArchiveModel::in_memory());
1217 let registry =
1218 ToastRegistry::with_archive(host::ToastInstallOptions::default(), archive.clone());
1219
1220 let audience = ToastAudience::new(99);
1221 let (h, _) = registry.enqueue(Toast::info(lit!("scoped")).target(audience));
1222 let live_route = registry.with_entry(h.entry_id(), |e| e.route).unwrap();
1223 assert_eq!(live_route, ToastRoute::Audience(audience));
1224
1225 let archived_route = archive.entries().with_item(0, |e| e.route).unwrap();
1226 assert_eq!(
1227 archived_route,
1228 ToastRoute::Audience(audience),
1229 "the archived NotificationEntry must carry the SAME route as the live \
1230 entry it was mirrored from"
1231 );
1232 }
1233
1234 fn surface_node(
1242 severity: ToastSeverity,
1243 priority: ToastPriority,
1244 ) -> teksilo_core::accessibility::AccessNodeBuilder {
1245 use crate::toast::surface::{ToastSurface, ToastSurfaceData};
1246 use teksilo_core::accessibility::AccessNodeBuilder;
1247 use teksilo_core::widget::Widget;
1248 let data = ToastSurfaceData {
1249 entry_id: 1,
1250 severity,
1251 priority,
1252 title: teksilo_i18n::lit!("x"),
1253 body: None,
1254 announcement: None,
1255 actions: Rc::new(Vec::new()),
1256 show_close_button: false,
1257 on_click: None,
1258 style_override: None,
1259 body_state: teksilo_core::signal::Signal::new(0),
1260 };
1261 let surface = ToastSurface::new(data, None, fresh_registry(), false);
1262 let mut builder = AccessNodeBuilder::new();
1263 surface.accessibility(&mut builder);
1264 builder
1265 }
1266
1267 fn surface_role_for(
1268 severity: ToastSeverity,
1269 priority: ToastPriority,
1270 ) -> teksilo_core::accesskit::Role {
1271 surface_node(severity, priority).role()
1272 }
1273
1274 fn surface_live_for(
1275 severity: ToastSeverity,
1276 priority: ToastPriority,
1277 ) -> teksilo_core::accesskit::Live {
1278 let mut node = surface_node(severity, priority);
1279 node.inner_mut()
1280 .live()
1281 .unwrap_or(teksilo_core::accesskit::Live::Off)
1282 }
1283
1284 #[test]
1285 fn at_role_status_for_info_success_warning_normal() {
1286 use teksilo_core::accesskit::Role;
1287 assert_eq!(
1288 surface_role_for(ToastSeverity::Info, ToastPriority::Normal),
1289 Role::Status
1290 );
1291 assert_eq!(
1292 surface_role_for(ToastSeverity::Success, ToastPriority::Normal),
1293 Role::Status
1294 );
1295 assert_eq!(
1296 surface_role_for(ToastSeverity::Warning, ToastPriority::Normal),
1297 Role::Status
1298 );
1299 }
1300
1301 #[test]
1302 fn at_role_alert_for_error_and_warning_high() {
1303 use teksilo_core::accesskit::Role;
1304 assert_eq!(
1305 surface_role_for(ToastSeverity::Error, ToastPriority::Normal),
1306 Role::Alert
1307 );
1308 assert_eq!(
1309 surface_role_for(ToastSeverity::Error, ToastPriority::High),
1310 Role::Alert
1311 );
1312 assert_eq!(
1313 surface_role_for(ToastSeverity::Warning, ToastPriority::High),
1314 Role::Alert
1315 );
1316 assert_eq!(
1317 surface_role_for(ToastSeverity::Warning, ToastPriority::Urgent),
1318 Role::Alert
1319 );
1320 }
1321
1322 #[test]
1323 fn at_live_polite_for_status_assertive_for_alert() {
1324 use teksilo_core::accesskit::Live;
1325 assert_eq!(
1326 surface_live_for(ToastSeverity::Info, ToastPriority::Normal),
1327 Live::Polite
1328 );
1329 assert_eq!(
1330 surface_live_for(ToastSeverity::Error, ToastPriority::Normal),
1331 Live::Assertive
1332 );
1333 assert_eq!(
1334 surface_live_for(ToastSeverity::Warning, ToastPriority::High),
1335 Live::Assertive
1336 );
1337 }
1338
1339 #[test]
1340 fn urgent_priority_forces_assertive_regardless_of_severity() {
1341 use teksilo_core::accesskit::Live;
1342 assert_eq!(
1344 surface_live_for(ToastSeverity::Info, ToastPriority::Urgent),
1345 Live::Assertive
1346 );
1347 assert_eq!(
1348 surface_live_for(ToastSeverity::Success, ToastPriority::Urgent),
1349 Live::Assertive
1350 );
1351 }
1352
1353 use crate::primitives::TextWidget as TestLeaf; fn setup_host_tree(
1360 opts: host::ToastInstallOptions,
1361 ) -> (teksilo_core::widget_tree::WidgetTree, ToastRegistry) {
1362 use std::any::{Any, TypeId};
1363 use std::collections::HashMap;
1364
1365 let registry = ToastRegistry::new(opts.clone());
1366 let mut app_state: HashMap<TypeId, Box<dyn Any>> = HashMap::new();
1367 app_state.insert(TypeId::of::<ToastRegistry>(), Box::new(registry.clone()));
1368
1369 let mut tree = teksilo_core::widget_tree::WidgetTree::new()
1370 .with_theme(teksilo_core::presets::intui::light());
1371 tree.set_app_context(Rc::new(
1372 teksilo_core::event_source::TreeAppContext::empty().with_app_state(app_state),
1373 ));
1374 let user_root = tree.add(TestLeaf::new(lit!("user content")));
1375 let host = ToastHost::wrapping(user_root, registry.clone(), opts);
1376 tree.add(host);
1377 tree.layout(teksilo_canvas::SizeProposal::exact(800.0, 600.0));
1378 (tree, registry)
1379 }
1380
1381 #[test]
1382 fn host_renders_a_toast_surface_for_each_live_entry() {
1383 use std::any::{Any, TypeId};
1384 use std::collections::HashMap;
1385
1386 let opts = host::ToastInstallOptions::default();
1391 let registry = ToastRegistry::new(opts.clone());
1392 let _h = registry.enqueue(Toast::success(lit!("Saved")));
1393 assert_eq!(registry.live_count(), 1);
1394
1395 let mut app_state: HashMap<TypeId, Box<dyn Any>> = HashMap::new();
1396 app_state.insert(TypeId::of::<ToastRegistry>(), Box::new(registry.clone()));
1397
1398 let mut tree = teksilo_core::widget_tree::WidgetTree::new()
1399 .with_theme(teksilo_core::presets::intui::light());
1400 tree.set_app_context(Rc::new(
1401 teksilo_core::event_source::TreeAppContext::empty().with_app_state(app_state),
1402 ));
1403 let user_root = tree.add(TestLeaf::new(lit!("user content")));
1404 tree.add(ToastHost::wrapping(user_root, registry.clone(), opts));
1405 tree.layout(teksilo_canvas::SizeProposal::exact(800.0, 600.0));
1406
1407 assert!(
1408 tree.find_by_role(teksilo_core::accesskit::Role::Status)
1409 .is_some(),
1410 "Success toast renders a Role::Status surface in the host"
1411 );
1412 }
1413
1414 #[test]
1415 fn host_promotes_error_toast_to_role_alert() {
1416 use std::any::{Any, TypeId};
1417 use std::collections::HashMap;
1418 let opts = host::ToastInstallOptions::default();
1419 let registry = ToastRegistry::new(opts.clone());
1420 let _h = registry.enqueue(Toast::error(lit!("Build failed")).persistent());
1421
1422 let mut app_state: HashMap<TypeId, Box<dyn Any>> = HashMap::new();
1423 app_state.insert(TypeId::of::<ToastRegistry>(), Box::new(registry.clone()));
1424 let mut tree = teksilo_core::widget_tree::WidgetTree::new()
1425 .with_theme(teksilo_core::presets::intui::light());
1426 tree.set_app_context(Rc::new(
1427 teksilo_core::event_source::TreeAppContext::empty().with_app_state(app_state),
1428 ));
1429 let user_root = tree.add(TestLeaf::new(lit!("root")));
1430 tree.add(ToastHost::wrapping(user_root, registry.clone(), opts));
1431 tree.layout(teksilo_canvas::SizeProposal::exact(800.0, 600.0));
1432 assert!(
1433 tree.find_by_role(teksilo_core::accesskit::Role::Alert)
1434 .is_some(),
1435 "Error toast emits Role::Alert via the surface widget"
1436 );
1437 }
1438
1439 #[test]
1440 fn hover_count_flips_paused_state_observed_by_host_tick() {
1441 let r = fresh_registry();
1446 let (h, _) =
1447 r.enqueue(Toast::info(lit!("hover me")).auto_dismiss_after(Duration::from_millis(200)));
1448 r.hover_count_signal().set(1);
1450 for _ in 0..10 {
1453 let hover = r.hover_count_signal().get() > 0;
1454 r.tick_timers(Duration::from_millis(100), hover);
1455 }
1456 assert!(
1457 r.live_entry_ids().contains(&h.entry_id()),
1458 "hover-paused entry must survive past its auto-dismiss window"
1459 );
1460 r.hover_count_signal().set(0);
1462 let hover = r.hover_count_signal().get() > 0;
1463 r.tick_timers(Duration::from_millis(250), hover);
1464 assert!(
1465 !r.live_entry_ids().contains(&h.entry_id()),
1466 "after un-hover, entry expires"
1467 );
1468 }
1469
1470 #[test]
1481 fn show_toast_default_targets_the_originating_window() {
1482 use crate::button::Button;
1483 use std::any::{Any, TypeId};
1484 use std::collections::HashMap;
1485
1486 use teksilo_core::window::state::WindowStateInit;
1487 use teksilo_core::window::{TeksiloWindowId, WindowPlacement, WindowState};
1488
1489 let registry = fresh_registry();
1490 let mut app_state: HashMap<TypeId, Box<dyn Any>> = HashMap::new();
1491 app_state.insert(TypeId::of::<ToastRegistry>(), Box::new(registry.clone()));
1492
1493 let mut tree = teksilo_core::widget_tree::WidgetTree::new()
1494 .with_theme(teksilo_core::presets::intui::light());
1495 tree.set_app_context(Rc::new(
1496 teksilo_core::event_source::TreeAppContext::empty().with_app_state(app_state),
1497 ));
1498 tree.set_window_state(WindowState::new(WindowStateInit {
1499 id: TeksiloWindowId::new(1),
1500 string_id: Some("test".to_string()),
1501 placement: WindowPlacement::Floating,
1502 title: "Test".to_string(),
1503 size: (800, 600),
1504 position: (0, 0),
1505 focused: false,
1506 resizable: true,
1507 always_on_top: false,
1508 }));
1509
1510 let btn = tree.add(Button::new(lit!("Save")).on_activate_fn(|ctx| {
1511 let _ = Toast::info(lit!("Saved")).present(ctx);
1512 }));
1513 tree.layout(teksilo_canvas::SizeProposal::exact(400.0, 300.0));
1514
1515 tree.click(btn);
1516
1517 assert_eq!(
1518 registry.live_count(),
1519 1,
1520 "the click must have enqueued a toast"
1521 );
1522 let eid = registry.live_entry_ids()[0];
1523 registry
1524 .with_entry(eid, |e| {
1525 assert_eq!(
1526 e.route,
1527 ToastRoute::Window(TeksiloWindowId::new(1)),
1528 "no explicit target + a real window at present time → origin-window route"
1529 );
1530 })
1531 .unwrap();
1532 }
1533
1534 #[test]
1535 fn target_routes_to_the_matching_audience_only() {
1536 let r = fresh_registry();
1537 let audience = ToastAudience::new(42);
1538 let (h, _) = r.enqueue(Toast::info(lit!("scoped")).target(audience));
1539 r.with_entry(h.entry_id(), |e| {
1540 assert_eq!(e.route, ToastRoute::Audience(audience));
1541 })
1542 .unwrap();
1543 }
1544
1545 #[test]
1546 fn broadcast_routes_regardless_of_window_or_audience() {
1547 let r = fresh_registry();
1548 let (h, _) = r.enqueue(Toast::warning(lit!("everyone")).broadcast());
1549 r.with_entry(h.entry_id(), |e| {
1550 assert_eq!(e.route, ToastRoute::Broadcast);
1551 })
1552 .unwrap();
1553 }
1554
1555 #[test]
1556 fn no_window_no_target_falls_back_to_broadcast() {
1557 let r = fresh_registry();
1562 let (h, _) = r.enqueue(Toast::error(lit!("no context here")));
1563 r.with_entry(h.entry_id(), |e| {
1564 assert_eq!(e.route, ToastRoute::Broadcast);
1565 })
1566 .unwrap();
1567 }
1568
1569 #[test]
1570 fn per_audience_admission_one_burst_does_not_starve_another_audience() {
1571 let r = small_registry(2);
1575 let audience_a = ToastAudience::new(1);
1576 let audience_b = ToastAudience::new(2);
1577
1578 let (a1, _) = r.enqueue(Toast::info(lit!("a1")).target(audience_a));
1579 let (a2, _) = r.enqueue(Toast::info(lit!("a2")).target(audience_a));
1580 let (a3, _) = r.enqueue(Toast::info(lit!("a3")).target(audience_a));
1583 assert!(
1584 !a3.is_alive(),
1585 "audience A's third toast overflows its own bucket"
1586 );
1587 assert!(a1.is_alive() && a2.is_alive());
1588
1589 let (b1, _) = r.enqueue(Toast::info(lit!("b1")).target(audience_b));
1591 let (b2, _) = r.enqueue(Toast::info(lit!("b2")).target(audience_b));
1592 assert!(
1593 b1.is_alive() && b2.is_alive(),
1594 "audience B's own admission must be unaffected by A's burst"
1595 );
1596 assert_eq!(
1597 r.live_count(),
1598 4,
1599 "2 live A entries + 2 live B entries — B was never starved"
1600 );
1601
1602 let (b3, _) = r.enqueue(Toast::info(lit!("b3")).target(audience_b));
1606 assert!(!b3.is_alive());
1607 assert_eq!(r.live_count(), 4);
1608 }
1609
1610 #[test]
1611 fn per_audience_high_priority_evicts_oldest_normal_within_the_same_bucket_only() {
1612 let r = small_registry(2);
1613 let audience_a = ToastAudience::new(1);
1614 let audience_b = ToastAudience::new(2);
1615
1616 let (a_old, _) = r.enqueue(Toast::info(lit!("a-old")).target(audience_a));
1617 let (a_new, _) = r.enqueue(Toast::info(lit!("a-new")).target(audience_a));
1618 let (b1, _) = r.enqueue(Toast::info(lit!("b1")).target(audience_b));
1619 let (b2, _) = r.enqueue(Toast::info(lit!("b2")).target(audience_b));
1620
1621 let (a_high, _) = r.enqueue(
1625 Toast::info(lit!("a-urgent"))
1626 .target(audience_a)
1627 .priority(ToastPriority::High),
1628 );
1629 let live_ids = r.live_entry_ids();
1630 assert!(
1631 !live_ids.contains(&a_old.entry_id()),
1632 "oldest Normal within audience A's bucket is evicted"
1633 );
1634 assert!(live_ids.contains(&a_new.entry_id()));
1635 assert!(live_ids.contains(&a_high.entry_id()));
1636 assert!(
1637 live_ids.contains(&b1.entry_id()) && live_ids.contains(&b2.entry_id()),
1638 "audience B's entries must be untouched by A's High-priority eviction"
1639 );
1640 assert_eq!(r.live_count(), 4);
1641 }
1642
1643 #[test]
1644 fn host_renders_no_surfaces_when_registry_empty() {
1645 let (tree, registry) = setup_host_tree(host::ToastInstallOptions::default());
1648 assert_eq!(registry.live_count(), 0);
1649 assert!(
1650 tree.find_by_role(teksilo_core::accesskit::Role::Status)
1651 .is_none()
1652 );
1653 assert!(
1654 tree.find_by_role(teksilo_core::accesskit::Role::Alert)
1655 .is_none()
1656 );
1657 }
1658}