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