Skip to main content

gpui/elements/
toast.rs

1use std::time::Duration;
2
3use crate::{
4    AnyElement, Context, IntoElement, ParentElement, Render, SharedString, Styled, Timer,
5    WeakEntity, Window, WindowAppearance, div, hsla, px,
6};
7
8/// Position where toasts appear on screen.
9#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
10pub enum ToastPosition {
11    /// Top-right corner of the window.
12    #[default]
13    TopRight,
14    /// Bottom-right corner of the window.
15    BottomRight,
16    /// Top-center of the window.
17    TopCenter,
18}
19
20/// Configuration for a single toast notification.
21#[derive(Clone)]
22pub struct Toast {
23    title: SharedString,
24    body: Option<SharedString>,
25    duration: Duration,
26    position: ToastPosition,
27}
28
29impl Toast {
30    /// Create a new toast with the given title.
31    pub fn new(title: impl Into<SharedString>) -> Self {
32        Self {
33            title: title.into(),
34            body: None,
35            duration: Duration::from_secs(3),
36            position: ToastPosition::default(),
37        }
38    }
39
40    /// Set the body text of the toast.
41    pub fn body(mut self, body: impl Into<SharedString>) -> Self {
42        self.body = Some(body.into());
43        self
44    }
45
46    /// Set how long the toast should be displayed before auto-dismissing.
47    pub fn duration(mut self, duration: Duration) -> Self {
48        self.duration = duration;
49        self
50    }
51
52    /// Set the screen position where the toast appears.
53    pub fn position(mut self, position: ToastPosition) -> Self {
54        self.position = position;
55        self
56    }
57}
58
59struct ToastEntry {
60    toast: Toast,
61}
62
63/// A stack of toast notifications that manages display and auto-dismissal.
64///
65/// Create a `ToastStack` as a GPUI entity and render it as part of your
66/// window's view tree. Use [`ToastStack::push`] to add new toasts.
67pub struct ToastStack {
68    toasts: Vec<ToastEntry>,
69    position: ToastPosition,
70}
71
72impl ToastStack {
73    /// Create a new empty toast stack with the default position.
74    pub fn new() -> Self {
75        Self {
76            toasts: Vec::new(),
77            position: ToastPosition::default(),
78        }
79    }
80
81    /// Set the default position for toasts in this stack.
82    pub fn with_position(mut self, position: ToastPosition) -> Self {
83        self.position = position;
84        self
85    }
86
87    /// Push a new toast onto the stack and schedule its auto-dismissal.
88    pub fn push(&mut self, toast: Toast, window: &Window, cx: &mut Context<Self>) {
89        let duration = toast.duration;
90        self.toasts.push(ToastEntry { toast });
91        cx.notify();
92
93        let index = self.toasts.len() - 1;
94        cx.spawn_in(window, async move |this: WeakEntity<Self>, cx| {
95            Timer::after(duration).await;
96            this.update(cx, |stack, cx| {
97                if index < stack.toasts.len() {
98                    stack.toasts.remove(index);
99                    cx.notify();
100                }
101            })
102            .ok();
103        })
104        .detach();
105    }
106
107    /// Remove all toasts from the stack.
108    pub fn clear(&mut self, cx: &mut Context<Self>) {
109        self.toasts.clear();
110        cx.notify();
111    }
112
113    fn is_dark_appearance(window: &Window) -> bool {
114        matches!(
115            window.appearance(),
116            WindowAppearance::Dark | WindowAppearance::VibrantDark
117        )
118    }
119}
120
121impl Render for ToastStack {
122    fn render(&mut self, window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
123        let is_dark = Self::is_dark_appearance(window);
124        let position = self.position;
125
126        let mut container = div().flex().flex_col().gap_2().p_4().max_w(px(360.0));
127
128        match position {
129            ToastPosition::TopRight => {
130                container = container.absolute().top_0().right_0();
131            }
132            ToastPosition::BottomRight => {
133                container = container.absolute().bottom_0().right_0();
134            }
135            ToastPosition::TopCenter => {
136                container = container.absolute().top_0().left_auto().right_auto();
137            }
138        }
139
140        let children: Vec<AnyElement> = self
141            .toasts
142            .iter()
143            .map(|entry| render_toast_item(&entry.toast, is_dark))
144            .collect();
145
146        for child in children {
147            container = container.child(child);
148        }
149
150        container
151    }
152}
153
154fn render_toast_item(toast: &Toast, is_dark: bool) -> AnyElement {
155    let bg_color = if is_dark {
156        hsla(0.0, 0.0, 0.1, 0.92)
157    } else {
158        hsla(0.0, 0.0, 0.0, 0.85)
159    };
160
161    let text_color = if is_dark {
162        hsla(0.0, 0.0, 0.95, 1.0)
163    } else {
164        hsla(0.0, 0.0, 1.0, 1.0)
165    };
166
167    let secondary_text_color = if is_dark {
168        hsla(0.0, 0.0, 0.7, 1.0)
169    } else {
170        hsla(0.0, 0.0, 0.85, 1.0)
171    };
172
173    let title = toast.title.clone();
174    let body = toast.body.clone();
175
176    let mut toast_div = div()
177        .flex()
178        .flex_col()
179        .gap_1()
180        .py(px(12.0))
181        .px(px(16.0))
182        .rounded(px(8.0))
183        .bg(bg_color)
184        .shadow_lg()
185        .max_w(px(320.0))
186        .min_w(px(200.0))
187        .text_color(text_color)
188        .text_sm()
189        .child(div().font_weight(crate::FontWeight::SEMIBOLD).child(title));
190
191    if let Some(body_text) = body {
192        toast_div = toast_div.child(
193            div()
194                .text_xs()
195                .text_color(secondary_text_color)
196                .child(body_text),
197        );
198    }
199
200    toast_div.into_any_element()
201}