Skip to main content

ui/components/notification/
toast_stack.rs

1use std::time::Duration;
2
3use crate::AnnouncementToast;
4use crate::prelude::*;
5use gpui::{AnyElement, Context, Render};
6use smallvec::SmallVec;
7
8/// Default cap on simultaneously visible toasts (Tailwind reference spec: max 3 visible).
9pub const TOAST_STACK_MAX_VISIBLE: usize = 3;
10
11/// A bottom-right stacking container for [`crate::AnnouncementToast`] instances.
12///
13/// The caller owns the toast list (and its lifetime/state) and passes already-built
14/// `AnnouncementToast` elements as children on every render; [`ToastStack`] only
15/// handles layout (vertical stack, `gap-3`, bottom-right corner) and caps how many
16/// are shown at once via [`Self::max_visible`] — any elements beyond the cap are
17/// simply not rendered (the caller's own list still holds them, e.g. for a "N more"
18/// indicator).
19///
20/// Auto-dismiss timing is intentionally **caller-driven**: this component has no
21/// timer of its own. Callers wanting a timed dismissal should schedule it themselves
22/// (e.g. via `cx.spawn` + `cx.background_executor().timer(..)`, the same pattern used
23/// elsewhere in this crate) and remove the toast from their owned list when it fires.
24#[derive(IntoElement, RegisterComponent)]
25pub struct ToastStack {
26    children: SmallVec<[AnyElement; 4]>,
27    max_visible: usize,
28}
29
30impl ToastStack {
31    pub fn new() -> Self {
32        Self {
33            children: SmallVec::new(),
34            max_visible: TOAST_STACK_MAX_VISIBLE,
35        }
36    }
37
38    /// Caps how many toasts are rendered at once. Values below `1` are clamped to `1`.
39    pub fn max_visible(mut self, max: usize) -> Self {
40        self.max_visible = max.max(1);
41        self
42    }
43}
44
45impl Default for ToastStack {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51impl ParentElement for ToastStack {
52    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
53        self.children.extend(elements)
54    }
55}
56
57impl RenderOnce for ToastStack {
58    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
59        let visible = self.children.into_iter().take(self.max_visible);
60
61        div().absolute().bottom_0().right_0().p_4().child(
62            v_flex()
63                .id("toast-stack")
64                .gap(DynamicSpacing::Base12.rems(cx))
65                .items_end()
66                .children(visible),
67        )
68    }
69}
70
71impl Component for ToastStack {
72    fn scope() -> ComponentScope {
73        ComponentScope::Notification
74    }
75
76    fn description() -> Option<&'static str> {
77        Some(
78            "Stacks AnnouncementToast instances bottom-right, capping visible count; auto-dismiss and list ownership stay with the caller.",
79        )
80    }
81
82    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
83        Some(
84            example_group(vec![single_example(
85                "Basic",
86                div()
87                    .relative()
88                    .w(px(480.))
89                    .h(px(320.))
90                    .overflow_hidden()
91                    .child(
92                        ToastStack::new()
93                            .child(
94                                div().w_80().child(
95                                    AnnouncementToast::new()
96                                        .severity(Severity::Success)
97                                        .heading("Saved")
98                                        .description("Your changes were saved."),
99                                ),
100                            )
101                            .child(
102                                div().w_80().child(
103                                    AnnouncementToast::new()
104                                        .severity(Severity::Info)
105                                        .heading("Sync complete")
106                                        .description("All projects are up to date."),
107                                ),
108                            ),
109                    )
110                    .into_any_element(),
111            )])
112            .into_any_element(),
113        )
114    }
115}
116
117/// Default auto-dismiss duration for [`SonnerStack`] toasts.
118pub const SONNER_DEFAULT_DISMISS: Duration = Duration::from_secs(4);
119
120/// Sonner-style toast variant.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
122pub enum SonnerToastVariant {
123    #[default]
124    Default,
125    Success,
126    Error,
127    Info,
128    Loading,
129}
130
131impl SonnerToastVariant {
132    fn severity(self) -> Option<Severity> {
133        match self {
134            SonnerToastVariant::Default | SonnerToastVariant::Loading => None,
135            SonnerToastVariant::Success => Some(Severity::Success),
136            SonnerToastVariant::Error => Some(Severity::Error),
137            SonnerToastVariant::Info => Some(Severity::Info),
138        }
139    }
140
141    fn icon(self) -> IconName {
142        match self {
143            SonnerToastVariant::Default => IconName::Bell,
144            SonnerToastVariant::Success => IconName::CheckCircle,
145            SonnerToastVariant::Error => IconName::XCircle,
146            SonnerToastVariant::Info => IconName::Info,
147            SonnerToastVariant::Loading => IconName::CountdownTimer,
148        }
149    }
150}
151
152/// A compact Sonner-style toast notification.
153#[derive(IntoElement)]
154pub struct SonnerToast {
155    message: SharedString,
156    variant: SonnerToastVariant,
157}
158
159impl SonnerToast {
160    pub fn new(message: impl Into<SharedString>) -> Self {
161        Self {
162            message: message.into(),
163            variant: SonnerToastVariant::Default,
164        }
165    }
166
167    pub fn variant(mut self, variant: SonnerToastVariant) -> Self {
168        self.variant = variant;
169        self
170    }
171}
172
173impl RenderOnce for SonnerToast {
174    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
175        let icon_color = match self.variant.severity() {
176            Some(Severity::Success) => palette::success(600),
177            Some(Severity::Error) => palette::danger(600),
178            Some(Severity::Info) => palette::primary(600),
179            Some(Severity::Warning) => palette::warning(600),
180            None => palette::neutral(600),
181        };
182
183        h_flex()
184            .w(px(320.))
185            .items_center()
186            .gap_3()
187            .px_4()
188            .py_3()
189            .rounded_lg()
190            .bg(semantic::elevated_surface(cx))
191            .border_1()
192            .border_color(semantic::border(cx))
193            .shadow_level(Shadow::Lg)
194            .child(
195                Icon::new(self.variant.icon())
196                    .size(IconSize::Small)
197                    .color(Color::Custom(icon_color)),
198            )
199            .child(Label::new(self.message))
200    }
201}
202
203#[derive(Clone)]
204struct SonnerToastEntry {
205    id: u64,
206    message: SharedString,
207    variant: SonnerToastVariant,
208}
209
210/// Sonner-style toast queue with auto-dismiss timers (newest appended last).
211///
212/// Create with `cx.new(|cx| SonnerStack::new(cx))`.
213pub struct SonnerStack {
214    toasts: Vec<SonnerToastEntry>,
215    next_id: u64,
216    max_visible: usize,
217}
218
219impl SonnerStack {
220    pub fn new(cx: &mut Context<Self>) -> Self {
221        let mut stack = Self {
222            toasts: Vec::new(),
223            next_id: 1,
224            max_visible: TOAST_STACK_MAX_VISIBLE,
225        };
226        stack.push_internal(
227            cx,
228            "Welcome back!".into(),
229            SonnerToastVariant::Info,
230            SONNER_DEFAULT_DISMISS,
231        );
232        stack
233    }
234
235    pub fn max_visible(mut self, max: usize) -> Self {
236        self.max_visible = max.max(1);
237        self
238    }
239
240    /// Enqueue a toast and schedule auto-dismiss.
241    pub fn push(
242        &mut self,
243        cx: &mut Context<Self>,
244        message: impl Into<SharedString>,
245        variant: SonnerToastVariant,
246    ) {
247        self.push_internal(cx, message.into(), variant, SONNER_DEFAULT_DISMISS);
248    }
249
250    pub fn push_with_duration(
251        &mut self,
252        cx: &mut Context<Self>,
253        message: impl Into<SharedString>,
254        variant: SonnerToastVariant,
255        dismiss_after: Duration,
256    ) {
257        self.push_internal(cx, message.into(), variant, dismiss_after);
258    }
259
260    fn push_internal(
261        &mut self,
262        cx: &mut Context<Self>,
263        message: SharedString,
264        variant: SonnerToastVariant,
265        dismiss_after: Duration,
266    ) {
267        let id = self.next_id;
268        self.next_id += 1;
269        self.toasts.push(SonnerToastEntry {
270            id,
271            message,
272            variant,
273        });
274        cx.notify();
275
276        cx.spawn(async move |this, cx| {
277            cx.background_executor().timer(dismiss_after).await;
278            this.update(cx, |stack, cx| {
279                stack.dismiss(id, cx);
280            })
281            .ok();
282        })
283        .detach();
284    }
285
286    pub fn dismiss(&mut self, id: u64, cx: &mut Context<Self>) {
287        self.toasts.retain(|entry| entry.id != id);
288        cx.notify();
289    }
290}
291
292impl Render for SonnerStack {
293    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
294        let visible: Vec<_> = self
295            .toasts
296            .iter()
297            .rev()
298            .take(self.max_visible)
299            .cloned()
300            .collect();
301
302        div().absolute().bottom_0().right_0().p_4().child(
303            v_flex()
304                .id("sonner-stack")
305                .gap(DynamicSpacing::Base12.rems(cx))
306                .items_end()
307                .children(
308                    visible
309                        .into_iter()
310                        .map(|entry| SonnerToast::new(entry.message).variant(entry.variant)),
311                ),
312        )
313    }
314}
315
316/// Gallery catalog entry for [`SonnerStack`].
317#[derive(IntoElement, RegisterComponent)]
318pub struct SonnerStackPreview;
319
320impl RenderOnce for SonnerStackPreview {
321    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
322        div()
323            .relative()
324            .w(px(480.))
325            .h(px(320.))
326            .overflow_hidden()
327            .child(cx.new(SonnerStack::new))
328    }
329}
330
331impl Component for SonnerStackPreview {
332    fn scope() -> ComponentScope {
333        ComponentScope::Notification
334    }
335
336    fn description() -> Option<&'static str> {
337        Some("Sonner-style toast queue with auto-dismiss timers; newest on top.")
338    }
339
340    fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
341        SonnerStackPreview
342            .render(window, cx)
343            .into_any_element()
344            .into()
345    }
346}