Skip to main content

gpui_component/
tooltip.rs

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