diff --git a/Cargo.lock b/Cargo.lock index 6158b44b..94af97cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3800,6 +3800,7 @@ dependencies = [ "soundcraft-video", "soundcraft-vst3-host", "toml", + "zbus", ] [[package]] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 66158bba..874fd2e1 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -27,6 +27,9 @@ serde_json.workspace = true log.workspace = true image.workspace = true +[target.'cfg(target_os = "linux")'.dependencies] +zbus = "5" + [dev-dependencies] egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } diff --git a/crates/ui-egui/src/appearance.rs b/crates/ui-egui/src/appearance.rs new file mode 100644 index 00000000..da917bfe --- /dev/null +++ b/crates/ui-egui/src/appearance.rs @@ -0,0 +1,135 @@ +//! The desktop's dark/light setting for the System theme on Linux, where eframe/winit reports no +//! system theme to egui. +//! +//! The XDG settings portal (`org.freedesktop.appearance` / `color-scheme`) is read once on a worker +//! thread, which then follows its `SettingChanged` signal and wakes the UI only when the value +//! changes. Nothing is polled. Elsewhere egui's own report (`Context::system_theme`) is enough. + +/// egui's own report when it has one, otherwise the desktop's. +pub fn resolve(reported: Option, desktop: impl FnOnce() -> Option) -> Option { + reported.or_else(desktop) +} + +/// The desktop's appearance, or `None` when it is unknown or this platform has no portal. +/// Unit tests never ask the real desktop, so they do not depend on the machine's setting. +pub fn desktop_theme(ctx: &egui::Context) -> Option { + #[cfg(all(target_os = "linux", not(test)))] + { + linux::current(ctx) + } + #[cfg(any(not(target_os = "linux"), test))] + { + let _ = ctx; + None + } +} + +#[cfg(target_os = "linux")] +#[cfg_attr(test, allow(dead_code))] +mod linux { + use std::sync::atomic::{AtomicU8, Ordering}; + use std::sync::{Arc, OnceLock}; + + const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance"; + const PORTAL_KEY: &str = "color-scheme"; + + /// The last `color-scheme` seen: 0 = unknown or no preference, 1 = dark, 2 = light. + static STATE: OnceLock> = OnceLock::new(); + + pub fn current(ctx: &egui::Context) -> Option { + let state = STATE.get_or_init(|| { + let value = Arc::new(AtomicU8::new(0)); + let (worker_value, worker_ctx) = (Arc::clone(&value), ctx.clone()); + // If the thread cannot start, the value stays 0 and System keeps following egui only. + let _ = std::thread::Builder::new().name("appearance-portal".into()).spawn(move || watch(&worker_value, &worker_ctx)); + value + }); + decode(u32::from(state.load(Ordering::Relaxed))) + } + + fn portal_proxy() -> Option> { + let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?; + zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok() + } + + fn watch(value: &AtomicU8, ctx: &egui::Context) { + let Some(proxy) = portal_proxy() else { return }; + // Subscribe before Read so a change during startup is queued rather than lost. + let Ok(signals) = proxy.receive_signal("SettingChanged") else { return }; + let initial: Option = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok(); + publish(value, ctx, initial.and_then(portal_code).and_then(decode)); + // Blocks until the portal sends a signal: no polling. + for message in signals { + let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else { + continue; + }; + if namespace == PORTAL_NAMESPACE && key == PORTAL_KEY { + publish(value, ctx, portal_code(changed).and_then(decode)); + } + } + } + + fn publish(value: &AtomicU8, ctx: &egui::Context, theme: Option) { + if value.swap(code_of(theme), Ordering::Relaxed) != code_of(theme) { + ctx.request_repaint(); + } + } + + /// The `u32` inside a portal value. `Settings.Read` answers a variant, and some portals nest + /// another variant inside it. + fn portal_code(value: zbus::zvariant::OwnedValue) -> Option { + let mut value: zbus::zvariant::Value<'_> = value.into(); + for _ in 0..4 { + match value { + zbus::zvariant::Value::Value(inner) => value = *inner, + other => return u32::try_from(other).ok(), + } + } + None + } + + /// The portal's `color-scheme`: 0 = no preference, 1 = prefer dark, 2 = prefer light. + pub(super) fn decode(code: u32) -> Option { + match code { + 1 => Some(egui::Theme::Dark), + 2 => Some(egui::Theme::Light), + _ => None, + } + } + + fn code_of(theme: Option) -> u8 { + match theme { + Some(egui::Theme::Dark) => 1, + Some(egui::Theme::Light) => 2, + None => 0, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn desktop_setting_is_used_only_when_egui_reports_nothing() { + assert_eq!(resolve(None, || Some(egui::Theme::Light)), Some(egui::Theme::Light)); + assert_eq!(resolve(None, || None), None); + assert_eq!(resolve(Some(egui::Theme::Dark), || Some(egui::Theme::Light)), Some(egui::Theme::Dark)); + let asked = std::cell::Cell::new(false); + let theme = resolve(Some(egui::Theme::Light), || { + asked.set(true); + None + }); + assert_eq!(theme, Some(egui::Theme::Light)); + assert!(!asked.get(), "must not ask the desktop"); + } + + #[cfg(target_os = "linux")] + #[test] + fn portal_color_scheme_codes() { + assert_eq!(linux::decode(1), Some(egui::Theme::Dark)); + assert_eq!(linux::decode(2), Some(egui::Theme::Light)); + assert_eq!(linux::decode(0), None); + assert_eq!(linux::decode(7), None); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8b532400..dbb7e118 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -4,6 +4,7 @@ //! executing commands. It can be replaced without touching anything below it. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod appearance; pub mod bounce; pub mod control; pub mod credits; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 0cf5b9bd..1b4541aa 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -393,12 +393,13 @@ pub fn is_light() -> bool { LIGHT_THEME.load(Ordering::Relaxed) } -/// Resolve each frame from the integration's current OS appearance. Unknown uses Dark. +/// Resolve each frame from the integration's current OS appearance, or on Linux the desktop's own +/// setting when egui reports none. Unknown uses Dark. pub fn wants_light(ctx: &egui::Context, mode: ThemeMode) -> bool { match mode { ThemeMode::Light => true, ThemeMode::Dark => false, - ThemeMode::System => ctx.system_theme() == Some(egui::Theme::Light), + ThemeMode::System => crate::appearance::resolve(ctx.system_theme(), || crate::appearance::desktop_theme(ctx)) == Some(egui::Theme::Light), } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9efe3789..0ece1140 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -83,9 +83,11 @@ script events such as `{"system_theme": "dark"}` to change it on subsequent fram Appearance defaults to Dark. `ui.theme {"mode":"system"}` follows the appearance supplied by the native or web integration at launch and on later frames, while the saved choice stays System. -When the integration cannot report an appearance, System uses Dark. Manual Light and Dark stay -fixed when the OS appearance changes. An offscreen render without `--system-theme` supplies no -OS appearance and therefore resolves System to Dark. +When the integration cannot report an appearance, as on Linux, System reads the desktop's +settings portal (`org.freedesktop.appearance` `color-scheme`) and follows its changes; without +a portal answer it uses Dark. Manual Light and Dark stay fixed when the OS appearance changes. +An offscreen render without `--system-theme` supplies no OS appearance and therefore resolves +System to Dark, or on Linux to the desktop's portal setting when one is available. ## Panel docking