Skip to main content

guise/overlay/
hovercard.rs

1//! `HoverCard` — an anchored panel that opens on hover (gpui entity).
2//!
3//! The pointer-driven sibling of [`Popover`](super::Popover): a trigger plus a
4//! deferred card, both **builder closures** re-invoked every render. Hovering
5//! the trigger opens the card after a short delay; leaving both the trigger
6//! and the card closes it after another. Moving the pointer onto the card
7//! keeps it open, so its content can be interacted with.
8//!
9//! ```ignore
10//! let card = cx.new(|cx| {
11//!     HoverCard::new(
12//!         cx,
13//!         |_w, _app| Badge::new("@ada").into_any_element(),
14//!         |_w, _app| Text::new("Ada Lovelace — wrote the first program.").into_any_element(),
15//!     )
16//!     .placement(Placement::Bottom)
17//!     .width(260.0)
18//! });
19//! ```
20
21use std::time::Duration;
22
23use gpui::prelude::*;
24use gpui::{deferred, div, px, relative, AnyElement, App, Context, IntoElement, Task, Window};
25
26use super::Placement;
27use crate::theme::theme;
28
29type Builder = Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>;
30
31/// An on-hover floating card. Create with `cx.new(|cx| HoverCard::new(cx, ..))`.
32pub struct HoverCard {
33    open: bool,
34    trigger: Builder,
35    content: Builder,
36    placement: Placement,
37    width: Option<f32>,
38    open_delay: Duration,
39    close_delay: Duration,
40    over_trigger: bool,
41    over_card: bool,
42    /// The one in-flight open/close timer. Replacing it drops (cancels) the
43    /// previous task, so at most one delayed transition is ever pending.
44    pending: Option<Task<()>>,
45}
46
47impl HoverCard {
48    pub fn new(
49        _cx: &mut Context<Self>,
50        trigger: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
51        content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
52    ) -> Self {
53        HoverCard {
54            open: false,
55            trigger: Box::new(trigger),
56            content: Box::new(content),
57            placement: Placement::Bottom,
58            width: None,
59            open_delay: Duration::from_millis(300),
60            close_delay: Duration::from_millis(150),
61            over_trigger: false,
62            over_card: false,
63            pending: None,
64        }
65    }
66
67    pub fn placement(mut self, placement: Placement) -> Self {
68        self.placement = placement;
69        self
70    }
71
72    /// Fix the card width (otherwise it sizes to content, min 180px).
73    pub fn width(mut self, width: f32) -> Self {
74        self.width = Some(width);
75        self
76    }
77
78    /// Hover time before the card opens (default 300ms).
79    pub fn open_delay(mut self, delay: Duration) -> Self {
80        self.open_delay = delay;
81        self
82    }
83
84    /// Grace period after the pointer leaves before the card closes
85    /// (default 150ms) — long enough to travel from trigger to card.
86    pub fn close_delay(mut self, delay: Duration) -> Self {
87        self.close_delay = delay;
88        self
89    }
90
91    pub fn is_open(&self) -> bool {
92        self.open
93    }
94
95    /// Close immediately (e.g. from an action inside the card).
96    pub fn close(&mut self, cx: &mut Context<Self>) {
97        self.pending = None;
98        self.over_trigger = false;
99        self.over_card = false;
100        self.open = false;
101        cx.notify();
102    }
103
104    fn hover_trigger(&mut self, hovered: bool, cx: &mut Context<Self>) {
105        self.over_trigger = hovered;
106        if hovered {
107            self.schedule_open(cx);
108        } else {
109            self.schedule_close(cx);
110        }
111    }
112
113    fn hover_card(&mut self, hovered: bool, cx: &mut Context<Self>) {
114        self.over_card = hovered;
115        if hovered {
116            // Cancel any pending close: the pointer arrived on the card.
117            self.pending = None;
118        } else {
119            self.schedule_close(cx);
120        }
121    }
122
123    fn schedule_open(&mut self, cx: &mut Context<Self>) {
124        if self.open {
125            // Already open — just cancel a pending close.
126            self.pending = None;
127            return;
128        }
129        let delay = self.open_delay;
130        self.pending = Some(cx.spawn(async move |this, cx| {
131            cx.background_executor().timer(delay).await;
132            this.update(cx, |this, cx| {
133                if this.over_trigger && !this.open {
134                    this.open = true;
135                    cx.notify();
136                }
137            })
138            .ok();
139        }));
140    }
141
142    fn schedule_close(&mut self, cx: &mut Context<Self>) {
143        if !self.open {
144            // Not open yet — dropping the pending task cancels a scheduled open.
145            self.pending = None;
146            return;
147        }
148        let delay = self.close_delay;
149        self.pending = Some(cx.spawn(async move |this, cx| {
150            cx.background_executor().timer(delay).await;
151            this.update(cx, |this, cx| {
152                if !this.over_trigger && !this.over_card && this.open {
153                    this.open = false;
154                    cx.notify();
155                }
156            })
157            .ok();
158        }));
159    }
160}
161
162impl Render for HoverCard {
163    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
164        let t = theme(cx);
165        let radius = t.radius(t.default_radius);
166        let surface = t.surface().hsla();
167        let border = t.border().hsla();
168
169        let trigger_el = (self.trigger)(window, cx);
170        let mut wrap = div().relative().child(
171            div()
172                .id("guise-hovercard-trigger")
173                .on_hover(cx.listener(|this, hovered: &bool, _window, cx| {
174                    this.hover_trigger(*hovered, cx);
175                }))
176                .child(trigger_el),
177        );
178
179        if self.open {
180            let content_el = (self.content)(window, cx);
181            let base = div()
182                .id("guise-hovercard-card")
183                .on_hover(cx.listener(|this, hovered: &bool, _window, cx| {
184                    this.hover_card(*hovered, cx);
185                }))
186                .absolute()
187                .flex()
188                .flex_col()
189                .p(px(12.0))
190                .rounded(px(radius))
191                .border_1()
192                .border_color(border)
193                .bg(surface)
194                .shadow_md()
195                .child(content_el);
196            let placed = match self.placement {
197                Placement::Bottom => base.top(relative(1.0)).mt(px(6.0)).left(px(0.0)),
198                Placement::BottomEnd => base.top(relative(1.0)).mt(px(6.0)).right(px(0.0)),
199                Placement::Top => base.bottom(relative(1.0)).mb(px(6.0)).left(px(0.0)),
200                Placement::TopEnd => base.bottom(relative(1.0)).mb(px(6.0)).right(px(0.0)),
201            };
202            let placed = match self.width {
203                Some(w) => placed.w(px(w)),
204                None => placed.min_w(px(180.0)),
205            };
206            wrap = wrap.child(deferred(placed));
207        }
208
209        wrap
210    }
211}