ui/components/notification/
toast_stack.rs1use std::time::Duration;
2
3use crate::AnnouncementToast;
4use crate::prelude::*;
5use gpui::{AnyElement, Context, Render};
6use smallvec::SmallVec;
7
8pub const TOAST_STACK_MAX_VISIBLE: usize = 3;
10
11#[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 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
117pub const SONNER_DEFAULT_DISMISS: Duration = Duration::from_secs(4);
119
120#[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#[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
210pub 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 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#[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}