forked from reya/gpui-native-kit
theme: add linux system theme sources
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user