chore: audit components

This commit is contained in:
2026-11-10 15:06:16 +07:00
parent af2177c6c8
commit 03369f501e
92 changed files with 2744 additions and 1947 deletions
+1
View File
@@ -7,6 +7,7 @@ description = "Native theme engine: reads the OS theme and resolves it into sema
[dependencies]
anyhow = "1"
gpui = { workspace = true }
log = "0.4"
serde = { version = "1", features = ["derive"] }
toml = "0.8"
+89 -61
View File
@@ -9,37 +9,37 @@ use crate::ThemeVariant;
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
pub struct ThemeDefinition {
pub mode: Option<ThemeVariant>,
pub control_radius: Option<f32>,
pub overlay_radius: Option<f32>,
pub sm_radius: Option<f32>,
pub accent: Option<String>,
pub accent_foreground: Option<String>,
pub background: Option<String>,
pub foreground: Option<String>,
pub surface: Option<String>,
pub surface_foreground: Option<String>,
pub primary: Option<String>,
pub primary_foreground: Option<String>,
pub secondary: Option<String>,
pub secondary_foreground: Option<String>,
pub muted: Option<String>,
pub muted_hover: Option<String>,
pub muted_foreground: Option<String>,
pub destructive: Option<String>,
pub destructive_foreground: Option<String>,
pub success: Option<String>,
pub success_foreground: Option<String>,
pub warning: Option<String>,
pub warning_foreground: Option<String>,
pub info: Option<String>,
pub info_foreground: Option<String>,
pub border: Option<String>,
pub input: Option<String>,
pub ring: Option<String>,
pub selection: Option<String>,
pub overlay: Option<String>,
pub scrollbar_thumb: Option<String>,
mode: Option<ThemeVariant>,
control_radius: Option<f32>,
overlay_radius: Option<f32>,
sm_radius: Option<f32>,
accent: Option<String>,
accent_foreground: Option<String>,
background: Option<String>,
foreground: Option<String>,
surface: Option<String>,
surface_foreground: Option<String>,
primary: Option<String>,
primary_foreground: Option<String>,
secondary: Option<String>,
secondary_foreground: Option<String>,
muted: Option<String>,
muted_hover: Option<String>,
muted_foreground: Option<String>,
destructive: Option<String>,
destructive_foreground: Option<String>,
success: Option<String>,
success_foreground: Option<String>,
warning: Option<String>,
warning_foreground: Option<String>,
info: Option<String>,
info_foreground: Option<String>,
border: Option<String>,
input: Option<String>,
ring: Option<String>,
selection: Option<String>,
overlay: Option<String>,
scrollbar_thumb: Option<String>,
}
impl ThemeDefinition {
@@ -47,112 +47,140 @@ impl ThemeDefinition {
Ok(toml::from_str(source)?)
}
pub fn mode(&self) -> Option<ThemeVariant> {
self.mode
}
pub fn control_radius(&self) -> Option<f32> {
self.control_radius
}
pub fn overlay_radius(&self) -> Option<f32> {
self.overlay_radius
}
pub fn sm_radius(&self) -> Option<f32> {
self.sm_radius
}
pub fn accent(&self) -> Option<Hsla> {
parse_hex(self.accent.as_deref()?)
self.color(&self.accent, "accent")
}
pub fn accent_foreground(&self) -> Option<Hsla> {
parse_hex(self.accent_foreground.as_deref()?)
self.color(&self.accent_foreground, "accent_foreground")
}
pub fn background(&self) -> Option<Hsla> {
parse_hex(self.background.as_deref()?)
self.color(&self.background, "background")
}
pub fn foreground(&self) -> Option<Hsla> {
parse_hex(self.foreground.as_deref()?)
self.color(&self.foreground, "foreground")
}
pub fn surface(&self) -> Option<Hsla> {
parse_hex(self.surface.as_deref()?)
self.color(&self.surface, "surface")
}
pub fn surface_foreground(&self) -> Option<Hsla> {
parse_hex(self.surface_foreground.as_deref()?)
self.color(&self.surface_foreground, "surface_foreground")
}
pub fn primary(&self) -> Option<Hsla> {
parse_hex(self.primary.as_deref()?)
self.color(&self.primary, "primary")
}
pub fn primary_foreground(&self) -> Option<Hsla> {
parse_hex(self.primary_foreground.as_deref()?)
self.color(&self.primary_foreground, "primary_foreground")
}
pub fn secondary(&self) -> Option<Hsla> {
parse_hex(self.secondary.as_deref()?)
self.color(&self.secondary, "secondary")
}
pub fn secondary_foreground(&self) -> Option<Hsla> {
parse_hex(self.secondary_foreground.as_deref()?)
self.color(&self.secondary_foreground, "secondary_foreground")
}
pub fn muted(&self) -> Option<Hsla> {
parse_hex(self.muted.as_deref()?)
self.color(&self.muted, "muted")
}
pub fn muted_hover(&self) -> Option<Hsla> {
parse_hex(self.muted_hover.as_deref()?)
self.color(&self.muted_hover, "muted_hover")
}
pub fn muted_foreground(&self) -> Option<Hsla> {
parse_hex(self.muted_foreground.as_deref()?)
self.color(&self.muted_foreground, "muted_foreground")
}
pub fn destructive(&self) -> Option<Hsla> {
parse_hex(self.destructive.as_deref()?)
self.color(&self.destructive, "destructive")
}
pub fn destructive_foreground(&self) -> Option<Hsla> {
parse_hex(self.destructive_foreground.as_deref()?)
self.color(&self.destructive_foreground, "destructive_foreground")
}
pub fn success(&self) -> Option<Hsla> {
parse_hex(self.success.as_deref()?)
self.color(&self.success, "success")
}
pub fn success_foreground(&self) -> Option<Hsla> {
parse_hex(self.success_foreground.as_deref()?)
self.color(&self.success_foreground, "success_foreground")
}
pub fn warning(&self) -> Option<Hsla> {
parse_hex(self.warning.as_deref()?)
self.color(&self.warning, "warning")
}
pub fn warning_foreground(&self) -> Option<Hsla> {
parse_hex(self.warning_foreground.as_deref()?)
self.color(&self.warning_foreground, "warning_foreground")
}
pub fn info(&self) -> Option<Hsla> {
parse_hex(self.info.as_deref()?)
self.color(&self.info, "info")
}
pub fn info_foreground(&self) -> Option<Hsla> {
parse_hex(self.info_foreground.as_deref()?)
self.color(&self.info_foreground, "info_foreground")
}
pub fn border(&self) -> Option<Hsla> {
parse_hex(self.border.as_deref()?)
self.color(&self.border, "border")
}
pub fn input(&self) -> Option<Hsla> {
parse_hex(self.input.as_deref()?)
self.color(&self.input, "input")
}
pub fn ring(&self) -> Option<Hsla> {
parse_hex(self.ring.as_deref()?)
self.color(&self.ring, "ring")
}
pub fn selection(&self) -> Option<Hsla> {
parse_hex(self.selection.as_deref()?)
self.color(&self.selection, "selection")
}
pub fn overlay(&self) -> Option<Hsla> {
parse_hex(self.overlay.as_deref()?)
self.color(&self.overlay, "overlay")
}
pub fn scrollbar_thumb(&self) -> Option<Hsla> {
parse_hex(self.scrollbar_thumb.as_deref()?)
self.color(&self.scrollbar_thumb, "scrollbar_thumb")
}
/// Parses a color field, warning when a stated value is malformed.
fn color(&self, field: &Option<String>, role: &str) -> Option<Hsla> {
let value = field.as_deref()?;
match parse_hex(value) {
Some(color) => Some(color),
None => {
log::warn!("theme definition {role} is not #rgb/#rrggbb: {value:?}");
None
}
}
}
}
@@ -226,12 +254,12 @@ mod tests {
"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_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));
assert_eq!(def.overlay_radius(), Some(15.0));
assert_eq!(def.sm_radius(), Some(6.0));
}
#[test]
+116 -40
View File
@@ -20,7 +20,9 @@ pub use ramps::{AccentShades, Oklch};
pub use resolve::{Ramps, ResolvedTheme};
pub use system::{AccessibilityPreferences, PartialPalette, SourceKind, SystemTheme};
use gpui::{App, Global, Hsla, Pixels, WindowAppearance, WindowBackgroundAppearance, px};
use gpui::{
App, Global, Hsla, Pixels, SpringConfig, WindowAppearance, WindowBackgroundAppearance, hsla, px,
};
use std::time::Duration;
/// The hairline width of the control border every button and field paints.
@@ -46,7 +48,51 @@ pub const TRAFFIC_LIGHT_PAD: Pixels = px(78.0);
/// The width of a Windows caption button.
pub const WINDOWS_CAPTION_WIDTH: Pixels = px(36.0);
/// Windows paints its caption close-button hover red in every theme.
pub const WINDOWS_CLOSE_HOVER_HEX: u32 = 0xE81120FF;
pub const WINDOWS_CLOSE_HOVER: Hsla = hsla(0.986043, 0.863406, 0.488235, 1.0);
/// The close-button hover dimmed while pressed.
pub const WINDOWS_CLOSE_PRESSED: Hsla = hsla(0.986043, 0.863406, 0.488235, 0.8);
/// The close glyph's color over the red hover.
pub const WINDOWS_CLOSE_FOREGROUND: Hsla = gpui::white();
/// The width a sidebar opens at.
pub const SIDEBAR_DEFAULT_WIDTH: Pixels = px(240.0);
/// The narrowest a sidebar may be dragged.
pub const SIDEBAR_MIN_WIDTH: Pixels = px(160.0);
/// The widest a sidebar may be dragged.
pub const SIDEBAR_MAX_WIDTH: Pixels = px(480.0);
/// The size a pane starts at before its group has measured the extent.
pub const PANE_DEFAULT_SIZE: Pixels = px(200.0);
/// The floor a pane keeps while dragging unless the caller lowers it.
pub const PANE_MIN_SIZE: Pixels = px(48.0);
/// The hairline a resize seam paints on a shared boundary.
pub const HAIRLINE_WIDTH: Pixels = px(0.5);
/// How much of the window shadow's strength survives losing focus.
pub const INACTIVE_SHADOW_OPACITY: f32 = 0.7;
/// The spread of the keyboard focus ring.
pub const FOCUS_RING_SPREAD: Pixels = px(3.0);
/// How far a pushed alert slides in on entry.
pub const ENTER_SLIDE_DISTANCE: Pixels = px(24.0);
/// The widest a toast may grow.
pub const TOAST_MAX_WIDTH: Pixels = px(480.0);
/// One revolution of the spinner.
pub const SPINNER_PERIOD: Duration = Duration::from_millis(800);
/// One sweep of the indeterminate progress bar.
pub const PROGRESS_ANIMATION_PERIOD: Duration = Duration::from_millis(1500);
/// How long an autohide scrollbar thumb stays fully visible before fading.
pub const SCROLLBAR_AUTOHIDE_DELAY: Duration = Duration::from_millis(700);
/// How long the autohide scrollbar's fade runs.
pub const SCROLLBAR_FADE_DURATION: Duration = Duration::from_millis(250);
/// How much a change wash tints the rows it covers.
pub const WASH_ALPHA: f32 = 0.14;
/// The stronger wash under number gutters and intra-line word changes.
pub const WASH_ALPHA_STRONG: f32 = 0.34;
/// The subtle wash that sets the number gutter off from the code.
pub const WASH_ALPHA_GUTTER: f32 = 0.18;
/// The soft wash that marks the active code row.
pub const WASH_ALPHA_SOFT: f32 = 0.45;
/// How far a scrolling list renders past its viewport.
pub const CODE_OVERSCROLL_MAX: Pixels = px(400.0);
/// The spring every control's toggle and segment animations ride.
pub const CONTROL_SPRING: SpringConfig = SpringConfig::new(500., 35., 1.);
/// The monospace advance estimate as a share of the text size.
pub const MONO_ADVANCE_RATIO: f32 = 0.62;
/// How much a pixel avatar's tint washes over its background.
@@ -73,6 +119,15 @@ impl ThemeVariant {
WindowAppearance::Dark | WindowAppearance::VibrantDark => Self::Dark,
}
}
/// Classifies a surface by its lightness, for platforms that state no mode.
pub fn by_lightness(background: Hsla) -> Self {
if background.l >= 0.5 {
Self::Light
} else {
Self::Dark
}
}
}
/// The resolved theme, installed as the app-wide global.
@@ -587,21 +642,23 @@ impl Theme {
reapply(cx);
}
/// Re-resolves when a window's appearance differs from the resolved variant.
/// Re-resolves when no override is set and a window's appearance differs
/// from the resolved variant; an override pins the variant instead.
///
/// Wire to `Window::observe_window_appearance`.
pub fn sync_from_window(window: &mut gpui::Window, cx: &mut App) {
let variant = ThemeVariant::from_appearance(window.appearance());
if cx.global::<Theme>().variant != variant
|| cx.global::<ThemeEngine>().mode_override.is_none()
{
let window_variant = ThemeVariant::from_appearance(window.appearance());
let theme = cx.global::<Theme>();
let engine = cx.global::<ThemeEngine>();
if engine.mode_override.is_none() && theme.variant != window_variant {
reapply(cx);
}
}
/// Sizes the window's rem to the theme's root font size.
pub fn sync_window_rem_size(window: &mut gpui::Window, cx: &App) {
window.set_rem_size(cx.global::<Theme>().root_font_size);
let theme = cx.global::<Theme>();
window.set_rem_size(theme.root_font_size);
}
}
@@ -617,20 +674,17 @@ impl Global for ThemeEngine {}
/// Reads the system, resolves through the current knobs,
/// installs the theme global, and repaints every window.
pub(crate) fn reapply(cx: &mut App) {
let (system_appearance, preset, mode_override) = {
let (preset, mode_override, system) = {
let engine = cx.global::<ThemeEngine>();
(
ThemeVariant::from_appearance(cx.window_appearance()),
engine.preset.as_ref().map(|(_, def)| def.clone()),
engine.mode_override,
)
let system_appearance = ThemeVariant::from_appearance(cx.window_appearance());
let preset = engine.preset.as_ref().map(|(_, def)| def.clone());
let mode_override = engine.mode_override;
let variant = mode_override
.or(preset.as_ref().and_then(|def| def.mode()))
.unwrap_or(system_appearance);
(preset, mode_override, engine.source.snapshot(variant))
};
let variant = mode_override
.or(preset.as_ref().and_then(|def| def.mode))
.unwrap_or(system_appearance);
let system = cx.global::<ThemeEngine>().source.snapshot(variant);
let resolved = resolve::resolve(&system, preset.as_ref(), mode_override);
cx.set_reduce_motion(resolved.accessibility.reduce_motion);
@@ -640,41 +694,39 @@ pub(crate) fn reapply(cx: &mut App) {
/// Forces a variant, independent of the OS. `None` follows the system.
pub fn set_mode_override(variant: Option<ThemeVariant>, cx: &mut App) {
cx.global_mut::<ThemeEngine>().mode_override = variant;
let engine = cx.global_mut::<ThemeEngine>();
engine.mode_override = variant;
reapply(cx);
}
/// Installs a built-in preset, or `None` to follow the system again.
pub fn set_preset(name: Option<&str>, cx: &mut App) -> anyhow::Result<()> {
let preset = match name {
Some(name) => {
let def =
presets::builtin(name).ok_or_else(|| anyhow::anyhow!("unknown preset: {name}"))?;
let static_name = presets::preset_names()
.iter()
.copied()
.find(|candidate| *candidate == name)
.ok_or_else(|| anyhow::anyhow!("unknown preset: {name}"))?;
Some((static_name, def))
}
None => None,
};
cx.global_mut::<ThemeEngine>().preset = preset;
let preset = name
.map(|name| {
presets::builtin(name).ok_or_else(|| {
anyhow::anyhow!(
"unknown preset: {name}; available: {}",
presets::preset_names().collect::<Vec<_>>().join(", ")
)
})
})
.transpose()?;
let engine = cx.global_mut::<ThemeEngine>();
engine.preset = preset;
reapply(cx);
Ok(())
}
/// The variant the mode override forces, if any.
pub fn mode_override(cx: &App) -> Option<ThemeVariant> {
cx.global::<ThemeEngine>().mode_override
let engine = cx.global::<ThemeEngine>();
engine.mode_override
}
/// The active preset name, if one is installed.
pub fn active_preset(cx: &App) -> Option<&'static str> {
cx.global::<ThemeEngine>()
.preset
.as_ref()
.map(|(name, _)| *name)
let engine = cx.global::<ThemeEngine>();
engine.preset.as_ref().map(|(name, _)| *name)
}
/// Clears the mode override and preset; the theme follows the system again.
@@ -687,5 +739,29 @@ pub fn follow_system(cx: &mut App) {
/// Which adapter produced the current system snapshot.
pub fn detected_source(cx: &App) -> &'static str {
cx.global::<ThemeEngine>().source.label()
let engine = cx.global::<ThemeEngine>();
engine.source.label()
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::Rgba;
#[test]
fn the_windows_close_colors_match_the_legacy_hex() {
let hover = Hsla::from(gpui::rgba(0xE81123FF));
let pressed = hover.opacity(0.8);
for (token, expected) in [
(WINDOWS_CLOSE_HOVER, hover),
(WINDOWS_CLOSE_PRESSED, pressed),
] {
let (token, expected) = (Rgba::from(token), Rgba::from(expected));
assert!((token.r - expected.r).abs() < 1e-4);
assert!((token.g - expected.g).abs() < 1e-4);
assert!((token.b - expected.b).abs() < 1e-4);
assert!((token.a - expected.a).abs() < 1e-4);
}
assert_eq!(WINDOWS_CLOSE_FOREGROUND, gpui::white());
}
}
+31 -14
View File
@@ -2,22 +2,39 @@
use crate::definition::ThemeDefinition;
const TOKYO_NIGHT: &str = include_str!("../presets/tokyo-night.toml");
/// The bundled presets: `(name, TOML source)`. Enumeration and lookup both
/// read this one table.
const PRESETS: &[(&str, &str)] = &[("tokyo-night", include_str!("../presets/tokyo-night.toml"))];
pub fn preset_names() -> &'static [&'static str] {
&["tokyo-night"]
/// Every bundled preset's name.
pub fn preset_names() -> impl Iterator<Item = &'static str> {
PRESETS.iter().map(|(name, _)| *name)
}
/// Parses the named preset.
pub fn builtin(name: &str) -> Option<ThemeDefinition> {
match name {
"tokyo-night" => match ThemeDefinition::parse_toml(TOKYO_NIGHT) {
Ok(def) => Some(def),
Err(err) => {
eprintln!("bundled preset tokyo-night failed to parse: {err}");
None
}
},
_ => None,
/// Parses the named preset, returning its static name and definition.
pub fn builtin(name: &str) -> Option<(&'static str, ThemeDefinition)> {
let (name, source) = PRESETS
.iter()
.find(|(candidate, _)| *candidate == name)
.copied()?;
match ThemeDefinition::parse_toml(source) {
Ok(definition) => Some((name, definition)),
Err(error) => {
log::error!("bundled preset {name} failed to parse: {error}");
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_name_table_and_lookup_agree() {
for name in preset_names() {
assert!(builtin(name).is_some(), "{name} fails to parse");
}
assert!(builtin("no-such-preset").is_none());
}
}
+1
View File
@@ -165,6 +165,7 @@ pub struct AccentShades {
/// Derives hover/pressed/subtle shades for an accent against its background.
///
/// Where a platform states ramps (WinUI, KDE) those are used instead,
/// every other platform derives its shades here.
pub fn derive_shades(accent: Hsla, background: Hsla, is_dark: bool) -> AccentShades {
let accent_ok = Oklch::from_hsla(accent);
let step = if is_dark { 0.05 } else { -0.05 };
+4 -4
View File
@@ -55,7 +55,7 @@ pub(crate) fn resolve(
mode_override: Option<ThemeVariant>,
) -> ResolvedTheme {
let variant = mode_override
.or(preset.and_then(|def| def.mode))
.or(preset.and_then(|def| def.mode()))
.unwrap_or(system.variant);
let base = match variant {
@@ -246,7 +246,7 @@ pub(crate) fn resolve(
let mut radius = Radius::default();
if let Some(control) = preset
.and_then(|def| def.control_radius)
.and_then(|def| def.control_radius())
.map(px)
.or(system.control_radius)
{
@@ -254,7 +254,7 @@ pub(crate) fn resolve(
}
if let Some(overlay) = preset
.and_then(|def| def.overlay_radius)
.and_then(|def| def.overlay_radius())
.map(px)
.or(system.overlay_radius)
{
@@ -262,7 +262,7 @@ pub(crate) fn resolve(
}
if let Some(sm) = preset
.and_then(|def| def.sm_radius)
.and_then(|def| def.sm_radius())
.map(px)
.or(system.sm_radius)
{
+10
View File
@@ -12,6 +12,16 @@ pub(crate) trait ThemeSource: 'static {
fn label(&self) -> &'static str;
}
/// Narrows an f64 color component to f32, clamping to the 0..=1 range and
/// mapping non-finite values to 0.0 rather than truncating garbage silently.
pub(crate) fn narrow_component(value: f64) -> f32 {
if value.is_finite() {
value.clamp(0.0, 1.0) as f32
} else {
0.0
}
}
#[cfg(target_os = "macos")]
pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
Box::new(macos::MacosSource::default())
+15 -21
View File
@@ -77,7 +77,7 @@ impl KdeScheme {
//
// Older setups fall back to the scheme's background lightness.
variant: portal_variant
.or(self.background.map(variant_by_lightness))
.or(self.background.map(crate::ThemeVariant::by_lightness))
.unwrap_or(crate::ThemeVariant::Light),
accent: self.accent.or(portal_accent),
palette: self.palette,
@@ -99,14 +99,6 @@ impl KdeScheme {
}
}
fn variant_by_lightness(background: Hsla) -> crate::ThemeVariant {
if background.l >= 0.5 {
crate::ThemeVariant::Light
} else {
crate::ThemeVariant::Dark
}
}
/// 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();
@@ -158,7 +150,10 @@ fn color_list(value: &str) -> Option<Hsla> {
let green = components.next()?.parse::<f32>().ok()?;
let blue = components.next()?.parse::<f32>().ok()?;
let alpha = components.next().and_then(|a| a.parse::<f32>().ok());
if red < 0.0 || green < 0.0 || blue < 0.0 {
if !(0.0..=255.0).contains(&red)
|| !(0.0..=255.0).contains(&green)
|| !(0.0..=255.0).contains(&blue)
{
return None;
}
let scale = |component: f32| component / 255.0;
@@ -335,19 +330,18 @@ activeBackground=39,44,49
}
#[test]
fn the_scheme_states_breeze_metrics_and_the_default_font() {
fn the_default_font_applies_without_a_stated_font() {
let system = KdeScheme::parse(BREEZE_DARK).into_system(Default::default(), None, None);
assert!((system.root_font_size.expect("default font") - px(13.33)).abs() < px(0.01));
}
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 color_components_outside_0_to_255_are_rejected() {
let above = KdeScheme::parse("[Colors:Window]\nBackgroundNormal=300,20,20\n");
assert!(above.palette.background.is_none());
let below = KdeScheme::parse("[Colors:Window]\nBackgroundNormal=-5,20,20\n");
assert!(below.palette.background.is_none());
}
#[test]
+47 -35
View File
@@ -13,9 +13,30 @@ use super::kde;
use super::omarchy;
use super::portal;
use super::signal::ReapplySignal;
use crate::ThemeVariant;
use crate::system::{PartialPalette, SourceKind, SystemTheme};
/// libadwaita's control corner radius, in px at 100% scaling.
const CONTROL_RADIUS_PX: f32 = 9.0;
/// libadwaita's popover corner radius.
const OVERLAY_RADIUS_PX: f32 = 15.0;
/// The checkbox indicator's corner radius.
const SM_RADIUS_PX: f32 = 6.0;
/// libadwaita's standard control and text-field height.
const CONTROL_HEIGHT_PX: f32 = 34.0;
/// The GTK menu row height.
const MENU_ITEM_HEIGHT_PX: f32 = 32.0;
/// The GTK switch: a 46x26 track with a 20px knob.
const SWITCH_HEIGHT_PX: f32 = 26.0;
const SWITCH_WIDTH_PX: f32 = 46.0;
const SWITCH_KNOB_PX: f32 = 20.0;
/// GNOME's headerbar height, in px at 100% scaling.
const TITLEBAR_HEIGHT_PX: f32 = 47.0;
/// The default 11pt interface font, in px.
const ROOT_FONT_SIZE_PX: f32 = 14.67;
pub(crate) struct LinuxSource {
backend: Backend,
portal: Option<portal::io::PortalClient>,
@@ -154,20 +175,20 @@ impl LinuxSource {
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.)),
control_radius: Some(gpui::px(CONTROL_RADIUS_PX)),
overlay_radius: Some(gpui::px(OVERLAY_RADIUS_PX)),
sm_radius: Some(gpui::px(SM_RADIUS_PX)),
control_height: Some(gpui::px(CONTROL_HEIGHT_PX)),
input_height: Some(gpui::px(CONTROL_HEIGHT_PX)),
menu_item_height: Some(gpui::px(MENU_ITEM_HEIGHT_PX)),
switch_height: Some(gpui::px(SWITCH_HEIGHT_PX)),
switch_width: Some(gpui::px(SWITCH_WIDTH_PX)),
switch_knob: Some(gpui::px(SWITCH_KNOB_PX)),
titlebar_height: Some(gpui::px(TITLEBAR_HEIGHT_PX)),
root_font_size: Some(
self.gsettings
.and_then(|gsettings| gsettings.root_font_size)
.unwrap_or(gpui::px(14.67)),
.unwrap_or(gpui::px(ROOT_FONT_SIZE_PX)),
),
accessibility,
window_background: None,
@@ -237,41 +258,44 @@ impl ThemeSource for LinuxSource {
fn install_watchers(&mut self, async_cx: gpui::AsyncApp) {
let signal = ReapplySignal::install(&async_cx);
let file_signal = signal.sender();
if let Some(portal) = &self.portal {
portal.spawn_watch(signal.sender());
}
// Stored before any fallible step: an early return below
// must not drop and cancel the reapply loop.
self._signal = Some(signal);
match self.backend {
Backend::Omarchy => {
let Some(current) = omarchy::io::current_dir() else {
return;
};
let reapply_signal = signal.sender();
let watcher = notify::recommended_watcher(
move |event: std::result::Result<notify::Event, notify::Error>| match event {
Ok(_) => ReapplySignal::poke(&reapply_signal),
Err(error) => eprintln!("theme: omarchy watch error: {error}"),
Ok(_) => ReapplySignal::poke(&file_signal),
Err(error) => log::warn!("theme: omarchy watch error: {error}"),
},
);
match watcher {
Ok(mut watcher) => {
if let Err(error) = watcher.watch(&current, RecursiveMode::NonRecursive) {
eprintln!("theme: omarchy watch failed: {error}");
log::warn!("theme: omarchy watch failed: {error}");
return;
}
self.file_watcher = Some(watcher);
}
Err(error) => eprintln!("theme: omarchy watcher unavailable: {error}"),
Err(error) => log::warn!("theme: omarchy watcher unavailable: {error}"),
}
}
Backend::Kde => {
let Some(config) = home_dir().map(|home| home.join(".config")) else {
return;
};
let reapply_signal = signal.sender();
let watcher = notify::recommended_watcher(
move |event: std::result::Result<notify::Event, notify::Error>| match event {
@@ -279,27 +303,25 @@ impl ThemeSource for LinuxSource {
if event.paths.iter().any(|path| {
path.file_name().is_some_and(|name| name == "kdeglobals")
}) {
ReapplySignal::poke(&reapply_signal);
ReapplySignal::poke(&file_signal);
}
}
Err(error) => eprintln!("theme: kde watch error: {error}"),
Err(error) => log::warn!("theme: kde watch error: {error}"),
},
);
match watcher {
Ok(mut watcher) => {
if let Err(error) = watcher.watch(&config, RecursiveMode::NonRecursive) {
eprintln!("theme: kde watch failed: {error}");
log::warn!("theme: kde watch failed: {error}");
return;
}
self.file_watcher = Some(watcher);
}
Err(error) => eprintln!("theme: kde watcher unavailable: {error}"),
Err(error) => log::warn!("theme: kde watcher unavailable: {error}"),
}
}
Backend::Portal | Backend::Fallback => {}
}
self._signal = Some(signal);
}
fn label(&self) -> &'static str {
@@ -354,7 +376,7 @@ mod tests {
}
#[test]
fn the_gnome_snapshot_states_the_libadwaita_metrics() {
fn the_gnome_snapshot_labels_gnome_and_defaults_the_root_font() {
let source = LinuxSource {
backend: Backend::Portal,
portal: None,
@@ -369,17 +391,7 @@ mod tests {
);
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)));
assert_eq!(system.root_font_size, Some(gpui::px(ROOT_FONT_SIZE_PX)));
}
}
+6 -4
View File
@@ -14,6 +14,8 @@ use objc2_foundation::{
};
use std::ptr::NonNull;
use super::narrow_component;
use super::ThemeSource;
use crate::ThemeVariant;
use crate::system::{AccessibilityPreferences, PartialPalette, SourceKind, SystemTheme};
@@ -46,10 +48,10 @@ impl MacosSource {
let converted = color.colorUsingColorSpace(&srgb)?;
Some(gpui::Hsla::from(gpui::Rgba {
r: converted.redComponent() as f32,
g: converted.greenComponent() as f32,
b: converted.blueComponent() as f32,
a: converted.alphaComponent() as f32,
r: narrow_component(converted.redComponent()),
g: narrow_component(converted.greenComponent()),
b: narrow_component(converted.blueComponent()),
a: narrow_component(converted.alphaComponent()),
}))
}
+2 -18
View File
@@ -27,7 +27,7 @@ impl OmarchyTheme {
.get("mode")
.and_then(toml::Value::as_str)
.and_then(mode_variant)
.unwrap_or_else(|| variant_by_lightness(background));
.unwrap_or_else(|| ThemeVariant::by_lightness(background));
let accent = hex_color(&table, "accent").or_else(|| hex_color(&table, "color4"));
let selection = hex_color(&table, "selection");
@@ -86,14 +86,6 @@ fn mode_variant(mode: &str) -> Option<ThemeVariant> {
}
}
fn variant_by_lightness(background: Hsla) -> ThemeVariant {
if background.l >= 0.5 {
ThemeVariant::Light
} else {
ThemeVariant::Dark
}
}
fn hex_color(table: &Table, key: &str) -> Option<Hsla> {
let value = table.get(key)?.as_str()?;
let digits = value.trim().strip_prefix('#')?;
@@ -224,18 +216,10 @@ color15 = \"#635d6f\"
}
#[test]
fn omarchy_shares_the_gnome_control_metrics() {
fn omarchy_states_no_titlebar_or_root_font() {
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);
}
+16 -9
View File
@@ -5,6 +5,8 @@
use gpui::Hsla;
use super::narrow_component;
use crate::{ThemeVariant, system::AccessibilityPreferences};
/// The raw portal values, already converted off the DBus wire types.
@@ -26,9 +28,9 @@ impl AppearanceValues {
color_scheme: self.color_scheme,
accent: self.accent_rgb.map(|(r, g, b)| {
Hsla::from(gpui::Rgba {
r: r as f32,
g: g as f32,
b: b as f32,
r: narrow_component(r),
g: narrow_component(g),
b: narrow_component(b),
a: 1.0,
})
}),
@@ -98,7 +100,9 @@ pub(crate) mod io {
/// The most recently read appearance.
pub(crate) fn appearance(&self) -> Appearance {
*self.appearance.lock().expect("portal cache poisoned")
// A watch-thread panic must not crash the UI thread.
let cache = self.appearance.lock();
*cache.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Forwards portal `SettingChanged` events to the reapply signal from a dedicated thread.
@@ -107,17 +111,17 @@ pub(crate) mod io {
std::thread::spawn(move || {
let Some(connection) = Connection::session().ok() else {
eprintln!("theme: portal watch skipped, no session bus");
log::warn!("theme: portal watch skipped, no session bus");
return;
};
let Some(proxy) = settings_proxy(connection) else {
eprintln!("theme: portal watch skipped, settings proxy unavailable");
log::warn!("theme: portal watch skipped, settings proxy unavailable");
return;
};
let Ok(signals) = proxy.receive_signal("SettingChanged") else {
eprintln!("theme: portal watch skipped, signal subscription failed");
log::warn!("theme: portal watch skipped, signal subscription failed");
return;
};
@@ -134,10 +138,13 @@ pub(crate) mod io {
match read_appearance(&proxy) {
Some(latest) => {
*appearance.lock().expect("portal cache poisoned") = latest;
let mut cache = appearance
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*cache = latest;
super::super::signal::ReapplySignal::poke(&reapply_signal);
}
None => eprintln!("theme: portal read failed on SettingChanged"),
None => log::warn!("theme: portal read failed on SettingChanged"),
}
}
});
+1 -1
View File
@@ -32,7 +32,7 @@ impl ReapplySignal {
/// Pokes the pipeline from a thread holding a [`Self::sender`] clone.
pub(crate) fn poke(sender: &async_channel::Sender<()>) {
if let Err(error) = sender.try_send(()) {
eprintln!("theme: reapply signal failed: {error}");
log::warn!("theme: reapply signal failed: {error}");
}
}
}
+10 -13
View File
@@ -74,16 +74,19 @@ pub(crate) struct WindowsSource {
}
pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
// SAFETY: WinRT activation requires COM on this thread; the STA matches
// the UI thread's message pump, and RPC_E_CHANGED_MODE means COM is
// already initialized in another apartment.
let initialized = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
if initialized.is_err() && initialized != RPC_E_CHANGED_MODE {
eprintln!("theme: COM initialization failed: {initialized:?}");
log::error!("theme: COM initialization failed: {initialized:?}");
}
match WindowsSource::new() {
Some(source) => Box::new(source),
None => {
eprintln!("theme: Windows system theme unavailable; using fallback");
log::warn!("theme: Windows system theme unavailable; using fallback");
Box::new(super::fallback::FallbackSource)
}
}
@@ -94,7 +97,7 @@ impl WindowsSource {
let ui_settings = match UISettings::new() {
Ok(ui_settings) => ui_settings,
Err(error) => {
eprintln!("theme: UISettings unavailable: {error}");
log::warn!("theme: UISettings unavailable: {error}");
return None;
}
};
@@ -102,7 +105,7 @@ impl WindowsSource {
let accessibility_settings = match AccessibilitySettings::new() {
Ok(accessibility_settings) => accessibility_settings,
Err(error) => {
eprintln!("theme: AccessibilitySettings unavailable: {error}");
log::warn!("theme: AccessibilitySettings unavailable: {error}");
return None;
}
};
@@ -157,13 +160,7 @@ impl ThemeSource for WindowsSource {
let foreground = self.color(UIColorType::Foreground);
let variant = background
.map(|background| {
if background.l >= 0.5 {
ThemeVariant::Light
} else {
ThemeVariant::Dark
}
})
.map(ThemeVariant::by_lightness)
.or(self.registry.apps_use_light_theme.map(|light| {
if light {
ThemeVariant::Light
@@ -212,7 +209,7 @@ impl ThemeSource for WindowsSource {
});
if let Err(error) = self.ui_settings.ColorValuesChanged(&color_handler) {
eprintln!("theme: color change watch failed: {error}");
log::warn!("theme: color change watch failed: {error}");
}
let contrast_signal = signal.sender();
@@ -226,7 +223,7 @@ impl ThemeSource for WindowsSource {
.accessibility_settings
.HighContrastChanged(&contrast_handler)
{
eprintln!("theme: high contrast watch failed: {error}");
log::warn!("theme: high contrast watch failed: {error}");
}
self._signal = Some(signal);