theme: add linux system theme sources
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
//! 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<u32>,
|
||||
/// 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<u32>,
|
||||
/// 0 = no preference, 1 = reduced.
|
||||
pub(crate) reduced_motion: Option<u32>,
|
||||
}
|
||||
|
||||
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<u32>,
|
||||
pub(crate) accent: Option<Hsla>,
|
||||
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<Mutex<Appearance>>;
|
||||
|
||||
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<Self> {
|
||||
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;
|
||||
if let Err(error) = reapply_signal.try_send(()) {
|
||||
eprintln!("theme: portal reapply signal failed: {error}");
|
||||
}
|
||||
}
|
||||
None => eprintln!("theme: portal read failed on SettingChanged"),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn settings_proxy(connection: Connection) -> Option<Proxy<'static>> {
|
||||
Proxy::new(&connection, SERVICE, PATH, INTERFACE).ok()
|
||||
}
|
||||
|
||||
fn read_appearance(proxy: &Proxy<'static>) -> Option<Appearance> {
|
||||
let values: HashMap<String, HashMap<String, OwnedValue>> =
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user