1use std::{
2 rc::Rc,
3 time::{Duration, Instant},
4};
5
6use gpui::{AnyView, DismissEvent, Entity, FocusHandle, ManagedView, Subscription, Task, actions};
7use ui::{animation::DefaultAnimations, prelude::*};
8
9use crate::Workspace;
10
11const DEFAULT_TOAST_DURATION: Duration = Duration::from_secs(10);
12const MINIMUM_RESUME_DURATION: Duration = Duration::from_millis(800);
13
14actions!(toast, [RunAction]);
15
16pub fn init(cx: &mut App) {
17 cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
18 workspace.register_action(|_workspace, _: &RunAction, window, cx| {
19 let workspace = cx.entity();
20 let window = window.window_handle();
21 cx.defer(move |cx| {
22 let action = workspace
23 .read(cx)
24 .toast_layer
25 .read(cx)
26 .active_toast
27 .as_ref()
28 .and_then(|active_toast| active_toast.action.clone());
29
30 if let Some(on_click) = action.and_then(|action| action.on_click) {
31 window
32 .update(cx, |_, window, cx| {
33 on_click(window, cx);
34 })
35 .ok();
36 }
37 });
38 });
39 })
40 .detach();
41}
42
43pub trait ToastView: ManagedView {
44 fn action(&self) -> Option<ToastAction>;
45}
46
47#[derive(Clone)]
48pub struct ToastAction {
49 pub id: ElementId,
50 pub label: SharedString,
51 pub on_click: Option<Rc<dyn Fn(&mut Window, &mut App) + 'static>>,
52}
53
54impl ToastAction {
55 pub fn new(
56 label: SharedString,
57 on_click: Option<Rc<dyn Fn(&mut Window, &mut App) + 'static>>,
58 ) -> Self {
59 let id = ElementId::Name(label.clone());
60
61 Self {
62 id,
63 label,
64 on_click,
65 }
66 }
67}
68
69trait ToastViewHandle {
70 fn view(&self) -> AnyView;
71}
72
73impl<V: ToastView> ToastViewHandle for Entity<V> {
74 fn view(&self) -> AnyView {
75 self.clone().into()
76 }
77}
78
79pub struct ActiveToast {
80 toast: Box<dyn ToastViewHandle>,
81 action: Option<ToastAction>,
82 _subscriptions: [Subscription; 1],
83 focus_handle: FocusHandle,
84}
85
86struct DismissTimer {
87 instant_started: Instant,
88 _task: Task<()>,
89}
90
91pub struct ToastLayer {
92 active_toast: Option<ActiveToast>,
93 duration_remaining: Option<Duration>,
94 dismiss_timer: Option<DismissTimer>,
95}
96
97impl Default for ToastLayer {
98 fn default() -> Self {
99 Self::new()
100 }
101}
102
103impl ToastLayer {
104 pub fn new() -> Self {
105 Self {
106 active_toast: None,
107 duration_remaining: None,
108 dismiss_timer: None,
109 }
110 }
111
112 pub fn toggle_toast<V>(&mut self, cx: &mut Context<Self>, new_toast: Entity<V>)
113 where
114 V: ToastView,
115 {
116 if let Some(active_toast) = &self.active_toast {
117 let is_close = active_toast.toast.view().downcast::<V>().is_ok();
118 let did_close = self.hide_toast(cx);
119 if is_close || !did_close {
120 return;
121 }
122 }
123 self.show_toast(new_toast, cx);
124 }
125
126 pub fn show_toast<V>(&mut self, new_toast: Entity<V>, cx: &mut Context<Self>)
127 where
128 V: ToastView,
129 {
130 let action = new_toast.read(cx).action();
131 let focus_handle = cx.focus_handle();
132
133 self.active_toast = Some(ActiveToast {
134 toast: Box::new(new_toast.clone()),
135 action,
136 _subscriptions: [cx.subscribe(&new_toast, |this, _, _: &DismissEvent, cx| {
137 this.hide_toast(cx);
138 })],
139 focus_handle,
140 });
141
142 self.start_dismiss_timer(DEFAULT_TOAST_DURATION, cx);
143
144 cx.notify();
145 }
146
147 pub fn hide_toast(&mut self, cx: &mut Context<Self>) -> bool {
148 self.active_toast.take();
149 cx.notify();
150
151 true
152 }
153
154 pub fn active_toast<V>(&self) -> Option<Entity<V>>
155 where
156 V: 'static,
157 {
158 let active_toast = self.active_toast.as_ref()?;
159 active_toast.toast.view().downcast::<V>().ok()
160 }
161
162 pub fn has_active_toast(&self) -> bool {
163 self.active_toast.is_some()
164 }
165
166 fn pause_dismiss_timer(&mut self) {
167 let Some(dismiss_timer) = self.dismiss_timer.take() else {
168 return;
169 };
170 let Some(duration_remaining) = self.duration_remaining.as_mut() else {
171 return;
172 };
173 *duration_remaining =
174 duration_remaining.saturating_sub(dismiss_timer.instant_started.elapsed());
175 if *duration_remaining < MINIMUM_RESUME_DURATION {
176 *duration_remaining = MINIMUM_RESUME_DURATION;
177 }
178 }
179
180 /// Starts a timer to automatically dismiss the toast after the specified duration
181 pub fn start_dismiss_timer(&mut self, duration: Duration, cx: &mut Context<Self>) {
182 self.clear_dismiss_timer(cx);
183
184 let instant_started = std::time::Instant::now();
185 let task = cx.spawn(async move |this, cx| {
186 cx.background_executor().timer(duration).await;
187
188 if let Some(this) = this.upgrade() {
189 this.update(cx, |this, cx| this.hide_toast(cx)).ok();
190 }
191 });
192
193 self.duration_remaining = Some(duration);
194 self.dismiss_timer = Some(DismissTimer {
195 instant_started,
196 _task: task,
197 });
198 cx.notify();
199 }
200
201 /// Restarts the dismiss timer with a new duration
202 pub fn restart_dismiss_timer(&mut self, cx: &mut Context<Self>) {
203 let Some(duration) = self.duration_remaining else {
204 return;
205 };
206 self.start_dismiss_timer(duration, cx);
207 cx.notify();
208 }
209
210 /// Clears the dismiss timer if one exists
211 pub fn clear_dismiss_timer(&mut self, cx: &mut Context<Self>) {
212 self.dismiss_timer.take();
213 cx.notify();
214 }
215}
216
217impl Render for ToastLayer {
218 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
219 let Some(active_toast) = &self.active_toast else {
220 return div();
221 };
222 let handle = cx.weak_entity();
223
224 div().absolute().size_full().bottom_0().left_0().child(
225 v_flex()
226 .id("toast-layer-container")
227 .absolute()
228 .w_full()
229 .bottom(px(0.))
230 .flex()
231 .flex_col()
232 .items_center()
233 .track_focus(&active_toast.focus_handle)
234 .child(
235 h_flex()
236 .id("active-toast-container")
237 .occlude()
238 .on_hover(move |hover_start, _window, cx| {
239 let Some(this) = handle.upgrade() else {
240 return;
241 };
242 if *hover_start {
243 this.update(cx, |this, _| this.pause_dismiss_timer());
244 } else {
245 this.update(cx, |this, cx| this.restart_dismiss_timer(cx));
246 }
247 cx.stop_propagation();
248 })
249 .on_click(|_, _, cx| {
250 cx.stop_propagation();
251 })
252 .child(active_toast.toast.view()),
253 )
254 .animate_in(AnimationDirection::FromBottom, true),
255 )
256 }
257}