Skip to main content

guise/feedback/
toast.rs

1//! `ToastStack` — a positioned, stacking toast manager (gpui entity).
2//!
3//! Holds a list of live toasts and paints them as a deferred, top-right stack
4//! above the page. Push from anywhere you hold the entity handle; each card has
5//! a close button. Pushed toasts auto-dismiss after four seconds by default;
6//! set [`ToastStack::duration`] to change the delay or pass `None` to keep
7//! toasts until closed.
8
9use std::time::Duration;
10
11use gpui::prelude::*;
12use gpui::{deferred, div, px, Context, FontWeight, IntoElement, SharedString, Window};
13
14use crate::devtools::ProbedAny;
15use crate::theme::{theme, ColorName, Size};
16
17struct Toast {
18    id: usize,
19    title: Option<SharedString>,
20    message: SharedString,
21    color: ColorName,
22}
23
24/// A stack of toasts. Create with `cx.new(|_| ToastStack::new())` and render it
25/// inside a full-size root.
26pub struct ToastStack {
27    toasts: Vec<Toast>,
28    next_id: usize,
29    duration: Option<Duration>,
30}
31
32impl ToastStack {
33    pub fn new() -> Self {
34        ToastStack {
35            toasts: Vec::new(),
36            next_id: 0,
37            duration: Some(Duration::from_secs(4)),
38        }
39    }
40
41    /// Set the auto-dismiss delay for subsequently pushed toasts. `None`
42    /// keeps toasts until closed. Chainable, so it slots into construction:
43    /// `cx.new(|_| ToastStack::new().duration(None))`.
44    pub fn duration(mut self, duration: Option<Duration>) -> Self {
45        self.set_duration(duration);
46        self
47    }
48
49    /// [`duration`](ToastStack::duration) for an already-built stack, e.g.
50    /// inside `entity.update(cx, ...)` right before a sticky push.
51    pub fn set_duration(&mut self, duration: Option<Duration>) {
52        self.duration = duration;
53    }
54
55    /// Push a plain message toast. Returns its id (pass to [`remove`]).
56    ///
57    /// [`remove`]: ToastStack::remove
58    pub fn push(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) -> usize {
59        self.push_toast(None, message.into(), ColorName::Blue, cx)
60    }
61
62    /// Push a titled, colored toast.
63    pub fn push_titled(
64        &mut self,
65        title: impl Into<SharedString>,
66        message: impl Into<SharedString>,
67        color: ColorName,
68        cx: &mut Context<Self>,
69    ) -> usize {
70        self.push_toast(Some(title.into()), message.into(), color, cx)
71    }
72
73    fn push_toast(
74        &mut self,
75        title: Option<SharedString>,
76        message: SharedString,
77        color: ColorName,
78        cx: &mut Context<Self>,
79    ) -> usize {
80        let id = self.next_id;
81        self.next_id += 1;
82        self.toasts.push(Toast {
83            id,
84            title,
85            message,
86            color,
87        });
88        cx.notify();
89
90        if let Some(delay) = self.duration {
91            // Ids are never reused, so this removes exactly this toast (or
92            // nothing, if it was closed by hand first).
93            cx.spawn(async move |this, cx| {
94                cx.background_executor().timer(delay).await;
95                this.update(cx, |this, cx| this.remove(id, cx)).ok();
96            })
97            .detach();
98        }
99
100        id
101    }
102
103    /// Remove a toast by id.
104    pub fn remove(&mut self, id: usize, cx: &mut Context<Self>) {
105        self.toasts.retain(|t| t.id != id);
106        cx.notify();
107    }
108
109    pub fn clear(&mut self, cx: &mut Context<Self>) {
110        self.toasts.clear();
111        cx.notify();
112    }
113
114    pub fn len(&self) -> usize {
115        self.toasts.len()
116    }
117
118    pub fn is_empty(&self) -> bool {
119        self.toasts.is_empty()
120    }
121}
122
123impl Default for ToastStack {
124    fn default() -> Self {
125        ToastStack::new()
126    }
127}
128
129impl Render for ToastStack {
130    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
131        let mut root = div();
132        if self.toasts.is_empty() {
133            return root.into_any_element();
134        }
135
136        let t = theme(cx);
137        let surface = t.surface().hsla();
138        let border = t.border().hsla();
139        let text = t.text().hsla();
140        let dimmed = t.dimmed().hsla();
141        let radius = t.radius(Size::Md);
142        let font_sm = t.font_size(Size::Sm);
143
144        let mut stack = div()
145            .absolute()
146            .top(px(16.0))
147            .right(px(16.0))
148            .flex()
149            .flex_col()
150            .gap(px(10.0));
151
152        for toast in &self.toasts {
153            let id = toast.id;
154            let accent = t.color(toast.color, t.primary_shade()).hsla();
155
156            let mut content = div().flex().flex_col().gap(px(2.0)).flex_1();
157            if let Some(title) = toast.title.clone() {
158                content = content.child(
159                    div()
160                        .font_weight(FontWeight::BOLD)
161                        .text_size(px(font_sm))
162                        .text_color(text)
163                        .child(title),
164                );
165            }
166            content = content.child(
167                div()
168                    .text_size(px(font_sm))
169                    .text_color(dimmed)
170                    .child(toast.message.clone()),
171            );
172
173            let card = div()
174                .flex()
175                .items_start()
176                .gap(px(12.0))
177                .w(px(320.0))
178                .p(px(t.spacing(Size::Md)))
179                .rounded(px(radius))
180                .bg(surface)
181                .border_1()
182                .border_color(border)
183                .shadow_md()
184                .child(div().w(px(4.0)).h(px(38.0)).rounded(px(4.0)).bg(accent))
185                .child(content)
186                .child(
187                    div()
188                        .id(("guise-toast-close", id))
189                        .text_color(dimmed)
190                        .hover(move |s| s.text_color(text))
191                        .child(SharedString::new_static("\u{00d7}"))
192                        .on_click(cx.listener(move |this, _ev, _window, cx| this.remove(id, cx))),
193                );
194
195            stack = stack.child(card);
196        }
197
198        root = root.child(deferred(stack));
199        root.probe_any("ToastStack").into_any_element()
200    }
201}