chore: audit all components #2

Merged
reya merged 14 commits from audit into master 2026-11-08 06:27:14 +00:00
6 changed files with 289 additions and 37 deletions
Showing only changes of commit eb2273f87e - Show all commits
+8 -3
View File
@@ -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))
+100 -16
View File
@@ -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<Bounds<Pixels>>,
}
impl Tooltip {
@@ -16,7 +20,28 @@ impl Tooltip {
where
L: Into<SharedString>,
{
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<L, I>(text: L, anchor_id: I) -> impl Fn(&mut Window, &mut App) -> AnyView
where
L: Into<SharedString>,
I: Into<ElementId>,
{
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<Self>) -> impl IntoElement {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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)
}
}
+111 -8
View File
@@ -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<RefCell<Vec<&'static str>>>,
}
@@ -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<Self>) -> 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<LowHarness>) {
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"
);
}
+39 -2
View File
@@ -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<ElementId, Bounds<Pixels>>);
impl Global for AnchorBoundsRegistry {}
impl AnchorBoundsRegistry {
fn record(&mut self, id: ElementId, bounds: Bounds<Pixels>) {
self.0.insert(id, bounds);
}
fn get(&self, id: &ElementId) -> Option<Bounds<Pixels>> {
self.0.get(id).copied()
}
}
/// The last bounds an element painted at.
pub fn anchor_bounds(id: &ElementId, cx: &mut App) -> Option<Bounds<Pixels>> {
cx.try_global::<AnchorBoundsRegistry>()
.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<Pixels>, &mut Window, &mut App) + 'static {
move |bounds, _window, cx| {
let mut registry = cx
.try_global::<AnchorBoundsRegistry>()
.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<BoxShadow> {
vec![BoxShadow::new(px(0.), px(0.), color.alpha(0.5)).spread_radius(px(3.))]
+10 -1
View File
@@ -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
+21 -7
View File
@@ -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",
)),
),
))
}