mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-24 02:26:46 -06:00
Merge pull request #977 from MarekSpodBiedry/issue-951
Follow the desktop light/dark setting for the System theme on Linux (#951)
This commit is contained in:
Generated
+1
@@ -3800,6 +3800,7 @@ dependencies = [
|
||||
"soundcraft-video",
|
||||
"soundcraft-vst3-host",
|
||||
"toml",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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<egui::Theme>, desktop: impl FnOnce() -> Option<egui::Theme>) -> Option<egui::Theme> {
|
||||
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<egui::Theme> {
|
||||
#[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<Arc<AtomicU8>> = OnceLock::new();
|
||||
|
||||
pub fn current(ctx: &egui::Context) -> Option<egui::Theme> {
|
||||
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<zbus::blocking::Proxy<'static>> {
|
||||
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<zbus::zvariant::OwnedValue> = 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<egui::Theme>) {
|
||||
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<u32> {
|
||||
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<egui::Theme> {
|
||||
match code {
|
||||
1 => Some(egui::Theme::Dark),
|
||||
2 => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn code_of(theme: Option<egui::Theme>) -> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user