From c45f93921112f37e7fd675411ccc2fe3d36774c8 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 31 Oct 2026 07:57:27 +0700 Subject: [PATCH] theme: add linux system theme sources --- Cargo.lock | 93 +++++++++ crates/theme/Cargo.toml | 5 + crates/theme/src/lib.rs | 3 +- crates/theme/src/source.rs | 17 +- crates/theme/src/source/fallback.rs | 4 +- crates/theme/src/source/kde.rs | 307 ++++++++++++++++++++++++++++ crates/theme/src/source/linux.rs | 295 ++++++++++++++++++++++++++ crates/theme/src/source/omarchy.rs | 221 ++++++++++++++++++++ crates/theme/src/source/portal.rs | 233 +++++++++++++++++++++ crates/theme/src/system.rs | 7 + docs/PLAN.md | 12 +- 11 files changed, 1187 insertions(+), 10 deletions(-) create mode 100644 crates/theme/src/source/kde.rs create mode 100644 crates/theme/src/source/linux.rs create mode 100644 crates/theme/src/source/omarchy.rs create mode 100644 crates/theme/src/source/portal.rs diff --git a/Cargo.lock b/Cargo.lock index 18f4dd0..15c9f60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1740,13 +1740,16 @@ name = "gpui-native-theme" version = "0.1.0" dependencies = [ "anyhow", + "async-channel", "block2 0.6.2", "gpui", + "notify", "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "serde", "toml 0.8.23", + "zbus", ] [[package]] @@ -2341,6 +2344,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -2452,6 +2475,26 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.2", + "libc", +] + [[package]] name = "kurbo" version = "0.13.1" @@ -2732,6 +2775,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -2817,6 +2872,25 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "notify" +version = "8.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f812702fbc75d35416d3f884e04925be7e9498f4eceea2e24ba34559284674" +dependencies = [ + "bitflags 2.13.2", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "objc2-core-foundation", + "objc2-core-services", + "walkdir", + "windows-sys", +] + [[package]] name = "notify-rust" version = "4.18.2" @@ -2831,6 +2905,15 @@ dependencies = [ "zbus", ] +[[package]] +name = "notify-types" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a437a9308232ce794b1e7fdf540d38ea37efc3c73492640c13b4c8568b8f108" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -3063,6 +3146,16 @@ dependencies = [ "objc2-metal 0.2.2", ] +[[package]] +name = "objc2-core-services" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-core-text" version = "0.3.2" diff --git a/crates/theme/Cargo.toml b/crates/theme/Cargo.toml index 4f894ca..3d7e3d3 100644 --- a/crates/theme/Cargo.toml +++ b/crates/theme/Cargo.toml @@ -13,6 +13,11 @@ toml = "0.8" [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } +[target.'cfg(target_os = "linux")'.dependencies] +async-channel = "2" +notify = "8" +zbus = "5" + [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6" objc2 = "0.6" diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index 1c992ee..4d0c5cd 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -233,8 +233,7 @@ impl SizeTable { } } - /// Sets the medium class's text-field height to the platform's stated - /// value, scaling the other classes by the same ratio. + /// Sets the medium class's text-field height to the platform's stated value. pub fn set_input_height(&mut self, medium_input_height: Pixels) { let ratio = medium_input_height / self.medium.input_height; self.small.input_height *= ratio; diff --git a/crates/theme/src/source.rs b/crates/theme/src/source.rs index 6eeb393..a045b49 100644 --- a/crates/theme/src/source.rs +++ b/crates/theme/src/source.rs @@ -17,7 +17,12 @@ pub(crate) fn platform_source() -> Box { Box::new(macos::MacosSource::default()) } -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "linux")] +pub(crate) fn platform_source() -> Box { + linux::platform_source() +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] pub(crate) fn platform_source() -> Box { Box::new(fallback::FallbackSource) } @@ -25,4 +30,14 @@ pub(crate) fn platform_source() -> Box { #[cfg(target_os = "macos")] pub(crate) mod macos; +#[cfg(target_os = "linux")] +pub(crate) mod linux; + +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +pub(crate) mod kde; +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +pub(crate) mod omarchy; +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +pub(crate) mod portal; + pub(crate) mod fallback; diff --git a/crates/theme/src/source/fallback.rs b/crates/theme/src/source/fallback.rs index 6482185..3e0dfb3 100644 --- a/crates/theme/src/source/fallback.rs +++ b/crates/theme/src/source/fallback.rs @@ -5,8 +5,8 @@ use super::ThemeSource; use crate::ThemeVariant; use crate::system::SystemTheme; -// Constructed only by `platform_source` on non-macOS targets. -#[cfg_attr(target_os = "macos", allow(dead_code))] +// Constructed only by `platform_source` on platforms without their own adapter. +#[cfg_attr(any(target_os = "macos", target_os = "linux"), allow(dead_code))] pub(crate) struct FallbackSource; impl ThemeSource for FallbackSource { diff --git a/crates/theme/src/source/kde.rs b/crates/theme/src/source/kde.rs new file mode 100644 index 0000000..42a7411 --- /dev/null +++ b/crates/theme/src/source/kde.rs @@ -0,0 +1,307 @@ +//! KDE adapter: reads the color groups Plasma writes into `~/.config/kdeglobals` +//! (plus the Plasma 6.2+ `[General] AccentColor`). The parse is pure, so +//! fixtures run on every OS; the IO lives behind the Linux cfg. + +use std::collections::HashMap; + +use gpui::Hsla; + +use crate::system::{PartialPalette, SourceKind, SystemTheme}; + +/// The colors stated in kdeglobals' `[Colors:*]` groups. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct KdeScheme { + pub(crate) accent: Option, + pub(crate) background: Option, + pub(crate) palette: PartialPalette, +} + +impl KdeScheme { + /// Parses kdeglobals text. + pub(crate) fn parse(text: &str) -> Self { + let groups = parse_groups(text); + let window = groups.get("Colors:Window"); + let view = groups.get("Colors:View"); + let button = groups.get("Colors:Button"); + let selection = groups.get("Colors:Selection"); + + let background = window.and_then(|group| group_color(group, "BackgroundNormal")); + + let accent = groups + .get("General") + .and_then(|group| group.get("AccentColor")) + .and_then(|value| color_list(value)); + + let palette = PartialPalette { + background, + foreground: window.and_then(|group| group_color(group, "ForegroundNormal")), + surface: view.and_then(|group| group_color(group, "BackgroundNormal")), + surface_foreground: view.and_then(|group| group_color(group, "ForegroundNormal")), + secondary: button.and_then(|group| group_color(group, "BackgroundNormal")), + secondary_foreground: button.and_then(|group| group_color(group, "ForegroundNormal")), + muted: window.and_then(|group| group_color(group, "DecorationHover")), + muted_foreground: window.and_then(|group| group_color(group, "ForegroundInactive")), + destructive: window.and_then(|group| group_color(group, "ForegroundNegative")), + success: window.and_then(|group| group_color(group, "ForegroundPositive")), + border: window.and_then(|group| group_color(group, "DecorationNormal")), + ring: window.and_then(|group| group_color(group, "DecorationFocus")), + selection: selection.and_then(|group| group_color(group, "BackgroundNormal")), + primary_foreground: selection.and_then(|group| group_color(group, "ForegroundNormal")), + ..PartialPalette::default() + }; + + Self { + accent, + background, + palette, + } + } + + pub(crate) fn into_system( + mut self, + accessibility: crate::system::AccessibilityPreferences, + portal_accent: Option, + portal_variant: Option, + ) -> SystemTheme { + // Schemes that state no DecorationFocus fall back to the accent. + if self.palette.ring.is_none() { + self.palette.ring = self.accent; + } + + SystemTheme { + // The portal states the mode on Plasma 5.21+. + // + // Older setups fall back to the scheme's background lightness. + variant: portal_variant + .or(self.background.map(variant_by_lightness)) + .unwrap_or(crate::ThemeVariant::Light), + accent: self.accent.or(portal_accent), + palette: self.palette, + control_radius: None, + input_height: None, + root_font_size: None, + accessibility, + source: SourceKind::Kde, + } + } +} + +fn variant_by_lightness(background: Hsla) -> crate::ThemeVariant { + if background.l >= 0.5 { + crate::ThemeVariant::Light + } else { + crate::ThemeVariant::Dark + } +} + +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; + + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some(rest) = line.strip_prefix('[') { + section = Some(rest.split(']').next().unwrap_or(rest)); + continue; + } + if let (Some(section), Some((key, value))) = (section, line.split_once('=')) { + groups + .entry(section) + .or_default() + .insert(key.trim(), value.trim()); + } + } + + groups +} + +fn group_color<'a>(group: &'a HashMap<&'a str, &'a str>, key: &str) -> Option { + color_list(group.get(key)?) +} + +/// Parses `r,g,b[,a]` with components in 0..=255. +fn color_list(value: &str) -> Option { + let mut components = value.split(',').map(str::trim); + let red = components.next()?.parse::().ok()?; + let green = components.next()?.parse::().ok()?; + let blue = components.next()?.parse::().ok()?; + let alpha = components.next().and_then(|a| a.parse::().ok()); + if red < 0.0 || green < 0.0 || blue < 0.0 { + return None; + } + let scale = |component: f32| component / 255.0; + + Some(Hsla::from(gpui::Rgba { + r: scale(red), + g: scale(green), + b: scale(blue), + a: alpha.map(|a| a / 255.0).unwrap_or(1.0), + })) +} + +#[cfg(target_os = "linux")] +pub(crate) mod io { + use std::path::PathBuf; + + use super::KdeScheme; + + fn kdeglobals_path() -> Option { + Some( + std::env::var_os("HOME") + .map(PathBuf::from)? + .join(".config/kdeglobals"), + ) + } + + pub(crate) fn read_current() -> Option { + let text = std::fs::read_to_string(kdeglobals_path()?).ok()?; + Some(KdeScheme::parse(&text)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const BREEZE_DARK: &str = "\ +[Colors:Button] +BackgroundAlternate=30,87,116 +BackgroundNormal=41,44,48 +DecorationFocus=61,174,233 +DecorationHover=61,174,233 +ForegroundInactive=161,169,177 +ForegroundNegative=218,68,83 +ForegroundNormal=252,252,252 +ForegroundPositive=39,174,96 + +[Colors:Selection] +BackgroundNormal=61,174,233 +DecorationFocus=61,174,233 +ForegroundNormal=252,252,252 + +[Colors:View] +BackgroundNormal=20,22,24 +BackgroundAlternate=29,31,34 +DecorationFocus=61,174,233 +DecorationHover=61,174,233 +ForegroundInactive=161,169,177 +ForegroundNormal=252,252,252 + +[Colors:Window] +BackgroundNormal=32,35,38 +DecorationFocus=61,174,233 +DecorationHover=61,174,233 +DecorationNormal=49,54,59 +ForegroundInactive=161,169,177 +ForegroundNegative=218,68,83 +ForegroundNormal=252,252,252 +ForegroundPositive=39,174,96 + +[General] +ColorScheme=BreezeDark +Name[de]=Breeze Dunkel + +[KDE] +contrast=4 + +[WM] +activeBackground=39,44,49 +"; + + #[test] + fn breeze_groups_map_onto_roles() { + let scheme = KdeScheme::parse(BREEZE_DARK); + let scale = |component: u32| component as f32 / 255.0; + let background = scheme.palette.background.expect("window background"); + + assert_eq!( + gpui::Rgba::from(background), + gpui::Rgba { + r: scale(32), + g: scale(35), + b: scale(38), + a: 1.0, + } + ); + assert_eq!( + scheme.palette.selection, + Some(Hsla::from(gpui::Rgba { + r: scale(61), + g: scale(174), + b: scale(233), + a: 1.0, + })) + ); + assert_eq!( + scheme.palette.muted_foreground, + Some(Hsla::from(gpui::Rgba { + r: scale(161), + g: scale(169), + b: scale(177), + a: 1.0, + })) + ); + assert_eq!( + scheme.palette.destructive, + Some(Hsla::from(gpui::Rgba { + r: scale(218), + g: scale(68), + b: scale(83), + a: 1.0, + })) + ); + } + + #[test] + fn localized_and_unrelated_keys_are_ignored() { + let scheme = KdeScheme::parse(BREEZE_DARK); + + assert!(scheme.accent.is_none()); + assert_eq!(scheme.palette.foreground, scheme.palette.surface_foreground); + } + + #[test] + fn a_stated_accent_reaches_the_system_theme() { + let text = + "[General]\nAccentColor=255,100,50\n[Colors:Window]\nBackgroundNormal=32,35,38\n"; + let scheme = KdeScheme::parse(text); + let system = scheme.into_system(Default::default(), None, None); + + let accent = system.accent.expect("stated accent"); + assert_eq!( + accent, + Hsla::from(gpui::Rgba { + r: 255.0 / 255.0, + g: 100.0 / 255.0, + b: 50.0 / 255.0, + a: 1.0, + }) + ); + assert_eq!(system.source, SourceKind::Kde); + assert_eq!(system.variant, crate::ThemeVariant::Dark); + } + + #[test] + fn the_portal_accent_covers_the_unset_marker() { + let text = + "[General]\nAccentColor=-1,-1,-1\n[Colors:Window]\nBackgroundNormal=250,250,250\n"; + let scheme = KdeScheme::parse(text); + assert!(scheme.accent.is_none()); + + let portal_accent = Some(Hsla::from(gpui::rgb(0x3584e4))); + let system = scheme.into_system(Default::default(), portal_accent, None); + assert_eq!(system.accent, portal_accent); + assert_eq!(system.variant, crate::ThemeVariant::Light); + } + + #[test] + fn the_portal_mode_beats_the_lightness_derivation() { + let text = "[Colors:Window]\nBackgroundNormal=32,35,38\n"; + let scheme = KdeScheme::parse(text); + let system = scheme.into_system(Default::default(), None, Some(crate::ThemeVariant::Light)); + assert_eq!(system.variant, crate::ThemeVariant::Light); + } +} diff --git a/crates/theme/src/source/linux.rs b/crates/theme/src/source/linux.rs new file mode 100644 index 0000000..65019c7 --- /dev/null +++ b/crates/theme/src/source/linux.rs @@ -0,0 +1,295 @@ +//! Linux adapter selection: Omarchy → KDE → portal (GNOME/generic) → +//! fallback, per the plan's detection cascade. Omarchy and KDE palettes come +//! from their files; the portal supplements mode, accent, and the +//! accessibility flags; `gsettings` covers sessions without a portal. + +use std::path::{Path, PathBuf}; + +use gpui::Hsla; +use notify::{RecursiveMode, Watcher}; + +use super::ThemeSource; +use super::kde; +use super::omarchy; +use super::portal; +use crate::ThemeVariant; +use crate::system::{PartialPalette, SourceKind, SystemTheme}; + +pub(crate) struct LinuxSource { + backend: Backend, + portal: Option, + gsettings: Option, + file_watcher: Option, + reapply_task: Option>, +} + +/// Which payload the source reads. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Backend { + Omarchy, + Kde, + Portal, + Fallback, +} + +fn home_dir() -> Option { + std::env::var_os("HOME").map(PathBuf::from) +} + +fn detect(home: &Path, portal_reachable: bool, gsettings_reachable: bool) -> Backend { + let omarchy_marker = home + .join(".local/state/omarchy/current/theme.name") + .exists() + || home.join(".local/state/omarchy/current/theme").is_dir() + || home.join(".config/omarchy").is_dir(); + if omarchy_marker { + Backend::Omarchy + } else if home.join(".config/kdeglobals").exists() { + Backend::Kde + } else if portal_reachable || gsettings_reachable { + Backend::Portal + } else { + Backend::Fallback + } +} + +/// What `gsettings` states for GNOME sessions without a portal. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct GSettingsSnapshot { + variant: Option, + accent: Option, +} + +fn gsettings_value(args: &[&str]) -> Option { + let output = std::process::Command::new("gsettings") + .args(args) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout).ok() +} + +/// The named accent colors GNOME ships (libadwaita's background variants). +fn accent_rgb(name: &str) -> Option { + let hex = match name { + "blue" => 0x3584e4, + "teal" => 0x2190a4, + "green" => 0x3a944a, + "yellow" => 0xc88800, + "orange" => 0xed5b00, + "red" => 0xe62d42, + "pink" => 0xd56199, + "purple" => 0x9141ac, + "slate" => 0x6f8396, + _ => return None, + }; + Some(Hsla::from(gpui::rgb(hex))) +} + +fn read_gsettings() -> Option { + let variant = gsettings_value(&["get", "org.gnome.desktop.interface", "color-scheme"]) + .and_then(|value| match value.trim().trim_matches('\'') { + "prefer-dark" => Some(ThemeVariant::Dark), + "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() { + return None; + } + Some(GSettingsSnapshot { variant, accent }) +} + +pub(crate) fn platform_source() -> Box { + let portal = portal::io::PortalClient::connect(); + let gsettings = if portal.is_none() { + read_gsettings() + } else { + None + }; + let backend = home_dir() + .map(|home| detect(&home, portal.is_some(), gsettings.is_some())) + .unwrap_or(Backend::Fallback); + + Box::new(LinuxSource { + backend, + portal, + gsettings, + file_watcher: None, + reapply_task: None, + }) +} + +impl LinuxSource { + fn portal_snapshot( + &self, + variant: ThemeVariant, + portal_appearance: Option, + 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, + root_font_size: None, + 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, + root_font_size: None, + accessibility, + source: SourceKind::Gnome, + }; + } + SystemTheme::fallback(variant) + } +} + +impl ThemeSource for LinuxSource { + fn snapshot(&self, variant: ThemeVariant) -> SystemTheme { + let portal_appearance = self.portal.as_ref().map(|portal| portal.appearance()); + let accessibility = portal_appearance + .map(portal::Appearance::accessibility) + .unwrap_or_default(); + + match self.backend { + Backend::Omarchy => omarchy::io::read_current() + .map(|theme| theme.into_system(accessibility)) + .unwrap_or_else(|| SystemTheme::fallback(variant)), + Backend::Kde => kde::io::read_current() + .map(|scheme| { + scheme.into_system( + accessibility, + portal_appearance.and_then(|appearance| appearance.accent), + portal_appearance.map(|appearance| appearance.variant(variant)), + ) + }) + .unwrap_or_else(|| SystemTheme::fallback(variant)), + Backend::Portal => self.portal_snapshot(variant, portal_appearance, accessibility), + Backend::Fallback => SystemTheme::fallback(variant), + } + } + + fn install_watchers(&mut self, async_cx: gpui::AsyncApp) { + let (reapply_signal, reapply_events) = async_channel::unbounded::<()>(); + + async_cx.update(|cx| { + self.reapply_task = Some(cx.spawn(async move |cx| { + while reapply_events.recv().await.is_ok() { + cx.update(crate::reapply); + } + })); + }); + + if let Some(portal) = &self.portal { + portal.spawn_watch(reapply_signal.clone()); + } + + match self.backend { + Backend::Omarchy => { + let Some(current) = omarchy::io::current_dir() else { + return; + }; + + let watcher = notify::recommended_watcher( + move |event: std::result::Result| match event { + Ok(_) => { + if let Err(error) = reapply_signal.try_send(()) { + eprintln!("theme: omarchy reapply signal failed: {error}"); + } + } + Err(error) => eprintln!("theme: omarchy watch error: {error}"), + }, + ); + + match watcher { + Ok(mut watcher) => { + if let Err(error) = watcher.watch(¤t, RecursiveMode::NonRecursive) { + eprintln!("theme: omarchy watch failed: {error}"); + return; + } + self.file_watcher = Some(watcher); + } + Err(error) => eprintln!("theme: omarchy watcher unavailable: {error}"), + } + } + Backend::Kde => { + let Some(config) = home_dir().map(|home| home.join(".config")) else { + return; + }; + + let watcher = notify::recommended_watcher( + move |event: std::result::Result| match event { + Ok(event) => { + if event.paths.iter().any(|path| { + path.file_name().is_some_and(|name| name == "kdeglobals") + }) && let Err(error) = reapply_signal.try_send(()) + { + eprintln!("theme: kde reapply signal failed: {error}"); + } + } + Err(error) => eprintln!("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}"); + return; + } + self.file_watcher = Some(watcher); + } + Err(error) => eprintln!("theme: kde watcher unavailable: {error}"), + } + } + Backend::Portal | Backend::Fallback => {} + } + } + + fn label(&self) -> &'static str { + match self.backend { + Backend::Omarchy => SourceKind::Omarchy.label(), + Backend::Kde => SourceKind::Kde.label(), + Backend::Portal => SourceKind::Gnome.label(), + Backend::Fallback => SourceKind::Fallback.label(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_detection_cascade_prefers_the_marker_files() { + let home = + std::env::temp_dir().join(format!("gpui-native-kit-detect-{}", std::process::id())); + std::fs::create_dir_all(&home).expect("temp home"); + assert_eq!(detect(&home, false, false), Backend::Fallback); + assert_eq!(detect(&home, false, true), Backend::Portal); + + std::fs::create_dir_all(home.join(".config/omarchy")).expect("omarchy marker"); + assert_eq!(detect(&home, false, false), Backend::Omarchy); + assert_eq!(detect(&home, true, true), Backend::Omarchy); + std::fs::remove_dir_all(home.join(".config/omarchy")).expect("remove marker"); + + std::fs::write(home.join(".config/kdeglobals"), "").expect("kdeglobals"); + assert_eq!(detect(&home, false, false), Backend::Kde); + assert_eq!(detect(&home, true, false), Backend::Kde); + std::fs::remove_dir_all(&home).expect("cleanup"); + } +} diff --git a/crates/theme/src/source/omarchy.rs b/crates/theme/src/source/omarchy.rs new file mode 100644 index 0000000..40f527e --- /dev/null +++ b/crates/theme/src/source/omarchy.rs @@ -0,0 +1,221 @@ +//! Omarchy adapter: reads the current theme's `colors.toml` from the state +//! directory (`~/.local/state/omarchy/current/theme/`). The parse is pure, +//! so fixtures run on every OS; the IO lives behind the Linux cfg. + +use gpui::Hsla; +use toml::Table; + +use crate::system::{PartialPalette, SourceKind, SystemTheme}; +use crate::{AccessibilityPreferences, ThemeVariant}; + +/// The palette Omarchy deploys with the current theme. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct OmarchyTheme { + pub(crate) variant: ThemeVariant, + pub(crate) accent: Option, + pub(crate) palette: PartialPalette, +} + +impl OmarchyTheme { + pub(crate) fn parse(text: &str) -> Option { + let table = text.parse::().ok()?; + + let background = hex_color(&table, "background")?; + let foreground = hex_color(&table, "foreground")?; + + let variant = table + .get("mode") + .and_then(toml::Value::as_str) + .and_then(mode_variant) + .unwrap_or_else(|| variant_by_lightness(background)); + + let accent = hex_color(&table, "accent").or_else(|| hex_color(&table, "color4")); + let selection = hex_color(&table, "selection"); + let muted_foreground = + hex_color(&table, "dark_foreground").or_else(|| hex_color(&table, "color8")); + + let palette = PartialPalette { + background: Some(background), + foreground: Some(foreground), + surface: hex_color(&table, "lighter_background"), + surface_foreground: Some(foreground), + muted_foreground, + destructive: hex_color(&table, "red").or_else(|| hex_color(&table, "color1")), + success: hex_color(&table, "green").or_else(|| hex_color(&table, "color2")), + warning: hex_color(&table, "yellow").or_else(|| hex_color(&table, "orange")), + ring: accent, + selection, + ..PartialPalette::default() + }; + + Some(Self { + variant, + accent, + palette, + }) + } + + pub(crate) fn into_system(self, accessibility: AccessibilityPreferences) -> SystemTheme { + SystemTheme { + variant: self.variant, + accent: self.accent, + palette: self.palette, + control_radius: None, + input_height: None, + root_font_size: None, + accessibility, + source: SourceKind::Omarchy, + } + } +} + +fn mode_variant(mode: &str) -> Option { + match mode { + "dark" => Some(ThemeVariant::Dark), + "light" => Some(ThemeVariant::Light), + _ => None, + } +} + +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 { + let value = table.get(key)?.as_str()?; + let digits = value.trim().strip_prefix('#')?; + if digits.len() != 6 { + return None; + } + let hex = u32::from_str_radix(digits, 16).ok()?; + Some(Hsla::from(gpui::rgb(hex))) +} + +#[cfg(target_os = "linux")] +pub(crate) mod io { + use std::path::PathBuf; + + use super::OmarchyTheme; + + fn home_dir() -> Option { + std::env::var_os("HOME").map(PathBuf::from) + } + + /// The directory theme-set swaps on every theme change. + pub(crate) fn current_dir() -> Option { + Some(home_dir()?.join(".local/state/omarchy/current")) + } + + pub(crate) fn read_current() -> Option { + let text = std::fs::read_to_string( + home_dir()?.join(".local/state/omarchy/current/theme/colors.toml"), + ) + .ok()?; + OmarchyTheme::parse(&text) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const TOKYO_NIGHT: &str = "\ +mode = \"dark\" + +accent = \"#7aa2f7\" +selection = \"#292e42\" +muted = \"#414868\" + +background = \"#1a1b26\" +dark_background = \"#13141c\" +darker_background = \"#0e0e14\" +lighter_background = \"#24283b\" + +foreground = \"#a9b1d6\" +dark_foreground = \"#565f89\" + +red = \"#f7768e\" +yellow = \"#e0af68\" +orange = \"#eb927b\" +green = \"#9ece6a\" +"; + + const ANSI_GENERATED: &str = "\ +accent = \"#0060ff\" +selection = \"#c1c8d2\" + +background = \"#e8e8ec\" +foreground = \"#251f32\" + +color0 = \"#e8e8ec\" +color1 = \"#ab2833\" +color2 = \"#3f7c38\" +color3 = \"#8f6235\" +color4 = \"#0060ff\" +color5 = \"#8d439d\" +color6 = \"#3e7d96\" +color7 = \"#251f32\" +color8 = \"#625e6a\" +color9 = \"#cd3d45\" +color10 = \"#44a540\" +color11 = \"#c18a41\" +color12 = \"#278aff\" +color13 = \"#a659b9\" +color14 = \"#4b9fc0\" +color15 = \"#635d6f\" +"; + + #[test] + fn semantic_keys_map_onto_roles() { + let theme = OmarchyTheme::parse(TOKYO_NIGHT).expect("tokyo-night parses"); + + assert_eq!(theme.variant, ThemeVariant::Dark); + assert_eq!(theme.accent, Some(Hsla::from(gpui::rgb(0x7aa2f7)))); + + let palette = theme.palette; + let background = palette.background.expect("background stated"); + assert_eq!(background, Hsla::from(gpui::rgb(0x1a1b26))); + assert_eq!(palette.surface, Some(Hsla::from(gpui::rgb(0x24283b)))); + assert_eq!( + palette.muted_foreground, + Some(Hsla::from(gpui::rgb(0x565f89))) + ); + assert_eq!(palette.destructive, Some(Hsla::from(gpui::rgb(0xf7768e)))); + assert_eq!(palette.success, Some(Hsla::from(gpui::rgb(0x9ece6a)))); + assert_eq!(palette.warning, Some(Hsla::from(gpui::rgb(0xe0af68)))); + assert_eq!(palette.selection, Some(Hsla::from(gpui::rgb(0x292e42)))); + assert_eq!(palette.ring, theme.accent); + } + + #[test] + fn ansi_fallback_derives_the_variant_from_the_background() { + let theme = OmarchyTheme::parse(ANSI_GENERATED).expect("ANSI palette parses"); + + assert_eq!(theme.variant, ThemeVariant::Light); + assert_eq!(theme.accent, Some(Hsla::from(gpui::rgb(0x0060ff)))); + assert_eq!( + theme.palette.destructive, + Some(Hsla::from(gpui::rgb(0xab2833))) + ); + assert_eq!( + theme.palette.muted_foreground, + Some(Hsla::from(gpui::rgb(0x625e6a))) + ); + } + + #[test] + fn a_stated_mode_beats_the_derivation() { + let text = "mode = \"light\"\naccent = \"#7aa2f7\"\nbackground = \"#1a1b26\"\nforeground = \"#a9b1d6\"\n"; + let theme = OmarchyTheme::parse(text).expect("parses"); + assert_eq!(theme.variant, ThemeVariant::Light); + } + + #[test] + fn non_toml_text_is_rejected() { + assert!(OmarchyTheme::parse("not a theme").is_none()); + } +} diff --git a/crates/theme/src/source/portal.rs b/crates/theme/src/source/portal.rs new file mode 100644 index 0000000..f5507e9 --- /dev/null +++ b/crates/theme/src/source/portal.rs @@ -0,0 +1,233 @@ +//! The xdg-desktop-portal Settings reader (`org.freedesktop.appearance`). +//! GTK and GNOME toolkits take these values from the same place, so this is +//! the canonical source on generic Linux. The value mapping is pure and +//! fixture-tested on every OS; the DBus client lives behind the Linux cfg. + +use gpui::Hsla; + +use crate::{ThemeVariant, system::AccessibilityPreferences}; + +/// The raw portal values, already converted off the DBus wire types. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct AppearanceValues { + /// 0 = no preference, 1 = prefer dark, 2 = prefer light. + pub(crate) color_scheme: Option, + /// The `accent-color` sRGB tuple, each component in 0..1. + pub(crate) accent_rgb: Option<(f64, f64, f64)>, + /// 0 = normal, 1 = higher. + pub(crate) contrast: Option, + /// 0 = no preference, 1 = reduced. + pub(crate) reduced_motion: Option, +} + +impl AppearanceValues { + pub(crate) fn decode(self) -> Appearance { + Appearance { + 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, + a: 1.0, + }) + }), + contrast: self.contrast.unwrap_or(0), + reduced_motion: self.reduced_motion.unwrap_or(0), + } + } +} + +/// What the portal states under `org.freedesktop.appearance`. +/// +/// Per the portal spec, unknown values read as unset. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct Appearance { + pub(crate) color_scheme: Option, + pub(crate) accent: Option, + pub(crate) contrast: u32, + pub(crate) reduced_motion: u32, +} + +impl Appearance { + pub(crate) fn variant(&self, no_preference: ThemeVariant) -> ThemeVariant { + match self.color_scheme { + Some(1) => ThemeVariant::Dark, + Some(2) => ThemeVariant::Light, + _ => no_preference, + } + } + + pub(crate) fn accessibility(self) -> AccessibilityPreferences { + AccessibilityPreferences { + high_contrast: self.contrast >= 1, + reduce_motion: self.reduced_motion >= 1, + reduce_transparency: false, + } + } +} + +#[cfg(target_os = "linux")] +pub(crate) mod io { + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + use zbus::blocking::{Connection, Proxy}; + use zbus::zvariant::OwnedValue; + + use super::{Appearance, AppearanceValues}; + + type AppearanceCache = Arc>; + + const SERVICE: &str = "org.freedesktop.portal.Desktop"; + const PATH: &str = "/org/freedesktop/portal/desktop"; + const INTERFACE: &str = "org.freedesktop.portal.Settings"; + const NAMESPACE: &str = "org.freedesktop.appearance"; + + pub(crate) struct PortalClient { + appearance: AppearanceCache, + } + + impl PortalClient { + /// Reads the appearance once. + pub(crate) fn connect() -> Option { + let proxy = settings_proxy(Connection::session().ok()?)?; + let appearance = Arc::new(Mutex::new(read_appearance(&proxy)?)); + Some(Self { appearance }) + } + + /// The most recently read appearance. + pub(crate) fn appearance(&self) -> Appearance { + *self.appearance.lock().expect("portal cache poisoned") + } + + /// Forwards portal `SettingChanged` events to the reapply signal from a dedicated thread. + pub(crate) fn spawn_watch(&self, reapply_signal: async_channel::Sender<()>) { + let appearance = self.appearance.clone(); + + std::thread::spawn(move || { + let Some(connection) = Connection::session().ok() else { + eprintln!("theme: portal watch skipped, no session bus"); + return; + }; + + let Some(proxy) = settings_proxy(connection) else { + eprintln!("theme: portal watch skipped, settings proxy unavailable"); + return; + }; + + let Ok(signals) = proxy.receive_signal("SettingChanged") else { + eprintln!("theme: portal watch skipped, signal subscription failed"); + return; + }; + + for message in signals { + let Ok((namespace, _key, _value)) = + message.body().deserialize::<(String, String, OwnedValue)>() + else { + continue; + }; + if namespace != NAMESPACE { + continue; + } + match read_appearance(&proxy) { + Some(latest) => { + *appearance.lock().expect("portal cache poisoned") = latest; + if let Err(error) = reapply_signal.try_send(()) { + eprintln!("theme: portal reapply signal failed: {error}"); + } + } + None => eprintln!("theme: portal read failed on SettingChanged"), + } + } + }); + } + } + + fn settings_proxy(connection: Connection) -> Option> { + Proxy::new(&connection, SERVICE, PATH, INTERFACE).ok() + } + + fn read_appearance(proxy: &Proxy<'static>) -> Option { + let values: HashMap> = + proxy.call("ReadAll", &([NAMESPACE],)).ok()?; + + let namespace = values.get(NAMESPACE)?; + let field = |key: &str| namespace.get(key); + + Some( + AppearanceValues { + color_scheme: field("color-scheme") + .and_then(|value| u32::try_from(value.clone()).ok()), + accent_rgb: field("accent-color") + .and_then(|value| <(f64, f64, f64)>::try_from(value.clone()).ok()), + contrast: field("contrast").and_then(|value| u32::try_from(value.clone()).ok()), + reduced_motion: field("reduced-motion") + .and_then(|value| u32::try_from(value.clone()).ok()), + } + .decode(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_full_appearance_decodes() { + let appearance = AppearanceValues { + color_scheme: Some(1), + accent_rgb: Some((0.48, 0.62, 0.89)), + contrast: Some(0), + reduced_motion: Some(0), + } + .decode(); + + assert_eq!( + appearance.variant(crate::ThemeVariant::Light), + crate::ThemeVariant::Dark + ); + let accent = appearance.accent.expect("accent decodes"); + let rgba = gpui::Rgba::from(accent); + assert!(rgba.b > rgba.r); + } + + #[test] + fn no_preference_yields_the_callers_variant() { + let appearance = AppearanceValues { + color_scheme: Some(0), + ..AppearanceValues::default() + } + .decode(); + assert_eq!( + appearance.variant(crate::ThemeVariant::Light), + crate::ThemeVariant::Light + ); + } + + #[test] + fn an_unset_scheme_yields_the_callers_variant() { + let appearance = AppearanceValues::default().decode(); + assert_eq!( + appearance.variant(crate::ThemeVariant::Dark), + crate::ThemeVariant::Dark + ); + assert_eq!(appearance, Appearance::default()); + } + + #[test] + fn accessibility_flags_forward() { + let accessibility = AppearanceValues { + contrast: Some(1), + reduced_motion: Some(1), + ..AppearanceValues::default() + } + .decode() + .accessibility(); + + assert!(accessibility.high_contrast); + assert!(accessibility.reduce_motion); + assert!(!accessibility.reduce_transparency); + } +} diff --git a/crates/theme/src/system.rs b/crates/theme/src/system.rs index 3b8a2d6..b9bd6d4 100644 --- a/crates/theme/src/system.rs +++ b/crates/theme/src/system.rs @@ -14,10 +14,14 @@ pub struct AccessibilityPreferences { /// Which adapter produced a [`SystemTheme`]. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] pub enum SourceKind { #[default] Fallback, Macos, + Omarchy, + Kde, + Gnome, } impl SourceKind { @@ -25,6 +29,9 @@ impl SourceKind { match self { Self::Fallback => "neutral fallback", Self::Macos => "macOS (NSColor)", + Self::Omarchy => "Omarchy (colors.toml)", + Self::Kde => "KDE (kdeglobals)", + Self::Gnome => "GNOME (portal)", } } } diff --git a/docs/PLAN.md b/docs/PLAN.md index 9a32f89..6580bd7 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -168,11 +168,13 @@ Controls through unit 10 paint from existing roles and ramps. | --- | --- | --- | --- | | macOS | compile target | `NSColor` semantic palette (`windowBackgroundColor`, `labelColor`, `controlColor`, `controlAccentColor`, `selectedTextBackgroundColor`, `keyboardFocusIndicatorColor`, …) in the effective appearance via `objc2-app-kit`; `NSWorkspace` accessibility flags | distributed notifications + GPUI appearance events | | Windows 11 (WinUI) | compile target | WinRT `UISettings` (`AccentColor`, `AccentLight1–3`, `AccentDark1–2`, `Background`, `Foreground`), `TextScaleFactor`, `AccessibilitySettings` high-contrast; `Personalize` registry fallback | `UISettings.ColorValuesChanged` | -| GNOME / generic Linux | portal reachable | portal `org.freedesktop.appearance` (`color-scheme`, `accent-color`, `contrast`, `reduced-motion`); `gsettings` fallback | portal `SettingChanged` | -| KDE | `kdeglobals` present | `kdeglobals` color groups + scheme hash + accent; portal for mode/accent | inotify on `kdeglobals` | -| Omarchy | `~/.local/state/omarchy` or `~/.config/omarchy` marker | `current/theme/colors.toml` + `theme.name` (semantic keys or ANSI `color0..15`); portal for contrast/reduced-motion | inotify on the `current` dir (palette edits + symlink replacement) | +| GNOME / generic Linux | portal reachable (else `gsettings`) | portal `org.freedesktop.appearance` (`color-scheme`, `accent-color` as `(ddd)`, `contrast`, `reduced-motion`); `gsettings` fallback (`color-scheme` name + the libadwaita accent-name table) | portal `SettingChanged` | +| KDE | `kdeglobals` present | `kdeglobals` `[Colors:*]` groups (Window/View/Button/Selection) + `[General] AccentColor` (`-1,-1,-1` = wallpaper-derived, covered by the portal); portal for mode/accent | inotify on `~/.config`, filtered to `kdeglobals` (survives kconfig's replace-on-write) | +| Omarchy | `~/.local/state/omarchy` marker (`theme.name`, `current/theme`, or `~/.config/omarchy`) | `current/theme/colors.toml` (semantic keys, or the ANSI `color0..15` fallback the alacritty generator writes); portal for contrast/reduced-motion; mode from the file's `mode` key or background lightness | inotify on the `current` dir — catches theme-set's whole-directory swaps and `theme.name` writes (hand edits of the deployed copy need a switch to re-fire) | | Fallback / unknown | none of the above, or reader failure | bundled neutral light/dark theme | manual only | +Linux adapters landed 2026-10-30 (fixture-tested parsers on every OS; payload facts transcribed from the portal spec, plasma's Breeze schemes, omarchy's `bin/omarchy-theme-set`, and libadwaita). The pinned gpui's `AsyncApp` is not `Send`, so watcher threads signal through an `async_channel` and a main-thread `App::spawn` loop reapplies. + ### 5.4 Accent ramp engine Every target supplies one accent color (or a stated ramp). Where the platform states shades (WinUI `AccentLight1–3`/`AccentDark1–2`, KDE scheme ramps) use them as-is; elsewhere derive the state shades components need (hover, pressed, selected, disabled, subtle fills) in OKLCH with fixed lightness/chroma offsets toward the background or foreground, hue preserved. Derived colors compute once per resolve, never per frame. @@ -192,7 +194,7 @@ Native feel is half geometry. Per-platform tables for: control heights and paddi ### 5.7 Implementation vehicles -`objc2` + `objc2-app-kit` (macOS), `windows` (WinRT `UISettings`), `zbus` (portal DBus), `notify` (file watches), `serde` + `toml` (definitions) — all behind the `ThemeSource` seam so the engine core stays platform-free and testable. +`objc2` + `objc2-app-kit` (macOS), `windows` (WinRT `UISettings`), `zbus` 5 blocking API on a dedicated thread (portal DBus), `async-channel` (watcher→main-thread reapply bridge), `notify` (file watches), `serde` + `toml` (definitions) — all behind the `ThemeSource` seam so the engine core stays platform-free and testable. ### 5.8 What we do differently from native-theme @@ -323,7 +325,7 @@ Dropped from rev 3: D2, D5, D9 (port-era decisions); the review gate returns in 4. ~~Phase D input~~ ✅ — single-line Input (unit 10) + the §6.2 completion pass (modifiers, undo/redo, read-only, actions + key context, caret blink, paint polish). NumberInput and multiline remain open as deferred units. 5. ~~Phase E overlays~~ ✅ — Popover (unit 11), ScrollArea (unit 11.5), Menu (unit 12), Tooltip + Dialog/AlertDialog + Toast (unit 11+ row; Sheet dropped). Phase gate held: escape/dismiss semantics and focus behavior per overlay. 6. ~~Unit 14 — motion, per component (§6 row 14, D21)~~ ✅ Landed in five passes (2026-10-30): (a) theme `reduce_motion` → `cx.set_reduce_motion` in `reapply`; (b) Switch thumb `with_spring`; (c) Toast 8 px slide-in; (d) Dialog scrim enters with the panel; (e) Progress 30 fps cap. Exits and hover washes stay deferred (§6 deferred list). -7. ~~Phase F, data first~~ ✅ data complete (2026-10-30) — `crates/data` opened with List, then Tree, Tabs, Sidebar (the dead `sheet` feature dropped from core along the way; Table dropped from the roadmap). Also extracted `crates/utils` (2026-10-30): `h_flex`/`v_flex` (core's `common` re-exports them), `focus_handle` (the namespaced keyed handle, 8 call sites), `focus_ring` (7 sites), `adjacent_selectable` + `nav_key` (4 sites), and the selectable-row paint helpers `selectable_label_color`/`paint_selectable_row` (3 sites, reading `&Theme` directly — no intermediate color struct) — `core` and `data` depend on it; the facade doesn't re-export it. Remaining Phase F: **Windows/GNOME/KDE/Omarchy theme sources** and the **Segoe Fluent/freedesktop icon resolvers**, slotted in as verification environments allow — a source may state `Motion.overlay` when it needs platform timing (§5.5) — with side-by-side gallery screenshots per platform. NumberInput/multiline return as deferred units when needed. +7. ~~Phase F, data first~~ ✅ data complete (2026-10-30) — `crates/data` opened with List, then Tree, Tabs, Sidebar (the dead `sheet` feature dropped from core along the way; Table dropped from the roadmap). Also extracted `crates/utils` (2026-10-30): `h_flex`/`v_flex` (core's `common` re-exports them), `focus_handle` (the namespaced keyed handle, 8 call sites), `focus_ring` (7 sites), `adjacent_selectable` + `nav_key` (4 sites), and the selectable-row paint helpers `selectable_label_color`/`paint_selectable_row` (3 sites, reading `&Theme` directly — no intermediate color struct) — `core` and `data` depend on it; the facade doesn't re-export it. **Linux theme sources landed (2026-10-30)**: Omarchy (colors.toml parse + state-dir detection + inotify on `current`), KDE (kdeglobals `[Colors:*]` + AccentColor + `~/.config` inotify), GNOME/generic (portal `ReadAll` + `SettingChanged` via zbus 5 blocking on its own thread, gsettings fallback with the libadwaita accent table) — `SourceKind::Omarchy/Kde/Gnome`, detection cascade Omarchy → KDE → portal → fallback, all parsers fixture-tested on every OS and the Linux build compile-checked via `cargo check --target x86_64-unknown-linux-gnu`. Remaining Phase F: the **Windows source (WinRT `UISettings`)** and the **Segoe Fluent/freedesktop icon resolvers**, slotted in as verification environments allow — with side-by-side gallery screenshots per platform. NumberInput/multiline return as deferred units when needed. 8. Then — Phase G: pickers, polish, → 0.1; start `crates/editor` if wanted. Batched polish candidates between units: scrollbar auto-hide + horizontal thumb, `Undo`/`Redo` menu events.