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}