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_foreground = theme.colors.muted_foreground;
let muted = theme.colors.muted; let muted = theme.colors.muted;
let surface = theme.colors.surface; 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 shadows = theme.elevations.popover.shadows();
let metrics = theme.sizes.resolve(Size::Medium); let metrics = theme.sizes.resolve(Size::Medium);
let icon_size = metrics.icon_size; let icon_size = metrics.icon_size;
@@ -190,7 +196,7 @@ impl RenderOnce for Alert {
.id((self.id, "alert-close")) .id((self.id, "alert-close"))
.flex_none() .flex_none()
.cursor_pointer() .cursor_pointer()
.p_0p5() .p_1()
.rounded(theme.radius.sm) .rounded(theme.radius.sm)
.hover(|style| style.bg(muted)) .hover(|style| style.bg(muted))
.child( .child(
-6
View File
@@ -315,10 +315,4 @@ mod tests {
assert!(filled >= MIN_FILLED * 2, "seed {seed} filled {filled}"); 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::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
AnyView, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, 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_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 gpui_native_utils::{focus_ring, record_anchor_bounds};
use std::rc::Rc; use std::rc::Rc;
use crate::common::{Sizable, h_flex}; use crate::common::{Sizable, h_flex};
use crate::spinner::Spinner;
/// The single activation handler, shared by pointer, Enter, and Space. /// The single activation handler, shared by pointer, Enter, and Space.
type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>; 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. /// Which visual treatment a button uses.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ButtonVariant { pub enum ButtonVariant {
/// Accent-filled; the platform's default action. /// The platform's default action.
#[default] #[default]
Primary, Primary,
/// Neutral and bordered; the everyday companion to `Primary`. /// The everyday companion to `Primary`.
Secondary, Secondary,
/// Unpainted until hovered, for toolbars and dense UI. /// Unpainted until hovered, for toolbars and dense UI.
Ghost, Ghost,
@@ -40,6 +42,7 @@ pub struct Button {
variant: ButtonVariant, variant: ButtonVariant,
size: Size, size: Size,
disabled: bool, disabled: bool,
loading: bool,
tab_stop: bool, tab_stop: bool,
debug_selector: Option<Rc<dyn Fn() -> String>>, debug_selector: Option<Rc<dyn Fn() -> String>>,
a11y_label: Option<SharedString>, a11y_label: Option<SharedString>,
@@ -60,6 +63,7 @@ impl Button {
variant: ButtonVariant::default(), variant: ButtonVariant::default(),
size: Size::default(), size: Size::default(),
disabled: false, disabled: false,
loading: false,
tab_stop: true, tab_stop: true,
debug_selector: None, debug_selector: None,
a11y_label: None, a11y_label: None,
@@ -119,6 +123,12 @@ impl Button {
self 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. /// Overrides the accessibility label, which defaults to the visible label.
pub fn accessibility_label<L>(mut self, label: L) -> Self pub fn accessibility_label<L>(mut self, label: L) -> Self
where where
@@ -149,11 +159,12 @@ impl RenderOnce for Button {
let theme = cx.theme(); let theme = cx.theme();
let colors = theme.colors; let colors = theme.colors;
let ramps = theme.ramps; let ramps = theme.ramps;
let borders = theme.borders;
let metrics = theme.sizes.resolve(self.size); let metrics = theme.sizes.resolve(self.size);
let show_border = borders.buttons && self.variant != ButtonVariant::Ghost;
let on_click = self.on_click; let on_click = self.on_click;
let a11y_label = self.a11y_label.or_else(|| self.label.clone()); 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 { let (background, foreground, hover, pressed) = match self.variant {
ButtonVariant::Primary => ( 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() h_flex()
.id(self.id) .id(self.id.clone())
.when_some(self.debug_selector, |button, selector| { .when_some(self.debug_selector, |button, selector| {
button.debug_selector(move || selector()) button.debug_selector(move || selector())
}) })
.role(Role::Button) .role(Role::Button)
.justify_center() .justify_center()
.when(icon_only, |button| button.size(metrics.control_height)) .h(metrics.control_height + BORDER_WIDTH * 2.0)
.when(!icon_only, |button| { .when(self.icon.is_some(), |button| {
button.h(metrics.control_height).px(metrics.padding_x) button.px(metrics.dense_padding_x)
}) })
.when(self.icon.is_none(), |button| button.px(metrics.padding_x))
.line_height(relative(1.0)) .line_height(relative(1.0))
.gap_1p5() .gap_2()
.rounded(theme.radius.control) .rounded(theme.radius.control)
.text_size(metrics.text_size) .text_size(metrics.text_size)
.when(self.variant == ButtonVariant::Secondary, |button| { .border_1()
button.border_1().border_color(colors.border) .border_color(border)
})
.when_some(background, |button, background| button.bg(background)) .when_some(background, |button, background| button.bg(background))
.text_color(foreground) .text_color(foreground)
.when_some(a11y_label, |button, label| button.aria_label(label)) .when_some(a11y_label, |button, label| button.aria_label(label))
.when_some(self.icon, |button, role| { .when(self.loading, |button| {
button.child(icon(role).size(metrics.icon_size).color(foreground)) 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)) .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(self.disabled, |button| button.opacity(0.5))
.when_some(self.tooltip, |button, tooltip| { .when_some(self.tooltip, |button, tooltip| {
button.tooltip(move |window, cx| tooltip(window, cx)) button.tooltip(move |window, cx| tooltip(window, cx))
}) })
.when(!self.disabled, |button| { .when(!self.disabled && !self.loading, |button| {
button button
.cursor_pointer() .cursor_pointer()
.focusable() .focusable()
.tab_stop(self.tab_stop) .tab_stop(self.tab_stop)
.hover(move |style| style.bg(hover)) .hover(move |style| style.bg(hover).border_color(hover_border))
.active(move |style| style.bg(pressed)) .active(move |style| style.bg(pressed).border_color(pressed_border))
.focus_visible(|style| style.shadow(focus_ring(colors.ring))) .focus_visible(|style| style.shadow(focus_ring(colors.ring)))
.when_some(on_click, |button, on_click| { .when_some(on_click, |button, on_click| {
button.on_click(move |event, window, cx| on_click(event, window, cx)) 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::{ use gpui::{
App, Bounds, Context, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement, App, Bounds, Context, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement,
KeyBinding, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, Role, KeyBinding, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, Role,
ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Task, ScrollHandle, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
Window, actions, canvas, deferred, div, px, Subscription, Task, Window, actions, anchored, canvas, deferred, div, point, px,
}; };
use gpui_native_icons::{IconRole, icon}; 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::input::{Input, InputState, InputStateEvent};
use crate::scroll_area::ScrollArea; use crate::scroll_area::ScrollArea;
@@ -81,6 +81,7 @@ where
filter: Option<ComboboxFilter<T>>, filter: Option<ComboboxFilter<T>>,
unmatched: UnmatchedText, unmatched: UnmatchedText,
size: Size, size: Size,
style: StyleRefinement,
} }
impl<T> Combobox<T> impl<T> Combobox<T>
@@ -103,6 +104,7 @@ where
filter: None, filter: None,
unmatched: UnmatchedText::default(), unmatched: UnmatchedText::default(),
size: Size::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. /// Emitted when the combobox's value changes.
pub struct ComboboxChanged; pub struct ComboboxChanged;
@@ -189,6 +201,8 @@ where
filter: ComboboxFilter<T>, filter: ComboboxFilter<T>,
unmatched: UnmatchedText, unmatched: UnmatchedText,
size: Size, 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. /// Handler reports in flight; held so they die with the state.
tasks: Vec<Task<()>>, tasks: Vec<Task<()>>,
/// Keeps the query-event subscription tied to the state's lifetime. /// Keeps the query-event subscription tied to the state's lifetime.
@@ -225,6 +239,7 @@ where
let subscription = let subscription =
cx.subscribe_in(&query, window, |state, _, event, window, cx| match event { 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::TextChanged => state.text_changed(window, cx),
InputStateEvent::Blurred => state.blurred(window, cx), InputStateEvent::Blurred => state.blurred(window, cx),
_ => {} _ => {}
@@ -248,6 +263,7 @@ where
}), }),
unmatched: combobox.unmatched, unmatched: combobox.unmatched,
size: combobox.size, size: combobox.size,
style: combobox.style,
tasks: Vec::new(), tasks: Vec::new(),
_subscription: subscription, _subscription: subscription,
} }
@@ -364,6 +380,16 @@ where
cx.notify(); 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. /// Losing focus. [`UnmatchedText`] decides what the text becomes.
fn blurred(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn blurred(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.highlight = None; self.highlight = None;
@@ -447,11 +473,25 @@ where
T: Clone + PartialEq + 'static, T: Clone + PartialEq + 'static,
{ {
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 {
let (metrics, colors, radius) = { let (metrics, colors, radius, panel_border, spacing_unit, shadows) = {
let theme = cx.theme(); let theme = cx.theme();
let metrics = theme.sizes.resolve(self.size); 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 icon_size = metrics.dense_icon_size;
let id = self.id.clone(); let id = self.id.clone();
let state = cx.entity(); let state = cx.entity();
@@ -490,7 +530,9 @@ where
.id(id.clone()) .id(id.clone())
.key_context(COMBOBOX_CONTEXT) .key_context(COMBOBOX_CONTEXT)
.relative() .relative()
.w_full() .w(window.rem_size() * 12.0)
.min_w(window.rem_size() * 6.0)
.refine_style(&self.style)
.role(Role::ComboBox) .role(Role::ComboBox)
.aria_expanded(open) .aria_expanded(open)
.aria_label(self.name.clone()) .aria_label(self.name.clone())
@@ -532,13 +574,14 @@ where
.child( .child(
Input::new((id.clone(), "query"), self.query.clone()) Input::new((id.clone(), "query"), self.query.clone())
.placeholder(self.placeholder.clone()) .placeholder(self.placeholder.clone())
.placeholder_when_focused(true)
.with_size(self.size), .with_size(self.size),
) )
.child( .child(
h_flex() h_flex()
.absolute() .absolute()
.right(metrics.dense_padding_x) .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), .child(chevron),
); );
@@ -626,6 +669,10 @@ where
}; };
let panel_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 popup_width = wrapper_bounds
.borrow()
.map(|bounds| bounds.size.width)
.unwrap_or_else(|| window.rem_size() * 12.0);
let panel = v_flex() let panel = v_flex()
.id((id.clone(), "panel")) .id((id.clone(), "panel"))
@@ -633,14 +680,14 @@ where
let name = format!("{id}-panel"); let name = format!("{id}-panel");
move || name move || name
}) })
.mt_1() .w(popup_width)
.w_full()
.p_1() .p_1()
.bg(colors.surface) .bg(colors.surface)
.text_color(colors.foreground) .text_color(colors.foreground)
.rounded(radius.overlay) .rounded(radius.overlay)
.border_1() .border_1()
.border_color(colors.border) .border_color(panel_border)
.shadow(shadows)
.overflow_hidden() .overflow_hidden()
.child(rows) .child(rows)
.child( .child(
@@ -684,7 +731,9 @@ where
.inset_0(), .inset_0(),
); );
wrapper = wrapper.child(deferred(panel)); wrapper = wrapper.child(deferred(
anchored().offset(point(px(0.), spacing_unit)).child(panel),
));
} }
wrapper wrapper
+22 -3
View File
@@ -10,6 +10,7 @@ use gpui::{
ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Window, ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Window,
actions, canvas, div, px, relative, actions, canvas, div, px, relative,
}; };
use gpui_native_icons::{IconRole, icon};
use gpui_native_theme::{ActiveTheme as _, Size}; use gpui_native_theme::{ActiveTheme as _, Size};
use crate::common::{h_flex, v_flex}; use crate::common::{h_flex, v_flex};
@@ -48,18 +49,27 @@ pub struct Command {
pub label: SharedString, pub label: SharedString,
/// Gray text on the row's right edge, e.g. the keybinding. /// Gray text on the row's right edge, e.g. the keybinding.
pub hint: Option<SharedString>, pub hint: Option<SharedString>,
/// Leading glyph shown before the label.
pub icon: Option<IconRole>,
} }
impl Command { 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 { pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self { Self {
id: id.into(), id: id.into(),
label: label.into(), label: label.into(),
hint: None, 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. /// Sets the row's right-edge hint.
pub fn hint(mut self, hint: impl Into<SharedString>) -> Self { pub fn hint(mut self, hint: impl Into<SharedString>) -> Self {
self.hint = Some(hint.into()); self.hint = Some(hint.into());
@@ -295,15 +305,21 @@ impl Render for CommandPaletteState {
return div().into_any_element(); return div().into_any_element();
} }
let (colors, radius, metrics, shadows) = { let (colors, radius, metrics, panel_border, shadows) = {
let theme = cx.theme(); let theme = cx.theme();
( (
theme.colors, theme.colors,
theme.radius, theme.radius,
theme.sizes.resolve(Size::default()), theme.sizes.resolve(Size::default()),
if theme.borders.overlays {
theme.colors.border
} else {
theme.colors.surface
},
theme.elevations.dialog.shadows(), theme.elevations.dialog.shadows(),
) )
}; };
let id = self.id.clone(); let id = self.id.clone();
let state = cx.entity(); let state = cx.entity();
@@ -345,6 +361,9 @@ impl Render for CommandPaletteState {
state.update(cx, |state, cx| state.choose_row(row_ix, window, cx)); 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( .child(
div() div()
.flex_1() .flex_1()
@@ -407,7 +426,7 @@ impl Render for CommandPaletteState {
.text_color(colors.foreground) .text_color(colors.foreground)
.rounded(radius.overlay) .rounded(radius.overlay)
.border_1() .border_1()
.border_color(colors.border) .border_color(panel_border)
.shadow(shadows) .shadow(shadows)
.overflow_hidden() .overflow_hidden()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .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(); let theme = cx.theme();
( (
theme.colors.overlay, theme.colors.overlay,
theme.colors.border, if theme.borders.overlays {
theme.colors.border
} else {
theme.colors.surface
},
theme.colors.foreground, theme.colors.foreground,
theme.colors.muted_foreground, theme.colors.muted_foreground,
theme.colors.surface, theme.colors.surface,
+243 -220
View File
@@ -7,15 +7,17 @@ use unicode_segmentation::UnicodeSegmentation;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
App, AppContext as _, Bounds, ClipboardItem, ContentMask, Context, CursorStyle, DefiniteLength, Animation, AnimationExt as _, AnyElement, App, AppContext as _, Bounds, ClipboardItem,
DispatchPhase, ElementId, Entity, EntityInputHandler, EventEmitter, Font, HitboxBehavior, ContentMask, Context, CursorStyle, DefiniteLength, DispatchPhase, ElementId, Entity,
InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, EntityInputHandler, EventEmitter, Font, HitboxBehavior, InteractiveElement as _, IntoElement,
MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, RenderOnce, Role, ScrollDelta, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
ScrollEvent, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Task, ParentElement as _, Pixels, RenderOnce, Role, ScrollDelta, ScrollEvent, SharedString,
TextAlign, TextRun, UTF16Selection, Window, WrappedLine, actions, canvas, div, fill, point, px, StatefulInteractiveElement as _, Styled as _, TextAlign, TextRun, UTF16Selection, Window,
WrappedLine, actions, canvas, div, fill, point, px,
}; };
use gpui_native_icons::IconRole; 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::button::{Button, ButtonVariant};
use crate::common::{Sizable, h_flex}; 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. /// The application-owned state of a text field.
pub struct InputState { pub struct InputState {
text: String, text: String,
@@ -606,20 +517,18 @@ pub struct InputState {
scroll_offset: gpui::Point<Pixels>, scroll_offset: gpui::Point<Pixels>,
/// Set by every caret-affecting change. /// Set by every caret-affecting change.
scroll_to_caret_pending: bool, scroll_to_caret_pending: bool,
/// Drives the focused caret's blink. /// Bumped on every edit.
caret_blink: Entity<CaretBlink>, caret_epoch: usize,
/// The shaped line and element bounds, recorded during each paint, /// When the current caret epoch started.
/// and shared with hit-testing and the IME geometry conversions. caret_epoch_started: Option<Instant>,
/// The shaped line and element bounds, recorded during each paint.
layout: Rc<RefCell<Option<InputLayout>>>, 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 EventEmitter<InputStateEvent> for InputState {}
impl InputState { impl InputState {
fn new_inner(cx: &mut Context<Self>) -> Self { fn new_inner(cx: &mut Context<Self>) -> Self {
let caret_blink = cx.new(CaretBlink::new);
Self { Self {
text: String::new(), text: String::new(),
cursor: 0, cursor: 0,
@@ -649,9 +558,9 @@ impl InputState {
goal_x: None, goal_x: None,
scroll_offset: point(Pixels::ZERO, Pixels::ZERO), scroll_offset: point(Pixels::ZERO, Pixels::ZERO),
scroll_to_caret_pending: true, 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)), 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. /// 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> { pub fn new_with_text(text: impl Into<String>, cx: &mut App) -> Entity<Self> {
let text = sanitize(text.into(), false); let text = sanitize(text.into(), false);
cx.new(|cx| { cx.new(|cx| {
@@ -681,10 +587,6 @@ impl InputState {
} }
/// Replaces the whole value and collapses the selection to the end. /// 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>) pub fn set_value<V>(&mut self, value: V, window: &mut Window, cx: &mut Context<Self>)
where where
V: Into<String>, V: Into<String>,
@@ -866,7 +768,8 @@ impl InputState {
cx.notify(); cx.notify();
self.scroll_to_caret_pending = true; self.scroll_to_caret_pending = true;
self.goal_x = None; 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); window.notify_text_input_changed(&self.focus_handle);
} }
@@ -899,10 +802,11 @@ impl InputState {
} }
self.focused = focused; self.focused = focused;
// A fresh focus starts a fresh blink epoch, so the caret shows solid
// for one interval before blinking resumes.
if focused { if focused {
self.caret_blink.update(cx, |blink, cx| blink.enable(cx)); self.caret_epoch += 1;
} else { self.caret_epoch_started = Some(Instant::now());
self.caret_blink.update(cx, |blink, cx| blink.disable(cx));
} }
cx.emit(if focused { cx.emit(if focused {
@@ -913,8 +817,22 @@ impl InputState {
} }
/// Whether the caret should paint. /// Whether the caret should paint.
pub fn caret_visible(&mut self, focused: bool, cx: &mut Context<Self>) -> bool { pub fn caret_visible(&mut self, focused: bool) -> bool {
focused && !self.read_only && self.caret_blink.read(cx).visible() 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. /// 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. // 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); self.paint_caret(origin, font_size, colors.foreground, window);
} }
@@ -1901,9 +1819,13 @@ pub struct Input {
placeholder: SharedString, placeholder: SharedString,
size: Size, size: Size,
disabled: bool, disabled: bool,
/// The placeholder stays visible while focused.
placeholder_when_focused: bool,
a11y_label: Option<SharedString>, 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, rows: usize,
prefix: Option<AnyElement>,
suffix: Option<AnyElement>,
} }
impl Input { impl Input {
@@ -1918,8 +1840,11 @@ impl Input {
placeholder: SharedString::default(), placeholder: SharedString::default(),
size: Size::default(), size: Size::default(),
disabled: false, disabled: false,
placeholder_when_focused: false,
a11y_label: None, a11y_label: None,
rows: DEFAULT_ROWS, rows: DEFAULT_ROWS,
prefix: None,
suffix: None,
} }
} }
@@ -1938,6 +1863,12 @@ impl Input {
self 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. /// Renders the field inert, unfocusable, and dimmed; disabled always wins.
pub fn disabled(mut self, disabled: bool) -> Self { pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled; self.disabled = disabled;
@@ -1952,6 +1883,24 @@ impl Input {
self.a11y_label = Some(label.into()); self.a11y_label = Some(label.into());
self 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 { impl Sizable for Input {
@@ -1967,25 +1916,28 @@ impl RenderOnce for Input {
let radius = theme.radius; let radius = theme.radius;
let metrics = theme.sizes.resolve(self.size); let metrics = theme.sizes.resolve(self.size);
let focus_handle = self.state.read(cx).focus_handle.clone(); let focus_handle = self.state.read(cx).focus_handle.clone();
let focused = focus_handle.is_focused(window); let focused = focus_handle.is_focused(window);
let is_empty = self.state.read(cx).text.is_empty(); let is_empty = self.state.read(cx).text.is_empty();
let is_password = self.state.read(cx).is_password(); let is_password = self.state.read(cx).is_password();
let revealed = self.state.read(cx).is_revealed(); let revealed = self.state.read(cx).is_revealed();
let multiline = self.state.read(cx).is_multiline(); 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 { let small = theme.sizes.resolve(Size::Small);
metrics.dense_padding_x + metrics.dense_icon_size let docked_height = small.control_height + BORDER_WIDTH * 2.0;
} else { let edge_inset = ((metrics.input_height - docked_height) / 2.0).max(Pixels::ZERO);
metrics.dense_padding_x
};
// 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 vertical_inset = metrics.dense_padding_x * 0.5;
let reveal_width = small.dense_padding_x * 2.0 + small.icon_size;
let field_padding = FieldPadding { let field_padding = FieldPadding {
left: metrics.dense_padding_x, left: Pixels::ZERO,
right: right_padding, right: if is_password {
(edge_inset + reveal_width - metrics.dense_padding_x).max(Pixels::ZERO)
} else {
Pixels::ZERO
},
top: vertical_inset, top: vertical_inset,
}; };
@@ -1993,9 +1945,10 @@ impl RenderOnce for Input {
DefiniteLength::Fraction(ratio) => metrics.text_size * ratio, DefiniteLength::Fraction(ratio) => metrics.text_size * ratio,
DefiniteLength::Absolute(length) => length.to_pixels(window.rem_size()), DefiniteLength::Absolute(length) => length.to_pixels(window.rem_size()),
}); });
let field_height = line_height.map_or(metrics.input_height, |line_height| { let field_height = line_height.map_or(metrics.input_height, |line_height| {
line_height * self.rows as f32 + vertical_inset * 2.0 line_height * self.rows as f32 + vertical_inset * 2.0
}); }) + BORDER_WIDTH * 2.0;
let fallback_label = self let fallback_label = self
.a11y_label .a11y_label
@@ -2003,8 +1956,111 @@ impl RenderOnce for Input {
.or_else(|| (!self.placeholder.is_empty()).then_some(self.placeholder.clone())) .or_else(|| (!self.placeholder.is_empty()).then_some(self.placeholder.clone()))
.unwrap_or_else(|| SharedString::from(self.id.to_string())); .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() h_flex()
.id(self.id.clone()) .id(self.id.clone())
.debug_selector(|| "input-field".to_string())
.key_context(INPUT_CONTEXT) .key_context(INPUT_CONTEXT)
.role(Role::TextInput) .role(Role::TextInput)
.aria_label(fallback_label) .aria_label(fallback_label)
@@ -2012,117 +2068,84 @@ impl RenderOnce for Input {
.overflow_hidden() .overflow_hidden()
.w_full() .w_full()
.h(field_height) .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) .rounded(radius.control)
.border_1()
.border_color(colors.border)
.bg(colors.input) .bg(colors.input)
.text_size(metrics.text_size) .text_size(metrics.text_size)
.text_color(colors.foreground) .text_color(colors.foreground)
.when_some(line_height, |input, line_height| { .border_1()
input.items_start().line_height(line_height) .border_color(if theme.borders.fields {
colors.border
} else {
colors.input
}) })
.when(self.disabled, |input| input.opacity(0.5)) .when(self.disabled, |input| input.opacity(0.5))
.when(!self.disabled, |input| { .when(!self.disabled, |input| {
input.track_focus(&focus_handle).tab_stop(true) input.track_focus(&focus_handle).tab_stop(true)
}) })
.when(focused, |input| input.border_color(colors.ring)) .when(focused && !theme.borders.focus_ring, |input| {
.when(is_empty && !focused, |input| { input.border_color(colors.ring)
input.child( })
div() .when(focused && theme.borders.focus_ring, |input| {
.text_color(colors.muted_foreground) input.shadow(focus_ring(colors.ring))
.when(multiline, |placeholder| placeholder.pt(vertical_inset)) })
.child(self.placeholder.clone()), .when_some(self.prefix, |input, prefix| {
) input.child(edge_zone("input-prefix", prefix))
}) })
.child( .child(
canvas( div()
{ .relative()
let state = self.state.clone(); .flex_1()
move |bounds, window, cx| { .min_w_0()
// The hitbox backs the I-beam hover cursor. .h_full()
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); .overflow_hidden()
let wrap_width = multiline.then(|| { .flex()
(bounds.size.width - field_padding.left - field_padding.right) .flex_row()
.max(Pixels::ZERO) .items_center()
}); .when_some(line_height, |region, line_height| {
let text = state.read(cx).shape_current_text(wrap_width, window); region.items_start().line_height(line_height)
(text, hitbox) })
} .when(
}, is_empty && (!focused || self.placeholder_when_focused),
{ |region| {
let state = self.state.clone(); region.child(
let focus = focus_handle.clone(); div()
let disabled = self.disabled; .text_color(colors.muted_foreground)
.when(multiline, |placeholder| placeholder.pt(vertical_inset))
move |bounds, (text, hitbox), window, cx| { .child(self.placeholder.clone()),
if !disabled { )
window.set_cursor_style(CursorStyle::IBeam, &hitbox); },
} )
.child(canvas_element),
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(),
) )
.when_some(self.suffix, |input, suffix| {
input.child(edge_zone("input-suffix", suffix))
})
.when(is_password && !self.disabled, |input| { .when(is_password && !self.disabled, |input| {
input.child( input.child(
div() div()
.id((self.id.clone(), "input-reveal")) .id((self.id.clone(), "input-reveal"))
.debug_selector(|| "input-reveal".to_string()) .debug_selector(|| "input-reveal".to_string())
.absolute() .absolute()
.right(metrics.dense_padding_x / 2.0) .right(edge_inset)
.top_0p5() .top_0()
.bottom_0p5() .bottom_0()
.flex() .flex()
.items_center() .items_center()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.child( .child(
Button::new((self.id.clone(), "input-reveal-button")) Button::new((self.id.clone(), "input-reveal-button"))
.debug_selector(|| "input-reveal-button".to_string())
.icon(if revealed { .icon(if revealed {
IconRole::EyeOff IconRole::EyeOff
} else { } else {
+50 -47
View File
@@ -7,7 +7,7 @@ use gpui::{
Entity, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, Entity, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent,
ParentElement as _, Pixels, Point, RenderOnce, Role, ScrollHandle, SharedString, ParentElement as _, Pixels, Point, RenderOnce, Role, ScrollHandle, SharedString,
StatefulInteractiveElement as _, Styled as _, Window, anchored, canvas, deferred, div, 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_icons::{IconRole, icon as icon_glyph};
use gpui_native_theme::ActiveTheme as _; use gpui_native_theme::ActiveTheme as _;
@@ -127,6 +127,7 @@ impl MenuItem {
struct MenuChrome { struct MenuChrome {
surface: gpui::Hsla, surface: gpui::Hsla,
border: gpui::Hsla, border: gpui::Hsla,
panel_border: gpui::Hsla,
foreground: gpui::Hsla, foreground: gpui::Hsla,
muted_foreground: gpui::Hsla, muted_foreground: gpui::Hsla,
primary: gpui::Hsla, primary: gpui::Hsla,
@@ -137,6 +138,9 @@ struct MenuChrome {
radius_sm: Pixels, radius_sm: Pixels,
icon_size: Pixels, icon_size: Pixels,
dense_icon_size: Pixels, dense_icon_size: Pixels,
menu_item_height: Pixels,
spacing_unit: Pixels,
text_size: Pixels,
shadow: Vec<BoxShadow>, shadow: Vec<BoxShadow>,
} }
@@ -242,6 +246,11 @@ impl RenderOnce for Menu {
MenuChrome { MenuChrome {
surface: theme.colors.surface, surface: theme.colors.surface,
border: theme.colors.border, border: theme.colors.border,
panel_border: if theme.borders.overlays {
theme.colors.border
} else {
theme.colors.surface
},
foreground: theme.colors.foreground, foreground: theme.colors.foreground,
muted_foreground: theme.colors.muted_foreground, muted_foreground: theme.colors.muted_foreground,
primary: theme.ramps.primary.base, primary: theme.ramps.primary.base,
@@ -252,6 +261,9 @@ impl RenderOnce for Menu {
radius_sm: theme.radius.sm, radius_sm: theme.radius.sm,
icon_size: theme.sizes.medium.icon_size, icon_size: theme.sizes.medium.icon_size,
dense_icon_size: theme.sizes.medium.dense_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(), shadow: theme.elevations.popover.shadows(),
}, },
theme.motion.overlay, 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 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 selection = window.use_keyed_state("menu-selection", cx, |_, _| 0usize);
let was_open = window.use_keyed_state("menu-was-open", cx, |_, _| self.open); 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() let panel = v_flex()
.id("menu-panel") .id("menu-panel")
.debug_selector(|| "menu-panel".to_string())
.occlude()
.track_focus(&focus) .track_focus(&focus)
.role(Role::Menu) .role(Role::Menu)
.aria_label(label) .aria_label(label)
.mt_1()
.min_w_56() .min_w_56()
.p_1() .p(chrome.spacing_unit)
.bg(chrome.surface) .bg(chrome.surface)
.text_color(chrome.foreground) .text_color(chrome.foreground)
.rounded(chrome.radius_overlay) .rounded(chrome.radius_overlay)
.border_1() .border_1()
.border_color(chrome.border) .border_color(chrome.panel_border)
.shadow(chrome.shadow.clone()) .shadow(chrome.shadow.clone())
.overflow_hidden() .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({ .on_key_down({
let items = items.clone(); let items = items.clone();
let on_change = on_change.clone(); let on_change = on_change.clone();
@@ -392,44 +420,14 @@ impl RenderOnce for Menu {
.children(rows), .children(rows),
) )
.child( .child(
canvas( canvas(|_, _, _| {}, {
{ let focus = focus.clone();
let cell = panel_bounds.clone(); move |_, _, window, cx| {
move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds) if !focus.contains_focused(window, cx) {
}, window.focus(&focus, cx);
{
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();
}
});
} }
}, }
) })
.absolute() .absolute()
.inset_0(), .inset_0(),
); );
@@ -437,7 +435,8 @@ impl RenderOnce for Menu {
let anchored = match self.open_at { let anchored = match self.open_at {
Some(position) => anchored().position(position), Some(position) => anchored().position(position),
None => anchored(), None => anchored(),
}; }
.offset(point(px(0.), chrome.spacing_unit));
wrapper = wrapper.child(deferred(anchored.child(panel.with_animation( wrapper = wrapper.child(deferred(anchored.child(panel.with_animation(
"menu-enter", "menu-enter",
@@ -462,8 +461,9 @@ fn render_item(
match item.kind { match item.kind {
MenuItemKind::Separator => div() MenuItemKind::Separator => div()
.h(px(1.)) .h(px(1.))
.mx_2() .debug_selector(move || format!("menu-separator-{index}"))
.my_1() .mx(chrome.spacing_unit * 2.0)
.my(chrome.spacing_unit)
.bg(chrome.border) .bg(chrome.border)
.into_any_element(), .into_any_element(),
MenuItemKind::Action => { MenuItemKind::Action => {
@@ -476,11 +476,14 @@ fn render_item(
div() div()
.id(("menu-item", index)) .id(("menu-item", index))
.debug_selector(move || format!("menu-item-{index}"))
.flex() .flex()
.items_center() .items_center()
.gap_2() .gap_2()
.px_2() .min_h(chrome.menu_item_height)
.py_1() .px(chrome.spacing_unit * 2.0)
.text_size(chrome.text_size)
.line_height(relative(1.0))
.rounded(chrome.radius_sm) .rounded(chrome.radius_sm)
.map(|row| { .map(|row| {
if disabled { if disabled {
+42 -52
View File
@@ -6,7 +6,7 @@ use gpui::{
Animation, AnimationExt as _, AnyElement, App, Bounds, DispatchPhase, ElementId, Animation, AnimationExt as _, AnyElement, App, Bounds, DispatchPhase, ElementId,
InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent,
ParentElement as _, Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, 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 _; use gpui_native_theme::ActiveTheme as _;
@@ -91,18 +91,23 @@ impl Popover {
impl RenderOnce for Popover { impl RenderOnce for Popover {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { 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(); let theme = cx.theme();
( (
theme.colors, theme.colors,
theme.radius, theme.radius,
theme.elevations.popover.shadows(), theme.elevations.popover.shadows(),
theme.motion.overlay, 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 trigger_bounds: BoundsCell = Rc::new(RefCell::new(None));
let panel_bounds: BoundsCell = Rc::new(RefCell::new(None));
let fallback_label = self let fallback_label = self
.a11y_label .a11y_label
@@ -166,18 +171,34 @@ impl RenderOnce for Popover {
let panel = v_flex() let panel = v_flex()
.id("popover-panel") .id("popover-panel")
.debug_selector(|| "popover-panel".to_string())
.occlude()
.track_focus(&focus) .track_focus(&focus)
.role(Role::Dialog) .role(Role::Dialog)
.aria_label(label) .aria_label(label)
.mt_1()
.min_w_32() .min_w_32()
.bg(colors.surface) .bg(colors.surface)
.text_color(colors.surface_foreground) .text_color(colors.surface_foreground)
.rounded(radius.overlay) .rounded(radius.overlay)
.border_1() .border_1()
.border_color(colors.border) .border_color(panel_border)
.shadow(shadows) .shadow(shadows)
.overflow_hidden() .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({ .on_key_down({
let on_change = on_change.clone(); let on_change = on_change.clone();
move |event: &KeyDownEvent, window, cx| { move |event: &KeyDownEvent, window, cx| {
@@ -191,58 +212,27 @@ impl RenderOnce for Popover {
}) })
.when_some(content, |panel, content| panel.child(content)) .when_some(content, |panel, content| panel.child(content))
.child( .child(
canvas( canvas(|_, _, _| {}, {
{ let focus = focus;
let cell = panel_bounds.clone(); move |_, _, window, cx| {
move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds) if !focus.contains_focused(window, cx) {
}, window.focus(&focus, cx);
{
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();
}
});
} }
}, }
) })
.absolute() .absolute()
.inset_0(), .inset_0(),
); );
wrapper = wrapper.child(deferred(anchored().child(panel.with_animation( wrapper = wrapper.child(deferred(
"popover-enter", anchored()
Animation::new(enter_duration).with_easing(ease_out_quint()), .offset(point(px(0.), anchor_gap))
|panel, delta| panel.opacity(delta), .child(panel.with_animation(
)))); "popover-enter",
Animation::new(enter_duration).with_easing(ease_out_quint()),
|panel, delta| panel.opacity(delta),
)),
));
} }
wrapper wrapper
+27 -5
View File
@@ -6,7 +6,8 @@ use gpui::{
SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window,
}; };
use gpui_native_icons::{IconRole, icon}; 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::common::{Sizable, StyledExt, h_flex};
use crate::menu::{Menu, MenuItem}; use crate::menu::{Menu, MenuItem};
@@ -169,6 +170,8 @@ where
let muted_foreground = theme.colors.muted_foreground; let muted_foreground = theme.colors.muted_foreground;
let radius = theme.radius.control; let radius = theme.radius.control;
let icon_size = metrics.dense_icon_size; let icon_size = metrics.dense_icon_size;
let fields_bordered = theme.borders.fields;
let focus_ring_on = theme.borders.focus_ring;
let label = self let label = self
.a11y_label .a11y_label
@@ -180,7 +183,22 @@ where
.use_keyed_state("select-open", cx, |_, _| false) .use_keyed_state("select-open", cx, |_, _| false)
.clone() .clone()
}); });
let is_open = *open.read(cx); 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| { let selected_index = self.selected.as_ref().and_then(|value| {
self.options self.options
@@ -194,6 +212,7 @@ where
let trigger_id = self.id.clone(); let trigger_id = self.id.clone();
let trigger_label = label.clone(); let trigger_label = label.clone();
let trigger_style = self.style; let trigger_style = self.style;
let trigger = move |_window: &mut Window, _cx: &mut App| { let trigger = move |_window: &mut Window, _cx: &mut App| {
let text = trigger_selected let text = trigger_selected
.as_ref() .as_ref()
@@ -207,17 +226,18 @@ where
h_flex() h_flex()
.id((trigger_id.clone(), "select-trigger")) .id((trigger_id.clone(), "select-trigger"))
.debug_selector(|| "select-trigger".to_string())
.role(Role::ComboBox) .role(Role::ComboBox)
.aria_label(trigger_label.clone()) .aria_label(trigger_label.clone())
.cursor_pointer() .cursor_pointer()
.justify_between() .justify_between()
.gap_2() .gap_2()
.w_full() .w_full()
.h(metrics.input_height) .h(metrics.input_height + BORDER_WIDTH * 2.0)
.px(metrics.dense_padding_x) .px(metrics.dense_padding_x)
.rounded(radius) .rounded(radius)
.border_1() .border_1()
.border_color(if is_open { ring } else { border }) .border_color(rest_border)
.bg(input) .bg(input)
.text_size(metrics.text_size) .text_size(metrics.text_size)
.text_color(if trigger_selected.is_some() { .text_color(if trigger_selected.is_some() {
@@ -225,7 +245,10 @@ where
} else { } else {
muted_foreground 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(text)
.child( .child(
icon(IconRole::ChevronDown) icon(IconRole::ChevronDown)
@@ -236,7 +259,6 @@ where
.into_any_element() .into_any_element()
}; };
let open_for_items = open.clone();
let items = move |_window: &mut Window, _cx: &mut App| { let items = move |_window: &mut Window, _cx: &mut App| {
self.options self.options
.iter() .iter()
+9 -1
View File
@@ -41,6 +41,7 @@ pub struct Spinner {
value: Option<f32>, value: Option<f32>,
variant: SpinnerVariant, variant: SpinnerVariant,
size: Size, size: Size,
color: Option<Hsla>,
a11y_label: Option<SharedString>, a11y_label: Option<SharedString>,
} }
@@ -55,10 +56,17 @@ impl Spinner {
value: None, value: None,
variant: SpinnerVariant::default(), variant: SpinnerVariant::default(),
size: Size::default(), size: Size::default(),
color: None,
a11y_label: 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. /// Sets the spinner's visual language.
pub fn variant(mut self, variant: SpinnerVariant) -> Self { pub fn variant(mut self, variant: SpinnerVariant) -> Self {
self.variant = variant; self.variant = variant;
@@ -93,7 +101,7 @@ impl RenderOnce for Spinner {
let side = theme.sizes.resolve(self.size).icon_size; let side = theme.sizes.resolve(self.size).icon_size;
let stroke = side * RING_STROKE; let stroke = side * RING_STROKE;
let track = theme.colors.border; 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)); let value = self.value.map(|value| value.clamp(0.0, 1.0));
div() div()
+9 -6
View File
@@ -96,16 +96,17 @@ impl RenderOnce for Switch {
let ramps = theme.ramps; let ramps = theme.ramps;
let metrics = theme.sizes.resolve(self.size); let metrics = theme.sizes.resolve(self.size);
let track_height = metrics.control_height; let track_height = metrics.switch_height;
let track_width = metrics.control_height * 1.65; let track_width = metrics.switch_width;
let knob = metrics.indicator_size; let knob = metrics.switch_knob;
let inset = metrics.plate_inset; let inset = (track_height - knob) / 2.;
let travel = track_width - knob - inset * 2.; let travel = track_width - knob - inset * 2.;
let next = !self.checked; let next = !self.checked;
let on_change = self.on_change; let on_change = self.on_change;
let a11y_label = self.a11y_label.or_else(|| self.label.clone()); 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_config = SpringConfig::new(500., 35., 1.);
let anim = SpringAnimation::new(anim_config).to(if self.checked { travel } else { px(0.) }); let anim = SpringAnimation::new(anim_config).to(if self.checked { travel } else { px(0.) });
@@ -132,6 +133,10 @@ impl RenderOnce for Switch {
div() div()
.flex_none() .flex_none()
.relative() .relative()
.debug_selector({
let name = format!("{id}-track");
move || name
})
.w(track_width) .w(track_width)
.h(track_height) .h(track_height)
.rounded(theme.radius.full) .rounded(theme.radius.full)
@@ -149,8 +154,6 @@ impl RenderOnce for Switch {
.size(knob) .size(knob)
.rounded(theme.radius.full) .rounded(theme.radius.full)
.bg(colors.primary_foreground) .bg(colors.primary_foreground)
.border_1()
.border_color(colors.border)
.with_spring("switch-thumb", anim, move |thumb, offset| { .with_spring("switch-thumb", anim, move |thumb, offset| {
thumb.left(inset + offset) thumb.left(inset + offset)
}), }),
+20 -25
View File
@@ -12,7 +12,8 @@ use gpui::{
use gpui_native_icons::{IconRole, icon}; use gpui_native_icons::{IconRole, icon};
use gpui_native_theme::ActiveTheme as _; 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`]. /// The action handler of a [`Toast`].
pub type ActionHandler = Rc<dyn Fn(&mut App)>; 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> { fn pill(entry: &ToastEntry, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful<gpui::Div> {
let theme = cx.theme(); let theme = cx.theme();
let surface = theme.colors.surface; 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 foreground = theme.colors.foreground;
let muted = theme.colors.muted; 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 radius = theme.radius.full;
let shadows = theme.elevations.popover.shadows(); let shadows = theme.elevations.popover.shadows();
let icon_size = theme.sizes.small.icon_size; 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| { .when_some(toast.action.clone(), |pill, action| {
let toasts = toasts.clone(); let toasts = toasts.clone();
pill.child( pill.child(
div() div().flex_none().ml_2p5().child(
.id(("toast-action", id)) Button::new(("toast-action", id))
.flex_none() .label(action.label.clone())
.ml_1p5() .variant(ButtonVariant::Primary)
.cursor_pointer() .small()
.px_2() .on_click(move |_, _window, cx| {
.py_0p5() (action.handler.clone())(cx);
.rounded(radius) toasts.update(cx, |toasts, cx| {
.bg(primary) toasts.dismiss(id, cx);
.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);
});
}),
) )
}) })
.child( .child(
@@ -331,7 +326,7 @@ fn pill(entry: &ToastEntry, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful
.flex_none() .flex_none()
.ml_4() .ml_4()
.cursor_pointer() .cursor_pointer()
.p_0p5() .p_1()
.rounded(theme.radius.sm) .rounded(theme.radius.sm)
.hover(|style| style.bg(muted)) .hover(|style| style.bg(muted))
.child( .child(
+100 -16
View File
@@ -1,13 +1,17 @@
use gpui::AppContext as _; use gpui::AppContext as _;
use gpui::{ use gpui::{
AnyView, App, Context, Entity, IntoElement, ParentElement as _, Render, SharedString, AnyView, App, Bounds, Context, ElementId, Entity, InteractiveElement as _, IntoElement,
Styled as _, Window, div, px, 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. /// A small text panel gpui shows next to a hovered element.
pub struct Tooltip { pub struct Tooltip {
text: SharedString, text: SharedString,
anchor: Option<Bounds<Pixels>>,
} }
impl Tooltip { impl Tooltip {
@@ -16,7 +20,28 @@ impl Tooltip {
where where
L: Into<SharedString>, 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. /// The `.tooltip(...)` closure for a plain text tooltip.
@@ -30,20 +55,79 @@ impl Tooltip {
} }
impl Render for 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 theme = cx.theme();
let text_size = theme.sizes.small.text_size; 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() let border = if theme.borders.overlays {
.px_2() theme.colors.border
.py_1() } else {
.max_w(px(360.)) theme.colors.surface
.bg(theme.colors.surface) };
.border_1()
.border_color(theme.colors.border) let build_panel = || {
.rounded(theme.radius.sm) div()
.text_size(text_size) .debug_selector(|| "tooltip-panel".to_string())
.text_color(theme.colors.surface_foreground) .px_2()
.child(self.text.clone()) .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 /// Clicking the close button is how the user acts on the alert; the banner
/// reports the intent instead of hiding itself. /// reports the intent instead of hiding itself.
#[test] #[test]
@@ -94,20 +68,3 @@ fn closing_the_alert_reports_dismissal() {
assert!(*dismissed.borrow(), "the close button reported 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 { struct Harness {
clicks: Rc<RefCell<usize>>, clicks: Rc<RefCell<usize>>,
disabled: bool, disabled: bool,
loading: bool,
} }
impl Render for Harness { impl Render for Harness {
@@ -20,14 +21,18 @@ impl Render for Harness {
Button::new("test-button") Button::new("test-button")
.label("Save") .label("Save")
.disabled(self.disabled) .disabled(self.disabled)
.loading(self.loading)
.on_click(move |_, _, _| *clicks.borrow_mut() += 1), .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. /// `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(); let mut cx = TestAppContext::single();
cx.update(Theme::init); cx.update(Theme::init);
let clicks = Rc::new(RefCell::new(0)); 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 { let window = cx.add_window(move |_, _| Harness {
clicks: harness_clicks, clicks: harness_clicks,
disabled, disabled,
loading,
}); });
let window = window.into(); let window = window.into();
cx.update_window(window, |_, window, cx| { cx.update_window(window, |_, window, cx| {
@@ -53,21 +59,28 @@ fn draw(cx: &mut VisualTestContext, window: AnyWindowHandle) {
#[test] #[test]
fn pointer_click_fires_on_click() { 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()); cx.simulate_click(point(px(60.), px(14.)), Modifiers::default());
assert_eq!(*clicks.borrow(), 1); assert_eq!(*clicks.borrow(), 1);
} }
#[test] #[test]
fn disabled_button_ignores_clicks() { 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()); cx.simulate_click(point(px(60.), px(14.)), Modifiers::default());
assert_eq!(*clicks.borrow(), 0); assert_eq!(*clicks.borrow(), 0);
} }
#[test] #[test]
fn enter_and_space_fire_on_click_while_focused() { 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()); cx.simulate_click(point(px(60.), px(14.)), Modifiers::default());
assert_eq!(*clicks.borrow(), 1, "the pointer click itself activates"); 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 std::rc::Rc;
use gpui::{ use gpui::{
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString, AppContext as _, Context, Entity, IntoElement, MouseButton, ParentElement as _, Render,
Styled as _, TestAppContext, VisualTestContext, Window, div, SharedString, Styled as _, TestAppContext, VisualTestContext, Window, div, point, px,
}; };
use gpui_native_core::combobox::{Combobox, ComboboxOption, ComboboxState}; use gpui_native_core::combobox::{Combobox, ComboboxOption, ComboboxState};
use gpui_native_core::init; 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) { fn focus(host: &mut ComboHost) {
let state = host.state.clone(); let state = host.state.clone();
host.visual.update(|window, cx| { 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::button::{Button, ButtonVariant};
use gpui_native_core::input::{Input, InputState}; use gpui_native_core::input::{Input, InputState};
use gpui_native_theme::ActiveTheme as _; use gpui_native_theme::ActiveTheme as _;
use gpui_native_theme::Theme; use gpui_native_theme::{BORDER_WIDTH, Theme};
struct Harness { struct Harness {
state: Entity<InputState>, state: Entity<InputState>,
@@ -36,10 +36,12 @@ impl Render for Harness {
} }
} }
/// A medium input is exactly `input_height`, and sits taller than the /// The medium input and button render at the stated height plus the
/// medium button: native text fields are a touch taller than push buttons. /// 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] #[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); cx.update(Theme::init);
let state = cx.update(|cx| InputState::new_with_text("", cx)); let state = cx.update(|cx| InputState::new_with_text("", cx));
let state_for_window = state.clone(); 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") .debug_bounds("input-wrapper")
.expect("input rendered"); .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. The
// both bounds are snapped to device pixels, so allow half a pixel. // stated height is the interior; the border sizes outside it.
let input_height = cx.update(|cx| cx.theme().sizes.medium.input_height); let input_height = cx.update(|cx| cx.theme().sizes.medium.input_height) + BORDER_WIDTH * 2.0;
assert!(input.size.height > button.size.height); 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!((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.))); click(&mut visual, point(px(960.), px(540.)));
assert!(changes.borrow().is_empty()); 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::{ use gpui::{
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
Styled as _, TestAppContext, VisualTestContext, Window, div, Styled as _, TestAppContext, VisualTestContext, Window, div,
@@ -13,12 +9,10 @@ use gpui_native_core::input::{Input, InputState};
struct FieldHost { struct FieldHost {
url: Entity<InputState>, url: Entity<InputState>,
renders: Rc<RefCell<usize>>,
} }
impl Render for FieldHost { impl Render for FieldHost {
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 {
*self.renders.borrow_mut() += 1;
let url_error = self let url_error = self
.url .url
.read(cx) .read(cx)
@@ -38,76 +32,48 @@ impl Render for FieldHost {
} }
} }
/// A field page in a real window, with the url input focused. /// A field page in a real window; the url input is focused unless
fn field_host() -> ( /// `focus` is false, which leaves the page truly idle.
TestAppContext, fn field_host(focus: bool) -> (TestAppContext, Entity<InputState>, VisualTestContext) {
Entity<InputState>,
Rc<RefCell<usize>>,
VisualTestContext,
) {
let cx = TestAppContext::single(); let cx = TestAppContext::single();
cx.update(init); cx.update(init);
let (url, renders, window) = cx.update(|cx| { let (url, window) = cx.update(|cx| {
let renders = Rc::new(RefCell::new(0));
let url = InputState::new(cx); let url = InputState::new(cx);
let url_for_host = url.clone(); let url_for_host = url.clone();
let renders_for_host = renders.clone();
let window = cx let window = cx
.open_window(gpui::WindowOptions::default(), |_, cx| { .open_window(gpui::WindowOptions::default(), |_, cx| {
cx.new(|_| FieldHost { cx.new(|_| FieldHost { url: url_for_host })
url: url_for_host,
renders: renders_for_host,
})
}) })
.unwrap(); .unwrap();
(url, renders, window) (url, window)
}); });
let mut visual = VisualTestContext::from_window(window.into(), &cx); let mut visual = VisualTestContext::from_window(window.into(), &cx);
visual.update(|window, cx| { visual.update(|window, cx| {
let focus = url.read(cx).focus_handle(); if focus {
window.focus(&focus, cx); let focus = url.read(cx).focus_handle();
window.focus(&focus, cx);
}
window.draw(cx).clear(cx); window.draw(cx).clear(cx);
}); });
(cx, url, renders, visual) (cx, url, 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
} }
#[test] #[test]
fn an_idle_field_page_schedules_no_frames() { 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)); let frames = visual.update(|window, cx| window.simulate_next_frame(cx));
assert_eq!( assert_eq!(
frames, 0, frames, 0,
"an idle field page must not request animation frames" "an idle unfocused field page must not request animation frames"
); );
} }
#[test] #[test]
fn field_typing_frame_cost() { fn a_focused_field_page_schedules_frames_for_its_blink() {
let (_cx, _url, _renders, mut visual) = field_host(); let (_cx, _url, mut visual) = field_host(true);
let elapsed = type_and_time(&mut visual, "dsdsadsadadadada"); let frames = visual.update(|window, cx| window.simulate_next_frame(cx));
assert!( assert!(
elapsed.as_millis() < 1000, frames > 0,
"typing 16 characters took {elapsed:?}" "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 _, MouseUpEvent, ParentElement as _, Render, ScrollDelta, ScrollEvent, Styled as _,
TestAppContext, VisualTestContext, Window, div, point, px, 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::{ use gpui_native_core::input::{
INPUT_CONTEXT, Input, InputBindings, InputState, InputStateEvent, Submit, SubmitOn, INPUT_CONTEXT, Input, InputBindings, InputState, InputStateEvent, Submit, SubmitOn,
bind_input_keys, bind_input_keys,
}; };
use gpui_native_theme::Theme; use gpui_native_icons::IconRole;
use std::cell::RefCell; use gpui_native_theme::{BORDER_WIDTH, Size, Theme};
use std::cell::{Cell, RefCell};
use std::rc::Rc; use std::rc::Rc;
use std::time::Duration; use std::time::Duration;
@@ -266,42 +269,45 @@ fn read_only_refuses_edits_but_keeps_selection_and_copy() {
assert_eq!(clipboard, "lo"); assert_eq!(clipboard, "lo");
// The caret is suppressed entirely while read-only. // 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); assert!(!caret);
} }
#[test] #[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); let (cx, state, mut visual) = input_window("", false);
focus(&mut visual, &state); focus(&mut visual, &state);
visual.update(|window, cx| { let caret = || cx.update(|cx| state.update(cx, |state, _| state.caret_visible(true)));
window.draw(cx).clear(cx);
});
visual.run_until_parked();
let caret = || cx.update(|cx| state.update(cx, |state, cx| state.caret_visible(true, cx))); // Fresh focus starts a new epoch: the caret shows solid — the elapsed
// time since the epoch began is microseconds, far under one interval.
// 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();
assert!(caret()); 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"); visual.simulate_keystrokes("a");
assert!(caret()); assert!(caret());
cx.executor().advance_clock(Duration::from_millis(500));
visual.run_until_parked();
assert!(!caret());
// An unfocused field reports no caret. // The blink cadence itself: hidden in the second half of the cycle.
let caret_unfocused = let hidden = cx.update(|cx| {
cx.update(|cx| state.update(cx, |state, cx| state.caret_visible(false, cx))); state.update(cx, |state, _| {
assert!(!caret_unfocused); 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] #[test]
@@ -459,15 +465,6 @@ fn disabled_input_ignores_typing_and_clicks() {
assert_eq!(value(&cx, &state), ""); 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 /// Subscribes to `state`'s events, recording each variant into a sink that
/// lives for the rest of the test. The returned `Subscription` must stay /// lives for the rest of the test. The returned `Subscription` must stay
/// bound for the same lifetime — dropping it unsubscribes. /// 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); 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] #[test]
fn pasted_newlines_become_spaces() { fn pasted_newlines_become_spaces() {
let (cx, state, mut visual) = input_window("", false); let (cx, state, mut visual) = input_window("", false);
@@ -568,15 +545,6 @@ fn pasted_newlines_become_spaces() {
assert_eq!(value(&cx, &state), "a bc"); 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] #[test]
fn enter_emits_submitted() { fn enter_emits_submitted() {
let (mut cx, state, mut visual) = input_window("draft", false); 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"); 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] #[test]
fn the_caret_can_rest_on_a_line_break() { fn the_caret_can_rest_on_a_line_break() {
let (cx, state, mut visual) = multiline_window("ab\ncdef"); let (cx, state, mut visual) = multiline_window("ab\ncdef");
@@ -906,3 +904,131 @@ fn vertical_scroll_keeps_the_caret_in_view() {
.unwrap_or_default(); .unwrap_or_default();
assert!(first_row_top < px(0.), "first row top: {first_row_top:?}"); 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()); 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] #[test]
fn arrow_navigation_skips_separators_and_disabled_items() { fn arrow_navigation_skips_separators_and_disabled_items() {
let (_cx, mut visual, _window, changes, activated) = menu_window(true); 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]); 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] #[test]
fn clicking_an_item_activates_and_closes() { fn clicking_an_item_activates_and_closes() {
let (_cx, mut visual, _window, changes, activated) = menu_window(true); 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!(*activated.borrow(), vec!["cut"]);
assert_eq!(*changes.borrow(), vec![false]); 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 std::{cell::RefCell, rc::Rc};
use gpui::{ use gpui::{
Context, IntoElement, Modifiers, MouseButton, ParentElement as _, Render, Styled as _, Context, InteractiveElement as _, IntoElement, Modifiers, MouseButton, ParentElement as _,
TestAppContext, VisualTestContext, Window, WindowHandle, div, point, px, rgba, Render, StatefulInteractiveElement as _, Styled as _, TestAppContext, VisualTestContext,
Window, WindowHandle, div, point, px, rgba,
}; };
use gpui_native_core::popover::Popover; use gpui_native_core::popover::Popover;
use gpui_native_theme::Theme; use gpui_native_theme::Theme;
@@ -10,37 +11,54 @@ use gpui_native_theme::Theme;
struct Harness { struct Harness {
open: bool, open: bool,
changes: Rc<RefCell<Vec<bool>>>, changes: Rc<RefCell<Vec<bool>>>,
target_clicks: Rc<RefCell<usize>>,
} }
impl Render for Harness { impl Render for Harness {
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 {
let changes = self.changes.clone(); let changes = self.changes.clone();
let target_clicks = self.target_clicks.clone();
let view = cx.entity(); let view = cx.entity();
div().size_full().child( div()
Popover::new("test-popover") .size_full()
.open(self.open) .child(
.on_change(move |next, _, cx| { div().absolute().left(px(400.)).top(px(300.)).child(
changes.borrow_mut().push(next);
view.update(cx, |harness, cx| {
harness.open = next;
cx.notify();
});
})
.trigger(|_, _| {
div() div()
.w(px(100.)) .id("popover-target")
.h(px(32.)) .size(px(40.))
.bg(rgba(0x000000ff)) .bg(rgba(0x000000ff))
.into_any_element() .on_click(move |_, _, cx| {
}) *target_clicks.borrow_mut() += 1;
.content(|_, _| { cx.stop_propagation();
div() }),
.w(px(200.)) ),
.h(px(80.)) )
.child("Popover content") .child(
.into_any_element() 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 /// 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)` /// on the trigger, `(100, 70)` on the panel, and anything past `(300, 300)`
/// is outside both. /// is outside both.
fn popover_window( type PopoverWindow = (
open: bool,
) -> (
TestAppContext, TestAppContext,
VisualTestContext, VisualTestContext,
WindowHandle<Harness>, WindowHandle<Harness>,
Rc<RefCell<Vec<bool>>>, Rc<RefCell<Vec<bool>>>,
) { Rc<RefCell<usize>>,
);
fn popover_window(open: bool) -> PopoverWindow {
let mut cx = TestAppContext::single(); let mut cx = TestAppContext::single();
cx.update(Theme::init); cx.update(Theme::init);
let changes = Rc::new(RefCell::new(Vec::new())); let changes = Rc::new(RefCell::new(Vec::new()));
let harness_changes = changes.clone(); 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 { let window = cx.add_window(move |_, _| Harness {
open, open,
changes: harness_changes, changes: harness_changes,
target_clicks: harness_clicks,
}); });
let mut visual = VisualTestContext::from_window(window.into(), &cx); let mut visual = VisualTestContext::from_window(window.into(), &cx);
visual.update(|window, cx| { visual.update(|window, cx| {
window.draw(cx).clear(cx); window.draw(cx).clear(cx);
}); });
(cx, visual, window, changes) (cx, visual, window, changes, target_clicks)
} }
fn draw(visual: &mut VisualTestContext) { fn draw(visual: &mut VisualTestContext) {
@@ -84,7 +106,7 @@ fn click(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
#[test] #[test]
fn clicking_outside_dismisses_and_reports_closed() { 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.))); click(&mut visual, point(px(600.), px(600.)));
assert_eq!(*changes.borrow(), vec![false]); assert_eq!(*changes.borrow(), vec![false]);
@@ -98,7 +120,7 @@ fn clicking_outside_dismisses_and_reports_closed() {
#[test] #[test]
fn clicking_the_trigger_toggles_and_clicking_the_panel_does_nothing() { 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. // On the open panel's trigger: toggles closed.
click(&mut visual, point(px(10.), px(16.))); click(&mut visual, point(px(10.), px(16.)));
@@ -111,7 +133,7 @@ fn clicking_the_trigger_toggles_and_clicking_the_panel_does_nothing() {
#[test] #[test]
fn clicking_the_trigger_opens_a_closed_popover() { 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.))); click(&mut visual, point(px(10.), px(16.)));
assert_eq!(*changes.borrow(), vec![true]); assert_eq!(*changes.borrow(), vec![true]);
@@ -119,7 +141,7 @@ fn clicking_the_trigger_opens_a_closed_popover() {
#[test] #[test]
fn escape_dismisses_while_the_panel_holds_focus() { 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); draw(&mut visual);
// The panel claims focus on open, so escape reaches its key listener. // 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] #[test]
fn the_controlled_loop_reopens_from_the_app_side() { 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. // Dismiss once.
click(&mut visual, point(px(600.), px(600.))); 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"); visual.simulate_keystrokes("escape");
assert_eq!(*changes.borrow(), vec![false, false]); 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.)); 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 /// The gallery's exact shell: a window-filling root, a window-filling scroll
/// area over padded flex-column content that towers past the viewport. /// area over padded flex-column content that towers past the viewport.
struct GalleryShell { 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] 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 std::{cell::RefCell, rc::Rc};
use gpui::{ use gpui::{
Context, IntoElement, MouseButton, ParentElement as _, Render, Styled as _, TestAppContext, Context, Entity, IntoElement, MouseButton, ParentElement as _, Render, Styled as _,
VisualTestContext, Window, div, point, px, TestAppContext, VisualTestContext, Window, div, point, px,
}; };
use gpui_native_core::input::{Input, InputState};
use gpui_native_core::select::Select; 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)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Strategy { enum Strategy {
@@ -200,3 +201,80 @@ fn two_selects_keep_their_popups_independent() {
assert_eq!(*first_changes.borrow(), vec![]); assert_eq!(*first_changes.borrow(), vec![]);
assert_eq!(*second_changes.borrow(), vec![Some(Strategy::Curated)]); 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()); 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 /// The three option rows must answer a pointer press wherever they were laid
/// inner padding, so the three rows ride `~58..90`, `~90..122`, `~122..154`. /// out — a popup that clips its last item never does. Row positions come from
/// Every option must answer a pointer press — a popup that clips its last /// the rendered bounds, so the stated menu row metric can change freely.
/// item never does.
#[test] #[test]
fn every_option_row_answers_the_pointer() { fn every_option_row_answers_the_pointer() {
let (_cx, mut visual, changes) = level_window(); let (_cx, mut visual, changes) = level_window();
let expected = [ // Open once to measure where the rows were actually drawn.
(px(74.), Level::Low), click(&mut visual, point(px(100.), px(31.)));
(px(106.), Level::Mid), visual.update(|window, cx| {
(px(138.), Level::High), window.draw(cx).clear(cx);
]; });
for (y, value) in expected { 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.))); click(&mut visual, point(px(100.), px(31.)));
visual.update(|window, cx| { visual.update(|window, cx| {
window.draw(cx).clear(cx); window.draw(cx).clear(cx);
}); });
click(&mut visual, point(px(100.), y)); click(&mut visual, centers[index]);
visual.update(|window, cx| { visual.update(|window, cx| {
window.draw(cx).clear(cx); window.draw(cx).clear(cx);
}); });
-13
View File
@@ -45,16 +45,3 @@ fn the_dot_is_half_the_icon_size() {
dot.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()); 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] #[test]
fn enter_and_space_report_the_next_value_while_focused() { fn enter_and_space_report_the_next_value_while_focused() {
let (mut cx, window, changes) = switch_window(false, false); let (mut cx, window, changes) = switch_window(false, false);
+105 -12
View File
@@ -1,17 +1,18 @@
use std::cell::RefCell; use std::{cell::RefCell, rc::Rc};
use std::rc::Rc;
use gpui::{ use gpui::{
Context, InteractiveElement as _, IntoElement, Modifiers, MouseMoveEvent, ParentElement as _, Context, InteractiveElement as _, IntoElement, Modifiers, MouseMoveEvent, ParentElement as _,
Render, StatefulInteractiveElement as _, Styled as _, TestAppContext, VisualTestContext, 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::button::Button;
use gpui_native_core::tooltip::Tooltip; use gpui_native_core::tooltip::Tooltip;
use gpui_native_theme::Theme; 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 /// 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 { struct Harness {
calls: Rc<RefCell<Vec<&'static str>>>, calls: Rc<RefCell<Vec<&'static str>>>,
} }
@@ -21,20 +22,26 @@ impl Render for Harness {
let calls = self.calls.clone(); let calls = self.calls.clone();
let target = div() let target = div()
.id("hover-target") .id("hover-target")
.debug_selector(|| "hover-target".to_string())
.w(px(100.)) .w(px(100.))
.h(px(40.)) .h(px(40.))
.bg(gpui::black()) .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"); 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_calls = self.calls.clone();
let button = Button::new("hover-button") let button = Button::new("hover-button")
.label("Hover me") .label("Hover me")
.tooltip(move |_, cx| { .tooltip(move |window, cx| {
button_calls.borrow_mut().push("button"); button_calls.borrow_mut().push("button");
Tooltip::new("Button tooltip", cx).into() Tooltip::anchored("Button tooltip", "hover-button")(window, cx)
}); });
div() 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] #[test]
fn hovering_the_target_deploys_the_tooltip_after_the_delay() { fn hovering_the_target_deploys_the_tooltip_after_the_delay() {
let (cx, mut visual, calls) = tooltip_window(); let (cx, mut visual, calls) = tooltip_window();
@@ -109,12 +150,64 @@ fn hovering_elsewhere_deploys_nothing() {
assert!(calls.borrow().is_empty()); 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] #[test]
fn the_button_passthrough_deploys_the_tooltip() { fn the_anchored_panel_hangs_below_the_parent_without_overlapping() {
let (cx, mut visual, calls) = tooltip_window(); let (cx, mut visual, _calls) = tooltip_window();
hover(&mut visual, point(px(50.), px(100.))); hover(&mut visual, point(px(50.), px(20.)));
cx.executor().advance_clock(SHOW_DELAY); cx.executor().advance_clock(SHOW_DELAY);
visual.run_until_parked(); visual.run_until_parked();
draw(&mut visual); 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"
);
} }
-10
View File
@@ -100,13 +100,3 @@ fn arrow_keys_skip_disabled_tabs_and_never_wrap() {
visual.simulate_keystrokes("end"); // End visual.simulate_keystrokes("end"); // End
assert_eq!(*changes.borrow(), vec![1, 3, 1, 0, 3]); assert_eq!(*changes.borrow(), vec![1, 3, 1, 0, 3]);
} }
#[test]
fn clicking_a_different_tab_reports_once() {
let (_cx, mut visual, changes) = tabs_window();
click(&mut visual, point(px(20.), px(12.))); // One: no change, just focus
click(&mut visual, point(px(62.), px(12.))); // Two
click(&mut visual, point(px(62.), px(12.))); // Two again: no change
assert_eq!(*changes.borrow(), vec![1]);
}
-14
View File
@@ -175,17 +175,3 @@ fn keyboard_navigation_skips_disabled_rows_and_never_wraps() {
] ]
); );
} }
#[test]
fn disclosure_keys_skip_disabled_and_unselected_rows() {
let (_cx, mut visual, _window, changes, disclosures) = tree_window();
// Focus through the first row, then park the selection on the leaf.
click(&mut visual, point(px(50.), px(16.)));
visual.simulate_keystrokes("down"); // Child (leaf), focused tree
visual.simulate_keystrokes("left"); // leaf: no disclosure
visual.simulate_keystrokes("right"); // leaf: no disclosure
draw(&mut visual);
assert!(disclosures.borrow().is_empty());
assert_eq!(*changes.borrow(), vec![Some(0), Some(1)]);
}
-6
View File
@@ -226,12 +226,6 @@ fn rows_render_virtualized_and_the_view_jumps_to_the_first_change() {
); );
} }
#[test]
fn identical_versions_produce_no_rows() {
let rows = diff_rows(OLD, OLD, Language::Rust);
assert!(rows.is_empty(), "identical versions have no diff to show");
}
#[test] #[test]
fn set_versions_re_diffs() { fn set_versions_re_diffs() {
let mut cx = TestAppContext::single(); let mut cx = TestAppContext::single();
-7
View File
@@ -241,13 +241,6 @@ fn nested_code_and_tables_carry_indent_and_quote() {
)); ));
} }
#[test]
fn task_markers_carry_their_checked_state() {
let blocks = parse_body("- [ ] todo\n- [x] done\n");
assert_eq!(item_of(&blocks, 0).0, ListMarker::Task(false));
assert_eq!(item_of(&blocks, 1).0, ListMarker::Task(true));
}
#[test] #[test]
fn quotes_mark_their_paragraphs_and_lists() { fn quotes_mark_their_paragraphs_and_lists() {
let blocks = parse_body("> quoted\n>\n> - listed\n\nplain"); let blocks = parse_body("> quoted\n>\n> - listed\n\nplain");
-8
View File
@@ -392,12 +392,4 @@ mod tests {
); );
std::fs::remove_dir_all(&base).expect("cleanup"); std::fs::remove_dir_all(&base).expect("cleanup");
} }
#[test]
fn every_role_states_freedesktop_candidates() {
for role in crate::IconRole::all() {
assert!(!role.freedesktop_names().is_empty());
assert!(!role.slug().is_empty());
}
}
} }
-17
View File
@@ -367,21 +367,4 @@ mod tests {
); );
} }
} }
#[test]
fn freedesktop_asset_keys_round_trip() {
let role = IconRole::ChevronDown;
let key = format!("icons/freedesktop/{}-16.svg", role.slug());
let stem = key
.strip_prefix("icons/freedesktop/")
.unwrap()
.strip_suffix(".svg")
.unwrap();
let (slug, parsed_size) = stem.rsplit_once('-').unwrap();
let resolved = IconRole::all()
.iter()
.find(|candidate| candidate.slug() == slug);
assert_eq!(resolved, Some(&role));
assert_eq!(parsed_size, "16");
}
} }
-5
View File
@@ -49,9 +49,4 @@ mod tests {
); );
} }
} }
#[test]
fn the_close_role_uses_the_chrome_close_glyph() {
assert_eq!(glyph(IconRole::Close), Some('\u{E8BB}'));
}
} }
+5 -1
View File
@@ -11,6 +11,8 @@ use crate::ThemeVariant;
pub struct ThemeDefinition { pub struct ThemeDefinition {
pub mode: Option<ThemeVariant>, pub mode: Option<ThemeVariant>,
pub control_radius: Option<f32>, pub control_radius: Option<f32>,
pub overlay_radius: Option<f32>,
pub sm_radius: Option<f32>,
pub accent: Option<String>, pub accent: Option<String>,
pub accent_foreground: Option<String>, pub accent_foreground: Option<String>,
pub background: Option<String>, pub background: Option<String>,
@@ -221,13 +223,15 @@ mod tests {
#[test] #[test]
fn parses_a_full_definition() { fn parses_a_full_definition() {
let def = ThemeDefinition::parse_toml( let def = ThemeDefinition::parse_toml(
"mode = \"dark\"\naccent = \"#7aa2f7\"\nbackground = \"#1a1b26\"\ninfo = \"#38bdf8\"\n", "mode = \"dark\"\naccent = \"#7aa2f7\"\nbackground = \"#1a1b26\"\ninfo = \"#38bdf8\"\noverlay_radius = 15.0\nsm_radius = 6.0\n",
) )
.unwrap(); .unwrap();
assert_eq!(def.mode, Some(crate::ThemeVariant::Dark)); assert_eq!(def.mode, Some(crate::ThemeVariant::Dark));
assert!(def.accent().is_some()); assert!(def.accent().is_some());
assert!(def.info().is_some()); assert!(def.info().is_some());
assert!(def.foreground().is_none()); assert!(def.foreground().is_none());
assert_eq!(def.overlay_radius, Some(15.0));
assert_eq!(def.sm_radius, Some(6.0));
} }
#[test] #[test]
+138 -18
View File
@@ -23,6 +23,10 @@ pub use system::{AccessibilityPreferences, PartialPalette, SourceKind, SystemThe
use gpui::{App, Global, Hsla, Pixels, WindowAppearance, px}; use gpui::{App, Global, Hsla, Pixels, WindowAppearance, px};
use std::time::Duration; use std::time::Duration;
/// The hairline width of the control border every button and field paints;
/// sized outside the stated metrics so control sizes match across platforms.
pub const BORDER_WIDTH: Pixels = px(1.0);
/// Light or dark, resolved from the system appearance. /// Light or dark, resolved from the system appearance.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@@ -48,6 +52,9 @@ pub struct Theme {
pub colors: ColorTokens, pub colors: ColorTokens,
pub ramps: Ramps, pub ramps: Ramps,
pub radius: Radius, pub radius: Radius,
/// Per-platform border and focus chrome.
pub borders: Borders,
pub spacing: Spacing,
pub elevations: Elevations, pub elevations: Elevations,
/// Overlay transition timing; gpui's animation primitives do the rest. /// Overlay transition timing; gpui's animation primitives do the rest.
pub motion: Motion, pub motion: Motion,
@@ -88,6 +95,59 @@ impl Default for Radius {
} }
} }
/// Which surfaces keep a 1px border, per the platform's chrome: Windows and
/// KDE frame their controls, macOS and GNOME paint fills.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Borders {
/// Filled actions keep a border (Windows, KDE).
pub buttons: bool,
/// Text fields and selects keep a border.
pub fields: bool,
/// Floating layers keep a border.
pub overlays: bool,
/// Keyboard focus paints an accent ring around the control.
pub focus_ring: bool,
}
impl Borders {
pub(crate) fn for_platform(source: SourceKind, variant: ThemeVariant) -> Self {
let overlays = variant == ThemeVariant::Dark;
match source {
SourceKind::Windows | SourceKind::Kde => Self {
buttons: true,
fields: true,
overlays: true,
focus_ring: false,
},
SourceKind::Macos => Self {
buttons: false,
fields: true,
overlays,
focus_ring: true,
},
SourceKind::Omarchy | SourceKind::Gnome => Self {
buttons: false,
fields: false,
overlays,
focus_ring: true,
},
SourceKind::Fallback => Self {
buttons: true,
fields: true,
overlays: true,
focus_ring: false,
},
}
}
}
/// The spacing rhythm of the platform: paddings and gaps step by this unit.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Spacing {
pub unit: Pixels,
}
/// One layer of a floating panel's drop shadow. /// One layer of a floating panel's drop shadow.
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct Shadow { pub struct Shadow {
@@ -129,6 +189,13 @@ pub struct Elevation {
} }
impl Elevation { impl Elevation {
/// No shadow: dark floating layers separate by border.
fn none() -> Self {
Self {
layers: [Shadow::none(), Shadow::none()],
}
}
fn single(key: Shadow) -> Self { fn single(key: Shadow) -> Self {
Self { Self {
layers: [key, Shadow::none()], layers: [key, Shadow::none()],
@@ -172,8 +239,8 @@ impl Elevations {
pub(crate) fn for_platform(source: SourceKind, variant: ThemeVariant) -> Self { pub(crate) fn for_platform(source: SourceKind, variant: ThemeVariant) -> Self {
if variant == ThemeVariant::Dark { if variant == ThemeVariant::Dark {
return Self { return Self {
popover: Elevation::single(Shadow::black(0., 4., 24., 0.3)), popover: Elevation::none(),
dialog: Elevation::single(Shadow::black(0., 8., 40., 0.35)), dialog: Elevation::none(),
control: Elevation::single(Shadow::black(0., 1., 2., 0.25)), control: Elevation::single(Shadow::black(0., 1., 2., 0.25)),
}; };
} }
@@ -283,12 +350,18 @@ pub struct ControlMetrics {
pub avatar_size: Pixels, pub avatar_size: Pixels,
/// Dense label text. /// Dense label text.
pub caption_size: Pixels, pub caption_size: Pixels,
/// Tight horizontal padding inside pills, segments, and fields. /// Tight horizontal padding inside pills, segments, fields, and icon-only buttons.
pub dense_padding_x: Pixels, pub dense_padding_x: Pixels,
/// The inset between a control's edge and a raised inner plate. /// The inset between a control's edge and a raised inner plate.
pub plate_inset: Pixels, pub plate_inset: Pixels,
/// The band height of multi-segment strips. /// The band height of multi-segment strips.
pub strip_height: Pixels, pub strip_height: Pixels,
/// The height of one menu row.
pub menu_item_height: Pixels,
/// Switch track height, width, and knob size.
pub switch_height: Pixels,
pub switch_width: Pixels,
pub switch_knob: Pixels,
} }
/// The control metrics for every size class. /// The control metrics for every size class.
@@ -309,6 +382,7 @@ impl SizeTable {
avatar: f32, avatar: f32,
strip: f32, strip: f32,
inset: f32, inset: f32,
floor_icon: bool,
} }
let small = ClassFactors { let small = ClassFactors {
@@ -319,6 +393,7 @@ impl SizeTable {
avatar: 1.5, avatar: 1.5,
strip: 1.5, strip: 1.5,
inset: 0.125, inset: 0.125,
floor_icon: false,
}; };
let medium = ClassFactors { let medium = ClassFactors {
@@ -329,6 +404,7 @@ impl SizeTable {
avatar: 2.0, avatar: 2.0,
strip: 2.0, strip: 2.0,
inset: 0.25, inset: 0.25,
floor_icon: true,
}; };
let large = ClassFactors { let large = ClassFactors {
@@ -339,21 +415,34 @@ impl SizeTable {
avatar: 3.0, avatar: 3.0,
strip: 3.0, strip: 3.0,
inset: 0.375, inset: 0.375,
floor_icon: true,
}; };
let metrics = |f: &ClassFactors| ControlMetrics { let metrics = |f: &ClassFactors| {
control_height: rem_size * f.height, let icon_size = if f.floor_icon {
input_height: rem_size * (f.height + 0.125), (rem_size * f.icon).max(px(16.))
padding_x: rem_size * f.padding, } else {
text_size: rem_size * f.text, rem_size * f.icon
icon_size: rem_size * f.icon, };
dense_icon_size: rem_size * f.icon * 0.75, let switch_height = rem_size * f.height * 0.75;
indicator_size: rem_size * f.height * 0.7, ControlMetrics {
avatar_size: rem_size * f.avatar, control_height: rem_size * f.height,
caption_size: rem_size * f.text, input_height: rem_size * (f.height + 0.125),
dense_padding_x: rem_size * f.padding * 0.75, padding_x: rem_size * f.padding,
plate_inset: rem_size * f.inset, text_size: rem_size * f.text,
strip_height: rem_size * f.strip, icon_size,
dense_icon_size: icon_size * 0.75,
indicator_size: rem_size * f.height * 0.7,
avatar_size: rem_size * f.avatar,
caption_size: rem_size * f.text,
dense_padding_x: rem_size * f.padding * 0.75,
plate_inset: rem_size * f.inset,
strip_height: rem_size * f.strip,
menu_item_height: rem_size * f.height * 0.85,
switch_height,
switch_width: switch_height * 2.25,
switch_knob: switch_height * 0.85,
}
}; };
let mut table = Self { let mut table = Self {
@@ -379,9 +468,38 @@ impl SizeTable {
/// Sets the medium class's text-field height to the platform's stated value. /// Sets the medium class's text-field height to the platform's stated value.
pub fn set_input_height(&mut self, medium_input_height: Pixels) { pub fn set_input_height(&mut self, medium_input_height: Pixels) {
let ratio = medium_input_height / self.medium.input_height; let ratio = medium_input_height / self.medium.input_height;
self.small.input_height *= ratio; self.small.input_height = (self.small.input_height * ratio).floor();
self.medium.input_height = medium_input_height; self.medium.input_height = medium_input_height;
self.large.input_height *= ratio; self.large.input_height = (self.large.input_height * ratio).floor();
}
/// Sets the medium class's control height to the platform's stated value.
pub fn set_control_height(&mut self, medium_control_height: Pixels) {
let ratio = medium_control_height / self.medium.control_height;
self.small.control_height = (self.small.control_height * ratio).floor();
self.medium.control_height = medium_control_height;
self.large.control_height = (self.large.control_height * ratio).floor();
}
/// Sets the medium class's menu row height to the platform's stated value.
pub fn set_menu_item_height(&mut self, medium_menu_item_height: Pixels) {
let ratio = medium_menu_item_height / self.medium.menu_item_height;
self.small.menu_item_height *= ratio;
self.medium.menu_item_height = medium_menu_item_height;
self.large.menu_item_height *= ratio;
}
/// Sets the medium class's switch dimensions to the platform's stated values.
pub fn set_switch_metrics(&mut self, track_height: Pixels, track_width: Pixels, knob: Pixels) {
let ratio = track_height / self.medium.switch_height;
for metrics in [&mut self.small, &mut self.large] {
metrics.switch_height *= ratio;
metrics.switch_width *= ratio;
metrics.switch_knob *= ratio;
}
self.medium.switch_height = track_height;
self.medium.switch_width = track_width;
self.medium.switch_knob = knob;
} }
} }
@@ -408,6 +526,8 @@ impl Theme {
colors: resolved.colors, colors: resolved.colors,
ramps: resolved.ramps, ramps: resolved.ramps,
radius: resolved.radius, radius: resolved.radius,
borders: resolved.borders,
spacing: resolved.spacing,
elevations: resolved.elevations, elevations: resolved.elevations,
motion: resolved.motion, motion: resolved.motion,
scrollbar: resolved.scrollbar, scrollbar: resolved.scrollbar,
+167 -42
View File
@@ -1,7 +1,9 @@
use crate::definition::ThemeDefinition; use crate::definition::ThemeDefinition;
use crate::ramps::{AccentShades, Oklch, contrast_foreground, derive_shades, mix}; use crate::ramps::{AccentShades, Oklch, contrast_foreground, derive_shades, mix};
use crate::system::SystemTheme; use crate::system::SystemTheme;
use crate::{ColorTokens, Elevations, Motion, Radius, Scrollbar, SizeTable, ThemeVariant}; use crate::{
Borders, ColorTokens, Elevations, Motion, Radius, Scrollbar, SizeTable, Spacing, ThemeVariant,
};
use gpui::px; use gpui::px;
/// The floor of a custom titlebar's height, in px at 100% scaling: the height /// The floor of a custom titlebar's height, in px at 100% scaling: the height
@@ -26,6 +28,8 @@ pub struct ResolvedTheme {
pub colors: ColorTokens, pub colors: ColorTokens,
pub ramps: Ramps, pub ramps: Ramps,
pub radius: Radius, pub radius: Radius,
pub borders: Borders,
pub spacing: Spacing,
pub elevations: Elevations, pub elevations: Elevations,
pub motion: Motion, pub motion: Motion,
pub scrollbar: Scrollbar, pub scrollbar: Scrollbar,
@@ -198,18 +202,35 @@ pub(crate) fn resolve(
// //
// Fallback: 16 (the web default gpui also uses for rems). // Fallback: 16 (the web default gpui also uses for rems).
let root_font_size = system.root_font_size.unwrap_or(px(16.)); let root_font_size = system.root_font_size.unwrap_or(px(16.));
let mut sizes = SizeTable::with_root_font_size(root_font_size); let mut sizes = SizeTable::with_root_font_size(root_font_size);
if let Some(control_height) = system.control_height {
sizes.set_control_height(control_height);
}
if let Some(input_height) = system.input_height { if let Some(input_height) = system.input_height {
sizes.set_input_height(input_height); sizes.set_input_height(input_height);
} }
if let Some(switch_height) = system.switch_height {
sizes.set_switch_metrics(
switch_height,
system.switch_width.unwrap_or(switch_height * 2.25),
system.switch_knob.unwrap_or(switch_height * 0.85),
);
}
if let Some(menu_item_height) = system.menu_item_height {
sizes.set_menu_item_height(menu_item_height);
}
// A platform that states its titlebar height wins. // A platform that states its titlebar height wins.
let titlebar_height = system let titlebar_height = system
.titlebar_height .titlebar_height
.unwrap_or_else(|| sizes.medium.control_height.max(px(TITLEBAR_HEIGHT_MIN_PX))); .unwrap_or_else(|| sizes.medium.control_height.max(px(TITLEBAR_HEIGHT_MIN_PX)));
let mut radius = Radius::default(); let mut radius = Radius::default();
if let Some(control) = preset if let Some(control) = preset
.and_then(|def| def.control_radius) .and_then(|def| def.control_radius)
.map(px) .map(px)
@@ -218,6 +239,22 @@ pub(crate) fn resolve(
radius.control = control; radius.control = control;
} }
if let Some(overlay) = preset
.and_then(|def| def.overlay_radius)
.map(px)
.or(system.overlay_radius)
{
radius.overlay = overlay;
}
if let Some(sm) = preset
.and_then(|def| def.sm_radius)
.map(px)
.or(system.sm_radius)
{
radius.sm = sm;
}
ResolvedTheme { ResolvedTheme {
variant, variant,
ramps: Ramps { ramps: Ramps {
@@ -238,6 +275,10 @@ pub(crate) fn resolve(
}, },
colors, colors,
radius, radius,
borders: Borders::for_platform(system.source, variant),
spacing: Spacing {
unit: system.source.spacing_unit(),
},
elevations: Elevations::for_platform(system.source, variant), elevations: Elevations::for_platform(system.source, variant),
motion: Motion::default(), motion: Motion::default(),
scrollbar: Scrollbar { scrollbar: Scrollbar {
@@ -283,7 +324,14 @@ mod tests {
accent, accent,
palette, palette,
control_radius: None, control_radius: None,
overlay_radius: None,
sm_radius: None,
control_height: None,
input_height: None, input_height: None,
menu_item_height: None,
switch_height: None,
switch_width: None,
switch_knob: None,
titlebar_height: None, titlebar_height: None,
root_font_size: None, root_font_size: None,
accessibility: AccessibilityPreferences::default(), accessibility: AccessibilityPreferences::default(),
@@ -291,24 +339,14 @@ mod tests {
} }
} }
#[test]
fn metric_priority_is_definition_then_system_then_fallback() {
let mut stated = system(ThemeVariant::Dark, PartialPalette::default(), None);
stated.control_radius = Some(px(4.));
let resolved = resolve(&stated, None, None);
assert_eq!(resolved.radius.control, px(4.));
let def = ThemeDefinition::parse_toml("control_radius = 12.0\n").unwrap();
let overridden = resolve(&stated, Some(&def), None);
assert_eq!(overridden.radius.control, px(12.));
}
#[test] #[test]
fn empty_system_resolves_to_the_neutral_fallback() { fn empty_system_resolves_to_the_neutral_fallback() {
for variant in [ThemeVariant::Light, ThemeVariant::Dark] { for variant in [ThemeVariant::Light, ThemeVariant::Dark] {
let resolved = resolve( let resolved = resolve(
&system(variant, PartialPalette::default(), None), &SystemTheme {
source: SourceKind::Fallback,
..system(variant, PartialPalette::default(), None)
},
None, None,
None, None,
); );
@@ -333,6 +371,21 @@ mod tests {
assert_eq!(resolved.sizes.medium.dense_padding_x, px(16. * 0.75)); assert_eq!(resolved.sizes.medium.dense_padding_x, px(16. * 0.75));
assert_eq!(resolved.sizes.medium.plate_inset, px(4.)); assert_eq!(resolved.sizes.medium.plate_inset, px(4.));
assert_eq!(resolved.sizes.medium.strip_height, px(32.)); assert_eq!(resolved.sizes.medium.strip_height, px(32.));
assert_eq!(resolved.sizes.medium.icon_size, px(16.));
assert_eq!(resolved.sizes.medium.menu_item_height, px(28. * 0.85));
assert_eq!(resolved.sizes.medium.switch_height, px(21.));
assert_eq!(resolved.sizes.medium.switch_width, px(21. * 2.25));
assert_eq!(resolved.sizes.medium.switch_knob, px(21. * 0.85));
assert_eq!(
resolved.borders,
Borders {
buttons: true,
fields: true,
overlays: true,
focus_ring: false,
}
);
assert_eq!(resolved.spacing.unit, px(4.));
assert_eq!(resolved.titlebar_height, px(34.)); assert_eq!(resolved.titlebar_height, px(34.));
} }
} }
@@ -383,17 +436,105 @@ mod tests {
let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None); let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None);
stated.input_height = Some(px(24.)); stated.input_height = Some(px(24.));
let resolved = resolve(&stated, None, None); let resolved = resolve(&stated, None, None);
// The medium class takes the stated height verbatim, and the other
// classes keep their derived ratio to the medium class.
assert_eq!(resolved.sizes.medium.input_height, px(24.)); assert_eq!(resolved.sizes.medium.input_height, px(24.));
// Derive what small would be without the statement, scale it by the
// stated-to-derived ratio, and compare.
let derived = SizeTable::with_root_font_size(px(16.)); let derived = SizeTable::with_root_font_size(px(16.));
let scale = px(24.) / derived.medium.input_height; let scale = px(24.) / derived.medium.input_height;
let expected_small = derived.small.input_height * scale; let expected_small = (derived.small.input_height * scale).floor();
assert!((resolved.sizes.small.input_height - expected_small).abs() < px(0.01)); assert_eq!(resolved.sizes.small.input_height, expected_small);
}
#[test]
fn a_stated_control_height_overrides_the_medium_class_and_scales() {
let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None);
stated.control_height = Some(px(32.));
let resolved = resolve(&stated, None, None);
assert_eq!(resolved.sizes.medium.control_height, px(32.));
let derived = SizeTable::with_root_font_size(px(16.));
let scale = px(32.) / derived.medium.control_height;
let expected_small = (derived.small.control_height * scale).floor();
let expected_large = (derived.large.control_height * scale).floor();
assert_eq!(resolved.sizes.small.control_height, expected_small);
assert_eq!(resolved.sizes.large.control_height, expected_large);
}
#[test]
fn stated_switch_and_menu_metrics_override_the_derived_ones() {
let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None);
stated.switch_height = Some(px(20.));
stated.switch_width = Some(px(40.));
stated.switch_knob = Some(px(12.));
stated.menu_item_height = Some(px(33.));
let resolved = resolve(&stated, None, None);
assert_eq!(resolved.sizes.medium.switch_height, px(20.));
assert_eq!(resolved.sizes.medium.switch_width, px(40.));
assert_eq!(resolved.sizes.medium.switch_knob, px(12.));
assert_eq!(resolved.sizes.medium.menu_item_height, px(33.));
}
#[test]
fn the_radius_ladder_follows_stated_values_with_preset_priority() {
let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None);
stated.control_radius = Some(px(9.));
stated.overlay_radius = Some(px(15.));
stated.sm_radius = Some(px(6.));
let resolved = resolve(&stated, None, None);
assert_eq!(resolved.radius.control, px(9.));
assert_eq!(resolved.radius.overlay, px(15.));
assert_eq!(resolved.radius.sm, px(6.));
let def = ThemeDefinition::parse_toml("overlay_radius = 12.0\n").unwrap();
let overridden = resolve(&stated, Some(&def), None);
assert_eq!(overridden.radius.overlay, px(12.));
assert_eq!(overridden.radius.sm, px(6.));
}
#[test]
fn borders_and_spacing_follow_the_platform_source() {
let chrome = |source| {
let mut stated = system(ThemeVariant::Light, PartialPalette::default(), None);
stated.source = source;
let resolved = resolve(&stated, None, None);
(resolved.borders, resolved.spacing.unit)
};
let (borders, unit) = chrome(SourceKind::Windows);
assert!(borders.buttons && borders.fields && borders.overlays && !borders.focus_ring);
assert_eq!(unit, px(4.));
let (borders, unit) = chrome(SourceKind::Kde);
assert!(borders.buttons && borders.fields && borders.overlays && !borders.focus_ring);
assert_eq!(unit, px(4.));
let (borders, unit) = chrome(SourceKind::Macos);
assert!(!borders.buttons && borders.fields && !borders.overlays && borders.focus_ring);
assert_eq!(unit, px(4.));
for source in [SourceKind::Gnome, SourceKind::Omarchy] {
let (borders, unit) = chrome(source);
assert!(!borders.buttons && !borders.fields && !borders.overlays && borders.focus_ring);
assert_eq!(unit, px(6.));
}
let (borders, unit) = chrome(SourceKind::Fallback);
assert!(borders.buttons && borders.fields && borders.overlays && !borders.focus_ring);
assert_eq!(unit, px(4.));
}
#[test]
fn dark_mode_separates_overlays_by_border() {
for source in [SourceKind::Macos, SourceKind::Gnome, SourceKind::Omarchy] {
let mut stated = system(ThemeVariant::Dark, PartialPalette::default(), None);
stated.source = source;
let resolved = resolve(&stated, None, None);
assert!(
resolved.borders.overlays,
"{source:?} frames floating layers in dark"
);
assert!(resolved.elevations.popover.shadows().is_empty());
assert!(resolved.elevations.dialog.shadows().is_empty());
}
} }
#[test] #[test]
@@ -426,7 +567,7 @@ mod tests {
#[test] #[test]
fn elevations_and_motion_resolve_with_defaults() { fn elevations_and_motion_resolve_with_defaults() {
let resolved = resolve( let resolved = resolve(
&system(ThemeVariant::Dark, PartialPalette::default(), None), &system(ThemeVariant::Light, PartialPalette::default(), None),
None, None,
None, None,
); );
@@ -438,7 +579,7 @@ mod tests {
); );
assert!(resolved.motion.overlay > std::time::Duration::ZERO); assert!(resolved.motion.overlay > std::time::Duration::ZERO);
let shadows = resolved.elevations.popover.shadows(); let shadows = resolved.elevations.popover.shadows();
assert_eq!(shadows.len(), 1); assert_eq!(shadows.len(), 2);
assert!(!shadows[0].inset); assert!(!shadows[0].inset);
} }
@@ -666,20 +807,4 @@ mod tests {
let preset_stated = resolve(&system(ThemeVariant::Dark, palette, None), Some(&def), None); let preset_stated = resolve(&system(ThemeVariant::Dark, palette, None), Some(&def), None);
assert_eq!(preset_stated.colors.info, hsla(0x0ea5e9)); assert_eq!(preset_stated.colors.info, hsla(0x0ea5e9));
} }
#[test]
fn muted_hover_follows_the_overlay_chain() {
// No platform states a hover wash; a preset or user file is the only
// override above the neutral base.
let palette = PartialPalette {
muted_hover: Some(hsla(0x155e75)),
..PartialPalette::default()
};
let stated = resolve(&system(ThemeVariant::Dark, palette, None), None, None);
assert_eq!(stated.colors.muted_hover, hsla(0x155e75));
let def = ThemeDefinition::parse_toml("muted_hover = \"#0ea5e9\"\n").unwrap();
let preset_stated = resolve(&system(ThemeVariant::Dark, palette, None), Some(&def), None);
assert_eq!(preset_stated.colors.muted_hover, hsla(0x0ea5e9));
}
} }
+59 -5
View File
@@ -4,7 +4,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use gpui::Hsla; use gpui::{Hsla, Pixels, px};
use crate::system::{PartialPalette, SourceKind, SystemTheme}; use crate::system::{PartialPalette, SourceKind, SystemTheme};
@@ -14,6 +14,9 @@ pub(crate) struct KdeScheme {
pub(crate) accent: Option<Hsla>, pub(crate) accent: Option<Hsla>,
pub(crate) background: Option<Hsla>, pub(crate) background: Option<Hsla>,
pub(crate) palette: PartialPalette, pub(crate) palette: PartialPalette,
/// The root font size parsed from `[General] font=` (family, pointSize,
/// pixelSize, ...).
pub(crate) root_font_size: Option<Pixels>,
} }
impl KdeScheme { impl KdeScheme {
@@ -54,6 +57,7 @@ impl KdeScheme {
accent, accent,
background, background,
palette, palette,
root_font_size: root_font_size(groups.get("General")),
} }
} }
@@ -77,10 +81,17 @@ impl KdeScheme {
.unwrap_or(crate::ThemeVariant::Light), .unwrap_or(crate::ThemeVariant::Light),
accent: self.accent.or(portal_accent), accent: self.accent.or(portal_accent),
palette: self.palette, palette: self.palette,
control_radius: None, control_radius: Some(px(5.)),
input_height: None, overlay_radius: Some(px(5.)),
titlebar_height: None, sm_radius: None,
root_font_size: None, control_height: Some(px(33.)),
input_height: Some(px(29.)),
menu_item_height: Some(px(29.)),
switch_height: Some(px(18.)),
switch_width: Some(px(36.)),
switch_knob: Some(px(18.)),
titlebar_height: Some(px(44.)),
root_font_size: Some(self.root_font_size.unwrap_or(px(13.33))),
accessibility, accessibility,
source: SourceKind::Kde, source: SourceKind::Kde,
} }
@@ -95,6 +106,22 @@ fn variant_by_lightness(background: Hsla) -> crate::ThemeVariant {
} }
} }
/// The root font size of a `[General]` group's `font=` QFont string.
fn root_font_size(group: Option<&HashMap<&str, &str>>) -> Option<Pixels> {
let fields: Vec<&str> = group?.get("font")?.split(',').map(str::trim).collect();
let points = fields.get(1)?.parse::<f32>().ok()?;
let pixels = fields
.get(2)
.and_then(|size| size.parse::<f32>().ok())
.unwrap_or(-1.0);
let size = if pixels > 0.0 {
pixels
} else {
points * 96.0 / 72.0
};
(size > 0.0).then(|| px(size))
}
fn parse_groups(text: &str) -> HashMap<&str, HashMap<&str, &str>> { fn parse_groups(text: &str) -> HashMap<&str, HashMap<&str, &str>> {
let mut groups: HashMap<&str, HashMap<&str, &str>> = HashMap::new(); let mut groups: HashMap<&str, HashMap<&str, &str>> = HashMap::new();
let mut section: Option<&str> = None; let mut section: Option<&str> = None;
@@ -305,4 +332,31 @@ activeBackground=39,44,49
let system = scheme.into_system(Default::default(), None, Some(crate::ThemeVariant::Light)); let system = scheme.into_system(Default::default(), None, Some(crate::ThemeVariant::Light));
assert_eq!(system.variant, crate::ThemeVariant::Light); assert_eq!(system.variant, crate::ThemeVariant::Light);
} }
#[test]
fn the_scheme_states_breeze_metrics_and_the_default_font() {
let system = KdeScheme::parse(BREEZE_DARK).into_system(Default::default(), None, None);
assert_eq!(system.control_height, Some(px(33.)));
assert_eq!(system.input_height, Some(px(29.)));
assert_eq!(system.menu_item_height, Some(px(29.)));
assert_eq!(system.control_radius, Some(px(5.)));
assert_eq!(system.overlay_radius, Some(px(5.)));
assert_eq!(system.switch_height, Some(px(18.)));
assert_eq!(system.switch_width, Some(px(36.)));
assert_eq!(system.switch_knob, Some(px(18.)));
assert_eq!(system.titlebar_height, Some(px(44.)));
assert!((system.root_font_size.unwrap() - px(13.33)).abs() < px(0.01));
}
#[test]
fn the_stated_font_beats_the_default() {
let text = "[General]\nfont=Noto Sans,10,-1,5,50,0,0,0,0,0\n";
let system = KdeScheme::parse(text).into_system(Default::default(), None, None);
assert!((system.root_font_size.unwrap() - px(10.0 * 96.0 / 72.0)).abs() < px(0.01));
let text = "[General]\nfont=Adwaita Sans,-1,12,5,50,0,0,0,0,0\n";
let system = KdeScheme::parse(text).into_system(Default::default(), None, None);
assert_eq!(system.root_font_size, Some(px(12.)));
}
} }
+107 -30
View File
@@ -5,7 +5,7 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use gpui::Hsla; use gpui::{Hsla, Pixels};
use notify::{RecursiveMode, Watcher}; use notify::{RecursiveMode, Watcher};
use super::ThemeSource; use super::ThemeSource;
@@ -55,11 +55,12 @@ fn detect(home: &Path, portal_reachable: bool, gsettings_reachable: bool) -> Bac
} }
} }
/// What `gsettings` states for GNOME sessions without a portal. /// What `gsettings` states for GNOME sessions.
#[derive(Clone, Copy, Debug, Default, PartialEq)] #[derive(Clone, Copy, Debug, Default, PartialEq)]
struct GSettingsSnapshot { struct GSettingsSnapshot {
variant: Option<ThemeVariant>, variant: Option<ThemeVariant>,
accent: Option<Hsla>, accent: Option<Hsla>,
root_font_size: Option<Pixels>,
} }
fn gsettings_value(args: &[&str]) -> Option<String> { fn gsettings_value(args: &[&str]) -> Option<String> {
@@ -97,23 +98,31 @@ fn read_gsettings() -> Option<GSettingsSnapshot> {
"prefer-light" => Some(ThemeVariant::Light), "prefer-light" => Some(ThemeVariant::Light),
_ => None, _ => None,
}); });
let accent = gsettings_value(&["get", "org.gnome.desktop.interface", "accent-color"]) let accent = gsettings_value(&["get", "org.gnome.desktop.interface", "accent-color"])
.and_then(|value| accent_rgb(value.trim().trim_matches('\''))); .and_then(|value| accent_rgb(value.trim().trim_matches('\'')));
if variant.is_none() && accent.is_none() { let root_font_size = gsettings_value(&["get", "org.gnome.desktop.interface", "font-name"])
.and_then(|value| font_root_px(value.trim().trim_matches('\'')));
if variant.is_none() && accent.is_none() && root_font_size.is_none() {
return None; return None;
} }
Some(GSettingsSnapshot { variant, accent }) Some(GSettingsSnapshot {
variant,
accent,
root_font_size,
})
}
fn font_root_px(name: &str) -> Option<Pixels> {
let points = name.split_whitespace().last()?.parse::<f32>().ok()?;
(points > 0.0).then(|| gpui::px(points * 96.0 / 72.0))
} }
pub(crate) fn platform_source() -> Box<dyn ThemeSource> { pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
let portal = portal::io::PortalClient::connect(); let portal = portal::io::PortalClient::connect();
let gsettings = read_gsettings();
let gsettings = if portal.is_none() {
read_gsettings()
} else {
None
};
let backend = home_dir() let backend = home_dir()
.map(|home| detect(&home, portal.is_some(), gsettings.is_some())) .map(|home| detect(&home, portal.is_some(), gsettings.is_some()))
@@ -129,6 +138,36 @@ pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
} }
impl LinuxSource { impl LinuxSource {
fn gnome_snapshot(
&self,
variant: ThemeVariant,
accent: Option<Hsla>,
accessibility: crate::system::AccessibilityPreferences,
) -> SystemTheme {
SystemTheme {
variant,
accent,
palette: PartialPalette::default(),
control_radius: Some(gpui::px(9.)),
overlay_radius: Some(gpui::px(15.)),
sm_radius: Some(gpui::px(6.)),
control_height: Some(gpui::px(34.)),
input_height: Some(gpui::px(34.)),
menu_item_height: Some(gpui::px(32.)),
switch_height: Some(gpui::px(26.)),
switch_width: Some(gpui::px(46.)),
switch_knob: Some(gpui::px(20.)),
titlebar_height: Some(gpui::px(47.)),
root_font_size: Some(
self.gsettings
.and_then(|gsettings| gsettings.root_font_size)
.unwrap_or(gpui::px(14.67)),
),
accessibility,
source: SourceKind::Gnome,
}
}
fn portal_snapshot( fn portal_snapshot(
&self, &self,
variant: ThemeVariant, variant: ThemeVariant,
@@ -136,31 +175,21 @@ impl LinuxSource {
accessibility: crate::system::AccessibilityPreferences, accessibility: crate::system::AccessibilityPreferences,
) -> SystemTheme { ) -> SystemTheme {
if let Some(appearance) = portal_appearance { if let Some(appearance) = portal_appearance {
return SystemTheme { return self.gnome_snapshot(
variant: appearance.variant(variant), appearance.variant(variant),
accent: appearance.accent, appearance.accent,
palette: PartialPalette::default(),
control_radius: None,
input_height: None,
titlebar_height: None,
root_font_size: None,
accessibility, accessibility,
source: SourceKind::Gnome, );
};
} }
if let Some(gsettings) = self.gsettings { if let Some(gsettings) = self.gsettings {
return SystemTheme { return self.gnome_snapshot(
variant: gsettings.variant.unwrap_or(variant), gsettings.variant.unwrap_or(variant),
accent: gsettings.accent, gsettings.accent,
palette: PartialPalette::default(),
control_radius: None,
input_height: None,
titlebar_height: None,
root_font_size: None,
accessibility, accessibility,
source: SourceKind::Gnome, );
};
} }
SystemTheme::fallback(variant) SystemTheme::fallback(variant)
} }
} }
@@ -289,4 +318,52 @@ mod tests {
assert_eq!(detect(&home, true, false), Backend::Kde); assert_eq!(detect(&home, true, false), Backend::Kde);
std::fs::remove_dir_all(&home).expect("cleanup"); std::fs::remove_dir_all(&home).expect("cleanup");
} }
#[test]
fn the_gsettings_font_name_states_the_root_font_size() {
assert_eq!(
font_root_px("Adwaita Sans 11"),
Some(gpui::px(11.0 * 96.0 / 72.0))
);
assert_eq!(
font_root_px("Noto Sans 10"),
Some(gpui::px(10.0 * 96.0 / 72.0))
);
assert_eq!(
font_root_px("Cantarell Bold 12.5"),
Some(gpui::px(12.5 * 96.0 / 72.0))
);
assert_eq!(font_root_px("Adwaita Sans"), None);
assert_eq!(font_root_px("Sans 0"), None);
}
#[test]
fn the_gnome_snapshot_states_the_libadwaita_metrics() {
let source = LinuxSource {
backend: Backend::Portal,
portal: None,
gsettings: None,
file_watcher: None,
_signal: None,
};
let system = source.gnome_snapshot(
ThemeVariant::Light,
None,
crate::system::AccessibilityPreferences::default(),
);
assert_eq!(system.source, SourceKind::Gnome);
assert_eq!(system.control_height, Some(gpui::px(34.)));
assert_eq!(system.input_height, Some(gpui::px(34.)));
assert_eq!(system.menu_item_height, Some(gpui::px(32.)));
assert_eq!(system.control_radius, Some(gpui::px(9.)));
assert_eq!(system.overlay_radius, Some(gpui::px(15.)));
assert_eq!(system.sm_radius, Some(gpui::px(6.)));
assert_eq!(system.switch_height, Some(gpui::px(26.)));
assert_eq!(system.switch_width, Some(gpui::px(46.)));
assert_eq!(system.switch_knob, Some(gpui::px(20.)));
assert_eq!(system.titlebar_height, Some(gpui::px(47.)));
// The default 11pt font when gsettings states none.
assert_eq!(system.root_font_size, Some(gpui::px(14.67)));
}
} }
+18 -1
View File
@@ -22,6 +22,16 @@ use crate::system::{AccessibilityPreferences, PartialPalette, SourceKind, System
const CONTROL_RADIUS_PTS: f32 = 6.0; const CONTROL_RADIUS_PTS: f32 = 6.0;
/// The rounded-bezel text field's intrinsic height at regular size. /// The rounded-bezel text field's intrinsic height at regular size.
const INPUT_HEIGHT_PTS: f32 = 24.0; const INPUT_HEIGHT_PTS: f32 = 24.0;
/// AppKit's regular push button, measured via `NSButton::fittingSize`.
const CONTROL_HEIGHT_PTS: f32 = 24.0;
/// The standard `NSMenu` row height.
const MENU_ITEM_HEIGHT_PTS: f32 = 20.0;
/// `NSSwitch`, measured via `fittingSize`.
const SWITCH_TRACK_HEIGHT_PTS: f32 = 24.0;
const SWITCH_TRACK_WIDTH_PTS: f32 = 54.0;
const SWITCH_KNOB_PTS: f32 = 20.0;
/// The standard titled window's titlebar, measured via `contentLayoutRect`.
const TITLEBAR_HEIGHT_PTS: f32 = 32.0;
/// The HIG's default body text on macOS is 13 pt (system font size 13 at 1x). /// The HIG's default body text on macOS is 13 pt (system font size 13 at 1x).
const ROOT_FONT_SIZE_PTS: f32 = 13.0; const ROOT_FONT_SIZE_PTS: f32 = 13.0;
@@ -112,8 +122,15 @@ impl ThemeSource for MacosSource {
accent: Self::color_to_hsla(&NSColor::controlAccentColor()), accent: Self::color_to_hsla(&NSColor::controlAccentColor()),
palette, palette,
control_radius: Some(gpui::px(CONTROL_RADIUS_PTS)), control_radius: Some(gpui::px(CONTROL_RADIUS_PTS)),
overlay_radius: None,
sm_radius: None,
control_height: Some(gpui::px(CONTROL_HEIGHT_PTS)),
input_height: Some(gpui::px(INPUT_HEIGHT_PTS)), input_height: Some(gpui::px(INPUT_HEIGHT_PTS)),
titlebar_height: None, menu_item_height: Some(gpui::px(MENU_ITEM_HEIGHT_PTS)),
switch_height: Some(gpui::px(SWITCH_TRACK_HEIGHT_PTS)),
switch_width: Some(gpui::px(SWITCH_TRACK_WIDTH_PTS)),
switch_knob: Some(gpui::px(SWITCH_KNOB_PTS)),
titlebar_height: Some(gpui::px(TITLEBAR_HEIGHT_PTS)),
root_font_size: Some(gpui::px(ROOT_FONT_SIZE_PTS)), root_font_size: Some(gpui::px(ROOT_FONT_SIZE_PTS)),
accessibility, accessibility,
source: SourceKind::Macos, source: SourceKind::Macos,
+23 -4
View File
@@ -60,8 +60,15 @@ impl OmarchyTheme {
variant: self.variant, variant: self.variant,
accent: self.accent, accent: self.accent,
palette: self.palette, palette: self.palette,
control_radius: None, control_radius: Some(gpui::px(9.)),
input_height: None, overlay_radius: Some(gpui::px(15.)),
sm_radius: Some(gpui::px(6.)),
control_height: Some(gpui::px(34.)),
input_height: Some(gpui::px(34.)),
menu_item_height: Some(gpui::px(32.)),
switch_height: Some(gpui::px(26.)),
switch_width: Some(gpui::px(46.)),
switch_knob: Some(gpui::px(20.)),
titlebar_height: None, titlebar_height: None,
root_font_size: None, root_font_size: None,
accessibility, accessibility,
@@ -216,7 +223,19 @@ color15 = \"#635d6f\"
} }
#[test] #[test]
fn non_toml_text_is_rejected() { fn omarchy_shares_the_gnome_control_metrics() {
assert!(OmarchyTheme::parse("not a theme").is_none()); let theme = OmarchyTheme::parse(TOKYO_NIGHT).expect("parses");
let system = theme.into_system(Default::default());
assert_eq!(system.control_height, Some(gpui::px(34.)));
assert_eq!(system.input_height, Some(gpui::px(34.)));
assert_eq!(system.menu_item_height, Some(gpui::px(32.)));
assert_eq!(system.control_radius, Some(gpui::px(9.)));
assert_eq!(system.overlay_radius, Some(gpui::px(15.)));
assert_eq!(system.switch_height, Some(gpui::px(26.)));
assert_eq!(system.switch_width, Some(gpui::px(46.)));
assert_eq!(system.switch_knob, Some(gpui::px(20.)));
assert_eq!(system.titlebar_height, None);
assert_eq!(system.root_font_size, None);
} }
} }
-10
View File
@@ -206,16 +206,6 @@ mod tests {
); );
} }
#[test]
fn an_unset_scheme_yields_the_callers_variant() {
let appearance = AppearanceValues::default().decode();
assert_eq!(
appearance.variant(crate::ThemeVariant::Dark),
crate::ThemeVariant::Dark
);
assert_eq!(appearance, Appearance::default());
}
#[test] #[test]
fn accessibility_flags_forward() { fn accessibility_flags_forward() {
let accessibility = AppearanceValues { let accessibility = AppearanceValues {
+17
View File
@@ -14,8 +14,18 @@ use crate::system::{AccessibilityPreferences, PartialPalette, SourceKind, System
/// WinUI 11's `ControlCornerRadius`, in pixels at 100% scaling. /// WinUI 11's `ControlCornerRadius`, in pixels at 100% scaling.
const CONTROL_RADIUS_PX: f32 = 4.0; const CONTROL_RADIUS_PX: f32 = 4.0;
/// WinUI 11's `OverlayCornerRadius`, in pixels at 100% scaling.
const OVERLAY_RADIUS_PX: f32 = 8.0;
/// WinUI 11's default text-box height, in pixels at 100% scaling. /// WinUI 11's default text-box height, in pixels at 100% scaling.
const INPUT_HEIGHT_PX: f32 = 32.0; const INPUT_HEIGHT_PX: f32 = 32.0;
/// The default button height, in pixels at 100% scaling.
const CONTROL_HEIGHT_PX: f32 = 32.0;
/// The default menu row: item padding 4/5 + a 20px Body line + 2px item margins.
const MENU_ITEM_HEIGHT_PX: f32 = 33.0;
/// ToggleSwitch: a 40x20 track with a 12px resting knob.
const SWITCH_TRACK_HEIGHT_PX: f32 = 20.0;
const SWITCH_TRACK_WIDTH_PX: f32 = 40.0;
const SWITCH_KNOB_PX: f32 = 12.0;
/// The window caption's height, in pixels at 100% scaling, as Win32 reports it. /// The window caption's height, in pixels at 100% scaling, as Win32 reports it.
const TITLEBAR_HEIGHT_PX: f32 = 32.0; const TITLEBAR_HEIGHT_PX: f32 = 32.0;
/// WinUI 11's default body text size, in pixels at 100% scaling. /// WinUI 11's default body text size, in pixels at 100% scaling.
@@ -159,7 +169,14 @@ impl ThemeSource for WindowsSource {
..PartialPalette::default() ..PartialPalette::default()
}, },
control_radius: Some(gpui::px(CONTROL_RADIUS_PX)), control_radius: Some(gpui::px(CONTROL_RADIUS_PX)),
overlay_radius: Some(gpui::px(OVERLAY_RADIUS_PX)),
sm_radius: None,
control_height: Some(gpui::px(CONTROL_HEIGHT_PX)),
input_height: Some(gpui::px(INPUT_HEIGHT_PX)), input_height: Some(gpui::px(INPUT_HEIGHT_PX)),
menu_item_height: Some(gpui::px(MENU_ITEM_HEIGHT_PX)),
switch_height: Some(gpui::px(SWITCH_TRACK_HEIGHT_PX)),
switch_width: Some(gpui::px(SWITCH_TRACK_WIDTH_PX)),
switch_knob: Some(gpui::px(SWITCH_KNOB_PX)),
titlebar_height: Some(gpui::px(TITLEBAR_HEIGHT_PX)), titlebar_height: Some(gpui::px(TITLEBAR_HEIGHT_PX)),
root_font_size: Some(gpui::px(ROOT_FONT_SIZE_PX * text_scale as f32)), root_font_size: Some(gpui::px(ROOT_FONT_SIZE_PX * text_scale as f32)),
accessibility: self.accessibility(), accessibility: self.accessibility(),
+33 -1
View File
@@ -1,6 +1,6 @@
//! The normalized snapshot a platform adapter produces. //! The normalized snapshot a platform adapter produces.
use gpui::{Hsla, Pixels}; use gpui::{Hsla, Pixels, px};
use crate::ThemeVariant; use crate::ThemeVariant;
@@ -43,6 +43,17 @@ impl SourceKind {
pub fn scrollbar_autohide(self) -> bool { pub fn scrollbar_autohide(self) -> bool {
matches!(self, Self::Macos | Self::Omarchy | Self::Gnome) matches!(self, Self::Macos | Self::Omarchy | Self::Gnome)
} }
/// The base spacing unit:
///
/// - GNOME lays out on a 6px grid
/// - Other platform on 4px grid
pub fn spacing_unit(self) -> Pixels {
match self {
Self::Gnome | Self::Omarchy => px(6.),
_ => px(4.),
}
}
} }
/// A partial palette with optional colors. /// A partial palette with optional colors.
@@ -83,8 +94,22 @@ pub struct SystemTheme {
pub palette: PartialPalette, pub palette: PartialPalette,
/// The platform's stated control corner radius, if it has one. /// The platform's stated control corner radius, if it has one.
pub control_radius: Option<Pixels>, pub control_radius: Option<Pixels>,
/// The platform's stated menu/popover radius, if it has one.
pub overlay_radius: Option<Pixels>,
/// The platform's stated checkbox radius, if it has one.
pub sm_radius: Option<Pixels>,
/// The platform's stated control height, if it has one.
pub control_height: Option<Pixels>,
/// The platform's stated text-field height, if it has one. /// The platform's stated text-field height, if it has one.
pub input_height: Option<Pixels>, pub input_height: Option<Pixels>,
/// The platform's stated menu row height, if it has one.
pub menu_item_height: Option<Pixels>,
/// The platform's stated switch track height, if it has one.
pub switch_height: Option<Pixels>,
/// The platform's stated switch track width, if it has one.
pub switch_width: Option<Pixels>,
/// The platform's stated switch knob size, if it has one.
pub switch_knob: Option<Pixels>,
/// The platform's native titlebar height, if it states one. /// The platform's native titlebar height, if it states one.
pub titlebar_height: Option<Pixels>, pub titlebar_height: Option<Pixels>,
/// The platform's native root font size, if it has one. /// The platform's native root font size, if it has one.
@@ -100,7 +125,14 @@ impl SystemTheme {
accent: None, accent: None,
palette: PartialPalette::default(), palette: PartialPalette::default(),
control_radius: None, control_radius: None,
overlay_radius: None,
sm_radius: None,
control_height: None,
input_height: None, input_height: None,
menu_item_height: None,
switch_height: None,
switch_width: None,
switch_knob: None,
titlebar_height: None, titlebar_height: None,
root_font_size: None, root_font_size: None,
accessibility: AccessibilityPreferences::default(), accessibility: AccessibilityPreferences::default(),
+41 -4
View File
@@ -1,9 +1,10 @@
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
App, BoxShadow, Div, ElementId, FocusHandle, Hsla, IntoElement, Refineable as _, App, Bounds, BoxShadow, Div, ElementId, FocusHandle, Global, Hsla, IntoElement, Pixels,
StatefulInteractiveElement, StyleRefinement, Styled, Window, div, px, Refineable as _, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, px,
}; };
use gpui_native_theme::ActiveTheme as _; use gpui_native_theme::ActiveTheme as _;
use std::collections::HashMap;
/// A row whose children center on the cross axis. /// A row whose children center on the cross axis.
pub fn h_flex() -> Div { pub fn h_flex() -> Div {
@@ -35,9 +36,45 @@ pub fn focus_handle(id: &ElementId, window: &mut Window, cx: &mut App) -> FocusH
.clone() .clone()
} }
/// Returns a focus ring shadow with the given color. /// 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> { pub fn focus_ring(color: Hsla) -> Vec<BoxShadow> {
vec![BoxShadow::new(px(0.), px(0.), color).spread_radius(px(3.))] vec![BoxShadow::new(px(0.), px(0.), color.alpha(0.5)).spread_radius(px(3.))]
} }
/// The next selectable row in the direction, or `None` at the ends. /// The next selectable row in the direction, or `None` at the ends.
+95 -2
View File
@@ -11,6 +11,9 @@ is the first downstream consumer; its screens drive the order.
## Queue — to follow ## Queue — to follow
Platform metric adoption continues one component at a time; each stop goes
through the review gate and a gallery pass.
Gaps surfaced by the signed comparison; ordered by signed's usage. Gaps surfaced by the signed comparison; ordered by signed's usage.
- [ ] **Virtual list** — generic virtualized variable-height rows with a - [ ] **Virtual list** — generic virtualized variable-height rows with a
@@ -47,6 +50,14 @@ Gaps surfaced by the signed comparison; ordered by signed's usage.
- [x] Platform sources — macOS (NSColor), Windows 11 (UISettings), GNOME - [x] Platform sources — macOS (NSColor), Windows 11 (UISettings), GNOME
(portal), KDE (kdeglobals), Omarchy (colors.toml), neutral fallback; (portal), KDE (kdeglobals), Omarchy (colors.toml), neutral fallback;
live watchers for each live watchers for each
- [x] Platform metric fidelity — stated control/input/menu-row/switch
metrics and the full radius ladder per platform, Linux root fonts
(GNOME gsettings `font-name`, KDE `kdeglobals font=`), per-source
border + focus chrome (`Theme::borders`), and a per-platform spacing
unit (`Theme::spacing`); overlay borders engage in dark mode, where
floating layers separate by border instead of shadow — dark elevation
shadows are dropped entirely, since they only muddied the dark scrim.
Components adopt the tokens one by one (see Next and Queue)
### icons (`crates/icons`) ### icons (`crates/icons`)
@@ -55,12 +66,93 @@ Gaps surfaced by the signed comparison; ordered by signed's usage.
### core (`crates/core`) ### core (`crates/core`)
- [x] Button platform chrome — every variant always paints the hairline
border, sized outside the stated control height (`BORDER_WIDTH`); where
the platform paints no border (`Theme::borders.buttons`, ghost) the
border takes the fill's color and disappears, so sizes match across
platforms; icon-bearing buttons pad with `dense_padding_x` instead of
`padding_x`
- [x] Button loading state — `.loading(true)` shows the spinner in place of
the icon and suppresses activation; Spinner gained a `.color` override
for accent-filled surfaces
- [x] Input platform chrome — the field always paints the hairline border,
sized outside the stated field height; where the platform paints no
border (`Theme::borders.fields`) it takes the field's fill; focus
recolors the border on Windows / KDE and paints the accent ring on
macOS / GNOME via `Theme::borders.focus_ring`; the shared `focus_ring`
band paints at half ink (every focusable control picks it up)
- [x] Input prefix / suffix — elements docked inside the field's edges,
typically small ghost buttons; a docked click activates the control
without reaching the field's caret handler, and the text region shrinks
to make room
- [x] Select platform chrome — the trigger takes the input treatment: the
hairline border always painted, sized outside the stated field height
and taking the fill's color where the platform paints none; open
recolors the border on Windows / KDE and paints the accent ring on
macOS / GNOME, so the trigger matches a focused field; the menu
popup's border sits behind `Theme::borders.overlays`, disappearing
into the surface where the platform paints none
- [x] Menu platform chrome — the popup border behind `Theme::borders.overlays`
(and dark mode); the panel padding and separator margins come from
`Theme::spacing`, and every action row renders at the stated
`menu_item_height` with content centered, so the stated metric is the
whole row; rows carry debug selectors for headless geometry tests
- [x] Popover platform chrome — the panel border behind
`Theme::borders.overlays` (and dark mode); the anchor gap is the
spacing unit and rides `anchored().offset`, not a margin, so the
window-edge clamp stays intact; the panel carries a debug selector
- [x] Dialog platform chrome — the panel border behind
`Theme::borders.overlays` (and dark mode), taking the surface color
where the platform paints none
- [x] Toast platform chrome — the pill border behind
`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] Alert platform chrome — the banner border behind
`Theme::borders.overlays` (and dark mode), taking the surface color
where the platform paints none; it rides the popover elevation, so
dark mode's no-shadow rule applies to it too
- [x] Combobox platform chrome — the popup 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; the trigger is a plain `Input`, so it
already carries the full field treatment, and the chevron recenters on
the field's full height, border included; the popup mounts on
`deferred(anchored().offset(...))` like the Popover — out of the
wrapper's layout, one spacing gap below, window-clamped — and always
matches the combobox's width; the combobox itself is no longer full
width: it takes a compact default and style refinements on the builder
(`.w(...)`) set the width; the empty field keeps its placeholder while
focused (`Input::placeholder_when_focused`) and clicking into it opens
the options right away, with no keystroke needed
- [x] Command palette platform chrome — the panel border behind
`Theme::borders.overlays` (and dark mode), taking the surface color
where the platform paints none; it rides the dialog elevation, so dark
mode's no-shadow rule applies to it too
- [x] Switch platform chrome — the track and knob take the stated
`switch_height` / `switch_width` / `switch_knob` (the knob's inset
derived to center it in the track, its travel from the remaining
width); the knob is borderless, and the track carries a debug selector
for headless geometry tests
- [x] Button, Checkbox, Switch, Radio + RadioGroup, Toggle, Link, Slider, - [x] Button, Checkbox, Switch, Radio + RadioGroup, Toggle, Link, Slider,
Progress, Avatar (image + seeded pixel-art fallback) Progress, Avatar (image + seeded pixel-art fallback)
- [x] Spinner — painted ring + rotating-icon variant, indeterminate and - [x] Spinner — painted ring + rotating-icon variant, indeterminate and
determinate; replaces `Tab::busy`'s placeholder determinate; replaces `Tab::busy`'s placeholder
- [x] Input — single-line, password with reveal, multiline, undo/redo, actions - [x] Input — single-line, password with reveal, multiline, undo/redo, actions
+ key context, caret blink + key context, caret blink (time-math driven: the focused field mounts a
repeating blink animation whose frames redraw the caret, the caret is
solid for one interval after each edit, and an idle unfocused page
schedules no frames)
- [x] ScrollArea, Popover, Menu, Tooltip, Dialog / AlertDialog, Toast, - [x] ScrollArea, Popover, Menu, Tooltip, Dialog / AlertDialog, Toast,
Alert + alerts stack Alert + alerts stack
- [x] Select, Segmented, ContextMenu, EmptyState, Badge - [x] Select, Segmented, ContextMenu, EmptyState, Badge
@@ -70,7 +162,8 @@ Gaps surfaced by the signed comparison; ordered by signed's usage.
screen-reader label screen-reader label
- [x] Combobox — type-to-filter picker with a caller-owned matcher, create / - [x] Combobox — type-to-filter picker with a caller-owned matcher, create /
keep / revert blur modes keep / revert blur modes
- [x] Command palette — filtered actions; the matcher stays caller-owned - [x] Command palette — filtered actions; the matcher stays caller-owned,
and commands may carry a leading icon
- [x] Scrollbar — overlay thumbs for gpui's `list` and `uniform_list`, - [x] Scrollbar — overlay thumbs for gpui's `list` and `uniform_list`,
platform autohide with a `visibility` override platform autohide with a `visibility` override
- [x] Clipboard button — copies a value, confirms with a brief check mark - [x] Clipboard button — copies a value, confirms with a brief check mark
+120 -17
View File
@@ -205,8 +205,10 @@ fn gallery_commands() -> Vec<Command> {
Command::new("open-settings", "Open Settings").hint("Cmd+,"), Command::new("open-settings", "Open Settings").hint("Cmd+,"),
Command::new("toggle-theme", "Toggle Theme").hint("Shift+Cmd+T"), Command::new("toggle-theme", "Toggle Theme").hint("Shift+Cmd+T"),
Command::new("save-all", "Save All").hint("Cmd+Alt+S"), Command::new("save-all", "Save All").hint("Cmd+Alt+S"),
Command::new("close-tab", "Close Tab").hint("Cmd+W"), Command::new("close-tab", "Close Tab")
Command::new("reload-window", "Reload Window"), .hint("Cmd+W")
.icon(IconRole::Close),
Command::new("reload-window", "Reload Window").icon(IconRole::Loader),
] ]
} }
@@ -876,27 +878,83 @@ impl Render for ButtonDemo {
}); });
}); });
if variant == ButtonVariant::Destructive { 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 button
})) })),
))
.child(demo_section(
"icon + label",
colors,
h_flex()
.flex_wrap()
.gap_2()
.children(variants.map(|(name, variant)| {
let view = view.clone();
let mut button =
Button::new(SharedString::from(format!("button-demo-icon-{name}")))
.label(button_label(variant))
.icon(IconRole::Folder)
.variant(variant)
.on_click(move |_, _, cx| {
view.update(cx, |demo, cx| {
demo.clicks += 1;
cx.notify();
});
});
if variant == ButtonVariant::Destructive {
let id = SharedString::from(format!("button-demo-icon-{name}"));
button = button
.tooltip(Tooltip::anchored("Removes the selected item", id));
}
button
})),
))
.child(demo_section(
"icon only",
colors,
h_flex()
.flex_wrap()
.gap_2()
.children(variants.map(|(name, variant)| {
let view = view.clone();
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::anchored("Opens the selected file", id))
.on_click(move |_, _, cx| {
view.update(cx, |demo, cx| {
demo.clicks += 1;
cx.notify();
});
})
})),
))
.child(demo_section(
"states",
colors,
h_flex()
.flex_wrap()
.gap_2()
.child( .child(
Button::new("button-demo-disabled") Button::new("button-demo-disabled")
.label("Disabled") .label("Disabled")
.disabled(true), .disabled(true),
) )
.child( .child(
Button::new("button-demo-icon") Button::new("button-demo-loading")
.label("Open") .label("Saving")
.icon(IconRole::Folder) .loading(true),
.tooltip(Tooltip::text("Opens the selected file")),
) )
.child( .child(
Button::new("button-demo-icon-only") Button::new("button-demo-loading-icon-only")
.icon(IconRole::Folder) .icon(IconRole::Folder)
.variant(ButtonVariant::Ghost) .accessibility_label("Saving the selected file")
.accessibility_label("Open the selected file") .loading(true),
.tooltip(Tooltip::text("Opens the selected file")),
), ),
)) ))
.child(demo_section( .child(demo_section(
@@ -923,6 +981,7 @@ struct InputDemo {
text: Entity<InputState>, text: Entity<InputState>,
password: Entity<InputState>, password: Entity<InputState>,
notes: Entity<InputState>, notes: Entity<InputState>,
search: Entity<InputState>,
_subscriptions: Vec<gpui::Subscription>, _subscriptions: Vec<gpui::Subscription>,
} }
@@ -933,17 +992,20 @@ impl InputDemo {
password.update(cx, |state, cx| state.set_password(true, cx)); password.update(cx, |state, cx| state.set_password(true, cx));
let notes = InputState::new(cx); let notes = InputState::new(cx);
notes.update(cx, |state, cx| state.set_multiline(true, cx)); notes.update(cx, |state, cx| state.set_multiline(true, cx));
let search = InputState::new(cx);
// The panel is a cached view in the dock: the value readouts re-render // The panel is a cached view in the dock: the value readouts re-render
// only when the inputs it reads are notified. // only when the inputs it reads are notified.
let subscriptions = vec![ let subscriptions = vec![
cx.observe(&text, |_, _, cx| cx.notify()), cx.observe(&text, |_, _, cx| cx.notify()),
cx.observe(&password, |_, _, cx| cx.notify()), cx.observe(&password, |_, _, cx| cx.notify()),
cx.observe(&notes, |_, _, cx| cx.notify()), cx.observe(&notes, |_, _, cx| cx.notify()),
cx.observe(&search, |_, _, cx| cx.notify()),
]; ];
Self { Self {
text, text,
password, password,
notes, notes,
search,
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
} }
@@ -956,7 +1018,36 @@ impl Render for InputDemo {
ScrollArea::new("input-demo").size_full().p_4().child( ScrollArea::new("input-demo").size_full().p_4().child(
v_flex() v_flex()
.gap_3() .gap_3()
.child(Input::new("input-demo-text", self.text.clone()).placeholder("Type here…")) .child(
Input::new("input-demo-search", self.search.clone())
.placeholder("Search")
.prefix(
Button::new("input-demo-search-prefix")
.icon(IconRole::Search)
.variant(ButtonVariant::Ghost)
.small()
.accessibility_label("Search"),
),
)
.child(
Input::new("input-demo-text", self.text.clone())
.placeholder("Type here…")
.suffix(
Button::new("input-demo-clear")
.icon(IconRole::Close)
.variant(ButtonVariant::Ghost)
.small()
.accessibility_label("Clear")
.on_click({
let text = self.text.clone();
move |_, window, cx| {
text.update(cx, |state, cx| {
state.set_value("", window, cx)
});
}
}),
),
)
.child( .child(
Input::new("input-demo-password", self.password.clone()) Input::new("input-demo-password", self.password.clone())
.placeholder("Password"), .placeholder("Password"),
@@ -1168,7 +1259,10 @@ impl ComboboxDemo {
) )
.selected(SharedString::from("main")) .selected(SharedString::from("main"))
.placeholder("Switch branch…") .placeholder("Switch branch…")
.keep_unmatched_text(), .keep_unmatched_text()
// Widths are style refinements on the builder; the popup
// always matches whatever the combobox ends up being.
.w(px(280.)),
window, window,
cx, cx,
) )
@@ -2716,20 +2810,29 @@ impl Render for TooltipDemo {
Button::new("tooltip-demo-destructive") Button::new("tooltip-demo-destructive")
.label("Delete") .label("Delete")
.variant(ButtonVariant::Destructive) .variant(ButtonVariant::Destructive)
.tooltip(Tooltip::text("Removes the selected item")), .tooltip(Tooltip::anchored(
"Removes the selected item",
"tooltip-demo-destructive",
)),
) )
.child( .child(
Button::new("tooltip-demo-icon") Button::new("tooltip-demo-icon")
.label("Open") .label("Open")
.icon(IconRole::Folder) .icon(IconRole::Folder)
.tooltip(Tooltip::text("Opens the selected file")), .tooltip(Tooltip::anchored(
"Opens the selected file",
"tooltip-demo-icon",
)),
) )
.child( .child(
Button::new("tooltip-demo-icon-only") Button::new("tooltip-demo-icon-only")
.icon(IconRole::Folder) .icon(IconRole::Folder)
.variant(ButtonVariant::Ghost) .variant(ButtonVariant::Ghost)
.accessibility_label("Open the selected file") .accessibility_label("Open the selected file")
.tooltip(Tooltip::text("Opens the selected file")), .tooltip(Tooltip::anchored(
"Opens the selected file",
"tooltip-demo-icon-only",
)),
), ),
)) ))
} }