Skip to main content

gpui_cn/
tooltip.rs

1use std::{cell::Cell, rc::Rc};
2
3use gpui_kit::{
4    AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div, ElementId,
5    InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Pixels, Render,
6    SharedString, StatefulInteractiveElement, Styled as _, Window,
7    base::{
8        ElementExt as _, Keyframe, Keyframes, Placement, TestSupportExt as _, Timing,
9        Tooltip as BaseTooltip, TooltipRequest, TooltipTransition, animate_keyframes,
10    },
11    div,
12};
13
14use crate::{ActiveTheme as _, Theme, TooltipHost};
15use gpui_kit::{StyledText, TextLayout};
16
17/// A label rendered as a `StyledText` so its layout can be read back after
18/// the frame that painted it: a label the layout cut short shows its full
19/// text as a tooltip. Split it with [`into_parts`](Self::into_parts) and
20/// put the text in the element tree.
21pub(crate) struct TruncatedLabel {
22    label: SharedString,
23    text: StyledText,
24}
25
26impl TruncatedLabel {
27    pub(crate) fn new(label: SharedString) -> Self {
28        Self {
29            text: StyledText::new(label.clone()),
30            label,
31        }
32    }
33
34    /// The text to render and the check that reads its layout back.
35    pub(crate) fn into_parts(self) -> (StyledText, TruncationCheck) {
36        let layout = self.text.layout().clone();
37        (
38            self.text,
39            TruncationCheck {
40                label: self.label,
41                layout,
42            },
43        )
44    }
45}
46
47/// Whether a [`TruncatedLabel`] was cut short in the last frame.
48#[derive(Clone)]
49pub(crate) struct TruncationCheck {
50    label: SharedString,
51    layout: TextLayout,
52}
53
54impl TruncationCheck {
55    pub(crate) fn is_truncated(&self) -> bool {
56        self.layout.text() != self.label.as_ref()
57    }
58
59    /// The hover rule of a truncation tooltip: ask to show only while the
60    /// label is truncated, and always pass a leave on so a tooltip that
61    /// showed hides.
62    pub(crate) fn hovered(
63        &self,
64        trigger: &TooltipTrigger,
65        hovered: bool,
66        window: &mut Window,
67        cx: &mut App,
68    ) {
69        if self.is_truncated() || !hovered {
70            trigger.hovered(hovered, window, cx);
71        }
72    }
73}
74
75thread_local! {
76    /// One ramp, `0.` to `1.`, built once per thread (keyframes hold an
77    /// `Rc` easing, so they cannot be a global static).
78    static RAMP: Keyframes<f32> =
79        Keyframes::try_new([Keyframe::new(0., 0f32), Keyframe::new(1., 1f32)])
80            .expect("two ordered stops");
81}
82
83/// A short text tooltip, the content a trigger builds for the overlay.
84///
85/// It draws the gpui-cn tooltip surface itself, so it looks the same
86/// through the overlay gpui-cn adds to each `Root` and as GPUI's native tooltip
87/// in a window without one.
88pub struct Tooltip {
89    text: SharedString,
90}
91
92impl Tooltip {
93    /// A tooltip with `text`.
94    pub fn new(text: impl Into<SharedString>) -> Self {
95        Self { text: text.into() }
96    }
97
98    /// Builds the tooltip as the view the overlay expects.
99    pub fn build(text: impl Into<SharedString>, cx: &mut App) -> AnyView {
100        let text = text.into();
101        cx.new(|_| Self::new(text)).into()
102    }
103}
104
105impl Render for Tooltip {
106    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
107        // Observed so UI tests can find the shown tooltip by id.
108        div()
109            .id("gpui-cn-tooltip")
110            .test_support()
111            .child(surface(cx).child(self.text.clone()))
112    }
113}
114
115/// The tooltip surface as the reference app draws it: a 30px box
116/// on the `tooltip` token with the large radius, a hairline, 13px text,
117/// and 13px side padding. Any custom tooltip view can wrap itself in it.
118pub fn surface(cx: &App) -> Div {
119    let theme = cx.theme();
120    div()
121        .flex()
122        .items_center()
123        .h(theme.metrics.row)
124        .px(theme.text_control.size)
125        .rounded(theme.radius_lg())
126        .bg(theme.tooltip)
127        .text_color(theme.tooltip_foreground)
128        .border_1()
129        .border_color(theme.border())
130        .text_size(theme.text_control.size)
131        .whitespace_nowrap()
132}
133
134/// Positions and fades any tooltip view. Installed on the base overlay by
135/// the overlay gpui-cn adds to each `Root`.
136///
137/// A new tooltip fades in over `motion.normal`. Switching between
138/// neighboring triggers is immediate. The view draws its own surface.
139pub(crate) fn render_surface(
140    view: AnyView,
141    transition: TooltipTransition,
142    window: &mut Window,
143    cx: &mut App,
144) -> AnyElement {
145    let normal = Theme::global(cx).motion.normal;
146    let opacity = match transition {
147        TooltipTransition::Enter { epoch } if !normal.is_zero() => RAMP.with(|ramp| {
148            animate_keyframes(
149                ElementId::from(("gpui-cn-tooltip-enter", epoch)),
150                ramp,
151                Timing::new(normal),
152                window,
153                cx,
154            )
155            .value
156        }),
157        _ => 1.,
158    };
159    BaseTooltip::new("gpui-cn-tooltip-positioner")
160        .opacity(opacity)
161        .child(view)
162        .into_any_element()
163}
164
165/// The trigger side of a managed tooltip, for a component that owns its
166/// own hover listener (GPUI allows one per element).
167///
168/// Record the trigger's bounds with [`track_bounds`](Self::track_bounds),
169/// forward the hover listener to [`hovered`](Self::hovered), and call
170/// [`pressed`](Self::pressed) on mouse down. In a window with no registered
171/// overlay use [`native`](Self::native) as GPUI's tooltip builder instead.
172/// [`Button`](crate::Button) shows the intended use.
173pub struct TooltipTrigger {
174    owner: ElementId,
175    build: Rc<dyn Fn(&mut App) -> AnyView>,
176    placement: Option<Placement>,
177    bounds: Rc<Cell<Bounds<Pixels>>>,
178}
179
180impl TooltipTrigger {
181    /// A trigger owned by the element `owner`, building `build` when shown.
182    /// The owner id is stable across frames, and only the trigger that
183    /// asked for the tooltip hides it on leave.
184    pub fn new(
185        owner: ElementId,
186        placement: Option<Placement>,
187        build: impl Fn(&mut App) -> AnyView + 'static,
188    ) -> Self {
189        Self {
190            owner,
191            build: Rc::new(build),
192            placement,
193            bounds: Rc::new(Cell::new(Bounds::default())),
194        }
195    }
196
197    /// A trigger for a [`Tooltip`] with `text`.
198    pub fn text(owner: ElementId, placement: Option<Placement>, text: SharedString) -> Self {
199        Self::new(owner, placement, move |cx| Tooltip::build(text.clone(), cx))
200    }
201
202    /// The prepaint callback that records the trigger's bounds.
203    pub fn track_bounds(&self) -> impl Fn(Bounds<Pixels>, &mut Window, &mut App) + 'static {
204        let bounds = self.bounds.clone();
205        move |resolved, _, _| bounds.set(resolved)
206    }
207
208    /// Call from the trigger's hover listener.
209    pub fn hovered(&self, hovered: bool, window: &mut Window, cx: &mut App) {
210        let Some(overlay) = TooltipHost::overlay(window, cx) else {
211            return;
212        };
213        if hovered {
214            let build = self.build.clone();
215            let request = TooltipRequest::new(self.bounds.get(), move |_, cx| build(cx));
216            let request = match self.placement {
217                Some(placement) => request.with_placement(placement),
218                None => request,
219            };
220            TooltipHost::note_requester(window, &self.owner, cx);
221            overlay.update(cx, |overlay, cx| overlay.request_show(request, window, cx));
222        } else if TooltipHost::is_requester(window, &self.owner, cx) {
223            overlay.update(cx, |overlay, cx| overlay.request_hide(window, cx));
224        }
225    }
226
227    /// Call from the trigger's mouse-down listener.
228    pub fn pressed(window: &mut Window, cx: &mut App) {
229        if let Some(overlay) = TooltipHost::overlay(window, cx) {
230            overlay.update(cx, |overlay, cx| overlay.hide(cx));
231        }
232    }
233
234    /// The native tooltip builder for a window with no overlay.
235    pub fn native(&self) -> impl Fn(&mut Window, &mut App) -> AnyView + 'static {
236        let build = self.build.clone();
237        move |_, cx| build(cx)
238    }
239}
240
241/// Gives a stateful element a managed tooltip.
242///
243/// The tooltip shows through the window's registered base overlay (see
244/// [`TooltipHost`]) after the overlay's delay, follows the trigger's bounds,
245/// hides on press, and is suppressed on iOS and Android by base. In a window
246/// with no registered overlay the element gets GPUI's native tooltip
247/// instead, so the text is never lost. The window decides which, so the
248/// methods take it.
249///
250/// The managed tooltip installs the element's hover listener. An element
251/// that sets `on_hover` itself uses [`TooltipTrigger`] directly instead, as
252/// gpui-cn components do.
253pub trait TooltipExt: StatefulInteractiveElement + Sized {
254    /// A text tooltip.
255    fn managed_tooltip(self, text: impl Into<SharedString>, window: &Window, cx: &App) -> Self {
256        let text = text.into();
257        self.managed_tooltip_with(None, move |cx| Tooltip::build(text.clone(), cx), window, cx)
258    }
259
260    /// A text tooltip that prefers `placement`, falling back when it does
261    /// not fit.
262    fn managed_tooltip_at(
263        self,
264        placement: Placement,
265        text: impl Into<SharedString>,
266        window: &Window,
267        cx: &App,
268    ) -> Self {
269        let text = text.into();
270        self.managed_tooltip_with(
271            Some(placement),
272            move |cx| Tooltip::build(text.clone(), cx),
273            window,
274            cx,
275        )
276    }
277
278    /// A tooltip with an arbitrary view.
279    fn managed_tooltip_with(
280        self,
281        placement: Option<Placement>,
282        build: impl Fn(&mut App) -> AnyView + 'static,
283        window: &Window,
284        cx: &App,
285    ) -> Self;
286}
287
288impl<E: StatefulInteractiveElement + gpui_kit::ParentElement> TooltipExt for E {
289    fn managed_tooltip_with(
290        mut self,
291        placement: Option<Placement>,
292        build: impl Fn(&mut App) -> AnyView + 'static,
293        window: &Window,
294        cx: &App,
295    ) -> Self {
296        let owner = self
297            .interactivity()
298            .element_id
299            .clone()
300            .expect("a stateful element has an id");
301        let trigger = TooltipTrigger::new(owner, placement, build);
302        if TooltipHost::overlay(window, cx).is_none() {
303            return self.tooltip(trigger.native());
304        }
305        self.on_prepaint(trigger.track_bounds())
306            .on_hover(move |hovered, window, cx| trigger.hovered(*hovered, window, cx))
307            .on_mouse_down(MouseButton::Left, |_, window, cx| {
308                TooltipTrigger::pressed(window, cx);
309            })
310    }
311}