From d696b1e707e1b42ec13ce2411a72ff2105c1aed3 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 7 Nov 2026 14:13:15 +0700 Subject: [PATCH] feat: state platform metrics and chrome in theme --- crates/core/tests/control_sizes.rs | 13 +-- crates/theme/src/definition.rs | 6 +- crates/theme/src/lib.rs | 137 ++++++++++++++++++++++--- crates/theme/src/resolve.rs | 155 ++++++++++++++++++++++++++++- crates/theme/src/source/kde.rs | 64 +++++++++++- crates/theme/src/source/linux.rs | 137 +++++++++++++++++++------ crates/theme/src/source/macos.rs | 19 +++- crates/theme/src/source/omarchy.rs | 28 +++++- crates/theme/src/source/windows.rs | 17 ++++ crates/theme/src/system.rs | 34 ++++++- docs/CHECKLIST.md | 25 +++-- docs/platform-metrics-audit.md | 12 ++- 12 files changed, 574 insertions(+), 73 deletions(-) diff --git a/crates/core/tests/control_sizes.rs b/crates/core/tests/control_sizes.rs index 792693b..e83662b 100644 --- a/crates/core/tests/control_sizes.rs +++ b/crates/core/tests/control_sizes.rs @@ -36,10 +36,11 @@ impl Render for Harness { } } -/// A medium input is exactly `input_height`, and sits taller than the -/// medium button: native text fields are a touch taller than push buttons. +/// The medium input renders at the theme's stated field height and the +/// medium button at the stated control height; the two match wherever the +/// platform states equal values (macOS, Windows, GNOME). #[gpui::test] -fn medium_input_is_taller_than_the_button(cx: &mut TestAppContext) { +fn controls_render_at_the_stated_heights(cx: &mut TestAppContext) { cx.update(Theme::init); let state = cx.update(|cx| InputState::new_with_text("", cx)); let state_for_window = state.clone(); @@ -58,9 +59,9 @@ fn medium_input_is_taller_than_the_button(cx: &mut TestAppContext) { .debug_bounds("input-wrapper") .expect("input rendered"); - // The input's height is the theme table's (platform-stated) field height; - // both bounds are snapped to device pixels, so allow half a pixel. + // Both bounds are snapped to device pixels, so allow half a pixel. let input_height = cx.update(|cx| cx.theme().sizes.medium.input_height); - assert!(input.size.height > button.size.height); + let button_height = cx.update(|cx| cx.theme().sizes.medium.control_height); assert!((input.size.height - input_height).abs() <= gpui::px(0.5)); + assert!((button.size.height - button_height).abs() <= gpui::px(0.5)); } diff --git a/crates/theme/src/definition.rs b/crates/theme/src/definition.rs index 8bb8f4e..d697acd 100644 --- a/crates/theme/src/definition.rs +++ b/crates/theme/src/definition.rs @@ -11,6 +11,8 @@ use crate::ThemeVariant; pub struct ThemeDefinition { pub mode: Option, pub control_radius: Option, + pub overlay_radius: Option, + pub sm_radius: Option, pub accent: Option, pub accent_foreground: Option, pub background: Option, @@ -221,13 +223,15 @@ mod tests { #[test] fn parses_a_full_definition() { 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(); assert_eq!(def.mode, Some(crate::ThemeVariant::Dark)); assert!(def.accent().is_some()); assert!(def.info().is_some()); assert!(def.foreground().is_none()); + assert_eq!(def.overlay_radius, Some(15.0)); + assert_eq!(def.sm_radius, Some(6.0)); } #[test] diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index 3f84858..d4d69fa 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -48,6 +48,9 @@ pub struct Theme { pub colors: ColorTokens, pub ramps: Ramps, pub radius: Radius, + /// Per-platform border and focus chrome. + pub borders: Borders, + pub spacing: Spacing, pub elevations: Elevations, /// Overlay transition timing; gpui's animation primitives do the rest. pub motion: Motion, @@ -88,6 +91,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 (Windows, KDE; the macOS + /// hairline bezel). + pub fields: bool, + /// Floating layers keep a border (Windows, KDE). + pub overlays: bool, + /// Keyboard focus paints an accent ring around the control (macOS, + /// GNOME); otherwise focus re-colors the border (Windows, KDE). + pub focus_ring: bool, +} + +impl Borders { + pub(crate) fn for_platform(source: SourceKind) -> Self { + match source { + SourceKind::Windows | SourceKind::Kde => Self { + buttons: true, + fields: true, + overlays: true, + focus_ring: false, + }, + SourceKind::Macos => Self { + buttons: false, + fields: true, + overlays: false, + focus_ring: true, + }, + SourceKind::Omarchy | SourceKind::Gnome => Self { + buttons: false, + fields: false, + overlays: false, + 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. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Shadow { @@ -289,6 +345,12 @@ pub struct ControlMetrics { pub plate_inset: Pixels, /// The band height of multi-segment strips. 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. @@ -309,6 +371,7 @@ impl SizeTable { avatar: f32, strip: f32, inset: f32, + floor_icon: bool, } let small = ClassFactors { @@ -319,6 +382,7 @@ impl SizeTable { avatar: 1.5, strip: 1.5, inset: 0.125, + floor_icon: false, }; let medium = ClassFactors { @@ -329,6 +393,7 @@ impl SizeTable { avatar: 2.0, strip: 2.0, inset: 0.25, + floor_icon: true, }; let large = ClassFactors { @@ -339,21 +404,36 @@ impl SizeTable { avatar: 3.0, strip: 3.0, inset: 0.375, + floor_icon: true, }; - let metrics = |f: &ClassFactors| ControlMetrics { - control_height: rem_size * f.height, - input_height: rem_size * (f.height + 0.125), - padding_x: rem_size * f.padding, - text_size: rem_size * f.text, - icon_size: rem_size * f.icon, - dense_icon_size: rem_size * f.icon * 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, + let metrics = |f: &ClassFactors| { + // Standard controls carry 16px glyphs on every platform, fixed + // against the root font the way the platforms fix them. + let icon_size = if f.floor_icon { + (rem_size * f.icon).max(px(16.)) + } else { + rem_size * f.icon + }; + let switch_height = rem_size * f.height * 0.75; + ControlMetrics { + control_height: rem_size * f.height, + input_height: rem_size * (f.height + 0.125), + padding_x: rem_size * f.padding, + text_size: rem_size * f.text, + 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 { @@ -383,6 +463,35 @@ impl SizeTable { self.medium.input_height = medium_input_height; self.large.input_height *= ratio; } + + /// 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 *= ratio; + self.medium.control_height = medium_control_height; + self.large.control_height *= ratio; + } + + /// 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; + } } /// Reads the resolved theme global from any app context. @@ -408,6 +517,8 @@ impl Theme { colors: resolved.colors, ramps: resolved.ramps, radius: resolved.radius, + borders: resolved.borders, + spacing: resolved.spacing, elevations: resolved.elevations, motion: resolved.motion, scrollbar: resolved.scrollbar, diff --git a/crates/theme/src/resolve.rs b/crates/theme/src/resolve.rs index 725824a..7155736 100644 --- a/crates/theme/src/resolve.rs +++ b/crates/theme/src/resolve.rs @@ -1,7 +1,9 @@ use crate::definition::ThemeDefinition; use crate::ramps::{AccentShades, Oklch, contrast_foreground, derive_shades, mix}; 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; /// 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 ramps: Ramps, pub radius: Radius, + pub borders: Borders, + pub spacing: Spacing, pub elevations: Elevations, pub motion: Motion, pub scrollbar: Scrollbar, @@ -198,18 +202,35 @@ pub(crate) fn resolve( // // Fallback: 16 (the web default gpui also uses for rems). let root_font_size = system.root_font_size.unwrap_or(px(16.)); - 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 { 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. let titlebar_height = system .titlebar_height .unwrap_or_else(|| sizes.medium.control_height.max(px(TITLEBAR_HEIGHT_MIN_PX))); let mut radius = Radius::default(); + if let Some(control) = preset .and_then(|def| def.control_radius) .map(px) @@ -218,6 +239,22 @@ pub(crate) fn resolve( 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 { variant, ramps: Ramps { @@ -238,6 +275,10 @@ pub(crate) fn resolve( }, colors, radius, + borders: Borders::for_platform(system.source), + spacing: Spacing { + unit: system.source.spacing_unit(), + }, elevations: Elevations::for_platform(system.source, variant), motion: Motion::default(), scrollbar: Scrollbar { @@ -283,7 +324,14 @@ mod tests { accent, palette, control_radius: None, + overlay_radius: None, + sm_radius: None, + control_height: None, input_height: None, + menu_item_height: None, + switch_height: None, + switch_width: None, + switch_knob: None, titlebar_height: None, root_font_size: None, accessibility: AccessibilityPreferences::default(), @@ -308,7 +356,10 @@ mod tests { fn empty_system_resolves_to_the_neutral_fallback() { for variant in [ThemeVariant::Light, ThemeVariant::Dark] { let resolved = resolve( - &system(variant, PartialPalette::default(), None), + &SystemTheme { + source: SourceKind::Fallback, + ..system(variant, PartialPalette::default(), None) + }, None, None, ); @@ -333,6 +384,21 @@ mod tests { 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.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.)); } } @@ -396,6 +462,89 @@ mod tests { assert!((resolved.sizes.small.input_height - expected_small).abs() < px(0.01)); } + #[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; + assert!( + (resolved.sizes.small.control_height - derived.small.control_height * scale).abs() + < px(0.01) + ); + assert!( + (resolved.sizes.large.control_height - derived.large.control_height * scale).abs() + < px(0.01) + ); + } + + #[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 overlay_is_translucent_and_overridable() { let light = resolve( diff --git a/crates/theme/src/source/kde.rs b/crates/theme/src/source/kde.rs index 48720ab..e9a6acd 100644 --- a/crates/theme/src/source/kde.rs +++ b/crates/theme/src/source/kde.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; -use gpui::Hsla; +use gpui::{Hsla, Pixels, px}; use crate::system::{PartialPalette, SourceKind, SystemTheme}; @@ -14,6 +14,9 @@ pub(crate) struct KdeScheme { pub(crate) accent: Option, pub(crate) background: Option, pub(crate) palette: PartialPalette, + /// The root font size parsed from `[General] font=` (family, pointSize, + /// pixelSize, ...). + pub(crate) root_font_size: Option, } impl KdeScheme { @@ -54,6 +57,7 @@ impl KdeScheme { accent, background, palette, + root_font_size: root_font_size(groups.get("General")), } } @@ -77,10 +81,17 @@ impl KdeScheme { .unwrap_or(crate::ThemeVariant::Light), accent: self.accent.or(portal_accent), palette: self.palette, - control_radius: None, - input_height: None, - titlebar_height: None, - root_font_size: None, + control_radius: Some(px(5.)), + overlay_radius: Some(px(5.)), + sm_radius: 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, 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 { + let fields: Vec<&str> = group?.get("font")?.split(',').map(str::trim).collect(); + let points = fields.get(1)?.parse::().ok()?; + let pixels = fields + .get(2) + .and_then(|size| size.parse::().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>> { let mut groups: HashMap<&str, HashMap<&str, &str>> = HashMap::new(); 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)); 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.))); + } } diff --git a/crates/theme/src/source/linux.rs b/crates/theme/src/source/linux.rs index 5e7f9d5..c6acece 100644 --- a/crates/theme/src/source/linux.rs +++ b/crates/theme/src/source/linux.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; -use gpui::Hsla; +use gpui::{Hsla, Pixels}; use notify::{RecursiveMode, Watcher}; 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)] struct GSettingsSnapshot { variant: Option, accent: Option, + root_font_size: Option, } fn gsettings_value(args: &[&str]) -> Option { @@ -97,23 +98,31 @@ fn read_gsettings() -> Option { "prefer-light" => Some(ThemeVariant::Light), _ => None, }); + let accent = gsettings_value(&["get", "org.gnome.desktop.interface", "accent-color"]) .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; } - Some(GSettingsSnapshot { variant, accent }) + Some(GSettingsSnapshot { + variant, + accent, + root_font_size, + }) +} + +fn font_root_px(name: &str) -> Option { + let points = name.split_whitespace().last()?.parse::().ok()?; + (points > 0.0).then(|| gpui::px(points * 96.0 / 72.0)) } pub(crate) fn platform_source() -> Box { let portal = portal::io::PortalClient::connect(); - - let gsettings = if portal.is_none() { - read_gsettings() - } else { - None - }; + let gsettings = read_gsettings(); let backend = home_dir() .map(|home| detect(&home, portal.is_some(), gsettings.is_some())) @@ -129,6 +138,36 @@ pub(crate) fn platform_source() -> Box { } impl LinuxSource { + fn gnome_snapshot( + &self, + variant: ThemeVariant, + accent: Option, + 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( &self, variant: ThemeVariant, @@ -136,31 +175,21 @@ impl LinuxSource { accessibility: crate::system::AccessibilityPreferences, ) -> SystemTheme { if let Some(appearance) = portal_appearance { - return SystemTheme { - variant: appearance.variant(variant), - accent: appearance.accent, - palette: PartialPalette::default(), - control_radius: None, - input_height: None, - titlebar_height: None, - root_font_size: None, + return self.gnome_snapshot( + appearance.variant(variant), + appearance.accent, accessibility, - source: SourceKind::Gnome, - }; + ); } + if let Some(gsettings) = self.gsettings { - return SystemTheme { - variant: gsettings.variant.unwrap_or(variant), - accent: gsettings.accent, - palette: PartialPalette::default(), - control_radius: None, - input_height: None, - titlebar_height: None, - root_font_size: None, + return self.gnome_snapshot( + gsettings.variant.unwrap_or(variant), + gsettings.accent, accessibility, - source: SourceKind::Gnome, - }; + ); } + SystemTheme::fallback(variant) } } @@ -289,4 +318,52 @@ mod tests { assert_eq!(detect(&home, true, false), Backend::Kde); 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))); + } } diff --git a/crates/theme/src/source/macos.rs b/crates/theme/src/source/macos.rs index 9b4caf1..30e3763 100644 --- a/crates/theme/src/source/macos.rs +++ b/crates/theme/src/source/macos.rs @@ -22,6 +22,16 @@ use crate::system::{AccessibilityPreferences, PartialPalette, SourceKind, System const CONTROL_RADIUS_PTS: f32 = 6.0; /// The rounded-bezel text field's intrinsic height at regular size. 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). const ROOT_FONT_SIZE_PTS: f32 = 13.0; @@ -112,8 +122,15 @@ impl ThemeSource for MacosSource { accent: Self::color_to_hsla(&NSColor::controlAccentColor()), palette, 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)), - 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)), accessibility, source: SourceKind::Macos, diff --git a/crates/theme/src/source/omarchy.rs b/crates/theme/src/source/omarchy.rs index f411c44..2476a6e 100644 --- a/crates/theme/src/source/omarchy.rs +++ b/crates/theme/src/source/omarchy.rs @@ -60,8 +60,15 @@ impl OmarchyTheme { variant: self.variant, accent: self.accent, palette: self.palette, - control_radius: None, - input_height: None, + 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: None, root_font_size: None, accessibility, @@ -215,6 +222,23 @@ color15 = \"#635d6f\" assert_eq!(theme.variant, ThemeVariant::Light); } + #[test] + fn omarchy_shares_the_gnome_control_metrics() { + 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); + } + #[test] fn non_toml_text_is_rejected() { assert!(OmarchyTheme::parse("not a theme").is_none()); diff --git a/crates/theme/src/source/windows.rs b/crates/theme/src/source/windows.rs index 198d2b8..a2f66aa 100644 --- a/crates/theme/src/source/windows.rs +++ b/crates/theme/src/source/windows.rs @@ -14,8 +14,18 @@ use crate::system::{AccessibilityPreferences, PartialPalette, SourceKind, System /// WinUI 11's `ControlCornerRadius`, in pixels at 100% scaling. 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. 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. const TITLEBAR_HEIGHT_PX: f32 = 32.0; /// WinUI 11's default body text size, in pixels at 100% scaling. @@ -159,7 +169,14 @@ impl ThemeSource for WindowsSource { ..PartialPalette::default() }, 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)), + 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)), root_font_size: Some(gpui::px(ROOT_FONT_SIZE_PX * text_scale as f32)), accessibility: self.accessibility(), diff --git a/crates/theme/src/system.rs b/crates/theme/src/system.rs index 671e170..7be281b 100644 --- a/crates/theme/src/system.rs +++ b/crates/theme/src/system.rs @@ -1,6 +1,6 @@ //! The normalized snapshot a platform adapter produces. -use gpui::{Hsla, Pixels}; +use gpui::{Hsla, Pixels, px}; use crate::ThemeVariant; @@ -43,6 +43,17 @@ impl SourceKind { pub fn scrollbar_autohide(self) -> bool { 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. @@ -83,8 +94,22 @@ pub struct SystemTheme { pub palette: PartialPalette, /// The platform's stated control corner radius, if it has one. pub control_radius: Option, + /// The platform's stated menu/popover radius, if it has one. + pub overlay_radius: Option, + /// The platform's stated checkbox radius, if it has one. + pub sm_radius: Option, + /// The platform's stated control height, if it has one. + pub control_height: Option, /// The platform's stated text-field height, if it has one. pub input_height: Option, + /// The platform's stated menu row height, if it has one. + pub menu_item_height: Option, + /// The platform's stated switch track height, if it has one. + pub switch_height: Option, + /// The platform's stated switch track width, if it has one. + pub switch_width: Option, + /// The platform's stated switch knob size, if it has one. + pub switch_knob: Option, /// The platform's native titlebar height, if it states one. pub titlebar_height: Option, /// The platform's native root font size, if it has one. @@ -100,7 +125,14 @@ impl SystemTheme { accent: None, palette: PartialPalette::default(), control_radius: None, + overlay_radius: None, + sm_radius: None, + control_height: None, input_height: None, + menu_item_height: None, + switch_height: None, + switch_width: None, + switch_knob: None, titlebar_height: None, root_font_size: None, accessibility: AccessibilityPreferences::default(), diff --git a/docs/CHECKLIST.md b/docs/CHECKLIST.md index 3f134d2..907d183 100644 --- a/docs/CHECKLIST.md +++ b/docs/CHECKLIST.md @@ -9,18 +9,20 @@ is the first downstream consumer; its screens drive the order. ## Next +- [ ] **Platform metric adoption** — update components one at a time + through the review gate against the landed theme tokens. The stated + metrics and the radius ladder already flow through the size table, + 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 Gaps surfaced by the signed comparison; ordered by signed's usage. -- [ ] **Platform metric fidelity** — native-sizing pass over the theme and - components: stated `control_height` per platform (macOS 24 measured / - Windows 32 / GNOME ≈34 / KDE ≈33), Linux root font sizes (GNOME 14.67px, - KDE 13.33px), platform overlay/sm radii, per-source border + focus - policy (macOS/GNOME borderless fills, Windows/KDE 1px), titlebar - heights (GNOME 47), a theme spacing scale, switch/icon calibration; - ranked findings in - [platform-metrics-audit.md](platform-metrics-audit.md). - [ ] **Virtual list** — generic virtualized variable-height rows with a scroll handle, promoted from the editor views' pattern over gpui's `list`; signed's PR lists and commit diff drive it. @@ -55,6 +57,13 @@ Gaps surfaced by the signed comparison; ordered by signed's usage. - [x] Platform sources — macOS (NSColor), Windows 11 (UISettings), GNOME (portal), KDE (kdeglobals), Omarchy (colors.toml), neutral fallback; 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`); findings in + [platform-metrics-audit.md](platform-metrics-audit.md). Components + adopt the tokens one by one (see Next) ### icons (`crates/icons`) diff --git a/docs/platform-metrics-audit.md b/docs/platform-metrics-audit.md index 232b102..5d15b7a 100644 --- a/docs/platform-metrics-audit.md +++ b/docs/platform-metrics-audit.md @@ -229,6 +229,10 @@ density is frozen at a macOS-ish tuning. ## 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` beside `input_height` on `SystemTheme`, reuse the ratio-scaling pattern: macOS 24 (measured), Windows 32, GNOME ≈34, KDE @@ -248,9 +252,11 @@ density is frozen at a macOS-ish tuning. 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, menu - item min-height (GNOME 32), and the hardcoded `min_w_`/`max_w` values - through it. + 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.