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