toast_layer.rs

  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}