diff --git a/crates/core/src/button.rs b/crates/core/src/button.rs index 619a0d2..524ceef 100644 --- a/crates/core/src/button.rs +++ b/crates/core/src/button.rs @@ -3,12 +3,12 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ AnyView, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, - RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, hsla, - relative, + RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas, + hsla, relative, }; use gpui_native_icons::{IconRole, icon}; use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size}; -use gpui_native_utils::focus_ring; +use gpui_native_utils::{focus_ring, record_anchor_bounds}; use std::rc::Rc; use crate::common::{Sizable, h_flex}; @@ -241,6 +241,11 @@ impl RenderOnce for Button { }) }) .when_some(self.label, |button, label| button.child(label)) + .child( + canvas(record_anchor_bounds(self.id.clone()), |_, _, _, _| {}) + .absolute() + .inset_0(), + ) .when(self.disabled, |button| button.opacity(0.5)) .when_some(self.tooltip, |button, tooltip| { button.tooltip(move |window, cx| tooltip(window, cx)) diff --git a/crates/core/src/tooltip.rs b/crates/core/src/tooltip.rs index 321081f..31c1e2b 100644 --- a/crates/core/src/tooltip.rs +++ b/crates/core/src/tooltip.rs @@ -1,13 +1,17 @@ use gpui::AppContext as _; use gpui::{ - AnyView, App, Context, Entity, IntoElement, ParentElement as _, Render, SharedString, - Styled as _, Window, div, px, + AnyView, App, Bounds, Context, ElementId, Entity, InteractiveElement as _, IntoElement, + ParentElement as _, Pixels, Render, SharedString, Styled as _, Window, canvas, div, point, px, }; -use gpui_native_theme::ActiveTheme as _; +use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH}; +use gpui_native_utils::{anchor_bounds, record_anchor_bounds}; + +const PANEL_BOUNDS_ID: &str = "tooltip-panel-bounds"; /// A small text panel gpui shows next to a hovered element. pub struct Tooltip { text: SharedString, + anchor: Option>, } impl Tooltip { @@ -16,7 +20,28 @@ impl Tooltip { where L: Into, { - cx.new(|_| Self { text: text.into() }) + cx.new(|_| Self { + text: text.into(), + anchor: None, + }) + } + + /// Sets the anchor element ID for the tooltip. + pub fn anchored(text: L, anchor_id: I) -> impl Fn(&mut Window, &mut App) -> AnyView + where + L: Into, + I: Into, + { + let text = text.into(); + let anchor_id = anchor_id.into(); + move |_window, cx| { + let anchor = anchor_bounds(&anchor_id, cx); + cx.new(|_| Self { + text: text.clone(), + anchor, + }) + .into() + } } /// The `.tooltip(...)` closure for a plain text tooltip. @@ -30,20 +55,79 @@ impl Tooltip { } impl Render for Tooltip { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // Copied out: the mutable reads below need `cx` free of this borrow. let theme = cx.theme(); let text_size = theme.sizes.small.text_size; + let spacing_unit = theme.spacing.unit; + let surface = theme.colors.surface; + let surface_foreground = theme.colors.surface_foreground; + let radius_sm = theme.radius.sm; + let shadows = theme.elevations.popover.shadows(); - div() - .px_2() - .py_1() - .max_w(px(360.)) - .bg(theme.colors.surface) - .border_1() - .border_color(theme.colors.border) - .rounded(theme.radius.sm) - .text_size(text_size) - .text_color(theme.colors.surface_foreground) - .child(self.text.clone()) + let border = if theme.borders.overlays { + theme.colors.border + } else { + theme.colors.surface + }; + + let build_panel = || { + div() + .debug_selector(|| "tooltip-panel".to_string()) + .px_2() + .py_1() + .max_w(px(360.)) + .bg(surface) + .border_1() + .border_color(border) + .rounded(radius_sm) + .shadow(shadows.clone()) + .text_size(text_size) + .text_color(surface_foreground) + .child(self.text.clone()) + }; + + let (wrapper, panel) = match self.anchor { + Some(parent) => { + let origin = window.mouse_position() + point(px(1.), px(1.)); + let viewport_height = window.viewport_size().height; + + let height = anchor_bounds(&ElementId::from(PANEL_BOUNDS_ID), cx) + .map(|bounds| bounds.size.height) + .unwrap_or_else(|| { + text_size * 1.5 + window.rem_size() * 0.5 + BORDER_WIDTH * 2.0 + }); + + let below = viewport_height - spacing_unit - parent.bottom(); + let above = parent.top() - spacing_unit; + let hang_below = below >= height || below >= above; + + let panel = build_panel() + .ml(parent.left() - origin.x) + .mt(if hang_below { + parent.bottom() + spacing_unit - origin.y + } else { + parent.top() - spacing_unit - height - origin.y + }) + .child( + canvas( + record_anchor_bounds(ElementId::from(PANEL_BOUNDS_ID)), + |_, _, _, _| {}, + ) + .absolute() + .inset_0(), + ); + (div().debug_selector(|| "tooltip-anchor".to_string()), panel) + } + None => { + let wrapper = div() + .debug_selector(|| "tooltip-anchor".to_string()) + .pl(spacing_unit * 2.0) + .pt(spacing_unit * 2.0); + (wrapper, build_panel()) + } + }; + + wrapper.child(panel) } } diff --git a/crates/core/tests/tooltip.rs b/crates/core/tests/tooltip.rs index 036647b..5cd943d 100644 --- a/crates/core/tests/tooltip.rs +++ b/crates/core/tests/tooltip.rs @@ -1,17 +1,18 @@ -use std::cell::RefCell; -use std::rc::Rc; +use std::{cell::RefCell, rc::Rc}; use gpui::{ Context, InteractiveElement as _, IntoElement, Modifiers, MouseMoveEvent, ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, TestAppContext, VisualTestContext, - Window, div, point, px, + Window, WindowHandle, canvas, div, point, px, }; use gpui_native_core::button::Button; use gpui_native_core::tooltip::Tooltip; use gpui_native_theme::Theme; +use gpui_native_utils::record_anchor_bounds; /// Hover target at the top-left with a tooltip whose builder records every -/// invocation. +/// invocation. The tooltip anchors to the target itself, so it hangs below +/// the target's bottom edge instead of at the cursor. struct Harness { calls: Rc>>, } @@ -21,20 +22,26 @@ impl Render for Harness { let calls = self.calls.clone(); let target = div() .id("hover-target") + .debug_selector(|| "hover-target".to_string()) .w(px(100.)) .h(px(40.)) .bg(gpui::black()) - .tooltip(move |_, cx| { + .child( + canvas(record_anchor_bounds("hover-target".into()), |_, _, _, _| {}) + .absolute() + .inset_0(), + ) + .tooltip(move |window, cx| { calls.borrow_mut().push("target"); - Tooltip::new("Target tooltip", cx).into() + Tooltip::anchored("Target tooltip", "hover-target")(window, cx) }); let button_calls = self.calls.clone(); let button = Button::new("hover-button") .label("Hover me") - .tooltip(move |_, cx| { + .tooltip(move |window, cx| { button_calls.borrow_mut().push("button"); - Tooltip::new("Button tooltip", cx).into() + Tooltip::anchored("Button tooltip", "hover-button")(window, cx) }); div() @@ -80,6 +87,40 @@ fn draw(visual: &mut VisualTestContext) { }); } +struct LowHarness; + +impl Render for LowHarness { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + // The target is pinned to the window's bottom edge: the space below it + // is nil, so the tooltip must flip above the parent. + div().size_full().flex().flex_col().justify_end().child( + div() + .id("low-target") + .debug_selector(|| "low-target".to_string()) + .w(px(100.)) + .h(px(40.)) + .bg(gpui::black()) + .child( + canvas(record_anchor_bounds("low-target".into()), |_, _, _, _| {}) + .absolute() + .inset_0(), + ) + .tooltip(Tooltip::anchored("Low tooltip", "low-target")), + ) + } +} + +fn low_tooltip_window() -> (TestAppContext, VisualTestContext, WindowHandle) { + let mut cx = TestAppContext::single(); + cx.update(Theme::init); + let window = cx.add_window(move |_, _| LowHarness); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + (cx, visual, window) +} + #[test] fn hovering_the_target_deploys_the_tooltip_after_the_delay() { let (cx, mut visual, calls) = tooltip_window(); @@ -118,3 +159,65 @@ fn the_button_passthrough_deploys_the_tooltip() { draw(&mut visual); assert_eq!(*calls.borrow(), vec!["button"]); } + +/// An anchored tooltip hangs below the hovered parent's bottom edge with the +/// spacing gap: it never covers the parent, wherever the cursor is inside it. +#[test] +fn the_anchored_panel_hangs_below_the_parent_without_overlapping() { + let (cx, mut visual, _calls) = tooltip_window(); + hover(&mut visual, point(px(50.), px(20.))); + cx.executor().advance_clock(SHOW_DELAY); + visual.run_until_parked(); + draw(&mut visual); + + let target = visual + .debug_bounds("hover-target") + .expect("target laid out"); + let panel = visual + .debug_bounds("tooltip-panel") + .expect("panel rendered"); + + let unit = cx.update(|cx| Theme::global(cx).spacing.unit); + assert!( + (panel.top() - target.bottom() - unit).abs() <= px(0.5), + "the panel hangs {gap:?} below the target", + gap = panel.top() - target.bottom() + ); + assert!((panel.left() - target.left()).abs() <= px(0.5)); + assert!( + panel.top() >= target.bottom(), + "the panel must never overlap the hovered parent" + ); +} + +/// A parent pinned to the window's bottom edge leaves no room below: the +/// panel flips above the parent's top edge instead. +#[test] +fn an_anchored_tooltip_flips_above_a_low_parent() { + let (mut cx, mut visual, window) = low_tooltip_window(); + + let window_height = window + .update(&mut cx, |_, window, _cx| window.viewport_size().height) + .expect("window alive"); + hover(&mut visual, point(px(50.), window_height - px(20.))); + cx.executor().advance_clock(SHOW_DELAY); + visual.run_until_parked(); + draw(&mut visual); + + let target = visual.debug_bounds("low-target").expect("target laid out"); + let panel = visual + .debug_bounds("tooltip-panel") + .expect("panel rendered"); + + // The flip hangs the panel above the parent's top edge. + assert!( + panel.bottom() <= target.top(), + "the panel must not cover the parent: panel bottom {:?}, parent top {:?}", + panel.bottom(), + target.top() + ); + assert!( + panel.top() >= px(0.), + "the flipped panel must stay inside the window" + ); +} diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index 880ff50..fd350ea 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -1,9 +1,10 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ - App, BoxShadow, Div, ElementId, FocusHandle, Hsla, IntoElement, Refineable as _, - StatefulInteractiveElement, StyleRefinement, Styled, Window, div, px, + App, Bounds, BoxShadow, Div, ElementId, FocusHandle, Global, Hsla, IntoElement, Pixels, + Refineable as _, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, px, }; use gpui_native_theme::ActiveTheme as _; +use std::collections::HashMap; /// A row whose children center on the cross axis. pub fn h_flex() -> Div { @@ -35,6 +36,42 @@ pub fn focus_handle(id: &ElementId, window: &mut Window, cx: &mut App) -> FocusH .clone() } +/// The painted bounds of elements that tooltips anchor to, keyed by element id. +#[derive(Clone, Default)] +struct AnchorBoundsRegistry(HashMap>); + +impl Global for AnchorBoundsRegistry {} + +impl AnchorBoundsRegistry { + fn record(&mut self, id: ElementId, bounds: Bounds) { + self.0.insert(id, bounds); + } + + fn get(&self, id: &ElementId) -> Option> { + self.0.get(id).copied() + } +} + +/// The last bounds an element painted at. +pub fn anchor_bounds(id: &ElementId, cx: &mut App) -> Option> { + cx.try_global::() + .and_then(|registry| registry.get(id)) +} + +/// A canvas prepaint closure recording the element's painted bounds. +pub fn record_anchor_bounds( + id: ElementId, +) -> impl Fn(Bounds, &mut Window, &mut App) + 'static { + move |bounds, _window, cx| { + let mut registry = cx + .try_global::() + .cloned() + .unwrap_or_default(); + registry.record(id.clone(), bounds); + cx.set_global(registry); + } +} + /// Returns a focus ring shadow with the given color at half ink. pub fn focus_ring(color: Hsla) -> Vec { vec![BoxShadow::new(px(0.), px(0.), color.alpha(0.5)).spread_radius(px(3.))] diff --git a/docs/CHECKLIST.md b/docs/CHECKLIST.md index 22a0dd7..41ce6e9 100644 --- a/docs/CHECKLIST.md +++ b/docs/CHECKLIST.md @@ -14,7 +14,6 @@ is the first downstream consumer; its screens drive the order. Platform metric adoption continues one component at a time; each stop goes through the review gate and a gallery pass. -- [ ] **Tooltip platform chrome** — overlay border gate. - [ ] **Alert platform chrome** — overlay border gate. - [ ] **Combobox platform chrome** — popup border behind `Theme::borders.overlays`; the trigger follows the input stop's field @@ -118,6 +117,16 @@ Gaps surfaced by the signed comparison; ordered by signed's usage. `Theme::borders.overlays` (and dark mode), taking the surface color where the platform paints none; the pill rides the popover elevation, so dark mode's no-shadow rule applies to it too +- [x] Tooltip platform chrome — the panel border behind + `Theme::borders.overlays` (and dark mode), taking the surface color + where the platform paints none, and riding the popover elevation for + the light-mode shadow; anchored tooltips hang below the + hovered parent (`Tooltip::anchored(text, id)`) so they never cover it — + the element records its painted bounds with `record_anchor_bounds` + (`Button` does), the panel hangs below that with the spacing gap, and + it flips above the parent when it would overflow the window bottom + (the panel records its own height after the first frame); unanchored + tooltips fall back to the cursor-offset wrapper - [x] Button, Checkbox, Switch, Radio + RadioGroup, Toggle, Link, Slider, Progress, Avatar (image + seeded pixel-art fallback) - [x] Spinner — painted ring + rotating-icon variant, indeterminate and diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index de91736..8b0418a 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -876,7 +876,9 @@ impl Render for ButtonDemo { }); }); if variant == ButtonVariant::Destructive { - button = button.tooltip(Tooltip::text("Removes the selected item")); + let id = SharedString::from(format!("button-demo-{name}")); + button = button + .tooltip(Tooltip::anchored("Removes the selected item", id)); } button })), @@ -901,7 +903,9 @@ impl Render for ButtonDemo { }); }); if variant == ButtonVariant::Destructive { - button = button.tooltip(Tooltip::text("Removes the selected item")); + let id = SharedString::from(format!("button-demo-icon-{name}")); + button = button + .tooltip(Tooltip::anchored("Removes the selected item", id)); } button })), @@ -914,11 +918,12 @@ impl Render for ButtonDemo { .gap_2() .children(variants.map(|(name, variant)| { let view = view.clone(); - Button::new(SharedString::from(format!("button-demo-icon-only-{name}"))) + let id = SharedString::from(format!("button-demo-icon-only-{name}")); + Button::new(id.clone()) .icon(IconRole::Folder) .variant(variant) .accessibility_label("Open the selected file") - .tooltip(Tooltip::text("Opens the selected file")) + .tooltip(Tooltip::anchored("Opens the selected file", id)) .on_click(move |_, _, cx| { view.update(cx, |demo, cx| { demo.clicks += 1; @@ -2800,20 +2805,29 @@ impl Render for TooltipDemo { Button::new("tooltip-demo-destructive") .label("Delete") .variant(ButtonVariant::Destructive) - .tooltip(Tooltip::text("Removes the selected item")), + .tooltip(Tooltip::anchored( + "Removes the selected item", + "tooltip-demo-destructive", + )), ) .child( Button::new("tooltip-demo-icon") .label("Open") .icon(IconRole::Folder) - .tooltip(Tooltip::text("Opens the selected file")), + .tooltip(Tooltip::anchored( + "Opens the selected file", + "tooltip-demo-icon", + )), ) .child( Button::new("tooltip-demo-icon-only") .icon(IconRole::Folder) .variant(ButtonVariant::Ghost) .accessibility_label("Open the selected file") - .tooltip(Tooltip::text("Opens the selected file")), + .tooltip(Tooltip::anchored( + "Opens the selected file", + "tooltip-demo-icon-only", + )), ), )) }