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