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.
94pub struct TooltipOverlay {
95    content: Option<TooltipRequest>,
96    previous_bounds: Option<Bounds<Pixels>>,
97    epoch: usize,
98    had_recent_tooltip: bool,
99    animation_epoch: usize,
100    is_switching: bool,
101    show_task: Option<Task<()>>,
102    hide_task: Option<Task<()>>,
103    renderer: TooltipRenderer,
104}
105
106impl TooltipOverlay {
107    pub fn new() -> Self {
108        Self {
109            content: None,
110            previous_bounds: None,
111            epoch: 0,
112            had_recent_tooltip: false,
113            animation_epoch: 0,
114            is_switching: false,
115            show_task: None,
116            hide_task: None,
117            renderer: Rc::new(|view, _, _, _| div().child(view).into_any_element()),
118        }
119    }
120
121    pub fn render_with(
122        mut self,
123        renderer: impl Fn(AnyView, TooltipTransition, &mut Window, &mut App) -> AnyElement + 'static,
124    ) -> Self {
125        self.renderer = Rc::new(renderer);
126        self
127    }
128
129    fn next_epoch(&mut self) -> usize {
130        self.epoch += 1;
131        self.epoch
132    }
133
134    pub fn request_show(
135        &mut self,
136        content: TooltipRequest,
137        window: &mut Window,
138        cx: &mut Context<Self>,
139    ) {
140        self.hide_task = None;
141        let was_visible = self.content.is_some();
142        if was_visible || self.had_recent_tooltip {
143            self.previous_bounds = self.content.as_ref().map(|content| content.trigger_bounds);
144            self.content = Some(content);
145            self.show_task = None;
146            self.is_switching = was_visible;
147            self.animation_epoch += 1;
148            cx.notify();
149            return;
150        }
151
152        let epoch = self.next_epoch();
153        self.show_task = Some(cx.spawn_in(window, async move |this, cx| {
154            cx.background_executor().timer(SHOW_DELAY).await;
155            let _ = this.update_in(cx, |this, _, cx| {
156                if this.epoch == epoch {
157                    this.content = Some(content);
158                    this.previous_bounds = None;
159                    this.is_switching = false;
160                    this.animation_epoch += 1;
161                    cx.notify();
162                }
163            });
164        }));
165    }
166
167    pub fn request_hide(&mut self, window: &mut Window, cx: &mut Context<Self>) {
168        self.show_task = None;
169        if self.content.is_none() {
170            return;
171        }
172        let epoch = self.next_epoch();
173        self.had_recent_tooltip = true;
174        self.hide_task = Some(cx.spawn_in(window, async move |this, cx| {
175            cx.background_executor().timer(GRACE_PERIOD).await;
176            let _ = this.update_in(cx, |this, _, cx| {
177                if this.epoch == epoch {
178                    this.content = None;
179                    this.previous_bounds = None;
180                    this.had_recent_tooltip = false;
181                    cx.notify();
182                }
183            });
184        }));
185    }
186
187    pub fn hide(&mut self, cx: &mut Context<Self>) {
188        let changed = self.content.is_some()
189            || self.previous_bounds.is_some()
190            || self.had_recent_tooltip
191            || self.show_task.is_some()
192            || self.hide_task.is_some();
193        self.content = None;
194        self.previous_bounds = None;
195        self.had_recent_tooltip = false;
196        self.is_switching = false;
197        self.show_task = None;
198        self.hide_task = None;
199        if changed {
200            cx.notify();
201        }
202    }
203}
204
205impl Default for TooltipOverlay {
206    fn default() -> Self {
207        Self::new()
208    }
209}
210
211impl Render for TooltipOverlay {
212    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
213        let Some(content) = self.content.as_ref() else {
214            return div().into_any_element();
215        };
216        let view = (content.build)(window, cx);
217        let transition = match (self.is_switching, self.previous_bounds) {
218            (true, Some(previous)) => TooltipTransition::Switch {
219                epoch: self.animation_epoch,
220                previous,
221                current: content.trigger_bounds,
222            },
223            _ => TooltipTransition::Enter {
224                epoch: self.animation_epoch,
225            },
226        };
227        let rendered = (self.renderer)(view, transition, window, cx);
228        deferred(
229            TooltipPositioner::new(content.trigger_bounds)
230                .when_some(content.preferred_placement, |this, placement| {
231                    this.placement(placement)
232                })
233                .child(rendered),
234        )
235        .with_priority(TOOLTIP_PRIORITY)
236        .into_any_element()
237    }
238}
239
240/// An unstyled tooltip positioner with viewport-aware flipping and clamping.
241///
242/// This is a tooltip-named view of [`crate::Positioner`]'s side placement. It
243/// adds no element of its own; the shared positioner is what gets rendered.
244pub struct TooltipPositioner(Positioner);
245
246impl TooltipPositioner {
247    pub fn new(trigger_bounds: Bounds<Pixels>) -> Self {
248        Self(Positioner::side(trigger_bounds).margin(WINDOW_MARGIN))
249    }
250
251    pub fn placement(mut self, placement: Placement) -> Self {
252        self.0 = self.0.placement(placement);
253        self
254    }
255}
256
257impl ParentElement for TooltipPositioner {
258    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
259        self.0.extend(elements);
260    }
261}
262
263impl IntoElement for TooltipPositioner {
264    type Element = Positioner;
265
266    fn into_element(self) -> Self::Element {
267        self.0
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use gpui::{AppContext as _, point, size};
275
276    fn bounds(x: f32, y: f32, width: f32, height: f32) -> Bounds<Pixels> {
277        Bounds::new(point(px(x), px(y)), size(px(width), px(height)))
278    }
279
280    #[gpui::test]
281    fn provider_owns_grace_switch_and_dismiss(cx: &mut gpui::TestAppContext) {
282        let state = cx.update(|cx| cx.new(|_| TooltipOverlay::new()));
283        let cx = cx.add_empty_window();
284        cx.update(|window, cx| {
285            state.update(cx, |tooltip, cx| {
286                tooltip.had_recent_tooltip = true;
287                tooltip.request_show(
288                    TooltipRequest::new(bounds(0., 0., 20., 20.), |_, _| {
289                        panic!("content is not rendered by this lifecycle test")
290                    }),
291                    window,
292                    cx,
293                );
294            });
295        });
296        cx.update(|_, cx| assert!(state.read(cx).content.is_some()));
297
298        cx.update(|_, cx| {
299            state.update(cx, |tooltip, cx| tooltip.hide(cx));
300        });
301        cx.update(|_, cx| assert!(state.read(cx).content.is_none()));
302    }
303
304    #[test]
305    fn tooltip_priority_exceeds_popup_layer() {
306        assert!(TOOLTIP_PRIORITY > crate::POPUP_PRIORITY);
307    }
308}