mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
* claim: #7944 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Theme: System follows the desktop's dark/light setting on Linux (#7944) On GNOME (Wayland, or X11 through XWayland) the window toolkit never reports the desktop's dark or light preference, nor its changes, so Theme: System kept its colours. The Linux app now reads org.freedesktop.appearance color-scheme through the XDG Desktop Portal's Settings interface (then GNOME's color-scheme and gtk-theme when it has no preference) on a thread of its own, follows the portal's SettingChanged signal, and falls back to gsettings or a slow poll. The window reads the answer through Services::desktop_dark; the decision from the raw values is prefs::desktop_prefers_dark. Windows and macOS keep the window toolkit's theme. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
@@ -4242,6 +4242,7 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"winit",
|
||||
"x11rb",
|
||||
"zbus 5.19.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -104,5 +104,10 @@ winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
raw-window-handle = "0.6"
|
||||
x11rb = { version = "0.13", default-features = false }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# The desktop's dark or light preference through the XDG Desktop Portal (src/desktop_theme.rs,
|
||||
# #7944); already in the tree through eframe's accessibility support (accesskit_unix).
|
||||
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
|
||||
|
||||
[build-dependencies]
|
||||
embed-resource = "3.0.11"
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Linux: the desktop's dark or light preference, for Theme: System (#7944).
|
||||
//!
|
||||
//! The window toolkit does not pass it on everywhere: on GNOME (Wayland, or X11 through XWayland)
|
||||
//! the window never learns it, nor when it changes, so the app kept its colours. This reads the
|
||||
//! freedesktop setting through the XDG Desktop Portal's Settings interface on the session bus
|
||||
//! (`org.freedesktop.appearance color-scheme`, then GNOME's `color-scheme` and `gtk-theme`), the
|
||||
//! same setting other applications follow, and inside a Flatpak too. It runs on a thread of its
|
||||
//! own: the first reading, then the portal's `SettingChanged` signal; where the signal can't be
|
||||
//! had, a reading every few seconds. Without a portal it asks `gsettings` instead, on the same
|
||||
//! thread. The window reads the last answer with an atomic load
|
||||
//! (`solidcraft_ui_egui::Services::desktop_dark`), and the decision from the raw values is
|
||||
//! `solidcraft_ui_egui::prefs::desktop_prefers_dark`.
|
||||
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use zbus::zvariant::{OwnedValue, Value};
|
||||
|
||||
const UNKNOWN: u8 = 0;
|
||||
const DARK: u8 = 1;
|
||||
const LIGHT: u8 = 2;
|
||||
|
||||
/// How often to read again where the portal's change signal is not available.
|
||||
const POLL: Duration = Duration::from_secs(5);
|
||||
/// How long one `gsettings` reading may take before it is given up.
|
||||
const GSETTINGS_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
const PORTAL: &str = "org.freedesktop.portal.Desktop";
|
||||
const PORTAL_PATH: &str = "/org/freedesktop/portal/desktop";
|
||||
const SETTINGS: &str = "org.freedesktop.portal.Settings";
|
||||
const APPEARANCE: &str = "org.freedesktop.appearance";
|
||||
const GNOME: &str = "org.gnome.desktop.interface";
|
||||
|
||||
/// Start following the desktop's preference; the closure answers the latest reading (`None`
|
||||
/// until there is one, or when the desktop says nothing).
|
||||
pub fn start(ctx: &egui::Context) -> Box<dyn Fn() -> Option<bool>> {
|
||||
let state = Arc::new(AtomicU8::new(UNKNOWN));
|
||||
let (thread_state, ctx) = (state.clone(), ctx.clone());
|
||||
if let Err(e) = std::thread::Builder::new().name("desktop-theme".into()).spawn(move || follow(&thread_state, &ctx)) {
|
||||
log::warn!("desktop theme: no thread to follow it: {e}");
|
||||
}
|
||||
Box::new(move || match state.load(Ordering::Relaxed) {
|
||||
DARK => Some(true),
|
||||
LIGHT => Some(false),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Keep `state` on the desktop's preference, asking for a frame whenever it changes.
|
||||
fn follow(state: &AtomicU8, ctx: &egui::Context) {
|
||||
let publish = |dark: Option<bool>| {
|
||||
let code = match dark {
|
||||
Some(true) => DARK,
|
||||
Some(false) => LIGHT,
|
||||
None => UNKNOWN,
|
||||
};
|
||||
if state.swap(code, Ordering::Relaxed) != code {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
};
|
||||
let portal = match portal() {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
log::info!("desktop theme: no settings portal ({e}); asking gsettings");
|
||||
None
|
||||
}
|
||||
};
|
||||
// The portal first; gsettings where there is none or it says nothing (its backend missing).
|
||||
let read = |portal: Option<&zbus::blocking::Proxy<'static>>| portal.and_then(read_portal).or_else(read_gsettings);
|
||||
let from_portal = portal.as_ref().and_then(read_portal);
|
||||
let first = from_portal.or_else(read_gsettings);
|
||||
publish(first);
|
||||
// Wait for the portal's signal only when the portal answers: else the reading is gsettings',
|
||||
// which only a new reading notices.
|
||||
if let Some(p) = portal.as_ref().filter(|_| from_portal.is_some()) {
|
||||
match p.receive_signal("SettingChanged") {
|
||||
Ok(changes) => {
|
||||
for message in changes {
|
||||
if relevant(&message) {
|
||||
publish(read(Some(p)));
|
||||
}
|
||||
}
|
||||
log::info!("desktop theme: the portal's change signal ended; reading every few seconds");
|
||||
}
|
||||
Err(e) => log::info!("desktop theme: no change signal ({e}); reading every few seconds"),
|
||||
}
|
||||
} else if portal.is_none() && first.is_none() && !gsettings_available() {
|
||||
// Neither the portal nor gsettings: nothing to follow; the window toolkit's theme stays.
|
||||
return;
|
||||
}
|
||||
loop {
|
||||
std::thread::sleep(POLL);
|
||||
publish(read(portal.as_ref()));
|
||||
}
|
||||
}
|
||||
|
||||
/// The portal's Settings interface on the session bus.
|
||||
fn portal() -> zbus::Result<zbus::blocking::Proxy<'static>> {
|
||||
let connection = zbus::blocking::Connection::session()?;
|
||||
zbus::blocking::Proxy::new(&connection, PORTAL, PORTAL_PATH, SETTINGS)
|
||||
}
|
||||
|
||||
/// One reading through the portal.
|
||||
fn read_portal(portal: &zbus::blocking::Proxy<'static>) -> Option<bool> {
|
||||
let appearance = read_setting(portal, APPEARANCE, "color-scheme").and_then(|v| as_u32(&v));
|
||||
// The GNOME settings only matter when the freedesktop one has no preference.
|
||||
if matches!(appearance, Some(1 | 2)) {
|
||||
return solidcraft_ui_egui::prefs::desktop_prefers_dark(appearance, None, None);
|
||||
}
|
||||
let gnome = read_setting(portal, GNOME, "color-scheme").and_then(|v| as_string(&v));
|
||||
let gtk_theme = read_setting(portal, GNOME, "gtk-theme").and_then(|v| as_string(&v));
|
||||
solidcraft_ui_egui::prefs::desktop_prefers_dark(appearance, gnome.as_deref(), gtk_theme.as_deref())
|
||||
}
|
||||
|
||||
/// A setting through `ReadOne` (portal version 2), else the older `Read`.
|
||||
fn read_setting(portal: &zbus::blocking::Proxy<'static>, namespace: &str, key: &str) -> Option<OwnedValue> {
|
||||
let one: zbus::Result<OwnedValue> = portal.call("ReadOne", &(namespace, key));
|
||||
one.or_else(|_| -> zbus::Result<OwnedValue> { portal.call("Read", &(namespace, key)) }).ok()
|
||||
}
|
||||
|
||||
/// Whether a `SettingChanged` signal is about a setting the preference is read from.
|
||||
fn relevant(message: &zbus::Message) -> bool {
|
||||
let body = message.body();
|
||||
let Ok((namespace, key, _)) = body.deserialize::<(String, String, OwnedValue)>() else { return false };
|
||||
matches!((namespace.as_str(), key.as_str()), (APPEARANCE, "color-scheme") | (GNOME, "color-scheme" | "gtk-theme"))
|
||||
}
|
||||
|
||||
/// The value inside the variants `Read` wraps it in (a portal answers one or two deep).
|
||||
fn unwrap_variants<'v, 'a>(value: &'v Value<'a>) -> &'v Value<'a> {
|
||||
let mut value = value;
|
||||
for _ in 0..4 {
|
||||
match value {
|
||||
Value::Value(inner) => value = &**inner,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn as_u32(value: &Value<'_>) -> Option<u32> {
|
||||
match unwrap_variants(value) {
|
||||
Value::U32(n) => Some(*n),
|
||||
Value::I32(n) => u32::try_from(*n).ok(),
|
||||
Value::U8(n) => Some(u32::from(*n)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn as_string(value: &Value<'_>) -> Option<String> {
|
||||
match unwrap_variants(value) {
|
||||
Value::Str(s) => Some(s.as_str().to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Without a portal: GNOME's settings through `gsettings` (`None` when it is not there).
|
||||
fn read_gsettings() -> Option<bool> {
|
||||
let gnome = gsettings("color-scheme");
|
||||
let gtk_theme = gsettings("gtk-theme");
|
||||
solidcraft_ui_egui::prefs::desktop_prefers_dark(None, gnome.as_deref(), gtk_theme.as_deref())
|
||||
}
|
||||
|
||||
fn gsettings_available() -> bool {
|
||||
gsettings("gtk-theme").is_some()
|
||||
}
|
||||
|
||||
/// `gsettings get org.gnome.desktop.interface <key>`, its quoted answer, or `None` when the
|
||||
/// program is missing, fails or does not answer in time.
|
||||
fn gsettings(key: &str) -> Option<String> {
|
||||
let mut child = std::process::Command::new("gsettings")
|
||||
.args(["get", GNOME, key])
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
let started = std::time::Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) if status.success() => break,
|
||||
Ok(None) if started.elapsed() < GSETTINGS_TIMEOUT => std::thread::sleep(Duration::from_millis(20)),
|
||||
_ => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = String::new();
|
||||
child.stdout.take()?.take(4096).read_to_string(&mut out).ok()?;
|
||||
let value = out.trim().trim_matches('\'').to_string();
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
@@ -22,6 +22,8 @@
|
||||
mod app_icon;
|
||||
mod come_forward;
|
||||
mod control_server;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod desktop_theme;
|
||||
mod device_lost;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod native_crash;
|
||||
@@ -441,6 +443,12 @@ fn services(cc: &eframe::CreationContext<'_>, folder: &DialogFolder) -> Services
|
||||
graphics_adapters: Vec::new(),
|
||||
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
|
||||
window_system: window_system_now(),
|
||||
// Linux: the desktop's dark or light setting, which the window is not told on every
|
||||
// desktop (#7944). Windows and macOS: the window's own theme.
|
||||
#[cfg(target_os = "linux")]
|
||||
desktop_dark: Some(desktop_theme::start(&cc.egui_ctx)),
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
desktop_dark: None,
|
||||
http_get: Some(std::sync::Arc::new(http_get)),
|
||||
http_download: Some(std::sync::Arc::new(http_download)),
|
||||
// Already an installed app: only the browser build offers to install.
|
||||
|
||||
@@ -408,6 +408,11 @@ pub struct Services {
|
||||
pub graphics_path: Option<String>,
|
||||
/// Linux: the window system this run uses, `wayland` or `x11` (Preferences shows it, #3083).
|
||||
pub window_system: Option<&'static str>,
|
||||
/// The desktop's dark (`Some(true)`) or light preference as the host reads it, for Theme:
|
||||
/// System where the window toolkit does not report it (Linux desktops, #7944). Called every
|
||||
/// frame, so it only reads a value kept up to date elsewhere. None, or `None` from it: the
|
||||
/// window toolkit's theme.
|
||||
pub desktop_dark: Option<Box<dyn Fn() -> Option<bool>>>,
|
||||
/// Adapter names and descriptive labels from the desktop renderer's chosen backends.
|
||||
pub graphics_adapters: Vec<(String, String)>,
|
||||
/// HTTPS GET for the update check (`update`); None: never check (browser, tests).
|
||||
|
||||
@@ -1740,7 +1740,7 @@ fn theme_row(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
Some("export") => {
|
||||
let system_dark = ui.ctx().system_theme().map_or(app.ui.dark, |theme| theme == egui::Theme::Dark);
|
||||
let system_dark = system_dark(app, ui.ctx()).unwrap_or(app.ui.dark);
|
||||
let file = theme_file(app, action_theme.as_deref().unwrap_or(&app.preferences.theme), system_dark);
|
||||
let name = file.get("name").and_then(serde_json::Value::as_str).unwrap_or("Theme").to_string();
|
||||
let text = serde_json::to_string_pretty(&file).unwrap_or_default();
|
||||
@@ -1882,6 +1882,39 @@ fn defaults(app: &mut SolveApp, page: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the desktop asks for dark colours, for Theme: System; `None` when nothing says.
|
||||
/// The host's own reading of the desktop (`Services::desktop_dark`, the freedesktop setting on
|
||||
/// Linux, #7944) comes first, as the window toolkit does not report it on every desktop
|
||||
/// (GNOME on Wayland), nor its changes; then the window toolkit's.
|
||||
pub(crate) fn system_dark(app: &SolveApp, ctx: &egui::Context) -> Option<bool> {
|
||||
app.services.desktop_dark.as_ref().and_then(|f| f()).or_else(|| ctx.system_theme().map(|t| t == egui::Theme::Dark))
|
||||
}
|
||||
|
||||
/// The desktop's dark or light preference from the freedesktop settings a Linux host reads
|
||||
/// (#7944): `appearance` is `org.freedesktop.appearance color-scheme` (1 prefer dark, 2 prefer
|
||||
/// light, 0 no preference), `gnome` is GNOME's `org.gnome.desktop.interface color-scheme`
|
||||
/// (`prefer-dark`, `prefer-light`, `default`) and `gtk_theme` its `gtk-theme` name. An explicit
|
||||
/// preference wins; otherwise a dark-named GTK theme ("Adwaita-dark") means dark, and anything
|
||||
/// else light, as GTK applications read it (GNOME's Light is `default`). `None` when no setting
|
||||
/// was read at all.
|
||||
pub fn desktop_prefers_dark(appearance: Option<u32>, gnome: Option<&str>, gtk_theme: Option<&str>) -> Option<bool> {
|
||||
match appearance {
|
||||
Some(1) => return Some(true),
|
||||
Some(2) => return Some(false),
|
||||
_ => {}
|
||||
}
|
||||
match gnome.map(|s| s.trim().trim_matches('\'')) {
|
||||
Some("prefer-dark") => return Some(true),
|
||||
Some("prefer-light") => return Some(false),
|
||||
_ => {}
|
||||
}
|
||||
let theme = gtk_theme.map(|s| s.trim().trim_matches('\'').to_ascii_lowercase()).filter(|s| !s.is_empty());
|
||||
if theme.as_deref().is_some_and(|t| t.ends_with("-dark") || t.ends_with(":dark")) {
|
||||
return Some(true);
|
||||
}
|
||||
(appearance.is_some() || gnome.is_some() || theme.is_some()).then_some(false)
|
||||
}
|
||||
|
||||
/// Settings that follow the preferences every frame: the theme when it follows the system, and
|
||||
/// the interface scale.
|
||||
/// Tell the native window which appearance to take (#8983): the operating system's while the
|
||||
@@ -1918,9 +1951,9 @@ pub fn apply(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
follow_view_fov(app);
|
||||
if app.preferences.theme == "system"
|
||||
&& let Some(t) = ctx.system_theme()
|
||||
&& let Some(dark) = system_dark(app, ctx)
|
||||
{
|
||||
app.ui.dark = t == egui::Theme::Dark;
|
||||
app.ui.dark = dark;
|
||||
}
|
||||
// A theme file's palette, in place of its base's (dark or light); the visuals follow. A
|
||||
// followed file (#7573) wins while it is followed and has read completely once.
|
||||
@@ -5182,6 +5215,59 @@ mod tests {
|
||||
assert_eq!(h.app.preferences.browser_bodies_folders, BodiesFolders::Several, "Interface's Restore Defaults");
|
||||
}
|
||||
|
||||
/// Theme: System on a Linux desktop (#7944): the freedesktop preference wins, GNOME's
|
||||
/// settings decide when it has none (its Light is `default`, so a plain theme name is
|
||||
/// light), and nothing read leaves the window toolkit to say.
|
||||
#[test]
|
||||
fn the_desktops_colour_scheme_setting_decides_dark_or_light() {
|
||||
use super::desktop_prefers_dark as d;
|
||||
assert_eq!(d(Some(1), Some("prefer-light"), Some("Adwaita")), Some(true), "freedesktop: prefer dark");
|
||||
assert_eq!(d(Some(2), Some("prefer-dark"), Some("Adwaita-dark")), Some(false), "freedesktop: prefer light");
|
||||
assert_eq!(d(Some(0), Some("prefer-dark"), None), Some(true), "no preference: GNOME's");
|
||||
assert_eq!(d(Some(0), Some("'prefer-light'"), Some("Adwaita-dark")), Some(false), "gsettings quotes its answer");
|
||||
assert_eq!(d(Some(0), Some("default"), Some("Adwaita-dark")), Some(true), "a dark GTK theme");
|
||||
assert_eq!(d(None, None, Some("Yaru:dark")), Some(true));
|
||||
assert_eq!(d(Some(0), Some("default"), Some("Adwaita")), Some(false), "GNOME's Light");
|
||||
assert_eq!(d(Some(0), None, None), Some(false), "no preference at all: light, as GTK applications");
|
||||
assert_eq!(d(Some(u32::MAX), None, None), Some(false), "an unknown value is no preference");
|
||||
assert_eq!(d(None, None, Some(" ")), None, "nothing read");
|
||||
assert_eq!(d(None, None, None), None);
|
||||
}
|
||||
|
||||
/// The host's reading of the desktop comes before the window toolkit's theme, which egui
|
||||
/// reports as None in tests, and Theme: System follows it every frame (#7944).
|
||||
#[test]
|
||||
fn theme_system_follows_the_hosts_desktop_setting() {
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
let desktop = Arc::new(AtomicU8::new(0));
|
||||
let reading = desktop.clone();
|
||||
let services = crate::Services {
|
||||
desktop_dark: Some(Box::new(move || match reading.load(Ordering::Relaxed) {
|
||||
1 => Some(true),
|
||||
2 => Some(false),
|
||||
_ => None,
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), services);
|
||||
app.preferences.theme = "system".into();
|
||||
app.ui.dark = true;
|
||||
let ctx = egui::Context::default();
|
||||
super::apply(&mut app, &ctx);
|
||||
assert!(app.ui.dark, "nothing known: the colours stay");
|
||||
desktop.store(2, Ordering::Relaxed);
|
||||
super::apply(&mut app, &ctx);
|
||||
assert!(!app.ui.dark, "the desktop turned light");
|
||||
desktop.store(1, Ordering::Relaxed);
|
||||
super::apply(&mut app, &ctx);
|
||||
assert!(app.ui.dark, "and dark again");
|
||||
app.preferences.theme = "light".into();
|
||||
app.ui.dark = false;
|
||||
super::apply(&mut app, &ctx);
|
||||
assert!(!app.ui.dark, "an explicit theme does not follow the desktop");
|
||||
}
|
||||
|
||||
/// Theme files (#2255): a built-in one and an imported one turn on through `ui.prefs`, the
|
||||
/// next frame draws with their colours (dark following the base), an export gives the theme
|
||||
/// back complete, imported themes are saved with the preferences, Remove falls back to the
|
||||
|
||||
@@ -203,7 +203,9 @@ too narrow for a section's buttons, its menu lists them again.
|
||||
|
||||
### Colour themes
|
||||
|
||||
Interface › **Theme** lists Dark, Light and System (follows the operating system), the built-in
|
||||
Interface › **Theme** lists Dark, Light and System (follows the operating system, and changes
|
||||
with it; on Linux it reads the desktop's dark or light style through the desktop portal, as
|
||||
GNOME and KDE set it), the built-in
|
||||
themes and any themes you have imported. A choice applies at once. Under the themes, **Viewport** draws the 3D view in the Light or Dark theme's colours whatever the window uses, for example the model on a light view in a dark window (**Follow theme**, the default, keeps the theme's own). **Logo** draws the SolidCraft logo at the top left in shades of the theme's accent instead of the theme's logo colours. The built-in themes are Gruvbox
|
||||
(dark and light, each in hard, normal and soft contrast), SolidCraft 95, Platinum 9, Cupertino,
|
||||
Green Phosphor, Amber Phosphor, Blueprint, One Dark, Tokyo Night, Solarized Light, Nord, High
|
||||
|
||||
Reference in New Issue
Block a user