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(¤t_tooltip, cx);
491 mark_tooltip_open(cx);
492 } else if leaving_keyboard_focus {
493 start_tooltip_cooldown(¤t_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(¤t, 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(¤t, 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);