Skip to main content

gpui_component/
hover_card.rs

1use std::rc::Rc;
2
3use gpui::{
4    Anchor, AnyElement, App, Context, ElementId, InteractiveElement as _, IntoElement,
5    ParentElement, RenderOnce, StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _,
6};
7use gpui_base::HoverCard as BaseHoverCard;
8pub use gpui_base::HoverCardState;
9use instant::Duration;
10
11use crate::{StyledExt as _, popover::Popover};
12
13/// A hover card element that displays content when hovering over a trigger element.
14///
15/// Similar to Popover but triggered by mouse hover instead of click, with configurable delays
16/// for showing and hiding the content. On iOS and Android, tapping the trigger
17/// toggles the card and tapping outside dismisses it; hover delays are ignored.
18#[derive(IntoElement)]
19pub struct HoverCard {
20    id: ElementId,
21    style: StyleRefinement,
22    /// Style for the trigger container, the element laid out in the parent
23    /// and measured to anchor the card.
24    trigger_style: Option<StyleRefinement>,
25    anchor: Anchor,
26    trigger: Option<Box<dyn FnOnce(&mut Window, &App) -> AnyElement + 'static>>,
27    content: Option<
28        Rc<
29            dyn Fn(&mut HoverCardState, &mut Window, &mut Context<HoverCardState>) -> AnyElement
30                + 'static,
31        >,
32    >,
33    children: Vec<AnyElement>,
34    open_delay: Duration,
35    close_delay: Duration,
36    appearance: bool,
37    on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
38}
39
40impl HoverCard {
41    /// Create a new HoverCard.
42    pub fn new(id: impl Into<ElementId>) -> Self {
43        Self {
44            id: id.into(),
45            style: StyleRefinement::default(),
46            trigger_style: None,
47            anchor: Anchor::TopCenter,
48            trigger: None,
49            content: None,
50            children: vec![],
51            open_delay: Duration::from_secs_f64(0.6),
52            close_delay: Duration::from_secs_f64(0.3),
53            appearance: true,
54            on_open_change: None,
55        }
56    }
57
58    /// Set the anchor corner of the hover card, default is [`Anchor::TopCenter`].
59    pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
60        self.anchor = anchor.into();
61        self
62    }
63
64    /// Set the trigger element of the hover card.
65    pub fn trigger<T>(mut self, trigger: T) -> Self
66    where
67        T: IntoElement + 'static,
68    {
69        self.trigger = Some(Box::new(|_, _| trigger.into_any_element()));
70        self
71    }
72
73    /// Set the style for the trigger container.
74    ///
75    /// The container is the element laid out in the parent and measured to
76    /// anchor the card, so this is where `w_full` or `flex_1` must go for the
77    /// trigger to fill its slot.
78    pub fn trigger_style(mut self, style: StyleRefinement) -> Self {
79        self.trigger_style = Some(style);
80        self
81    }
82
83    /// Set the content builder of the hover card.
84    pub fn content<F, E>(mut self, content: F) -> Self
85    where
86        F: Fn(&mut HoverCardState, &mut Window, &mut Context<HoverCardState>) -> E + 'static,
87        E: IntoElement + 'static,
88    {
89        self.content = Some(Rc::new(move |state, window, cx| {
90            content(state, window, cx).into_any_element()
91        }));
92        self
93    }
94
95    /// Set the delay before showing the hover card, default is 600ms.
96    pub fn open_delay(mut self, duration: Duration) -> Self {
97        self.open_delay = duration;
98        self
99    }
100
101    /// Set the delay before hiding the hover card, default is 300ms.
102    pub fn close_delay(mut self, duration: Duration) -> Self {
103        self.close_delay = duration;
104        self
105    }
106
107    /// Set whether to apply default appearance styles, default is `true`.
108    pub fn appearance(mut self, appearance: bool) -> Self {
109        self.appearance = appearance;
110        self
111    }
112
113    /// Set a callback to be called when the open state changes.
114    pub fn on_open_change<F>(mut self, callback: F) -> Self
115    where
116        F: Fn(&bool, &mut Window, &mut App) + 'static,
117    {
118        self.on_open_change = Some(Rc::new(callback));
119        self
120    }
121}
122
123impl Styled for HoverCard {
124    fn style(&mut self) -> &mut StyleRefinement {
125        &mut self.style
126    }
127}
128
129impl ParentElement for HoverCard {
130    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
131        self.children.extend(elements);
132    }
133}
134
135impl RenderOnce for HoverCard {
136    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
137        let Some(trigger) = self.trigger else {
138            return div().id("empty").into_any_element();
139        };
140
141        let anchor = self.anchor;
142        let appearance = self.appearance;
143        let content = self.content;
144        let children = self.children;
145        let style = self.style;
146        let trigger_style = self.trigger_style;
147
148        BaseHoverCard::new(self.id)
149            .anchor(anchor)
150            .open_delay(self.open_delay)
151            .close_delay(self.close_delay)
152            .trigger((trigger)(window, cx))
153            .content(move |state, window, cx| {
154                Popover::render_popover_content(anchor, appearance, window, cx)
155                    .overflow_hidden()
156                    .when_some(content, |this, content| {
157                        this.child((content)(state, window, cx))
158                    })
159                    .children(children)
160                    .refine_style(&style)
161            })
162            .when_some(self.on_open_change, |this, callback| {
163                this.on_open_change(move |open, window, cx| callback(open, window, cx))
164            })
165            .when_some(trigger_style, |this, style| this.refine_style(&style))
166            .into_any_element()
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::{button::Button, h_flex};
174    use gpui::{Context, Render, point, px};
175
176    struct TriggerStyleHarness {
177        styled: bool,
178    }
179
180    impl Render for TriggerStyleHarness {
181        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
182            // Keep the trigger clear of the popup's window margin so the
183            // content position reflects only the trigger container's bounds.
184            div().size_full().child(
185                h_flex()
186                    .absolute()
187                    .left(px(100.))
188                    .top(px(100.))
189                    .w(px(200.))
190                    .child(
191                        HoverCard::new("trigger-style-hover-card")
192                            .appearance(false)
193                            .open_delay(Duration::ZERO)
194                            .anchor(Anchor::TopRight)
195                            .when(self.styled, |this| {
196                                this.trigger_style(StyleRefinement::default().w_full())
197                            })
198                            .trigger(Button::new("styled-trigger").size(px(40.)))
199                            .child(
200                                div()
201                                    .debug_selector(|| "styled-content".into())
202                                    .size(px(20.)),
203                            ),
204                    ),
205            )
206        }
207    }
208
209    /// `trigger_style` styles the container the parent lays out and the card
210    /// is anchored to. Applied to anything nested inside it, `w_full` would
211    /// resolve against a container that only wraps its content.
212    #[gpui::test]
213    fn trigger_style_is_applied_to_the_trigger_container(cx: &mut gpui::TestAppContext) {
214        cx.update(crate::init);
215        let (view, cx) = cx.add_window_view(|_, _| TriggerStyleHarness { styled: false });
216        cx.update(|window, cx| window.draw(cx).clear(cx));
217        cx.simulate_mouse_move(point(px(110.), px(110.)), None, Default::default());
218        cx.run_until_parked();
219        cx.update(|window, cx| {
220            window.draw(cx).clear(cx);
221            window.draw(cx).clear(cx);
222        });
223        // Unstyled: the container wraps the 40px trigger, so the content's right
224        // edge meets the trigger's right edge at 140px.
225        assert_eq!(
226            cx.debug_bounds("styled-content").unwrap().top_right().x,
227            px(140.)
228        );
229
230        cx.update(|window, cx| {
231            view.update(cx, |view, cx| {
232                view.styled = true;
233                cx.notify();
234            });
235            window.draw(cx).clear(cx);
236            window.draw(cx).clear(cx);
237        });
238        // `w_full` stretches the container across the 200px row, and the card
239        // follows the container's right edge at 300px.
240        assert_eq!(
241            cx.debug_bounds("styled-content").unwrap().top_right().x,
242            px(300.)
243        );
244    }
245}