//! 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, /// 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, /// 0 = no preference, 1 = reduced. pub(crate) reduced_motion: Option, } 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, pub(crate) accent: Option, 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>; 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 { 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; super::super::signal::ReapplySignal::poke(&reapply_signal); } None => eprintln!("theme: portal read failed on SettingChanged"), } } }); } } fn settings_proxy(connection: Connection) -> Option> { Proxy::new(&connection, SERVICE, PATH, INTERFACE).ok() } fn read_appearance(proxy: &Proxy<'static>) -> Option { let values: HashMap> = 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); } }