theme: add linux system theme sources

This commit is contained in:
2026-10-31 07:57:27 +07:00
parent dacdd84aa4
commit c45f939211
11 changed files with 1187 additions and 10 deletions
+233
View File
@@ -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);
}
}