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, Global, InteractiveElement,
5    IntoElement, ParentElement, Pixels, Render, RenderOnce, Role, Stateful,
6    StatefulInteractiveElement, Styled, Task, Window, deferred, div, prelude::FluentBuilder as _,
7    px,
8};
9
10use crate::{Placement, Positioner};
11
12const TOOLTIP_PRIORITY: usize = 200;
13const WINDOW_MARGIN: Pixels = px(4.);
14
15type TooltipBuilder = Rc<dyn Fn(&mut Window, &mut App) -> AnyView>;
16type TooltipRenderer = Rc<dyn Fn(AnyView, TooltipTransition, &mut Window, &mut App) -> AnyElement>;
17
18/// An unstyled tooltip popup.
19///
20/// This corresponds to Base UI's `Tooltip.Popup`: it owns the accessible
21/// tooltip role and accepts application-owned content and presentation.
22#[derive(IntoElement)]
23pub struct Tooltip {
24    base: Stateful<Div>,
25}
26
27impl Tooltip {
28    pub fn new(id: impl Into<ElementId>) -> Self {
29        Self {
30            base: div().id(id).role(Role::Tooltip),
31        }
32    }
33}
34
35impl Styled for Tooltip {
36    fn style(&mut self) -> &mut gpui::StyleRefinement {
37        self.base.style()
38    }
39}
40
41impl ParentElement for Tooltip {
42    fn extend(&mut self, children: impl IntoIterator<Item = AnyElement>) {
43        self.base.extend(children);
44    }
45}
46
47impl RenderOnce for Tooltip {
48    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
49        self.base
50    }
51}
52
53/// Application-wide timing for tooltips shown through [`TooltipOverlay`].
54///
55/// Read on every show and hide request, so installing new defaults takes
56/// effect in windows that are already open.
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub struct TooltipDefaults {
59    show_delay: Duration,
60    grace_period: Duration,
61}
62
63impl Global for TooltipDefaults {}
64
65impl TooltipDefaults {
66    /// Creates the Base defaults: a 500 ms show delay and a 300 ms grace period.
67    pub fn new() -> Self {
68        Self {
69            show_delay: Duration::from_millis(500),
70            grace_period: Duration::from_millis(300),
71        }
72    }
73
74    /// Sets how long the pointer must rest on a trigger before its tooltip shows.
75    pub fn with_show_delay(mut self, delay: Duration) -> Self {
76        self.show_delay = delay;
77        self
78    }
79
80    /// Sets how long a tooltip stays after the pointer leaves its trigger.
81    ///
82    /// Entering another trigger within this period switches to its tooltip
83    /// without waiting for the show delay.
84    pub fn with_grace_period(mut self, period: Duration) -> Self {
85        self.grace_period = period;
86        self
87    }
88
89    /// How long the pointer must rest on a trigger before its tooltip shows.
90    pub fn show_delay(&self) -> Duration {
91        self.show_delay
92    }
93
94    /// How long a tooltip stays after the pointer leaves its trigger.
95    pub fn grace_period(&self) -> Duration {
96        self.grace_period
97    }
98
99    /// Installs these defaults for the whole application.
100    pub fn install(self, cx: &mut App) {
101        cx.set_global(self);
102    }
103
104    /// Returns the installed defaults, or the Base ones when none were.
105    pub fn global(cx: &App) -> Self {
106        cx.try_global::<Self>().copied().unwrap_or_default()
107    }
108}
109
110impl Default for TooltipDefaults {
111    fn default() -> Self {
112        Self::new()
113    }
114}
115
116/// Content requested by a tooltip trigger.
117#[derive(Clone)]
118pub struct TooltipRequest {
119    build: TooltipBuilder,
120    trigger_bounds: Bounds<Pixels>,
121    preferred_placement: Option<Placement>,
122    show_delay: Option<Duration>,
123}
124
125impl TooltipRequest {
126    pub fn new(
127        trigger_bounds: Bounds<Pixels>,
128        build: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
129    ) -> Self {
130        Self {
131            build: Rc::new(build),
132            trigger_bounds,
133            preferred_placement: None,
134            show_delay: None,
135        }
136    }
137
138    /// Prefers a side for the tooltip, falling back when it does not fit.
139    pub fn with_placement(mut self, placement: Placement) -> Self {
140        self.preferred_placement = Some(placement);
141        self
142    }
143
144    #[deprecated(note = "use `with_placement`")]
145    pub fn placement(self, placement: Placement) -> Self {
146        self.with_placement(placement)
147    }
148
149    /// Overrides [`TooltipDefaults::show_delay`] for this trigger.
150    pub fn with_show_delay(mut self, delay: Duration) -> Self {
151        self.show_delay = Some(delay);
152        self
153    }
154}
155
156/// Presentation transition requested by the Base tooltip lifecycle.
157#[derive(Clone, Copy, Debug)]
158pub enum TooltipTransition {
159    Enter {
160        epoch: usize,
161    },
162    Switch {
163        epoch: usize,
164        previous: Bounds<Pixels>,
165        current: Bounds<Pixels>,
166    },
167}
168
169/// Per-window tooltip provider and overlay.
170///
171/// Show requests are ignored on iOS and Android, where touch input must not
172/// open hover tooltips. This does not control GPUI's native `.tooltip()` API.
173pub struct TooltipOverlay {
174    enabled: bool,
175    content: Option<TooltipRequest>,
176    previous_bounds: Option<Bounds<Pixels>>,
177    epoch: usize,
178    had_recent_tooltip: bool,
179    animation_epoch: usize,
180    is_switching: bool,
181    show_task: Option<Task<()>>,
182    hide_task: Option<Task<()>>,
183    renderer: TooltipRenderer,
184}
185
186impl TooltipOverlay {
187    pub fn new() -> Self {
188        Self {
189            enabled: !crate::is_mobile(),
190            content: None,
191            previous_bounds: None,
192            epoch: 0,
193            had_recent_tooltip: false,
194            animation_epoch: 0,
195            is_switching: false,
196            show_task: None,
197            hide_task: None,
198            renderer: Rc::new(|view, _, _, _| div().child(view).into_any_element()),
199        }
200    }
201
202    pub fn render_with(
203        mut self,
204        renderer: impl Fn(AnyView, TooltipTransition, &mut Window, &mut App) -> AnyElement + 'static,
205    ) -> Self {
206        self.renderer = Rc::new(renderer);
207        self
208    }
209
210    fn next_epoch(&mut self) -> usize {
211        self.epoch += 1;
212        self.epoch
213    }
214
215    pub fn request_show(
216        &mut self,
217        content: TooltipRequest,
218        window: &mut Window,
219        cx: &mut Context<Self>,
220    ) {
221        // Gate both delayed display and the immediate grace-period switch.
222        // Keep this in Base so every managed component shares the policy.
223        if !self.enabled {
224            return;
225        }
226        self.hide_task = None;
227        let show_delay = content
228            .show_delay
229            .unwrap_or_else(|| TooltipDefaults::global(cx).show_delay);
230        let was_visible = self.content.is_some();
231        if was_visible || self.had_recent_tooltip || show_delay.is_zero() {
232            self.previous_bounds = self.content.as_ref().map(|content| content.trigger_bounds);
233            self.content = Some(content);
234            self.show_task = None;
235            self.is_switching = was_visible;
236            self.animation_epoch += 1;
237            cx.notify();
238            return;
239        }
240
241        let epoch = self.next_epoch();
242        self.show_task = Some(cx.spawn_in(window, async move |this, cx| {
243            cx.background_executor().timer(show_delay).await;
244            let _ = this.update_in(cx, |this, _, cx| {
245                if this.epoch == epoch {
246                    this.content = Some(content);
247                    this.previous_bounds = None;
248                    this.is_switching = false;
249                    this.animation_epoch += 1;
250                    cx.notify();
251                }
252            });
253        }));
254    }
255
256    pub fn request_hide(&mut self, window: &mut Window, cx: &mut Context<Self>) {
257        self.show_task = None;
258        if self.content.is_none() {
259            return;
260        }
261        let epoch = self.next_epoch();
262        let grace_period = TooltipDefaults::global(cx).grace_period;
263        self.had_recent_tooltip = true;
264        self.hide_task = Some(cx.spawn_in(window, async move |this, cx| {
265            cx.background_executor().timer(grace_period).await;
266            let _ = this.update_in(cx, |this, _, cx| {
267                if this.epoch == epoch {
268                    this.content = None;
269                    this.previous_bounds = None;
270                    this.had_recent_tooltip = false;
271                    cx.notify();
272                }
273            });
274        }));
275    }
276
277    pub fn hide(&mut self, cx: &mut Context<Self>) {
278        let changed = self.content.is_some()
279            || self.previous_bounds.is_some()
280            || self.had_recent_tooltip
281            || self.show_task.is_some()
282            || self.hide_task.is_some();
283        self.content = None;
284        self.previous_bounds = None;
285        self.had_recent_tooltip = false;
286        self.is_switching = false;
287        self.show_task = None;
288        self.hide_task = None;
289        if changed {
290            cx.notify();
291        }
292    }
293}
294
295impl Default for TooltipOverlay {
296    fn default() -> Self {
297        Self::new()
298    }
299}
300
301impl Render for TooltipOverlay {
302    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
303        let Some(content) = self.content.as_ref() else {
304            return div().into_any_element();
305        };
306        let view = (content.build)(window, cx);
307        let transition = match (self.is_switching, self.previous_bounds) {
308            (true, Some(previous)) => TooltipTransition::Switch {
309                epoch: self.animation_epoch,
310                previous,
311                current: content.trigger_bounds,
312            },
313            _ => TooltipTransition::Enter {
314                epoch: self.animation_epoch,
315            },
316        };
317        let rendered = (self.renderer)(view, transition, window, cx);
318        deferred(
319            TooltipPositioner::new(content.trigger_bounds)
320                .when_some(content.preferred_placement, |this, placement| {
321                    this.placement(placement)
322                })
323                .child(rendered),
324        )
325        .with_priority(TOOLTIP_PRIORITY)
326        .into_any_element()
327    }
328}
329
330/// An unstyled tooltip positioner with viewport-aware flipping and clamping.
331///
332/// This is a tooltip-named view of [`crate::Positioner`]'s side placement. It
333/// adds no element of its own; the shared positioner is what gets rendered.
334pub struct TooltipPositioner(Positioner);
335
336impl TooltipPositioner {
337    pub fn new(trigger_bounds: Bounds<Pixels>) -> Self {
338        Self(Positioner::side(trigger_bounds).margin(WINDOW_MARGIN))
339    }
340
341    pub fn placement(mut self, placement: Placement) -> Self {
342        self.0 = self.0.placement(placement);
343        self
344    }
345}
346
347impl ParentElement for TooltipPositioner {
348    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
349        self.0.extend(elements);
350    }
351}
352
353impl IntoElement for TooltipPositioner {
354    type Element = Positioner;
355
356    fn into_element(self) -> Self::Element {
357        self.0
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use gpui::{AppContext as _, point, size};
365
366    fn bounds(x: f32, y: f32, width: f32, height: f32) -> Bounds<Pixels> {
367        Bounds::new(point(px(x), px(y)), size(px(width), px(height)))
368    }
369
370    #[gpui::test]
371    fn provider_owns_grace_switch_and_dismiss(cx: &mut gpui::TestAppContext) {
372        let state = cx.update(|cx| cx.new(|_| TooltipOverlay::new()));
373        let cx = cx.add_empty_window();
374        cx.update(|window, cx| {
375            state.update(cx, |tooltip, cx| {
376                tooltip.had_recent_tooltip = true;
377                tooltip.request_show(
378                    TooltipRequest::new(bounds(0., 0., 20., 20.), |_, _| {
379                        panic!("content is not rendered by this lifecycle test")
380                    }),
381                    window,
382                    cx,
383                );
384            });
385        });
386        cx.update(|_, cx| assert!(state.read(cx).content.is_some()));
387
388        cx.update(|_, cx| {
389            state.update(cx, |tooltip, cx| tooltip.hide(cx));
390        });
391        cx.update(|_, cx| assert!(state.read(cx).content.is_none()));
392    }
393
394    #[gpui::test]
395    fn show_delay_follows_defaults_and_request_override(cx: &mut gpui::TestAppContext) {
396        cx.update(|cx| {
397            TooltipDefaults::new()
398                .with_show_delay(Duration::from_millis(100))
399                .install(cx)
400        });
401        // The delayed show updates the overlay in the window it was drawn in.
402        let (state, cx) = cx.add_window_view(|_, _| TooltipOverlay::new());
403        cx.update(|window, cx| window.draw(cx).clear(cx));
404        let request = || {
405            TooltipRequest::new(bounds(0., 0., 20., 20.), |_, cx| {
406                cx.new(|_| gpui::Empty).into()
407            })
408        };
409
410        cx.update(|window, cx| {
411            state.update(cx, |tooltip, cx| {
412                tooltip.request_show(request(), window, cx)
413            });
414        });
415        cx.executor().advance_clock(Duration::from_millis(99));
416        cx.run_until_parked();
417        cx.update(|_, cx| assert!(state.read(cx).content.is_none()));
418        cx.executor().advance_clock(Duration::from_millis(1));
419        cx.run_until_parked();
420        cx.update(|_, cx| assert!(state.read(cx).content.is_some()));
421
422        cx.update(|_, cx| state.update(cx, |tooltip, cx| tooltip.hide(cx)));
423        cx.update(|window, cx| {
424            state.update(cx, |tooltip, cx| {
425                tooltip.request_show(request().with_show_delay(Duration::ZERO), window, cx);
426                assert!(tooltip.content.is_some());
427                assert!(tooltip.show_task.is_none());
428            });
429        });
430    }
431
432    #[test]
433    fn tooltip_priority_exceeds_popup_layer() {
434        assert!(TOOLTIP_PRIORITY > crate::POPUP_PRIORITY);
435    }
436
437    #[gpui::test]
438    fn disabled_provider_ignores_delayed_and_immediate_requests(cx: &mut gpui::TestAppContext) {
439        let state = cx.update(|cx| {
440            cx.new(|_| TooltipOverlay {
441                enabled: false,
442                ..TooltipOverlay::new()
443            })
444        });
445        let cx = cx.add_empty_window();
446        for had_recent_tooltip in [false, true] {
447            cx.update(|window, cx| {
448                state.update(cx, |tooltip, cx| {
449                    tooltip.had_recent_tooltip = had_recent_tooltip;
450                    tooltip.request_show(
451                        TooltipRequest::new(bounds(0., 0., 20., 20.), |_, _| {
452                            panic!("disabled tooltips must not build content")
453                        }),
454                        window,
455                        cx,
456                    );
457                    assert!(tooltip.content.is_none());
458                    assert!(tooltip.show_task.is_none());
459                    assert!(tooltip.hide_task.is_none());
460                    assert_eq!(tooltip.animation_epoch, 0);
461                });
462            });
463        }
464    }
465}