audit button

This commit is contained in:
2026-11-07 15:16:25 +07:00
parent d696b1e707
commit fd36ada35e
7 changed files with 157 additions and 314 deletions
+35 -14
View File
@@ -11,6 +11,7 @@ use gpui_native_utils::focus_ring;
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 +21,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 +41,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 +62,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 +122,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 +158,11 @@ 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 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 => (
@@ -183,35 +192,47 @@ impl RenderOnce for Button {
}; };
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)
.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| { .when(
button.border_1().border_color(colors.border) borders.buttons && self.variant != ButtonVariant::Ghost,
}) |button| button.border_1().border_color(colors.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))
.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()
+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()
+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");
+1 -1
View File
@@ -339,7 +339,7 @@ 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,
+34 -12
View File
@@ -9,18 +9,34 @@ is the first downstream consumer; its screens drive the order.
## Next ## Next
- [ ] **Platform metric adoption** — update components one at a time - [ ] **Input platform chrome** — field border behind
through the review gate against the landed theme tokens. The stated `Theme::borders.fields`; focus split between the ring shadow and
metrics and the radius ladder already flow through the size table, border recolor via `Theme::borders.focus_ring`.
so each stop covers border + focus chrome (`Theme::borders`), the
spacing unit (`Theme::spacing`), and the component's stated dims:
button (the API template) first, then input, select, menu, popover,
dialog, toast, tooltip, alert, combobox, command palette, switch;
per-platform targets in
[platform-metrics-audit.md](platform-metrics-audit.md).
## 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.
- [ ] **Select platform chrome** — the input treatment on the trigger; the
popup border behind `Theme::borders.overlays`.
- [ ] **Menu platform chrome** — overlay border gate; paddings, separators,
and the row min-height through `Theme::spacing` and the stated menu
row height.
- [ ] **Popover platform chrome** — overlay border gate; the anchor gap
through `Theme::spacing`.
- [ ] **Dialog platform chrome** — overlay border gate.
- [ ] **Toast platform chrome** — overlay border gate.
- [ ] **Tooltip platform chrome** — overlay border gate.
- [ ] **Alert platform chrome** — overlay border gate.
- [ ] **Combobox platform chrome** — popup border behind
`Theme::borders.overlays`; the trigger follows the input stop's field
treatment.
- [ ] **Command palette platform chrome** — overlay border gate.
- [ ] **Switch platform chrome** — stated track and knob dims
(`switch_height` / `switch_width` / `switch_knob`, inset and travel
derived); borderless knob.
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
@@ -61,9 +77,8 @@ Gaps surfaced by the signed comparison; ordered by signed's usage.
metrics and the full radius ladder per platform, Linux root fonts metrics and the full radius ladder per platform, Linux root fonts
(GNOME gsettings `font-name`, KDE `kdeglobals font=`), per-source (GNOME gsettings `font-name`, KDE `kdeglobals font=`), per-source
border + focus chrome (`Theme::borders`), and a per-platform spacing border + focus chrome (`Theme::borders`), and a per-platform spacing
unit (`Theme::spacing`); findings in unit (`Theme::spacing`). Components adopt the tokens one by one
[platform-metrics-audit.md](platform-metrics-audit.md). Components (see Next and Queue)
adopt the tokens one by one (see Next)
### icons (`crates/icons`) ### icons (`crates/icons`)
@@ -72,6 +87,13 @@ Gaps surfaced by the signed comparison; ordered by signed's usage.
### core (`crates/core`) ### core (`crates/core`)
- [x] Button platform chrome — the filled variants' 1px border gated behind
`Theme::borders.buttons` (Windows / KDE keep it, macOS / GNOME paint
fills, ghost stays borderless); 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] 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
-272
View File
@@ -1,272 +0,0 @@
# Platform metrics audit — Apple HIG, GNOME HIG, Fluent, KDE HIG
Why our controls read small and foreign next to native ones. Assessed 2026-11
against the primary sources: the libadwaita stylesheet (`libadwaita` `main`,
`src/stylesheet/**.scss`), WinUI 3 theme resources (`microsoft-ui-xaml`
CommonStyles) and the Learn design pages, Kirigami `units.cpp` + Breeze
`breezemetrics.h`/`breezestyle.cpp`, and the Apple HIG pages. Apple publishes
no pixel metrics, so macOS numbers are **measured on this machine via AppKit
`fittingSize`** — note the OS-version variance called out below. Queue entry:
[CHECKLIST.md](CHECKLIST.md) — "Platform metric fidelity".
## Verdict
Three real problems, none of them "spacing values are off by a bit":
1. **Heights.** `control_height` is derived as `1.75 × root_font_size`.
Native platforms do not size controls that way — Windows fixes buttons at
32px, GNOME and Breeze add fixed padding to the font height. Result: our
buttons are −23% vs native on Windows, −18% on GNOME. The main "looks
small" driver.
2. **Radii.** Only `radius.control` adapts per platform; `sm`/`overlay` are
frozen at 4/10. Native overlay radii span 5px (KDE) to 15px (GNOME) — we
are 5px off in both directions.
3. **Borders.** We paint `border_1()` on nearly everything (shadcn-style).
That matches Windows and KDE and contradicts macOS and GNOME/libadwaita,
whose controls are borderless fills at normal contrast. The "feels weird"
driver.
Structural gap underneath the spacing complaint: **the theme has no spacing
tokens** — components hardcode gpui utilities (`p_1`, `px_2`, `gap_1p5`,
`min_w_56`), so platform rhythm (GNOME's 6px grid, KDE's 4/6/8) cannot be
expressed.
## What the platforms specify
### Apple / macOS
The HIG publishes no pixel metrics for macOS — verified across the buttons,
text-fields, toolbars, toggles, menus, windows, layout, sidebars, sheets,
materials and designing-for-macos pages. What is published: the type ramp
(body 13pt, min 10pt) and the 44×44pt hit-region rule. Everything else below
was measured locally (AppKit, regular control size, this macOS build):
| Control | Measured | Confidence |
|---|---|---|
| Push button | **24pt** (small 20, mini 16) | measured |
| Rounded text field | **24pt** | measured |
| Pop-up button / segmented | **24pt** / **24pt** | measured |
| Switch | **54×24** | measured |
| Checkbox (empty control) | **16×16** | measured |
| Slider control height | **16pt** | measured |
| Window titlebar | **32pt** on this build; 28pt on older macOS | measured, version-dependent |
| Body text | 13pt | published |
Border language: toolbar items and image buttons are *explicitly borderless*
("toolbar items don't include a bezel"); hierarchy is expressed by fill, not
borders; fields keep a hairline bezel; menus/popovers separate by shadow and
material, not borders. Concentricity rule: "buttons, text fields, headers,
and footers have corner radii that are concentric with bar corners" (rule
only, no values).
### GNOME / libadwaita
Exact values from the stylesheet; GTK px are logical pixels at 96dpi.
| Metric | Value |
|---|---|
| Button total height | min-height 24 + padding 5px 10px ⇒ **≈34px** |
| Entry | **34px** (border-box, no vertical padding) |
| Header bar | **47px** |
| Menu item | min-height **32px**, padding `0 12px`; container padding 6px |
| Switch | knob 20px, trough ≈**46×26** |
| Checkbox | 14px + 3px pad each side ⇒ **20px**, radius **6px** |
| Radius ladder | button/entry **9px** (`$button_radius`), checkbox 6px, cards 12px, menu/popover/dialog/window **15px**, menu items 9px |
| Font | Adwaita Sans 11pt = **14.67px** |
| Focus | **2px accent outline** (accent @50% default, 80% high contrast) |
Borders: **controls are borderless at normal contrast** — raised buttons,
entries, switches, menus draw shape from translucent `currentColor`-mix
fills. 1px borders are a high-contrast affordance only. Always-on hairlines
are reserved for the window outline, headerbar bottom shade, frames, and
boxed-list row separators.
### Windows 11 / WinUI 3 (Fluent 2)
Published tokens from the theme-resource XAML and Learn pages, at 100%
scaling:
| Metric | Value |
|---|---|
| Button | **32px** (padding 11,5,11,6 + 1px border + Body 14/20); `ButtonHeight=32` lives in OS generic.xaml |
| TextBox | **32px** (`TextControlThemeMinHeight`, OS generic.xaml — not fetched) |
| CheckBox box | **20×20**, 1px stroke (row 32) |
| ToggleSwitch | track **40×20** pill; knob **12×12** resting → 14 hover |
| Radius | ControlCornerRadius **4px**, OverlayCornerRadius **8px** |
| Title bar | **32px** standard, 48px "Tall" |
| Type ramp | Caption 12, Body **14**, Body Large 18, Subtitle 20, Title 28 |
| Spacing | 8 between buttons, 12 control↔label, 16 edge↔text — a **4px grid** |
| Focus | system ring drawn outside the control (negative focus margin) |
Borders: buttons and textboxes carry a **1px elevation border** (gradient
stroke, lighter top + darker bottom); the accent button keeps a border too;
checkbox box 1px; switch track 1px while off; slider borderless except the
thumb.
### KDE Plasma 6 / Breeze
Source constants (`breezemetrics.h`, `units.cpp`); the HIG names the usage
rules, not px.
| Metric | Value |
|---|---|
| Kirigami units | gridUnit **18**, smallSpacing **4**, mediumSpacing **6**, largeSpacing **8**, cornerRadius **5** |
| Button | fontHeight + 2×(6+2) ⇒ **≈33px** at the default 10pt font |
| Line edit | fontHeight + 12 ⇒ **≈29px** |
| Checkbox | **20×20**, radius 4 |
| Switch (qqc2) | **36×18**, knob 18 |
| Radius | **5px** everywhere — buttons, line edits, menus, popups, windows |
| Font | Noto Sans 10pt = **13.33px** |
| Titlebar (KWin) | max(fontHeight, 2×gridUnit = 36) + margins ⇒ **≈44px** |
| Focus | rounded ring filled highlight @30%; item views get a focus underline |
Borders: 1px outline (`PenWidth::Frame`) on all controls; **flat buttons are
borderless until hover/press/check**; menus and popups framed 1px.
## What our tokens resolve to today
`SizeTable::with_root_font_size` medium class (height 1.75rem, padding
1rem) fed by each adapter's inputs in `crates/theme/src/source/`:
| | macOS (root 13) | Windows (root 14) | GNOME (fallback 16) | KDE (fallback 16) |
|---|---|---|---|---|
| control_height | 22.75 | 24.5 | 28 | 28 |
| input_height | 24 (stated) | 32 (stated) | 30 | 30 |
| padding_x | 13 | 14 | 16 | 16 |
| text / icon / indicator | 13 / 13 / 15.9 | 14 / 14 / 17.2 | 16 / 16 / 19.6 | 16 / 16 / 19.6 |
| radius sm / control / overlay | 4 / 6 (stated) / 10 | 4 / 4 (stated) / 10 | 4 / 6 / 10 | 4 / 6 / 10 |
| titlebar | max(22.75, 34 floor) = 34 | 32 (stated) | 34 | 34 |
| switch (1.65×h × h, knob 1.225rem) | 37.5×22.75 | 40.4×24.5 | 46.2×28 | 46.2×28 |
## Gap analysis
### Heights — the "looks small" bug
| Button height | Native | Ours | Delta |
|---|---|---|---|
| Windows | 32 | 24.5 | **−7.5px (−23%)** |
| GNOME | ≈34 | 28 | **−6px (−18%)** |
| KDE | ≈33 | 28 | −5px |
| macOS | 24 | 22.75 | −1.25pt |
Root cause: native heights are not `k × font`. Windows fixes 32px; GNOME is
font + 10px padding; Breeze is fontHeight + 16px. One rem factor cannot hit
any of them — and the trap is that reading the real Linux fonts (14.67 /
13.33px) without changing the model makes our buttons *smaller* (24.4 /
23.3). The codebase already has the right seam: `SystemTheme.input_height`,
an optional platform-stated value with ratio scaling for the other classes.
`control_height` needs the same treatment. Our stated `input_height` values
(macOS 24, Windows 32) are already exactly right — the derived button height
next to them is what is off.
Text compounds it on Linux: the GNOME/KDE adapters never state
`root_font_size`, so the 16px fallback makes labels oversized (16 vs 14.67 /
13.33) while controls read undersized.
### Radii
| Radius | macOS | Windows | GNOME | KDE |
|---|---|---|---|---|
| control | 6 ~ bezel ≈6 | 4 ✓ | **6 vs 9** | 6 vs 5 ~ |
| overlay (menu/popover/dialog) | 10 ~ (unpublished, ≈10–12) | **10 vs 8** | **10 vs 15** | **10 vs 5** |
| sm (checkbox) | 4 ~ | 4 ~ | 4 vs 6 | 4 ✓ |
Only `control` is platform-overridable (`resolve`); `Radius::default`
freezes sm/overlay/full. GNOME's whole ladder sits 3–5px above ours; KDE's
overlays sit 5px below.
### Borders — the "feels weird" bug
Our current policy: `border_1()` on secondary button, input, select,
checkbox, radio, menu, popover, dialog, combobox popup, command palette,
toast, tooltip, alert, even slider/switch knobs. Primary button is
borderless.
| Element | macOS | GNOME | Windows | KDE | Ours |
|---|---|---|---|---|---|
| secondary button | fill, **no border** | fill, **no border** | 1px elevation border | 1px border | 1px — ✗mac/GNOME |
| primary button | fill, no border | fill, no border | **accent fill + border** | accent fill + border | no border — ✗Win/KDE |
| input | hairline bezel | **borderless**, 2px accent outline on focus | 1px border → accent on focus | 1px border → focus color | 1px always |
| menu / popover | **no border** (shadow/material) | no border (shadow + 1px black@5% ring) | framed flyout | 1px frame | 1px |
| separator roles | hairline ✓ | hairline ✓ | ✓ | ✓ | ✓ |
Half-right on Windows/KDE, systematically wrong on macOS/GNOME — and even on
Windows/KDE our primary button drops a border the natives keep. Our
high-contrast mode sharpening borders toward the foreground is exactly
GNOME's model (borders as a high-contrast affordance); it just never
switches off at normal contrast.
### Spacing — structural, not numeric
No spacing tokens exist in `Theme`; components hardcode a fixed 4px grid:
menu `p_1` + items `px_2 py_1`, popover `mt_1 min_w_32`, menu `min_w_56`,
toast/tooltip `px_3`/`px_2 py_1`, button `gap_1p5`, hardcoded
`max_w(480)`/`max_w(360)`. Native rhythms: GNOME = 6px base (menu padding
12px, item min-height 32px — our GNOME menu rows ≈24px), KDE = 4/6/8,
Windows and macOS ≈ 4px grid (we happen to match those two). Menu/popover
density is frozen at a macOS-ish tuning.
### Per-component calibration
- **Switch**: macOS native 54×24 (ours 37.5 wide — too narrow); Windows
40×20 with a 12px knob (ours 24.5 tall, 17px knob — too big); KDE 36×18
(ours 46×28); only GNOME ≈ matches, by accident.
- **Checkbox**: Windows/GNOME/KDE ≈20 (ours 17.2–19.6); macOS 16 ✓.
- **Icons**: macOS ≈16 (ours 13), Windows 16 (ours 14); GNOME/KDE 16 ✓.
- **Titlebar**: measured 32 on this macOS build vs our 34 floor (fine-ish;
the floor silently drifted past older macOS's 28); GNOME headerbar 47 vs
34 — a visible −13px; KDE ≈44 vs 34; Windows 32 stated ✓.
## What is already right
- The `input_height` stated-value seam — the pattern to generalize.
- Windows `ControlCornerRadius` 4; stated titlebar 32.
- Body text on macOS (13) and Windows (14); scrollbar autohide mapping;
per-platform elevation stacks; high-contrast border sharpening mirrors
GNOME's model.
- GNOME accent via portal/gsettings, KDE `DecorationFocus`/`DecorationHover`
role mapping.
## Recommended changes, in priority order
Landed 2026-11 with two deviations: Omarchy is grouped with GNOME (it
styles its apps with adwaita), and macOS's titlebar is stated at the
measured 32pt rather than queried at runtime (28pt on older builds).
1. **State `control_height` per platform** — add `control_height:
Option<Pixels>` beside `input_height` on `SystemTheme`, reuse the
ratio-scaling pattern: macOS 24 (measured), Windows 32, GNOME ≈34, KDE
≈33. Fixes the smallness everywhere in one change.
2. **State `root_font_size` from Linux font settings** — GNOME
`font-name` (11pt ⇒ 14.67px), KDE `kdeglobals [General] font=` (10pt ⇒
13.33px); the parsing seams already exist for colors.
3. **Make the radius ladder platform-overridable** — stated overlay/sm
radius on `SystemTheme`: GNOME 15/6, Windows 8/4, KDE 5/4, macOS ≈10/4;
keep preset > system precedence.
4. **Per-source border and focus policy** — a theme-level token consumed by
components: secondary button and inputs drop borders on macOS/GNOME
(fill tints + 2px accent focus outline), keep 1px on Windows/KDE;
primary gains a border on Windows/KDE; menus/popovers shadow-only on
macOS/GNOME.
5. **State `titlebar_height` per platform** — GNOME 47, KDE ≈44; macOS
queried rather than a drifted floor (32 on current builds, 28 older);
Windows stays stated.
6. **Add a minimal spacing scale to `Theme`** driven by a per-platform base
unit (GNOME 6, KDE 4/6/8, others 4); route menu/popover paddings and menu
row min-heights through it — GNOME 32 (published), Windows ≈33 (padding
4/5 + a 20px Body line + 2px item margins), KDE ≈29 (max(font, 20px
floors) + 1 + 2×4); the hardcoded `min_w_`/`max_w` surface values stay
put — they are platform-invariant.
7. **Calibrate switches and icons per platform** — macOS 54×24, Windows
40×20 with 12px knob, KDE 36×18; icon 16 on macOS/Windows.
## Source confidence notes
Everything in the GNOME/Windows/KDE tables is quoted from the cited
stylesheets, sources, or Learn pages (published). KDE button/line-edit/title
heights are formulas from source constants (derived). Windows TextBox height
token and the focus-stroke pixel spec live in the OS `generic.xaml` and were
not retrievable — low confidence, commonly cited as 32. Apple publishes no
macOS control metrics; all macOS control numbers here are AppKit
measurements from 2026-11 on this machine and may shift across macOS
releases (titlebar already differs from older builds).
+60 -9
View File
@@ -879,24 +879,75 @@ impl Render for ButtonDemo {
button = button.tooltip(Tooltip::text("Removes the selected item")); button = button.tooltip(Tooltip::text("Removes the selected item"));
} }
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 {
button = button.tooltip(Tooltip::text("Removes the selected item"));
}
button
})),
))
.child(demo_section(
"icon only",
colors,
h_flex()
.flex_wrap()
.gap_2()
.children(variants.map(|(name, variant)| {
let view = view.clone();
Button::new(SharedString::from(format!("button-demo-icon-only-{name}")))
.icon(IconRole::Folder)
.variant(variant)
.accessibility_label("Open the selected file")
.tooltip(Tooltip::text("Opens the selected file"))
.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(