theme: add linux system theme sources

This commit is contained in:
2026-10-31 07:57:27 +07:00
parent dacdd84aa4
commit c45f939211
11 changed files with 1187 additions and 10 deletions
Generated
+93
View File
@@ -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"
+5
View File
@@ -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"
+1 -2
View File
@@ -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;
+16 -1
View File
@@ -17,7 +17,12 @@ pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
Box::new(macos::MacosSource::default())
}
#[cfg(not(target_os = "macos"))]
#[cfg(target_os = "linux")]
pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
linux::platform_source()
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
Box::new(fallback::FallbackSource)
}
@@ -25,4 +30,14 @@ pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
#[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;
+2 -2
View File
@@ -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 {
+307
View File
@@ -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<Hsla>,
pub(crate) background: Option<Hsla>,
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<Hsla>,
portal_variant: Option<crate::ThemeVariant>,
) -> 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<Hsla> {
color_list(group.get(key)?)
}
/// Parses `r,g,b[,a]` with components in 0..=255.
fn color_list(value: &str) -> Option<Hsla> {
let mut components = value.split(',').map(str::trim);
let red = components.next()?.parse::<f32>().ok()?;
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 {
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<PathBuf> {
Some(
std::env::var_os("HOME")
.map(PathBuf::from)?
.join(".config/kdeglobals"),
)
}
pub(crate) fn read_current() -> Option<KdeScheme> {
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);
}
}
+295
View File
@@ -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<portal::io::PortalClient>,
gsettings: Option<GSettingsSnapshot>,
file_watcher: Option<notify::RecommendedWatcher>,
reapply_task: Option<gpui::Task<()>>,
}
/// Which payload the source reads.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Backend {
Omarchy,
Kde,
Portal,
Fallback,
}
fn home_dir() -> Option<PathBuf> {
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<ThemeVariant>,
accent: Option<Hsla>,
}
fn gsettings_value(args: &[&str]) -> Option<String> {
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<Hsla> {
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<GSettingsSnapshot> {
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<dyn ThemeSource> {
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<portal::Appearance>,
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<notify::Event, notify::Error>| 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(&current, 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<notify::Event, notify::Error>| 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");
}
}
+221
View File
@@ -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<Hsla>,
pub(crate) palette: PartialPalette,
}
impl OmarchyTheme {
pub(crate) fn parse(text: &str) -> Option<Self> {
let table = text.parse::<Table>().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<ThemeVariant> {
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<Hsla> {
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<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
/// The directory theme-set swaps on every theme change.
pub(crate) fn current_dir() -> Option<PathBuf> {
Some(home_dir()?.join(".local/state/omarchy/current"))
}
pub(crate) fn read_current() -> Option<OmarchyTheme> {
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());
}
}
+233
View File
@@ -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<u32>,
/// 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<u32>,
/// 0 = no preference, 1 = reduced.
pub(crate) reduced_motion: Option<u32>,
}
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<u32>,
pub(crate) accent: Option<Hsla>,
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<Mutex<Appearance>>;
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<Self> {
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<'static>> {
Proxy::new(&connection, SERVICE, PATH, INTERFACE).ok()
}
fn read_appearance(proxy: &Proxy<'static>) -> Option<Appearance> {
let values: HashMap<String, HashMap<String, OwnedValue>> =
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);
}
}
+7
View File
@@ -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)",
}
}
}
+7 -5
View File
@@ -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.