theme: add linux system theme sources
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(¤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<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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user