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. Auto-dismiss is left to the host (call [`ToastStack::remove`]
6//! from a timer) so the manager stays free of executor assumptions.
7
8use gpui::prelude::*;
9use gpui::{
10    deferred, div, px, Context, FontWeight, IntoElement, SharedString, Window,
11};
12
13use crate::theme::{theme, ColorName, Size};
14
15struct Toast {
16    id: usize,
17    title: Option<SharedString>,
18    message: SharedString,
19    color: ColorName,
20}
21
22/// A stack of toasts. Create with `cx.new(|_| ToastStack::new())` and render it
23/// inside a full-size root.
24pub struct ToastStack {
25    toasts: Vec<Toast>,
26    next_id: usize,
27}
28
29impl ToastStack {
30    pub fn new() -> Self {
31        ToastStack {
32            toasts: Vec::new(),
33            next_id: 0,
34        }
35    }
36
37    /// Push a plain message toast. Returns its id (pass to [`remove`]).
38    ///
39    /// [`remove`]: ToastStack::remove
40    pub fn push(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) -> usize {
41        self.push_toast(None, message.into(), ColorName::Blue, cx)
42    }
43
44    /// Push a titled, colored toast.
45    pub fn push_titled(
46        &mut self,
47        title: impl Into<SharedString>,
48        message: impl Into<SharedString>,
49        color: ColorName,
50        cx: &mut Context<Self>,
51    ) -> usize {
52        self.push_toast(Some(title.into()), message.into(), color, cx)
53    }
54
55    fn push_toast(
56        &mut self,
57        title: Option<SharedString>,
58        message: SharedString,
59        color: ColorName,
60        cx: &mut Context<Self>,
61    ) -> usize {
62        let id = self.next_id;
63        self.next_id += 1;
64        self.toasts.push(Toast {
65            id,
66            title,
67            message,
68            color,
69        });
70        cx.notify();
71        id
72    }
73
74    /// Remove a toast by id.
75    pub fn remove(&mut self, id: usize, cx: &mut Context<Self>) {
76        self.toasts.retain(|t| t.id != id);
77        cx.notify();
78    }
79
80    pub fn clear(&mut self, cx: &mut Context<Self>) {
81        self.toasts.clear();
82        cx.notify();
83    }
84
85    pub fn len(&self) -> usize {
86        self.toasts.len()
87    }
88
89    pub fn is_empty(&self) -> bool {
90        self.toasts.is_empty()
91    }
92}
93
94impl Default for ToastStack {
95    fn default() -> Self {
96        ToastStack::new()
97    }
98}
99
100impl Render for ToastStack {
101    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
102        let mut root = div();
103        if self.toasts.is_empty() {
104            return root;
105        }
106
107        let t = theme(cx);
108        let surface = t.surface().hsla();
109        let border = t.border().hsla();
110        let text = t.text().hsla();
111        let dimmed = t.dimmed().hsla();
112        let radius = t.radius(Size::Md);
113        let font_sm = t.font_size(Size::Sm);
114
115        let mut stack = div()
116            .absolute()
117            .top(px(16.0))
118            .right(px(16.0))
119            .flex()
120            .flex_col()
121            .gap(px(10.0));
122
123        for toast in &self.toasts {
124            let id = toast.id;
125            let accent = t.color(toast.color, t.primary_shade()).hsla();
126
127            let mut content = div().flex().flex_col().gap(px(2.0)).flex_1();
128            if let Some(title) = toast.title.clone() {
129                content = content.child(
130                    div()
131                        .font_weight(FontWeight::BOLD)
132                        .text_size(px(font_sm))
133                        .text_color(text)
134                        .child(title),
135                );
136            }
137            content = content.child(
138                div()
139                    .text_size(px(font_sm))
140                    .text_color(dimmed)
141                    .child(toast.message.clone()),
142            );
143
144            let card = div()
145                .flex()
146                .items_start()
147                .gap(px(12.0))
148                .w(px(320.0))
149                .p(px(t.spacing(Size::Md)))
150                .rounded(px(radius))
151                .bg(surface)
152                .border_1()
153                .border_color(border)
154                .shadow_md()
155                .child(div().w(px(4.0)).h(px(38.0)).rounded(px(4.0)).bg(accent))
156                .child(content)
157                .child(
158                    div()
159                        .id(("guise-toast-close", id))
160                        .text_color(dimmed)
161                        .hover(move |s| s.text_color(text))
162                        .child(SharedString::new_static("\u{00d7}"))
163                        .on_click(cx.listener(move |this, _ev, _window, cx| this.remove(id, cx))),
164                );
165
166            stack = stack.child(card);
167        }
168
169        root = root.child(deferred(stack));
170        root
171    }
172}