Theme: System follows the desktop's dark/light setting on Linux GNOME (#7944) (#8123)

* 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:
PaddleStroke
2026-10-30 13:25:23 +00:00
committed by GitHub
parent 0fc495b182
commit 36dbcb6553
7 changed files with 306 additions and 4 deletions
Generated
+1
View File
@@ -4242,6 +4242,7 @@ dependencies = [
"windows-sys 0.61.2",
"winit",
"x11rb",
"zbus 5.19.0",
]
[[package]]
+5
View File
@@ -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"
+195
View File
@@ -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)
}
+8
View File
@@ -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.
+5
View File
@@ -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).
+89 -3
View File
@@ -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
+3 -1
View File
@@ -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