Skip to main content

gpui_base/
tooltip.rs

1use std::{rc::Rc, time::Duration};
2
3use gpui::{
4    AnyElement, AnyView, App, Bounds, Context, Div, ElementId, InteractiveElement, IntoElement,
5    ParentElement, Pixels, Render, RenderOnce, Role, Stateful, StatefulInteractiveElement, Styled,
6    Task, Window, deferred, div, prelude::FluentBuilder as _, px,
7};
8
9use crate::{Placement, Positioner};
10
11const TOOLTIP_PRIORITY: usize = 200;
12const WINDOW_MARGIN: Pixels = px(4.);
13const GRACE_PERIOD: Duration = Duration::from_millis(300);
14const SHOW_DELAY: Duration = Duration::from_millis(500);
15
16type TooltipBuilder = Rc<dyn Fn(&mut Window, &mut App) -> AnyView>;
17type TooltipRenderer = Rc<dyn Fn(AnyView, TooltipTransition, &mut Window, &mut App) -> AnyElement>;
18
19/// An unstyled tooltip popup.
20///
21/// This corresponds to Base UI's `Tooltip.Popup`: it owns the accessible
22/// tooltip role and accepts application-owned content and presentation.
23#[derive(IntoElement)]
24pub struct Tooltip {
25    base: Stateful<Div>,
26}
27
28impl Tooltip {
29    pub fn new(id: impl Into<ElementId>) -> Self {
30        Self {
31            base: div().id(id).role(Role::Tooltip),
32        }
33    }
34}
35
36impl Styled for Tooltip {
37    fn style(&mut self) -> &mut gpui::StyleRefinement {
38        self.base.style()
39    }
40}
41
42impl ParentElement for Tooltip {
43    fn extend(&mut self, children: impl IntoIterator<Item = AnyElement>) {
44        self.base.extend(children);
45    }
46}
47
48impl RenderOnce for Tooltip {
49    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
50        self.base
51    }
52}
53
54/// Content requested by a tooltip trigger.
55#[derive(Clone)]
56pub struct TooltipRequest {
57    build: TooltipBuilder,
58    trigger_bounds: Bounds<Pixels>,
59    preferred_placement: Option<Placement>,
60}
61
62impl TooltipRequest {
63    pub fn new(
64        trigger_bounds: Bounds<Pixels>,
65        build: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
66    ) -> Self {
67        Self {
68            build: Rc::new(build),
69            trigger_bounds,
70            preferred_placement: None,
71        }
72    }
73
74    pub fn placement(mut self, placement: Placement) -> Self {
75        self.preferred_placement = Some(placement);
76        self
77    }
78}
79
80/// Presentation transition requested by the Base tooltip lifecycle.
81#[derive(Clone, Copy, Debug)]
82pub enum TooltipTransition {
83    Enter {
84        epoch: usize,
85    },
86    Switch {
87        epoch: usize,
88        previous: Bounds<Pixels>,
89        current: Bounds<Pixels>,
90    },
91}
92
93/// Per-window tooltip provider and overlay.
94///
95/// Show requests are ignored on iOS and Android, where touch input must not
96/// open hover tooltips. This does not control GPUI's native `.tooltip()` API.
97pub struct TooltipOverlay {
98    enabled: bool,
99    content: Option<TooltipRequest>,
100    previous_bounds: Option<Bounds<Pixels>>,
101    epoch: usize,
102    had_recent_tooltip: bool,
103    animation_epoch: usize,
104    is_switching: bool,
105    show_task: Option<Task<()>>,
106    hide_task: Option<Task<()>>,
107    renderer: TooltipRenderer,
108}
109
110impl TooltipOverlay {
111    pub fn new() -> Self {
112        Self {
113            enabled: !crate::is_mobile(),
114            content: None,
115            previous_bounds: None,
116            epoch: 0,
117            had_recent_tooltip: false,
118            animation_epoch: 0,
119            is_switching: false,
120            show_task: None,
121            hide_task: None,
122            renderer: Rc::new(|view, _, _, _| div().child(view).into_any_element()),
123        }
124    }
125
126    pub fn render_with(
127        mut self,
128        renderer: impl Fn(AnyView, TooltipTransition, &mut Window, &mut App) -> AnyElement + 'static,
129    ) -> Self {
130        self.renderer = Rc::new(renderer);
131        self
132    }
133
134    fn next_epoch(&mut self) -> usize {
135        self.epoch += 1;
136        self.epoch
137    }
138
139    pub fn request_show(
140        &mut self,
141        content: TooltipRequest,
142        window: &mut Window,
143        cx: &mut Context<Self>,
144    ) {
145        // Gate both delayed display and the immediate grace-period switch.
146        // Keep this in Base so every managed component shares the policy.
147        if !self.enabled {
148            return;
149        }
150        self.hide_task = None;
151        let was_visible = self.content.is_some();
152        if was_visible || self.had_recent_tooltip {
153            self.previous_bounds = self.content.as_ref().map(|content| content.trigger_bounds);
154            self.content = Some(content);
155            self.show_task = None;
156            self.is_switching = was_visible;
157            self.animation_epoch += 1;
158            cx.notify();
159            return;
160        }
161
162        let epoch = self.next_epoch();
163        self.show_task = Some(cx.spawn_in(window, async move |this, cx| {
164            cx.background_executor().timer(SHOW_DELAY).await;
165            let _ = this.update_in(cx, |this, _, cx| {
166                if this.epoch == epoch {
167                    this.content = Some(content);
168                    this.previous_bounds = None;
169                    this.is_switching = false;
170                    this.animation_epoch += 1;
171                    cx.notify();
172                }
173            });
174        }));
175    }
176
177    pub fn request_hide(&mut self, window: &mut Window, cx: &mut Context<Self>) {
178        self.show_task = None;
179        if self.content.is_none() {
180            return;
181        }
182        let epoch = self.next_epoch();
183        self.had_recent_tooltip = true;
184        self.hide_task = Some(cx.spawn_in(window, async move |this, cx| {
185            cx.background_executor().timer(GRACE_PERIOD).await;
186            let _ = this.update_in(cx, |this, _, cx| {
187                if this.epoch == epoch {
188                    this.content = None;
189                    this.previous_bounds = None;
190                    this.had_recent_tooltip = false;
191                    cx.notify();
192                }
193            });
194        }));
195    }
196
197    pub fn hide(&mut self, cx: &mut Context<Self>) {
198        let changed = self.content.is_some()
199            || self.previous_bounds.is_some()
200            || self.had_recent_tooltip
201            || self.show_task.is_some()
202            || self.hide_task.is_some();
203        self.content = None;
204        self.previous_bounds = None;
205        self.had_recent_tooltip = false;
206        self.is_switching = false;
207        self.show_task = None;
208        self.hide_task = None;
209        if changed {
210            cx.notify();
211        }
212    }
213}
214
215impl Default for TooltipOverlay {
216    fn default() -> Self {
217        Self::new()
218    }
219}
220
221impl Render for TooltipOverlay {
222    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
223        let Some(content) = self.content.as_ref() else {
224            return div().into_any_element();
225        };
226        let view = (content.build)(window, cx);
227        let transition = match (self.is_switching, self.previous_bounds) {
228            (true, Some(previous)) => TooltipTransition::Switch {
229                epoch: self.animation_epoch,
230                previous,
231                current: content.trigger_bounds,
232            },
233            _ => TooltipTransition::Enter {
234                epoch: self.animation_epoch,
235            },
236        };
237        let rendered = (self.renderer)(view, transition, window, cx);
238        deferred(
239            TooltipPositioner::new(content.trigger_bounds)
240                .when_some(content.preferred_placement, |this, placement| {
241                    this.placement(placement)
242                })
243                .child(rendered),
244        )
245        .with_priority(TOOLTIP_PRIORITY)
246        .into_any_element()
247    }
248}
249
250/// An unstyled tooltip positioner with viewport-aware flipping and clamping.
251///
252/// This is a tooltip-named view of [`crate::Positioner`]'s side placement. It
253/// adds no element of its own; the shared positioner is what gets rendered.
254pub struct TooltipPositioner(Positioner);
255
256impl TooltipPositioner {
257    pub fn new(trigger_bounds: Bounds<Pixels>) -> Self {
258        Self(Positioner::side(trigger_bounds).margin(WINDOW_MARGIN))
259    }
260
261    pub fn placement(mut self, placement: Placement) -> Self {
262        self.0 = self.0.placement(placement);
263        self
264    }
265}
266
267impl ParentElement for TooltipPositioner {
268    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
269        self.0.extend(elements);
270    }
271}
272
273impl IntoElement for TooltipPositioner {
274    type Element = Positioner;
275
276    fn into_element(self) -> Self::Element {
277        self.0
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use gpui::{AppContext as _, point, size};
285
286    fn bounds(x: f32, y: f32, width: f32, height: f32) -> Bounds<Pixels> {
287        Bounds::new(point(px(x), px(y)), size(px(width), px(height)))
288    }
289
290    #[gpui::test]
291    fn provider_owns_grace_switch_and_dismiss(cx: &mut gpui::TestAppContext) {
292        let state = cx.update(|cx| cx.new(|_| TooltipOverlay::new()));
293        let cx = cx.add_empty_window();
294        cx.update(|window, cx| {
295            state.update(cx, |tooltip, cx| {
296                tooltip.had_recent_tooltip = true;
297                tooltip.request_show(
298                    TooltipRequest::new(bounds(0., 0., 20., 20.), |_, _| {
299                        panic!("content is not rendered by this lifecycle test")
300                    }),
301                    window,
302                    cx,
303                );
304            });
305        });
306        cx.update(|_, cx| assert!(state.read(cx).content.is_some()));
307
308        cx.update(|_, cx| {
309            state.update(cx, |tooltip, cx| tooltip.hide(cx));
310        });
311        cx.update(|_, cx| assert!(state.read(cx).content.is_none()));
312    }
313
314    #[test]
315    fn tooltip_priority_exceeds_popup_layer() {
316        assert!(TOOLTIP_PRIORITY > crate::POPUP_PRIORITY);
317    }
318
319    #[gpui::test]
320    fn disabled_provider_ignores_delayed_and_immediate_requests(cx: &mut gpui::TestAppContext) {
321        let state = cx.update(|cx| {
322            cx.new(|_| TooltipOverlay {
323                enabled: false,
324                ..TooltipOverlay::new()
325            })
326        });
327        let cx = cx.add_empty_window();
328        for had_recent_tooltip in [false, true] {
329            cx.update(|window, cx| {
330                state.update(cx, |tooltip, cx| {
331                    tooltip.had_recent_tooltip = had_recent_tooltip;
332                    tooltip.request_show(
333                        TooltipRequest::new(bounds(0., 0., 20., 20.), |_, _| {
334                            panic!("disabled tooltips must not build content")
335                        }),
336                        window,
337                        cx,
338                    );
339                    assert!(tooltip.content.is_none());
340                    assert!(tooltip.show_task.is_none());
341                    assert!(tooltip.hide_task.is_none());
342                    assert_eq!(tooltip.animation_epoch, 0);
343                });
344            });
345        }
346    }
347}