Skip to main content

fret_ui_kit/window_overlays/
toast.rs

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/// Mirrors Radix toast `swipeDirection` (`left`/`right`/`up`/`down`).
49#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
50pub enum ToastSwipeDirection {
51    Left,
52    #[default]
53    Right,
54    Up,
55    Down,
56}
57
58/// Sonner-style swipe direction allowlist (the `swipeDirections` toaster prop).
59///
60/// This intentionally supports multiple directions at once (e.g. `['top', 'right']`).
61#[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    /// When false, activating the action does not dismiss the toast.
203    ///
204    /// This mirrors Sonner's `event.preventDefault()` escape hatch for action clicks.
205    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    /// Use the toaster default duration (Sonner: `duration` prop, falling back to 4000ms).
226    #[default]
227    UseDefault,
228    /// Pin the toast (no auto-close timer).
229    Pinned,
230    /// Use a fixed duration for this toast.
231    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    /// Optional description override.
252    ///
253    /// When `None`, the existing description is preserved on upsert (Sonner upsert semantics).
254    pub description: Option<ToastDescription>,
255    pub duration: ToastDuration,
256    /// When `None`, the existing variant is preserved on upsert.
257    pub variant: Option<ToastVariant>,
258    /// Explicit icon override for this toast (`toast.icon` in Sonner).
259    ///
260    /// When `None`, the toaster-level icon overrides apply.
261    pub icon: Option<ToastIconOverride>,
262    /// Marks this toast as a promise toast (`toast.promise` in Sonner).
263    ///
264    /// This is primarily used to mirror Sonner's slightly different loading-icon semantics for
265    /// promise toasts.
266    pub promise: bool,
267    pub action: Option<ToastAction>,
268    pub cancel: Option<ToastAction>,
269    /// When `None`, the existing dismissible flag is preserved on upsert.
270    pub dismissible: Option<bool>,
271    /// Per-toast close button override (Sonner: `toast.closeButton`).
272    ///
273    /// When `None`, the toaster-level `closeButton` setting applies.
274    pub close_button: Option<bool>,
275    pub position: Option<ToastPosition>,
276    pub rich_colors: Option<bool>,
277    /// When `None`, the existing invert flag is preserved on upsert.
278    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            // Do not treat "loading suppressed timers" as a pause signal.
805            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        // Prefer evicting auto-closing toasts first; keep pinned toasts around when possible.
896        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
1519/// Dismisses all active toasts for the given window (Sonner: `toast.dismiss()` with no id).
1520///
1521/// Returns the number of toasts that were scheduled for removal.
1522pub 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/// Thread-safe handle for scheduling toast upserts/dismissals from background work.
1553///
1554/// Messages are applied on the UI thread during the window overlays render pass.
1555#[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}