toast_layer.rs

  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}