Skip to main content

herogpui_components/
toast.rs

1//! Toast — port of `@heroui/toast` (v3).
2//!
3//! Create the store once (`toast_store(cx)`), render [`ToastViewport`] — the
4//! equivalent of `Toast.Provider` — from your root view, and fire toasts
5//! anywhere with [`Toast::push`].
6//!
7//! v3 names the colour prop `variant`, and its values are the semantic colour
8//! roles, so [`Color`] is the variant type here.
9
10use std::cell::RefCell;
11use std::collections::{HashMap, HashSet};
12use std::future::Future;
13use std::rc::Rc;
14use std::time::Duration;
15
16use gpui::{
17    prelude::*, px, AnimationExt, AnyElement, App, ElementId, Entity, Global, IntoElement,
18    Keystroke, Pixels, RenderOnce, SharedString, Styled, Subscription, Window,
19};
20use herogpui_core::{element_id, Color};
21use herogpui_theme::ActiveTheme;
22
23use crate::a11y::{self, A11y as _};
24use crate::icons;
25
26/// `maxVisibleToasts` default from `Toast.Provider`.
27pub const DEFAULT_MAX_VISIBLE_TOASTS: usize = 3;
28
29/// `exitDuration` default from `Toast.Provider` / the pinned queue (300ms).
30/// The stylesheet's `--toast-exit-duration` is 250ms; the queue keeps the card
31/// mounted slightly longer so the fade can finish.
32pub const DEFAULT_TOAST_EXIT_DURATION: Duration = Duration::from_millis(300);
33
34/// Pinned `DEFAULT_HOTKEY`: Alt+T, with no extra modifiers.
35pub const DEFAULT_TOAST_HOTKEY: ToastHotkey = ToastHotkey::ALT_T;
36
37/// `hotkey` on `Toast.Provider` — a modifier set plus one key.
38///
39/// HeroUI matches `KeyboardEvent` modifier booleans exactly and the remaining
40/// entries against `event.code`. An empty key disables the shortcut.
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct ToastHotkey {
43    /// Whether Alt must be held.
44    pub alt: bool,
45    /// Whether Shift must be held.
46    pub shift: bool,
47    /// Whether Control must be held.
48    pub control: bool,
49    /// Whether the platform key (Command or Windows key) must be held.
50    pub platform: bool,
51    /// The key to match; empty disables the shortcut.
52    pub key: SharedString,
53}
54
55impl ToastHotkey {
56    /// Alt+T, the pinned default.
57    pub const ALT_T: Self = Self {
58        alt: true,
59        shift: false,
60        control: false,
61        platform: false,
62        key: SharedString::new_static("t"),
63    };
64
65    /// `hotkey={[]}` — the document listener is installed but never matches.
66    pub fn disabled() -> Self {
67        Self {
68            alt: false,
69            shift: false,
70            control: false,
71            platform: false,
72            key: SharedString::default(),
73        }
74    }
75
76    fn matches(&self, keystroke: &Keystroke) -> bool {
77        if self.key.is_empty() {
78            return false;
79        }
80        keystroke.modifiers.alt == self.alt
81            && keystroke.modifiers.shift == self.shift
82            && keystroke.modifiers.control == self.control
83            && keystroke.modifiers.platform == self.platform
84            && keystroke.key.eq_ignore_ascii_case(self.key.as_ref())
85    }
86}
87
88/// Where the toast region sits (`placement` on `Toast.Provider`).
89#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
90pub enum ToastPlacement {
91    /// Top-left of the window.
92    TopStart,
93    /// Top centre of the window.
94    Top,
95    /// Top-right of the window.
96    TopEnd,
97    /// Bottom-left of the window.
98    BottomStart,
99    /// Bottom centre of the window (the default).
100    #[default]
101    Bottom,
102    /// Bottom-right of the window.
103    BottomEnd,
104}
105
106impl ToastPlacement {
107    /// Every placement, in declaration order.
108    pub const ALL: [ToastPlacement; 6] = [
109        ToastPlacement::TopStart,
110        ToastPlacement::Top,
111        ToastPlacement::TopEnd,
112        ToastPlacement::BottomStart,
113        ToastPlacement::Bottom,
114        ToastPlacement::BottomEnd,
115    ];
116
117    fn is_top(self) -> bool {
118        matches!(self, Self::TopStart | Self::Top | Self::TopEnd)
119    }
120
121    /// The placement's display name.
122    pub fn label(self) -> &'static str {
123        match self {
124            ToastPlacement::TopStart => "Top start",
125            ToastPlacement::Top => "Top",
126            ToastPlacement::TopEnd => "Top end",
127            ToastPlacement::BottomStart => "Bottom start",
128            ToastPlacement::Bottom => "Bottom",
129            ToastPlacement::BottomEnd => "Bottom end",
130        }
131    }
132}
133
134/// What a toast's action button, or its `onClose`, runs.
135pub type ToastHandler = std::sync::Arc<dyn Fn(&mut App) + 'static>;
136
137/// Toast's placement translation and opacity use separate CSS timelines in
138/// HeroUI. Keep the card itself stable and put the opacity animation inside a
139/// small wrapper so a 150ms fade does not shorten the 350ms edge translation.
140fn toast_entering<E>(
141    el: E,
142    id: impl Into<ElementId>,
143    edge: crate::anim::Edge,
144    travel: Pixels,
145    motion: crate::anim::Motion,
146    cx: &App,
147) -> AnyElement
148where
149    E: IntoElement + Styled + 'static,
150{
151    if ActiveTheme::reduce_motion(cx) {
152        return el.into_any_element();
153    }
154    let id = id.into();
155    let opacity = el.with_animation(
156        element_id::scoped(&id, "opacity"),
157        gpui::Animation::new(Duration::from_millis(crate::anim::TOAST_OPACITY_MS))
158            .with_easing(move |t| motion.curve.at(t)),
159        |el, delta| el.opacity(delta),
160    );
161    gpui::div()
162        .child(opacity)
163        .with_animation(
164            element_id::scoped(&id, "slide"),
165            gpui::Animation::new(Duration::from_millis(motion.ms))
166                .with_easing(move |t| motion.curve.at(t)),
167            move |el, delta| {
168                let remaining = travel * (1.0 - delta);
169                match edge {
170                    crate::anim::Edge::Left => el.ml(-remaining),
171                    crate::anim::Edge::Right => el.mr(-remaining),
172                    crate::anim::Edge::Top => el.mt(-remaining),
173                    crate::anim::Edge::Bottom => el.mb(-remaining),
174                }
175            },
176        )
177        .into_any_element()
178}
179
180/// Frontmost Toast exit: the card fades on the 150ms opacity track while its
181/// placement translation continues for the full 350ms motion.
182fn toast_exiting<E>(
183    el: E,
184    id: impl Into<ElementId>,
185    edge: crate::anim::Edge,
186    travel: Pixels,
187    motion: crate::anim::Motion,
188    cx: &App,
189) -> AnyElement
190where
191    E: IntoElement + Styled + 'static,
192{
193    if ActiveTheme::reduce_motion(cx) {
194        return el.into_any_element();
195    }
196    let id = id.into();
197    let opacity = el.with_animation(
198        element_id::scoped(&id, "opacity"),
199        gpui::Animation::new(Duration::from_millis(crate::anim::TOAST_OPACITY_MS))
200            .with_easing(move |t| motion.curve.at(t)),
201        |el, delta| el.opacity(1.0 - delta),
202    );
203    gpui::div()
204        .child(opacity)
205        .with_animation(
206            element_id::scoped(&id, "slide"),
207            gpui::Animation::new(Duration::from_millis(motion.ms))
208                .with_easing(move |t| motion.curve.at(t)),
209            move |el, delta| {
210                let gone = travel * delta;
211                match edge {
212                    crate::anim::Edge::Left => el.ml(-gone),
213                    crate::anim::Edge::Right => el.mr(-gone),
214                    crate::anim::Edge::Top => el.mt(-gone),
215                    crate::anim::Edge::Bottom => el.mb(-gone),
216                }
217            },
218        )
219        .into_any_element()
220}
221
222/// Caller-owned content for `Toast.Indicator`.
223///
224/// Toasts are queued and cloned while they remain mounted, so the content is
225/// represented as a small render factory rather than a one-shot `AnyElement`.
226/// The factory receives the live app context and must return a fresh element
227/// for each render; the surrounding indicator box still owns the pinned 4px
228/// inset and 16px content footprint.
229pub type ToastIndicatorContent = Rc<dyn Fn(&mut App) -> AnyElement + 'static>;
230
231/// One toast's data.
232#[derive(Clone)]
233pub struct ToastData {
234    /// Unique id assigned by the store.
235    pub id: u64,
236    /// The toast's colour variant.
237    pub color: Color,
238    /// The title text.
239    pub title: SharedString,
240    /// The optional description text.
241    pub description: Option<SharedString>,
242    /// Whether the toast shows a close button.
243    pub closable: bool,
244    /// `indicator` — the glyph before the text.
245    ///
246    /// Two states in one prop, as in v3: left alone it is the variant's own
247    /// glyph, and `indicator={null}` hides it. `indicator_set` is which of the
248    /// two an empty `indicator` means.
249    pub indicator: Option<SharedString>,
250    /// Optional caller-owned indicator render factory. This takes precedence
251    /// over the path/default glyph while preserving the shared indicator box.
252    pub indicator_content: Option<ToastIndicatorContent>,
253    /// Whether `indicator` was set explicitly, so that an empty `indicator` means hidden rather than default.
254    pub indicator_set: bool,
255    /// `isLoading` — a spinner stands in for the indicator.
256    pub is_loading: bool,
257    /// `actionProps` — a button inside the toast: its label and its handler.
258    pub action: Option<(SharedString, ToastHandler)>,
259    /// `onClose` — run when the toast goes away, however it goes.
260    pub on_close: Option<ToastHandler>,
261    /// The fill the close button takes on hover, in place of `--default`.
262    pub close_hover_bg: Option<gpui::Hsla>,
263    /// Both card padding axes; unset keeps v3's `px-4 py-3` (16px/12px)
264    /// insets.
265    pub padding: Option<Pixels>,
266    /// The card's corner radius; unset keeps `container_radius`. The card's
267    /// entry zoom interpolates the same value.
268    pub radius: Option<Pixels>,
269}
270
271/// Entity holding the active toasts — v3's `ToastQueue`.
272pub struct ToastStore {
273    toasts: Vec<ToastData>,
274    next_id: u64,
275    /// `pauseAll` / `resumeAll`. Every toast's timer reads this on each tick,
276    /// which is why the timer ticks rather than sleeping once.
277    paused: bool,
278    /// Hover or focus-within on the region. Pinned HeroUI suspends timers on
279    /// interaction and documents that a forced `isExpanded` does not.
280    interaction_paused: bool,
281    /// Timeout configured for each queued toast.
282    timeouts: HashMap<u64, Duration>,
283    /// Generation for each currently armed toast, so a stale task cannot close
284    /// a later toast if ids are ever reused.
285    timer_generations: HashMap<u64, u64>,
286    next_timer_generation: u64,
287    /// Ids whose `onClose` has already been run. `dismiss_toast` and a
288    /// toast's own timer both report the close; whoever claims the id first is
289    /// the only one that does.
290    reported: HashSet<u64>,
291    /// Dismissed cards still mounted for [`Self::exit_duration`].
292    exiting: Vec<ToastData>,
293    exit_generations: HashMap<u64, u64>,
294    exit_frontmost: HashSet<u64>,
295    next_exit_generation: u64,
296    /// `exitDuration` on `Toast.Provider`, synced from the viewport.
297    exit_duration: Duration,
298    /// `hotkey` on `Toast.Provider`, synced from the viewport.
299    hotkey: ToastHotkey,
300    /// Last stack `data-expanded` the viewport painted, so tests can read it.
301    viewport_expanded: bool,
302}
303
304impl ToastStore {
305    fn new() -> Self {
306        Self {
307            toasts: Vec::new(),
308            next_id: 1,
309            paused: false,
310            interaction_paused: false,
311            timeouts: HashMap::new(),
312            timer_generations: HashMap::new(),
313            next_timer_generation: 1,
314            reported: HashSet::new(),
315            exiting: Vec::new(),
316            exit_generations: HashMap::new(),
317            exit_frontmost: HashSet::new(),
318            next_exit_generation: 1,
319            exit_duration: DEFAULT_TOAST_EXIT_DURATION,
320            hotkey: DEFAULT_TOAST_HOTKEY,
321            viewport_expanded: false,
322        }
323    }
324
325    /// `ToastQueue.subscribe` — run `f` whenever the queue changes.
326    ///
327    /// v3 returns an unsubscribe function; gpui returns a `Subscription` whose
328    /// drop does the same, so the caller keeps it for as long as it wants the
329    /// callback.
330    pub fn subscribe(
331        store: &Entity<Self>,
332        cx: &mut App,
333        mut f: impl FnMut(&mut App) + 'static,
334    ) -> Subscription {
335        cx.observe(store, move |_, cx| f(cx))
336    }
337
338    /// The active toasts, newest first.
339    pub fn toasts(&self) -> &[ToastData] {
340        &self.toasts
341    }
342
343    /// The newest toasts currently exposed by `maxVisibleToasts`; overflow
344    /// remains queued until a visible toast closes.
345    pub fn visible_toasts(&self, max_visible: usize) -> &[ToastData] {
346        &self.toasts[..self.toasts.len().min(max_visible.max(1))]
347    }
348
349    /// `pauseAll` — stop every toast's dismissal clock.
350    pub fn pause_all(&mut self) {
351        self.paused = true;
352    }
353
354    /// `resumeAll` — start them again.
355    pub fn resume_all(&mut self) {
356        self.paused = false;
357    }
358
359    /// Whether all timers were paused explicitly (`pauseAll`).
360    pub fn is_paused(&self) -> bool {
361        self.paused
362    }
363
364    /// True while the region is hovered or focus-within, or `pauseAll` is on.
365    pub fn timers_paused(&self) -> bool {
366        self.paused || self.interaction_paused
367    }
368
369    /// Whether timers are paused by hover or focus within the region.
370    pub fn is_interaction_paused(&self) -> bool {
371        self.interaction_paused
372    }
373
374    /// Cards still mounted after `close` for `exitDuration`.
375    pub fn exiting(&self) -> &[ToastData] {
376        &self.exiting
377    }
378
379    /// The last `data-expanded` the viewport painted.
380    pub fn is_expanded(&self) -> bool {
381        self.viewport_expanded
382    }
383
384    /// Overflow past `maxVisibleToasts` — still queued, marked `data-hidden`.
385    pub fn hidden_toasts(&self, max_visible: usize) -> &[ToastData] {
386        let start = self.toasts.len().min(max_visible.max(1));
387        &self.toasts[start..]
388    }
389
390    /// `add` — queue a toast, notify subscribers, and return its id.
391    pub fn add(store: &Entity<Self>, data: ToastData, cx: &mut App) -> u64 {
392        store.update(cx, |store, cx| {
393            let id = store.insert(data);
394            cx.notify();
395            id
396        })
397    }
398
399    /// `close` — drop one toast by id and report its `onClose` once.
400    ///
401    /// The card leaves [`Self::toasts`] immediately. Unless reduced motion is
402    /// on or `exitDuration` is zero, it stays in [`Self::exiting`] for the
403    /// configured fade so `[data-exiting]` can paint. `onClose` still fires
404    /// at the start of that exit, matching the pinned queue.
405    pub fn close(store: &Entity<Self>, id: u64, cx: &mut App) {
406        let skip_exit = ActiveTheme::reduce_motion(cx);
407        let (on_close, exit) = store.update(cx, |store, cx| {
408            let callback = if store.claim_close(id) {
409                store.on_close(id)
410            } else {
411                None
412            };
413            let was_frontmost = store.toasts.first().is_some_and(|toast| toast.id == id);
414            let data = store.toasts.iter().find(|toast| toast.id == id).cloned();
415            store.dismiss(id);
416            let exit = if skip_exit || store.exit_duration.is_zero() {
417                None
418            } else {
419                data.and_then(|toast| store.begin_exit(toast, was_frontmost))
420            };
421            cx.notify();
422            (callback, exit)
423        });
424        if let Some(callback) = on_close {
425            callback(cx);
426        }
427        if let Some((id, generation, duration)) = exit {
428            start_exit_timer(store.downgrade(), id, generation, duration, cx);
429        }
430    }
431
432    /// `clear` — drop all of them and notify subscribers.
433    pub fn clear(store: &Entity<Self>, cx: &mut App) {
434        store.update(cx, |store, cx| {
435            store.clear_inner();
436            cx.notify();
437        });
438    }
439
440    fn clear_inner(&mut self) {
441        // React Stately's `ToastQueue.clear()` does not call each toast's
442        // `onClose`. Retire every id before its sleeping timer wakes, or the
443        // timer's missing-row path would claim and report that close later.
444        self.reported
445            .extend(self.toasts.iter().map(|toast| toast.id));
446        self.toasts.clear();
447        self.timeouts.clear();
448        self.timer_generations.clear();
449        self.exiting.clear();
450        self.exit_generations.clear();
451        self.exit_frontmost.clear();
452    }
453
454    /// The `onClose` of the toast with this id, so a caller closing a toast runs
455    /// the same handler the timer would have.
456    fn on_close(&self, id: u64) -> Option<ToastHandler> {
457        self.toasts
458            .iter()
459            .find(|t| t.id == id)
460            .and_then(|t| t.on_close.clone())
461    }
462
463    /// Claims the right to report a toast's close: `true` when this call is
464    /// the first to do so. Both `dismiss_toast` and the toast's own timer run
465    /// its `onClose`; the path that gets here second sees `false` and stays
466    /// silent, which is what stops a hand-dismissed timed toast from closing
467    /// twice — the timer wakes to find the toast already gone and would
468    /// otherwise fire the same handler again.
469    fn claim_close(&mut self, id: u64) -> bool {
470        self.reported.insert(id)
471    }
472
473    fn dismiss(&mut self, id: u64) {
474        self.toasts.retain(|t| t.id != id);
475        self.timeouts.remove(&id);
476        self.timer_generations.remove(&id);
477    }
478
479    fn begin_exit(
480        &mut self,
481        toast: ToastData,
482        was_frontmost: bool,
483    ) -> Option<(u64, u64, Duration)> {
484        let id = toast.id;
485        self.exiting.retain(|existing| existing.id != id);
486        self.exiting.push(toast);
487        if was_frontmost {
488            self.exit_frontmost.insert(id);
489        } else {
490            self.exit_frontmost.remove(&id);
491        }
492        let generation = self.next_exit_generation;
493        self.next_exit_generation = self.next_exit_generation.saturating_add(1);
494        self.exit_generations.insert(id, generation);
495        Some((id, generation, self.exit_duration))
496    }
497
498    fn arm_timeout(&mut self, id: u64, timeout: Duration) -> u64 {
499        self.timeouts.insert(id, timeout);
500        let generation = self.next_timer_generation;
501        self.next_timer_generation = self.next_timer_generation.saturating_add(1);
502        self.timer_generations.insert(id, generation);
503        generation
504    }
505
506    /// Inserts a fully-formed toast; zero ids are auto-assigned.
507    pub fn insert(&mut self, mut data: ToastData) -> u64 {
508        if data.id == 0 {
509            data.id = self.next_id;
510            self.next_id += 1;
511        } else if data.id >= self.next_id {
512            self.next_id = data.id.saturating_add(1);
513        }
514        let id = data.id;
515        // Explicit ids may be reused by a custom queue. A newly inserted
516        // toast owns a fresh close lifecycle even when an older toast with the
517        // same id was dismissed or cleared.
518        self.reported.remove(&id);
519        self.toasts.insert(0, data);
520        id
521    }
522}
523
524struct ToastHub {
525    store: Entity<ToastStore>,
526}
527impl Global for ToastHub {}
528
529/// Creates (or returns) the app-wide toast store.
530pub fn toast_store(cx: &mut App) -> Entity<ToastStore> {
531    if let Some(hub) = cx.try_global::<ToastHub>() {
532        return hub.store.clone();
533    }
534    let store = cx.new(|_| ToastStore::new());
535    cx.set_global(ToastHub {
536        store: store.clone(),
537    });
538    store
539}
540
541/// v3's default toast timeout: four seconds, and `timeout: 0` for one that
542/// stays until it is closed.
543pub const DEFAULT_TOAST_TIMEOUT: Duration = Duration::from_secs(4);
544
545/// Builder for a toast notification.
546#[must_use = "a Toast is shown only once it is pushed (`push`, `update`)"]
547pub struct Toast {
548    color: Color,
549    title: SharedString,
550    description: Option<SharedString>,
551    closable: bool,
552    indicator: Option<SharedString>,
553    indicator_content: Option<ToastIndicatorContent>,
554    indicator_set: bool,
555    is_loading: bool,
556    action: Option<(SharedString, ToastHandler)>,
557    on_close: Option<ToastHandler>,
558    timeout: Option<Duration>,
559    /// `true` after [`Toast::timeout`], so [`Toast::update`] can inherit a
560    /// running countdown when the caller omits it.
561    timeout_set: bool,
562    /// Set by [`Toast::close_hover_bg`]: the close button's hover fill.
563    close_hover_bg: Option<gpui::Hsla>,
564    /// Set by [`Toast::padding`].
565    padding: Option<Pixels>,
566    /// Set by [`Toast::radius`].
567    radius: Option<Pixels>,
568}
569
570impl Toast {
571    /// Creates a toast with the given title.
572    pub fn new(title: impl Into<SharedString>) -> Self {
573        Self {
574            color: Color::Default,
575            title: title.into(),
576            description: None,
577            closable: true,
578            indicator: None,
579            indicator_content: None,
580            indicator_set: false,
581            is_loading: false,
582            action: None,
583            on_close: None,
584            timeout: Some(DEFAULT_TOAST_TIMEOUT),
585            timeout_set: false,
586            close_hover_bg: None,
587            padding: None,
588            radius: None,
589        }
590    }
591
592    /// `toast.success(..)` — the same toast in the success variant.
593    pub fn success(title: impl Into<SharedString>) -> Self {
594        Self::new(title).variant(Color::Success)
595    }
596
597    /// `toast.danger(..)`, and the `error` message of `toast.promise`.
598    pub fn error(title: impl Into<SharedString>) -> Self {
599        Self::new(title).variant(Color::Danger)
600    }
601
602    /// The `loading` message of `toast.promise`: a spinner, and no timeout, so
603    /// the caller closes it when the work finishes.
604    pub fn loading(title: impl Into<SharedString>) -> Self {
605        Self::new(title).is_loading(true).timeout(Duration::ZERO)
606    }
607
608    /// Sets the description text.
609    pub fn description(mut self, d: impl Into<SharedString>) -> Self {
610        self.description = Some(d.into());
611        self
612    }
613
614    /// `indicator` — the glyph before the text.
615    ///
616    /// Left alone it is the variant's own; `None` hides it, which is v3's
617    /// `indicator={null}`.
618    pub fn indicator(mut self, icon: impl Into<Option<SharedString>>) -> Self {
619        self.indicator = icon.into();
620        self.indicator_content = None;
621        self.indicator_set = true;
622        self
623    }
624
625    /// Replaces the variant glyph with caller-owned content while retaining
626    /// HeroUI's shared indicator box. The closure runs for each mounted-card
627    /// render, which keeps queued and exiting toasts cloneable and lets the
628    /// content read the live theme through `App`.
629    pub fn indicator_content<E, F>(mut self, render: F) -> Self
630    where
631        E: IntoElement + 'static,
632        F: Fn(&mut App) -> E + 'static,
633    {
634        self.indicator_content = Some(Rc::new(move |cx| render(cx).into_any_element()));
635        self.indicator_set = true;
636        self
637    }
638
639    /// `isLoading` — a spinner in place of the indicator. Pair it with a zero
640    /// `timeout` for a toast that waits on something.
641    pub fn is_loading(mut self, v: bool) -> Self {
642        self.is_loading = v;
643        self
644    }
645
646    /// `actionProps` — a button in the toast. v3 passes `{children, onPress}`;
647    /// here that is the label and the handler.
648    pub fn action(
649        mut self,
650        label: impl Into<SharedString>,
651        on_press: impl Fn(&mut App) + 'static,
652    ) -> Self {
653        self.action = Some((label.into(), std::sync::Arc::new(on_press)));
654        self
655    }
656
657    /// `onClose` — run when the toast goes away, whether it timed out or was
658    /// dismissed.
659    pub fn on_close(mut self, f: impl Fn(&mut App) + 'static) -> Self {
660        self.on_close = Some(std::sync::Arc::new(f));
661        self
662    }
663
664    /// `timeout` — how long the toast stays. `Duration::ZERO` is v3's
665    /// `timeout: 0`: it stays until something closes it.
666    pub fn timeout(mut self, timeout: Duration) -> Self {
667        self.timeout = Some(timeout);
668        self.timeout_set = true;
669        self
670    }
671
672    /// `variant` — `default | accent | success | warning | danger`.
673    pub fn variant(mut self, variant: Color) -> Self {
674        self.color = variant;
675        self
676    }
677
678    /// Sets whether the toast shows a close button.
679    pub fn closable(mut self, v: bool) -> Self {
680        self.closable = v;
681        self
682    }
683
684    /// The fill the close button takes on hover, in place of `--default`.
685    pub fn close_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
686        self.close_hover_bg = Some(color.into());
687        self
688    }
689
690    /// Sets both card padding axes; unset keeps v3's `px-4 py-3` (16px/12px)
691    /// insets.
692    pub fn padding(mut self, padding: impl Into<Pixels>) -> Self {
693        self.padding = Some(padding.into());
694        self
695    }
696
697    /// The card's corner radius, in place of the owning `container_radius`
698    /// helper. The card's entry zoom interpolates the same value, so both
699    /// follow the override. Not a v3 prop; the removed v2 `radius` prop is
700    /// prohibited and this is a per-component repository extension.
701    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
702        self.radius = Some(radius.into());
703        self
704    }
705
706    /// Pushes the toast and starts its clock unless the timeout is zero.
707    ///
708    /// `duration` overrides [`Self::timeout`], which is how the caller that
709    /// spells the timeout at the push site keeps working.
710    pub fn push(self, duration: Option<Duration>, cx: &mut App) -> u64 {
711        // A zero timeout is v3's persistent toast, and so is `Some(ZERO)` from
712        // the builder: either way there is no clock to arm.
713        let timeout = duration.or(self.timeout).unwrap_or(Duration::ZERO);
714        let store = toast_store(cx);
715        let (id, generation) = store.update(cx, |s, cx| {
716            let id = s.next_id;
717            s.next_id += 1;
718            let pushed = s.insert(ToastData {
719                id,
720                color: self.color,
721                title: self.title.clone(),
722                description: self.description.clone(),
723                closable: self.closable,
724                indicator: self.indicator.clone(),
725                indicator_content: self.indicator_content.clone(),
726                indicator_set: self.indicator_set,
727                is_loading: self.is_loading,
728                action: self.action.clone(),
729                on_close: self.on_close.clone(),
730                close_hover_bg: self.close_hover_bg,
731                padding: self.padding,
732                radius: self.radius,
733            });
734            let generation = if timeout.is_zero() {
735                None
736            } else {
737                Some(s.arm_timeout(id, timeout))
738            };
739            cx.notify();
740            (pushed, generation)
741        });
742        if let Some(generation) = generation {
743            start_toast_timer(store.downgrade(), id, timeout, generation, cx);
744        }
745        id
746    }
747
748    /// `toast.update(id, title, options)` — replace content in place.
749    ///
750    /// The card keeps its key and stack position. Title, description, variant,
751    /// indicator, loading, action and chrome come from this builder. `timeout`
752    /// and `onClose` are inherited unless this builder set them; a missing id
753    /// falls back to a new toast, the way HeroUI does.
754    pub fn update(self, id: u64, cx: &mut App) -> u64 {
755        let store = toast_store(cx);
756        let timeout = self
757            .timeout_set
758            .then_some(self.timeout.unwrap_or(Duration::ZERO));
759        let on_close_set = self.on_close.is_some();
760        let replaced = store.update(cx, |s, cx| {
761            let existing = s.toasts.iter_mut().find(|toast| toast.id == id)?;
762            existing.color = self.color;
763            existing.title = self.title.clone();
764            existing.description = self.description.clone();
765            existing.closable = self.closable;
766            existing.indicator = self.indicator.clone();
767            existing.indicator_content = self.indicator_content.clone();
768            existing.indicator_set = self.indicator_set;
769            existing.is_loading = self.is_loading;
770            existing.action = self.action.clone();
771            existing.close_hover_bg = self.close_hover_bg;
772            existing.padding = self.padding;
773            existing.radius = self.radius;
774            if on_close_set {
775                existing.on_close = self.on_close.clone();
776            }
777            let generation = match timeout {
778                None => None,
779                Some(duration) if duration.is_zero() => {
780                    s.timeouts.remove(&id);
781                    s.timer_generations.remove(&id);
782                    None
783                }
784                Some(duration) => Some((duration, s.arm_timeout(id, duration))),
785            };
786            cx.notify();
787            Some(generation)
788        });
789        match replaced {
790            Some(Some((duration, generation))) => {
791                start_toast_timer(store.downgrade(), id, duration, generation, cx);
792                id
793            }
794            Some(None) => id,
795            None => self.push(None, cx),
796        }
797    }
798
799    /// `toast.promise(promise, { loading, success, error })`.
800    ///
801    /// Pushes a loading toast (`isLoading`, `timeout: 0`) and, when `future`
802    /// settles, updates that same card in place. `Ok` is the success message
803    /// and variant; `Err` is the danger / `error` message. The default dismiss
804    /// clock starts at settle, matching the pinned queue.
805    pub fn promise(
806        future: impl Future<Output = Result<SharedString, SharedString>> + 'static,
807        loading: impl Into<SharedString>,
808        cx: &mut App,
809    ) -> u64 {
810        let (id, task) = Self::promise_task(future, loading, cx);
811        task.detach();
812        id
813    }
814
815    /// A GPUI-owned version of [`Self::promise`], returning its completion task.
816    /// Keep the task alive, or detach it, to publish the settled result. Dropping
817    /// it cancels pending work but leaves the loading toast for the owner to
818    /// dismiss or clear. A result already published is unaffected.
819    pub fn promise_task(
820        future: impl Future<Output = Result<SharedString, SharedString>> + 'static,
821        loading: impl Into<SharedString>,
822        cx: &mut App,
823    ) -> (u64, gpui::Task<()>) {
824        let id = Self::loading(loading).push(None, cx);
825        let task = cx.spawn(async move |cx: &mut gpui::AsyncApp| {
826            let result = future.await;
827            cx.update(|cx| match result {
828                Ok(title) => {
829                    Self::success(title)
830                        .timeout(DEFAULT_TOAST_TIMEOUT)
831                        .update(id, cx);
832                }
833                Err(title) => {
834                    Self::error(title)
835                        .timeout(DEFAULT_TOAST_TIMEOUT)
836                        .update(id, cx);
837                }
838            });
839        });
840        (id, task)
841    }
842}
843
844enum ToastTimerTick {
845    Continue,
846    Closed(Option<ToastHandler>),
847    Expired,
848}
849
850fn start_exit_timer(
851    store: gpui::WeakEntity<ToastStore>,
852    id: u64,
853    generation: u64,
854    duration: Duration,
855    cx: &mut App,
856) {
857    cx.spawn(async move |cx: &mut gpui::AsyncApp| {
858        cx.background_executor().timer(duration).await;
859        let Some(store) = store.upgrade() else { return };
860        store.update(cx, |s, cx| {
861            if s.exit_generations.get(&id) != Some(&generation) {
862                return;
863            }
864            s.exiting.retain(|toast| toast.id != id);
865            s.exit_generations.remove(&id);
866            s.exit_frontmost.remove(&id);
867            cx.notify();
868        });
869    })
870    .detach();
871}
872
873fn start_toast_timer(
874    store: gpui::WeakEntity<ToastStore>,
875    id: u64,
876    timeout: Duration,
877    generation: u64,
878    cx: &mut App,
879) {
880    cx.spawn(async move |cx: &mut gpui::AsyncApp| {
881        // Tick instead of sleeping once: `pauseAll` has to be able to stop the
882        // clock, and a gpui timer cannot be cancelled.
883        const TICK: Duration = Duration::from_millis(100);
884        let mut left = timeout;
885        loop {
886            cx.background_executor().timer(TICK).await;
887            let Some(store) = store.upgrade() else { return };
888            let tick = store.update(cx, |s, _cx| {
889                if !s.toasts.iter().any(|toast| toast.id == id) {
890                    s.timer_generations.remove(&id);
891                    s.timeouts.remove(&id);
892                    return ToastTimerTick::Closed(None);
893                }
894                if s.timer_generations.get(&id) != Some(&generation) {
895                    return ToastTimerTick::Closed(None);
896                }
897                if s.timers_paused() {
898                    return ToastTimerTick::Continue;
899                }
900                left = left.saturating_sub(TICK);
901                if left.is_zero() {
902                    return ToastTimerTick::Expired;
903                }
904                ToastTimerTick::Continue
905            });
906            match tick {
907                ToastTimerTick::Continue => {}
908                ToastTimerTick::Closed(callback) => {
909                    if let Some(cb) = callback {
910                        cx.update(|cx| cb(cx));
911                    }
912                    return;
913                }
914                ToastTimerTick::Expired => {
915                    cx.update(|cx| ToastStore::close(&store, id, cx));
916                    return;
917                }
918            }
919        }
920    })
921    .detach();
922}
923
924/// Convenience free function (manual dismissal).
925pub fn push_toast(toast: Toast, cx: &mut App) -> u64 {
926    toast.timeout(Duration::ZERO).push(None, cx)
927}
928
929/// Dismisses one toast by id, running its `onClose`.
930pub fn dismiss_toast(id: u64, cx: &mut App) {
931    let store = toast_store(cx);
932    ToastStore::close(&store, id, cx);
933}
934
935/// `toast.clear()` — closes every toast.
936pub fn clear_toasts(cx: &mut App) {
937    let store = toast_store(cx);
938    ToastStore::clear(&store, cx);
939}
940
941/// `toast.pauseAll()` / `toast.resumeAll()` — stops and restarts every clock.
942pub fn pause_toasts(paused: bool, cx: &mut App) {
943    let store = toast_store(cx);
944    store.update(cx, |s, cx| {
945        if paused {
946            s.pause_all();
947        } else {
948            s.resume_all();
949        }
950        cx.notify();
951    });
952}
953
954/// The toast region — `Toast.Provider` in React. Mount once near the root; it
955/// reads the store on every root re-render (store mutations notify it via the
956/// parent view's `cx.notify()`).
957#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
958#[derive(IntoElement)]
959pub struct ToastViewport {
960    placement: ToastPlacement,
961    gap: Pixels,
962    max_visible_toasts: usize,
963    width: Pixels,
964    inset: Pixels,
965    scale_factor: f32,
966    /// `isExpanded` — force the stack open. Hover/focus still expand when
967    /// this is false; a single toast never expands.
968    is_expanded: bool,
969    /// `exitDuration` — how long a dismissed card stays mounted.
970    exit_duration: Duration,
971    /// `hotkey` — focuses the region so the stack expands.
972    hotkey: ToastHotkey,
973    id: Option<ElementId>,
974    /// The `sx` slot, refined over the root style at the end of render.
975    sx: Option<Box<gpui::StyleRefinement>>,
976}
977
978impl ToastViewport {
979    /// Creates a viewport with the default placement, gap, width and inset.
980    pub fn new() -> Self {
981        Self {
982            placement: ToastPlacement::default(),
983            gap: px(12.),
984            max_visible_toasts: DEFAULT_MAX_VISIBLE_TOASTS,
985            scale_factor: 0.05,
986            width: px(460.),
987            inset: px(16.),
988            is_expanded: false,
989            exit_duration: DEFAULT_TOAST_EXIT_DURATION,
990            hotkey: DEFAULT_TOAST_HOTKEY,
991            id: None,
992            sx: None,
993        }
994    }
995
996    /// Names this region so it can report `role="region"`. Unnamed viewports
997    /// produce no AccessKit node — two viewports sharing a constant id would
998    /// fold their landmarks into one.
999    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
1000        self.id = Some(id.into());
1001        self
1002    }
1003
1004    /// Sets where the toast region sits.
1005    pub fn placement(mut self, placement: ToastPlacement) -> Self {
1006        self.placement = placement;
1007        self
1008    }
1009
1010    /// Sets the gap between expanded toasts.
1011    pub fn gap(mut self, gap: impl Into<Pixels>) -> Self {
1012        self.gap = gap.into();
1013        self
1014    }
1015
1016    /// `scaleFactor` on `Toast.Provider` — how much each toast behind the
1017    /// newest one shrinks, 0.05 in v3.
1018    ///
1019    /// gpui cannot scale a div, so the shrink is geometric: a stacked toast is
1020    /// inset horizontally by its depth's share of the width. Pass `0.0` for a
1021    /// flat stack.
1022    pub fn scale_factor(mut self, factor: f32) -> Self {
1023        self.scale_factor = factor.clamp(0.0, 1.0);
1024        self
1025    }
1026
1027    /// Sets how many toasts are visible at once (at least 1).
1028    pub fn max_visible_toasts(mut self, n: usize) -> Self {
1029        self.max_visible_toasts = n.max(1);
1030        self
1031    }
1032
1033    /// `isExpanded` on `Toast.Provider`. Forces the stack open when more than
1034    /// one toast is active. Hover and focus-within still expand when this is
1035    /// false; the prop alone does not pause timers.
1036    pub fn is_expanded(mut self, expanded: bool) -> Self {
1037        self.is_expanded = expanded;
1038        self
1039    }
1040
1041    /// `exitDuration` on `Toast.Provider` — how long a dismissed card stays
1042    /// mounted for `[data-exiting]`. `Duration::ZERO` removes it immediately.
1043    pub fn exit_duration(mut self, duration: Duration) -> Self {
1044        self.exit_duration = duration;
1045        self
1046    }
1047
1048    /// `hotkey` on `Toast.Provider`. The default is Alt+T. Pass
1049    /// [`ToastHotkey::disabled`] to turn the shortcut off.
1050    pub fn hotkey(mut self, hotkey: ToastHotkey) -> Self {
1051        self.hotkey = hotkey;
1052        self
1053    }
1054
1055    /// Sets the width of each toast.
1056    pub fn width(mut self, width: impl Into<Pixels>) -> Self {
1057        self.width = width.into();
1058        self
1059    }
1060
1061    /// Distance from the window edge.
1062    pub fn inset(mut self, inset: impl Into<Pixels>) -> Self {
1063        self.inset = inset.into();
1064        self
1065    }
1066
1067    /// The one slot for caller-owned low-level styling: GPUI's styling methods
1068    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
1069    /// applied to the region's root element after every value the placement and
1070    /// the active theme chose, so they win. The region is absolutely
1071    /// positioned, so an override that sets its own position wins over the
1072    /// placement's insets.
1073    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
1074        crate::util::refine_sx(&mut self.sx, style);
1075        self
1076    }
1077}
1078
1079impl Default for ToastViewport {
1080    fn default() -> Self {
1081        Self::new()
1082    }
1083}
1084
1085impl RenderOnce for ToastViewport {
1086    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1087        let store = cx.try_global::<ToastHub>().map(|hub| hub.store.clone());
1088        let (mut toasts, exiting, exiting_frontmost) = match store.as_ref() {
1089            Some(store) => {
1090                let snap = store.read(cx);
1091                (
1092                    snap.toasts().to_vec(),
1093                    snap.exiting().to_vec(),
1094                    snap.exit_frontmost.clone(),
1095                )
1096            }
1097            None => (Vec::new(), Vec::new(), HashSet::new()),
1098        };
1099
1100        let region_id = self
1101            .id
1102            .clone()
1103            .unwrap_or_else(|| ElementId::Name("toast-region".into()));
1104        let pointer_state =
1105            window.use_keyed_state(element_id::scoped(&region_id, "pointer"), cx, |_, _| false);
1106        let region_focus = window
1107            .use_keyed_state(element_id::scoped(&region_id, "focus"), cx, |_, cx| {
1108                cx.focus_handle().tab_stop(false)
1109            })
1110            .read(cx)
1111            .clone();
1112        // HeroUI measures every mounted card and uses those heights for both
1113        // the expanded offsets and the collapsed front-height overlap. Keep
1114        // the cache in a keyed slot so a rerender of the viewport or a queue
1115        // update does not throw away measurements that still belong to the
1116        // same toast ids.
1117        let measured_height_state =
1118            window.use_keyed_state(element_id::scoped(&region_id, "heights"), cx, |_, _| {
1119                Rc::new(RefCell::new(HashMap::<u64, Pixels>::new()))
1120            });
1121        let measured_heights = measured_height_state.read(cx).clone();
1122        let _hotkey_sub =
1123            window.use_keyed_state(element_id::scoped(&region_id, "hotkey"), cx, |_, cx| {
1124                let focus = region_focus.clone();
1125                cx.intercept_keystrokes(move |event, window, cx| {
1126                    let Some(hub) = cx.try_global::<ToastHub>() else {
1127                        return;
1128                    };
1129                    let hotkey = hub.store.read(cx).hotkey.clone();
1130                    if !hotkey.matches(&event.keystroke) {
1131                        return;
1132                    }
1133                    if hub.store.read(cx).toasts().is_empty() {
1134                        return;
1135                    }
1136                    focus.focus(window, cx);
1137                    cx.stop_propagation();
1138                })
1139            });
1140
1141        let max_visible = self.max_visible_toasts;
1142        let hidden: Vec<ToastData> = if toasts.len() > max_visible {
1143            toasts.split_off(max_visible)
1144        } else {
1145            Vec::new()
1146        };
1147        // Toast ids are monotonic, so retaining every measured height would
1148        // make a long-lived viewport grow forever. Keep measurements only for
1149        // cards that are still mounted, including cards in their exit lifetime.
1150        let live_ids: HashSet<u64> = toasts
1151            .iter()
1152            .chain(hidden.iter())
1153            .chain(exiting.iter())
1154            .map(|toast| toast.id)
1155            .collect();
1156        measured_heights
1157            .borrow_mut()
1158            .retain(|id, _| live_ids.contains(id));
1159        let active_count = toasts.len();
1160        let pointer_within = *pointer_state.read(cx);
1161        let focus_within = region_focus.contains_focused(window, cx);
1162        let interaction_within = (pointer_within || focus_within) && active_count > 0;
1163        // A single remaining toast never expands; exiting cards do not count.
1164        let expanded = (self.is_expanded || interaction_within) && active_count > 1;
1165        if active_count == 0 && pointer_within {
1166            pointer_state.update(cx, |held, _| *held = false);
1167        }
1168
1169        if let Some(store) = store.as_ref() {
1170            store.update(cx, |s, _| {
1171                s.interaction_paused = interaction_within;
1172                s.exit_duration = self.exit_duration;
1173                s.hotkey = self.hotkey.clone();
1174                s.viewport_expanded = expanded;
1175            });
1176        }
1177
1178        let stack_gap = if expanded || active_count <= 1 {
1179            self.gap
1180        } else {
1181            px(0.)
1182        };
1183        // HeroUI keeps a 16px inline inset and switches to
1184        // `calc(100vw - 2rem)` below its small breakpoint. GPUI has no media
1185        // query breakpoint here, so clamp the configured desktop width to the
1186        // live viewport on every platform. This prevents a 460px toast from
1187        // escaping a narrow native or browser window while preserving the
1188        // configured width whenever the viewport has room.
1189        let width = px(f32::from(self.width)
1190            .min((f32::from(window.viewport_size().width) - f32::from(self.inset) * 2.0).max(1.0)));
1191        let front_height = toasts
1192            .first()
1193            .and_then(|toast| measured_heights.borrow().get(&toast.id).copied())
1194            .unwrap_or(px(0.));
1195        let have_all_heights = active_count > 0
1196            && front_height > px(0.)
1197            && (!expanded
1198                || toasts
1199                    .iter()
1200                    .all(|toast| measured_heights.borrow().contains_key(&toast.id)));
1201        let stack_absolute = have_all_heights;
1202        let expanded_height = if expanded {
1203            let content_height = toasts.iter().fold(0.0, |height, toast| {
1204                height
1205                    + measured_heights
1206                        .borrow()
1207                        .get(&toast.id)
1208                        .map_or(0.0, |value| f32::from(*value))
1209            });
1210            px(content_height + f32::from(self.gap) * active_count.saturating_sub(1) as f32)
1211        } else {
1212            px(f32::from(front_height)
1213                + f32::from(self.gap) * active_count.saturating_sub(1) as f32)
1214        };
1215        let mut region = gpui::div()
1216            .id(region_id)
1217            .track_focus(&region_focus)
1218            .absolute();
1219        if stack_absolute {
1220            // The absolute region itself is already a positioned containing
1221            // block for its absolute card slots. Calling `.relative()` here
1222            // would replace the placement's absolute positioning and detach
1223            // the whole stack from the window edge.
1224            region = region.h(expanded_height);
1225        } else {
1226            region = region.flex().flex_col().gap(stack_gap);
1227        }
1228        region = region
1229            .on_hover({
1230                let pointer_state = pointer_state.clone();
1231                move |over, window, cx| {
1232                    pointer_state.update(cx, |held, cx| {
1233                        if *held != *over {
1234                            *held = *over;
1235                            cx.notify();
1236                        }
1237                    });
1238                    window.refresh();
1239                }
1240            })
1241            .on_key_down({
1242                let pointer_state = pointer_state.clone();
1243                move |event, window, cx| {
1244                    if event.keystroke.key != "escape" {
1245                        return;
1246                    }
1247                    pointer_state.update(cx, |held, cx| {
1248                        *held = false;
1249                        cx.notify();
1250                    });
1251                    window.blur(cx);
1252                    window.refresh();
1253                }
1254            });
1255
1256        region = if self.placement.is_top() {
1257            region.top(self.inset)
1258        } else {
1259            region.bottom(self.inset)
1260        };
1261
1262        region = match self.placement {
1263            ToastPlacement::TopStart | ToastPlacement::BottomStart => {
1264                let region = region.left(self.inset);
1265                if stack_absolute {
1266                    region.w(width)
1267                } else {
1268                    region
1269                }
1270            }
1271            ToastPlacement::TopEnd | ToastPlacement::BottomEnd => {
1272                let region = region.right(self.inset);
1273                if stack_absolute {
1274                    region.w(width)
1275                } else {
1276                    region
1277                }
1278            }
1279            // Centred placements stretch and centre their children.
1280            ToastPlacement::Top | ToastPlacement::Bottom => {
1281                region.left_0().right_0().items_center()
1282            }
1283        };
1284
1285        // Pinned React Stately unshifts new entries, so index zero is the
1286        // frontmost toast. A bottom stack draws it last so it sits nearest the
1287        // edge; a top stack draws it first. Either way its depth stays zero.
1288        let scale = self.scale_factor;
1289        let peek = self.gap;
1290        let top = self.placement.is_top();
1291        let visible_len = toasts.len();
1292        let last = visible_len.saturating_sub(1);
1293        let n = active_count + hidden.len();
1294        let align_end = matches!(
1295            self.placement,
1296            ToastPlacement::TopEnd | ToastPlacement::BottomEnd
1297        );
1298        let align_center = matches!(self.placement, ToastPlacement::Top | ToastPlacement::Bottom);
1299        let mut specs: Vec<(ToastData, usize, Pixels)> = Vec::with_capacity(visible_len);
1300        if stack_absolute {
1301            // Paint older cards first so the newest/frontmost card owns the
1302            // overlap and its controls remain on top in both top and bottom
1303            // placements. The source queue stays newest-first for state and
1304            // callback semantics; only paint order is reversed here.
1305            let mut offset = 0.0;
1306            for (depth, toast) in toasts.into_iter().enumerate() {
1307                specs.push((toast.clone(), depth, px(offset)));
1308                if expanded {
1309                    offset += measured_heights
1310                        .borrow()
1311                        .get(&toast.id)
1312                        .map_or(0.0, |value| f32::from(*value))
1313                        + f32::from(peek);
1314                } else {
1315                    offset = f32::from(peek) * (depth + 1) as f32;
1316                }
1317            }
1318            specs.reverse();
1319        } else {
1320            if !top {
1321                toasts.reverse();
1322            }
1323            for (i, toast) in toasts.into_iter().enumerate() {
1324                let depth = if top { i } else { last - i };
1325                specs.push((toast, depth, px(0.)));
1326            }
1327        }
1328        let visible_heights = measured_heights.clone();
1329        let visible_height_state = measured_height_state.clone();
1330        let visible_cards = specs
1331            .into_iter()
1332            .map(move |(t, depth, offset)| ToastCardEl {
1333                t,
1334                width,
1335                depth: if expanded && !stack_absolute {
1336                    0
1337                } else {
1338                    depth
1339                },
1340                scale_factor: scale,
1341                frontmost: depth == 0,
1342                expanded,
1343                hidden: false,
1344                exiting: false,
1345                peek,
1346                stack_absolute,
1347                stack_offset: offset,
1348                front_height,
1349                stack_top: top,
1350                align_end,
1351                align_center,
1352                measured_heights: visible_heights.clone(),
1353                height_state: visible_height_state.clone(),
1354            });
1355        let hidden_heights = measured_heights.clone();
1356        let hidden_height_state = measured_height_state.clone();
1357        let hidden_cards = hidden.into_iter().map(move |t| ToastCardEl {
1358            t,
1359            width,
1360            depth: 0,
1361            scale_factor: scale,
1362            frontmost: false,
1363            expanded,
1364            hidden: true,
1365            exiting: false,
1366            peek,
1367            stack_absolute,
1368            stack_offset: px(0.),
1369            front_height,
1370            stack_top: top,
1371            align_end,
1372            align_center,
1373            measured_heights: hidden_heights.clone(),
1374            height_state: hidden_height_state.clone(),
1375        });
1376        let exiting_heights = measured_heights;
1377        let exiting_height_state = measured_height_state;
1378        let exiting_cards = exiting.into_iter().map(move |t| {
1379            let frontmost = exiting_frontmost.contains(&t.id);
1380            ToastCardEl {
1381                t,
1382                width,
1383                depth: 0,
1384                scale_factor: scale,
1385                frontmost,
1386                expanded,
1387                hidden: false,
1388                exiting: true,
1389                peek,
1390                stack_absolute,
1391                stack_offset: px(0.),
1392                front_height,
1393                stack_top: top,
1394                align_end,
1395                align_center,
1396                measured_heights: exiting_heights.clone(),
1397                height_state: exiting_height_state.clone(),
1398            }
1399        });
1400        let region = region
1401            .children(visible_cards)
1402            .children(hidden_cards)
1403            .children(exiting_cards);
1404        let region = crate::util::apply_sx(region, &self.sx);
1405        if self.id.is_some() {
1406            let name = if n == 1 {
1407                SharedString::from("1 notification.")
1408            } else {
1409                SharedString::from(format!("{n} notifications."))
1410            };
1411            region
1412                .a11y_named(a11y::Role::Region, &a11y::Name::labelled(name))
1413                .into_any_element()
1414        } else {
1415            region.into_any_element()
1416        }
1417    }
1418}
1419
1420#[derive(IntoElement)]
1421struct ToastCardEl {
1422    t: ToastData,
1423    width: Pixels,
1424    depth: usize,
1425    scale_factor: f32,
1426    frontmost: bool,
1427    expanded: bool,
1428    hidden: bool,
1429    exiting: bool,
1430    peek: Pixels,
1431    stack_absolute: bool,
1432    stack_offset: Pixels,
1433    front_height: Pixels,
1434    stack_top: bool,
1435    align_end: bool,
1436    align_center: bool,
1437    measured_heights: Rc<RefCell<HashMap<u64, Pixels>>>,
1438    height_state: Entity<Rc<RefCell<HashMap<u64, Pixels>>>>,
1439}
1440
1441#[derive(Clone, Copy, Debug, Default)]
1442struct ToastCloseMotion {
1443    visible: bool,
1444    generation: usize,
1445}
1446
1447impl RenderOnce for ToastCardEl {
1448    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1449        // `.toast__close-button` is the CloseButton v3 composes, which is a
1450        // real keyboard tab stop. The handle has to be created before the
1451        // theme tokens are read: `use_keyed_state` takes `cx` mutably and
1452        // `colors` borrows it.
1453        // Every part of a toast card hangs off the toast's own numeric id.
1454        let base_id = ElementId::named_usize("toast", self.t.id as usize);
1455        // Frontmost is always interactive. Expanded visible cards are too.
1456        // Hidden and exiting cards stay inert (`data-hidden` / `data-exiting`).
1457        let interactive = !self.hidden && !self.exiting && (self.frontmost || self.expanded);
1458        let collapsed_behind = !self.frontmost && !self.expanded && !self.hidden && !self.exiting;
1459        let close_focus = if self.t.closable && interactive {
1460            Some(crate::util::tab_stop_handle(
1461                element_id::scoped(&element_id::scoped(&base_id, "close"), "focus"),
1462                window,
1463                cx,
1464            ))
1465        } else {
1466            None
1467        };
1468        let close_hovered =
1469            window.use_keyed_state(element_id::scoped(&base_id, "close-hovered"), cx, |_, _| {
1470                false
1471            });
1472        let close_motion =
1473            window.use_keyed_state(element_id::scoped(&base_id, "close-motion"), cx, |_, _| {
1474                ToastCloseMotion::default()
1475            });
1476        let close_focused = close_focus
1477            .as_ref()
1478            .is_some_and(|focus| focus.is_focused(window));
1479        let close_target = interactive && (*close_hovered.read(cx) || close_focused);
1480        let mut close_frame = *close_motion.read(cx);
1481        if close_frame.visible != close_target {
1482            close_frame.visible = close_target;
1483            close_frame.generation = close_frame.generation.wrapping_add(1);
1484            close_motion.update(cx, |current, _| *current = close_frame);
1485        }
1486        let close_visible = close_frame.visible;
1487        // Build caller-owned indicator content before borrowing theme colors;
1488        // the render factory needs a mutable `App` while the card uses the
1489        // immutable color snapshot for every following slot.
1490        let custom_indicator = if self.t.is_loading {
1491            None
1492        } else {
1493            self.t
1494                .indicator_content
1495                .as_ref()
1496                .map(|render_indicator| render_indicator(cx))
1497        };
1498        let colors = cx.colors();
1499        let sem = cx.role(self.t.color);
1500        let title_color = match self.t.color {
1501            Color::Default => colors.overlay.foreground,
1502            Color::Accent | Color::Success | Color::Warning | Color::Danger => {
1503                sem.soft_foreground(colors.foreground)
1504            }
1505        };
1506        let indicator_color = match self.t.color {
1507            Color::Default | Color::Accent => colors.overlay.foreground,
1508            Color::Success | Color::Warning | Color::Danger => {
1509                sem.soft_foreground(colors.foreground)
1510            }
1511        };
1512
1513        // The card owns its resting radius; Toast's pinned motion translates
1514        // the whole card from the placement edge instead of scaling its
1515        // rounded chrome.
1516        let radius = self
1517            .t
1518            .radius
1519            .unwrap_or_else(|| crate::util::container_radius(cx));
1520        // The padding pair remains caller-owned geometry on every motion path.
1521        let panel_padding_y = self.t.padding.unwrap_or(px(12.));
1522        let panel_padding_x = self.t.padding.unwrap_or(px(16.));
1523        // Each step back shrinks the card by `scale_factor`, expressed as a
1524        // horizontal inset since a div cannot be scaled. Expanded cards stay
1525        // full width (`--toast-scale: 1`).
1526        let shrink = (1.0 - self.scale_factor * self.depth as f32).clamp(0.5, 1.0);
1527        let width = px(f32::from(self.width) * shrink);
1528        let should_measure = !self.hidden && !self.exiting && (self.frontmost || self.expanded);
1529        // `toast/toast.js` renders RAC's `UNSTABLE_Toast`, whose props come
1530        // from `react-aria/dist/private/toast/useToast.js`: the card is
1531        // `role="alertdialog"` with `aria-modal="false"`, named by its
1532        // title and described by its description. The `aria-modal` half
1533        // and the inner `role="alert"` content node are recorded omissions
1534        // in `crate::a11y` — the first has no gpui builder and no AccessKit
1535        // field, the second exists only to make a live announcement gpui
1536        // cannot make.
1537        let mut card = gpui::div();
1538        if should_measure {
1539            let measured_heights = self.measured_heights.clone();
1540            let height_state = self.height_state.clone();
1541            let toast_id = self.t.id;
1542            card = card.on_children_prepainted(move |bounds, _, cx| {
1543                let content_height = bounds
1544                    .iter()
1545                    .map(|bound| f32::from(bound.size.height))
1546                    .fold(0.0, f32::max);
1547                let next = px(content_height + f32::from(panel_padding_y) * 2.0);
1548                let mut heights = measured_heights.borrow_mut();
1549                if heights.get(&toast_id).copied() != Some(next) {
1550                    heights.insert(toast_id, next);
1551                    height_state.update(cx, |_, cx| cx.notify());
1552                }
1553            });
1554        }
1555        let mut card = card
1556            .id(base_id.clone())
1557            .a11y_named(
1558                a11y::Role::AlertDialog,
1559                &a11y::Name::labelled(self.t.title.clone()).described(self.t.description.clone()),
1560            )
1561            .w(width)
1562            .flex()
1563            .items_start()
1564            .gap(px(6.))
1565            .px(panel_padding_x)
1566            .py(panel_padding_y)
1567            .relative()
1568            .rounded(radius)
1569            .bg(colors.surface.background)
1570            .text_color(colors.overlay.foreground)
1571            .when(!cx.layout().overlay_shadow.is_empty(), |c| {
1572                c.shadow(cx.layout().overlay_shadow.clone())
1573            });
1574        if interactive {
1575            let close_hovered = close_hovered.clone();
1576            card = card.on_hover(move |over, window, cx| {
1577                close_hovered.update(cx, |held, cx| {
1578                    if *held != *over {
1579                        *held = *over;
1580                        cx.notify();
1581                    }
1582                });
1583                window.refresh();
1584            });
1585        }
1586        if self.hidden {
1587            // `data-hidden`: stay in the tree at zero opacity, out of flow
1588            // so overflow does not move the frontmost close target.
1589            card = card
1590                .when(!self.stack_absolute, |card| card.absolute())
1591                .w(px(0.))
1592                .h(px(0.))
1593                .opacity(0.);
1594        } else if collapsed_behind {
1595            // Collapsed non-front: Sonner-style peek. GPUI cannot measure
1596            // the front card's height until the first laid-out frame; once it
1597            // is known, every collapsed card owns that same height and its
1598            // absolute offset exposes only the pinned gap between entries.
1599            let collapsed_height = if self.stack_absolute {
1600                self.front_height
1601            } else {
1602                self.peek
1603            };
1604            card = card.h(collapsed_height).overflow_hidden();
1605        } else if self.exiting && !self.stack_absolute {
1606            card = card.absolute();
1607        }
1608
1609        // `.toast__indicator` — `flex shrink-0 items-center justify-center p-1`
1610        // at `size-4`. v3 uses the overlay foreground for default/accent and a
1611        // status role's soft foreground for success/warning/danger.
1612        if self.t.is_loading {
1613            card = card.child(
1614                gpui::div()
1615                    .flex()
1616                    .flex_shrink_0()
1617                    .p(px(4.))
1618                    .when(collapsed_behind, |c| c.opacity(0.))
1619                    .child(
1620                        crate::spinner::Spinner::new(element_id::scoped(&base_id, "spinner"))
1621                            .size(herogpui_core::Size::Sm)
1622                            .current_color(indicator_color),
1623                    ),
1624            );
1625        } else if let Some(custom_indicator) = custom_indicator {
1626            card = card.child(
1627                gpui::div()
1628                    .flex()
1629                    .flex_shrink_0()
1630                    .items_center()
1631                    .justify_center()
1632                    .p(px(4.))
1633                    .when(collapsed_behind, |c| c.opacity(0.))
1634                    .child(custom_indicator),
1635            );
1636        } else if let Some(icon) = self.t.indicator.clone().or_else(|| {
1637            // Not set at all means the variant's own glyph; set to nothing
1638            // means v3's `indicator={null}`.
1639            if self.t.indicator_set {
1640                None
1641            } else {
1642                default_indicator(self.t.color).map(SharedString::from)
1643            }
1644        }) {
1645            card = card.child(
1646                gpui::div()
1647                    .flex()
1648                    .flex_shrink_0()
1649                    .items_center()
1650                    .justify_center()
1651                    .p(px(4.))
1652                    .when(collapsed_behind, |c| c.opacity(0.))
1653                    .child(
1654                        gpui::svg()
1655                            .size(px(16.))
1656                            .path(icon)
1657                            .text_color(indicator_color),
1658                    ),
1659            );
1660        }
1661
1662        // `.toast__content` -- the title and description column, beside the
1663        // indicator and inside the card.
1664        let mut text_col = gpui::div()
1665            .flex()
1666            .flex_col()
1667            .flex_1()
1668            .min_w_0()
1669            .when(collapsed_behind, |c| c.opacity(0.));
1670        text_col = text_col.child(
1671            gpui::div()
1672                // `.toast__title` is `text-sm leading-5 font-medium`.
1673                .text_size(px(14.))
1674                .line_height(px(20.))
1675                .font_weight(gpui::FontWeight::MEDIUM)
1676                .text_color(title_color)
1677                // HeroUI's title slot has no truncation utility. Let a long
1678                // notification grow naturally inside the width-constrained
1679                // content column, just like the description beneath it.
1680                .whitespace_normal()
1681                .child(self.t.title.to_string()),
1682        );
1683        if let Some(desc) = &self.t.description {
1684            text_col = text_col.child(
1685                gpui::div()
1686                    // `.toast__description` is `text-sm text-muted`.
1687                    .text_size(px(14.))
1688                    .line_height(px(20.))
1689                    .text_color(colors.muted)
1690                    .child(desc.to_string()),
1691            );
1692        }
1693        card = card.child(text_col);
1694
1695        // `.toast__action` — the button v3 configures with `actionProps`.
1696        if let Some((label, on_press)) = self.t.action.clone() {
1697            let id = self.t.id;
1698            let mut action = crate::button::Button::new(element_id::scoped(&base_id, "action"))
1699                .label(label)
1700                .variant(herogpui_core::Variant::Secondary)
1701                .size(herogpui_core::Size::Sm)
1702                .is_disabled(!interactive);
1703            if interactive {
1704                action = action.on_press(move |_, _, cx| {
1705                    on_press(cx);
1706                    // v3's action closes the toast it belongs to.
1707                    dismiss_toast(id, cx);
1708                });
1709            }
1710            card = card.child(action);
1711        }
1712
1713        if self.t.closable {
1714            let id = self.t.id;
1715            let hover_bg = self.t.close_hover_bg.unwrap_or(colors.default.color);
1716            let radius = crate::util::small_radius(cx);
1717            let mut close_btn = gpui::div()
1718                .id(element_id::scoped(&base_id, "close"))
1719                .flex()
1720                .items_center()
1721                .justify_center()
1722                // `.toast__close-button` is `absolute -end-1 -top-1 size-5`
1723                // with `sm:border border-border sm:bg-overlay`, and its icon
1724                // follows the close button's own `size-3`. Keeping it out of
1725                // the flex row preserves the title/action width and lets the
1726                // rounded card own only its content geometry.
1727                .size(px(20.))
1728                .absolute()
1729                .top(px(-4.))
1730                .right(px(-4.))
1731                // The focus ring belongs to this stable 20px hit target, not
1732                // only to its animated child surface. Keep the radius here so
1733                // keyboard focus cannot turn into a square outline.
1734                .rounded(radius);
1735            if interactive {
1736                close_btn = crate::util::cursor_interactive(close_btn, cx);
1737                // `.toast__close-button:hover` fills with `bg-default` --
1738                // the full token, overriding the composed CloseButton's own
1739                // `--default-hover` refinement.
1740                let close_hovered_for_click = close_hovered;
1741                let close_focus_for_click = close_focus.clone();
1742                close_btn = close_btn.on_click(move |_, window, cx| {
1743                    let visible = *close_hovered_for_click.read(cx)
1744                        || close_focus_for_click
1745                            .as_ref()
1746                            .is_some_and(|focus| focus.is_focused(window));
1747                    if visible {
1748                        dismiss_toast(id, cx);
1749                    }
1750                });
1751                // A keyboard tab stop that rings on focus-visible — gpui builds
1752                // its tab order from `track_focus` handles, and the Enter/Space
1753                // activation fires the click listener above on its own.
1754                let close_focus = close_focus
1755                    .as_ref()
1756                    .expect("a frontmost closable toast created its close handle");
1757                // The overlay goes on the same stable 20px hit target that
1758                // carries `rounded(radius)` and the focus handle, not on the
1759                // animated child surface below it.
1760                close_btn = crate::util::ring_overlay_if_focused(
1761                    close_btn.track_focus(close_focus),
1762                    close_focus,
1763                    true,
1764                    radius,
1765                    Vec::new(),
1766                    window,
1767                    cx,
1768                );
1769            } else {
1770                // Pinned CSS hides and disables the close affordance behind
1771                // the front toast.
1772                close_btn = close_btn.opacity(0.);
1773            }
1774            let close_visual = gpui::div()
1775                .absolute()
1776                .inset_0()
1777                .flex()
1778                .items_center()
1779                .justify_center()
1780                .border(cx.layout().border_width)
1781                .border_color(colors.border)
1782                .bg(colors.overlay.background)
1783                .rounded(radius)
1784                .hover(move |s| s.bg(hover_bg))
1785                .child(
1786                    gpui::svg()
1787                        .size(px(12.))
1788                        .path(icons::CLOSE)
1789                        .text_color(colors.muted),
1790                );
1791            let close_visual: AnyElement = if close_frame.generation > 0
1792                && !ActiveTheme::reduce_motion(cx)
1793            {
1794                let visible = close_visible;
1795                let from = if visible { 0.0 } else { 1.0 };
1796                let to = if visible { 1.0 } else { 0.0 };
1797                close_visual
1798                    .with_animation(
1799                        element_id::indexed(&base_id, "close-opacity", close_frame.generation),
1800                        gpui::Animation::new(Duration::from_millis(crate::anim::TOAST_OPACITY_MS))
1801                            .with_easing(|t| crate::anim::Curve::Smooth.at(t)),
1802                        move |el, delta| el.opacity(from + (to - from) * delta),
1803                    )
1804                    .into_any_element()
1805            } else {
1806                close_visual
1807                    .opacity(if close_visible { 1.0 } else { 0.0 })
1808                    .into_any_element()
1809            };
1810            card = card.child(close_btn.child(close_visual));
1811        }
1812
1813        let card_height = if self.hidden {
1814            px(0.)
1815        } else if collapsed_behind {
1816            self.front_height
1817        } else {
1818            self.measured_heights
1819                .borrow()
1820                .get(&self.t.id)
1821                .copied()
1822                .unwrap_or(self.front_height)
1823        };
1824        // HeroUI's `--toast-enter: -100%` uses the card's own extent. The
1825        // measured height is available after the first layout; keep a small
1826        // line-height-based fallback for the first frame so a new toast still
1827        // starts from the correct edge before its height is cached.
1828        let travel = if card_height > px(0.) {
1829            card_height
1830        } else if self.front_height > px(0.) {
1831            self.front_height
1832        } else {
1833            px(44.)
1834        };
1835        let edge = if self.stack_top {
1836            crate::anim::Edge::Top
1837        } else {
1838            crate::anim::Edge::Bottom
1839        };
1840        let animated = if self.exiting && self.frontmost {
1841            toast_exiting(
1842                card,
1843                element_id::scoped(&base_id, "anim"),
1844                edge,
1845                travel,
1846                crate::anim::Motion::TOAST_OUT,
1847                cx,
1848            )
1849        } else if self.exiting {
1850            crate::anim::exiting(
1851                card,
1852                element_id::scoped(&base_id, "anim"),
1853                crate::anim::ZoomBox::panel(panel_padding_y, radius)
1854                    .padding_x(panel_padding_x)
1855                    .sized(width),
1856                crate::anim::Motion::TOAST_STACK_OUT,
1857                cx,
1858            )
1859        } else {
1860            toast_entering(
1861                card,
1862                element_id::scoped(&base_id, "anim"),
1863                edge,
1864                travel,
1865                crate::anim::Motion::TOAST_IN,
1866                cx,
1867            )
1868        };
1869        if self.stack_absolute {
1870            let slot_id = self.t.id;
1871            let mut slot = gpui::div()
1872                .absolute()
1873                .left_0()
1874                .right_0()
1875                .h(card_height)
1876                .flex()
1877                .debug_selector(move || format!("toast-slot-{slot_id}"));
1878            slot = if self.stack_top {
1879                slot.top(self.stack_offset)
1880            } else {
1881                slot.bottom(self.stack_offset)
1882            };
1883            slot = if self.align_end {
1884                slot.justify_end()
1885            } else if self.align_center {
1886                slot.justify_center()
1887            } else {
1888                slot.justify_start()
1889            };
1890            slot.child(animated).into_any_element()
1891        } else {
1892            animated
1893        }
1894    }
1895}
1896
1897/// The glyph a variant shows when the caller names none —
1898/// `[data-slot="toast-default-icon"]` in v3, coloured by
1899/// `.toast--<variant> .toast__indicator`.
1900fn default_indicator(color: Color) -> Option<&'static str> {
1901    match color {
1902        Color::Default | Color::Accent => Some(icons::INFO_CIRCLE),
1903        Color::Success => Some(icons::CHECK_CIRCLE),
1904        Color::Warning => Some(icons::WARNING_TRIANGLE),
1905        Color::Danger => Some(icons::CIRCLE_EXCLAMATION),
1906    }
1907}
1908
1909#[cfg(test)]
1910mod tests {
1911    use super::*;
1912
1913    fn implementation_source() -> &'static str {
1914        include_str!("toast.rs")
1915            .split("#[cfg(test)]")
1916            .next()
1917            .expect("the implementation section is always present")
1918    }
1919
1920    #[test]
1921    fn default_uses_info_indicator() {
1922        assert_eq!(default_indicator(Color::Default), Some(icons::INFO_CIRCLE));
1923    }
1924
1925    #[test]
1926    fn accent_uses_the_pinned_info_indicator() {
1927        assert_eq!(default_indicator(Color::Accent), Some(icons::INFO_CIRCLE));
1928    }
1929
1930    #[test]
1931    fn success_uses_check_circle_indicator() {
1932        assert_eq!(default_indicator(Color::Success), Some(icons::CHECK_CIRCLE));
1933    }
1934
1935    #[test]
1936    fn warning_uses_warning_triangle_indicator() {
1937        assert_eq!(
1938            default_indicator(Color::Warning),
1939            Some(icons::WARNING_TRIANGLE)
1940        );
1941    }
1942
1943    #[test]
1944    fn danger_uses_circle_exclamation_indicator() {
1945        assert_eq!(
1946            default_indicator(Color::Danger),
1947            Some(icons::CIRCLE_EXCLAMATION)
1948        );
1949    }
1950
1951    #[test]
1952    fn neutral_indicators_use_the_overlay_foreground_token() {
1953        let indicator = implementation_source()
1954            .split("let indicator_color = match self.t.color")
1955            .nth(1)
1956            .expect("the indicator color implementation is present")
1957            .split("let mut card =")
1958            .next()
1959            .expect("the card implementation follows the indicator color");
1960        assert!(
1961            indicator.contains("Color::Default | Color::Accent => colors.overlay.foreground"),
1962            "default and accent indicators must use `text-overlay-foreground`"
1963        );
1964    }
1965
1966    #[test]
1967    fn semantic_indicators_use_their_soft_foreground_tokens() {
1968        let indicator = implementation_source()
1969            .split("let indicator_color = match self.t.color")
1970            .nth(1)
1971            .expect("the indicator color implementation is present")
1972            .split("let mut card =")
1973            .next()
1974            .expect("the card implementation follows the indicator color");
1975        assert!(
1976            indicator.contains("Color::Success | Color::Warning | Color::Danger =>"),
1977            "status indicators must branch separately from neutral indicators"
1978        );
1979    }
1980
1981    #[test]
1982    fn loading_indicator_inherits_the_status_indicator_color() {
1983        let loading = implementation_source()
1984            .split("// `.toast__indicator`")
1985            .nth(1)
1986            .expect("the loading indicator implementation is present")
1987            .split("if self.t.is_loading {")
1988            .nth(1)
1989            .expect("the loading branch follows the indicator marker")
1990            .split("} else if let Some(icon)")
1991            .next()
1992            .expect("the custom indicator implementation follows loading");
1993        assert!(
1994            loading.contains(".current_color(indicator_color)"),
1995            "the loading spinner must inherit the toast indicator color"
1996        );
1997    }
1998
1999    #[test]
2000    fn custom_indicator_content_keeps_the_shared_indicator_box() {
2001        let source = implementation_source();
2002        assert!(source.contains("pub fn indicator_content<E, F>"));
2003        assert!(source.contains(".indicator_content\n                .as_ref()"));
2004        let custom = source
2005            .split(".indicator_content\n                .as_ref()")
2006            .nth(1)
2007            .expect("the custom indicator branch is present")
2008            .split("} else if let Some(icon)")
2009            .next()
2010            .expect("the default indicator follows the custom branch");
2011        assert!(custom.contains(".p(px(4.))"));
2012        assert!(custom.contains("render_indicator(cx)"));
2013    }
2014
2015    #[test]
2016    fn toast_card_does_not_add_a_border() {
2017        let card = implementation_source()
2018            .split("let mut card =")
2019            .nth(1)
2020            .expect("the card implementation is present")
2021            .split("// `.toast__indicator`")
2022            .next()
2023            .expect("the indicator implementation follows the card");
2024        assert!(!card.contains(".border(cx.layout().border_width)"));
2025        assert!(!card.contains(".border_color(colors.border)"));
2026    }
2027
2028    #[test]
2029    fn toast_content_does_not_add_an_extra_gap() {
2030        let content = implementation_source()
2031            .split("let mut text_col =")
2032            .nth(1)
2033            .expect("the content implementation is present")
2034            .split("// `.toast__action`")
2035            .next()
2036            .expect("the action implementation follows the content");
2037        assert!(!content.contains(".gap(px(2.))"));
2038    }
2039
2040    #[test]
2041    fn toast_titles_wrap_instead_of_truncating() {
2042        let content = implementation_source()
2043            .split("let mut text_col =")
2044            .nth(1)
2045            .expect("the toast content implementation is present")
2046            .split("if let Some(desc)")
2047            .next()
2048            .expect("the description follows the title");
2049        assert!(
2050            content.contains(".whitespace_normal()"),
2051            "the pinned toast title has normal wrapping"
2052        );
2053        assert!(
2054            !content.contains(".truncate()"),
2055            "toast titles must remain readable when they exceed one line"
2056        );
2057    }
2058
2059    #[test]
2060    fn toast_close_button_is_out_of_flow() {
2061        let close = implementation_source()
2062            .split("let mut close_btn =")
2063            .nth(1)
2064            .expect("the close button implementation is present")
2065            .split("if interactive")
2066            .next()
2067            .expect("the close interaction follows its geometry");
2068        assert!(close.contains(".absolute()"));
2069        assert!(close.contains(".top(px(-4.))"));
2070        assert!(close.contains(".right(px(-4.))"));
2071        assert!(close.contains(".rounded(radius)"));
2072    }
2073
2074    #[test]
2075    fn toast_close_button_reveals_on_card_hover_and_fades() {
2076        let source = implementation_source();
2077        assert!(source.contains("close-hovered"));
2078        assert!(source.contains("close-opacity"));
2079        assert!(source.contains("TOAST_OPACITY_MS"));
2080        assert!(source.contains("close_focus_for_click"));
2081        assert!(source.contains("if visible {\n                        dismiss_toast"));
2082        assert!(source.contains("!ActiveTheme::reduce_motion(cx)"));
2083    }
2084
2085    #[test]
2086    fn toast_viewport_clamps_width_to_the_live_window() {
2087        let source = implementation_source();
2088        let width = source
2089            .split("let width = px(")
2090            .nth(1)
2091            .expect("the viewport width clamp is present")
2092            .split("let front_height")
2093            .next()
2094            .expect("the width is resolved before stack layout");
2095        assert!(width.contains("window.viewport_size().width"));
2096        assert!(width.contains("self.inset"));
2097        assert!(width.contains(".min("));
2098        assert!(source.contains("region.w(width)"));
2099    }
2100
2101    #[test]
2102    fn toast_motion_uses_the_physical_placement_edge() {
2103        let source = implementation_source();
2104        assert!(source.contains("toast_entering("));
2105        assert!(source.contains("toast_exiting("));
2106        assert!(source.contains("crate::anim::Motion::TOAST_IN"));
2107        assert!(source.contains("crate::anim::Motion::TOAST_OUT"));
2108        assert!(source.contains("crate::anim::Motion::TOAST_STACK_OUT"));
2109        assert!(source.contains("TOAST_OPACITY_MS"));
2110        assert!(source.contains("let edge = if self.stack_top"));
2111        assert!(source.contains("self.exiting && self.frontmost"));
2112    }
2113
2114    // The pinned `.toast__close-button:hover` fills with `bg-default`, the
2115    // full token -- the composed CloseButton's own `--default-hover`
2116    // refinement is overridden, and no soft token survives here.
2117    #[test]
2118    fn the_close_button_hovers_the_full_default() {
2119        // Scan the implementation only; this test's own text names the
2120        // forbidden accessor.
2121        let source = implementation_source();
2122        assert!(
2123            source
2124                .contains("let hover_bg = self.t.close_hover_bg.unwrap_or(colors.default.color);"),
2125            "the toast close button must default to `bg-default` and honor \
2126             the named override (pinned `.toast__close-button:hover`)"
2127        );
2128        assert!(
2129            !source.contains("soft_hover()"),
2130            "the close button must not come back on a soft token"
2131        );
2132    }
2133}
2134
2135crate::util::impl_component_styled!(ToastViewport);