audit button
This commit is contained in:
+35
-14
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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).
|
|
||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user