1use fret_core::time::{Duration, Instant};
2use std::collections::HashMap;
3use std::sync::Arc;
4use std::sync::Mutex;
5
6use fret_core::{AppWindowId, Point, Px, TimerToken};
7use fret_runtime::{CommandId, Effect, Model};
8use fret_ui::UiHost;
9use fret_ui::action::UiActionHostAdapter;
10use fret_ui::elements::GlobalElementId;
11
12use super::requests::ToastIconOverride;
13
14pub(super) const TOAST_CLOSE_DURATION: Duration = Duration::from_millis(200);
15pub(super) const TOAST_AUTO_CLOSE_TICK: Duration = Duration::from_millis(100);
16pub const DEFAULT_MAX_TOASTS: usize = 3;
17pub const DEFAULT_SWIPE_THRESHOLD_PX: f32 = 45.0;
18pub const DEFAULT_SWIPE_MAX_DRAG_PX: f32 = 240.0;
19pub const DEFAULT_SWIPE_DRAGGING_THRESHOLD_PX: f32 = 4.0;
20pub const DEFAULT_VISIBLE_TOASTS: usize = 3;
21pub const DEFAULT_TOAST_DURATION: Duration = Duration::from_millis(4000);
22const DEFAULT_SONNER_SWIPE_AXIS_LOCK_THRESHOLD_PX: f32 = 1.0;
23const DEFAULT_SONNER_SWIPE_VELOCITY_THRESHOLD_PX_PER_MS: f32 = 0.11;
24
25#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
26pub enum ToastPosition {
27 TopLeft,
28 TopCenter,
29 TopRight,
30 BottomLeft,
31 BottomCenter,
32 #[default]
33 BottomRight,
34}
35
36#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
37pub enum ToastVariant {
38 #[default]
39 Default,
40 Destructive,
41 Success,
42 Info,
43 Warning,
44 Error,
45 Loading,
46}
47
48#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
50pub enum ToastSwipeDirection {
51 Left,
52 #[default]
53 Right,
54 Up,
55 Down,
56}
57
58#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
62pub struct ToastSwipeDirections {
63 pub allow_left: bool,
64 pub allow_right: bool,
65 pub allow_up: bool,
66 pub allow_down: bool,
67}
68
69impl ToastSwipeDirections {
70 pub fn from_slice(dirs: &[ToastSwipeDirection]) -> Self {
71 let mut out = Self::default();
72 for dir in dirs {
73 match dir {
74 ToastSwipeDirection::Left => out.allow_left = true,
75 ToastSwipeDirection::Right => out.allow_right = true,
76 ToastSwipeDirection::Up => out.allow_up = true,
77 ToastSwipeDirection::Down => out.allow_down = true,
78 }
79 }
80 out
81 }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq)]
85pub struct ToastSwipeConfig {
86 pub direction: ToastSwipeDirection,
87 pub threshold: Px,
88 pub max_drag: Px,
89 pub dragging_threshold: Px,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub(super) enum ToastDragAxis {
94 X,
95 Y,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq)]
99pub(super) struct ToastSwipeDragConfig {
100 allow_left: bool,
101 allow_right: bool,
102 allow_up: bool,
103 allow_down: bool,
104 threshold: Px,
105 max_drag: Px,
106 dragging_threshold: Px,
107 axis_lock_threshold: Px,
108 velocity_threshold_px_per_ms: f32,
109 fixed_axis: Option<ToastDragAxis>,
110}
111
112impl ToastSwipeDragConfig {
113 fn from_single(cfg: ToastSwipeConfig) -> Self {
114 let (allow_left, allow_right, allow_up, allow_down, fixed_axis) = match cfg.direction {
115 ToastSwipeDirection::Left => (true, false, false, false, Some(ToastDragAxis::X)),
116 ToastSwipeDirection::Right => (false, true, false, false, Some(ToastDragAxis::X)),
117 ToastSwipeDirection::Up => (false, false, true, false, Some(ToastDragAxis::Y)),
118 ToastSwipeDirection::Down => (false, false, false, true, Some(ToastDragAxis::Y)),
119 };
120
121 Self {
122 allow_left,
123 allow_right,
124 allow_up,
125 allow_down,
126 threshold: cfg.threshold,
127 max_drag: cfg.max_drag,
128 dragging_threshold: cfg.dragging_threshold,
129 axis_lock_threshold: Px(0.0),
130 velocity_threshold_px_per_ms: DEFAULT_SONNER_SWIPE_VELOCITY_THRESHOLD_PX_PER_MS,
131 fixed_axis,
132 }
133 }
134
135 fn sonner_for_position(position: ToastPosition) -> Self {
136 let (allow_up, allow_down) = match position {
137 ToastPosition::TopLeft | ToastPosition::TopCenter | ToastPosition::TopRight => {
138 (true, false)
139 }
140 ToastPosition::BottomLeft
141 | ToastPosition::BottomCenter
142 | ToastPosition::BottomRight => (false, true),
143 };
144 let (allow_left, allow_right) = match position {
145 ToastPosition::TopLeft | ToastPosition::BottomLeft => (true, false),
146 ToastPosition::TopRight | ToastPosition::BottomRight => (false, true),
147 ToastPosition::TopCenter | ToastPosition::BottomCenter => (false, false),
148 };
149
150 Self {
151 allow_left,
152 allow_right,
153 allow_up,
154 allow_down,
155 threshold: Px(DEFAULT_SWIPE_THRESHOLD_PX),
156 max_drag: Px(DEFAULT_SWIPE_MAX_DRAG_PX),
157 dragging_threshold: Px(DEFAULT_SWIPE_DRAGGING_THRESHOLD_PX),
158 axis_lock_threshold: Px(DEFAULT_SONNER_SWIPE_AXIS_LOCK_THRESHOLD_PX),
159 velocity_threshold_px_per_ms: DEFAULT_SONNER_SWIPE_VELOCITY_THRESHOLD_PX_PER_MS,
160 fixed_axis: None,
161 }
162 }
163
164 fn sonner_for_directions(dirs: ToastSwipeDirections) -> Self {
165 Self {
166 allow_left: dirs.allow_left,
167 allow_right: dirs.allow_right,
168 allow_up: dirs.allow_up,
169 allow_down: dirs.allow_down,
170 threshold: Px(DEFAULT_SWIPE_THRESHOLD_PX),
171 max_drag: Px(DEFAULT_SWIPE_MAX_DRAG_PX),
172 dragging_threshold: Px(DEFAULT_SWIPE_DRAGGING_THRESHOLD_PX),
173 axis_lock_threshold: Px(DEFAULT_SONNER_SWIPE_AXIS_LOCK_THRESHOLD_PX),
174 velocity_threshold_px_per_ms: DEFAULT_SONNER_SWIPE_VELOCITY_THRESHOLD_PX_PER_MS,
175 fixed_axis: None,
176 }
177 }
178
179 fn axis_allowed(self, axis: ToastDragAxis) -> bool {
180 match axis {
181 ToastDragAxis::X => self.allow_left || self.allow_right,
182 ToastDragAxis::Y => self.allow_up || self.allow_down,
183 }
184 }
185}
186
187impl Default for ToastSwipeConfig {
188 fn default() -> Self {
189 Self {
190 direction: ToastSwipeDirection::default(),
191 threshold: Px(DEFAULT_SWIPE_THRESHOLD_PX),
192 max_drag: Px(DEFAULT_SWIPE_MAX_DRAG_PX),
193 dragging_threshold: Px(DEFAULT_SWIPE_DRAGGING_THRESHOLD_PX),
194 }
195 }
196}
197
198#[derive(Debug, Clone)]
199pub struct ToastAction {
200 pub label: Arc<str>,
201 pub command: CommandId,
202 pub dismiss_toast: bool,
206}
207
208impl ToastAction {
209 pub fn new(label: impl Into<Arc<str>>, command: impl Into<CommandId>) -> Self {
210 Self {
211 label: label.into(),
212 command: command.into(),
213 dismiss_toast: true,
214 }
215 }
216
217 pub fn dismiss_toast(mut self, dismiss: bool) -> Self {
218 self.dismiss_toast = dismiss;
219 self
220 }
221}
222
223#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
224pub enum ToastDuration {
225 #[default]
227 UseDefault,
228 Pinned,
230 Fixed(Duration),
232}
233
234impl ToastDuration {
235 fn is_explicit(self) -> bool {
236 !matches!(self, Self::UseDefault)
237 }
238}
239
240#[derive(Debug, Clone, PartialEq, Eq)]
241pub enum ToastDescription {
242 Text(Arc<str>),
243 Hidden,
244}
245
246#[derive(Debug, Clone)]
247pub struct ToastRequest {
248 pub id: Option<ToastId>,
249 pub toaster_id: Option<Arc<str>>,
250 pub title: Arc<str>,
251 pub description: Option<ToastDescription>,
255 pub duration: ToastDuration,
256 pub variant: Option<ToastVariant>,
258 pub icon: Option<ToastIconOverride>,
262 pub promise: bool,
267 pub action: Option<ToastAction>,
268 pub cancel: Option<ToastAction>,
269 pub dismissible: Option<bool>,
271 pub close_button: Option<bool>,
275 pub position: Option<ToastPosition>,
276 pub rich_colors: Option<bool>,
277 pub invert: Option<bool>,
279 pub test_id: Option<Arc<str>>,
280}
281
282impl ToastRequest {
283 pub fn new(title: impl Into<Arc<str>>) -> Self {
284 Self {
285 id: None,
286 toaster_id: None,
287 title: title.into(),
288 description: None,
289 duration: ToastDuration::UseDefault,
290 variant: None,
291 icon: None,
292 promise: false,
293 action: None,
294 cancel: None,
295 dismissible: None,
296 close_button: None,
297 position: None,
298 rich_colors: None,
299 invert: None,
300 test_id: None,
301 }
302 }
303
304 pub fn description(mut self, description: impl Into<Arc<str>>) -> Self {
305 self.description = Some(ToastDescription::Text(description.into()));
306 self
307 }
308
309 pub fn no_description(mut self) -> Self {
310 self.description = Some(ToastDescription::Hidden);
311 self
312 }
313
314 pub fn id(mut self, id: ToastId) -> Self {
315 self.id = Some(id);
316 self
317 }
318
319 pub fn toaster_id(mut self, id: impl Into<Arc<str>>) -> Self {
320 self.toaster_id = Some(id.into());
321 self
322 }
323
324 pub fn toaster_id_opt(mut self, id: Option<Arc<str>>) -> Self {
325 self.toaster_id = id;
326 self
327 }
328
329 pub fn duration(mut self, duration: Option<Duration>) -> Self {
330 self.duration = match duration {
331 Some(d) => ToastDuration::Fixed(d),
332 None => ToastDuration::Pinned,
333 };
334 self
335 }
336
337 pub fn variant(mut self, variant: ToastVariant) -> Self {
338 self.variant = Some(variant);
339 self
340 }
341
342 pub fn icon(mut self, icon: ToastIconOverride) -> Self {
343 self.icon = Some(icon);
344 self
345 }
346
347 pub fn no_icon(mut self) -> Self {
348 self.icon = Some(ToastIconOverride::Hidden);
349 self
350 }
351
352 pub fn promise(mut self, promise: bool) -> Self {
353 self.promise = promise;
354 self
355 }
356
357 pub fn action(mut self, action: ToastAction) -> Self {
358 self.action = Some(action);
359 self
360 }
361
362 pub fn cancel(mut self, cancel: ToastAction) -> Self {
363 self.cancel = Some(cancel);
364 self
365 }
366
367 pub fn dismissible(mut self, dismissible: bool) -> Self {
368 self.dismissible = Some(dismissible);
369 self
370 }
371
372 pub fn close_button(mut self, close_button: bool) -> Self {
373 self.close_button = Some(close_button);
374 self
375 }
376
377 pub fn position(mut self, position: ToastPosition) -> Self {
378 self.position = Some(position);
379 self
380 }
381
382 pub fn rich_colors(mut self, rich_colors: bool) -> Self {
383 self.rich_colors = Some(rich_colors);
384 self
385 }
386
387 pub fn invert(mut self, invert: bool) -> Self {
388 self.invert = Some(invert);
389 self
390 }
391
392 pub fn test_id(mut self, test_id: impl Into<Arc<str>>) -> Self {
393 self.test_id = Some(test_id.into());
394 self
395 }
396}
397
398#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
399pub struct ToastId(pub u64);
400
401#[derive(Debug, Clone, Copy, PartialEq, Eq)]
402enum ToastTimerKind {
403 AutoClose,
404 RemoveAfterClose,
405}
406
407#[derive(Debug, Clone, Copy)]
408struct ToastTimerRef {
409 window: AppWindowId,
410 toast: ToastId,
411 kind: ToastTimerKind,
412}
413
414#[derive(Debug, Clone)]
415pub(super) struct ToastEntry {
416 pub(super) id: ToastId,
417 pub(super) toaster_id: Option<Arc<str>>,
418 pub(super) title: Arc<str>,
419 pub(super) description: Option<Arc<str>>,
420 pub(super) duration: Option<Duration>,
421 pub(super) auto_close_remaining: Option<Duration>,
422 pub(super) variant: ToastVariant,
423 pub(super) icon: Option<ToastIconOverride>,
424 pub(super) promise: bool,
425 pub(super) action: Option<ToastAction>,
426 pub(super) cancel: Option<ToastAction>,
427 pub(super) dismissible: bool,
428 pub(super) close_button: Option<bool>,
429 pub(super) position: Option<ToastPosition>,
430 pub(super) rich_colors: Option<bool>,
431 pub(super) invert: bool,
432 pub(super) test_id: Option<Arc<str>>,
433 pub(super) measured_height: Option<Px>,
434 pub(super) open: bool,
435 pub(super) auto_close_token: Option<TimerToken>,
436 pub(super) remove_token: Option<TimerToken>,
437 pub(super) drag_start: Option<Point>,
438 pub(super) drag_offset: Point,
439 pub(super) settle_from: Option<Point>,
440 pub(super) dragging: bool,
441 pub(super) drag_axis: Option<ToastDragAxis>,
442 pub(super) drag_cfg: Option<ToastSwipeDragConfig>,
443 pub(super) drag_started_at: Option<Instant>,
444}
445
446#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
447pub struct ToastWindowCounts {
448 pub total: usize,
449 pub open: usize,
450 pub removing: usize,
451}
452
453#[derive(Debug, Clone)]
454pub(super) struct ToastUpsertOutcome {
455 pub(super) id: ToastId,
456 pub(super) cancel_auto: Option<TimerToken>,
457 pub(super) schedule_auto: Option<(TimerToken, Duration)>,
458 pub(super) evicted: Vec<ToastId>,
459}
460
461#[derive(Debug, Default)]
462pub struct ToastStore {
463 next_id: u64,
464 by_window: HashMap<AppWindowId, Vec<ToastEntry>>,
465 by_token: HashMap<TimerToken, ToastTimerRef>,
466 max_toasts_by_window: HashMap<AppWindowId, usize>,
467 swipe_by_window: HashMap<AppWindowId, ToastSwipeConfig>,
468 toaster_swipe_directions: HashMap<(AppWindowId, GlobalElementId), ToastSwipeDirections>,
469 default_duration_by_window: HashMap<AppWindowId, Duration>,
470 default_duration_by_toaster_id: HashMap<(AppWindowId, Arc<str>), Duration>,
471 close_duration_by_window: HashMap<AppWindowId, Duration>,
472 toaster_state: HashMap<(AppWindowId, GlobalElementId), ToasterState>,
473}
474
475#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
476pub(crate) struct ToasterState {
477 pub(crate) hovered: bool,
478 pub(crate) interacting: bool,
479 pub(crate) hotkey_expanded: bool,
480}
481
482impl ToastStore {
483 pub fn set_window_close_duration(&mut self, window: AppWindowId, duration: Duration) -> bool {
484 let prev = self.close_duration_by_window.get(&window).copied();
485 if prev == Some(duration) {
486 return false;
487 }
488 self.close_duration_by_window.insert(window, duration);
489 true
490 }
491
492 fn close_duration_for_window(&self, window: AppWindowId) -> Duration {
493 self.close_duration_by_window
494 .get(&window)
495 .copied()
496 .unwrap_or(TOAST_CLOSE_DURATION)
497 }
498
499 pub fn set_window_default_duration(
500 &mut self,
501 window: AppWindowId,
502 toaster_id: Option<Arc<str>>,
503 duration: Option<Duration>,
504 ) -> bool {
505 match (toaster_id, duration) {
506 (None, Some(duration)) => {
507 let prev = self.default_duration_by_window.get(&window).copied();
508 if prev == Some(duration) {
509 return false;
510 }
511 self.default_duration_by_window.insert(window, duration);
512 true
513 }
514 (None, None) => self.default_duration_by_window.remove(&window).is_some(),
515 (Some(id), Some(duration)) => {
516 let key = (window, id);
517 let prev = self.default_duration_by_toaster_id.get(&key).copied();
518 if prev == Some(duration) {
519 return false;
520 }
521 self.default_duration_by_toaster_id.insert(key, duration);
522 true
523 }
524 (Some(id), None) => self
525 .default_duration_by_toaster_id
526 .remove(&(window, id))
527 .is_some(),
528 }
529 }
530
531 fn default_duration_for(&self, window: AppWindowId, toaster_id: Option<&Arc<str>>) -> Duration {
532 if let Some(id) = toaster_id
533 && let Some(duration) = self
534 .default_duration_by_toaster_id
535 .get(&(window, id.clone()))
536 .copied()
537 {
538 return duration;
539 }
540 self.default_duration_by_window
541 .get(&window)
542 .copied()
543 .unwrap_or(DEFAULT_TOAST_DURATION)
544 }
545
546 pub fn set_toaster_swipe_directions(
547 &mut self,
548 window: AppWindowId,
549 toaster: GlobalElementId,
550 directions: Option<ToastSwipeDirections>,
551 ) -> bool {
552 let key = (window, toaster);
553 match directions {
554 Some(directions) => {
555 let prev = self.toaster_swipe_directions.get(&key).copied();
556 if prev == Some(directions) {
557 return false;
558 }
559 self.toaster_swipe_directions.insert(key, directions);
560 true
561 }
562 None => self.toaster_swipe_directions.remove(&key).is_some(),
563 }
564 }
565
566 pub fn set_window_max_toasts(
567 &mut self,
568 window: AppWindowId,
569 max_toasts: Option<usize>,
570 ) -> bool {
571 let max_toasts = max_toasts.unwrap_or(0);
572 let prev = self.max_toasts_by_window.get(&window).copied().unwrap_or(0);
573 if prev == max_toasts {
574 return false;
575 }
576 if max_toasts == 0 {
577 self.max_toasts_by_window.remove(&window);
578 } else {
579 self.max_toasts_by_window.insert(window, max_toasts);
580 }
581 true
582 }
583
584 pub(super) fn toasts_for_window(&self, window: AppWindowId) -> &[ToastEntry] {
585 self.by_window
586 .get(&window)
587 .map(Vec::as_slice)
588 .unwrap_or(&[])
589 }
590
591 pub fn window_counts(&self, window: AppWindowId) -> ToastWindowCounts {
592 let Some(toasts) = self.by_window.get(&window) else {
593 return ToastWindowCounts::default();
594 };
595 ToastWindowCounts {
596 total: toasts.len(),
597 open: toasts.iter().filter(|t| t.open).count(),
598 removing: toasts.iter().filter(|t| t.remove_token.is_some()).count(),
599 }
600 }
601
602 fn max_toasts_for_window(&self, window: AppWindowId) -> Option<usize> {
603 self.max_toasts_by_window.get(&window).copied()
604 }
605
606 pub fn set_window_swipe_config(
607 &mut self,
608 window: AppWindowId,
609 direction: ToastSwipeDirection,
610 threshold: Px,
611 ) -> bool {
612 let cfg = ToastSwipeConfig {
613 direction,
614 threshold: Px(threshold.0.max(1.0)),
615 ..Default::default()
616 };
617 let prev = self.swipe_by_window.get(&window).copied();
618 if prev == Some(cfg) {
619 return false;
620 }
621 self.swipe_by_window.insert(window, cfg);
622 true
623 }
624
625 pub fn set_window_swipe_config_with_options(
626 &mut self,
627 window: AppWindowId,
628 config: ToastSwipeConfig,
629 ) -> bool {
630 let prev = self.swipe_by_window.get(&window).copied();
631 if prev == Some(config) {
632 return false;
633 }
634 self.swipe_by_window.insert(window, config);
635 true
636 }
637
638 fn add_toast(
639 &mut self,
640 window: AppWindowId,
641 request: ToastRequest,
642 auto_close_token: Option<TimerToken>,
643 ) -> ToastId {
644 let toaster_id = request.toaster_id.clone();
645 let default_duration = self.default_duration_for(window, toaster_id.as_ref());
646 let variant = request.variant.unwrap_or_default();
647 let duration = match request.duration {
648 ToastDuration::UseDefault => Some(default_duration),
649 ToastDuration::Pinned => None,
650 ToastDuration::Fixed(d) => Some(d),
651 };
652 let wants_timer = duration.filter(|d| d.as_secs_f32() > 0.0);
653 let auto_close_token = if variant == ToastVariant::Loading {
654 None
655 } else {
656 wants_timer.and(auto_close_token)
657 };
658 if self.next_id == 0 {
659 self.next_id = 1;
660 }
661 let id = ToastId(self.next_id);
662 self.next_id = self.next_id.saturating_add(1);
663
664 if let Some(token) = auto_close_token {
665 self.by_token.insert(
666 token,
667 ToastTimerRef {
668 window,
669 toast: id,
670 kind: ToastTimerKind::AutoClose,
671 },
672 );
673 }
674
675 self.by_window.entry(window).or_default().push(ToastEntry {
676 id,
677 toaster_id: request.toaster_id,
678 title: request.title,
679 description: match request.description {
680 Some(ToastDescription::Text(text)) => Some(text),
681 Some(ToastDescription::Hidden) => None,
682 None => None,
683 },
684 duration,
685 auto_close_remaining: wants_timer,
686 variant,
687 icon: request.icon,
688 promise: request.promise,
689 action: request.action,
690 cancel: request.cancel,
691 dismissible: request.dismissible.unwrap_or(true),
692 close_button: request.close_button,
693 position: request.position,
694 rich_colors: request.rich_colors,
695 invert: request.invert.unwrap_or(false),
696 test_id: request.test_id,
697 measured_height: None,
698 open: true,
699 auto_close_token,
700 remove_token: None,
701 drag_start: None,
702 drag_offset: Point::new(Px(0.0), Px(0.0)),
703 settle_from: None,
704 dragging: false,
705 drag_axis: None,
706 drag_cfg: None,
707 drag_started_at: None,
708 });
709
710 id
711 }
712
713 pub(super) fn upsert_toast(
714 &mut self,
715 window: AppWindowId,
716 request: ToastRequest,
717 auto_close_token: Option<TimerToken>,
718 ) -> ToastUpsertOutcome {
719 if let Some(id) = request.id
720 && let Some(toasts) = self.by_window.get_mut(&window)
721 && let Some(toast) = toasts
722 .iter_mut()
723 .find(|t| t.id == id && t.open && t.remove_token.is_none())
724 {
725 let prev_variant = toast.variant;
726 let prev_remaining = toast.auto_close_remaining;
727 let prev_token = toast.auto_close_token;
728
729 toast.title = request.title;
730 if let Some(desc) = request.description {
731 toast.description = match desc {
732 ToastDescription::Text(text) => Some(text),
733 ToastDescription::Hidden => None,
734 };
735 }
736 if let Some(variant) = request.variant {
737 toast.variant = variant;
738 }
739 if request.icon.is_some() {
740 toast.icon = request.icon;
741 }
742 if request.promise {
743 toast.promise = true;
744 }
745 if request.action.is_some() {
746 toast.action = request.action;
747 }
748 if request.cancel.is_some() {
749 toast.cancel = request.cancel;
750 }
751 if let Some(dismissible) = request.dismissible {
752 toast.dismissible = dismissible;
753 }
754 if request.close_button.is_some() {
755 toast.close_button = request.close_button;
756 }
757 if request.toaster_id.is_some() {
758 toast.toaster_id = request.toaster_id;
759 }
760 if request.position.is_some() {
761 toast.position = request.position;
762 }
763 if request.rich_colors.is_some() {
764 toast.rich_colors = request.rich_colors;
765 }
766 if let Some(invert) = request.invert {
767 toast.invert = invert;
768 }
769 if request.test_id.is_some() {
770 toast.test_id = request.test_id;
771 }
772 toast.drag_start = None;
773 toast.drag_offset = Point::new(Px(0.0), Px(0.0));
774 toast.settle_from = None;
775 toast.dragging = false;
776 toast.measured_height = None;
777 toast.drag_axis = None;
778 toast.drag_cfg = None;
779 toast.drag_started_at = None;
780
781 let mut cancel_auto: Option<TimerToken> = None;
782 let mut schedule_auto: Option<(TimerToken, Duration)> = None;
783
784 let duration_explicit = request.duration.is_explicit();
785 let variant_changed = request.variant.is_some() && toast.variant != prev_variant;
786 let leaving_loading = variant_changed
787 && prev_variant == ToastVariant::Loading
788 && toast.variant != ToastVariant::Loading;
789 let restart_needed = duration_explicit || leaving_loading;
790
791 if duration_explicit {
792 toast.duration = match request.duration {
793 ToastDuration::Pinned => None,
794 ToastDuration::Fixed(d) => Some(d),
795 ToastDuration::UseDefault => toast.duration,
796 };
797 }
798
799 let wants_timer = toast.duration.filter(|d| d.as_secs_f32() > 0.0);
800 if duration_explicit || leaving_loading {
801 toast.auto_close_remaining = wants_timer;
802 }
803
804 let was_paused = prev_remaining.is_some()
806 && prev_token.is_none()
807 && prev_variant != ToastVariant::Loading;
808
809 if toast.variant == ToastVariant::Loading {
810 if let Some(token) = toast.auto_close_token.take() {
811 self.by_token.remove(&token);
812 cancel_auto = Some(token);
813 }
814 } else {
815 match (toast.auto_close_remaining, toast.auto_close_token) {
816 (Some(after), Some(token)) if restart_needed => {
817 schedule_auto = Some((token, auto_close_next_after(after)));
818 }
819 (Some(after), None) if !was_paused => {
820 if let Some(token) = auto_close_token {
821 toast.auto_close_token = Some(token);
822 self.by_token.insert(
823 token,
824 ToastTimerRef {
825 window,
826 toast: id,
827 kind: ToastTimerKind::AutoClose,
828 },
829 );
830 schedule_auto = Some((token, auto_close_next_after(after)));
831 }
832 }
833 (None, Some(token)) => {
834 toast.auto_close_token = None;
835 self.by_token.remove(&token);
836 cancel_auto = Some(token);
837 }
838 _ => {}
839 }
840 }
841
842 return ToastUpsertOutcome {
843 id,
844 cancel_auto,
845 schedule_auto,
846 evicted: Vec::new(),
847 };
848 }
849
850 let id = self.add_toast(window, request, auto_close_token);
851 let schedule_auto = self
852 .by_window
853 .get(&window)
854 .and_then(|toasts| toasts.iter().find(|t| t.id == id))
855 .and_then(
856 |toast| match (toast.auto_close_remaining, toast.auto_close_token) {
857 (Some(after), Some(token)) => Some((token, auto_close_next_after(after))),
858 _ => None,
859 },
860 );
861 let evicted = self.evict_excess_toasts(window, id);
862
863 ToastUpsertOutcome {
864 id,
865 cancel_auto: None,
866 schedule_auto,
867 evicted,
868 }
869 }
870
871 fn evict_excess_toasts(&self, window: AppWindowId, keep: ToastId) -> Vec<ToastId> {
872 let Some(max) = self.max_toasts_for_window(window) else {
873 return Vec::new();
874 };
875 if max == 0 {
876 return Vec::new();
877 }
878
879 let Some(toasts) = self.by_window.get(&window) else {
880 return Vec::new();
881 };
882
883 let active: Vec<&ToastEntry> = toasts
884 .iter()
885 .filter(|t| t.open && t.remove_token.is_none())
886 .collect();
887
888 let mut need = active.len().saturating_sub(max);
889 if need == 0 {
890 return Vec::new();
891 }
892
893 let mut evicted = Vec::new();
894
895 for toast in &active {
897 if need == 0 {
898 break;
899 }
900 if toast.id == keep || toast.auto_close_remaining.is_none() {
901 continue;
902 }
903 evicted.push(toast.id);
904 need = need.saturating_sub(1);
905 }
906
907 for toast in &active {
908 if need == 0 {
909 break;
910 }
911 if toast.id == keep || toast.auto_close_remaining.is_some() {
912 continue;
913 }
914 evicted.push(toast.id);
915 need = need.saturating_sub(1);
916 }
917 evicted
918 }
919
920 fn remove_toast(&mut self, window: AppWindowId, id: ToastId) -> Option<ToastEntry> {
921 let toasts = self.by_window.get_mut(&window)?;
922 let idx = toasts.iter().position(|t| t.id == id)?;
923 let entry = toasts.remove(idx);
924 if let Some(token) = entry.auto_close_token {
925 self.by_token.remove(&token);
926 }
927 if let Some(token) = entry.remove_token {
928 self.by_token.remove(&token);
929 }
930 Some(entry)
931 }
932
933 pub(super) fn begin_close(
934 &mut self,
935 window: AppWindowId,
936 id: ToastId,
937 remove_token: TimerToken,
938 ) -> Option<ToastClosePlan> {
939 let toasts = self.by_window.get_mut(&window)?;
940 let toast = toasts.iter_mut().find(|t| t.id == id)?;
941 if toast.remove_token.is_some() {
942 return Some(ToastClosePlan {
943 cancel_auto: None,
944 schedule_remove: None,
945 });
946 }
947
948 toast.open = false;
949 toast.auto_close_remaining = None;
950 toast.drag_start = None;
951 toast.drag_offset = Point::new(Px(0.0), Px(0.0));
952 toast.settle_from = None;
953 toast.dragging = false;
954 toast.drag_axis = None;
955 toast.drag_cfg = None;
956 toast.drag_started_at = None;
957 let cancel_auto = toast.auto_close_token.take();
958 if let Some(token) = cancel_auto {
959 self.by_token.remove(&token);
960 }
961
962 toast.remove_token = Some(remove_token);
963 self.by_token.insert(
964 remove_token,
965 ToastTimerRef {
966 window,
967 toast: id,
968 kind: ToastTimerKind::RemoveAfterClose,
969 },
970 );
971
972 let remove_after = self.close_duration_for_window(window);
973 Some(ToastClosePlan {
974 cancel_auto,
975 schedule_remove: Some((remove_token, remove_after)),
976 })
977 }
978
979 pub(super) fn pause_auto_close(
980 &mut self,
981 window: AppWindowId,
982 id: ToastId,
983 ) -> Option<TimerToken> {
984 let toasts = self.by_window.get_mut(&window)?;
985 let toast = toasts.iter_mut().find(|t| t.id == id)?;
986 let token = toast.auto_close_token.take()?;
987 self.by_token.remove(&token);
988 Some(token)
989 }
990
991 pub(super) fn resume_auto_close(
992 &mut self,
993 window: AppWindowId,
994 id: ToastId,
995 token: TimerToken,
996 ) -> Option<Duration> {
997 let toasts = self.by_window.get_mut(&window)?;
998 let toast = toasts.iter_mut().find(|t| t.id == id)?;
999 if !toast.open || toast.auto_close_token.is_some() || toast.remove_token.is_some() {
1000 return None;
1001 }
1002 let remaining = toast
1003 .auto_close_remaining
1004 .filter(|d| d.as_secs_f32() > 0.0)?;
1005 toast.auto_close_token = Some(token);
1006 self.by_token.insert(
1007 token,
1008 ToastTimerRef {
1009 window,
1010 toast: id,
1011 kind: ToastTimerKind::AutoClose,
1012 },
1013 );
1014 Some(auto_close_next_after(remaining))
1015 }
1016
1017 pub(super) fn begin_drag(
1018 &mut self,
1019 window: AppWindowId,
1020 toaster: GlobalElementId,
1021 id: ToastId,
1022 start: Point,
1023 position: ToastPosition,
1024 ) -> bool {
1025 let Some(toasts) = self.by_window.get_mut(&window) else {
1026 return false;
1027 };
1028 let Some(toast) = toasts.iter_mut().find(|t| t.id == id) else {
1029 return false;
1030 };
1031 if !toast.open
1032 || toast.remove_token.is_some()
1033 || !toast.dismissible
1034 || toast.variant == ToastVariant::Loading
1035 {
1036 return false;
1037 }
1038 let cfg = self
1039 .swipe_by_window
1040 .get(&window)
1041 .copied()
1042 .map(ToastSwipeDragConfig::from_single)
1043 .or_else(|| {
1044 self.toaster_swipe_directions
1045 .get(&(window, toaster))
1046 .copied()
1047 .map(ToastSwipeDragConfig::sonner_for_directions)
1048 })
1049 .unwrap_or_else(|| ToastSwipeDragConfig::sonner_for_position(position));
1050 toast.drag_start = Some(start);
1051 toast.drag_offset = Point::new(Px(0.0), Px(0.0));
1052 toast.settle_from = None;
1053 toast.dragging = false;
1054 toast.drag_axis = cfg.fixed_axis;
1055 toast.drag_cfg = Some(cfg);
1056 toast.drag_started_at = Some(Instant::now());
1057 true
1058 }
1059
1060 pub(super) fn clear_settle(&mut self, window: AppWindowId, id: ToastId) -> bool {
1061 let Some(toasts) = self.by_window.get_mut(&window) else {
1062 return false;
1063 };
1064 let Some(toast) = toasts.iter_mut().find(|t| t.id == id) else {
1065 return false;
1066 };
1067 if toast.settle_from.is_none() {
1068 return false;
1069 }
1070 toast.settle_from = None;
1071 true
1072 }
1073
1074 fn toast_dampen_delta(delta: Px) -> Px {
1075 let factor = delta.0.abs() / 20.0;
1076 let scale = 1.0 / (1.5 + factor);
1077 let dampened = Px(delta.0 * scale);
1078 if dampened.0.abs() < delta.0.abs() {
1079 dampened
1080 } else {
1081 delta
1082 }
1083 }
1084
1085 fn toast_drag_offset(
1086 start: Point,
1087 position: Point,
1088 cfg: ToastSwipeDragConfig,
1089 axis: ToastDragAxis,
1090 ) -> Point {
1091 if !cfg.axis_allowed(axis) {
1092 return Point::new(Px(0.0), Px(0.0));
1093 }
1094
1095 let dx = Px(position.x.0 - start.x.0);
1096 let dy = Px(position.y.0 - start.y.0);
1097 let max = cfg.max_drag.0.max(1.0);
1098
1099 match axis {
1100 ToastDragAxis::X => {
1101 let mut delta = dx;
1102 if (dx.0 > 0.0 && !cfg.allow_right) || (dx.0 < 0.0 && !cfg.allow_left) {
1103 delta = Self::toast_dampen_delta(dx);
1104 }
1105 let delta = Px(delta.0.clamp(-max, max));
1106 Point::new(delta, Px(0.0))
1107 }
1108 ToastDragAxis::Y => {
1109 let mut delta = dy;
1110 if (dy.0 > 0.0 && !cfg.allow_down) || (dy.0 < 0.0 && !cfg.allow_up) {
1111 delta = Self::toast_dampen_delta(dy);
1112 }
1113 let delta = Px(delta.0.clamp(-max, max));
1114 Point::new(Px(0.0), delta)
1115 }
1116 }
1117 }
1118
1119 fn toast_drag_amount(offset: Point, axis: ToastDragAxis) -> Px {
1120 match axis {
1121 ToastDragAxis::X => Px(offset.x.0.abs()),
1122 ToastDragAxis::Y => Px(offset.y.0.abs()),
1123 }
1124 }
1125
1126 pub(super) fn drag_move(
1127 &mut self,
1128 window: AppWindowId,
1129 id: ToastId,
1130 position: Point,
1131 ) -> Option<ToastDragMove> {
1132 let toasts = self.by_window.get_mut(&window)?;
1133 let toast = toasts.iter_mut().find(|t| t.id == id)?;
1134 let start = toast.drag_start?;
1135 let cfg = toast.drag_cfg?;
1136 if !toast.open || toast.remove_token.is_some() {
1137 return None;
1138 }
1139
1140 let dx = Px(position.x.0 - start.x.0);
1141 let dy = Px(position.y.0 - start.y.0);
1142 if toast.drag_axis.is_none()
1143 && (dx.0.abs() > cfg.axis_lock_threshold.0 || dy.0.abs() > cfg.axis_lock_threshold.0)
1144 {
1145 toast.drag_axis = Some(if dx.0.abs() > dy.0.abs() {
1146 ToastDragAxis::X
1147 } else {
1148 ToastDragAxis::Y
1149 });
1150 }
1151
1152 let Some(axis) = toast.drag_axis else {
1153 return Some(ToastDragMove {
1154 dragging: false,
1155 capture_pointer: false,
1156 });
1157 };
1158
1159 let offset = Self::toast_drag_offset(start, position, cfg, axis);
1160 let was_dragging = toast.dragging;
1161 if !toast.dragging
1162 && Self::toast_drag_amount(offset, axis).0 >= cfg.dragging_threshold.0.max(0.0)
1163 {
1164 toast.dragging = true;
1165 }
1166 toast.drag_offset = offset;
1167
1168 Some(ToastDragMove {
1169 dragging: toast.dragging,
1170 capture_pointer: toast.dragging && !was_dragging,
1171 })
1172 }
1173
1174 pub(super) fn end_drag(&mut self, window: AppWindowId, id: ToastId) -> Option<ToastDragEnd> {
1175 let toasts = self.by_window.get_mut(&window)?;
1176 let toast = toasts.iter_mut().find(|t| t.id == id)?;
1177 toast.drag_start?;
1178 let cfg = toast.drag_cfg?;
1179 let Some(axis) = toast.drag_axis else {
1180 toast.drag_start = None;
1181 toast.drag_offset = Point::new(Px(0.0), Px(0.0));
1182 toast.dragging = false;
1183 toast.settle_from = None;
1184 toast.drag_cfg = None;
1185 toast.drag_started_at = None;
1186 return Some(ToastDragEnd {
1187 dragging: false,
1188 dismiss: false,
1189 });
1190 };
1191
1192 let amount = Self::toast_drag_amount(toast.drag_offset, axis);
1193 let elapsed_ms = toast
1194 .drag_started_at
1195 .map(|t| t.elapsed().as_millis() as f32)
1196 .unwrap_or(0.0);
1197 let velocity = if elapsed_ms > 0.0 {
1198 amount.0 / elapsed_ms
1199 } else {
1200 0.0
1201 };
1202 let dismiss = toast.dragging
1203 && (amount.0 >= cfg.threshold.0.max(1.0)
1204 || velocity > cfg.velocity_threshold_px_per_ms);
1205 let settle_from = (!dismiss && toast.dragging).then_some(toast.drag_offset);
1206 let result = ToastDragEnd {
1207 dragging: toast.dragging,
1208 dismiss,
1209 };
1210 toast.drag_start = None;
1211 toast.drag_offset = Point::new(Px(0.0), Px(0.0));
1212 toast.dragging = false;
1213 toast.settle_from = settle_from;
1214 toast.drag_axis = None;
1215 toast.drag_cfg = None;
1216 toast.drag_started_at = None;
1217 Some(result)
1218 }
1219
1220 pub(super) fn on_timer(
1221 &mut self,
1222 token: TimerToken,
1223 remove_token: TimerToken,
1224 ) -> ToastTimerOutcome {
1225 let Some(timer) = self.by_token.get(&token).copied() else {
1226 return ToastTimerOutcome::Noop;
1227 };
1228
1229 match timer.kind {
1230 ToastTimerKind::AutoClose => {
1231 self.on_auto_close_tick(token, timer.window, timer.toast, remove_token)
1232 }
1233 ToastTimerKind::RemoveAfterClose => {
1234 self.by_token.remove(&token);
1235 let removed = self.remove_toast(timer.window, timer.toast).is_some();
1236 if removed {
1237 ToastTimerOutcome::Removed {
1238 window: timer.window,
1239 }
1240 } else {
1241 ToastTimerOutcome::Noop
1242 }
1243 }
1244 }
1245 }
1246
1247 fn on_auto_close_tick(
1248 &mut self,
1249 token: TimerToken,
1250 window: AppWindowId,
1251 toast_id: ToastId,
1252 remove_token: TimerToken,
1253 ) -> ToastTimerOutcome {
1254 let Some(toasts) = self.by_window.get_mut(&window) else {
1255 self.by_token.remove(&token);
1256 return ToastTimerOutcome::Noop;
1257 };
1258 let Some(toast) = toasts.iter_mut().find(|t| t.id == toast_id) else {
1259 self.by_token.remove(&token);
1260 return ToastTimerOutcome::Noop;
1261 };
1262
1263 if !toast.open || toast.remove_token.is_some() || toast.auto_close_token != Some(token) {
1264 self.by_token.remove(&token);
1265 return ToastTimerOutcome::Noop;
1266 }
1267
1268 let Some(mut remaining) = toast.auto_close_remaining else {
1269 toast.auto_close_token = None;
1270 self.by_token.remove(&token);
1271 return ToastTimerOutcome::Noop;
1272 };
1273
1274 let step = remaining.min(TOAST_AUTO_CLOSE_TICK);
1275 remaining = remaining.saturating_sub(step);
1276 toast.auto_close_remaining = (!remaining.is_zero()).then_some(remaining);
1277
1278 if !remaining.is_zero() {
1279 ToastTimerOutcome::RescheduleAuto {
1280 window,
1281 token,
1282 after: auto_close_next_after(remaining),
1283 }
1284 } else {
1285 let plan = self.begin_close(window, toast_id, remove_token);
1286 let Some(plan) = plan else {
1287 return ToastTimerOutcome::Noop;
1288 };
1289 if let Some((_token, after)) = plan.schedule_remove {
1290 ToastTimerOutcome::BeganClose {
1291 window,
1292 remove_token,
1293 after,
1294 }
1295 } else {
1296 ToastTimerOutcome::Noop
1297 }
1298 }
1299 }
1300
1301 pub fn set_toaster_hovered(
1302 &mut self,
1303 window: AppWindowId,
1304 toaster: GlobalElementId,
1305 hovered: bool,
1306 ) -> bool {
1307 let st = self.toaster_state.entry((window, toaster)).or_default();
1308 if st.hovered == hovered {
1309 return false;
1310 }
1311 st.hovered = hovered;
1312 true
1313 }
1314
1315 pub fn set_toaster_interacting(
1316 &mut self,
1317 window: AppWindowId,
1318 toaster: GlobalElementId,
1319 interacting: bool,
1320 ) -> bool {
1321 let st = self.toaster_state.entry((window, toaster)).or_default();
1322 if st.interacting == interacting {
1323 return false;
1324 }
1325 st.interacting = interacting;
1326 true
1327 }
1328
1329 pub fn set_toaster_hotkey_expanded(
1330 &mut self,
1331 window: AppWindowId,
1332 toaster: GlobalElementId,
1333 expanded: bool,
1334 ) -> bool {
1335 let st = self.toaster_state.entry((window, toaster)).or_default();
1336 if st.hotkey_expanded == expanded {
1337 return false;
1338 }
1339 st.hotkey_expanded = expanded;
1340 true
1341 }
1342
1343 pub(crate) fn toaster_state(
1344 &self,
1345 window: AppWindowId,
1346 toaster: GlobalElementId,
1347 ) -> ToasterState {
1348 self.toaster_state
1349 .get(&(window, toaster))
1350 .copied()
1351 .unwrap_or_default()
1352 }
1353
1354 pub fn set_toast_measured_height(
1355 &mut self,
1356 window: AppWindowId,
1357 id: ToastId,
1358 height: Px,
1359 ) -> bool {
1360 let Some(toasts) = self.by_window.get_mut(&window) else {
1361 return false;
1362 };
1363 let Some(toast) = toasts.iter_mut().find(|t| t.id == id) else {
1364 return false;
1365 };
1366 if toast.measured_height == Some(height) {
1367 return false;
1368 }
1369 toast.measured_height = Some(height);
1370 true
1371 }
1372}
1373
1374fn auto_close_next_after(remaining: Duration) -> Duration {
1375 remaining.min(TOAST_AUTO_CLOSE_TICK)
1376}
1377
1378#[derive(Debug, Clone, Copy)]
1379pub(super) struct ToastClosePlan {
1380 pub(super) cancel_auto: Option<TimerToken>,
1381 pub(super) schedule_remove: Option<(TimerToken, Duration)>,
1382}
1383
1384#[derive(Debug, Clone, Copy)]
1385pub(super) struct ToastDragMove {
1386 pub(super) dragging: bool,
1387 pub(super) capture_pointer: bool,
1388}
1389
1390#[derive(Debug, Clone, Copy)]
1391pub(super) struct ToastDragEnd {
1392 pub(super) dragging: bool,
1393 pub(super) dismiss: bool,
1394}
1395
1396#[derive(Debug, Clone, Copy)]
1397pub(super) enum ToastTimerOutcome {
1398 Noop,
1399 RescheduleAuto {
1400 window: AppWindowId,
1401 token: TimerToken,
1402 after: Duration,
1403 },
1404 BeganClose {
1405 window: AppWindowId,
1406 remove_token: TimerToken,
1407 after: Duration,
1408 },
1409 Removed {
1410 window: AppWindowId,
1411 },
1412}
1413
1414#[derive(Default)]
1415struct ToastService {
1416 store: Option<Model<ToastStore>>,
1417}
1418
1419pub fn toast_store<H: UiHost>(app: &mut H) -> Model<ToastStore> {
1420 app.with_global_mut_untracked(ToastService::default, |svc, app| {
1421 svc.store
1422 .get_or_insert_with(|| app.models_mut().insert(ToastStore::default()))
1423 .clone()
1424 })
1425}
1426
1427pub fn toast_action(
1428 host: &mut dyn fret_ui::action::UiActionHost,
1429 store: Model<ToastStore>,
1430 window: AppWindowId,
1431 request: ToastRequest,
1432) -> ToastId {
1433 let token = Some(host.next_timer_token());
1434
1435 let outcome = host
1436 .models_mut()
1437 .update(&store, |st| st.upsert_toast(window, request, token))
1438 .ok();
1439
1440 let Some(outcome) = outcome else {
1441 return ToastId(0);
1442 };
1443
1444 if let Some(token) = outcome.cancel_auto {
1445 host.push_effect(Effect::CancelTimer { token });
1446 }
1447
1448 if let Some((token, after)) = outcome.schedule_auto {
1449 host.push_effect(Effect::SetTimer {
1450 window: Some(window),
1451 token,
1452 after,
1453 repeat: None,
1454 });
1455 }
1456
1457 for id in outcome.evicted {
1458 let remove_token = host.next_timer_token();
1459 let plan = host
1460 .models_mut()
1461 .update(&store, |st| st.begin_close(window, id, remove_token))
1462 .ok()
1463 .flatten();
1464
1465 let Some(plan) = plan else {
1466 continue;
1467 };
1468
1469 if let Some(token) = plan.cancel_auto {
1470 host.push_effect(Effect::CancelTimer { token });
1471 }
1472
1473 if let Some((token, after)) = plan.schedule_remove {
1474 host.push_effect(Effect::SetTimer {
1475 window: Some(window),
1476 token,
1477 after,
1478 repeat: None,
1479 });
1480 }
1481 }
1482
1483 host.request_redraw(window);
1484 outcome.id
1485}
1486
1487pub fn dismiss_toast_action(
1488 host: &mut dyn fret_ui::action::UiActionHost,
1489 store: Model<ToastStore>,
1490 window: AppWindowId,
1491 id: ToastId,
1492) -> bool {
1493 let remove_token = host.next_timer_token();
1494 let plan = host
1495 .models_mut()
1496 .update(&store, |st| st.begin_close(window, id, remove_token))
1497 .ok();
1498 let Some(plan) = plan.flatten() else {
1499 return false;
1500 };
1501
1502 if let Some(token) = plan.cancel_auto {
1503 host.push_effect(Effect::CancelTimer { token });
1504 }
1505
1506 if let Some((token, after)) = plan.schedule_remove {
1507 host.push_effect(Effect::SetTimer {
1508 window: Some(window),
1509 token,
1510 after,
1511 repeat: None,
1512 });
1513 }
1514
1515 host.request_redraw(window);
1516 true
1517}
1518
1519pub fn dismiss_all_toasts_action(
1523 host: &mut dyn fret_ui::action::UiActionHost,
1524 store: Model<ToastStore>,
1525 window: AppWindowId,
1526) -> usize {
1527 let ids: Vec<ToastId> = host
1528 .models_mut()
1529 .read(&store, |st| {
1530 st.toasts_for_window(window)
1531 .iter()
1532 .filter(|t| t.open && t.remove_token.is_none())
1533 .map(|t| t.id)
1534 .collect()
1535 })
1536 .unwrap_or_default();
1537
1538 let mut dismissed = 0;
1539 for id in ids {
1540 if dismiss_toast_action(host, store.clone(), window, id) {
1541 dismissed += 1;
1542 }
1543 }
1544 dismissed
1545}
1546
1547#[derive(Default)]
1548struct ToastAsyncQueue {
1549 inner: Arc<Mutex<Vec<ToastAsyncMsg>>>,
1550}
1551
1552#[derive(Clone, Debug)]
1556pub struct ToastAsyncQueueHandle {
1557 inner: Arc<Mutex<Vec<ToastAsyncMsg>>>,
1558}
1559
1560#[derive(Clone, Debug)]
1561#[allow(clippy::large_enum_variant)]
1562pub enum ToastAsyncMsg {
1563 Upsert {
1564 window: AppWindowId,
1565 request: ToastRequest,
1566 },
1567 Dismiss {
1568 window: AppWindowId,
1569 id: ToastId,
1570 },
1571}
1572
1573impl ToastAsyncQueueHandle {
1574 pub fn push(&self, msg: ToastAsyncMsg) {
1575 let mut lock = self.inner.lock().unwrap_or_else(|p| p.into_inner());
1576 lock.push(msg);
1577 }
1578
1579 pub fn upsert(&self, window: AppWindowId, request: ToastRequest) {
1580 self.push(ToastAsyncMsg::Upsert { window, request });
1581 }
1582
1583 pub fn dismiss(&self, window: AppWindowId, id: ToastId) {
1584 self.push(ToastAsyncMsg::Dismiss { window, id });
1585 }
1586}
1587
1588pub fn toast_async_queue<H: UiHost>(app: &mut H) -> ToastAsyncQueueHandle {
1589 app.with_global_mut_untracked(ToastAsyncQueue::default, |queue, _app| {
1590 ToastAsyncQueueHandle {
1591 inner: queue.inner.clone(),
1592 }
1593 })
1594}
1595
1596pub(super) fn drain_toast_async_queue<H: UiHost>(app: &mut H) {
1597 let msgs = app.with_global_mut_untracked(ToastAsyncQueue::default, |queue, _app| {
1598 let mut lock = queue.inner.lock().unwrap_or_else(|p| p.into_inner());
1599 std::mem::take(&mut *lock)
1600 });
1601
1602 if msgs.is_empty() {
1603 return;
1604 }
1605
1606 let store = toast_store(app);
1607 let mut host = UiActionHostAdapter { app };
1608
1609 for msg in msgs {
1610 match msg {
1611 ToastAsyncMsg::Upsert { window, request } => {
1612 let _ = toast_action(&mut host, store.clone(), window, request);
1613 }
1614 ToastAsyncMsg::Dismiss { window, id } => {
1615 let _ = dismiss_toast_action(&mut host, store.clone(), window, id);
1616 }
1617 }
1618 }
1619}
1620
1621#[cfg(test)]
1622mod tests {
1623 use super::*;
1624
1625 #[test]
1626 fn toast_pause_resume_and_removal_flow() {
1627 let window = AppWindowId::default();
1628 let mut store = ToastStore::default();
1629
1630 let request = ToastRequest::new("Hello").duration(Some(Duration::from_millis(250)));
1631 let id = store.add_toast(window, request, Some(TimerToken(1)));
1632
1633 let paused = store.pause_auto_close(window, id);
1634 assert_eq!(paused, Some(TimerToken(1)));
1635
1636 let resumed = store.resume_auto_close(window, id, TimerToken(2));
1637 assert_eq!(resumed, Some(Duration::from_millis(100)));
1638
1639 let outcome = store.on_timer(TimerToken(2), TimerToken(3));
1640 match outcome {
1641 ToastTimerOutcome::RescheduleAuto {
1642 window: w, after, ..
1643 } => {
1644 assert_eq!(w, window);
1645 assert_eq!(after, Duration::from_millis(100));
1646 }
1647 _ => panic!("expected RescheduleAuto"),
1648 }
1649
1650 let paused = store.pause_auto_close(window, id);
1651 assert_eq!(paused, Some(TimerToken(2)));
1652
1653 let resumed = store.resume_auto_close(window, id, TimerToken(4));
1654 assert_eq!(resumed, Some(Duration::from_millis(100)));
1655
1656 let outcome = store.on_timer(TimerToken(4), TimerToken(5));
1657 match outcome {
1658 ToastTimerOutcome::RescheduleAuto {
1659 window: w, after, ..
1660 } => {
1661 assert_eq!(w, window);
1662 assert_eq!(after, Duration::from_millis(50));
1663 }
1664 _ => panic!("expected RescheduleAuto"),
1665 }
1666
1667 let outcome = store.on_timer(TimerToken(4), TimerToken(6));
1668 match outcome {
1669 ToastTimerOutcome::BeganClose { window: w, .. } => assert_eq!(w, window),
1670 _ => panic!("expected BeganClose"),
1671 }
1672
1673 let outcome = store.on_timer(TimerToken(6), TimerToken(7));
1674 match outcome {
1675 ToastTimerOutcome::Removed { window: w } => assert_eq!(w, window),
1676 _ => panic!("expected Removed"),
1677 }
1678
1679 assert!(store.toasts_for_window(window).is_empty());
1680 }
1681
1682 #[test]
1683 fn toast_drag_sets_and_resets_offset() {
1684 let window = AppWindowId::default();
1685 let mut store = ToastStore::default();
1686
1687 let request = ToastRequest::new("Drag").duration(None);
1688 let id = store.add_toast(window, request, None);
1689
1690 store.set_window_swipe_config(window, ToastSwipeDirection::Right, Px(50.0));
1691 assert!(store.begin_drag(
1692 window,
1693 GlobalElementId(0),
1694 id,
1695 Point::new(Px(10.0), Px(10.0)),
1696 ToastPosition::BottomRight,
1697 ));
1698
1699 let moved = store.drag_move(window, id, Point::new(Px(30.0), Px(10.0)));
1700 assert!(moved.is_some());
1701 assert!(store.toasts_for_window(window)[0].drag_offset.x.0 > 0.0);
1702
1703 let end = store.end_drag(window, id);
1704 assert!(end.is_some());
1705 assert_eq!(
1706 store.toasts_for_window(window)[0].drag_offset,
1707 Point::new(Px(0.0), Px(0.0))
1708 );
1709 assert_eq!(store.toasts_for_window(window)[0].drag_start, None);
1710 }
1711
1712 #[test]
1713 fn toast_sonner_default_swipe_directions_follow_position_and_threshold() {
1714 let window = AppWindowId::default();
1715 let mut store = ToastStore::default();
1716
1717 let id = store.add_toast(window, ToastRequest::new("Drag").duration(None), None);
1718
1719 assert!(store.begin_drag(
1720 window,
1721 GlobalElementId(0),
1722 id,
1723 Point::new(Px(0.0), Px(0.0)),
1724 ToastPosition::BottomRight,
1725 ));
1726 assert!(
1727 store
1728 .drag_move(window, id, Point::new(Px(50.0), Px(0.0)))
1729 .is_some()
1730 );
1731 let end = store.end_drag(window, id).expect("end");
1732 assert!(end.dragging);
1733 assert!(
1734 end.dismiss,
1735 "expected 50px > 45px Sonner threshold to dismiss"
1736 );
1737
1738 let id = store.add_toast(window, ToastRequest::new("Drag2").duration(None), None);
1739 assert!(store.begin_drag(
1740 window,
1741 GlobalElementId(0),
1742 id,
1743 Point::new(Px(0.0), Px(0.0)),
1744 ToastPosition::TopCenter,
1745 ));
1746 assert!(
1747 store
1748 .drag_move(window, id, Point::new(Px(50.0), Px(0.0)))
1749 .is_some()
1750 );
1751 let end = store.end_drag(window, id).expect("end");
1752 assert!(
1753 !end.dragging && !end.dismiss,
1754 "expected horizontal swipe on top-center to not engage"
1755 );
1756 }
1757
1758 #[test]
1759 fn toast_drag_cancel_records_settle_offset() {
1760 let window = AppWindowId::default();
1761 let mut store = ToastStore::default();
1762
1763 let id = store.add_toast(window, ToastRequest::new("Cancel").duration(None), None);
1764 store.set_window_swipe_config_with_options(
1765 window,
1766 ToastSwipeConfig {
1767 direction: ToastSwipeDirection::Right,
1768 threshold: Px(50.0),
1769 max_drag: Px(240.0),
1770 dragging_threshold: Px(0.0),
1771 },
1772 );
1773
1774 assert!(store.begin_drag(
1775 window,
1776 GlobalElementId(0),
1777 id,
1778 Point::new(Px(10.0), Px(10.0)),
1779 ToastPosition::BottomRight,
1780 ));
1781 assert!(
1782 store
1783 .drag_move(window, id, Point::new(Px(30.0), Px(10.0)))
1784 .is_some()
1785 );
1786 let end = store.end_drag(window, id).expect("end");
1787 assert!(end.dragging);
1788 assert!(!end.dismiss, "expected below threshold to not dismiss");
1789
1790 let toast = store
1791 .toasts_for_window(window)
1792 .iter()
1793 .find(|t| t.id == id)
1794 .expect("toast entry");
1795 assert_eq!(toast.drag_offset, Point::new(Px(0.0), Px(0.0)));
1796 assert_eq!(toast.settle_from, Some(Point::new(Px(20.0), Px(0.0))));
1797
1798 assert!(store.clear_settle(window, id));
1799 let toast = store
1800 .toasts_for_window(window)
1801 .iter()
1802 .find(|t| t.id == id)
1803 .expect("toast entry");
1804 assert_eq!(toast.settle_from, None);
1805 assert!(!store.clear_settle(window, id));
1806 }
1807
1808 #[test]
1809 fn toast_drag_dismiss_does_not_record_settle_offset() {
1810 let window = AppWindowId::default();
1811 let mut store = ToastStore::default();
1812
1813 let id = store.add_toast(window, ToastRequest::new("Dismiss").duration(None), None);
1814 store.set_window_swipe_config_with_options(
1815 window,
1816 ToastSwipeConfig {
1817 direction: ToastSwipeDirection::Right,
1818 threshold: Px(50.0),
1819 max_drag: Px(240.0),
1820 dragging_threshold: Px(0.0),
1821 },
1822 );
1823
1824 assert!(store.begin_drag(
1825 window,
1826 GlobalElementId(0),
1827 id,
1828 Point::new(Px(10.0), Px(10.0)),
1829 ToastPosition::BottomRight,
1830 ));
1831 assert!(
1832 store
1833 .drag_move(window, id, Point::new(Px(80.0), Px(10.0)))
1834 .is_some()
1835 );
1836 let end = store.end_drag(window, id).expect("end");
1837 assert!(end.dragging);
1838 assert!(end.dismiss);
1839
1840 let toast = store
1841 .toasts_for_window(window)
1842 .iter()
1843 .find(|t| t.id == id)
1844 .expect("toast entry");
1845 assert_eq!(toast.settle_from, None);
1846 }
1847
1848 #[test]
1849 fn toast_drag_dismiss_uses_swipe_config_direction_and_threshold() {
1850 let window = AppWindowId::default();
1851 let mut store = ToastStore::default();
1852
1853 let id = store.add_toast(window, ToastRequest::new("Swipe").duration(None), None);
1854
1855 store.set_window_swipe_config(window, ToastSwipeDirection::Right, Px(50.0));
1856 assert!(store.begin_drag(
1857 window,
1858 GlobalElementId(0),
1859 id,
1860 Point::new(Px(10.0), Px(10.0)),
1861 ToastPosition::BottomRight,
1862 ));
1863 assert!(
1864 store
1865 .drag_move(window, id, Point::new(Px(70.0), Px(10.0)))
1866 .is_some()
1867 );
1868 let end = store.end_drag(window, id).expect("end");
1869 assert!(end.dragging);
1870 assert!(end.dismiss, "expected swipe-right to dismiss");
1871
1872 let id = store.add_toast(window, ToastRequest::new("Swipe2").duration(None), None);
1873 store.set_window_swipe_config(window, ToastSwipeDirection::Left, Px(50.0));
1874 assert!(store.begin_drag(
1875 window,
1876 GlobalElementId(0),
1877 id,
1878 Point::new(Px(60.0), Px(10.0)),
1879 ToastPosition::BottomRight,
1880 ));
1881 assert!(
1882 store
1883 .drag_move(window, id, Point::new(Px(20.0), Px(10.0)))
1884 .is_some()
1885 );
1886 let end = store.end_drag(window, id).expect("end");
1887 assert!(end.dragging);
1888 assert!(
1889 !end.dismiss,
1890 "expected swipe-left below threshold to not dismiss"
1891 );
1892
1893 let id = store.add_toast(window, ToastRequest::new("Swipe3").duration(None), None);
1894 store.set_window_swipe_config(window, ToastSwipeDirection::Up, Px(50.0));
1895 assert!(store.begin_drag(
1896 window,
1897 GlobalElementId(0),
1898 id,
1899 Point::new(Px(10.0), Px(60.0)),
1900 ToastPosition::BottomRight,
1901 ));
1902 assert!(
1903 store
1904 .drag_move(window, id, Point::new(Px(10.0), Px(0.0)))
1905 .is_some()
1906 );
1907 assert!(
1908 store
1909 .toasts_for_window(window)
1910 .iter()
1911 .find(|t| t.id == id)
1912 .expect("toast entry")
1913 .drag_offset
1914 .y
1915 .0
1916 < 0.0
1917 );
1918 let end = store.end_drag(window, id).expect("end");
1919 assert!(end.dragging);
1920 assert!(end.dismiss, "expected swipe-up to dismiss");
1921
1922 let id = store.add_toast(window, ToastRequest::new("Swipe4").duration(None), None);
1923 store.set_window_swipe_config(window, ToastSwipeDirection::Down, Px(50.0));
1924 assert!(store.begin_drag(
1925 window,
1926 GlobalElementId(0),
1927 id,
1928 Point::new(Px(10.0), Px(10.0)),
1929 ToastPosition::BottomRight,
1930 ));
1931 assert!(
1932 store
1933 .drag_move(window, id, Point::new(Px(10.0), Px(70.0)))
1934 .is_some()
1935 );
1936 assert!(
1937 store
1938 .toasts_for_window(window)
1939 .iter()
1940 .find(|t| t.id == id)
1941 .expect("toast entry")
1942 .drag_offset
1943 .y
1944 .0
1945 > 0.0
1946 );
1947 let end = store.end_drag(window, id).expect("end");
1948 assert!(end.dragging);
1949 assert!(end.dismiss, "expected swipe-down to dismiss");
1950 }
1951
1952 #[test]
1953 fn toast_drag_clamps_to_max_drag_for_swipe_axis() {
1954 let window = AppWindowId::default();
1955 let mut store = ToastStore::default();
1956
1957 let id = store.add_toast(window, ToastRequest::new("Clamp").duration(None), None);
1958 store.set_window_swipe_config_with_options(
1959 window,
1960 ToastSwipeConfig {
1961 direction: ToastSwipeDirection::Right,
1962 threshold: Px(50.0),
1963 max_drag: Px(16.0),
1964 dragging_threshold: Px(0.0),
1965 },
1966 );
1967
1968 assert!(store.begin_drag(
1969 window,
1970 GlobalElementId(0),
1971 id,
1972 Point::new(Px(10.0), Px(10.0)),
1973 ToastPosition::BottomRight,
1974 ));
1975 assert!(
1976 store
1977 .drag_move(window, id, Point::new(Px(200.0), Px(200.0)))
1978 .is_some()
1979 );
1980
1981 let toast = store
1982 .toasts_for_window(window)
1983 .iter()
1984 .find(|t| t.id == id)
1985 .expect("toast entry");
1986 assert_eq!(toast.drag_offset.x, Px(16.0));
1987 assert_eq!(toast.drag_offset.y, Px(0.0));
1988 }
1989
1990 #[test]
1991 fn toast_dragging_threshold_controls_capture_arming() {
1992 let window = AppWindowId::default();
1993 let mut store = ToastStore::default();
1994
1995 let id = store.add_toast(window, ToastRequest::new("Threshold").duration(None), None);
1996 store.set_window_swipe_config_with_options(
1997 window,
1998 ToastSwipeConfig {
1999 direction: ToastSwipeDirection::Right,
2000 threshold: Px(50.0),
2001 max_drag: Px(240.0),
2002 dragging_threshold: Px(40.0),
2003 },
2004 );
2005
2006 assert!(store.begin_drag(
2007 window,
2008 GlobalElementId(0),
2009 id,
2010 Point::new(Px(10.0), Px(10.0)),
2011 ToastPosition::BottomRight,
2012 ));
2013 let moved = store.drag_move(window, id, Point::new(Px(45.0), Px(10.0)));
2014 assert!(
2015 moved.is_some_and(|m| !m.dragging),
2016 "expected below dragging threshold"
2017 );
2018
2019 let moved = store.drag_move(window, id, Point::new(Px(55.0), Px(10.0)));
2020 assert!(
2021 moved.is_some_and(|m| m.dragging && m.capture_pointer),
2022 "expected to arm pointer capture once dragging begins"
2023 );
2024 }
2025
2026 #[test]
2027 fn toast_upsert_updates_existing_entry_and_resets_timer() {
2028 let window = AppWindowId::default();
2029 let mut store = ToastStore::default();
2030
2031 let out0 = store.upsert_toast(
2032 window,
2033 ToastRequest::new("Loading")
2034 .variant(ToastVariant::Loading)
2035 .duration(None),
2036 None,
2037 );
2038 let id = out0.id;
2039
2040 let out1 = store.upsert_toast(
2041 window,
2042 ToastRequest::new("Done")
2043 .id(id)
2044 .variant(ToastVariant::Success)
2045 .duration(Some(Duration::from_secs(2)))
2046 .action(ToastAction::new("Undo", CommandId::from("toast.undo")))
2047 .cancel(ToastAction::new("Cancel", CommandId::from("toast.cancel"))),
2048 Some(TimerToken(10)),
2049 );
2050 assert_eq!(out1.id, id);
2051 assert_eq!(out1.cancel_auto, None);
2052 assert_eq!(
2053 out1.schedule_auto,
2054 Some((TimerToken(10), TOAST_AUTO_CLOSE_TICK))
2055 );
2056
2057 let toast = store.toasts_for_window(window)[0].clone();
2058 assert_eq!(toast.id, id);
2059 assert_eq!(toast.title.as_ref(), "Done");
2060 assert_eq!(toast.variant, ToastVariant::Success);
2061 assert_eq!(toast.auto_close_token, Some(TimerToken(10)));
2062 assert_eq!(
2063 toast.action.as_ref().map(|a| a.label.as_ref()),
2064 Some("Undo")
2065 );
2066 assert_eq!(
2067 toast.cancel.as_ref().map(|a| a.label.as_ref()),
2068 Some("Cancel")
2069 );
2070 }
2071
2072 #[test]
2073 fn toast_upsert_noops_swipe_for_non_dismissible_toasts() {
2074 let window = AppWindowId::default();
2075 let mut store = ToastStore::default();
2076
2077 let id = store.add_toast(
2078 window,
2079 ToastRequest::new("Pinned")
2080 .duration(None)
2081 .dismissible(false),
2082 None,
2083 );
2084
2085 assert!(!store.begin_drag(
2086 window,
2087 GlobalElementId(0),
2088 id,
2089 Point::new(Px(10.0), Px(10.0)),
2090 ToastPosition::BottomRight,
2091 ));
2092 }
2093
2094 #[test]
2095 fn toast_upsert_persists_icon_and_promise_flags() {
2096 let window = AppWindowId::default();
2097 let mut store = ToastStore::default();
2098
2099 let out = store.upsert_toast(
2100 window,
2101 ToastRequest::new("Loading")
2102 .variant(ToastVariant::Loading)
2103 .duration(None)
2104 .promise(true)
2105 .no_icon(),
2106 None,
2107 );
2108
2109 let toast = store
2110 .toasts_for_window(window)
2111 .iter()
2112 .find(|t| t.id == out.id)
2113 .expect("toast present");
2114
2115 assert!(toast.promise);
2116 assert!(matches!(toast.icon, Some(ToastIconOverride::Hidden)));
2117 }
2118
2119 #[test]
2120 fn toast_upsert_updates_icon_override() {
2121 let window = AppWindowId::default();
2122 let mut store = ToastStore::default();
2123
2124 let out = store.upsert_toast(
2125 window,
2126 ToastRequest::new("A").icon(ToastIconOverride::glyph("!")),
2127 None,
2128 );
2129
2130 let _ = store.upsert_toast(
2131 window,
2132 ToastRequest::new("B")
2133 .id(out.id)
2134 .icon(ToastIconOverride::glyph("i")),
2135 None,
2136 );
2137
2138 let toast = store
2139 .toasts_for_window(window)
2140 .iter()
2141 .find(|t| t.id == out.id)
2142 .expect("toast present");
2143
2144 assert!(matches!(toast.icon, Some(ToastIconOverride::Glyph(_))));
2145 assert_eq!(
2146 match toast.icon.as_ref() {
2147 Some(ToastIconOverride::Glyph(g)) => g.as_ref(),
2148 _ => "<missing>",
2149 },
2150 "i"
2151 );
2152 }
2153
2154 #[test]
2155 fn toast_max_toasts_evicts_oldest_open_toasts() {
2156 let window = AppWindowId::default();
2157 let mut store = ToastStore::default();
2158 store.set_window_max_toasts(window, Some(2));
2159
2160 let out0 = store.upsert_toast(window, ToastRequest::new("A").duration(None), None);
2161 let out1 = store.upsert_toast(window, ToastRequest::new("B").duration(None), None);
2162 let out2 = store.upsert_toast(window, ToastRequest::new("C").duration(None), None);
2163
2164 assert_eq!(out0.evicted, Vec::new());
2165 assert_eq!(out1.evicted, Vec::new());
2166 assert_eq!(out2.evicted, vec![out0.id]);
2167 }
2168
2169 #[test]
2170 fn toast_max_toasts_prefers_evicting_auto_closing_toasts_over_pinned() {
2171 let window = AppWindowId::default();
2172 let mut store = ToastStore::default();
2173 store.set_window_max_toasts(window, Some(2));
2174
2175 let pinned = store.upsert_toast(window, ToastRequest::new("Pinned").duration(None), None);
2176 let auto0 = store.upsert_toast(
2177 window,
2178 ToastRequest::new("Auto0").duration(Some(Duration::from_secs(3))),
2179 None,
2180 );
2181 let auto1 = store.upsert_toast(
2182 window,
2183 ToastRequest::new("Auto1").duration(Some(Duration::from_secs(3))),
2184 None,
2185 );
2186
2187 assert_eq!(pinned.evicted, Vec::new());
2188 assert_eq!(auto0.evicted, Vec::new());
2189 assert_eq!(auto1.evicted, vec![auto0.id]);
2190 }
2191
2192 #[test]
2193 fn toast_max_toasts_evicts_pinned_when_all_are_pinned() {
2194 let window = AppWindowId::default();
2195 let mut store = ToastStore::default();
2196 store.set_window_max_toasts(window, Some(1));
2197
2198 let a = store.upsert_toast(window, ToastRequest::new("A").duration(None), None);
2199 let b = store.upsert_toast(window, ToastRequest::new("B").duration(None), None);
2200
2201 assert_eq!(a.evicted, Vec::new());
2202 assert_eq!(b.evicted, vec![a.id]);
2203 }
2204}