Skip to main content

gpui_base/
toast.rs

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