forked from reya/gpui-native-kit
293 lines
10 KiB
Rust
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(¤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 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");
|
|
}
|
|
}
|