chore: audit all components (#2)

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-11-08 06:27:12 +00:00
parent 0ad5c3fe90
commit 11b29dc91a
51 changed files with 2096 additions and 958 deletions
+8 -2
View File
@@ -126,7 +126,13 @@ impl RenderOnce for Alert {
let muted_foreground = theme.colors.muted_foreground;
let muted = theme.colors.muted;
let surface = theme.colors.surface;
let border = theme.colors.border;
let border = if theme.borders.overlays {
theme.colors.border
} else {
surface
};
let shadows = theme.elevations.popover.shadows();
let metrics = theme.sizes.resolve(Size::Medium);
let icon_size = metrics.icon_size;
@@ -190,7 +196,7 @@ impl RenderOnce for Alert {
.id((self.id, "alert-close"))
.flex_none()
.cursor_pointer()
.p_0p5()
.p_1()
.rounded(theme.radius.sm)
.hover(|style| style.bg(muted))
.child(
-6
View File
@@ -315,10 +315,4 @@ mod tests {
assert!(filled >= MIN_FILLED * 2, "seed {seed} filled {filled}");
}
}
#[test]
fn fnv1a_is_stable_across_calls() {
assert_eq!(fnv1a(b"reya"), fnv1a(b"reya"));
assert_ne!(fnv1a(b"reya"), fnv1a(b"signed"));
}
}
+58 -19
View File
@@ -3,14 +3,16 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyView, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement as _,
RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, relative,
RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas,
hsla, relative,
};
use gpui_native_icons::{IconRole, icon};
use gpui_native_theme::{ActiveTheme as _, Size};
use gpui_native_utils::focus_ring;
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
use gpui_native_utils::{focus_ring, record_anchor_bounds};
use std::rc::Rc;
use crate::common::{Sizable, h_flex};
use crate::spinner::Spinner;
/// The single activation handler, shared by pointer, Enter, and Space.
type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
@@ -20,10 +22,10 @@ type TooltipBuilder = Rc<dyn Fn(&mut Window, &mut App) -> AnyView>;
/// Which visual treatment a button uses.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ButtonVariant {
/// Accent-filled; the platform's default action.
/// The platform's default action.
#[default]
Primary,
/// Neutral and bordered; the everyday companion to `Primary`.
/// The everyday companion to `Primary`.
Secondary,
/// Unpainted until hovered, for toolbars and dense UI.
Ghost,
@@ -40,6 +42,7 @@ pub struct Button {
variant: ButtonVariant,
size: Size,
disabled: bool,
loading: bool,
tab_stop: bool,
debug_selector: Option<Rc<dyn Fn() -> String>>,
a11y_label: Option<SharedString>,
@@ -60,6 +63,7 @@ impl Button {
variant: ButtonVariant::default(),
size: Size::default(),
disabled: false,
loading: false,
tab_stop: true,
debug_selector: None,
a11y_label: None,
@@ -119,6 +123,12 @@ impl Button {
self
}
/// Shows the spinner in place of the icon and suppresses activation.
pub fn loading(mut self, loading: bool) -> Self {
self.loading = loading;
self
}
/// Overrides the accessibility label, which defaults to the visible label.
pub fn accessibility_label<L>(mut self, label: L) -> Self
where
@@ -149,11 +159,12 @@ impl RenderOnce for Button {
let theme = cx.theme();
let colors = theme.colors;
let ramps = theme.ramps;
let borders = theme.borders;
let metrics = theme.sizes.resolve(self.size);
let show_border = borders.buttons && self.variant != ButtonVariant::Ghost;
let on_click = self.on_click;
let a11y_label = self.a11y_label.or_else(|| self.label.clone());
let icon_only = self.label.is_none() && self.icon.is_some();
let (background, foreground, hover, pressed) = match self.variant {
ButtonVariant::Primary => (
@@ -182,42 +193,70 @@ impl RenderOnce for Button {
),
};
let (border, hover_border, pressed_border) = if show_border {
(colors.border, colors.border, colors.border)
} else {
match background {
Some(fill) => (fill, hover, pressed),
None => (
hsla(0.0, 0.0, 0.0, 0.0),
hsla(0.0, 0.0, 0.0, 0.0),
hsla(0.0, 0.0, 0.0, 0.0),
),
}
};
h_flex()
.id(self.id)
.id(self.id.clone())
.when_some(self.debug_selector, |button, selector| {
button.debug_selector(move || selector())
})
.role(Role::Button)
.justify_center()
.when(icon_only, |button| button.size(metrics.control_height))
.when(!icon_only, |button| {
button.h(metrics.control_height).px(metrics.padding_x)
.h(metrics.control_height + BORDER_WIDTH * 2.0)
.when(self.icon.is_some(), |button| {
button.px(metrics.dense_padding_x)
})
.when(self.icon.is_none(), |button| button.px(metrics.padding_x))
.line_height(relative(1.0))
.gap_1p5()
.gap_2()
.rounded(theme.radius.control)
.text_size(metrics.text_size)
.when(self.variant == ButtonVariant::Secondary, |button| {
button.border_1().border_color(colors.border)
})
.border_1()
.border_color(border)
.when_some(background, |button, background| button.bg(background))
.text_color(foreground)
.when_some(a11y_label, |button, label| button.aria_label(label))
.when_some(self.icon, |button, role| {
button.child(icon(role).size(metrics.icon_size).color(foreground))
.when(self.loading, |button| {
button.child(
Spinner::new((self.id.clone(), "spinner"))
.with_size(self.size)
.variant(crate::spinner::SpinnerVariant::Icon)
.color(foreground),
)
})
.when(!self.loading, |button| {
button.when_some(self.icon, |button, role| {
button.child(icon(role).size(metrics.icon_size).color(foreground))
})
})
.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))
})
.when(!self.disabled, |button| {
.when(!self.disabled && !self.loading, |button| {
button
.cursor_pointer()
.focusable()
.tab_stop(self.tab_stop)
.hover(move |style| style.bg(hover))
.active(move |style| style.bg(pressed))
.hover(move |style| style.bg(hover).border_color(hover_border))
.active(move |style| style.bg(pressed).border_color(pressed_border))
.focus_visible(|style| style.shadow(focus_ring(colors.ring)))
.when_some(on_click, |button, on_click| {
button.on_click(move |event, window, cx| on_click(event, window, cx))
+61 -12
View File
@@ -12,13 +12,13 @@ use gpui::prelude::FluentBuilder as _;
use gpui::{
App, Bounds, Context, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement,
KeyBinding, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, Role,
ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Task,
Window, actions, canvas, deferred, div, px,
ScrollHandle, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
Subscription, Task, Window, actions, anchored, canvas, deferred, div, point, px,
};
use gpui_native_icons::{IconRole, icon};
use gpui_native_theme::{ActiveTheme as _, Size};
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
use crate::common::{Sizable, h_flex, v_flex};
use crate::common::{Sizable, StyledExt as _, h_flex, v_flex};
use crate::input::{Input, InputState, InputStateEvent};
use crate::scroll_area::ScrollArea;
@@ -81,6 +81,7 @@ where
filter: Option<ComboboxFilter<T>>,
unmatched: UnmatchedText,
size: Size,
style: StyleRefinement,
}
impl<T> Combobox<T>
@@ -103,6 +104,7 @@ where
filter: None,
unmatched: UnmatchedText::default(),
size: Size::default(),
style: StyleRefinement::default(),
}
}
@@ -165,6 +167,16 @@ where
}
}
/// Style refinements set the combobox's width.
impl<T> Styled for Combobox<T>
where
T: Clone + PartialEq + 'static,
{
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
/// Emitted when the combobox's value changes.
pub struct ComboboxChanged;
@@ -189,6 +201,8 @@ where
filter: ComboboxFilter<T>,
unmatched: UnmatchedText,
size: Size,
/// Width refinements from the builder, applied to the wrapper each render.
style: StyleRefinement,
/// Handler reports in flight; held so they die with the state.
tasks: Vec<Task<()>>,
/// Keeps the query-event subscription tied to the state's lifetime.
@@ -225,6 +239,7 @@ where
let subscription =
cx.subscribe_in(&query, window, |state, _, event, window, cx| match event {
InputStateEvent::Focused => state.query_focused(window, cx),
InputStateEvent::TextChanged => state.text_changed(window, cx),
InputStateEvent::Blurred => state.blurred(window, cx),
_ => {}
@@ -248,6 +263,7 @@ where
}),
unmatched: combobox.unmatched,
size: combobox.size,
style: combobox.style,
tasks: Vec::new(),
_subscription: subscription,
}
@@ -364,6 +380,16 @@ where
cx.notify();
}
/// Gaining focus.
fn query_focused(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
if self.selected.is_some() {
return;
}
self.refilter("");
self.highlight = (!self.visible.is_empty()).then_some(0usize);
cx.notify();
}
/// Losing focus. [`UnmatchedText`] decides what the text becomes.
fn blurred(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.highlight = None;
@@ -447,11 +473,25 @@ where
T: Clone + PartialEq + 'static,
{
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (metrics, colors, radius) = {
let (metrics, colors, radius, panel_border, spacing_unit, shadows) = {
let theme = cx.theme();
let metrics = theme.sizes.resolve(self.size);
(metrics, theme.colors, theme.radius)
let panel_border = if theme.borders.overlays {
theme.colors.border
} else {
theme.colors.surface
};
(
metrics,
theme.colors,
theme.radius,
panel_border,
theme.spacing.unit,
theme.elevations.popover.shadows(),
)
};
let icon_size = metrics.dense_icon_size;
let id = self.id.clone();
let state = cx.entity();
@@ -490,7 +530,9 @@ where
.id(id.clone())
.key_context(COMBOBOX_CONTEXT)
.relative()
.w_full()
.w(window.rem_size() * 12.0)
.min_w(window.rem_size() * 6.0)
.refine_style(&self.style)
.role(Role::ComboBox)
.aria_expanded(open)
.aria_label(self.name.clone())
@@ -532,13 +574,14 @@ where
.child(
Input::new((id.clone(), "query"), self.query.clone())
.placeholder(self.placeholder.clone())
.placeholder_when_focused(true)
.with_size(self.size),
)
.child(
h_flex()
.absolute()
.right(metrics.dense_padding_x)
.top((metrics.input_height - icon_size) / 2.)
.top((metrics.input_height + BORDER_WIDTH * 2.0 - icon_size) / 2.)
.child(chevron),
);
@@ -626,6 +669,10 @@ where
};
let panel_bounds: Rc<RefCell<Option<Bounds<Pixels>>>> = Rc::new(RefCell::new(None));
let popup_width = wrapper_bounds
.borrow()
.map(|bounds| bounds.size.width)
.unwrap_or_else(|| window.rem_size() * 12.0);
let panel = v_flex()
.id((id.clone(), "panel"))
@@ -633,14 +680,14 @@ where
let name = format!("{id}-panel");
move || name
})
.mt_1()
.w_full()
.w(popup_width)
.p_1()
.bg(colors.surface)
.text_color(colors.foreground)
.rounded(radius.overlay)
.border_1()
.border_color(colors.border)
.border_color(panel_border)
.shadow(shadows)
.overflow_hidden()
.child(rows)
.child(
@@ -684,7 +731,9 @@ where
.inset_0(),
);
wrapper = wrapper.child(deferred(panel));
wrapper = wrapper.child(deferred(
anchored().offset(point(px(0.), spacing_unit)).child(panel),
));
}
wrapper
+22 -3
View File
@@ -10,6 +10,7 @@ use gpui::{
ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Window,
actions, canvas, div, px, relative,
};
use gpui_native_icons::{IconRole, icon};
use gpui_native_theme::{ActiveTheme as _, Size};
use crate::common::{h_flex, v_flex};
@@ -48,18 +49,27 @@ pub struct Command {
pub label: SharedString,
/// Gray text on the row's right edge, e.g. the keybinding.
pub hint: Option<SharedString>,
/// Leading glyph shown before the label.
pub icon: Option<IconRole>,
}
impl Command {
/// Creates a command without a hint.
/// Creates a command without a hint or icon.
pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: label.into(),
hint: None,
icon: None,
}
}
/// Shows an icon before the label.
pub fn icon(mut self, role: IconRole) -> Self {
self.icon = Some(role);
self
}
/// Sets the row's right-edge hint.
pub fn hint(mut self, hint: impl Into<SharedString>) -> Self {
self.hint = Some(hint.into());
@@ -295,15 +305,21 @@ impl Render for CommandPaletteState {
return div().into_any_element();
}
let (colors, radius, metrics, shadows) = {
let (colors, radius, metrics, panel_border, shadows) = {
let theme = cx.theme();
(
theme.colors,
theme.radius,
theme.sizes.resolve(Size::default()),
if theme.borders.overlays {
theme.colors.border
} else {
theme.colors.surface
},
theme.elevations.dialog.shadows(),
)
};
let id = self.id.clone();
let state = cx.entity();
@@ -345,6 +361,9 @@ impl Render for CommandPaletteState {
state.update(cx, |state, cx| state.choose_row(row_ix, window, cx));
}
})
.when_some(command.icon, |row, role| {
row.child(icon(role).size(metrics.icon_size).color(colors.foreground))
})
.child(
div()
.flex_1()
@@ -407,7 +426,7 @@ impl Render for CommandPaletteState {
.text_color(colors.foreground)
.rounded(radius.overlay)
.border_1()
.border_color(colors.border)
.border_color(panel_border)
.shadow(shadows)
.overflow_hidden()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
+5 -1
View File
@@ -130,7 +130,11 @@ impl RenderOnce for Dialog {
let theme = cx.theme();
(
theme.colors.overlay,
theme.colors.border,
if theme.borders.overlays {
theme.colors.border
} else {
theme.colors.surface
},
theme.colors.foreground,
theme.colors.muted_foreground,
theme.colors.surface,
+243 -220
View File
@@ -7,15 +7,17 @@ use unicode_segmentation::UnicodeSegmentation;
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, AppContext as _, Bounds, ClipboardItem, ContentMask, Context, CursorStyle, DefiniteLength,
DispatchPhase, ElementId, Entity, EntityInputHandler, EventEmitter, Font, HitboxBehavior,
InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent,
MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, RenderOnce, Role, ScrollDelta,
ScrollEvent, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Task,
TextAlign, TextRun, UTF16Selection, Window, WrappedLine, actions, canvas, div, fill, point, px,
Animation, AnimationExt as _, AnyElement, App, AppContext as _, Bounds, ClipboardItem,
ContentMask, Context, CursorStyle, DefiniteLength, DispatchPhase, ElementId, Entity,
EntityInputHandler, EventEmitter, Font, HitboxBehavior, InteractiveElement as _, IntoElement,
KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
ParentElement as _, Pixels, RenderOnce, Role, ScrollDelta, ScrollEvent, SharedString,
StatefulInteractiveElement as _, Styled as _, TextAlign, TextRun, UTF16Selection, Window,
WrappedLine, actions, canvas, div, fill, point, px,
};
use gpui_native_icons::IconRole;
use gpui_native_theme::{ActiveTheme as _, ColorTokens, Size};
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, ColorTokens, Size};
use gpui_native_utils::focus_ring;
use crate::button::{Button, ButtonVariant};
use crate::common::{Sizable, h_flex};
@@ -476,97 +478,6 @@ impl HistoryEntry {
}
}
/// Caret blink controller.
struct CaretBlink {
interval: Duration,
generation: usize,
visible: bool,
active: bool,
paused: bool,
/// Pending blink timers; held so they die with the controller.
tasks: Vec<Task<()>>,
}
impl CaretBlink {
fn new(_cx: &mut Context<Self>) -> Self {
Self {
interval: CARET_BLINK_INTERVAL,
generation: 0,
visible: true,
active: false,
paused: false,
tasks: Vec::new(),
}
}
fn visible(&self) -> bool {
self.visible
}
/// Starts blinking; the caret shows first and hides after one interval.
fn enable(&mut self, cx: &mut Context<Self>) {
if self.active {
return;
}
self.active = true;
self.visible = true;
self.paused = false;
self.schedule(cx);
}
/// Stops blinking. The caret stays hidden; paint gates on focus anyway.
fn disable(&mut self, cx: &mut Context<Self>) {
self.active = false;
self.visible = false;
self.paused = false;
cx.notify();
}
/// Holds the caret visible and restarts the hide timer.
fn pause(&mut self, cx: &mut Context<Self>) {
if !self.active {
return;
}
if !self.visible {
self.visible = true;
cx.notify();
}
self.paused = true;
self.generation += 1;
self.schedule_pause(cx);
}
fn tick(&mut self, cx: &mut Context<Self>) {
if !self.active || self.paused {
return;
}
self.visible = !self.visible;
cx.notify();
self.schedule(cx);
}
fn schedule(&mut self, cx: &mut Context<Self>) {
self.generation += 1;
self.schedule_pause(cx);
}
fn schedule_pause(&mut self, cx: &mut Context<Self>) {
let generation = self.generation;
let interval = self.interval;
let task = cx.spawn(async move |this, cx| {
cx.background_executor().timer(interval).await;
this.update(cx, |this, cx| {
if this.generation == generation {
this.paused = false;
this.tick(cx);
}
})
.ok();
});
self.tasks.push(task);
}
}
/// The application-owned state of a text field.
pub struct InputState {
text: String,
@@ -606,20 +517,18 @@ pub struct InputState {
scroll_offset: gpui::Point<Pixels>,
/// Set by every caret-affecting change.
scroll_to_caret_pending: bool,
/// Drives the focused caret's blink.
caret_blink: Entity<CaretBlink>,
/// The shaped line and element bounds, recorded during each paint,
/// and shared with hit-testing and the IME geometry conversions.
/// Bumped on every edit.
caret_epoch: usize,
/// When the current caret epoch started.
caret_epoch_started: Option<Instant>,
/// The shaped line and element bounds, recorded during each paint.
layout: Rc<RefCell<Option<InputLayout>>>,
/// Keeps the field repainting on blink ticks. Dropping it unsubscribes.
_caret_blink_subscription: Subscription,
}
impl EventEmitter<InputStateEvent> for InputState {}
impl InputState {
fn new_inner(cx: &mut Context<Self>) -> Self {
let caret_blink = cx.new(CaretBlink::new);
Self {
text: String::new(),
cursor: 0,
@@ -649,9 +558,9 @@ impl InputState {
goal_x: None,
scroll_offset: point(Pixels::ZERO, Pixels::ZERO),
scroll_to_caret_pending: true,
caret_blink: caret_blink.clone(),
caret_epoch: 0,
caret_epoch_started: Some(Instant::now()),
layout: Rc::new(RefCell::new(None)),
_caret_blink_subscription: cx.observe(&caret_blink, |_, _, cx| cx.notify()),
}
}
@@ -661,9 +570,6 @@ impl InputState {
}
/// Creates the state entity with initial text, caret at the end.
///
/// Newlines become spaces; flip the field to multiline with [`set_multiline`]
/// before `set_value` to seed lines.
pub fn new_with_text(text: impl Into<String>, cx: &mut App) -> Entity<Self> {
let text = sanitize(text.into(), false);
cx.new(|cx| {
@@ -681,10 +587,6 @@ impl InputState {
}
/// Replaces the whole value and collapses the selection to the end.
///
/// Newlines become spaces unless the field is multiline. Emits
/// [`InputStateEvent::TextChanged`] when the value changed; setting the
/// current value is a no-op.
pub fn set_value<V>(&mut self, value: V, window: &mut Window, cx: &mut Context<Self>)
where
V: Into<String>,
@@ -866,7 +768,8 @@ impl InputState {
cx.notify();
self.scroll_to_caret_pending = true;
self.goal_x = None;
self.caret_blink.update(cx, |blink, cx| blink.pause(cx));
self.caret_epoch += 1;
self.caret_epoch_started = Some(Instant::now());
window.notify_text_input_changed(&self.focus_handle);
}
@@ -899,10 +802,11 @@ impl InputState {
}
self.focused = focused;
// A fresh focus starts a fresh blink epoch, so the caret shows solid
// for one interval before blinking resumes.
if focused {
self.caret_blink.update(cx, |blink, cx| blink.enable(cx));
} else {
self.caret_blink.update(cx, |blink, cx| blink.disable(cx));
self.caret_epoch += 1;
self.caret_epoch_started = Some(Instant::now());
}
cx.emit(if focused {
@@ -913,8 +817,22 @@ impl InputState {
}
/// Whether the caret should paint.
pub fn caret_visible(&mut self, focused: bool, cx: &mut Context<Self>) -> bool {
focused && !self.read_only && self.caret_blink.read(cx).visible()
pub fn caret_visible(&mut self, focused: bool) -> bool {
let elapsed = self
.caret_epoch_started
.map(|started| started.elapsed())
.unwrap_or_default();
self.caret_blink_visible_at(focused, elapsed)
}
/// The visibility math at a given time since the epoch started.
pub fn caret_blink_visible_at(&self, focused: bool, elapsed: Duration) -> bool {
if !focused || self.read_only {
return false;
}
let period = CARET_BLINK_INTERVAL * 2;
(elapsed.as_millis() as u64 % period.as_millis() as u64)
< CARET_BLINK_INTERVAL.as_millis() as u64
}
/// Replaces a byte range with `text` and collapses the caret to its end.
@@ -1315,7 +1233,7 @@ impl InputState {
}
// A caret promises that typing lands there; in read-only it does not.
if focused && self.caret_visible(focused, cx) && self.selection().is_empty() {
if focused && self.caret_visible(focused) && self.selection().is_empty() {
self.paint_caret(origin, font_size, colors.foreground, window);
}
@@ -1901,9 +1819,13 @@ pub struct Input {
placeholder: SharedString,
size: Size,
disabled: bool,
/// The placeholder stays visible while focused.
placeholder_when_focused: bool,
a11y_label: Option<SharedString>,
/// Multiline field height in rows; ignored when the state is single-line.
/// Multiline field height in row, ignored when the state is single-line.
rows: usize,
prefix: Option<AnyElement>,
suffix: Option<AnyElement>,
}
impl Input {
@@ -1918,8 +1840,11 @@ impl Input {
placeholder: SharedString::default(),
size: Size::default(),
disabled: false,
placeholder_when_focused: false,
a11y_label: None,
rows: DEFAULT_ROWS,
prefix: None,
suffix: None,
}
}
@@ -1938,6 +1863,12 @@ impl Input {
self
}
/// Shows the placeholder while the field is focused.
pub fn placeholder_when_focused(mut self, when_focused: bool) -> Self {
self.placeholder_when_focused = when_focused;
self
}
/// Renders the field inert, unfocusable, and dimmed; disabled always wins.
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
@@ -1952,6 +1883,24 @@ impl Input {
self.a11y_label = Some(label.into());
self
}
/// Docks an element inside the field's leading edge.
pub fn prefix<E>(mut self, prefix: E) -> Self
where
E: IntoElement,
{
self.prefix = Some(prefix.into_any_element());
self
}
/// Docks an element inside the field's trailing edge.
pub fn suffix<E>(mut self, suffix: E) -> Self
where
E: IntoElement,
{
self.suffix = Some(suffix.into_any_element());
self
}
}
impl Sizable for Input {
@@ -1967,25 +1916,28 @@ impl RenderOnce for Input {
let radius = theme.radius;
let metrics = theme.sizes.resolve(self.size);
let focus_handle = self.state.read(cx).focus_handle.clone();
let focused = focus_handle.is_focused(window);
let is_empty = self.state.read(cx).text.is_empty();
let is_password = self.state.read(cx).is_password();
let revealed = self.state.read(cx).is_revealed();
let multiline = self.state.read(cx).is_multiline();
let recording = focused && !self.disabled && !self.state.read(cx).is_read_only();
let right_padding = if is_password {
metrics.dense_padding_x + metrics.dense_icon_size
} else {
metrics.dense_padding_x
};
let small = theme.sizes.resolve(Size::Small);
let docked_height = small.control_height + BORDER_WIDTH * 2.0;
let edge_inset = ((metrics.input_height - docked_height) / 2.0).max(Pixels::ZERO);
// The field's own breathing room; the text starts here from the top when multiline.
let vertical_inset = metrics.dense_padding_x * 0.5;
let reveal_width = small.dense_padding_x * 2.0 + small.icon_size;
let field_padding = FieldPadding {
left: metrics.dense_padding_x,
right: right_padding,
left: Pixels::ZERO,
right: if is_password {
(edge_inset + reveal_width - metrics.dense_padding_x).max(Pixels::ZERO)
} else {
Pixels::ZERO
},
top: vertical_inset,
};
@@ -1993,9 +1945,10 @@ impl RenderOnce for Input {
DefiniteLength::Fraction(ratio) => metrics.text_size * ratio,
DefiniteLength::Absolute(length) => length.to_pixels(window.rem_size()),
});
let field_height = line_height.map_or(metrics.input_height, |line_height| {
line_height * self.rows as f32 + vertical_inset * 2.0
});
}) + BORDER_WIDTH * 2.0;
let fallback_label = self
.a11y_label
@@ -2003,8 +1956,111 @@ impl RenderOnce for Input {
.or_else(|| (!self.placeholder.is_empty()).then_some(self.placeholder.clone()))
.unwrap_or_else(|| SharedString::from(self.id.to_string()));
let edge_zone = |name: &'static str, element: AnyElement| {
div()
.debug_selector(|| name.to_string())
.flex_none()
.h_full()
.flex()
.items_center()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.child(element)
};
let canvas_element = {
let canvas = canvas(
{
let state = self.state.clone();
move |bounds, window, cx| {
// The hitbox backs the I-beam hover cursor.
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
let wrap_width = multiline.then(|| {
(bounds.size.width - field_padding.left - field_padding.right)
.max(Pixels::ZERO)
});
let text = state.read(cx).shape_current_text(wrap_width, window);
(text, hitbox)
}
},
{
let state = self.state.clone();
let focus = focus_handle.clone();
let disabled = self.disabled;
move |bounds, (text, hitbox), window, cx| {
if !disabled {
window.set_cursor_style(CursorStyle::IBeam, &hitbox);
}
let dragging = state.update(cx, |state, cx| {
state.paint(text, bounds, field_padding, colors, window, cx)
});
if disabled {
return;
}
window.handle_input(
&focus,
gpui::ElementInputHandler::new(bounds, state.clone()),
cx,
);
window.on_mouse_event({
let state = state.clone();
move |event: &ScrollEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble
|| !bounds.contains(&event.position)
{
return;
}
let delta = match event.delta {
ScrollDelta::Pixels(delta) => delta,
ScrollDelta::Lines(lines) => point(
px(lines.x * SCROLL_LINES_HEIGHT),
px(lines.y * SCROLL_LINES_HEIGHT),
),
};
let consumed = state
.update(cx, |state, cx| state.scroll_by(delta, window, cx));
if consumed {
cx.stop_propagation();
}
}
});
if dragging {
InputState::register_drag_listeners(&state, window);
}
}
},
)
.absolute()
.inset_0();
if recording {
let epoch = self.state.read(cx).caret_epoch;
let animation_id = ElementId::from(SharedString::from(format!(
"{self_id}-caret-blink-{epoch}",
self_id = self.id,
)));
canvas
.with_animation(
animation_id,
Animation::new(CARET_BLINK_INTERVAL * 2).repeat(),
|canvas, _| canvas,
)
.into_any_element()
} else {
canvas.into_any_element()
}
};
h_flex()
.id(self.id.clone())
.debug_selector(|| "input-field".to_string())
.key_context(INPUT_CONTEXT)
.role(Role::TextInput)
.aria_label(fallback_label)
@@ -2012,117 +2068,84 @@ impl RenderOnce for Input {
.overflow_hidden()
.w_full()
.h(field_height)
.px(metrics.dense_padding_x)
.pl(if self.prefix.is_some() {
edge_inset
} else {
metrics.dense_padding_x
})
.pr(if self.suffix.is_some() {
edge_inset
} else {
metrics.dense_padding_x
})
.gap_1p5()
.rounded(radius.control)
.border_1()
.border_color(colors.border)
.bg(colors.input)
.text_size(metrics.text_size)
.text_color(colors.foreground)
.when_some(line_height, |input, line_height| {
input.items_start().line_height(line_height)
.border_1()
.border_color(if theme.borders.fields {
colors.border
} else {
colors.input
})
.when(self.disabled, |input| input.opacity(0.5))
.when(!self.disabled, |input| {
input.track_focus(&focus_handle).tab_stop(true)
})
.when(focused, |input| input.border_color(colors.ring))
.when(is_empty && !focused, |input| {
input.child(
div()
.text_color(colors.muted_foreground)
.when(multiline, |placeholder| placeholder.pt(vertical_inset))
.child(self.placeholder.clone()),
)
.when(focused && !theme.borders.focus_ring, |input| {
input.border_color(colors.ring)
})
.when(focused && theme.borders.focus_ring, |input| {
input.shadow(focus_ring(colors.ring))
})
.when_some(self.prefix, |input, prefix| {
input.child(edge_zone("input-prefix", prefix))
})
.child(
canvas(
{
let state = self.state.clone();
move |bounds, window, cx| {
// The hitbox backs the I-beam hover cursor.
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
let wrap_width = multiline.then(|| {
(bounds.size.width - field_padding.left - field_padding.right)
.max(Pixels::ZERO)
});
let text = state.read(cx).shape_current_text(wrap_width, window);
(text, hitbox)
}
},
{
let state = self.state.clone();
let focus = focus_handle.clone();
let disabled = self.disabled;
move |bounds, (text, hitbox), window, cx| {
if !disabled {
window.set_cursor_style(CursorStyle::IBeam, &hitbox);
}
let dragging = state.update(cx, |state, cx| {
state.paint(text, bounds, field_padding, colors, window, cx)
});
if disabled {
return;
}
window.handle_input(
&focus,
gpui::ElementInputHandler::new(bounds, state.clone()),
cx,
);
window.on_mouse_event({
let state = state.clone();
move |event: &ScrollEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble
|| !bounds.contains(&event.position)
{
return;
}
let delta = match event.delta {
ScrollDelta::Pixels(delta) => delta,
ScrollDelta::Lines(lines) => point(
px(lines.x * SCROLL_LINES_HEIGHT),
px(lines.y * SCROLL_LINES_HEIGHT),
),
};
let consumed = state
.update(cx, |state, cx| state.scroll_by(delta, window, cx));
if consumed {
cx.stop_propagation();
}
}
});
if dragging {
InputState::register_drag_listeners(&state, window);
}
}
},
)
.absolute()
.inset_0(),
div()
.relative()
.flex_1()
.min_w_0()
.h_full()
.overflow_hidden()
.flex()
.flex_row()
.items_center()
.when_some(line_height, |region, line_height| {
region.items_start().line_height(line_height)
})
.when(
is_empty && (!focused || self.placeholder_when_focused),
|region| {
region.child(
div()
.text_color(colors.muted_foreground)
.when(multiline, |placeholder| placeholder.pt(vertical_inset))
.child(self.placeholder.clone()),
)
},
)
.child(canvas_element),
)
.when_some(self.suffix, |input, suffix| {
input.child(edge_zone("input-suffix", suffix))
})
.when(is_password && !self.disabled, |input| {
input.child(
div()
.id((self.id.clone(), "input-reveal"))
.debug_selector(|| "input-reveal".to_string())
.absolute()
.right(metrics.dense_padding_x / 2.0)
.top_0p5()
.bottom_0p5()
.right(edge_inset)
.top_0()
.bottom_0()
.flex()
.items_center()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.child(
Button::new((self.id.clone(), "input-reveal-button"))
.debug_selector(|| "input-reveal-button".to_string())
.icon(if revealed {
IconRole::EyeOff
} else {
+50 -47
View File
@@ -7,7 +7,7 @@ use gpui::{
Entity, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent,
ParentElement as _, Pixels, Point, RenderOnce, Role, ScrollHandle, SharedString,
StatefulInteractiveElement as _, Styled as _, Window, anchored, canvas, deferred, div,
ease_out_quint, px,
ease_out_quint, point, px, relative,
};
use gpui_native_icons::{IconRole, icon as icon_glyph};
use gpui_native_theme::ActiveTheme as _;
@@ -127,6 +127,7 @@ impl MenuItem {
struct MenuChrome {
surface: gpui::Hsla,
border: gpui::Hsla,
panel_border: gpui::Hsla,
foreground: gpui::Hsla,
muted_foreground: gpui::Hsla,
primary: gpui::Hsla,
@@ -137,6 +138,9 @@ struct MenuChrome {
radius_sm: Pixels,
icon_size: Pixels,
dense_icon_size: Pixels,
menu_item_height: Pixels,
spacing_unit: Pixels,
text_size: Pixels,
shadow: Vec<BoxShadow>,
}
@@ -242,6 +246,11 @@ impl RenderOnce for Menu {
MenuChrome {
surface: theme.colors.surface,
border: theme.colors.border,
panel_border: if theme.borders.overlays {
theme.colors.border
} else {
theme.colors.surface
},
foreground: theme.colors.foreground,
muted_foreground: theme.colors.muted_foreground,
primary: theme.ramps.primary.base,
@@ -252,6 +261,9 @@ impl RenderOnce for Menu {
radius_sm: theme.radius.sm,
icon_size: theme.sizes.medium.icon_size,
dense_icon_size: theme.sizes.medium.dense_icon_size,
menu_item_height: theme.sizes.medium.menu_item_height,
spacing_unit: theme.spacing.unit,
text_size: theme.sizes.medium.text_size,
shadow: theme.elevations.popover.shadows(),
},
theme.motion.overlay,
@@ -259,7 +271,6 @@ impl RenderOnce for Menu {
};
let trigger_bounds: Rc<RefCell<Option<Bounds<Pixels>>>> = Rc::new(RefCell::new(None));
let panel_bounds: Rc<RefCell<Option<Bounds<Pixels>>>> = Rc::new(RefCell::new(None));
let selection = window.use_keyed_state("menu-selection", cx, |_, _| 0usize);
let was_open = window.use_keyed_state("menu-was-open", cx, |_, _| self.open);
@@ -363,19 +374,36 @@ impl RenderOnce for Menu {
let panel = v_flex()
.id("menu-panel")
.debug_selector(|| "menu-panel".to_string())
.occlude()
.track_focus(&focus)
.role(Role::Menu)
.aria_label(label)
.mt_1()
.min_w_56()
.p_1()
.p(chrome.spacing_unit)
.bg(chrome.surface)
.text_color(chrome.foreground)
.rounded(chrome.radius_overlay)
.border_1()
.border_color(chrome.border)
.border_color(chrome.panel_border)
.shadow(chrome.shadow.clone())
.overflow_hidden()
.on_mouse_down_out({
let on_change = on_change.clone();
let trigger_bounds = trigger_bounds.clone();
move |event, window, cx| {
if trigger_bounds
.borrow()
.is_some_and(|bounds| bounds.contains(&event.position))
{
return;
}
if let Some(on_change) = &on_change {
on_change(false, window, cx);
}
}
})
.on_key_down({
let items = items.clone();
let on_change = on_change.clone();
@@ -392,44 +420,14 @@ impl RenderOnce for Menu {
.children(rows),
)
.child(
canvas(
{
let cell = panel_bounds.clone();
move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds)
},
{
let on_change = on_change.clone();
move |_, _, window, cx| {
if !focus.contains_focused(window, cx) {
window.focus(&focus, cx);
}
window.on_mouse_event({
let on_change = on_change.clone();
move |event: &MouseDownEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble {
return;
}
let on_panel = panel_bounds
.borrow()
.is_some_and(|bounds| bounds.contains(&event.position));
let on_trigger = trigger_bounds
.borrow()
.is_some_and(|bounds| bounds.contains(&event.position));
if on_panel || on_trigger {
return;
}
if let Some(on_change) = &on_change {
on_change(false, window, cx);
}
cx.stop_propagation();
}
});
canvas(|_, _, _| {}, {
let focus = focus.clone();
move |_, _, window, cx| {
if !focus.contains_focused(window, cx) {
window.focus(&focus, cx);
}
},
)
}
})
.absolute()
.inset_0(),
);
@@ -437,7 +435,8 @@ impl RenderOnce for Menu {
let anchored = match self.open_at {
Some(position) => anchored().position(position),
None => anchored(),
};
}
.offset(point(px(0.), chrome.spacing_unit));
wrapper = wrapper.child(deferred(anchored.child(panel.with_animation(
"menu-enter",
@@ -462,8 +461,9 @@ fn render_item(
match item.kind {
MenuItemKind::Separator => div()
.h(px(1.))
.mx_2()
.my_1()
.debug_selector(move || format!("menu-separator-{index}"))
.mx(chrome.spacing_unit * 2.0)
.my(chrome.spacing_unit)
.bg(chrome.border)
.into_any_element(),
MenuItemKind::Action => {
@@ -476,11 +476,14 @@ fn render_item(
div()
.id(("menu-item", index))
.debug_selector(move || format!("menu-item-{index}"))
.flex()
.items_center()
.gap_2()
.px_2()
.py_1()
.min_h(chrome.menu_item_height)
.px(chrome.spacing_unit * 2.0)
.text_size(chrome.text_size)
.line_height(relative(1.0))
.rounded(chrome.radius_sm)
.map(|row| {
if disabled {
+42 -52
View File
@@ -6,7 +6,7 @@ use gpui::{
Animation, AnimationExt as _, AnyElement, App, Bounds, DispatchPhase, ElementId,
InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent,
ParentElement as _, Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
Styled as _, Window, anchored, canvas, deferred, div, ease_out_quint,
Styled as _, Window, anchored, canvas, deferred, div, ease_out_quint, point, px,
};
use gpui_native_theme::ActiveTheme as _;
@@ -91,18 +91,23 @@ impl Popover {
impl RenderOnce for Popover {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (colors, radius, shadows, enter_duration) = {
let (colors, radius, shadows, enter_duration, panel_border, anchor_gap) = {
let theme = cx.theme();
(
theme.colors,
theme.radius,
theme.elevations.popover.shadows(),
theme.motion.overlay,
if theme.borders.overlays {
theme.colors.border
} else {
theme.colors.surface
},
theme.spacing.unit,
)
};
let trigger_bounds: BoundsCell = Rc::new(RefCell::new(None));
let panel_bounds: BoundsCell = Rc::new(RefCell::new(None));
let fallback_label = self
.a11y_label
@@ -166,18 +171,34 @@ impl RenderOnce for Popover {
let panel = v_flex()
.id("popover-panel")
.debug_selector(|| "popover-panel".to_string())
.occlude()
.track_focus(&focus)
.role(Role::Dialog)
.aria_label(label)
.mt_1()
.min_w_32()
.bg(colors.surface)
.text_color(colors.surface_foreground)
.rounded(radius.overlay)
.border_1()
.border_color(colors.border)
.border_color(panel_border)
.shadow(shadows)
.overflow_hidden()
.on_mouse_down_out({
let on_change = on_change.clone();
let trigger_bounds = trigger_bounds.clone();
move |event, window, cx| {
if trigger_bounds
.borrow()
.is_some_and(|bounds| bounds.contains(&event.position))
{
return;
}
if let Some(on_change) = &on_change {
on_change(false, window, cx);
}
}
})
.on_key_down({
let on_change = on_change.clone();
move |event: &KeyDownEvent, window, cx| {
@@ -191,58 +212,27 @@ impl RenderOnce for Popover {
})
.when_some(content, |panel, content| panel.child(content))
.child(
canvas(
{
let cell = panel_bounds.clone();
move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds)
},
{
let on_change = on_change.clone();
let focus = focus;
move |_, _, window, cx| {
if !focus.contains_focused(window, cx) {
window.focus(&focus, cx);
}
window.on_mouse_event({
let on_change = on_change.clone();
move |event: &MouseDownEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble
|| event.button != MouseButton::Left
{
return;
}
let on_panel = panel_bounds
.borrow()
.is_some_and(|bounds| bounds.contains(&event.position));
let on_trigger = trigger_bounds
.borrow()
.is_some_and(|bounds| bounds.contains(&event.position));
if on_panel || on_trigger {
return;
}
if let Some(on_change) = &on_change {
on_change(false, window, cx);
}
cx.stop_propagation();
}
});
canvas(|_, _, _| {}, {
let focus = focus;
move |_, _, window, cx| {
if !focus.contains_focused(window, cx) {
window.focus(&focus, cx);
}
},
)
}
})
.absolute()
.inset_0(),
);
wrapper = wrapper.child(deferred(anchored().child(panel.with_animation(
"popover-enter",
Animation::new(enter_duration).with_easing(ease_out_quint()),
|panel, delta| panel.opacity(delta),
))));
wrapper = wrapper.child(deferred(
anchored()
.offset(point(px(0.), anchor_gap))
.child(panel.with_animation(
"popover-enter",
Animation::new(enter_duration).with_easing(ease_out_quint()),
|panel, delta| panel.opacity(delta),
)),
));
}
wrapper
+27 -5
View File
@@ -6,7 +6,8 @@ use gpui::{
SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window,
};
use gpui_native_icons::{IconRole, icon};
use gpui_native_theme::{ActiveTheme as _, Size};
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
use gpui_native_utils::focus_ring;
use crate::common::{Sizable, StyledExt, h_flex};
use crate::menu::{Menu, MenuItem};
@@ -169,6 +170,8 @@ where
let muted_foreground = theme.colors.muted_foreground;
let radius = theme.radius.control;
let icon_size = metrics.dense_icon_size;
let fields_bordered = theme.borders.fields;
let focus_ring_on = theme.borders.focus_ring;
let label = self
.a11y_label
@@ -180,7 +183,22 @@ where
.use_keyed_state("select-open", cx, |_, _| false)
.clone()
});
let is_open = *open.read(cx);
let open_for_items = open.clone();
let (rest_border, hover_border) = if fields_bordered {
(
if is_open && !focus_ring_on {
ring
} else {
border
},
border,
)
} else {
(input, muted)
};
let selected_index = self.selected.as_ref().and_then(|value| {
self.options
@@ -194,6 +212,7 @@ where
let trigger_id = self.id.clone();
let trigger_label = label.clone();
let trigger_style = self.style;
let trigger = move |_window: &mut Window, _cx: &mut App| {
let text = trigger_selected
.as_ref()
@@ -207,17 +226,18 @@ where
h_flex()
.id((trigger_id.clone(), "select-trigger"))
.debug_selector(|| "select-trigger".to_string())
.role(Role::ComboBox)
.aria_label(trigger_label.clone())
.cursor_pointer()
.justify_between()
.gap_2()
.w_full()
.h(metrics.input_height)
.h(metrics.input_height + BORDER_WIDTH * 2.0)
.px(metrics.dense_padding_x)
.rounded(radius)
.border_1()
.border_color(if is_open { ring } else { border })
.border_color(rest_border)
.bg(input)
.text_size(metrics.text_size)
.text_color(if trigger_selected.is_some() {
@@ -225,7 +245,10 @@ where
} else {
muted_foreground
})
.hover(|style| style.bg(muted))
.when(is_open && focus_ring_on, |trigger| {
trigger.shadow(focus_ring(ring))
})
.hover(|style| style.bg(muted).border_color(hover_border))
.child(text)
.child(
icon(IconRole::ChevronDown)
@@ -236,7 +259,6 @@ where
.into_any_element()
};
let open_for_items = open.clone();
let items = move |_window: &mut Window, _cx: &mut App| {
self.options
.iter()
+9 -1
View File
@@ -41,6 +41,7 @@ pub struct Spinner {
value: Option<f32>,
variant: SpinnerVariant,
size: Size,
color: Option<Hsla>,
a11y_label: Option<SharedString>,
}
@@ -55,10 +56,17 @@ impl Spinner {
value: None,
variant: SpinnerVariant::default(),
size: Size::default(),
color: None,
a11y_label: None,
}
}
/// Overrides the arc color, for accent-filled surfaces.
pub fn color(mut self, color: Hsla) -> Self {
self.color = Some(color);
self
}
/// Sets the spinner's visual language.
pub fn variant(mut self, variant: SpinnerVariant) -> Self {
self.variant = variant;
@@ -93,7 +101,7 @@ impl RenderOnce for Spinner {
let side = theme.sizes.resolve(self.size).icon_size;
let stroke = side * RING_STROKE;
let track = theme.colors.border;
let accent = theme.ramps.primary.base;
let accent = self.color.unwrap_or(theme.ramps.primary.base);
let value = self.value.map(|value| value.clamp(0.0, 1.0));
div()
+9 -6
View File
@@ -96,16 +96,17 @@ impl RenderOnce for Switch {
let ramps = theme.ramps;
let metrics = theme.sizes.resolve(self.size);
let track_height = metrics.control_height;
let track_width = metrics.control_height * 1.65;
let track_height = metrics.switch_height;
let track_width = metrics.switch_width;
let knob = metrics.indicator_size;
let inset = metrics.plate_inset;
let knob = metrics.switch_knob;
let inset = (track_height - knob) / 2.;
let travel = track_width - knob - inset * 2.;
let next = !self.checked;
let on_change = self.on_change;
let a11y_label = self.a11y_label.or_else(|| self.label.clone());
let id = self.id.clone();
let anim_config = SpringConfig::new(500., 35., 1.);
let anim = SpringAnimation::new(anim_config).to(if self.checked { travel } else { px(0.) });
@@ -132,6 +133,10 @@ impl RenderOnce for Switch {
div()
.flex_none()
.relative()
.debug_selector({
let name = format!("{id}-track");
move || name
})
.w(track_width)
.h(track_height)
.rounded(theme.radius.full)
@@ -149,8 +154,6 @@ impl RenderOnce for Switch {
.size(knob)
.rounded(theme.radius.full)
.bg(colors.primary_foreground)
.border_1()
.border_color(colors.border)
.with_spring("switch-thumb", anim, move |thumb, offset| {
thumb.left(inset + offset)
}),
+20 -25
View File
@@ -12,7 +12,8 @@ use gpui::{
use gpui_native_icons::{IconRole, icon};
use gpui_native_theme::ActiveTheme as _;
use crate::common::DismissHandler;
use crate::button::{Button, ButtonVariant};
use crate::common::{DismissHandler, Sizable as _};
/// The action handler of a [`Toast`].
pub type ActionHandler = Rc<dyn Fn(&mut App)>;
@@ -256,12 +257,13 @@ impl RenderOnce for ToastsView {
fn pill(entry: &ToastEntry, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful<gpui::Div> {
let theme = cx.theme();
let surface = theme.colors.surface;
let border = theme.colors.border;
let border = if theme.borders.overlays {
theme.colors.border
} else {
surface
};
let foreground = theme.colors.foreground;
let muted = theme.colors.muted;
let primary = theme.ramps.primary.base;
let primary_hover = theme.ramps.primary.hover;
let primary_foreground = theme.colors.primary_foreground;
let radius = theme.radius.full;
let shadows = theme.elevations.popover.shadows();
let icon_size = theme.sizes.small.icon_size;
@@ -304,25 +306,18 @@ fn pill(entry: &ToastEntry, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful
.when_some(toast.action.clone(), |pill, action| {
let toasts = toasts.clone();
pill.child(
div()
.id(("toast-action", id))
.flex_none()
.ml_1p5()
.cursor_pointer()
.px_2()
.py_0p5()
.rounded(radius)
.bg(primary)
.hover(|style| style.bg(primary_hover))
.text_size(text_size)
.text_color(primary_foreground)
.child(action.label.clone())
.on_click(move |_, _window, cx| {
(action.handler.clone())(cx);
toasts.update(cx, |toasts, cx| {
toasts.dismiss(id, cx);
});
}),
div().flex_none().ml_2p5().child(
Button::new(("toast-action", id))
.label(action.label.clone())
.variant(ButtonVariant::Primary)
.small()
.on_click(move |_, _window, cx| {
(action.handler.clone())(cx);
toasts.update(cx, |toasts, cx| {
toasts.dismiss(id, cx);
});
}),
),
)
})
.child(
@@ -331,7 +326,7 @@ fn pill(entry: &ToastEntry, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful
.flex_none()
.ml_4()
.cursor_pointer()
.p_0p5()
.p_1()
.rounded(theme.radius.sm)
.hover(|style| style.bg(muted))
.child(
+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)
}
}
-43
View File
@@ -54,32 +54,6 @@ fn alert_window(
)
}
/// A banner with only a message paints, so the title stays optional.
#[test]
fn a_message_only_alert_renders() {
let mut cx = TestAppContext::single();
cx.update(Theme::init);
let window = cx.add_window(|_, _| MessageOnlyHarness);
let window = window.into();
let mut cx = VisualTestContext::from_window(window, &cx);
cx.update(|window, cx| {
window.draw(cx).clear(cx);
});
assert!(cx.debug_bounds("message-only-alert").is_some());
}
struct MessageOnlyHarness;
impl Render for MessageOnlyHarness {
fn render(&mut self, _window: &mut Window, _cx: &mut gpui::Context<Self>) -> impl IntoElement {
div().size_full().p_4().child(
div()
.debug_selector(|| "message-only-alert".to_string())
.child(Alert::new("message-only").message("Only line.")),
)
}
}
/// Clicking the close button is how the user acts on the alert; the banner
/// reports the intent instead of hiding itself.
#[test]
@@ -94,20 +68,3 @@ fn closing_the_alert_reports_dismissal() {
assert!(*dismissed.borrow(), "the close button reported dismissal");
}
/// Every severity paints a banner.
#[test]
fn every_severity_renders_a_banner() {
for severity in [
AlertSeverity::Info,
AlertSeverity::Success,
AlertSeverity::Warning,
AlertSeverity::Danger,
] {
let (mut cx, _window, _dismissed) = alert_window(severity);
assert!(
cx.debug_bounds("test-alert").is_some(),
"{severity:?} alert rendered"
);
}
}
+18 -5
View File
@@ -11,6 +11,7 @@ use gpui_native_theme::Theme;
struct Harness {
clicks: Rc<RefCell<usize>>,
disabled: bool,
loading: bool,
}
impl Render for Harness {
@@ -20,14 +21,18 @@ impl Render for Harness {
Button::new("test-button")
.label("Save")
.disabled(self.disabled)
.loading(self.loading)
.on_click(move |_, _, _| *clicks.borrow_mut() += 1),
)
}
}
/// A maximized window whose button stretches across the top 28px, so
/// A maximized window whose button stretches across the top band, so
/// `point(px(60.), px(14.))` always lands on it.
fn button_window(disabled: bool) -> (VisualTestContext, AnyWindowHandle, Rc<RefCell<usize>>) {
fn button_window(
disabled: bool,
loading: bool,
) -> (VisualTestContext, AnyWindowHandle, Rc<RefCell<usize>>) {
let mut cx = TestAppContext::single();
cx.update(Theme::init);
let clicks = Rc::new(RefCell::new(0));
@@ -35,6 +40,7 @@ fn button_window(disabled: bool) -> (VisualTestContext, AnyWindowHandle, Rc<RefC
let window = cx.add_window(move |_, _| Harness {
clicks: harness_clicks,
disabled,
loading,
});
let window = window.into();
cx.update_window(window, |_, window, cx| {
@@ -53,21 +59,28 @@ fn draw(cx: &mut VisualTestContext, window: AnyWindowHandle) {
#[test]
fn pointer_click_fires_on_click() {
let (mut cx, _window, clicks) = button_window(false);
let (mut cx, _window, clicks) = button_window(false, false);
cx.simulate_click(point(px(60.), px(14.)), Modifiers::default());
assert_eq!(*clicks.borrow(), 1);
}
#[test]
fn disabled_button_ignores_clicks() {
let (mut cx, _window, clicks) = button_window(true);
let (mut cx, _window, clicks) = button_window(true, false);
cx.simulate_click(point(px(60.), px(14.)), Modifiers::default());
assert_eq!(*clicks.borrow(), 0);
}
#[test]
fn loading_button_ignores_clicks() {
let (mut cx, _window, clicks) = button_window(false, true);
cx.simulate_click(point(px(60.), px(14.)), Modifiers::default());
assert_eq!(*clicks.borrow(), 0);
}
#[test]
fn enter_and_space_fire_on_click_while_focused() {
let (mut cx, window, clicks) = button_window(false);
let (mut cx, window, clicks) = button_window(false, false);
cx.simulate_click(point(px(60.), px(14.)), Modifiers::default());
assert_eq!(*clicks.borrow(), 1, "the pointer click itself activates");
+27 -2
View File
@@ -2,8 +2,8 @@ use std::cell::RefCell;
use std::rc::Rc;
use gpui::{
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
Styled as _, TestAppContext, VisualTestContext, Window, div,
AppContext as _, Context, Entity, IntoElement, MouseButton, ParentElement as _, Render,
SharedString, Styled as _, TestAppContext, VisualTestContext, Window, div, point, px,
};
use gpui_native_core::combobox::{Combobox, ComboboxOption, ComboboxState};
use gpui_native_core::init;
@@ -99,6 +99,31 @@ fn draw(visual: &mut VisualTestContext) {
});
}
fn click(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
visual.simulate_mouse_down(position, MouseButton::Left, gpui::Modifiers::default());
visual.simulate_mouse_up(position, MouseButton::Left, gpui::Modifiers::default());
}
/// Clicking into a valueless combobox field opens the options right away:
/// no keystroke is needed to see the choices.
#[test]
fn clicking_the_empty_field_opens_the_popup() {
let mut host = combo_host(false, false);
// Inside the field, which sits at the top of the padded host.
click(&mut host.visual, point(px(100.), px(27.)));
draw(&mut host.visual);
assert!(
host.state.read_with(&host.cx, |state, _| state.is_open()),
"clicking the empty field should show the options"
);
assert!(
host.visual.debug_bounds("test-combo-panel").is_some(),
"the options panel should be rendered"
);
}
fn focus(host: &mut ComboHost) {
let state = host.state.clone();
host.visual.update(|window, cx| {
+11 -8
View File
@@ -5,7 +5,7 @@ use gpui::{
use gpui_native_core::button::{Button, ButtonVariant};
use gpui_native_core::input::{Input, InputState};
use gpui_native_theme::ActiveTheme as _;
use gpui_native_theme::Theme;
use gpui_native_theme::{BORDER_WIDTH, Theme};
struct Harness {
state: Entity<InputState>,
@@ -36,10 +36,12 @@ impl Render for Harness {
}
}
/// A medium input is exactly `input_height`, and sits taller than the
/// medium button: native text fields are a touch taller than push buttons.
/// The medium input and button render at the stated height plus the
/// hairline border sized outside it, so the two match wherever the platform
/// states equal values (macOS, Windows, GNOME) and stay equal to each other
/// on every platform.
#[gpui::test]
fn medium_input_is_taller_than_the_button(cx: &mut TestAppContext) {
fn controls_render_at_the_stated_heights(cx: &mut TestAppContext) {
cx.update(Theme::init);
let state = cx.update(|cx| InputState::new_with_text("", cx));
let state_for_window = state.clone();
@@ -58,9 +60,10 @@ fn medium_input_is_taller_than_the_button(cx: &mut TestAppContext) {
.debug_bounds("input-wrapper")
.expect("input rendered");
// The input's height is the theme table's (platform-stated) field height;
// both bounds are snapped to device pixels, so allow half a pixel.
let input_height = cx.update(|cx| cx.theme().sizes.medium.input_height);
assert!(input.size.height > button.size.height);
// Both bounds are snapped to device pixels, so allow half a pixel. The
// stated height is the interior; the border sizes outside it.
let input_height = cx.update(|cx| cx.theme().sizes.medium.input_height) + BORDER_WIDTH * 2.0;
let button_height = cx.update(|cx| cx.theme().sizes.medium.control_height) + BORDER_WIDTH * 2.0;
assert!((input.size.height - input_height).abs() <= gpui::px(0.5));
assert!((button.size.height - button_height).abs() <= gpui::px(0.5));
}
-8
View File
@@ -158,11 +158,3 @@ fn clicking_the_panel_does_not_dismiss() {
click(&mut visual, point(px(960.), px(540.)));
assert!(changes.borrow().is_empty());
}
#[test]
fn a_closed_dialog_is_inert() {
let (_cx, mut visual, changes) = dialog_window(false, false);
visual.simulate_keystrokes("escape");
click(&mut visual, point(px(100.), px(100.)));
assert!(changes.borrow().is_empty());
}
+18 -52
View File
@@ -1,7 +1,3 @@
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Instant;
use gpui::{
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
Styled as _, TestAppContext, VisualTestContext, Window, div,
@@ -13,12 +9,10 @@ use gpui_native_core::input::{Input, InputState};
struct FieldHost {
url: Entity<InputState>,
renders: Rc<RefCell<usize>>,
}
impl Render for FieldHost {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
*self.renders.borrow_mut() += 1;
let url_error = self
.url
.read(cx)
@@ -38,76 +32,48 @@ impl Render for FieldHost {
}
}
/// A field page in a real window, with the url input focused.
fn field_host() -> (
TestAppContext,
Entity<InputState>,
Rc<RefCell<usize>>,
VisualTestContext,
) {
/// A field page in a real window; the url input is focused unless
/// `focus` is false, which leaves the page truly idle.
fn field_host(focus: bool) -> (TestAppContext, Entity<InputState>, VisualTestContext) {
let cx = TestAppContext::single();
cx.update(init);
let (url, renders, window) = cx.update(|cx| {
let renders = Rc::new(RefCell::new(0));
let (url, window) = cx.update(|cx| {
let url = InputState::new(cx);
let url_for_host = url.clone();
let renders_for_host = renders.clone();
let window = cx
.open_window(gpui::WindowOptions::default(), |_, cx| {
cx.new(|_| FieldHost {
url: url_for_host,
renders: renders_for_host,
})
cx.new(|_| FieldHost { url: url_for_host })
})
.unwrap();
(url, renders, window)
(url, window)
});
let mut visual = VisualTestContext::from_window(window.into(), &cx);
visual.update(|window, cx| {
let focus = url.read(cx).focus_handle();
window.focus(&focus, cx);
if focus {
let focus = url.read(cx).focus_handle();
window.focus(&focus, cx);
}
window.draw(cx).clear(cx);
});
(cx, url, renders, visual)
}
fn draw(visual: &mut VisualTestContext) {
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
}
fn type_and_time(visual: &mut VisualTestContext, text: &str) -> std::time::Duration {
let started = Instant::now();
for letter in text.chars() {
visual.simulate_keystrokes(&letter.to_string());
draw(visual);
}
let elapsed = started.elapsed();
println!(
"{} keystrokes: {elapsed:?} ({:.2} ms per keystroke)",
text.chars().count(),
elapsed.as_secs_f64() / text.chars().count() as f64 * 1000.
);
elapsed
(cx, url, visual)
}
#[test]
fn an_idle_field_page_schedules_no_frames() {
let (_cx, _url, _renders, mut visual) = field_host();
let (_cx, _url, mut visual) = field_host(false);
let frames = visual.update(|window, cx| window.simulate_next_frame(cx));
assert_eq!(
frames, 0,
"an idle field page must not request animation frames"
"an idle unfocused field page must not request animation frames"
);
}
#[test]
fn field_typing_frame_cost() {
let (_cx, _url, _renders, mut visual) = field_host();
let elapsed = type_and_time(&mut visual, "dsdsadsadadadada");
fn a_focused_field_page_schedules_frames_for_its_blink() {
let (_cx, _url, mut visual) = field_host(true);
let frames = visual.update(|window, cx| window.simulate_next_frame(cx));
assert!(
elapsed.as_millis() < 1000,
"typing 16 characters took {elapsed:?}"
frames > 0,
"the focused caret's blink needs frames to redraw at the blink cadence"
);
}
+190 -64
View File
@@ -3,12 +3,15 @@ use gpui::{
MouseUpEvent, ParentElement as _, Render, ScrollDelta, ScrollEvent, Styled as _,
TestAppContext, VisualTestContext, Window, div, point, px,
};
use gpui_native_core::button::{Button, ButtonVariant};
use gpui_native_core::common::Sizable as _;
use gpui_native_core::input::{
INPUT_CONTEXT, Input, InputBindings, InputState, InputStateEvent, Submit, SubmitOn,
bind_input_keys,
};
use gpui_native_theme::Theme;
use std::cell::RefCell;
use gpui_native_icons::IconRole;
use gpui_native_theme::{BORDER_WIDTH, Size, Theme};
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::time::Duration;
@@ -266,42 +269,45 @@ fn read_only_refuses_edits_but_keeps_selection_and_copy() {
assert_eq!(clipboard, "lo");
// The caret is suppressed entirely while read-only.
let caret = cx.update(|cx| state.update(cx, |state, cx| state.caret_visible(true, cx)));
let caret = cx.update(|cx| state.update(cx, |state, _| state.caret_visible(true)));
assert!(!caret);
}
#[test]
fn caret_blinks_while_focused_and_holds_solid_while_typing() {
fn the_caret_visibility_is_pure_time_math_against_the_epoch() {
let (cx, state, mut visual) = input_window("", false);
focus(&mut visual, &state);
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
visual.run_until_parked();
let caret = || cx.update(|cx| state.update(cx, |state, _| state.caret_visible(true)));
let caret = || cx.update(|cx| state.update(cx, |state, cx| state.caret_visible(true, cx)));
// The caret shows on focus, hides after one interval, and reappears
// after another.
assert!(caret());
cx.executor().advance_clock(Duration::from_millis(500));
visual.run_until_parked();
assert!(!caret());
cx.executor().advance_clock(Duration::from_millis(500));
visual.run_until_parked();
// Fresh focus starts a new epoch: the caret shows solid — the elapsed
// time since the epoch began is microseconds, far under one interval.
assert!(caret());
// Typing holds the caret solid and restarts the cycle.
// Typing restarts the epoch, so the caret stays solid through edits.
visual.simulate_keystrokes("a");
assert!(caret());
cx.executor().advance_clock(Duration::from_millis(500));
visual.run_until_parked();
assert!(!caret());
// An unfocused field reports no caret.
let caret_unfocused =
cx.update(|cx| state.update(cx, |state, cx| state.caret_visible(false, cx)));
assert!(!caret_unfocused);
// The blink cadence itself: hidden in the second half of the cycle.
let hidden = cx.update(|cx| {
state.update(cx, |state, _| {
state.caret_blink_visible_at(true, Duration::from_millis(700))
})
});
assert!(!hidden);
let visible = cx.update(|cx| {
state.update(cx, |state, _| {
state.caret_blink_visible_at(true, Duration::from_millis(300))
})
});
assert!(visible);
// An unfocused field reports no caret, at any point in the cycle.
let unfocused = cx.update(|cx| {
state.update(cx, |state, _| {
state.caret_blink_visible_at(false, Duration::from_millis(100))
})
});
assert!(!unfocused);
}
#[test]
@@ -459,15 +465,6 @@ fn disabled_input_ignores_typing_and_clicks() {
assert_eq!(value(&cx, &state), "");
}
#[test]
fn set_value_replaces_the_buffer() {
let (cx, state, mut visual) = input_window("", false);
visual.update(|window, cx| {
state.update(cx, |state, cx| state.set_value("preset", window, cx));
});
assert_eq!(value(&cx, &state), "preset");
}
/// Subscribes to `state`'s events, recording each variant into a sink that
/// lives for the rest of the test. The returned `Subscription` must stay
/// bound for the same lifetime — dropping it unsubscribes.
@@ -536,26 +533,6 @@ fn set_value_silent_updates_without_emitting() {
assert_eq!(count(&sink, &InputStateEvent::TextChanged), 0);
}
#[test]
fn focus_and_blur_emit_events() {
let (mut cx, state, mut visual) = input_window("", false);
let (sink, _subscription) = record_events(&mut cx, &state);
focus(&mut visual, &state);
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
visual.run_until_parked();
assert_eq!(count(&sink, &InputStateEvent::Focused), 1);
visual.update(|window, cx| window.blur(cx));
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
visual.run_until_parked();
assert_eq!(count(&sink, &InputStateEvent::Blurred), 1);
}
#[test]
fn pasted_newlines_become_spaces() {
let (cx, state, mut visual) = input_window("", false);
@@ -568,15 +545,6 @@ fn pasted_newlines_become_spaces() {
assert_eq!(value(&cx, &state), "a bc");
}
#[test]
fn set_value_sanitizes_newlines() {
let (cx, state, mut visual) = input_window("", false);
visual.update(|window, cx| {
state.update(cx, |state, cx| state.set_value("a\nb", window, cx));
});
assert_eq!(value(&cx, &state), "a b");
}
#[test]
fn enter_emits_submitted() {
let (mut cx, state, mut visual) = input_window("draft", false);
@@ -763,6 +731,36 @@ fn the_reveal_toggle_flips_masking() {
assert!(!revealed, "the toggle should mask the field again");
}
/// The reveal pill sits with equal insets on its three free sides; the
/// small control metric must stay whole-pixel or snapping skews it.
#[test]
fn the_reveal_pill_insets_equally() {
let (cx, _state, mut visual) = reveal_window();
let field = visual.debug_bounds("input-field").expect("field missing");
let pill = visual
.debug_bounds("input-reveal-button")
.expect("reveal pill missing");
cx.update(|cx| {
let theme = Theme::global(cx);
let small = theme.sizes.resolve(Size::Small);
let border = BORDER_WIDTH;
let docked = small.control_height + border * 2.0;
let inset = ((field.size.height - border * 2.0 - docked) / 2.0).max(px(0.));
let top_gap = pill.top() - (field.top() + border);
let bottom_gap = (field.bottom() - border) - pill.bottom();
let right_gap = (field.right() - border) - pill.right();
assert!(
(top_gap - inset).abs() < px(0.01)
&& (bottom_gap - inset).abs() < px(0.01)
&& (right_gap - inset).abs() < px(0.01),
"pill gaps top {top_gap:?} bottom {bottom_gap:?} right {right_gap:?} vs inset {inset:?}"
);
});
}
#[test]
fn the_caret_can_rest_on_a_line_break() {
let (cx, state, mut visual) = multiline_window("ab\ncdef");
@@ -906,3 +904,131 @@ fn vertical_scroll_keeps_the_caret_in_view() {
.unwrap_or_default();
assert!(first_row_top < px(0.), "first row top: {first_row_top:?}");
}
struct EdgeHarness {
state: Entity<InputState>,
clicked: Rc<Cell<bool>>,
}
impl Render for EdgeHarness {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
let clicked = self.clicked.clone();
div().w(px(400.)).p_4().child(
Input::new("edge-input", self.state.clone())
.placeholder("Search")
.prefix(
Button::new("edge-input-prefix-button")
.icon(IconRole::Search)
.variant(ButtonVariant::Ghost)
.small(),
)
.suffix(
Button::new("edge-input-suffix-button")
.debug_selector(|| "edge-suffix-button".to_string())
.icon(IconRole::Plus)
.variant(ButtonVariant::Ghost)
.small()
.on_click(move |_, _, _| clicked.set(true)),
),
)
}
}
fn edge_input_window(
initial: &str,
) -> (
TestAppContext,
Entity<InputState>,
VisualTestContext,
Rc<Cell<bool>>,
) {
let clicked = Rc::new(Cell::new(false));
let clicked_for_window = clicked.clone();
let mut cx = TestAppContext::single();
cx.update(|cx| {
Theme::init(cx);
bind_input_keys(cx, InputBindings::default());
});
let state = cx.update(|cx| InputState::new_with_text(initial, cx));
let state_for_window = state.clone();
let window = cx.add_window(move |_, _| EdgeHarness {
state: state_for_window,
clicked: clicked_for_window,
});
let mut visual = VisualTestContext::from_window(window.into(), &cx);
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
(cx, state, visual, clicked)
}
/// Prefix and suffix dock flush inside the field, prefix leading, and a
/// small button in a zone centers with equal insets like the reveal pill.
#[test]
fn prefix_and_suffix_dock_inside_the_field() {
let (cx, _state, mut visual, _clicked) = edge_input_window("abc");
let field = visual.debug_bounds("input-field").expect("field missing");
let prefix = visual
.debug_bounds("input-prefix")
.expect("prefix zone missing");
let suffix = visual
.debug_bounds("input-suffix")
.expect("suffix zone missing");
let pill = visual
.debug_bounds("edge-suffix-button")
.expect("suffix button missing");
assert!(prefix.left() >= field.left());
assert!(prefix.right() <= suffix.left());
assert!(suffix.right() <= field.right());
cx.update(|cx| {
let theme = Theme::global(cx);
let small = theme.sizes.resolve(Size::Small);
let border = BORDER_WIDTH;
let docked = small.control_height + border * 2.0;
let inset = ((field.size.height - border * 2.0 - docked) / 2.0).max(px(0.));
let top_gap = pill.top() - (field.top() + border);
let bottom_gap = (field.bottom() - border) - pill.bottom();
let right_gap = (field.right() - border) - pill.right();
assert!(
(top_gap - inset).abs() < px(0.01)
&& (bottom_gap - inset).abs() < px(0.01)
&& (right_gap - inset).abs() < px(0.01),
"suffix pill gaps top {top_gap:?} bottom {bottom_gap:?} right {right_gap:?} vs inset {inset:?}"
);
});
}
/// A click on a docked control activates it without running the field's
/// caret handler underneath.
#[test]
fn a_suffix_click_activates_without_focusing_the_field() {
let (cx, state, mut visual, clicked) = edge_input_window("abc");
let bounds = visual
.debug_bounds("input-suffix")
.expect("suffix zone missing");
click(
&mut visual,
point(
bounds.left() + bounds.size.width / 2.0,
bounds.top() + bounds.size.height / 2.0,
),
);
assert!(clicked.get(), "the suffix button should activate");
let focused = visual.update(|window, cx| state.read(cx).focus_handle().is_focused(window));
assert!(!focused, "the suffix click must not focus the field");
// The zone must not swallow text interaction either.
focus(&mut visual, &state);
visual.simulate_keystrokes("x");
assert_eq!(value(&cx, &state), "abcx");
}
+22 -17
View File
@@ -112,6 +112,28 @@ fn click(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
visual.simulate_mouse_up(position, MouseButton::Left, Modifiers::default());
}
/// Every action row renders at the platform's stated menu row height,
/// whatever its content — the metric is the whole row, not a floor under
/// padding. Item 1 of the harness menu is a separator, so it is skipped.
#[test]
fn rows_render_at_the_stated_menu_row_height() {
let (cx, mut visual, _window, _changes, _activated) = menu_window(true);
let stated = cx.update(|cx| Theme::global(cx).sizes.medium.menu_item_height);
for index in [0, 2, 3, 4] {
let selector: &'static str = Box::leak(format!("menu-item-{index}").into_boxed_str());
let row = visual
.debug_bounds(selector)
.unwrap_or_else(|| panic!("row {index} should have been laid out"));
// Painted bounds are snapped to device pixels; allow half a pixel.
assert!(
(row.size.height - stated).abs() <= px(0.5),
"row {index} is {:?} tall, stated menu row height is {stated:?}",
row.size.height
);
}
}
#[test]
fn arrow_navigation_skips_separators_and_disabled_items() {
let (_cx, mut visual, _window, changes, activated) = menu_window(true);
@@ -163,14 +185,6 @@ fn clicking_outside_dismisses_and_swallows_the_click() {
assert_eq!(*changes.borrow(), vec![false]);
}
#[test]
fn clicking_the_trigger_toggles() {
let (_cx, mut visual, _window, changes, _activated) = menu_window(false);
click(&mut visual, point(px(10.), px(16.)));
assert_eq!(*changes.borrow(), vec![true]);
}
#[test]
fn clicking_an_item_activates_and_closes() {
let (_cx, mut visual, _window, changes, activated) = menu_window(true);
@@ -180,12 +194,3 @@ fn clicking_an_item_activates_and_closes() {
assert_eq!(*activated.borrow(), vec!["cut"]);
assert_eq!(*changes.borrow(), vec![false]);
}
#[test]
fn enter_activates_the_selected_item_and_closes() {
let (_cx, mut visual, _window, changes, activated) = menu_window(true);
visual.simulate_keystrokes("enter");
assert_eq!(*activated.borrow(), vec!["cut"]);
assert_eq!(*changes.borrow(), vec![false]);
}
+92 -35
View File
@@ -1,8 +1,9 @@
use std::{cell::RefCell, rc::Rc};
use gpui::{
Context, IntoElement, Modifiers, MouseButton, ParentElement as _, Render, Styled as _,
TestAppContext, VisualTestContext, Window, WindowHandle, div, point, px, rgba,
Context, InteractiveElement as _, IntoElement, Modifiers, MouseButton, ParentElement as _,
Render, StatefulInteractiveElement as _, Styled as _, TestAppContext, VisualTestContext,
Window, WindowHandle, div, point, px, rgba,
};
use gpui_native_core::popover::Popover;
use gpui_native_theme::Theme;
@@ -10,37 +11,54 @@ use gpui_native_theme::Theme;
struct Harness {
open: bool,
changes: Rc<RefCell<Vec<bool>>>,
target_clicks: Rc<RefCell<usize>>,
}
impl Render for Harness {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let changes = self.changes.clone();
let target_clicks = self.target_clicks.clone();
let view = cx.entity();
div().size_full().child(
Popover::new("test-popover")
.open(self.open)
.on_change(move |next, _, cx| {
changes.borrow_mut().push(next);
view.update(cx, |harness, cx| {
harness.open = next;
cx.notify();
});
})
.trigger(|_, _| {
div()
.size_full()
.child(
div().absolute().left(px(400.)).top(px(300.)).child(
div()
.w(px(100.))
.h(px(32.))
.id("popover-target")
.size(px(40.))
.bg(rgba(0x000000ff))
.into_any_element()
})
.content(|_, _| {
div()
.w(px(200.))
.h(px(80.))
.child("Popover content")
.into_any_element()
}),
)
.on_click(move |_, _, cx| {
*target_clicks.borrow_mut() += 1;
cx.stop_propagation();
}),
),
)
.child(
Popover::new("test-popover")
.open(self.open)
.on_change(move |next, _, cx| {
changes.borrow_mut().push(next);
view.update(cx, |harness, cx| {
harness.open = next;
cx.notify();
});
})
.trigger(|_, _| {
div()
.debug_selector(|| "popover-trigger".to_string())
.w(px(100.))
.h(px(32.))
.bg(rgba(0x000000ff))
.into_any_element()
})
.content(|_, _| {
div()
.w(px(200.))
.h(px(80.))
.child("Popover content")
.into_any_element()
}),
)
}
}
@@ -48,27 +66,31 @@ impl Render for Harness {
/// hangs below it around `(0..200, 36..116)`, so clicks at `(10, 16)` land
/// on the trigger, `(100, 70)` on the panel, and anything past `(300, 300)`
/// is outside both.
fn popover_window(
open: bool,
) -> (
type PopoverWindow = (
TestAppContext,
VisualTestContext,
WindowHandle<Harness>,
Rc<RefCell<Vec<bool>>>,
) {
Rc<RefCell<usize>>,
);
fn popover_window(open: bool) -> PopoverWindow {
let mut cx = TestAppContext::single();
cx.update(Theme::init);
let changes = Rc::new(RefCell::new(Vec::new()));
let harness_changes = changes.clone();
let target_clicks = Rc::new(RefCell::new(0));
let harness_clicks = target_clicks.clone();
let window = cx.add_window(move |_, _| Harness {
open,
changes: harness_changes,
target_clicks: harness_clicks,
});
let mut visual = VisualTestContext::from_window(window.into(), &cx);
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
(cx, visual, window, changes)
(cx, visual, window, changes, target_clicks)
}
fn draw(visual: &mut VisualTestContext) {
@@ -84,7 +106,7 @@ fn click(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
#[test]
fn clicking_outside_dismisses_and_reports_closed() {
let (_cx, mut visual, _window, changes) = popover_window(true);
let (_cx, mut visual, _window, changes, _clicks) = popover_window(true);
click(&mut visual, point(px(600.), px(600.)));
assert_eq!(*changes.borrow(), vec![false]);
@@ -98,7 +120,7 @@ fn clicking_outside_dismisses_and_reports_closed() {
#[test]
fn clicking_the_trigger_toggles_and_clicking_the_panel_does_nothing() {
let (_cx, mut visual, _window, changes) = popover_window(true);
let (_cx, mut visual, _window, changes, _clicks) = popover_window(true);
// On the open panel's trigger: toggles closed.
click(&mut visual, point(px(10.), px(16.)));
@@ -111,7 +133,7 @@ fn clicking_the_trigger_toggles_and_clicking_the_panel_does_nothing() {
#[test]
fn clicking_the_trigger_opens_a_closed_popover() {
let (_cx, mut visual, _window, changes) = popover_window(false);
let (_cx, mut visual, _window, changes, _clicks) = popover_window(false);
click(&mut visual, point(px(10.), px(16.)));
assert_eq!(*changes.borrow(), vec![true]);
@@ -119,7 +141,7 @@ fn clicking_the_trigger_opens_a_closed_popover() {
#[test]
fn escape_dismisses_while_the_panel_holds_focus() {
let (_cx, mut visual, _window, changes) = popover_window(true);
let (_cx, mut visual, _window, changes, _clicks) = popover_window(true);
draw(&mut visual);
// The panel claims focus on open, so escape reaches its key listener.
@@ -134,7 +156,7 @@ fn escape_dismisses_while_the_panel_holds_focus() {
#[test]
fn the_controlled_loop_reopens_from_the_app_side() {
let (mut cx, mut visual, window, changes) = popover_window(true);
let (mut cx, mut visual, window, changes, _clicks) = popover_window(true);
// Dismiss once.
click(&mut visual, point(px(600.), px(600.)));
@@ -155,3 +177,38 @@ fn the_controlled_loop_reopens_from_the_app_side() {
visual.simulate_keystrokes("escape");
assert_eq!(*changes.borrow(), vec![false, false]);
}
/// Clicking another control while the popover is open dismisses it and still
/// acts on that control: dismissing does not claim the event.
#[test]
fn clicking_another_control_dismisses_and_reaches_it() {
let (_cx, mut visual, _window, changes, clicks) = popover_window(true);
click(&mut visual, point(px(420.), px(320.)));
assert_eq!(*changes.borrow(), vec![false]);
assert_eq!(*clicks.borrow(), 1);
}
/// The panel hangs one spacing unit below the trigger: the anchor gap is
/// theme-owned, and rides the anchored element so the window-edge clamp
/// stays intact.
#[test]
fn the_panel_hangs_one_spacing_unit_below_the_trigger() {
let (cx, mut visual, _window, _changes, _clicks) = popover_window(true);
let trigger = visual
.debug_bounds("popover-trigger")
.expect("trigger rendered");
let panel = visual
.debug_bounds("popover-panel")
.expect("panel rendered");
let expected = cx.update(|cx| Theme::global(cx).spacing.unit);
let gap = panel.top() - trigger.bottom();
// Painted bounds are snapped to device pixels; allow half a pixel.
assert!(
(gap - expected).abs() <= px(0.5),
"the anchor gap is {gap:?}, expected {expected:?}"
);
}
-36
View File
@@ -1,36 +0,0 @@
use gpui::{ParentElement as _, Render, Styled as _, TestAppContext, Window, div};
use gpui_native_core::progress::Progress;
use gpui_native_theme::Theme;
struct Harness;
impl Render for Harness {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
div()
.size_full()
.flex()
.flex_col()
.gap_2()
.child(Progress::new("test-progress", 0.6).accessibility_label("Upload"))
.child(
Progress::new("test-progress-indeterminate", 0.0)
.indeterminate(true)
.accessibility_label("Working"),
)
}
}
#[gpui::test]
fn determinate_and_indeterminate_bars_draw_headlessly(cx: &mut TestAppContext) {
cx.update(Theme::init);
let (view, visual) = cx.add_window_view(|_, _| Harness);
let _ = view;
visual.run_until_parked();
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
}
-7
View File
@@ -88,13 +88,6 @@ fn the_content_starts_unscrolled_and_horizontal_axes_scroll_x() {
assert_eq!(handle.offset().x, px(0.));
}
#[test]
fn the_thumb_paints_when_the_content_overflows() {
let (_cx, _visual, handle) = scroll_area_window(30);
// 30 rows × 20px in a 200px viewport: the thumb has something to report.
assert!(handle.max_offset().y > px(0.));
}
/// The gallery's exact shell: a window-filling root, a window-filling scroll
/// area over padded flex-column content that towers past the viewport.
struct GalleryShell {
-25
View File
@@ -121,28 +121,3 @@ fn arrows_move_the_selection_when_the_strip_has_focus() {
vec![Filter::Popular, Filter::Recent, Filter::All, Filter::Recent]
);
}
/// The selected plate exists after a choice and settles on the chosen
/// segment's slot; without it the active background disappears entirely.
#[test]
fn the_plate_settles_on_the_chosen_segment() {
let (_cx, mut visual, changes) = filter_window();
click(&mut visual, point(px(70.), px(27.)));
assert_eq!(*changes.borrow(), vec![Filter::Popular]);
// Let the spring settle in real time (it steps on Instant::now()), then
// check the plate landed in Popular's slot (~58..124).
std::thread::sleep(std::time::Duration::from_millis(600));
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
let plate = visual
.debug_bounds("segmented-plate")
.expect("plate missing");
assert!(
plate.left() >= px(44.) && plate.left() <= px(94.),
"plate settled at x {:?}",
plate.origin
);
}
+81 -3
View File
@@ -1,11 +1,12 @@
use std::{cell::RefCell, rc::Rc};
use gpui::{
Context, IntoElement, MouseButton, ParentElement as _, Render, Styled as _, TestAppContext,
VisualTestContext, Window, div, point, px,
Context, Entity, IntoElement, MouseButton, ParentElement as _, Render, Styled as _,
TestAppContext, VisualTestContext, Window, div, point, px,
};
use gpui_native_core::input::{Input, InputState};
use gpui_native_core::select::Select;
use gpui_native_theme::Theme;
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Theme};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Strategy {
@@ -200,3 +201,80 @@ fn two_selects_keep_their_popups_independent() {
assert_eq!(*first_changes.borrow(), vec![]);
assert_eq!(*second_changes.borrow(), vec![Some(Strategy::Curated)]);
}
#[test]
fn a_click_outside_closes_the_popup() {
let (_cx, mut visual, changes) = select_window();
click(&mut visual, point(px(100.), px(31.)));
draw(&mut visual);
click(&mut visual, point(px(100.), px(300.)));
draw(&mut visual);
// The popup held keyboard focus while open; a keystroke after the outside
// click answers nothing if the popup really closed.
visual.simulate_keystrokes("enter");
draw(&mut visual);
assert_eq!(*changes.borrow(), Vec::<Option<Strategy>>::new());
}
/// Clicking another control while the popup is open dismisses it and still
/// acts on the control: dismissing does not claim the event.
#[test]
fn a_click_on_another_control_dismisses_and_reaches_it() {
let (_cx, mut visual, first_changes, second_changes) = pair_window();
click(&mut visual, point(px(100.), px(31.)));
draw(&mut visual);
// The second select's trigger, below the first — at x=300, past the first
// popup's 182px-wide panel: this click must close the first popup AND open
// the second.
click(&mut visual, point(px(300.), px(69.)));
draw(&mut visual);
visual.simulate_keystrokes("enter");
draw(&mut visual);
assert_eq!(*first_changes.borrow(), Vec::<Option<Strategy>>::new());
assert_eq!(*second_changes.borrow(), vec![Some(Strategy::Curated)]);
}
struct HeightsHarness {
state: Entity<InputState>,
}
impl Render for HeightsHarness {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_col()
.gap_4()
.child(Select::new("heights").options(STRATEGIES))
.child(Input::new("heights-field", self.state.clone()))
}
}
/// The trigger renders at the same height as the input field: the stated
/// metric plus the always-painted hairline border, whatever the platform.
#[test]
fn the_trigger_matches_the_input_height() {
let mut cx = TestAppContext::single();
cx.update(Theme::init);
let state = cx.update(InputState::new);
let state_for_window = state.clone();
let window = cx.add_window(move |_, _| HeightsHarness {
state: state_for_window,
});
let mut visual = VisualTestContext::from_window(window.into(), &cx);
draw(&mut visual);
let trigger = visual
.debug_bounds("select-trigger")
.expect("trigger rendered");
let field = visual.debug_bounds("input-field").expect("input rendered");
// Snapped bounds: allow half a pixel.
assert!((trigger.size.height - field.size.height).abs() <= px(0.5));
let expected = cx.update(|cx| cx.theme().sizes.medium.input_height) + BORDER_WIDTH * 2.0;
assert!((trigger.size.height - expected).abs() <= px(0.5));
}
+31 -11
View File
@@ -67,25 +67,45 @@ fn click(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
visual.simulate_mouse_up(position, MouseButton::Left, gpui::Modifiers::default());
}
/// The trigger band is `16..46`; the panel hangs 4 px below it with a 4 px
/// inner padding, so the three rows ride `~58..90`, `~90..122`, `~122..154`.
/// Every option must answer a pointer press — a popup that clips its last
/// item never does.
/// The three option rows must answer a pointer press wherever they were laid
/// out — a popup that clips its last item never does. Row positions come from
/// the rendered bounds, so the stated menu row metric can change freely.
#[test]
fn every_option_row_answers_the_pointer() {
let (_cx, mut visual, changes) = level_window();
let expected = [
(px(74.), Level::Low),
(px(106.), Level::Mid),
(px(138.), Level::High),
];
for (y, value) in expected {
// Open once to measure where the rows were actually drawn.
click(&mut visual, point(px(100.), px(31.)));
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
let centers: Vec<gpui::Point<gpui::Pixels>> = (0..3)
.map(|index| {
let selector: &'static str = Box::leak(format!("menu-item-{index}").into_boxed_str());
let row = visual
.debug_bounds(selector)
.unwrap_or_else(|| panic!("row {index} should have been laid out"));
point(
row.left() + row.size.width / 2.0,
row.top() + row.size.height / 2.0,
)
})
.collect();
// Close again; each iteration below opens the popup fresh.
click(&mut visual, point(px(100.), px(31.)));
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
let expected = [(Level::Low, 0), (Level::Mid, 1), (Level::High, 2)];
for (value, index) in expected {
// Choosing the previous row closed the popup; reopen it.
click(&mut visual, point(px(100.), px(31.)));
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
click(&mut visual, point(px(100.), y));
click(&mut visual, centers[index]);
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
-13
View File
@@ -45,16 +45,3 @@ fn the_dot_is_half_the_icon_size() {
dot.size
);
}
#[test]
fn the_dot_keeps_a_square_footprint() {
let (cx, window) = dot_window();
let mut visual = VisualTestContext::from_window(window, &cx);
let dot = visual.debug_bounds("test-dot").expect("dot missing");
assert!(
(dot.size.width - dot.size.height).abs() <= gpui::px(0.5),
"the dot must stay circular: {:?}",
dot.size
);
}
+24
View File
@@ -83,6 +83,30 @@ fn disabled_switch_does_not_report_changes() {
assert!(changes.borrow().is_empty());
}
/// The track renders at the platform's stated switch dims, not the generic
/// control height — the stated metric is the whole chrome.
#[test]
fn track_renders_at_the_stated_switch_dims() {
let (mut cx, _window, _changes) = switch_window(false, false);
let (width, height) = cx.update(|_window, cx| {
let metrics = Theme::global(cx).sizes.medium;
(metrics.switch_width, metrics.switch_height)
});
let track = cx
.debug_bounds("test-switch-track")
.expect("the switch track should have been laid out");
assert!(
(track.size.width - width).abs() <= px(0.5),
"track is {:?} wide, stated switch width is {width:?}",
track.size.width
);
assert!(
(track.size.height - height).abs() <= px(0.5),
"track is {:?} tall, stated switch height is {height:?}",
track.size.height
);
}
#[test]
fn enter_and_space_report_the_next_value_while_focused() {
let (mut cx, window, changes) = switch_window(false, false);
+105 -12
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();
@@ -109,12 +150,64 @@ fn hovering_elsewhere_deploys_nothing() {
assert!(calls.borrow().is_empty());
}
/// 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_button_passthrough_deploys_the_tooltip() {
let (cx, mut visual, calls) = tooltip_window();
hover(&mut visual, point(px(50.), px(100.)));
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);
assert_eq!(*calls.borrow(), vec!["button"]);
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"
);
}