Skip to main content

gpui_base/
hover_card.rs

1use std::rc::Rc;
2
3use gpui::{
4    Anchor, AnyElement, App, Context, ElementId, InteractiveElement as _, IntoElement,
5    ParentElement as _, Render, RenderOnce, Stateful, StatefulInteractiveElement as _, Task,
6    Window, div, prelude::FluentBuilder as _,
7};
8use instant::Duration;
9
10use crate::Popup;
11
12type ContentBuilder = Box<
13    dyn FnOnce(
14        &mut HoverCardState,
15        &mut Window,
16        &mut Context<HoverCardState>,
17    ) -> Stateful<gpui::Div>,
18>;
19type OpenChangeHandler = Rc<dyn Fn(&bool, &mut Window, &mut App)>;
20
21/// An unstyled hover-triggered popup with delayed open and close behavior.
22#[derive(IntoElement)]
23pub struct HoverCard {
24    id: ElementId,
25    anchor: Anchor,
26    trigger: Option<AnyElement>,
27    content: Option<ContentBuilder>,
28    open_delay: Duration,
29    close_delay: Duration,
30    on_open_change: Option<OpenChangeHandler>,
31}
32
33impl HoverCard {
34    pub fn new(id: impl Into<ElementId>) -> Self {
35        Self {
36            id: id.into(),
37            anchor: Anchor::TopCenter,
38            trigger: None,
39            content: None,
40            open_delay: Duration::from_secs_f64(0.6),
41            close_delay: Duration::from_secs_f64(0.3),
42            on_open_change: None,
43        }
44    }
45
46    pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
47        self.anchor = anchor.into();
48        self
49    }
50
51    pub fn trigger(mut self, trigger: impl IntoElement) -> Self {
52        self.trigger = Some(trigger.into_any_element());
53        self
54    }
55
56    pub fn content<F>(mut self, content: F) -> Self
57    where
58        F: FnOnce(
59                &mut HoverCardState,
60                &mut Window,
61                &mut Context<HoverCardState>,
62            ) -> Stateful<gpui::Div>
63            + 'static,
64    {
65        self.content = Some(Box::new(content));
66        self
67    }
68
69    pub fn open_delay(mut self, duration: Duration) -> Self {
70        self.open_delay = duration;
71        self
72    }
73
74    pub fn close_delay(mut self, duration: Duration) -> Self {
75        self.close_delay = duration;
76        self
77    }
78
79    pub fn on_open_change(
80        mut self,
81        callback: impl Fn(&bool, &mut Window, &mut App) + 'static,
82    ) -> Self {
83        self.on_open_change = Some(Rc::new(callback));
84        self
85    }
86}
87
88/// State exposed to a [`HoverCard::content`] builder.
89pub struct HoverCardState {
90    open: bool,
91    open_delay: Duration,
92    close_delay: Duration,
93    on_open_change: Option<OpenChangeHandler>,
94    open_task: Option<Task<()>>,
95    close_task: Option<Task<()>>,
96    epoch: usize,
97    is_hovering_trigger: bool,
98    is_hovering_content: bool,
99}
100
101impl HoverCardState {
102    fn new(open_delay: Duration, close_delay: Duration) -> Self {
103        Self {
104            open: false,
105            open_delay,
106            close_delay,
107            on_open_change: None,
108            open_task: None,
109            close_task: None,
110            epoch: 0,
111            is_hovering_trigger: false,
112            is_hovering_content: false,
113        }
114    }
115
116    pub fn is_open(&self) -> bool {
117        self.open
118    }
119
120    fn sync(
121        &mut self,
122        open_delay: Duration,
123        close_delay: Duration,
124        on_open_change: Option<OpenChangeHandler>,
125    ) {
126        self.open_delay = open_delay;
127        self.close_delay = close_delay;
128        self.on_open_change = on_open_change;
129    }
130
131    fn schedule_open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
132        self.cancel_tasks();
133        let epoch = self.next_epoch();
134        let delay = self.open_delay;
135        self.open_task = Some(cx.spawn_in(window, async move |this, cx| {
136            cx.background_executor().timer(delay).await;
137            let _ = this.update_in(cx, |state, window, cx| {
138                if state.epoch == epoch {
139                    state.set_open(true, window, cx);
140                }
141            });
142        }));
143    }
144
145    fn schedule_close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
146        self.cancel_tasks();
147        let epoch = self.next_epoch();
148        let delay = self.close_delay;
149        self.close_task = Some(cx.spawn_in(window, async move |this, cx| {
150            cx.background_executor().timer(delay).await;
151            let _ = this.update_in(cx, |state, window, cx| {
152                if state.epoch == epoch && !state.is_hovering_trigger && !state.is_hovering_content
153                {
154                    state.set_open(false, window, cx);
155                }
156            });
157        }));
158    }
159
160    fn cancel_tasks(&mut self) {
161        self.epoch += 1;
162        self.open_task = None;
163        self.close_task = None;
164    }
165
166    fn next_epoch(&mut self) -> usize {
167        self.epoch += 1;
168        self.epoch
169    }
170
171    fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
172        if self.open == open {
173            return;
174        }
175
176        self.open = open;
177        cx.notify();
178        // The change is announced from here rather than from the element,
179        // because the delay timers outlive the `HoverCard` that carried the
180        // handler: by the time the state flips, that element is long gone.
181        if let Some(on_open_change) = self.on_open_change.clone() {
182            on_open_change(&open, window, cx);
183        }
184    }
185
186    fn on_trigger_hover(&mut self, hovering: bool, window: &mut Window, cx: &mut Context<Self>) {
187        self.is_hovering_trigger = hovering;
188        if hovering {
189            self.schedule_open(window, cx);
190        } else if !self.is_hovering_content {
191            self.schedule_close(window, cx);
192        }
193    }
194
195    fn on_content_hover(&mut self, hovering: bool, window: &mut Window, cx: &mut Context<Self>) {
196        self.is_hovering_content = hovering;
197        if hovering {
198            self.cancel_tasks();
199        } else if !self.is_hovering_trigger {
200            self.schedule_close(window, cx);
201        }
202    }
203}
204
205impl Render for HoverCardState {
206    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
207        div()
208    }
209}
210
211impl RenderOnce for HoverCard {
212    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
213        let state = window.use_keyed_state(self.id.clone(), cx, |_, _| {
214            HoverCardState::new(self.open_delay, self.close_delay)
215        });
216        state.update(cx, |state, _| {
217            state.sync(self.open_delay, self.close_delay, self.on_open_change)
218        });
219        let open = state.read(cx).is_open();
220
221        let trigger = self.trigger.unwrap_or_else(|| div().into_any_element());
222        let popup = Popup::new(
223            self.id,
224            div().id("trigger").child(trigger).on_hover(
225                window.listener_for(&state, |state, hovered, window, cx| {
226                    state.on_trigger_hover(*hovered, window, cx)
227                }),
228            ),
229        )
230        .anchor(self.anchor);
231
232        if !open {
233            return popup;
234        }
235
236        popup.when_some(self.content, |popup, content| {
237            let hover = window.listener_for(&state, |state, hovered, window, cx| {
238                state.on_content_hover(*hovered, window, cx)
239            });
240            popup.content(state.update(cx, |state, cx| content(state, window, cx).on_hover(hover)))
241        })
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use std::cell::RefCell;
248
249    use gpui::{Context, Render, Styled as _, TestAppContext, point, px};
250
251    use super::*;
252
253    #[derive(Default)]
254    struct Harness {
255        open_changes: Rc<RefCell<Vec<bool>>>,
256    }
257
258    impl Render for Harness {
259        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
260            let delay = Duration::from_millis(100);
261            let open_changes = self.open_changes.clone();
262            HoverCard::new("hover-card")
263                .open_delay(delay)
264                .close_delay(delay)
265                .on_open_change(move |open, _, _| open_changes.borrow_mut().push(*open))
266                .trigger(
267                    div()
268                        .debug_selector(|| "hover-card-trigger".into())
269                        .size(px(20.)),
270                )
271                .content(|_, _, _| {
272                    div()
273                        .id("hover-card-content")
274                        .debug_selector(|| "hover-card-content".into())
275                        .size(px(10.))
276                })
277        }
278    }
279
280    #[gpui::test]
281    fn public_hover_card_owns_delayed_open_and_close(cx: &mut TestAppContext) {
282        let delay = Duration::from_millis(100);
283        let (_, cx) = cx.add_window_view(|_, _| Harness::default());
284        cx.update(|window, cx| window.draw(cx).clear(cx));
285
286        cx.simulate_mouse_move(point(px(10.), px(10.)), None, gpui::Modifiers::default());
287        cx.executor().advance_clock(delay);
288        cx.run_until_parked();
289        cx.update(|window, cx| {
290            window.draw(cx).clear(cx);
291            window.draw(cx).clear(cx);
292        });
293        assert!(cx.debug_bounds("hover-card-content").is_some());
294
295        cx.simulate_mouse_move(point(px(100.), px(100.)), None, gpui::Modifiers::default());
296        cx.executor().advance_clock(delay);
297        cx.run_until_parked();
298        cx.update(|window, cx| window.draw(cx).clear(cx));
299        assert!(cx.debug_bounds("hover-card-content").is_none());
300    }
301
302    #[gpui::test]
303    fn public_hover_card_reports_each_open_change(cx: &mut TestAppContext) {
304        let delay = Duration::from_millis(100);
305        let open_changes = Rc::new(RefCell::new(Vec::new()));
306        let (_, cx) = cx.add_window_view({
307            let open_changes = open_changes.clone();
308            move |_, _| Harness { open_changes }
309        });
310        cx.update(|window, cx| window.draw(cx).clear(cx));
311
312        cx.simulate_mouse_move(point(px(10.), px(10.)), None, gpui::Modifiers::default());
313        assert_eq!(*open_changes.borrow(), Vec::<bool>::new());
314
315        cx.executor().advance_clock(delay);
316        cx.run_until_parked();
317        cx.update(|window, cx| {
318            window.draw(cx).clear(cx);
319            window.draw(cx).clear(cx);
320        });
321        assert_eq!(*open_changes.borrow(), vec![true]);
322
323        cx.simulate_mouse_move(point(px(100.), px(100.)), None, gpui::Modifiers::default());
324        cx.executor().advance_clock(delay);
325        cx.run_until_parked();
326        cx.update(|window, cx| window.draw(cx).clear(cx));
327        assert_eq!(*open_changes.borrow(), vec![true, false]);
328    }
329}