feat: state platform metrics and chrome in theme

This commit is contained in:
2026-11-07 14:13:15 +07:00
parent 9e378ff7ff
commit d696b1e707
12 changed files with 574 additions and 73 deletions
+7 -6
View File
@@ -36,10 +36,11 @@ impl Render for Harness {
} }
} }
/// A medium input is exactly `input_height`, and sits taller than the /// The medium input renders at the theme's stated field height and the
/// medium button: native text fields are a touch taller than push buttons. /// medium button at the stated control height; the two match wherever the
/// platform states equal values (macOS, Windows, GNOME).
#[gpui::test] #[gpui::test]
fn medium_input_is_taller_than_the_button(cx: &mut TestAppContext) { fn controls_render_at_the_stated_heights(cx: &mut TestAppContext) {
cx.update(Theme::init); cx.update(Theme::init);
let state = cx.update(|cx| InputState::new_with_text("", cx)); let state = cx.update(|cx| InputState::new_with_text("", cx));
let state_for_window = state.clone(); let state_for_window = state.clone();
@@ -58,9 +59,9 @@ fn medium_input_is_taller_than_the_button(cx: &mut TestAppContext) {
.debug_bounds("input-wrapper") .debug_bounds("input-wrapper")
.expect("input rendered"); .expect("input rendered");
// The input's height is the theme table's (platform-stated) field height; // Both bounds are snapped to device pixels, so allow half a pixel.
// both bounds are snapped to device pixels, so allow half a pixel.
let input_height = cx.update(|cx| cx.theme().sizes.medium.input_height); 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!((input.size.height - input_height).abs() <= gpui::px(0.5));
assert!((button.size.height - button_height).abs() <= gpui::px(0.5));
} }
+5 -1
View File
@@ -11,6 +11,8 @@ use crate::ThemeVariant;
pub struct ThemeDefinition { pub struct ThemeDefinition {
pub mode: Option<ThemeVariant>, pub mode: Option<ThemeVariant>,
pub control_radius: Option<f32>, pub control_radius: Option<f32>,
pub overlay_radius: Option<f32>,
pub sm_radius: Option<f32>,
pub accent: Option<String>, pub accent: Option<String>,
pub accent_foreground: Option<String>, pub accent_foreground: Option<String>,
pub background: Option<String>, pub background: Option<String>,
@@ -221,13 +223,15 @@ mod tests {
#[test] #[test]
fn parses_a_full_definition() { fn parses_a_full_definition() {
let def = ThemeDefinition::parse_toml( let def = ThemeDefinition::parse_toml(
"mode = \"dark\"\naccent = \"#7aa2f7\"\nbackground = \"#1a1b26\"\ninfo = \"#38bdf8\"\n", "mode = \"dark\"\naccent = \"#7aa2f7\"\nbackground = \"#1a1b26\"\ninfo = \"#38bdf8\"\noverlay_radius = 15.0\nsm_radius = 6.0\n",
) )
.unwrap(); .unwrap();
assert_eq!(def.mode, Some(crate::ThemeVariant::Dark)); assert_eq!(def.mode, Some(crate::ThemeVariant::Dark));
assert!(def.accent().is_some()); assert!(def.accent().is_some());
assert!(def.info().is_some()); assert!(def.info().is_some());
assert!(def.foreground().is_none()); assert!(def.foreground().is_none());
assert_eq!(def.overlay_radius, Some(15.0));
assert_eq!(def.sm_radius, Some(6.0));
} }
#[test] #[test]
+124 -13
View File
@@ -48,6 +48,9 @@ pub struct Theme {
pub colors: ColorTokens, pub colors: ColorTokens,
pub ramps: Ramps, pub ramps: Ramps,
pub radius: Radius, pub radius: Radius,
/// Per-platform border and focus chrome.
pub borders: Borders,
pub spacing: Spacing,
pub elevations: Elevations, pub elevations: Elevations,
/// Overlay transition timing; gpui's animation primitives do the rest. /// Overlay transition timing; gpui's animation primitives do the rest.
pub motion: Motion, pub motion: Motion,
@@ -88,6 +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. /// One layer of a floating panel's drop shadow.
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct Shadow { pub struct Shadow {
@@ -289,6 +345,12 @@ pub struct ControlMetrics {
pub plate_inset: Pixels, pub plate_inset: Pixels,
/// The band height of multi-segment strips. /// The band height of multi-segment strips.
pub strip_height: Pixels, pub strip_height: Pixels,
/// The height of one menu row.
pub menu_item_height: Pixels,
/// Switch track height, width, and knob size.
pub switch_height: Pixels,
pub switch_width: Pixels,
pub switch_knob: Pixels,
} }
/// The control metrics for every size class. /// The control metrics for every size class.
@@ -309,6 +371,7 @@ impl SizeTable {
avatar: f32, avatar: f32,
strip: f32, strip: f32,
inset: f32, inset: f32,
floor_icon: bool,
} }
let small = ClassFactors { let small = ClassFactors {
@@ -319,6 +382,7 @@ impl SizeTable {
avatar: 1.5, avatar: 1.5,
strip: 1.5, strip: 1.5,
inset: 0.125, inset: 0.125,
floor_icon: false,
}; };
let medium = ClassFactors { let medium = ClassFactors {
@@ -329,6 +393,7 @@ impl SizeTable {
avatar: 2.0, avatar: 2.0,
strip: 2.0, strip: 2.0,
inset: 0.25, inset: 0.25,
floor_icon: true,
}; };
let large = ClassFactors { let large = ClassFactors {
@@ -339,21 +404,36 @@ impl SizeTable {
avatar: 3.0, avatar: 3.0,
strip: 3.0, strip: 3.0,
inset: 0.375, inset: 0.375,
floor_icon: true,
}; };
let metrics = |f: &ClassFactors| ControlMetrics { let metrics = |f: &ClassFactors| {
control_height: rem_size * f.height, // Standard controls carry 16px glyphs on every platform, fixed
input_height: rem_size * (f.height + 0.125), // against the root font the way the platforms fix them.
padding_x: rem_size * f.padding, let icon_size = if f.floor_icon {
text_size: rem_size * f.text, (rem_size * f.icon).max(px(16.))
icon_size: rem_size * f.icon, } else {
dense_icon_size: rem_size * f.icon * 0.75, rem_size * f.icon
indicator_size: rem_size * f.height * 0.7, };
avatar_size: rem_size * f.avatar, let switch_height = rem_size * f.height * 0.75;
caption_size: rem_size * f.text, ControlMetrics {
dense_padding_x: rem_size * f.padding * 0.75, control_height: rem_size * f.height,
plate_inset: rem_size * f.inset, input_height: rem_size * (f.height + 0.125),
strip_height: rem_size * f.strip, 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 { let mut table = Self {
@@ -383,6 +463,35 @@ impl SizeTable {
self.medium.input_height = medium_input_height; self.medium.input_height = medium_input_height;
self.large.input_height *= ratio; 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. /// Reads the resolved theme global from any app context.
@@ -408,6 +517,8 @@ impl Theme {
colors: resolved.colors, colors: resolved.colors,
ramps: resolved.ramps, ramps: resolved.ramps,
radius: resolved.radius, radius: resolved.radius,
borders: resolved.borders,
spacing: resolved.spacing,
elevations: resolved.elevations, elevations: resolved.elevations,
motion: resolved.motion, motion: resolved.motion,
scrollbar: resolved.scrollbar, scrollbar: resolved.scrollbar,
+152 -3
View File
@@ -1,7 +1,9 @@
use crate::definition::ThemeDefinition; use crate::definition::ThemeDefinition;
use crate::ramps::{AccentShades, Oklch, contrast_foreground, derive_shades, mix}; use crate::ramps::{AccentShades, Oklch, contrast_foreground, derive_shades, mix};
use crate::system::SystemTheme; use crate::system::SystemTheme;
use crate::{ColorTokens, Elevations, Motion, Radius, Scrollbar, SizeTable, ThemeVariant}; use crate::{
Borders, ColorTokens, Elevations, Motion, Radius, Scrollbar, SizeTable, Spacing, ThemeVariant,
};
use gpui::px; use gpui::px;
/// The floor of a custom titlebar's height, in px at 100% scaling: the height /// The floor of a custom titlebar's height, in px at 100% scaling: the height
@@ -26,6 +28,8 @@ pub struct ResolvedTheme {
pub colors: ColorTokens, pub colors: ColorTokens,
pub ramps: Ramps, pub ramps: Ramps,
pub radius: Radius, pub radius: Radius,
pub borders: Borders,
pub spacing: Spacing,
pub elevations: Elevations, pub elevations: Elevations,
pub motion: Motion, pub motion: Motion,
pub scrollbar: Scrollbar, pub scrollbar: Scrollbar,
@@ -198,18 +202,35 @@ pub(crate) fn resolve(
// //
// Fallback: 16 (the web default gpui also uses for rems). // Fallback: 16 (the web default gpui also uses for rems).
let root_font_size = system.root_font_size.unwrap_or(px(16.)); let root_font_size = system.root_font_size.unwrap_or(px(16.));
let mut sizes = SizeTable::with_root_font_size(root_font_size); let mut sizes = SizeTable::with_root_font_size(root_font_size);
if let Some(control_height) = system.control_height {
sizes.set_control_height(control_height);
}
if let Some(input_height) = system.input_height { if let Some(input_height) = system.input_height {
sizes.set_input_height(input_height); sizes.set_input_height(input_height);
} }
if let Some(switch_height) = system.switch_height {
sizes.set_switch_metrics(
switch_height,
system.switch_width.unwrap_or(switch_height * 2.25),
system.switch_knob.unwrap_or(switch_height * 0.85),
);
}
if let Some(menu_item_height) = system.menu_item_height {
sizes.set_menu_item_height(menu_item_height);
}
// A platform that states its titlebar height wins. // A platform that states its titlebar height wins.
let titlebar_height = system let titlebar_height = system
.titlebar_height .titlebar_height
.unwrap_or_else(|| sizes.medium.control_height.max(px(TITLEBAR_HEIGHT_MIN_PX))); .unwrap_or_else(|| sizes.medium.control_height.max(px(TITLEBAR_HEIGHT_MIN_PX)));
let mut radius = Radius::default(); let mut radius = Radius::default();
if let Some(control) = preset if let Some(control) = preset
.and_then(|def| def.control_radius) .and_then(|def| def.control_radius)
.map(px) .map(px)
@@ -218,6 +239,22 @@ pub(crate) fn resolve(
radius.control = control; radius.control = control;
} }
if let Some(overlay) = preset
.and_then(|def| def.overlay_radius)
.map(px)
.or(system.overlay_radius)
{
radius.overlay = overlay;
}
if let Some(sm) = preset
.and_then(|def| def.sm_radius)
.map(px)
.or(system.sm_radius)
{
radius.sm = sm;
}
ResolvedTheme { ResolvedTheme {
variant, variant,
ramps: Ramps { ramps: Ramps {
@@ -238,6 +275,10 @@ pub(crate) fn resolve(
}, },
colors, colors,
radius, radius,
borders: Borders::for_platform(system.source),
spacing: Spacing {
unit: system.source.spacing_unit(),
},
elevations: Elevations::for_platform(system.source, variant), elevations: Elevations::for_platform(system.source, variant),
motion: Motion::default(), motion: Motion::default(),
scrollbar: Scrollbar { scrollbar: Scrollbar {
@@ -283,7 +324,14 @@ mod tests {
accent, accent,
palette, palette,
control_radius: None, control_radius: None,
overlay_radius: None,
sm_radius: None,
control_height: None,
input_height: None, input_height: None,
menu_item_height: None,
switch_height: None,
switch_width: None,
switch_knob: None,
titlebar_height: None, titlebar_height: None,
root_font_size: None, root_font_size: None,
accessibility: AccessibilityPreferences::default(), accessibility: AccessibilityPreferences::default(),
@@ -308,7 +356,10 @@ mod tests {
fn empty_system_resolves_to_the_neutral_fallback() { fn empty_system_resolves_to_the_neutral_fallback() {
for variant in [ThemeVariant::Light, ThemeVariant::Dark] { for variant in [ThemeVariant::Light, ThemeVariant::Dark] {
let resolved = resolve( let resolved = resolve(
&system(variant, PartialPalette::default(), None), &SystemTheme {
source: SourceKind::Fallback,
..system(variant, PartialPalette::default(), None)
},
None, None,
None, None,
); );
@@ -333,6 +384,21 @@ mod tests {
assert_eq!(resolved.sizes.medium.dense_padding_x, px(16. * 0.75)); assert_eq!(resolved.sizes.medium.dense_padding_x, px(16. * 0.75));
assert_eq!(resolved.sizes.medium.plate_inset, px(4.)); assert_eq!(resolved.sizes.medium.plate_inset, px(4.));
assert_eq!(resolved.sizes.medium.strip_height, px(32.)); assert_eq!(resolved.sizes.medium.strip_height, px(32.));
assert_eq!(resolved.sizes.medium.icon_size, px(16.));
assert_eq!(resolved.sizes.medium.menu_item_height, px(28. * 0.85));
assert_eq!(resolved.sizes.medium.switch_height, px(21.));
assert_eq!(resolved.sizes.medium.switch_width, px(21. * 2.25));
assert_eq!(resolved.sizes.medium.switch_knob, px(21. * 0.85));
assert_eq!(
resolved.borders,
Borders {
buttons: true,
fields: true,
overlays: true,
focus_ring: false,
}
);
assert_eq!(resolved.spacing.unit, px(4.));
assert_eq!(resolved.titlebar_height, px(34.)); assert_eq!(resolved.titlebar_height, px(34.));
} }
} }
@@ -396,6 +462,89 @@ mod tests {
assert!((resolved.sizes.small.input_height - expected_small).abs() < px(0.01)); 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] #[test]
fn overlay_is_translucent_and_overridable() { fn overlay_is_translucent_and_overridable() {
let light = resolve( let light = resolve(
+59 -5
View File
@@ -4,7 +4,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use gpui::Hsla; use gpui::{Hsla, Pixels, px};
use crate::system::{PartialPalette, SourceKind, SystemTheme}; use crate::system::{PartialPalette, SourceKind, SystemTheme};
@@ -14,6 +14,9 @@ pub(crate) struct KdeScheme {
pub(crate) accent: Option<Hsla>, pub(crate) accent: Option<Hsla>,
pub(crate) background: Option<Hsla>, pub(crate) background: Option<Hsla>,
pub(crate) palette: PartialPalette, pub(crate) palette: PartialPalette,
/// The root font size parsed from `[General] font=` (family, pointSize,
/// pixelSize, ...).
pub(crate) root_font_size: Option<Pixels>,
} }
impl KdeScheme { impl KdeScheme {
@@ -54,6 +57,7 @@ impl KdeScheme {
accent, accent,
background, background,
palette, palette,
root_font_size: root_font_size(groups.get("General")),
} }
} }
@@ -77,10 +81,17 @@ impl KdeScheme {
.unwrap_or(crate::ThemeVariant::Light), .unwrap_or(crate::ThemeVariant::Light),
accent: self.accent.or(portal_accent), accent: self.accent.or(portal_accent),
palette: self.palette, palette: self.palette,
control_radius: None, control_radius: Some(px(5.)),
input_height: None, overlay_radius: Some(px(5.)),
titlebar_height: None, sm_radius: None,
root_font_size: None, control_height: Some(px(33.)),
input_height: Some(px(29.)),
menu_item_height: Some(px(29.)),
switch_height: Some(px(18.)),
switch_width: Some(px(36.)),
switch_knob: Some(px(18.)),
titlebar_height: Some(px(44.)),
root_font_size: Some(self.root_font_size.unwrap_or(px(13.33))),
accessibility, accessibility,
source: SourceKind::Kde, source: SourceKind::Kde,
} }
@@ -95,6 +106,22 @@ fn variant_by_lightness(background: Hsla) -> crate::ThemeVariant {
} }
} }
/// The root font size of a `[General]` group's `font=` QFont string.
fn root_font_size(group: Option<&HashMap<&str, &str>>) -> Option<Pixels> {
let fields: Vec<&str> = group?.get("font")?.split(',').map(str::trim).collect();
let points = fields.get(1)?.parse::<f32>().ok()?;
let pixels = fields
.get(2)
.and_then(|size| size.parse::<f32>().ok())
.unwrap_or(-1.0);
let size = if pixels > 0.0 {
pixels
} else {
points * 96.0 / 72.0
};
(size > 0.0).then(|| px(size))
}
fn parse_groups(text: &str) -> HashMap<&str, HashMap<&str, &str>> { fn parse_groups(text: &str) -> HashMap<&str, HashMap<&str, &str>> {
let mut groups: HashMap<&str, HashMap<&str, &str>> = HashMap::new(); let mut groups: HashMap<&str, HashMap<&str, &str>> = HashMap::new();
let mut section: Option<&str> = None; let mut section: Option<&str> = None;
@@ -305,4 +332,31 @@ activeBackground=39,44,49
let system = scheme.into_system(Default::default(), None, Some(crate::ThemeVariant::Light)); let system = scheme.into_system(Default::default(), None, Some(crate::ThemeVariant::Light));
assert_eq!(system.variant, crate::ThemeVariant::Light); assert_eq!(system.variant, crate::ThemeVariant::Light);
} }
#[test]
fn the_scheme_states_breeze_metrics_and_the_default_font() {
let system = KdeScheme::parse(BREEZE_DARK).into_system(Default::default(), None, None);
assert_eq!(system.control_height, Some(px(33.)));
assert_eq!(system.input_height, Some(px(29.)));
assert_eq!(system.menu_item_height, Some(px(29.)));
assert_eq!(system.control_radius, Some(px(5.)));
assert_eq!(system.overlay_radius, Some(px(5.)));
assert_eq!(system.switch_height, Some(px(18.)));
assert_eq!(system.switch_width, Some(px(36.)));
assert_eq!(system.switch_knob, Some(px(18.)));
assert_eq!(system.titlebar_height, Some(px(44.)));
assert!((system.root_font_size.unwrap() - px(13.33)).abs() < px(0.01));
}
#[test]
fn the_stated_font_beats_the_default() {
let text = "[General]\nfont=Noto Sans,10,-1,5,50,0,0,0,0,0\n";
let system = KdeScheme::parse(text).into_system(Default::default(), None, None);
assert!((system.root_font_size.unwrap() - px(10.0 * 96.0 / 72.0)).abs() < px(0.01));
let text = "[General]\nfont=Adwaita Sans,-1,12,5,50,0,0,0,0,0\n";
let system = KdeScheme::parse(text).into_system(Default::default(), None, None);
assert_eq!(system.root_font_size, Some(px(12.)));
}
} }
+107 -30
View File
@@ -5,7 +5,7 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use gpui::Hsla; use gpui::{Hsla, Pixels};
use notify::{RecursiveMode, Watcher}; use notify::{RecursiveMode, Watcher};
use super::ThemeSource; use super::ThemeSource;
@@ -55,11 +55,12 @@ fn detect(home: &Path, portal_reachable: bool, gsettings_reachable: bool) -> Bac
} }
} }
/// What `gsettings` states for GNOME sessions without a portal. /// What `gsettings` states for GNOME sessions.
#[derive(Clone, Copy, Debug, Default, PartialEq)] #[derive(Clone, Copy, Debug, Default, PartialEq)]
struct GSettingsSnapshot { struct GSettingsSnapshot {
variant: Option<ThemeVariant>, variant: Option<ThemeVariant>,
accent: Option<Hsla>, accent: Option<Hsla>,
root_font_size: Option<Pixels>,
} }
fn gsettings_value(args: &[&str]) -> Option<String> { fn gsettings_value(args: &[&str]) -> Option<String> {
@@ -97,23 +98,31 @@ fn read_gsettings() -> Option<GSettingsSnapshot> {
"prefer-light" => Some(ThemeVariant::Light), "prefer-light" => Some(ThemeVariant::Light),
_ => None, _ => None,
}); });
let accent = gsettings_value(&["get", "org.gnome.desktop.interface", "accent-color"]) let accent = gsettings_value(&["get", "org.gnome.desktop.interface", "accent-color"])
.and_then(|value| accent_rgb(value.trim().trim_matches('\''))); .and_then(|value| accent_rgb(value.trim().trim_matches('\'')));
if variant.is_none() && accent.is_none() { let root_font_size = gsettings_value(&["get", "org.gnome.desktop.interface", "font-name"])
.and_then(|value| font_root_px(value.trim().trim_matches('\'')));
if variant.is_none() && accent.is_none() && root_font_size.is_none() {
return None; return None;
} }
Some(GSettingsSnapshot { variant, accent }) Some(GSettingsSnapshot {
variant,
accent,
root_font_size,
})
}
fn font_root_px(name: &str) -> Option<Pixels> {
let points = name.split_whitespace().last()?.parse::<f32>().ok()?;
(points > 0.0).then(|| gpui::px(points * 96.0 / 72.0))
} }
pub(crate) fn platform_source() -> Box<dyn ThemeSource> { pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
let portal = portal::io::PortalClient::connect(); let portal = portal::io::PortalClient::connect();
let gsettings = read_gsettings();
let gsettings = if portal.is_none() {
read_gsettings()
} else {
None
};
let backend = home_dir() let backend = home_dir()
.map(|home| detect(&home, portal.is_some(), gsettings.is_some())) .map(|home| detect(&home, portal.is_some(), gsettings.is_some()))
@@ -129,6 +138,36 @@ pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
} }
impl LinuxSource { impl LinuxSource {
fn gnome_snapshot(
&self,
variant: ThemeVariant,
accent: Option<Hsla>,
accessibility: crate::system::AccessibilityPreferences,
) -> SystemTheme {
SystemTheme {
variant,
accent,
palette: PartialPalette::default(),
control_radius: Some(gpui::px(9.)),
overlay_radius: Some(gpui::px(15.)),
sm_radius: Some(gpui::px(6.)),
control_height: Some(gpui::px(34.)),
input_height: Some(gpui::px(34.)),
menu_item_height: Some(gpui::px(32.)),
switch_height: Some(gpui::px(26.)),
switch_width: Some(gpui::px(46.)),
switch_knob: Some(gpui::px(20.)),
titlebar_height: Some(gpui::px(47.)),
root_font_size: Some(
self.gsettings
.and_then(|gsettings| gsettings.root_font_size)
.unwrap_or(gpui::px(14.67)),
),
accessibility,
source: SourceKind::Gnome,
}
}
fn portal_snapshot( fn portal_snapshot(
&self, &self,
variant: ThemeVariant, variant: ThemeVariant,
@@ -136,31 +175,21 @@ impl LinuxSource {
accessibility: crate::system::AccessibilityPreferences, accessibility: crate::system::AccessibilityPreferences,
) -> SystemTheme { ) -> SystemTheme {
if let Some(appearance) = portal_appearance { if let Some(appearance) = portal_appearance {
return SystemTheme { return self.gnome_snapshot(
variant: appearance.variant(variant), appearance.variant(variant),
accent: appearance.accent, appearance.accent,
palette: PartialPalette::default(),
control_radius: None,
input_height: None,
titlebar_height: None,
root_font_size: None,
accessibility, accessibility,
source: SourceKind::Gnome, );
};
} }
if let Some(gsettings) = self.gsettings { if let Some(gsettings) = self.gsettings {
return SystemTheme { return self.gnome_snapshot(
variant: gsettings.variant.unwrap_or(variant), gsettings.variant.unwrap_or(variant),
accent: gsettings.accent, gsettings.accent,
palette: PartialPalette::default(),
control_radius: None,
input_height: None,
titlebar_height: None,
root_font_size: None,
accessibility, accessibility,
source: SourceKind::Gnome, );
};
} }
SystemTheme::fallback(variant) SystemTheme::fallback(variant)
} }
} }
@@ -289,4 +318,52 @@ mod tests {
assert_eq!(detect(&home, true, false), Backend::Kde); assert_eq!(detect(&home, true, false), Backend::Kde);
std::fs::remove_dir_all(&home).expect("cleanup"); std::fs::remove_dir_all(&home).expect("cleanup");
} }
#[test]
fn the_gsettings_font_name_states_the_root_font_size() {
assert_eq!(
font_root_px("Adwaita Sans 11"),
Some(gpui::px(11.0 * 96.0 / 72.0))
);
assert_eq!(
font_root_px("Noto Sans 10"),
Some(gpui::px(10.0 * 96.0 / 72.0))
);
assert_eq!(
font_root_px("Cantarell Bold 12.5"),
Some(gpui::px(12.5 * 96.0 / 72.0))
);
assert_eq!(font_root_px("Adwaita Sans"), None);
assert_eq!(font_root_px("Sans 0"), None);
}
#[test]
fn the_gnome_snapshot_states_the_libadwaita_metrics() {
let source = LinuxSource {
backend: Backend::Portal,
portal: None,
gsettings: None,
file_watcher: None,
_signal: None,
};
let system = source.gnome_snapshot(
ThemeVariant::Light,
None,
crate::system::AccessibilityPreferences::default(),
);
assert_eq!(system.source, SourceKind::Gnome);
assert_eq!(system.control_height, Some(gpui::px(34.)));
assert_eq!(system.input_height, Some(gpui::px(34.)));
assert_eq!(system.menu_item_height, Some(gpui::px(32.)));
assert_eq!(system.control_radius, Some(gpui::px(9.)));
assert_eq!(system.overlay_radius, Some(gpui::px(15.)));
assert_eq!(system.sm_radius, Some(gpui::px(6.)));
assert_eq!(system.switch_height, Some(gpui::px(26.)));
assert_eq!(system.switch_width, Some(gpui::px(46.)));
assert_eq!(system.switch_knob, Some(gpui::px(20.)));
assert_eq!(system.titlebar_height, Some(gpui::px(47.)));
// The default 11pt font when gsettings states none.
assert_eq!(system.root_font_size, Some(gpui::px(14.67)));
}
} }
+18 -1
View File
@@ -22,6 +22,16 @@ use crate::system::{AccessibilityPreferences, PartialPalette, SourceKind, System
const CONTROL_RADIUS_PTS: f32 = 6.0; const CONTROL_RADIUS_PTS: f32 = 6.0;
/// The rounded-bezel text field's intrinsic height at regular size. /// The rounded-bezel text field's intrinsic height at regular size.
const INPUT_HEIGHT_PTS: f32 = 24.0; const INPUT_HEIGHT_PTS: f32 = 24.0;
/// AppKit's regular push button, measured via `NSButton::fittingSize`.
const CONTROL_HEIGHT_PTS: f32 = 24.0;
/// The standard `NSMenu` row height.
const MENU_ITEM_HEIGHT_PTS: f32 = 20.0;
/// `NSSwitch`, measured via `fittingSize`.
const SWITCH_TRACK_HEIGHT_PTS: f32 = 24.0;
const SWITCH_TRACK_WIDTH_PTS: f32 = 54.0;
const SWITCH_KNOB_PTS: f32 = 20.0;
/// The standard titled window's titlebar, measured via `contentLayoutRect`.
const TITLEBAR_HEIGHT_PTS: f32 = 32.0;
/// The HIG's default body text on macOS is 13 pt (system font size 13 at 1x). /// The HIG's default body text on macOS is 13 pt (system font size 13 at 1x).
const ROOT_FONT_SIZE_PTS: f32 = 13.0; const ROOT_FONT_SIZE_PTS: f32 = 13.0;
@@ -112,8 +122,15 @@ impl ThemeSource for MacosSource {
accent: Self::color_to_hsla(&NSColor::controlAccentColor()), accent: Self::color_to_hsla(&NSColor::controlAccentColor()),
palette, palette,
control_radius: Some(gpui::px(CONTROL_RADIUS_PTS)), control_radius: Some(gpui::px(CONTROL_RADIUS_PTS)),
overlay_radius: None,
sm_radius: None,
control_height: Some(gpui::px(CONTROL_HEIGHT_PTS)),
input_height: Some(gpui::px(INPUT_HEIGHT_PTS)), input_height: Some(gpui::px(INPUT_HEIGHT_PTS)),
titlebar_height: None, menu_item_height: Some(gpui::px(MENU_ITEM_HEIGHT_PTS)),
switch_height: Some(gpui::px(SWITCH_TRACK_HEIGHT_PTS)),
switch_width: Some(gpui::px(SWITCH_TRACK_WIDTH_PTS)),
switch_knob: Some(gpui::px(SWITCH_KNOB_PTS)),
titlebar_height: Some(gpui::px(TITLEBAR_HEIGHT_PTS)),
root_font_size: Some(gpui::px(ROOT_FONT_SIZE_PTS)), root_font_size: Some(gpui::px(ROOT_FONT_SIZE_PTS)),
accessibility, accessibility,
source: SourceKind::Macos, source: SourceKind::Macos,
+26 -2
View File
@@ -60,8 +60,15 @@ impl OmarchyTheme {
variant: self.variant, variant: self.variant,
accent: self.accent, accent: self.accent,
palette: self.palette, palette: self.palette,
control_radius: None, control_radius: Some(gpui::px(9.)),
input_height: None, overlay_radius: Some(gpui::px(15.)),
sm_radius: Some(gpui::px(6.)),
control_height: Some(gpui::px(34.)),
input_height: Some(gpui::px(34.)),
menu_item_height: Some(gpui::px(32.)),
switch_height: Some(gpui::px(26.)),
switch_width: Some(gpui::px(46.)),
switch_knob: Some(gpui::px(20.)),
titlebar_height: None, titlebar_height: None,
root_font_size: None, root_font_size: None,
accessibility, accessibility,
@@ -215,6 +222,23 @@ color15 = \"#635d6f\"
assert_eq!(theme.variant, ThemeVariant::Light); 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] #[test]
fn non_toml_text_is_rejected() { fn non_toml_text_is_rejected() {
assert!(OmarchyTheme::parse("not a theme").is_none()); assert!(OmarchyTheme::parse("not a theme").is_none());
+17
View File
@@ -14,8 +14,18 @@ use crate::system::{AccessibilityPreferences, PartialPalette, SourceKind, System
/// WinUI 11's `ControlCornerRadius`, in pixels at 100% scaling. /// WinUI 11's `ControlCornerRadius`, in pixels at 100% scaling.
const CONTROL_RADIUS_PX: f32 = 4.0; const CONTROL_RADIUS_PX: f32 = 4.0;
/// WinUI 11's `OverlayCornerRadius`, in pixels at 100% scaling.
const OVERLAY_RADIUS_PX: f32 = 8.0;
/// WinUI 11's default text-box height, in pixels at 100% scaling. /// WinUI 11's default text-box height, in pixels at 100% scaling.
const INPUT_HEIGHT_PX: f32 = 32.0; const INPUT_HEIGHT_PX: f32 = 32.0;
/// The default button height, in pixels at 100% scaling.
const CONTROL_HEIGHT_PX: f32 = 32.0;
/// The default menu row: item padding 4/5 + a 20px Body line + 2px item margins.
const MENU_ITEM_HEIGHT_PX: f32 = 33.0;
/// ToggleSwitch: a 40x20 track with a 12px resting knob.
const SWITCH_TRACK_HEIGHT_PX: f32 = 20.0;
const SWITCH_TRACK_WIDTH_PX: f32 = 40.0;
const SWITCH_KNOB_PX: f32 = 12.0;
/// The window caption's height, in pixels at 100% scaling, as Win32 reports it. /// The window caption's height, in pixels at 100% scaling, as Win32 reports it.
const TITLEBAR_HEIGHT_PX: f32 = 32.0; const TITLEBAR_HEIGHT_PX: f32 = 32.0;
/// WinUI 11's default body text size, in pixels at 100% scaling. /// WinUI 11's default body text size, in pixels at 100% scaling.
@@ -159,7 +169,14 @@ impl ThemeSource for WindowsSource {
..PartialPalette::default() ..PartialPalette::default()
}, },
control_radius: Some(gpui::px(CONTROL_RADIUS_PX)), control_radius: Some(gpui::px(CONTROL_RADIUS_PX)),
overlay_radius: Some(gpui::px(OVERLAY_RADIUS_PX)),
sm_radius: None,
control_height: Some(gpui::px(CONTROL_HEIGHT_PX)),
input_height: Some(gpui::px(INPUT_HEIGHT_PX)), input_height: Some(gpui::px(INPUT_HEIGHT_PX)),
menu_item_height: Some(gpui::px(MENU_ITEM_HEIGHT_PX)),
switch_height: Some(gpui::px(SWITCH_TRACK_HEIGHT_PX)),
switch_width: Some(gpui::px(SWITCH_TRACK_WIDTH_PX)),
switch_knob: Some(gpui::px(SWITCH_KNOB_PX)),
titlebar_height: Some(gpui::px(TITLEBAR_HEIGHT_PX)), titlebar_height: Some(gpui::px(TITLEBAR_HEIGHT_PX)),
root_font_size: Some(gpui::px(ROOT_FONT_SIZE_PX * text_scale as f32)), root_font_size: Some(gpui::px(ROOT_FONT_SIZE_PX * text_scale as f32)),
accessibility: self.accessibility(), accessibility: self.accessibility(),
+33 -1
View File
@@ -1,6 +1,6 @@
//! The normalized snapshot a platform adapter produces. //! The normalized snapshot a platform adapter produces.
use gpui::{Hsla, Pixels}; use gpui::{Hsla, Pixels, px};
use crate::ThemeVariant; use crate::ThemeVariant;
@@ -43,6 +43,17 @@ impl SourceKind {
pub fn scrollbar_autohide(self) -> bool { pub fn scrollbar_autohide(self) -> bool {
matches!(self, Self::Macos | Self::Omarchy | Self::Gnome) matches!(self, Self::Macos | Self::Omarchy | Self::Gnome)
} }
/// The base spacing unit:
///
/// - GNOME lays out on a 6px grid
/// - Other platform on 4px grid
pub fn spacing_unit(self) -> Pixels {
match self {
Self::Gnome | Self::Omarchy => px(6.),
_ => px(4.),
}
}
} }
/// A partial palette with optional colors. /// A partial palette with optional colors.
@@ -83,8 +94,22 @@ pub struct SystemTheme {
pub palette: PartialPalette, pub palette: PartialPalette,
/// The platform's stated control corner radius, if it has one. /// The platform's stated control corner radius, if it has one.
pub control_radius: Option<Pixels>, pub control_radius: Option<Pixels>,
/// The platform's stated menu/popover radius, if it has one.
pub overlay_radius: Option<Pixels>,
/// The platform's stated checkbox radius, if it has one.
pub sm_radius: Option<Pixels>,
/// The platform's stated control height, if it has one.
pub control_height: Option<Pixels>,
/// The platform's stated text-field height, if it has one. /// The platform's stated text-field height, if it has one.
pub input_height: Option<Pixels>, pub input_height: Option<Pixels>,
/// The platform's stated menu row height, if it has one.
pub menu_item_height: Option<Pixels>,
/// The platform's stated switch track height, if it has one.
pub switch_height: Option<Pixels>,
/// The platform's stated switch track width, if it has one.
pub switch_width: Option<Pixels>,
/// The platform's stated switch knob size, if it has one.
pub switch_knob: Option<Pixels>,
/// The platform's native titlebar height, if it states one. /// The platform's native titlebar height, if it states one.
pub titlebar_height: Option<Pixels>, pub titlebar_height: Option<Pixels>,
/// The platform's native root font size, if it has one. /// The platform's native root font size, if it has one.
@@ -100,7 +125,14 @@ impl SystemTheme {
accent: None, accent: None,
palette: PartialPalette::default(), palette: PartialPalette::default(),
control_radius: None, control_radius: None,
overlay_radius: None,
sm_radius: None,
control_height: None,
input_height: None, input_height: None,
menu_item_height: None,
switch_height: None,
switch_width: None,
switch_knob: None,
titlebar_height: None, titlebar_height: None,
root_font_size: None, root_font_size: None,
accessibility: AccessibilityPreferences::default(), accessibility: AccessibilityPreferences::default(),
+17 -8
View File
@@ -9,18 +9,20 @@ is the first downstream consumer; its screens drive the order.
## Next ## 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 ## Queue — to follow
Gaps surfaced by the signed comparison; ordered by signed's usage. 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 - [ ] **Virtual list** — generic virtualized variable-height rows with a
scroll handle, promoted from the editor views' pattern over gpui's scroll handle, promoted from the editor views' pattern over gpui's
`list`; signed's PR lists and commit diff drive it. `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 - [x] Platform sources — macOS (NSColor), Windows 11 (UISettings), GNOME
(portal), KDE (kdeglobals), Omarchy (colors.toml), neutral fallback; (portal), KDE (kdeglobals), Omarchy (colors.toml), neutral fallback;
live watchers for each live watchers for each
- [x] Platform metric fidelity — stated control/input/menu-row/switch
metrics and the full radius ladder per platform, Linux root fonts
(GNOME gsettings `font-name`, KDE `kdeglobals font=`), per-source
border + focus chrome (`Theme::borders`), and a per-platform spacing
unit (`Theme::spacing`); findings in
[platform-metrics-audit.md](platform-metrics-audit.md). Components
adopt the tokens one by one (see Next)
### icons (`crates/icons`) ### icons (`crates/icons`)
+9 -3
View File
@@ -229,6 +229,10 @@ density is frozen at a macOS-ish tuning.
## Recommended changes, in priority order ## 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: 1. **State `control_height` per platform** — add `control_height:
Option<Pixels>` beside `input_height` on `SystemTheme`, reuse the Option<Pixels>` beside `input_height` on `SystemTheme`, reuse the
ratio-scaling pattern: macOS 24 (measured), Windows 32, GNOME ≈34, KDE 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); queried rather than a drifted floor (32 on current builds, 28 older);
Windows stays stated. Windows stays stated.
6. **Add a minimal spacing scale to `Theme`** driven by a per-platform base 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 unit (GNOME 6, KDE 4/6/8, others 4); route menu/popover paddings and menu
item min-height (GNOME 32), and the hardcoded `min_w_`/`max_w` values row min-heights through it — GNOME 32 (published), Windows ≈33 (padding
through it. 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 7. **Calibrate switches and icons per platform** — macOS 54×24, Windows
40×20 with 12px knob, KDE 36×18; icon 16 on macOS/Windows. 40×20 with 12px knob, KDE 36×18; icon 16 on macOS/Windows.