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 popup with delayed hover behavior on desktop.
22/// On iOS and Android, tapping toggles it and tapping outside dismisses it.
23#[derive(IntoElement)]
24pub struct HoverCard {
25    id: ElementId,
26    anchor: Anchor,
27    tap_to_open: bool,
28    trigger: Option<AnyElement>,
29    content: Option<ContentBuilder>,
30    open_delay: Duration,
31    close_delay: Duration,
32    on_open_change: Option<OpenChangeHandler>,
33}
34
35impl HoverCard {
36    pub fn new(id: impl Into<ElementId>) -> Self {
37        Self {
38            id: id.into(),
39            anchor: Anchor::TopCenter,
40            tap_to_open: crate::is_mobile(),
41            trigger: None,
42            content: None,
43            open_delay: Duration::from_secs_f64(0.6),
44            close_delay: Duration::from_secs_f64(0.3),
45            on_open_change: None,
46        }
47    }
48
49    pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
50        self.anchor = anchor.into();
51        self
52    }
53
54    pub fn trigger(mut self, trigger: impl IntoElement) -> Self {
55        self.trigger = Some(trigger.into_any_element());
56        self
57    }
58
59    pub fn content<F>(mut self, content: F) -> Self
60    where
61        F: FnOnce(
62                &mut HoverCardState,
63                &mut Window,
64                &mut Context<HoverCardState>,
65            ) -> Stateful<gpui::Div>
66            + 'static,
67    {
68        self.content = Some(Box::new(content));
69        self
70    }
71
72    pub fn open_delay(mut self, duration: Duration) -> Self {
73        self.open_delay = duration;
74        self
75    }
76
77    pub fn close_delay(mut self, duration: Duration) -> Self {
78        self.close_delay = duration;
79        self
80    }
81
82    pub fn on_open_change(
83        mut self,
84        callback: impl Fn(&bool, &mut Window, &mut App) + 'static,
85    ) -> Self {
86        self.on_open_change = Some(Rc::new(callback));
87        self
88    }
89}
90
91/// State exposed to a [`HoverCard::content`] builder.
92pub struct HoverCardState {
93    open: bool,
94    open_delay: Duration,
95    close_delay: Duration,
96    on_open_change: Option<OpenChangeHandler>,
97    open_task: Option<Task<()>>,
98    close_task: Option<Task<()>>,
99    epoch: usize,
100    is_hovering_trigger: bool,
101    is_hovering_content: bool,
102}
103
104impl HoverCardState {
105    fn new(open_delay: Duration, close_delay: Duration) -> Self {
106        Self {
107            open: false,
108            open_delay,
109            close_delay,
110            on_open_change: None,
111            open_task: None,
112            close_task: None,
113            epoch: 0,
114            is_hovering_trigger: false,
115            is_hovering_content: false,
116        }
117    }
118
119    pub fn is_open(&self) -> bool {
120        self.open
121    }
122
123    fn sync(
124        &mut self,
125        open_delay: Duration,
126        close_delay: Duration,
127        on_open_change: Option<OpenChangeHandler>,
128    ) {
129        self.open_delay = open_delay;
130        self.close_delay = close_delay;
131        self.on_open_change = on_open_change;
132    }
133
134    fn schedule_open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
135        self.cancel_tasks();
136        let epoch = self.next_epoch();
137        let delay = self.open_delay;
138        self.open_task = Some(cx.spawn_in(window, async move |this, cx| {
139            cx.background_executor().timer(delay).await;
140            let _ = this.update_in(cx, |state, window, cx| {
141                if state.epoch == epoch {
142                    state.set_open(true, window, cx);
143                }
144            });
145        }));
146    }
147
148    fn schedule_close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
149        self.cancel_tasks();
150        let epoch = self.next_epoch();
151        let delay = self.close_delay;
152        self.close_task = Some(cx.spawn_in(window, async move |this, cx| {
153            cx.background_executor().timer(delay).await;
154            let _ = this.update_in(cx, |state, window, cx| {
155                if state.epoch == epoch && !state.is_hovering_trigger && !state.is_hovering_content
156                {
157                    state.set_open(false, window, cx);
158                }
159            });
160        }));
161    }
162
163    fn cancel_tasks(&mut self) {
164        self.epoch += 1;
165        self.open_task = None;
166        self.close_task = None;
167    }
168
169    fn next_epoch(&mut self) -> usize {
170        self.epoch += 1;
171        self.epoch
172    }
173
174    fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
175        if self.open == open {
176            return;
177        }
178
179        self.open = open;
180        cx.notify();
181        // The change is announced from here rather than from the element,
182        // because the delay timers outlive the `HoverCard` that carried the
183        // handler: by the time the state flips, that element is long gone.
184        if let Some(on_open_change) = self.on_open_change.clone() {
185            on_open_change(&open, window, cx);
186        }
187    }
188
189    fn on_trigger_hover(&mut self, hovering: bool, window: &mut Window, cx: &mut Context<Self>) {
190        self.is_hovering_trigger = hovering;
191        if hovering {
192            self.schedule_open(window, cx);
193        } else if !self.is_hovering_content {
194            self.schedule_close(window, cx);
195        }
196    }
197
198    fn on_content_hover(&mut self, hovering: bool, window: &mut Window, cx: &mut Context<Self>) {
199        self.is_hovering_content = hovering;
200        if hovering {
201            self.cancel_tasks();
202        } else if !self.is_hovering_trigger {
203            self.schedule_close(window, cx);
204        }
205    }
206}
207
208impl Render for HoverCardState {
209    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
210        div()
211    }
212}
213
214impl RenderOnce for HoverCard {
215    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
216        let state = window.use_keyed_state(self.id.clone(), cx, |_, _| {
217            HoverCardState::new(self.open_delay, self.close_delay)
218        });
219        state.update(cx, |state, _| {
220            state.sync(self.open_delay, self.close_delay, self.on_open_change)
221        });
222        let open = state.read(cx).is_open();
223
224        let trigger = self.trigger.unwrap_or_else(|| div().into_any_element());
225        let popup = Popup::new(
226            self.id,
227            div()
228                .id("trigger")
229                .child(trigger)
230                .when(self.tap_to_open, |trigger| {
231                    trigger.on_click(window.listener_for(&state, move |state, _, window, cx| {
232                        state.cancel_tasks();
233                        // Toggle the state rendered by this trigger even if
234                        // outside dismissal handles the same release first.
235                        state.set_open(!open, window, cx);
236                    }))
237                })
238                .when(!self.tap_to_open, |trigger| {
239                    trigger.on_hover(window.listener_for(&state, |state, hovered, window, cx| {
240                        state.on_trigger_hover(*hovered, window, cx)
241                    }))
242                }),
243        )
244        .anchor(self.anchor);
245
246        if !open {
247            return popup;
248        }
249
250        popup.when_some(self.content, |popup, content| {
251            let hover = window.listener_for(&state, |state, hovered, window, cx| {
252                state.on_content_hover(*hovered, window, cx)
253            });
254            let dismiss = window.listener_for(&state, |state, _, window, cx| {
255                state.cancel_tasks();
256                state.set_open(false, window, cx);
257            });
258            popup.content(state.update(cx, |state, cx| {
259                content(state, window, cx)
260                    .when(self.tap_to_open, |content| {
261                        content.on_mouse_up_out(gpui::MouseButton::Left, dismiss)
262                    })
263                    .when(!self.tap_to_open, |content| content.on_hover(hover))
264            }))
265        })
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use std::cell::RefCell;
272
273    use gpui::{Context, Render, Styled as _, TestAppContext, point, px};
274
275    use super::*;
276
277    #[derive(Default)]
278    struct Harness {
279        open_changes: Rc<RefCell<Vec<bool>>>,
280        tap_to_open: bool,
281    }
282
283    impl Render for Harness {
284        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
285            let delay = Duration::from_millis(100);
286            let open_changes = self.open_changes.clone();
287            HoverCard::new("hover-card")
288                .map(|mut card| {
289                    card.tap_to_open = self.tap_to_open;
290                    card
291                })
292                .open_delay(delay)
293                .close_delay(delay)
294                .on_open_change(move |open, _, _| open_changes.borrow_mut().push(*open))
295                .trigger(
296                    div()
297                        .debug_selector(|| "hover-card-trigger".into())
298                        .size(px(20.)),
299                )
300                .content(|_, _, _| {
301                    div()
302                        .id("hover-card-content")
303                        .debug_selector(|| "hover-card-content".into())
304                        .size(px(10.))
305                })
306        }
307    }
308
309    #[gpui::test]
310    fn public_hover_card_owns_delayed_open_and_close(cx: &mut TestAppContext) {
311        let delay = Duration::from_millis(100);
312        let (_, cx) = cx.add_window_view(|_, _| Harness::default());
313        cx.update(|window, cx| window.draw(cx).clear(cx));
314
315        cx.simulate_mouse_move(point(px(10.), px(10.)), None, gpui::Modifiers::default());
316        cx.executor().advance_clock(delay);
317        cx.run_until_parked();
318        cx.update(|window, cx| {
319            window.draw(cx).clear(cx);
320            window.draw(cx).clear(cx);
321        });
322        assert!(cx.debug_bounds("hover-card-content").is_some());
323
324        cx.simulate_mouse_move(point(px(100.), px(100.)), None, gpui::Modifiers::default());
325        cx.executor().advance_clock(delay);
326        cx.run_until_parked();
327        cx.update(|window, cx| window.draw(cx).clear(cx));
328        assert!(cx.debug_bounds("hover-card-content").is_none());
329    }
330
331    #[gpui::test]
332    fn public_hover_card_reports_each_open_change(cx: &mut TestAppContext) {
333        let delay = Duration::from_millis(100);
334        let open_changes = Rc::new(RefCell::new(Vec::new()));
335        let (_, cx) = cx.add_window_view({
336            let open_changes = open_changes.clone();
337            move |_, _| Harness {
338                open_changes,
339                ..Default::default()
340            }
341        });
342        cx.update(|window, cx| window.draw(cx).clear(cx));
343
344        cx.simulate_mouse_move(point(px(10.), px(10.)), None, gpui::Modifiers::default());
345        assert_eq!(*open_changes.borrow(), Vec::<bool>::new());
346
347        cx.executor().advance_clock(delay);
348        cx.run_until_parked();
349        cx.update(|window, cx| {
350            window.draw(cx).clear(cx);
351            window.draw(cx).clear(cx);
352        });
353        assert_eq!(*open_changes.borrow(), vec![true]);
354
355        cx.simulate_mouse_move(point(px(100.), px(100.)), None, gpui::Modifiers::default());
356        cx.executor().advance_clock(delay);
357        cx.run_until_parked();
358        cx.update(|window, cx| window.draw(cx).clear(cx));
359        assert_eq!(*open_changes.borrow(), vec![true, false]);
360    }
361    #[gpui::test]
362    fn tap_card_ignores_hover_and_toggles_and_dismisses(cx: &mut TestAppContext) {
363        let changes = Rc::new(RefCell::new(Vec::new()));
364        let (_, cx) = cx.add_window_view({
365            let changes = changes.clone();
366            move |_, _| Harness {
367                tap_to_open: true,
368                open_changes: changes,
369            }
370        });
371        cx.update(|window, cx| window.draw(cx).clear(cx));
372        cx.simulate_mouse_move(point(px(10.), px(10.)), None, Default::default());
373        cx.executor().advance_clock(Duration::from_secs(1));
374        cx.run_until_parked();
375        assert!(changes.borrow().is_empty());
376
377        cx.simulate_click(point(px(10.), px(10.)), Default::default());
378        cx.update(|window, cx| {
379            window.draw(cx).clear(cx);
380            window.draw(cx).clear(cx);
381        });
382        assert!(cx.debug_bounds("hover-card-content").is_some());
383        cx.simulate_mouse_move(point(px(100.), px(100.)), None, Default::default());
384        cx.executor().advance_clock(Duration::from_secs(1));
385        cx.run_until_parked();
386        assert_eq!(*changes.borrow(), vec![true]);
387
388        cx.simulate_click(point(px(10.), px(10.)), Default::default());
389        cx.update(|window, cx| window.draw(cx).clear(cx));
390        assert!(cx.debug_bounds("hover-card-content").is_none());
391        assert_eq!(*changes.borrow(), vec![true, false]);
392
393        cx.simulate_click(point(px(10.), px(10.)), Default::default());
394        cx.update(|window, cx| {
395            window.draw(cx).clear(cx);
396            window.draw(cx).clear(cx);
397        });
398        let bounds = cx.debug_bounds("hover-card-content").unwrap();
399        cx.simulate_click(bounds.center(), Default::default());
400        assert_eq!(*changes.borrow(), vec![true, false, true]);
401        cx.simulate_click(point(px(100.), px(100.)), Default::default());
402        cx.update(|window, cx| window.draw(cx).clear(cx));
403        assert!(cx.debug_bounds("hover-card-content").is_none());
404        assert_eq!(*changes.borrow(), vec![true, false, true, false]);
405    }
406}