Skip to main content

gpui_base/
toast.rs

1use crate::TestSupportExt as _;
2#[cfg(not(target_family = "wasm"))]
3use std::time::Instant;
4use std::{cell::RefCell, collections::VecDeque, rc::Rc, time::Duration};
5#[cfg(target_family = "wasm")]
6use web_time::Instant;
7
8use gpui::{
9    Anchor, AnyElement, App, Div, ElementId, FocusHandle, InteractiveElement, Interactivity,
10    IntoElement, MouseMoveEvent, ParentElement, Pixels, RenderOnce, Role, Stateful,
11    StatefulInteractiveElement, StyleRefinement, Styled, Window, canvas, div,
12    prelude::FluentBuilder as _, px,
13};
14
15use crate::{
16    ElementExt as _, StyledExt as _,
17    motion::{Spring, spring},
18};
19
20/// Motion tokens used by an unstyled toast stack.
21#[derive(Clone, Copy, Debug)]
22pub struct ToastMotion {
23    /// Time scale of the stack's motion.
24    ///
25    /// This is read as two different quantities, because the stack sequences
26    /// one thing and interpolates another. [`ToastManager`] treats it as a
27    /// deadline: a toast is present once it has elapsed. The layout treats it
28    /// as a spring response, which is the scale the reflow is felt at rather
29    /// than the moment it stops — a toast arriving still nudges the stack for
30    /// a little longer than this.
31    pub duration: Duration,
32    /// Duration before an ending toast is unmounted.
33    pub exit_duration: Duration,
34    /// Visible distance between collapsed toast layers.
35    pub collapsed_peek: Pixels,
36    /// Distance between expanded toast items.
37    pub expanded_gap: Pixels,
38    /// Fractional width reduction for each collapsed layer.
39    pub collapsed_scale_step: f32,
40    /// Number of layers visible while the stack is collapsed.
41    pub collapsed_visible: usize,
42}
43
44impl ToastMotion {
45    /// Create motion matching the shadcn/Sonner toaster.
46    pub fn sonner() -> Self {
47        Self {
48            duration: Duration::from_millis(400),
49            exit_duration: Duration::from_millis(200),
50            collapsed_peek: px(14.),
51            expanded_gap: px(14.),
52            collapsed_scale_step: 0.05,
53            collapsed_visible: 3,
54        }
55    }
56}
57
58impl Default for ToastMotion {
59    fn default() -> Self {
60        Self::sonner()
61    }
62}
63
64/// Persistent private layout state used by [`ToastStack`].
65#[derive(Clone, Debug, Default)]
66pub struct ToastStackState {
67    heights: Rc<RefCell<std::collections::HashMap<ElementId, Pixels>>>,
68    width: Rc<std::cell::Cell<Pixels>>,
69    hovered: Rc<std::cell::Cell<bool>>,
70    focused: Rc<std::cell::Cell<bool>>,
71}
72
73impl ToastStackState {
74    /// Return whether interaction has expanded the stack.
75    pub fn is_expanded(&self) -> bool {
76        self.hovered.get() || self.focused.get()
77    }
78}
79
80/// Options applied when a toast enters a [`ToastManager`].
81#[derive(Clone, Copy, Debug, Default)]
82pub struct ToastOptions {
83    /// Active time before automatic dismissal; `None` disables auto-hide.
84    pub timeout: Option<Duration>,
85}
86
87#[derive(Debug)]
88struct ManagedToast<I, T> {
89    id: I,
90    value: T,
91    status: ToastTransitionStatus,
92    timeout_remaining: Option<Duration>,
93    transition_elapsed: Duration,
94    last_advance: Instant,
95}
96
97/// Changes produced when a toast manager advances its lifecycle clock.
98#[derive(Debug)]
99pub struct ToastAdvance<I, T> {
100    /// Whether a mounted toast changed phase or membership.
101    pub changed: bool,
102    /// Toast ids whose entry transition completed.
103    pub presented: Vec<I>,
104    /// Toast ids that entered their ending transition.
105    pub ending: Vec<I>,
106    /// Toast values removed after their ending transition completed.
107    pub removed: Vec<(I, T)>,
108}
109
110/// Ordered toast storage, lifecycle, auto-hide, limits, and exit coordination.
111#[derive(Debug)]
112pub struct ToastManager<I, T> {
113    entries: VecDeque<ManagedToast<I, T>>,
114    transition_duration: Duration,
115    exit_duration: Duration,
116}
117
118impl<I, T> ToastManager<I, T> {
119    /// Create a manager using the supplied motion duration for enter and exit.
120    pub fn new(motion: ToastMotion) -> Self {
121        Self {
122            entries: VecDeque::new(),
123            transition_duration: motion.duration,
124            exit_duration: motion.exit_duration,
125        }
126    }
127
128    /// Return the number of mounted toasts, including ending toasts.
129    pub fn len(&self) -> usize {
130        self.entries.len()
131    }
132
133    /// Return whether no toast is mounted.
134    pub fn is_empty(&self) -> bool {
135        self.entries.is_empty()
136    }
137
138    /// Iterate over mounted toast ids, values, and phases in display order.
139    pub fn iter(&self) -> impl DoubleEndedIterator<Item = (&I, &T, ToastTransitionStatus)> {
140        self.entries
141            .iter()
142            .map(|entry| (&entry.id, &entry.value, entry.status))
143    }
144
145    /// Iterate over newest visible active toasts plus ending toasts.
146    pub fn visible(&self, limit: usize) -> impl Iterator<Item = (&I, &T, ToastTransitionStatus)> {
147        let first = self
148            .entries
149            .iter()
150            .filter(|entry| entry.status != ToastTransitionStatus::Ending)
151            .count()
152            .saturating_sub(limit);
153        let mut active_index = 0usize;
154        self.entries.iter().filter_map(move |entry| {
155            let visible = if entry.status == ToastTransitionStatus::Ending {
156                true
157            } else {
158                let keep = active_index >= first;
159                active_index += 1;
160                keep
161            };
162            visible.then_some((&entry.id, &entry.value, entry.status))
163        })
164    }
165
166    /// Return a mounted toast value by id.
167    pub fn get(&self, id: &I) -> Option<&T>
168    where
169        I: Eq,
170    {
171        self.entries
172            .iter()
173            .find_map(|entry| (&entry.id == id).then_some(&entry.value))
174    }
175}
176
177impl<I: Clone + Eq, T> ToastManager<I, T> {
178    /// Add a newest toast, replacing an existing toast with the same id.
179    pub fn push(&mut self, id: I, value: T, options: ToastOptions, now: Instant) -> Option<T> {
180        let replaced = self
181            .entries
182            .iter()
183            .position(|entry| entry.id == id)
184            .and_then(|index| self.entries.remove(index))
185            .map(|entry| entry.value);
186        self.entries.push_back(ManagedToast {
187            id,
188            value,
189            status: ToastTransitionStatus::Starting,
190            timeout_remaining: options.timeout,
191            transition_elapsed: Duration::ZERO,
192            last_advance: now,
193        });
194        replaced
195    }
196
197    /// Begin a toast's exit transition, returning whether its state changed.
198    pub fn dismiss(&mut self, id: &I, now: Instant) -> bool {
199        let Some(entry) = self.entries.iter_mut().find(|entry| &entry.id == id) else {
200            return false;
201        };
202        if entry.status == ToastTransitionStatus::Ending {
203            return false;
204        }
205        entry.status = ToastTransitionStatus::Ending;
206        entry.transition_elapsed = Duration::ZERO;
207        entry.last_advance = now;
208        true
209    }
210
211    /// Begin the exit transition for every active toast.
212    pub fn dismiss_all(&mut self, now: Instant) -> Vec<I> {
213        let mut changed = Vec::new();
214        for entry in &mut self.entries {
215            if entry.status != ToastTransitionStatus::Ending {
216                entry.status = ToastTransitionStatus::Ending;
217                entry.transition_elapsed = Duration::ZERO;
218                entry.last_advance = now;
219                changed.push(entry.id.clone());
220            }
221        }
222        changed
223    }
224
225    /// Advance lifecycle time; active timers pause while `paused` is true.
226    pub fn advance(&mut self, now: Instant, paused: bool) -> ToastAdvance<I, T> {
227        let mut ending = Vec::new();
228        let mut presented = Vec::new();
229        let mut changed = false;
230        for entry in &mut self.entries {
231            let delta = now.saturating_duration_since(entry.last_advance);
232            entry.last_advance = now;
233            match entry.status {
234                ToastTransitionStatus::Starting => {
235                    entry.transition_elapsed += delta;
236                    if entry.transition_elapsed >= self.transition_duration {
237                        entry.status = ToastTransitionStatus::Present;
238                        entry.transition_elapsed = Duration::ZERO;
239                        presented.push(entry.id.clone());
240                        changed = true;
241                    }
242                }
243                ToastTransitionStatus::Present if !paused => {
244                    if let Some(remaining) = &mut entry.timeout_remaining {
245                        *remaining = remaining.saturating_sub(delta);
246                        if remaining.is_zero() {
247                            entry.status = ToastTransitionStatus::Ending;
248                            entry.transition_elapsed = Duration::ZERO;
249                            ending.push(entry.id.clone());
250                            changed = true;
251                        }
252                    }
253                }
254                ToastTransitionStatus::Ending => entry.transition_elapsed += delta,
255                ToastTransitionStatus::Present => {}
256            }
257        }
258        let mut removed = Vec::new();
259        let mut index = 0;
260        while index < self.entries.len() {
261            if self.entries[index].status == ToastTransitionStatus::Ending
262                && self.entries[index].transition_elapsed >= self.exit_duration
263            {
264                let entry = self.entries.remove(index).expect("toast index is valid");
265                removed.push((entry.id, entry.value));
266                changed = true;
267            } else {
268                index += 1;
269            }
270        }
271        ToastAdvance {
272            changed,
273            presented,
274            ending,
275            removed,
276        }
277    }
278}
279
280/// A deep toast-stack element that owns measurement, overlap, and expansion motion.
281#[derive(IntoElement)]
282pub struct ToastStack {
283    id: ElementId,
284    base: crate::ObservedElement<Stateful<Div>>,
285    style: StyleRefinement,
286    state: ToastStackState,
287    motion: ToastMotion,
288    placement: Anchor,
289    focus_handle: Option<FocusHandle>,
290    children: Vec<(ElementId, AnyElement)>,
291}
292
293impl ToastStack {
294    /// Create a toast stack with Base UI-compatible motion.
295    pub fn new(id: impl Into<ElementId>, state: ToastStackState) -> Self {
296        let id = id.into();
297        Self {
298            base: div().id(id.clone()).test_support(),
299            id,
300            style: StyleRefinement::default(),
301            state,
302            motion: ToastMotion::sonner(),
303            placement: Anchor::TopRight,
304            focus_handle: None,
305            children: Vec::new(),
306        }
307    }
308
309    /// Add a stably keyed toast item to the stack.
310    pub fn item(mut self, id: impl Into<ElementId>, child: impl IntoElement) -> Self {
311        self.children.push((id.into(), child.into_any_element()));
312        self
313    }
314
315    /// Set the stack motion tokens.
316    pub fn motion(mut self, motion: ToastMotion) -> Self {
317        self.motion = motion;
318        self
319    }
320
321    /// Set the viewport edge used to anchor stack geometry.
322    pub fn placement(mut self, placement: Anchor) -> Self {
323        self.placement = placement;
324        self
325    }
326
327    /// Set the focus scope that expands the stack and pauses auto-hide timers.
328    pub fn focus_handle(mut self, focus_handle: FocusHandle) -> Self {
329        self.focus_handle = Some(focus_handle);
330        self
331    }
332}
333
334impl Styled for ToastStack {
335    fn style(&mut self) -> &mut StyleRefinement {
336        &mut self.style
337    }
338}
339
340impl ParentElement for ToastStack {
341    fn extend(&mut self, children: impl IntoIterator<Item = AnyElement>) {
342        self.children
343            .extend(children.into_iter().enumerate().map(|(index, child)| {
344                (
345                    ElementId::NamedInteger("toast-stack-child".into(), index as u64),
346                    child,
347                )
348            }));
349    }
350}
351
352impl InteractiveElement for ToastStack {
353    fn interactivity(&mut self) -> &mut Interactivity {
354        self.base.interactivity()
355    }
356
357    fn track_focus(mut self, handle: &gpui::FocusHandle) -> Self {
358        self.base = self.base.track_focus(handle);
359        self
360    }
361}
362
363impl StatefulInteractiveElement for ToastStack {}
364
365fn stack_geometry(
366    heights: &[Pixels],
367    gap: Pixels,
368    peek: Pixels,
369    anchored_bottom: bool,
370) -> (Pixels, Pixels, Vec<(Pixels, Pixels)>) {
371    let count = heights.len();
372    let expanded_height = heights
373        .iter()
374        .copied()
375        .fold(px(0.), |sum, height| sum + height)
376        + gap * count.saturating_sub(1) as f32;
377    let front_height = heights.last().copied().unwrap_or(px(0.));
378    let collapsed_height = heights
379        .iter()
380        .enumerate()
381        .map(|(index, height)| {
382            let rank = count - 1 - index;
383            *height + peek * rank as f32
384        })
385        .fold(
386            front_height + peek * count.saturating_sub(1) as f32,
387            Pixels::max,
388        );
389    let offsets = heights
390        .iter()
391        .enumerate()
392        .map(|(index, height)| {
393            let rank = count - 1 - index;
394            let newer_height = heights[(index + 1)..]
395                .iter()
396                .copied()
397                .fold(px(0.), |sum, height| sum + height);
398            let expanded = if anchored_bottom {
399                expanded_height - newer_height - gap * rank as f32 - *height
400            } else {
401                newer_height + gap * rank as f32
402            };
403            let collapsed = if anchored_bottom {
404                collapsed_height - *height - peek * rank as f32
405            } else {
406                peek * rank as f32
407            };
408            (collapsed, expanded)
409        })
410        .collect();
411    (collapsed_height, expanded_height, offsets)
412}
413
414impl RenderOnce for ToastStack {
415    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
416        let focused = self
417            .focus_handle
418            .as_ref()
419            .is_some_and(|handle| handle.contains_focused(window, cx));
420        self.state.focused.set(focused);
421        let expanded = self.state.is_expanded();
422        let keys = self
423            .children
424            .iter()
425            .map(|(id, _)| id.clone())
426            .collect::<Vec<_>>();
427        self.state
428            .heights
429            .borrow_mut()
430            .retain(|id, _| keys.contains(id));
431        let measured_by_id = self.state.heights.borrow().clone();
432        let heights = keys
433            .iter()
434            .map(|id| measured_by_id.get(id).copied().unwrap_or(px(0.)))
435            .collect::<Vec<_>>();
436        let measured = self.state.heights.clone();
437        let peek = self.motion.collapsed_peek;
438        let gap = self.motion.expanded_gap;
439        let scale_step = self.motion.collapsed_scale_step;
440        let collapsed_visible = self.motion.collapsed_visible.max(1);
441        let stack_width = self.state.width.get();
442        let count = self.children.len();
443        let anchored_bottom = matches!(
444            self.placement,
445            Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight
446        );
447        let (collapsed_height, expanded_height, offsets) = stack_geometry(
448            &heights[..count.min(heights.len())],
449            gap,
450            peek,
451            anchored_bottom,
452        );
453        // The whole stack reflows every time a toast arrives or leaves, so each
454        // layer is sprung: one retargeted mid-move carries its velocity into the
455        // new layout instead of restarting. Critically damped, because a height
456        // or an opacity that overshoots its target reads as a glitch. Geometry
457        // settles in pixels, so its tolerance is coarser than the fade's.
458        //
459        // A bottom-anchored item's position is composed from two of these — the
460        // stack height and the item's own offset — and the stack height only
461        // acquires its real target once the new toast has been measured in
462        // prepaint, a frame after the offsets know theirs. Two springs sharing a
463        // config stay proportional to each other only while they also share a
464        // start, so that one frame of skew lets the composed position pass its
465        // settled value by a fraction of a pixel before arriving. Both springs
466        // snap on settling, so it is a transient, not a resting error.
467        let geometry = Spring::new(self.motion.duration).with_epsilon(0.1);
468        let fade = Spring::new(self.motion.duration);
469        let stack_height = spring(
470            (self.id.clone(), "height"),
471            if expanded {
472                expanded_height
473            } else {
474                collapsed_height
475            },
476            geometry,
477            window,
478            cx,
479        );
480        let items = self
481            .children
482            .into_iter()
483            .enumerate()
484            .map(move |(index, (item_id, child))| {
485                let (collapsed_offset, expanded_offset) =
486                    offsets.get(index).copied().unwrap_or((px(0.), px(0.)));
487                let measured = measured.clone();
488                let measured_id = item_id.clone();
489                let rank = count.saturating_sub(1 + index);
490                let target_offset = if expanded {
491                    expanded_offset
492                } else {
493                    collapsed_offset
494                };
495                let offset = spring(
496                    (item_id.clone(), "offset"),
497                    target_offset,
498                    geometry,
499                    window,
500                    cx,
501                );
502                let target_inset = if expanded {
503                    px(0.)
504                } else {
505                    stack_width * (scale_step * rank.min(collapsed_visible - 1) as f32 / 2.)
506                };
507                let inset = spring(
508                    (item_id.clone(), "inset"),
509                    target_inset,
510                    geometry,
511                    window,
512                    cx,
513                );
514                let opacity = spring(
515                    (item_id.clone(), "visibility"),
516                    if expanded || rank < collapsed_visible {
517                        1.
518                    } else {
519                        0.
520                    },
521                    fade,
522                    window,
523                    cx,
524                );
525                div()
526                    .id(item_id.clone())
527                    .absolute()
528                    .top_0()
529                    .left_0()
530                    .right_0()
531                    .top(offset)
532                    .left(inset)
533                    .right(inset)
534                    .opacity(opacity)
535                    .when(!expanded && rank >= collapsed_visible, |this| {
536                        this.invisible()
537                    })
538                    .on_prepaint(move |bounds, _, cx| {
539                        let mut heights = measured.borrow_mut();
540                        if heights.get(&measured_id).copied() != Some(bounds.size.height) {
541                            heights.insert(measured_id.clone(), bounds.size.height);
542                            cx.refresh_windows();
543                        }
544                    })
545                    .child(child)
546            });
547
548        let hovered_state = self.state.hovered.clone();
549        let measured_width = self.state.width.clone();
550        self.base
551            .relative()
552            .h(stack_height)
553            .when_some(self.focus_handle, |this, handle| this.track_focus(&handle))
554            .child(
555                canvas(
556                    |_, _, _| {},
557                    move |mut bounds, _, window, cx| {
558                        if measured_width.replace(bounds.size.width) != bounds.size.width {
559                            cx.refresh_windows();
560                        }
561                        if !expanded {
562                            if anchored_bottom {
563                                bounds.origin.y += stack_height - collapsed_height;
564                            }
565                            bounds.size.height = collapsed_height;
566                        }
567                        let hovered_state = hovered_state.clone();
568                        window.on_mouse_event(move |event: &MouseMoveEvent, _, _, cx| {
569                            let hovered = bounds.contains(&event.position);
570                            if hovered_state.replace(hovered) != hovered {
571                                cx.refresh_windows();
572                            }
573                        });
574                    },
575                )
576                .absolute()
577                .size_full(),
578            )
579            .children(items)
580            .refine_style(&self.style)
581    }
582}
583
584/// The lifecycle phase exposed by a toast root to application-owned presentation.
585#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
586pub enum ToastTransitionStatus {
587    /// The toast has just been added and may run its enter transition.
588    #[default]
589    Starting,
590    /// The toast is fully present.
591    Present,
592    /// The toast is closing and remains mounted until its exit transition completes.
593    Ending,
594}
595
596/// An unstyled semantic toast root. Applications own all presentation and motion.
597#[derive(IntoElement)]
598pub struct Toast {
599    base: crate::ObservedElement<Stateful<Div>>,
600    style: StyleRefinement,
601    transition_status: ToastTransitionStatus,
602    children: Vec<AnyElement>,
603}
604
605impl Toast {
606    /// Create an unstyled semantic toast in the starting transition phase.
607    pub fn new(id: impl Into<ElementId>) -> Self {
608        Self {
609            base: div().id(id).test_support(),
610            style: StyleRefinement::default(),
611            transition_status: ToastTransitionStatus::Starting,
612            children: Vec::new(),
613        }
614    }
615
616    /// Set the lifecycle phase used by application-owned toast presentation.
617    pub fn transition_status(mut self, status: ToastTransitionStatus) -> Self {
618        self.transition_status = status;
619        self
620    }
621
622    /// Return the lifecycle phase used by application-owned toast presentation.
623    pub fn status(&self) -> ToastTransitionStatus {
624        self.transition_status
625    }
626}
627
628impl Styled for Toast {
629    fn style(&mut self) -> &mut StyleRefinement {
630        &mut self.style
631    }
632}
633
634impl ParentElement for Toast {
635    fn extend(&mut self, children: impl IntoIterator<Item = AnyElement>) {
636        self.children.extend(children);
637    }
638}
639
640impl InteractiveElement for Toast {
641    fn interactivity(&mut self) -> &mut Interactivity {
642        self.base.interactivity()
643    }
644
645    fn track_focus(mut self, handle: &gpui::FocusHandle) -> Self {
646        self.base = self.base.track_focus(handle);
647        self
648    }
649}
650
651impl StatefulInteractiveElement for Toast {}
652
653impl RenderOnce for Toast {
654    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
655        self.base
656            .role(Role::Alert)
657            .children(self.children)
658            .refine_style(&self.style)
659    }
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665    use gpui::{Element as _, accesskit, point};
666
667    #[test]
668    fn manager_pauses_timeout_and_removes_only_after_exit() {
669        let motion = ToastMotion::sonner();
670        let start = Instant::now();
671        let mut manager = ToastManager::new(motion);
672        manager.push(
673            "a",
674            1,
675            ToastOptions {
676                timeout: Some(Duration::from_secs(5)),
677            },
678            start,
679        );
680        manager.advance(start + motion.duration, false);
681        manager.advance(start + motion.duration + Duration::from_secs(4), true);
682        assert_eq!(
683            manager.iter().next().unwrap().2,
684            ToastTransitionStatus::Present
685        );
686        let ending = manager.advance(start + motion.duration + Duration::from_secs(9), false);
687        assert_eq!(ending.ending, vec!["a"]);
688        assert!(ending.removed.is_empty());
689        let removed = manager.advance(
690            start + motion.duration + motion.exit_duration + Duration::from_secs(9),
691            false,
692        );
693        assert_eq!(removed.removed, vec![("a", 1)]);
694    }
695
696    #[test]
697    fn manager_limit_keeps_ending_toasts_mounted() {
698        let now = Instant::now();
699        let mut manager = ToastManager::new(ToastMotion::sonner());
700        for id in ["a", "b", "c"] {
701            manager.push(id, id, ToastOptions::default(), now);
702        }
703        manager.dismiss(&"a", now);
704        assert_eq!(
705            manager.visible(1).map(|(id, _, _)| *id).collect::<Vec<_>>(),
706            vec!["a", "c"]
707        );
708    }
709
710    #[test]
711    fn manager_replaces_duplicate_ids_as_the_newest_toast() {
712        let now = Instant::now();
713        let mut manager = ToastManager::new(ToastMotion::sonner());
714        manager.push("a", 1, ToastOptions::default(), now);
715        manager.push("b", 2, ToastOptions::default(), now);
716        assert_eq!(manager.push("a", 3, ToastOptions::default(), now), Some(1));
717        assert_eq!(
718            manager
719                .iter()
720                .map(|(id, value, _)| (*id, *value))
721                .collect::<Vec<_>>(),
722            vec![("b", 2), ("a", 3)]
723        );
724    }
725
726    #[test]
727    fn manager_resets_clock_when_reused_after_becoming_empty() {
728        let motion = ToastMotion::sonner();
729        let start = Instant::now();
730        let mut manager = ToastManager::new(motion);
731        manager.push("old", 1, ToastOptions::default(), start);
732        manager.dismiss(&"old", start);
733        manager.advance(start + motion.exit_duration, false);
734        let later = start + Duration::from_secs(60);
735        manager.push("new", 2, ToastOptions::default(), later);
736        manager.advance(later + Duration::from_millis(50), false);
737        assert_eq!(
738            manager.iter().next().unwrap().2,
739            ToastTransitionStatus::Starting
740        );
741    }
742
743    #[test]
744    fn newly_pushed_toast_does_not_inherit_existing_entry_clock() {
745        let motion = ToastMotion::sonner();
746        let start = Instant::now();
747        let mut manager = ToastManager::new(motion);
748        manager.push("old", 1, ToastOptions::default(), start);
749        let later = start + Duration::from_secs(60);
750        manager.push("new", 2, ToastOptions::default(), later);
751        manager.advance(later + Duration::from_millis(50), false);
752        assert_eq!(
753            manager
754                .iter()
755                .find(|(id, _, _)| **id == "new")
756                .map(|(_, _, status)| status),
757            Some(ToastTransitionStatus::Starting)
758        );
759    }
760
761    #[test]
762    fn stack_geometry_anchors_newest_item_and_supports_variable_heights() {
763        let heights = [px(40.), px(60.), px(80.)];
764        let (collapsed, expanded, top) = stack_geometry(&heights, px(12.), px(12.), false);
765        assert_eq!(collapsed, px(104.));
766        assert_eq!(expanded, px(204.));
767        assert_eq!(
768            top,
769            vec![(px(24.), px(164.)), (px(12.), px(92.)), (px(0.), px(0.))]
770        );
771
772        let (_, _, bottom) = stack_geometry(&heights, px(12.), px(12.), true);
773        assert_eq!(
774            bottom,
775            vec![(px(40.), px(0.)), (px(32.), px(52.)), (px(24.), px(124.))]
776        );
777
778        let tall_behind = [px(180.), px(60.)];
779        let (collapsed, _, top) = stack_geometry(&tall_behind, px(14.), px(14.), false);
780        assert_eq!(collapsed, px(194.));
781        assert_eq!(top[0].0, px(14.));
782        assert!(top[0].0 + tall_behind[0] <= collapsed);
783        assert_eq!(top[0].0 - top[1].0, px(14.));
784
785        let (_, _, bottom) = stack_geometry(&tall_behind, px(14.), px(14.), true);
786        assert!(bottom[0].0 >= px(0.));
787        assert!(bottom[0].0 + tall_behind[0] <= collapsed);
788        assert_eq!(
789            (bottom[1].0 + tall_behind[1]) - (bottom[0].0 + tall_behind[0]),
790            px(14.)
791        );
792    }
793
794    #[gpui::test]
795    fn toast_exposes_alert_semantics(cx: &mut gpui::TestAppContext) {
796        let window = cx.add_empty_window();
797        window.update(|window, cx| {
798            let mut node = accesskit::Node::new(Role::Alert);
799            Toast::new("toast")
800                .render(window, cx)
801                .into_element()
802                .write_a11y_info(&mut node);
803            assert_eq!(node.role(), Role::Alert);
804        });
805    }
806
807    #[gpui::test]
808    fn stack_bootstraps_measurement_without_zero_height_clipping(cx: &mut gpui::TestAppContext) {
809        struct Harness {
810            state: ToastStackState,
811        }
812        impl gpui::Render for Harness {
813            fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
814                ToastStack::new("stack", self.state.clone())
815                    .w(px(300.))
816                    .item("first", div().h(px(80.)).child("Visible"))
817            }
818        }
819
820        let (view, cx) = cx.add_window_view(|_, _| Harness {
821            state: ToastStackState::default(),
822        });
823        cx.update(|window, cx| window.draw(cx).clear(cx));
824        view.read_with(cx, |view, _| {
825            assert_eq!(
826                view.state
827                    .heights
828                    .borrow()
829                    .get(&ElementId::from("first"))
830                    .copied(),
831                Some(px(80.))
832            );
833        });
834    }
835
836    #[gpui::test]
837    fn stack_expands_for_hover_and_focus(cx: &mut gpui::TestAppContext) {
838        struct Harness {
839            state: ToastStackState,
840            focus: FocusHandle,
841        }
842        impl gpui::Render for Harness {
843            fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
844                ToastStack::new("stack", self.state.clone())
845                    .focus_handle(self.focus.clone())
846                    .w(px(300.))
847                    .item("first", div().h(px(80.)))
848            }
849        }
850
851        let (view, cx) = cx.add_window_view(|_, cx| Harness {
852            state: ToastStackState::default(),
853            focus: cx.focus_handle().tab_stop(true),
854        });
855        cx.update(|window, cx| window.draw(cx).clear(cx));
856
857        cx.simulate_mouse_move(point(px(10.), px(10.)), None, gpui::Modifiers::default());
858        cx.update(|window, cx| window.draw(cx).clear(cx));
859        view.read_with(cx, |view, _| assert!(view.state.is_expanded()));
860
861        cx.simulate_mouse_move(point(px(400.), px(400.)), None, gpui::Modifiers::default());
862        cx.update(|window, cx| window.draw(cx).clear(cx));
863        view.read_with(cx, |view, _| assert!(!view.state.is_expanded()));
864
865        cx.simulate_mouse_move(point(px(10.), px(10.)), None, gpui::Modifiers::default());
866        cx.update(|window, cx| window.draw(cx).clear(cx));
867        view.read_with(cx, |view, _| assert!(view.state.is_expanded()));
868
869        cx.simulate_mouse_move(point(px(400.), px(400.)), None, gpui::Modifiers::default());
870        cx.update(|window, cx| window.draw(cx).clear(cx));
871        view.read_with(cx, |view, _| assert!(!view.state.is_expanded()));
872
873        let focus = view.read_with(cx, |view, _| view.focus.clone());
874        cx.update(|window, cx| {
875            focus.focus(window, cx);
876            window.draw(cx).clear(cx);
877        });
878        view.read_with(cx, |view, _| assert!(view.state.is_expanded()));
879    }
880
881    #[gpui::test]
882    fn keyed_stack_reflow_moves_from_the_current_visual_position(cx: &mut gpui::TestAppContext) {
883        struct Harness {
884            state: ToastStackState,
885            show_second: bool,
886        }
887        impl gpui::Render for Harness {
888            fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
889                ToastStack::new("stack", self.state.clone())
890                    .w(px(300.))
891                    .item(
892                        "first",
893                        div().debug_selector(|| "first-toast".into()).h(px(40.)),
894                    )
895                    .when(self.show_second, |stack| {
896                        stack.item(
897                            "second",
898                            div().debug_selector(|| "second-toast".into()).h(px(80.)),
899                        )
900                    })
901            }
902        }
903
904        let (view, cx) = cx.add_window_view(|_, _| Harness {
905            state: ToastStackState::default(),
906            show_second: false,
907        });
908        cx.update(|window, cx| {
909            window.draw(cx).clear(cx);
910            window.draw(cx).clear(cx);
911        });
912        let initial_y = cx.debug_bounds("first-toast").unwrap().origin.y;
913
914        view.update(cx, |view, cx| {
915            view.show_second = true;
916            cx.notify();
917        });
918        cx.run_until_parked();
919        let first_reflow_y = cx.debug_bounds("first-toast").unwrap().origin.y;
920        assert_eq!(first_reflow_y, initial_y);
921
922        cx.executor().advance_clock(Duration::from_millis(200));
923        cx.update(|window, cx| window.draw(cx).clear(cx));
924        let middle_y = cx.debug_bounds("first-toast").unwrap().origin.y;
925        assert!(middle_y > initial_y);
926        assert!(middle_y < initial_y + px(14.));
927
928        cx.executor().advance_clock(Duration::from_millis(200));
929        cx.update(|window, cx| window.draw(cx).clear(cx));
930        assert_eq!(
931            cx.debug_bounds("first-toast").unwrap().origin.y,
932            initial_y + px(14.)
933        );
934        assert!(
935            cx.debug_bounds("first-toast").unwrap().size.width
936                < cx.debug_bounds("second-toast").unwrap().size.width
937        );
938    }
939
940    #[gpui::test]
941    fn bottom_stack_reflow_moves_up_without_an_opposite_direction_jump(
942        cx: &mut gpui::TestAppContext,
943    ) {
944        struct Harness {
945            state: ToastStackState,
946            show_second: bool,
947        }
948        impl gpui::Render for Harness {
949            fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
950                div().relative().size(px(300.)).child(
951                    ToastStack::new("bottom-stack", self.state.clone())
952                        .placement(Anchor::BottomRight)
953                        .absolute()
954                        .bottom_0()
955                        .w(px(300.))
956                        .item(
957                            "bottom-first",
958                            div()
959                                .debug_selector(|| "bottom-first-toast".into())
960                                .h(px(40.)),
961                        )
962                        .when(self.show_second, |stack| {
963                            stack.item("bottom-second", div().h(px(80.)))
964                        }),
965                )
966            }
967        }
968
969        let (view, cx) = cx.add_window_view(|_, _| Harness {
970            state: ToastStackState::default(),
971            show_second: false,
972        });
973        cx.update(|window, cx| {
974            window.draw(cx).clear(cx);
975            window.draw(cx).clear(cx);
976        });
977        // A bottom-anchored item's position is composed from two springs — the
978        // stack height and the item's own offset — and the stack height only
979        // acquires its real target once the toast has been measured in prepaint.
980        // Both the baseline and the final reading are taken settled, so neither
981        // carries the fraction of a pixel that separates them mid-flight.
982        cx.executor().advance_clock(Duration::from_secs(1));
983        cx.update(|window, cx| window.draw(cx).clear(cx));
984        let initial_y = cx.debug_bounds("bottom-first-toast").unwrap().origin.y;
985
986        view.update(cx, |view, cx| {
987            view.show_second = true;
988            cx.notify();
989        });
990        cx.run_until_parked();
991        assert_eq!(
992            cx.debug_bounds("bottom-first-toast").unwrap().origin.y,
993            initial_y
994        );
995
996        cx.executor().advance_clock(Duration::from_millis(200));
997        cx.update(|window, cx| window.draw(cx).clear(cx));
998        let middle_y = cx.debug_bounds("bottom-first-toast").unwrap().origin.y;
999        assert!(middle_y < initial_y);
1000        assert!(
1001            middle_y >= initial_y - px(14.),
1002            "middle={middle_y:?}, initial={initial_y:?}"
1003        );
1004
1005        cx.executor().advance_clock(Duration::from_secs(1));
1006        cx.update(|window, cx| window.draw(cx).clear(cx));
1007        assert_eq!(
1008            cx.debug_bounds("bottom-first-toast").unwrap().origin.y,
1009            initial_y - px(14.)
1010        );
1011    }
1012}