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