Skip to main content

gpui_component/
tooltip.rs

1use crate::root::WindowState;
2use std::{cell::Cell, rc::Rc, time::Duration};
3
4use gpui::{
5    Action, AnyElement, AnyView, App, AppContext, Bounds, Context, ElementId, IntoElement,
6    MouseButton, ParentElement, Pixels, Render, SharedString, StatefulInteractiveElement,
7    StyleRefinement, Styled, Window, div, prelude::FluentBuilder, px,
8};
9pub use gpui_base::TooltipDefaults;
10use gpui_base::{
11    Tooltip as BaseTooltip, TooltipOverlay as BaseTooltipOverlay,
12    TooltipRequest as BaseTooltipRequest, TooltipTransition as BaseTooltipTransition,
13};
14
15use crate::{
16    ActiveTheme, Placement, StyledExt,
17    animation::{EffectTransition, ease_in_out_cubic, ease_out_cubic},
18    kbd::Kbd,
19    text::Text,
20};
21
22pub(crate) fn init(_cx: &mut App) {
23    // No app-level init needed — TooltipOverlay is per-window via Root.
24}
25
26// ── Tooltip view (unchanged API) ────────────────────────────────────────────
27
28enum TooltipContext {
29    Text(Text),
30    Element(Box<dyn Fn(&mut Window, &mut App) -> AnyElement>),
31}
32
33/// A Tooltip element that can display text or custom content,
34/// with optional key binding information.
35pub struct Tooltip {
36    style: StyleRefinement,
37    content: TooltipContext,
38    key_binding: Option<Kbd>,
39    action: Option<(Box<dyn Action>, Option<SharedString>)>,
40}
41
42impl Tooltip {
43    /// Create a Tooltip with a text content.
44    pub fn new(text: impl Into<Text>) -> Self {
45        Self {
46            style: StyleRefinement::default(),
47            content: TooltipContext::Text(text.into()),
48            key_binding: None,
49            action: None,
50        }
51    }
52
53    /// Create a Tooltip with a custom element.
54    pub fn element<E, F>(builder: F) -> Self
55    where
56        E: IntoElement,
57        F: Fn(&mut Window, &mut App) -> E + 'static,
58    {
59        Self {
60            style: StyleRefinement::default(),
61            key_binding: None,
62            action: None,
63            content: TooltipContext::Element(Box::new(move |window, cx| {
64                builder(window, cx).into_any_element()
65            })),
66        }
67    }
68
69    /// Set Action to display key binding information for the tooltip if it exists.
70    pub fn action(mut self, action: &dyn Action, context: Option<&str>) -> Self {
71        self.action = Some((action.boxed_clone(), context.map(SharedString::new)));
72        self
73    }
74
75    /// Set KeyBinding information for the tooltip.
76    pub fn key_binding(mut self, key_binding: Option<Kbd>) -> Self {
77        self.key_binding = key_binding;
78        self
79    }
80
81    /// Build the tooltip and return it as an `AnyView`.
82    pub fn build(self, _: &mut Window, cx: &mut App) -> AnyView {
83        cx.new(|_| self).into()
84    }
85}
86
87impl FluentBuilder for Tooltip {}
88impl Styled for Tooltip {
89    fn style(&mut self) -> &mut StyleRefinement {
90        &mut self.style
91    }
92}
93impl Render for Tooltip {
94    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
95        let key_binding = if let Some(key_binding) = &self.key_binding {
96            Some(key_binding.clone())
97        } else {
98            if let Some((action, context)) = &self.action {
99                Kbd::binding_for_action(
100                    action.as_ref(),
101                    context.as_ref().map(|s| s.as_ref()),
102                    window,
103                )
104            } else {
105                None
106            }
107        };
108
109        div().child(
110            // Wrap in a child, to ensure the left margin is applied to the tooltip
111            BaseTooltip::new("tooltip-popup")
112                .h_flex()
113                .font_family(cx.theme().font_family.clone())
114                .m_3()
115                .bg(cx.theme().tokens.popover)
116                .text_color(cx.theme().popover_foreground)
117                .bg(cx.theme().tokens.popover)
118                .border_1()
119                .border_color(cx.theme().border)
120                .shadow_md()
121                .rounded(cx.theme().radius)
122                .justify_between()
123                .py_0p5()
124                .px_2()
125                .text_sm()
126                .gap_3()
127                .refine_style(&self.style)
128                .map(|this| {
129                    this.child(div().map(|this| match self.content {
130                        TooltipContext::Text(ref text) => this.child(text.clone()),
131                        TooltipContext::Element(ref builder) => this.child(builder(window, cx)),
132                    }))
133                })
134                .when_some(key_binding, |this, kbd| {
135                    this.child(
136                        div()
137                            .text_xs()
138                            .flex_shrink_0()
139                            .text_color(cx.theme().muted_foreground)
140                            .child(kbd.appearance(false)),
141                    )
142                }),
143        )
144    }
145}
146
147// ── Managed tooltip system ──────────────────────────────────────────────────
148
149/// Duration of the slide-down enter animation.
150const ENTER_DURATION: Duration = Duration::from_millis(150);
151/// Duration of the position-slide animation when switching tooltips.
152const SLIDE_DURATION: Duration = Duration::from_millis(200);
153pub(crate) fn render_tooltip(
154    content_view: AnyView,
155    transition: BaseTooltipTransition,
156    _: &mut Window,
157    _: &mut App,
158) -> AnyElement {
159    div().child(content_view).map(|element| match transition {
160        BaseTooltipTransition::Switch {
161            epoch,
162            previous,
163            current,
164        } => {
165            let same_row = (current.origin.y - previous.origin.y).abs() < px(10.);
166            if !same_row {
167                return element.into_any_element();
168            }
169            let dx = current.center().x - previous.center().x;
170            EffectTransition::new(SLIDE_DURATION)
171                .ease(ease_in_out_cubic)
172                .slide_x(-dx, px(0.))
173                .apply(
174                    element,
175                    ElementId::NamedInteger("tooltip-slide".into(), epoch as u64),
176                )
177                .into_any_element()
178        }
179        BaseTooltipTransition::Enter { epoch } => EffectTransition::new(ENTER_DURATION)
180            .ease(ease_out_cubic)
181            .slide_y(px(4.), px(0.))
182            .fade(0.0, 1.0)
183            .apply(
184                element,
185                ElementId::NamedInteger("tooltip-enter".into(), epoch as u64),
186            )
187            .into_any_element(),
188    })
189}
190
191// ── Extension trait for managed tooltips ─────────────────────────────────────
192
193// ── Shared tooltip state for components ─────────────────────────────────────
194
195/// Shared tooltip state that components (Button, Switch, Checkbox, Radio, etc.)
196/// can embed to get `.tooltip()` support with minimal boilerplate.
197#[derive(Default)]
198pub(crate) struct ComponentTooltip {
199    pub text: Option<(
200        SharedString,
201        Option<(Rc<Box<dyn Action>>, Option<SharedString>)>,
202    )>,
203    pub builder: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyView>>,
204    pub show_delay: Option<Duration>,
205}
206
207impl ComponentTooltip {
208    /// Apply this tooltip to a `Stateful<Div>` (or any `ManagedTooltipExt` element).
209    pub fn apply<E: ManagedTooltipExt>(self, el: E) -> E {
210        let show_delay = self.show_delay;
211        if let Some(builder) = self.builder {
212            el.managed_tooltip_with(None, show_delay, move |window, cx| builder(window, cx))
213        } else if let Some((text, action)) = self.text {
214            el.managed_tooltip_with(None, show_delay, move |window, cx| {
215                Tooltip::new(text.clone())
216                    .when_some(action.clone(), |this, (action, context)| {
217                        this.action(
218                            action.boxed_clone().as_ref(),
219                            context.as_ref().map(|c| c.as_ref()),
220                        )
221                    })
222                    .build(window, cx)
223            })
224        } else {
225            el
226        }
227    }
228}
229
230// ── Internal managed tooltip trait ──────────────────────────────────────────
231
232pub(crate) trait ManagedTooltipExt:
233    StatefulInteractiveElement + crate::ElementExt + Sized
234{
235    fn managed_tooltip(
236        self,
237        build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
238    ) -> Self {
239        self.managed_tooltip_with(None, None, build_tooltip)
240    }
241
242    fn managed_tooltip_at(
243        self,
244        placement: Placement,
245        build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
246    ) -> Self {
247        self.managed_tooltip_with(Some(placement), None, build_tooltip)
248    }
249
250    fn managed_tooltip_with(
251        self,
252        preferred_placement: Option<Placement>,
253        show_delay: Option<Duration>,
254        build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
255    ) -> Self {
256        let build_tooltip = Rc::new(build_tooltip);
257        let trigger_bounds_cell: Rc<Cell<Bounds<Pixels>>> = Rc::new(Cell::new(Bounds::default()));
258        let bounds_writer = trigger_bounds_cell.clone();
259
260        self.on_prepaint(move |bounds, _, _| {
261            bounds_writer.set(bounds);
262        })
263        .on_hover({
264            let trigger_bounds_cell = trigger_bounds_cell.clone();
265            let build_tooltip = build_tooltip.clone();
266            move |hovered, window, cx| {
267                if let Some(overlay) = WindowState::tooltip_overlay(window, cx) {
268                    if *hovered {
269                        let bounds = trigger_bounds_cell.get();
270                        overlay.update(cx, |o: &mut BaseTooltipOverlay, cx| {
271                            let build = build_tooltip.clone();
272                            let request = BaseTooltipRequest::new(bounds, move |window, cx| {
273                                build(window, cx)
274                            });
275                            let request = match preferred_placement {
276                                Some(placement) => request.with_placement(placement),
277                                None => request,
278                            };
279                            let request = match show_delay {
280                                Some(delay) => request.with_show_delay(delay),
281                                None => request,
282                            };
283                            o.request_show(request, window, cx);
284                        });
285                    } else {
286                        overlay.update(cx, |o: &mut BaseTooltipOverlay, cx| {
287                            o.request_hide(window, cx);
288                        });
289                    }
290                }
291            }
292        })
293        .on_mouse_down(MouseButton::Left, move |_, window, cx| {
294            if let Some(overlay) = WindowState::tooltip_overlay(window, cx) {
295                overlay.update(cx, |overlay, cx| {
296                    overlay.hide(cx);
297                });
298            }
299        })
300    }
301}
302
303impl<E: StatefulInteractiveElement + crate::ElementExt> ManagedTooltipExt for E {}