Skip to main content

herogpui_components/
tooltip.rs

1//! Tooltip — port of `@heroui/tooltip` (v3).
2//!
3//! The tip is state-driven rather than a pure hover style, because v3's `delay`
4//! and `closeDelay` need to know *when* the hover began. State lives in a
5//! per-tooltip [`Window::use_keyed_state`] entity, so callers still write a
6//! plain builder with no entity to thread through.
7
8use std::time::Duration;
9
10use gpui::{
11    prelude::*, px, AnyElement, App, ElementId, IntoElement, ParentElement, Pixels, RenderOnce,
12    SharedString, StatefulInteractiveElement, Styled, Window,
13};
14use herogpui_core::{element_id, PlacementAlign};
15use herogpui_theme::ActiveTheme;
16
17use crate::{
18    a11y::{self, A11y as _},
19    anim, icons, util,
20};
21
22/// Where the tip sits relative to its trigger.
23///
24/// Shares the one placement vocabulary with the popovers and pickers — the
25/// full 22-value React Aria union. The logical `start`/`end` aliases resolve
26/// to the same pixels as their `left`/`right` spellings because this port has
27/// no RTL mode.
28pub use herogpui_core::Placement as TooltipPlacement;
29
30/// The arrow points back at the trigger, so it faces opposite the tip.
31fn arrow_rotation(placement: TooltipPlacement) -> f32 {
32    if placement.is_above() {
33        // The asset's apex is at the bottom, so it points down unrotated:
34        // a tip above the trigger needs no rotation at all.
35        0.
36    } else if placement.is_side() {
37        if placement.is_start_side() {
38            -std::f32::consts::FRAC_PI_2
39        } else {
40            std::f32::consts::FRAC_PI_2
41        }
42    } else {
43        std::f32::consts::PI
44    }
45}
46
47/// HeroUI's `slide-in-from-*` entry offset for the physical side. Aligned
48/// top/bottom placements share the same motion as their centered form.
49fn entry_offset(placement: TooltipPlacement) -> (f32, f32) {
50    if placement.is_above() {
51        (0.0, 4.0)
52    } else if placement.is_side() {
53        if placement.is_start_side() {
54            (4.0, 0.0)
55        } else {
56            (-4.0, 0.0)
57        }
58    } else {
59        (0.0, -4.0)
60    }
61}
62
63/// GPUI's pinned text wrapper breaks normal prose at spaces but has no
64/// `overflow-wrap: anywhere` style. HeroUI applies that rule to tooltip text
65/// so a long URL or token still fits the 320px cap. Zero-width break
66/// opportunities preserve the visible and accessible text while allowing the
67/// existing normal wrapper to split an unbroken token when it reaches the cap.
68fn tooltip_text_with_break_opportunities(text: &str) -> String {
69    let mut chars = text.chars().peekable();
70    let mut result = String::with_capacity(text.len());
71    while let Some(ch) = chars.next() {
72        result.push(ch);
73        if !ch.is_whitespace()
74            && ch != '\u{200b}'
75            && chars
76                .peek()
77                .is_some_and(|next| !next.is_whitespace() && *next != '\u{200b}')
78        {
79            result.push('\u{200b}');
80        }
81    }
82    result
83}
84
85fn tooltip_display_content(text: &str, natural_width: Pixels) -> (String, bool) {
86    let needs_breaks = natural_width > px(320.);
87    let display = if needs_breaks {
88        tooltip_text_with_break_opportunities(text)
89    } else {
90        text.to_owned()
91    };
92    (display, needs_breaks)
93}
94
95/// Hover state for one tooltip.
96///
97/// `generation` is bumped on every hover transition; a timer that fires after a
98/// newer transition has been recorded is stale and must not flip the tip. That
99/// is what keeps a fast pass over a row of triggers from opening all of them.
100///
101/// `focus_dismissed` is what Escape trips for a *focus-opened* tip. The focus
102/// gate (`contains_focused && focus_visible`) is not something Escape may
103/// clear — `focus_visible` is app-wide state every focus ring reads — so the
104/// dismissal is remembered per tooltip instead, and dropped on either edge of
105/// the focus session. A dismissal therefore lasts only for the current focus:
106/// the next keyboard focus shows the tip again.
107pub struct TooltipHover {
108    open: bool,
109    generation: u64,
110    focus_dismissed: bool,
111    focus_open: bool,
112    was_focused: bool,
113}
114
115impl TooltipHover {
116    fn new() -> Self {
117        Self {
118            open: false,
119            generation: 0,
120            focus_dismissed: false,
121            focus_open: false,
122            was_focused: false,
123        }
124    }
125
126    /// A closed tip, for a caller that needs the same seed the component uses.
127    ///
128    /// The state lives in `Window::use_keyed_state` under the tooltip's id, and
129    /// a test (or any caller that wants to read the flag) has to hand that call
130    /// the identical initialiser or it seeds a different slot.
131    pub fn closed() -> Self {
132        Self::new()
133    }
134
135    /// Whether the tip is currently shown.
136    pub fn is_open(&self) -> bool {
137        self.open
138    }
139
140    /// Whether keyboard-visible focus opened the tip in this focus session.
141    pub fn is_focus_open(&self) -> bool {
142        self.focus_open && !self.focus_dismissed
143    }
144
145    fn close(&mut self, dismiss_focus: bool) -> bool {
146        self.generation += 1;
147        let was_open = self.open || self.is_focus_open();
148        self.open = false;
149        if dismiss_focus {
150            self.focus_dismissed = true;
151        }
152        was_open
153    }
154}
155
156#[derive(Default)]
157struct TooltipManager {
158    entries: Vec<gpui::WeakEntity<TooltipHover>>,
159    warmed_up: bool,
160    cooldown_generation: u64,
161}
162
163impl gpui::Global for TooltipManager {}
164
165fn ensure_tooltip_manager(cx: &mut App) {
166    if cx.try_global::<TooltipManager>().is_none() {
167        cx.set_global(TooltipManager::default());
168    }
169}
170
171fn prepare_tooltip_open(current: &gpui::WeakEntity<TooltipHover>, cx: &mut App) -> bool {
172    ensure_tooltip_manager(cx);
173    let (warmed_up, others) = cx.update_global::<TooltipManager, _>(|manager, _| {
174        manager.entries.retain(|entry| entry.upgrade().is_some());
175        let others = manager
176            .entries
177            .iter()
178            .filter(|entry| *entry != current)
179            .filter_map(gpui::WeakEntity::upgrade)
180            .collect::<Vec<_>>();
181        manager.entries.retain(|entry| entry == current);
182        if manager.entries.is_empty() {
183            manager.entries.push(current.clone());
184        }
185        (manager.warmed_up, others)
186    });
187    // Entity updates run after the global borrow is released. `current` may
188    // itself be mid-update when a hover timer calls this helper.
189    for other in others {
190        other.update(cx, |state, cx| {
191            if state.close(true) {
192                cx.notify();
193            }
194        });
195    }
196    warmed_up
197}
198
199fn mark_tooltip_open(cx: &mut App) {
200    cx.update_global::<TooltipManager, _>(|manager, _| {
201        manager.warmed_up = true;
202        manager.cooldown_generation += 1;
203    });
204}
205
206fn start_tooltip_cooldown(
207    current: &gpui::WeakEntity<TooltipHover>,
208    close_delay: u64,
209    cx: &mut App,
210) {
211    ensure_tooltip_manager(cx);
212    let generation = cx.update_global::<TooltipManager, _>(|manager, _| {
213        if !manager.warmed_up || !manager.entries.iter().any(|entry| entry == current) {
214            return None;
215        }
216        manager.cooldown_generation += 1;
217        Some(manager.cooldown_generation)
218    });
219    let Some(generation) = generation else {
220        return;
221    };
222    let cooldown = cx.layout().tooltip_cooldown_ms.max(close_delay);
223    cx.spawn(async move |cx: &mut gpui::AsyncApp| {
224        cx.background_executor()
225            .timer(Duration::from_millis(cooldown))
226            .await;
227        cx.update_global::<TooltipManager, _>(|manager, _| {
228            if manager.cooldown_generation == generation {
229                manager.warmed_up = false;
230                manager.entries.clear();
231            }
232        });
233    })
234    .detach();
235}
236
237/// `trigger` — what reveals the tip.
238///
239/// v3's default is `hover`, and React Aria shows a hovered tooltip on keyboard
240/// focus as well, so `Hover` means "either". `Focus` is the narrower one: the
241/// pointer does nothing and only focus opens it.
242#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
243pub enum TooltipTrigger {
244    #[default]
245    /// Shown while the trigger is hovered.
246    Hover,
247    /// Shown while the trigger is focused.
248    Focus,
249}
250
251impl TooltipTrigger {
252    /// Every trigger mode, in declaration order.
253    pub const ALL: [TooltipTrigger; 2] = [TooltipTrigger::Hover, TooltipTrigger::Focus];
254
255    /// A human-readable label for this trigger mode.
256    pub fn label(self) -> &'static str {
257        match self {
258            TooltipTrigger::Hover => "Hover",
259            TooltipTrigger::Focus => "Focus",
260        }
261    }
262}
263
264/// HeroUI Tooltip: wraps a trigger and reveals a tip on hover.
265#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
266#[derive(IntoElement)]
267pub struct Tooltip {
268    id: Option<ElementId>,
269    content: SharedString,
270    is_disabled: bool,
271    placement: TooltipPlacement,
272    show_arrow: bool,
273    offset: Option<Pixels>,
274    should_skip_animation: bool,
275    delay: Option<u64>,
276    close_delay: Option<u64>,
277    trigger: TooltipTrigger,
278    /// A caller-drawn tip body, in place of the measured single line.
279    #[allow(clippy::type_complexity)]
280    body: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
281    children: Vec<AnyElement>,
282    /// The corner radius, in place of the owning `small_radius` helper.
283    radius: Option<Pixels>,
284    /// The `sx` slot, refined over the root style at the end of render.
285    sx: Option<Box<gpui::StyleRefinement>>,
286}
287
288impl Tooltip {
289    /// Creates a tooltip with the given content.
290    pub fn new(content: impl Into<SharedString>) -> Self {
291        Self {
292            id: None,
293            content: content.into(),
294            is_disabled: false,
295            placement: TooltipPlacement::Top,
296            show_arrow: false,
297            offset: None,
298            should_skip_animation: false,
299            delay: None,
300            close_delay: None,
301            trigger: TooltipTrigger::default(),
302            body: None,
303            children: Vec::new(),
304            radius: None,
305            sx: None,
306        }
307    }
308
309    /// Distinguishes this tooltip's hover state from its neighbours'.
310    ///
311    /// The default key is the tip text, which is unique on most pages; set an
312    /// id when two tooltips on one screen share the same content.
313    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
314        self.id = Some(id.into());
315        self
316    }
317
318    /// Draws the tip's body yourself, in place of the text handed to
319    /// [`Tooltip::new`].
320    ///
321    /// v3's `Tooltip` takes children, so a tip is free to compose a small
322    /// table, a key/value list or a swatch legend. This port shapes the tip's
323    /// single line to reproduce CSS `max-content` capped at 320px, which only
324    /// a string can go through; an element body skips that measurement and
325    /// takes its own intrinsic width under the same 320px cap instead.
326    ///
327    /// The string from [`Tooltip::new`] is still required and still used: it
328    /// remains the tip's accessible name and the default hover key, so a rich
329    /// tip cannot ship without something a screen reader can read. Pass the
330    /// text the body conveys.
331    ///
332    /// The closure runs only while the tip is on screen, never for a closed
333    /// tooltip, and it runs again on each frame of the reveal.
334    ///
335    /// ```
336    /// # use herogpui_components::tooltip::Tooltip;
337    /// # use gpui::{div, IntoElement, ParentElement};
338    /// Tooltip::new("Tokens: name, kind, scope")
339    ///     .body(|_, _| {
340    ///         div()
341    ///             .child("name — the identifier")
342    ///             .child("kind — the token class")
343    ///             .into_any_element()
344    ///     })
345    ///     .child(div().child("tokens"));
346    /// ```
347    pub fn body(mut self, render: impl Fn(&mut Window, &mut App) -> AnyElement + 'static) -> Self {
348        self.body = Some(Box::new(render));
349        self
350    }
351
352    /// `isDisabled` — suppresses the tip entirely.
353    pub fn is_disabled(mut self, v: bool) -> Self {
354        self.is_disabled = v;
355        self
356    }
357
358    /// Sets where the tooltip sits relative to its trigger.
359    pub fn placement(mut self, p: TooltipPlacement) -> Self {
360        self.placement = p;
361        self
362    }
363
364    /// `showArrow` — draws the arrow indicator pointing at the trigger.
365    pub fn show_arrow(mut self, v: bool) -> Self {
366        self.show_arrow = v;
367        self
368    }
369
370    /// `offset` — distance from the trigger. Defaults to 3px, or 7px with an
371    /// arrow, matching v3.
372    pub fn offset(mut self, offset: impl Into<Pixels>) -> Self {
373        self.offset = Some(offset.into());
374        self
375    }
376
377    /// `shouldSkipAnimation` — reveal without the entry animation.
378    ///
379    /// v3 uses this when moving quickly between neighbouring triggers, where
380    /// re-animating each tip reads as flicker.
381    pub fn should_skip_animation(mut self, v: bool) -> Self {
382        self.should_skip_animation = v;
383        self
384    }
385
386    /// `trigger` — `hover` (the default, which also answers keyboard focus) or
387    /// `focus`, which the pointer cannot open.
388    pub fn trigger(mut self, trigger: TooltipTrigger) -> Self {
389        self.trigger = trigger;
390        self
391    }
392
393    /// `delay` — milliseconds to wait before showing. Defaults to the
394    /// `--tooltip-delay` theme token.
395    pub fn delay(mut self, ms: u64) -> Self {
396        self.delay = Some(ms);
397        self
398    }
399
400    /// `closeDelay` — milliseconds to wait before hiding. Defaults to the
401    /// `--tooltip-close-delay` theme token.
402    pub fn close_delay(mut self, ms: u64) -> Self {
403        self.close_delay = Some(ms);
404        self
405    }
406
407    /// The corner radius, in place of the owning `small_radius` helper. The
408    /// tip's entry zoom interpolates the same value, so both follow the
409    /// override. Not a v3 prop; the removed v2 `radius` prop is prohibited
410    /// and this is a per-component repository extension.
411    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
412        self.radius = Some(radius.into());
413        self
414    }
415
416    /// The one slot for caller-owned low-level styling: GPUI's styling methods
417    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
418    /// applied to the tooltip's root element — the wrapper the trigger and the
419    /// floating tip sit in — after every value the placement and the active
420    /// theme chose, so they win.
421    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
422        util::refine_sx(&mut self.sx, style);
423        self
424    }
425}
426
427impl ParentElement for Tooltip {
428    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
429        self.children.extend(elements);
430    }
431}
432
433impl RenderOnce for Tooltip {
434    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
435        if self.is_disabled {
436            // A disabled tooltip renders its trigger and nothing else.
437            return util::apply_sx(gpui::div().flex().children(self.children), &self.sx)
438                .into_any_element();
439        }
440
441        let key = self
442            .id
443            .clone()
444            .unwrap_or_else(|| ElementId::Name(self.content.clone()));
445        // The state entity has to be created before the theme tokens are read;
446        // `use_keyed_state` takes `cx` mutably and would conflict with them.
447        let state = window.use_keyed_state(key.clone(), cx, |_, _| TooltipHover::new());
448        let current_tooltip = state.downgrade();
449        let (delay, close_delay) = {
450            let layout = cx.layout();
451            (
452                self.delay.unwrap_or(layout.tooltip_delay_ms),
453                self.close_delay.unwrap_or(layout.tooltip_close_delay_ms),
454            )
455        };
456        // React Aria explicitly removes the Trigger wrapper's tab index: the
457        // caller's trigger is the stop, and this handle only reports whether a
458        // descendant currently owns focus.
459        let wrap_focus =
460            window.use_keyed_state(element_id::scoped(&key, "wrap-focus"), cx, |_, cx| {
461                cx.focus_handle()
462            });
463        let wrap_handle = wrap_focus.read(cx).clone();
464        let focus_held = wrap_handle.contains_focused(window, cx);
465        // Escape's dismissal is per focus *session*: once the focus leaves the
466        // trigger, the latch is dropped, so the next focus is a fresh one and
467        // shows the tip again. Clearing here rather than on the next open is
468        // what makes a dismissal not permanent without ever touching the
469        // app-wide `focus_visible`.
470        if focus_held != state.read(cx).was_focused {
471            let keyboard_focus = focus_held && util::focus_visible(cx);
472            let leaving_keyboard_focus = state.read(cx).is_focus_open() && !focus_held;
473            state.update(cx, |s, cx| {
474                let closed = leaving_keyboard_focus && s.close(false);
475                s.was_focused = focus_held;
476                s.focus_open = keyboard_focus;
477                // Either edge ends the previous dismissal session. Clearing
478                // on arrival matters when hover was dismissed before focus.
479                s.focus_dismissed = false;
480                if keyboard_focus {
481                    // An immediate focus open replaces a pending hover warmup,
482                    // just as React Stately clears its global warmup timeout.
483                    s.generation += 1;
484                }
485                if closed {
486                    cx.notify();
487                }
488            });
489            if keyboard_focus {
490                let _ = prepare_tooltip_open(&current_tooltip, cx);
491                mark_tooltip_open(cx);
492            } else if leaving_keyboard_focus {
493                start_tooltip_cooldown(&current_tooltip, close_delay, cx);
494            }
495        }
496        let state_snapshot = state.read(cx);
497        let focus_open = state_snapshot.is_focus_open();
498        let hover_open = state_snapshot.is_open();
499        // `trigger="focus"` takes the pointer out of it; `hover` is both, which
500        // is React Aria's behaviour for the default.
501        let open = match self.trigger {
502            TooltipTrigger::Hover => hover_open || focus_open,
503            TooltipTrigger::Focus => focus_open,
504        };
505
506        let hover_state = state.clone();
507        let dismiss_current = current_tooltip.clone();
508        let dismiss_tooltip = util::shared(move |cx: &mut App| {
509            state.update(cx, |s, cx| {
510                if s.close(true) {
511                    cx.notify();
512                }
513            });
514            start_tooltip_cooldown(&dismiss_current, close_delay, cx);
515            util::DismissResult::Handled
516        });
517        // Press dismissal belongs to the trigger. The tip is a sibling here;
518        // v3 portals it outside the trigger, so pressing the surface itself
519        // must not trip `shouldCloseOnPress`.
520        let trigger = gpui::div()
521            .flex()
522            .children(self.children)
523            .capture_any_mouse_down({
524                let dismiss_tooltip = dismiss_tooltip.clone();
525                move |_, _, cx| {
526                    dismiss_tooltip(cx);
527                }
528            })
529            .on_key_down({
530                let dismiss_tooltip = dismiss_tooltip.clone();
531                move |_, _, cx| {
532                    // RAC wires `onKeyDown: onPressStart` on the trigger: any
533                    // key dismisses an already-open tooltip immediately.
534                    dismiss_tooltip(cx);
535                }
536            });
537        let hover_enabled = self.trigger == TooltipTrigger::Hover;
538        let mut wrapper = gpui::div()
539            // `on_hover` needs a stateful element, so the wrapper carries the id.
540            .id(key.clone())
541            .track_focus(&wrap_handle)
542            .relative()
543            .flex()
544            .child(trigger)
545            .on_hover(move |over, _window, cx: &mut App| {
546                if !hover_enabled {
547                    return;
548                }
549                let over = *over;
550                let current = hover_state.downgrade();
551                let warmed_up = over && prepare_tooltip_open(&current, cx);
552                if !over {
553                    // GPUI dispatches sibling hover listeners in reverse paint
554                    // order. An outgoing tooltip may run after the incoming
555                    // one opened, so only the manager's current entry may cool.
556                    start_tooltip_cooldown(&current, close_delay, cx);
557                }
558                let wait = if over {
559                    if warmed_up { 0 } else { delay }
560                } else {
561                    close_delay
562                };
563                let generation = hover_state.update(cx, |s, _| {
564                    s.generation += 1;
565                    s.generation
566                });
567
568                if wait == 0 {
569                    hover_state.update(cx, |s, cx| {
570                        if over {
571                            mark_tooltip_open(cx);
572                            s.open = true;
573                            cx.notify();
574                        } else if s.close(true) {
575                            cx.notify();
576                        }
577                    });
578                    return;
579                }
580
581                let weak = hover_state.downgrade();
582                cx.spawn(async move |cx: &mut gpui::AsyncApp| {
583                    cx.background_executor()
584                        .timer(Duration::from_millis(wait))
585                        .await;
586                    if let Some(state) = weak.upgrade() {
587                        state.update(cx, |s, cx| {
588                            // A newer hover transition supersedes this timer.
589                            if s.generation == generation {
590                                if over {
591                                    mark_tooltip_open(cx);
592                                    if !s.open {
593                                        s.open = true;
594                                        cx.notify();
595                                    }
596                                } else if s.close(true) {
597                                    cx.notify();
598                                }
599                            }
600                        });
601                    }
602                })
603                .detach();
604            });
605        // React Aria hides a tooltip on Escape, which reaches here from the
606        // focused trigger inside the wrapper. The hover flag alone is not
607        // enough: a `trigger="focus"` tip reads the focus gate and never
608        // looks at `open`, so Escape has to trip `focus_dismissed` as well.
609        // The latch is per focus session — it is dropped when the focus
610        // leaves (see the render gate) — so the next focus shows the tip
611        // again, and `focus_visible` is deliberately left untouched.
612        let (phase, overlay_token) = util::overlay_scope(
613            window,
614            cx,
615            element_id::scoped(&key, "tip-phase"),
616            open,
617            true,
618        );
619        let captured_dismiss = dismiss_tooltip.clone();
620        util::capture_escape(&overlay_token, move |_window, cx| captured_dismiss(cx), cx);
621        wrapper = util::dismiss_on_escape_with_token(wrapper, overlay_token, move |_window, cx| {
622            dismiss_tooltip(cx)
623        });
624
625        // A tooltip leaves the way every other overlay does: `overlay_scope`
626        // keeps it for its exit run, which is what `[data-exiting]` needs to
627        // have something to play and gives Escape a stack position.
628        //
629        // The tip — and the max-content line shaping it is sized from — is
630        // only built while it is visible: `shape_line` is the most expensive
631        // call in this render, and a closed tooltip has no surface to size.
632        if phase != util::OverlayPhase::Closed {
633            // The caller's body is built first: it takes `cx` mutably, and the
634            // theme reads below hold it borrowed for the rest of this block.
635            let body = self.body.take().map(|render| render(window, cx));
636            let colors = cx.colors();
637            let layout = cx.layout();
638            // v3 pushes the tip further out when the arrow needs room.
639            let offset = self
640                .offset
641                .unwrap_or(if self.show_arrow { px(7.) } else { px(3.) });
642            // The entry zoom interpolates the tip's own radius, so one
643            // binding feeds both the painted shape and the animation.
644            let radius = self.radius.unwrap_or_else(|| util::small_radius(cx));
645            // CSS gives an absolutely positioned tooltip max-content width capped
646            // at 320px. GPUI otherwise resolves normal wrapping to min-content,
647            // making even "With an arrow" one word wide, so shape the single line
648            // and pin the same max-content result explicitly.
649            let content = self.content.clone();
650            // A caller-drawn body replaces the measured line, and with it the
651            // whole `max-content` reconstruction the string path performs: an
652            // element resolves its own intrinsic width the way CSS would, so
653            // the tip only has to impose the same 320px cap on it. The string
654            // stays the tip's accessible name in both shapes.
655            let mut tip = match body {
656                Some(body) => {
657                    gpui::div()
658                        .id(element_id::scoped(&key, "tip"))
659                        .a11y_named(a11y::Role::Tooltip, &a11y::Name::labelled(content))
660                        .relative()
661                        // `.tooltip` is `p-2` all round.
662                        .p(px(8.))
663                        .max_w(px(320.))
664                        .rounded(radius)
665                        .bg(colors.overlay.background)
666                        .text_color(colors.overlay.foreground)
667                        .text_size(px(12.))
668                        .line_height(px(16.))
669                        .when_some(layout.overlay_hairline, |el, hairline| {
670                            el.border(layout.border_width).border_color(hairline)
671                        })
672                        .shadow(layout.overlay_shadow.clone())
673                        .child(body)
674                }
675                None => {
676                    let raw_run = gpui::TextRun {
677                        len: content.len(),
678                        font: window.text_style().font(),
679                        color: gpui::black(),
680                        background_color: None,
681                        underline: None,
682                        strikethrough: None,
683                    };
684                    let hairline_width = if layout.overlay_hairline.is_some() {
685                        layout.border_width * 2.
686                    } else {
687                        px(0.)
688                    };
689                    // `overflow-wrap: anywhere` only takes effect when the natural
690                    // line would exceed the 320px cap.  Inserting a zero-width break
691                    // after every character unconditionally makes short placements
692                    // such as the `Left` tooltip wrap its final letter because GPUI's
693                    // line wrapper treats the opportunity as a legal split even when
694                    // the unbroken word would fit.  Measure the natural text first,
695                    // then add opportunities only for content that actually needs the
696                    // cap.
697                    let raw_line =
698                        window
699                            .text_system()
700                            .shape_line(content.clone(), px(12.), &[raw_run], None);
701                    let natural_width = raw_line.width + px(16.) + hairline_width;
702                    let (display, needs_breaks) =
703                        tooltip_display_content(content.as_ref(), natural_width);
704                    let display_content: SharedString = display.into();
705                    let run = gpui::TextRun {
706                        len: display_content.len(),
707                        font: window.text_style().font(),
708                        color: gpui::black(),
709                        background_color: None,
710                        underline: None,
711                        strikethrough: None,
712                    };
713                    let line = if display_content == content {
714                        raw_line
715                    } else {
716                        window.text_system().shape_line(
717                            display_content.clone(),
718                            px(12.),
719                            &[run],
720                            None,
721                        )
722                    };
723                    let intrinsic_width = line.width + px(16.) + hairline_width;
724                    let tooltip_width = if intrinsic_width < px(320.) {
725                        intrinsic_width
726                    } else {
727                        px(320.)
728                    };
729
730                    gpui::div()
731                    // `tooltip/tooltip.js` renders the RAC `Tooltip`, and
732                    // `react-aria/dist/private/tooltip/useTooltip.js` is a single
733                    // `role: 'tooltip'`. Upstream leaves the tip unnamed and
734                    // points the *trigger*'s `aria-describedby` at it; with no id
735                    // graph the port names the tip with its own content instead,
736                    // which is the text that describedby would have resolved to.
737                    .id(element_id::scoped(&key, "tip"))
738                    .a11y_named(a11y::Role::Tooltip, &a11y::Name::labelled(content))
739                    // The placement anchor lives on an outer absolute wrapper
740                    // below. Keeping the painted surface relative lets the entry
741                    // slide use top/left without replacing that anchor.
742                    .relative()
743                    // `.tooltip` is `p-2` all round, not a wider-than-tall pill.
744                    .p(px(8.))
745                    .w(tooltip_width)
746                    .rounded(radius)
747                    .bg(colors.overlay.background)
748                    .text_color(colors.overlay.foreground)
749                    .text_size(px(12.))
750                    .line_height(px(16.))
751                    // GPUI's normal wrapper can round a max-content width down by
752                    // a glyph fraction and split the last letter of a short
753                    // placement label (for example, `Left`).  Short tooltips have
754                    // already been measured to fit, so keep that line intact;
755                    // long capped content still uses normal wrapping at the
756                    // inserted zero-width opportunities above.
757                    .when(!needs_breaks, |el| el.whitespace_nowrap())
758                    .when_some(layout.overlay_hairline, |el, hairline| {
759                        el.border(layout.border_width).border_color(hairline)
760                    })
761                    .shadow(layout.overlay_shadow.clone())
762                    .child(display_content)
763                }
764            };
765
766            if self.show_arrow {
767                // The arrow leaf pins to the tip's resolved side; the
768                // placement's cross-axis alignment flushes it to that edge or
769                // centres it by stretching, mirroring the anchor below.
770                let mut arrow = gpui::div().absolute().child(
771                    gpui::svg()
772                        .size(px(12.))
773                        .path(icons::TOOLTIP_ARROW)
774                        // svg() never inherits text colour; the arrow has to be
775                        // tinted to match the tip body explicitly.
776                        .text_color(colors.overlay.background)
777                        .with_transformation(gpui::Transformation::rotate(gpui::radians(
778                            arrow_rotation(self.placement),
779                        ))),
780                );
781                arrow = if self.placement.is_side() {
782                    let base = if self.placement.is_start_side() {
783                        arrow.left_full()
784                    } else {
785                        arrow.right_full()
786                    };
787                    match self.placement.align() {
788                        PlacementAlign::Start => base.top(px(0.)),
789                        PlacementAlign::End => base.bottom(px(0.)),
790                        PlacementAlign::Center => {
791                            base.top(px(0.)).bottom(px(0.)).flex().items_center()
792                        }
793                    }
794                } else {
795                    let base = if self.placement.is_above() {
796                        arrow.top_full()
797                    } else {
798                        arrow.bottom_full()
799                    };
800                    match self.placement.align() {
801                        PlacementAlign::Start => base.left(px(0.)),
802                        PlacementAlign::End => base.right(px(0.)),
803                        PlacementAlign::Center => {
804                            base.left(px(0.)).right(px(0.)).flex().justify_center()
805                        }
806                    }
807                };
808                tip = tip.child(arrow);
809            }
810
811            // `absolute` does not lift the tip above later siblings in the page,
812            // so it has to paint last.
813            let (slide_x, slide_y) = entry_offset(self.placement);
814            let zoom = anim::ZoomBox::panel(px(8.), radius).padding_x(px(8.));
815            let zoom = anim::ZoomBox {
816                slide_x: (slide_x != 0.0).then(|| px(slide_x)),
817                slide_y: (slide_y != 0.0).then(|| px(slide_y)),
818                ..zoom
819            };
820            let animated = if self.should_skip_animation {
821                tip.into_any_element()
822            } else if phase == util::OverlayPhase::Exiting {
823                anim::exiting(
824                    tip,
825                    element_id::scoped(&key, "tip-out"),
826                    zoom,
827                    anim::Motion::LIST_OUT,
828                    cx,
829                )
830            } else {
831                // `tooltip.css` is `duration-150 ease-smooth zoom-in-90` — the
832                // same zoom as a popover, not a slide.
833                anim::entering_zoom(
834                    tip,
835                    element_id::scoped(&key, "tip"),
836                    zoom,
837                    anim::Motion::POPOVER_IN,
838                    cx,
839                )
840            };
841            // Keep the placement anchor outside the animated surface. The
842            // inner `ZoomBox` can then apply its four-pixel relative slide
843            // without clobbering the anchor's absolute side constraint.
844            let mut anchor = gpui::div().absolute();
845            anchor = if self.placement.is_side() {
846                let base = if self.placement.is_start_side() {
847                    anchor.right_full().mr(offset)
848                } else {
849                    anchor.left_full().ml(offset)
850                };
851                match self.placement.align() {
852                    PlacementAlign::Start => base.top_0(),
853                    PlacementAlign::End => base.bottom_0(),
854                    PlacementAlign::Center => base.top_0().bottom_0().flex().items_center(),
855                }
856            } else {
857                let base = if self.placement.is_above() {
858                    anchor.bottom_full().mb(offset)
859                } else {
860                    anchor.top_full().mt(offset)
861                };
862                match self.placement.align() {
863                    PlacementAlign::Start => base.left_0(),
864                    PlacementAlign::End => base.right_0(),
865                    PlacementAlign::Center => base.left_0().right_0().flex().justify_center(),
866                }
867            };
868            wrapper = wrapper.child(util::floating(anchor.child(animated)));
869        }
870
871        util::apply_sx(wrapper, &self.sx).into_any_element()
872    }
873}
874
875#[cfg(test)]
876mod tests {
877    use super::{
878        arrow_rotation, entry_offset, tooltip_display_content,
879        tooltip_text_with_break_opportunities, TooltipPlacement,
880    };
881
882    #[test]
883    fn tooltip_text_adds_breaks_without_changing_whitespace() {
884        assert_eq!(
885            tooltip_text_with_break_opportunities("longtoken"),
886            "l\u{200b}o\u{200b}n\u{200b}g\u{200b}t\u{200b}o\u{200b}k\u{200b}e\u{200b}n"
887        );
888        assert_eq!(
889            tooltip_text_with_break_opportunities("two words\nnext"),
890            "t\u{200b}w\u{200b}o w\u{200b}o\u{200b}r\u{200b}d\u{200b}s\nn\u{200b}e\u{200b}x\u{200b}t"
891        );
892    }
893
894    #[test]
895    fn short_tooltips_keep_their_label_while_long_content_gets_breaks() {
896        assert_eq!(
897            tooltip_display_content("Left", gpui::px(40.)),
898            ("Left".to_owned(), false)
899        );
900        let (long, needs_breaks) = tooltip_display_content("longtoken", gpui::px(321.));
901        assert!(needs_breaks);
902        assert!(long.contains('\u{200b}'));
903    }
904
905    #[test]
906    fn entry_offsets_follow_the_tooltip_side() {
907        assert_eq!(entry_offset(TooltipPlacement::Top), (0.0, 4.0));
908        assert_eq!(entry_offset(TooltipPlacement::TopStart), (0.0, 4.0));
909        assert_eq!(entry_offset(TooltipPlacement::TopLeft), (0.0, 4.0));
910        assert_eq!(entry_offset(TooltipPlacement::TopEnd), (0.0, 4.0));
911        assert_eq!(entry_offset(TooltipPlacement::TopRight), (0.0, 4.0));
912        assert_eq!(entry_offset(TooltipPlacement::Bottom), (0.0, -4.0));
913        assert_eq!(entry_offset(TooltipPlacement::BottomStart), (0.0, -4.0));
914        assert_eq!(entry_offset(TooltipPlacement::BottomLeft), (0.0, -4.0));
915        assert_eq!(entry_offset(TooltipPlacement::BottomEnd), (0.0, -4.0));
916        assert_eq!(entry_offset(TooltipPlacement::BottomRight), (0.0, -4.0));
917        assert_eq!(entry_offset(TooltipPlacement::Left), (4.0, 0.0));
918        assert_eq!(entry_offset(TooltipPlacement::LeftTop), (4.0, 0.0));
919        assert_eq!(entry_offset(TooltipPlacement::LeftBottom), (4.0, 0.0));
920        assert_eq!(entry_offset(TooltipPlacement::Start), (4.0, 0.0));
921        assert_eq!(entry_offset(TooltipPlacement::StartTop), (4.0, 0.0));
922        assert_eq!(entry_offset(TooltipPlacement::StartBottom), (4.0, 0.0));
923        assert_eq!(entry_offset(TooltipPlacement::Right), (-4.0, 0.0));
924        assert_eq!(entry_offset(TooltipPlacement::RightTop), (-4.0, 0.0));
925        assert_eq!(entry_offset(TooltipPlacement::RightBottom), (-4.0, 0.0));
926        assert_eq!(entry_offset(TooltipPlacement::End), (-4.0, 0.0));
927        assert_eq!(entry_offset(TooltipPlacement::EndTop), (-4.0, 0.0));
928        assert_eq!(entry_offset(TooltipPlacement::EndBottom), (-4.0, 0.0));
929    }
930
931    #[test]
932    #[allow(clippy::float_cmp)] // the rotations are quarter-turn constants
933    fn arrow_rotations_face_the_tooltip_side() {
934        assert_eq!(arrow_rotation(TooltipPlacement::Top), 0.);
935        assert_eq!(arrow_rotation(TooltipPlacement::TopRight), 0.);
936        assert_eq!(
937            arrow_rotation(TooltipPlacement::Bottom),
938            std::f32::consts::PI
939        );
940        assert_eq!(
941            arrow_rotation(TooltipPlacement::BottomLeft),
942            std::f32::consts::PI
943        );
944        assert_eq!(
945            arrow_rotation(TooltipPlacement::Left),
946            -std::f32::consts::FRAC_PI_2
947        );
948        assert_eq!(
949            arrow_rotation(TooltipPlacement::StartBottom),
950            -std::f32::consts::FRAC_PI_2
951        );
952        assert_eq!(
953            arrow_rotation(TooltipPlacement::Right),
954            std::f32::consts::FRAC_PI_2
955        );
956        assert_eq!(
957            arrow_rotation(TooltipPlacement::EndTop),
958            std::f32::consts::FRAC_PI_2
959        );
960    }
961
962    #[test]
963    fn placement_list_includes_the_supported_aligned_edges() {
964        assert_eq!(TooltipPlacement::ALL.len(), 22);
965        for placement in [
966            TooltipPlacement::TopStart,
967            TooltipPlacement::TopLeft,
968            TooltipPlacement::TopEnd,
969            TooltipPlacement::TopRight,
970            TooltipPlacement::BottomStart,
971            TooltipPlacement::BottomLeft,
972            TooltipPlacement::BottomEnd,
973            TooltipPlacement::BottomRight,
974            TooltipPlacement::LeftTop,
975            TooltipPlacement::LeftBottom,
976            TooltipPlacement::RightTop,
977            TooltipPlacement::RightBottom,
978            TooltipPlacement::Start,
979            TooltipPlacement::StartTop,
980            TooltipPlacement::StartBottom,
981            TooltipPlacement::End,
982            TooltipPlacement::EndTop,
983            TooltipPlacement::EndBottom,
984        ] {
985            assert!(TooltipPlacement::ALL.contains(&placement));
986        }
987    }
988}
989
990crate::util::impl_component_styled!(Tooltip);