Skip to main content

gpui_core/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    id: u64,
61    toast: Toast,
62}
63
64/// A stack of toast notifications that manages display and auto-dismissal.
65///
66/// Create a `ToastStack` as a GPUI entity and render it as part of your
67/// window's view tree. Use [`ToastStack::push`] to add new toasts.
68pub struct ToastStack {
69    toasts: Vec<ToastEntry>,
70    next_toast_id: u64,
71    position: ToastPosition,
72}
73
74impl ToastStack {
75    /// Create a new empty toast stack with the default position.
76    pub fn new() -> Self {
77        Self {
78            toasts: Vec::new(),
79            next_toast_id: 0,
80            position: ToastPosition::default(),
81        }
82    }
83
84    /// Set the default position for toasts in this stack.
85    pub fn with_position(mut self, position: ToastPosition) -> Self {
86        self.position = position;
87        self
88    }
89
90    /// Push a new toast onto the stack and schedule its auto-dismissal.
91    pub fn push(&mut self, toast: Toast, window: &Window, cx: &mut Context<Self>) {
92        let duration = toast.duration;
93        let id = self.next_toast_id;
94        self.next_toast_id += 1;
95        self.toasts.push(ToastEntry { id, toast });
96        cx.notify();
97
98        cx.spawn_in(window, async move |this: WeakEntity<Self>, cx| {
99            Timer::after(duration).await;
100            this.update(cx, |stack, cx| {
101                let len = stack.toasts.len();
102                stack.toasts.retain(|entry| entry.id != id);
103                if stack.toasts.len() != len {
104                    cx.notify();
105                }
106            })
107            .ok();
108        })
109        .detach();
110    }
111
112    /// Remove all toasts from the stack.
113    pub fn clear(&mut self, cx: &mut Context<Self>) {
114        self.toasts.clear();
115        cx.notify();
116    }
117
118    fn is_dark_appearance(window: &Window) -> bool {
119        matches!(
120            window.appearance(),
121            WindowAppearance::Dark | WindowAppearance::VibrantDark
122        )
123    }
124}
125
126impl Render for ToastStack {
127    fn render(&mut self, window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
128        let is_dark = Self::is_dark_appearance(window);
129        let position = self.position;
130
131        let mut container = div().flex().flex_col().gap_2().p_4().max_w(px(360.0));
132
133        match position {
134            ToastPosition::TopRight => {
135                container = container.absolute().top_0().right_0();
136            }
137            ToastPosition::BottomRight => {
138                container = container.absolute().bottom_0().right_0();
139            }
140            ToastPosition::TopCenter => {
141                container = container.absolute().top_0().left_auto().right_auto();
142            }
143        }
144
145        let children: Vec<AnyElement> = self
146            .toasts
147            .iter()
148            .map(|entry| render_toast_item(&entry.toast, is_dark))
149            .collect();
150
151        for child in children {
152            container = container.child(child);
153        }
154
155        container
156    }
157}
158
159fn render_toast_item(toast: &Toast, is_dark: bool) -> AnyElement {
160    let bg_color = if is_dark {
161        hsla(0.0, 0.0, 0.1, 0.92)
162    } else {
163        hsla(0.0, 0.0, 0.0, 0.85)
164    };
165
166    let text_color = if is_dark {
167        hsla(0.0, 0.0, 0.95, 1.0)
168    } else {
169        hsla(0.0, 0.0, 1.0, 1.0)
170    };
171
172    let secondary_text_color = if is_dark {
173        hsla(0.0, 0.0, 0.7, 1.0)
174    } else {
175        hsla(0.0, 0.0, 0.85, 1.0)
176    };
177
178    let title = toast.title.clone();
179    let body = toast.body.clone();
180
181    let mut toast_div = div()
182        .flex()
183        .flex_col()
184        .gap_1()
185        .py(px(12.0))
186        .px(px(16.0))
187        .rounded(px(8.0))
188        .bg(bg_color)
189        .shadow_lg()
190        .max_w(px(320.0))
191        .min_w(px(200.0))
192        .text_color(text_color)
193        .text_sm()
194        .child(div().font_weight(crate::FontWeight::SEMIBOLD).child(title));
195
196    if let Some(body_text) = body {
197        toast_div = toast_div.child(
198            div()
199                .text_xs()
200                .text_color(secondary_text_color)
201                .child(body_text),
202        );
203    }
204
205    toast_div.into_any_element()
206}