//! 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, gsettings: Option, file_watcher: Option, /// Keeps the main-thread channel loop alive. _signal: Option, } /// Which payload the source reads. #[derive(Clone, Copy, Debug, PartialEq)] enum Backend { Omarchy, Kde, Portal, Fallback, } fn home_dir() -> Option { 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, accent: Option, } fn gsettings_value(args: &[&str]) -> Option { 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 { 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 { 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 { 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, 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| 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| 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"); } }