Files
gpui-native-kit/crates/theme/src/source/linux.rs
T

293 lines
10 KiB
Rust

//! 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 super::signal::ReapplySignal;
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>,
/// Keeps the main-thread channel loop alive.
_signal: Option<ReapplySignal>,
}
/// 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,
_signal: 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,
titlebar_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,
titlebar_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 signal = ReapplySignal::install(&async_cx);
if let Some(portal) = &self.portal {
portal.spawn_watch(signal.sender());
}
match self.backend {
Backend::Omarchy => {
let Some(current) = omarchy::io::current_dir() else {
return;
};
let reapply_signal = signal.sender();
let watcher = notify::recommended_watcher(
move |event: std::result::Result<notify::Event, notify::Error>| match event {
Ok(_) => ReapplySignal::poke(&reapply_signal),
Err(error) => eprintln!("theme: omarchy watch error: {error}"),
},
);
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 reapply_signal = signal.sender();
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")
}) {
ReapplySignal::poke(&reapply_signal);
}
}
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 => {}
}
self._signal = Some(signal);
}
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");
}
}