From e8d935298f45de4922cecdf9396b2e91b88db4d5 Mon Sep 17 00:00:00 2001 From: GyuriX Date: Sat, 10 Oct 2026 21:36:26 +0200 Subject: [PATCH 1/2] Linux: the display theme asks the desktop, not winit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit winit reports no system theme on Linux — `ActiveEventLoop::system_theme()` is `None` there on every desktop — so `System` in the appearance setting never saw the desktop's choice and fell back to light. A new `desktop_theme` module asks the XDG Desktop Portal's Settings interface for `org.gnome.desktop.interface color-scheme` on a thread of its own, so no D-Bus call is ever made from the frame loop, and the app follows the answer, including a change made while it runs. egui resolves `ThemePreference::System` from what winit reports and offers no way to set that itself, so where a desktop answered, the app hands egui the concrete theme instead. What is drawn is unchanged on macOS and Windows, where winit answers and the same resolution happens a step earlier; the saved preference is still `system`, so a file keeps meaning the same thing. The colour scheme is asked for under its GNOME schema name first, which GNOME's portal and XFCE's both answer, then under the older freedesktop one, which KDE answers. A desktop that answers neither leaves the user's own choice alone. --- Cargo.lock | 1 + crates/ui-egui/Cargo.toml | 6 ++ crates/ui-egui/src/desktop_theme.rs | 132 +++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 19 +++- crates/ui-egui/tests/system_theme.rs | 4 +- 5 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/src/desktop_theme.rs diff --git a/Cargo.lock b/Cargo.lock index 6ff01cc..a4631e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1546,6 +1546,7 @@ dependencies = [ "sys-locale", "toml", "web-sys", + "zbus", ] [[package]] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index a6a2da2..bd48b92 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -17,6 +17,12 @@ serde_json = { workspace = true } log = { workspace = true } image = { workspace = true } +# Linux only, where winit reports no system theme: the desktop portal's light/dark answer (see +# `desktop_theme.rs`). Already in the tree, with these features, through egui's own +# accessibility bus; pinned to one version so nothing is built twice. +[target.'cfg(target_os = "linux")'.dependencies] +zbus = "=5.19.0" + [dev-dependencies] egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } diff --git a/crates/ui-egui/src/desktop_theme.rs b/crates/ui-egui/src/desktop_theme.rs new file mode 100644 index 0000000..2eb5d83 --- /dev/null +++ b/crates/ui-egui/src/desktop_theme.rs @@ -0,0 +1,132 @@ +//! The desktop's own light/dark choice, for an interface setting that follows it. +//! +//! winit reports no system theme on Linux — `ActiveEventLoop::system_theme` is `None` on every +//! desktop there — so "Use system setting" has to ask the desktop itself. The XDG Desktop Portal's +//! Settings interface answers on GNOME, KDE and XFCE alike, over X11 and Wayland alike. + +use std::sync::mpsc::{Receiver, TryRecvError, channel}; +use std::time::Duration; + +/// How often [`DesktopTheme::start`] re-reads the desktop's answer. The portal also publishes a +/// change signal, but subscribing needs a connection kept alive for the life of every window; one +/// round trip every few seconds is cheap and reacts soon enough after a theme switch. +const POLL: Duration = Duration::from_secs(4); + +/// Whether the desktop asks applications to draw dark. `None` when it says nothing, and when +/// there is no session bus or no portal to ask (a bare X server, a container), so the caller +/// keeps whatever it chose itself. +pub fn prefers_dark() -> Option { + read() +} + +/// Watches the desktop and reports its choice when it changes. [`prefers_dark`] covers the +/// answer at start-up; this covers the user switching theme while the app runs. +#[derive(Default)] +pub struct DesktopTheme { + /// `None` when no watcher thread could be started; the one-shot read still works. + changes: Option>>, +} + +impl DesktopTheme { + /// Start watching. Nothing runs before this is called, and calling it once is enough. The + /// first answer is sent straight away, so the caller does not have to ask for it separately. + pub fn start() -> Self { + let (tx, rx) = channel(); + let started = std::thread::Builder::new().name("gridcraft-desktop-theme".into()).spawn(move || { + let mut last = read(); + if tx.send(last).is_err() { + return; + } + loop { + std::thread::sleep(POLL); + let now = read(); + if now != last { + last = now; + if tx.send(now).is_err() { + return; + } + } + } + }); + Self { changes: started.ok().map(|_| rx) } + } + + /// The desktop's new answer if it changed since the last call, to be polled once a frame. + /// Several changes in one frame collapse to the newest, and a desktop that stops answering + /// leaves the last answer in place. + pub fn take_change(&mut self) -> Option { + let Some(changes) = &self.changes else { return None }; + let mut newest = None; + loop { + match changes.try_recv() { + Ok(changed) => newest = changed, + Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => return newest, + } + } + } +} + +/// Ask the portal once. D-Bus calls block, so this belongs off the UI thread: the watcher's +/// thread is its only caller. +#[cfg(target_os = "linux")] +fn read() -> Option { + const PORTAL: &str = "org.freedesktop.portal.Desktop"; + const SETTINGS: &str = "org.freedesktop.portal.Settings"; + const SCHEMAS: [(&str, &str); 2] = [("org.gnome.desktop.interface", "color-scheme"), ("org.freedesktop.interface.color-scheme", "color-scheme")]; + let connection = zbus::blocking::Connection::session().ok()?; + for (schema, key) in SCHEMAS { + let Ok(reply) = connection.call_method(Some(PORTAL), "/org/freedesktop/portal/desktop", Some(SETTINGS), "ReadOne", &(schema, key)) else { + continue; + }; + let Ok((answer,)) = reply.body().deserialize::<(zbus::zvariant::OwnedValue,)>() else { continue }; + if let Ok(scheme) = String::try_from(&*answer) { + return parse(&scheme); + } + } + None +} + +/// The portal's `color-scheme` answer. `default` means the desktop states no preference, so +/// neither does the application. +#[cfg(target_os = "linux")] +fn parse(scheme: &str) -> Option { + match scheme { + "prefer-dark" => Some(true), + "prefer-light" => Some(false), + _ => None, + } +} + +/// macOS and Windows answer through winit, which the caller already has. +#[cfg(not(target_os = "linux"))] +fn read() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The portal answers with one of three strings; only the two preferences are a choice. + #[test] + #[cfg(target_os = "linux")] + fn the_portal_answer_reads_as_a_choice_or_as_none() { + assert_eq!(parse("prefer-dark"), Some(true)); + assert_eq!(parse("prefer-light"), Some(false)); + assert_eq!(parse("default"), None); + assert_eq!(parse(""), None); + } + + /// Off Linux the caller already has winit's answer, so this adds nothing. + #[test] + #[cfg(not(target_os = "linux"))] + fn other_platforms_answer_through_winit() { + assert_eq!(prefers_dark(), None); + } + + /// A watcher that was never started is inert rather than a panic. + #[test] + fn a_watcher_without_a_thread_is_inert() { + assert_eq!(DesktopTheme::default().take_change(), None); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b039943..b6ef62b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -35,6 +35,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value as Json, json}; pub use control::{ControlRequest, ControlResponse}; +pub mod desktop_theme; /// Persisted UI preferences. #[derive(Clone, Debug, Serialize, Deserialize)] @@ -155,6 +156,10 @@ pub struct SheetApp { /// only when it changes this (see `theme::HanOrder`). fonts_han: Option, effective_dark: bool, + /// The desktop's light/dark choice, watched while the app runs (Linux has no winit answer). + desktop_theme: desktop_theme::DesktopTheme, + /// The last answer [`desktop_theme`] gave, so a frame that asks costs nothing. + desktop_dark: Option, pub name_box: Option, /// Transient ribbon keyboard navigation; never saved with UI preferences. pub keytips: keytips::KeyTips, @@ -189,6 +194,8 @@ impl SheetApp { fonts_set: false, fonts_han: None, effective_dark: false, + desktop_theme: desktop_theme::DesktopTheme::start(), + desktop_dark: None, name_box: None, keytips: keytips::KeyTips::default(), shots: control::Shots::default(), @@ -543,7 +550,17 @@ impl SheetApp { ctx.request_repaint(); } } - let preference = self.ui.theme_preference(); + if let Some(answer) = self.desktop_theme.take_change() { + self.desktop_dark = Some(answer); + } + let preference = match ( + self.ui.system_theme, + ctx.system_theme().or_else(|| self.desktop_dark.map(|d| if d { egui::Theme::Dark } else { egui::Theme::Light })), + ) { + (true, Some(egui::Theme::Dark)) => egui::ThemePreference::Dark, + (true, Some(egui::Theme::Light)) => egui::ThemePreference::Light, + _ => self.ui.theme_preference(), + }; if ctx.options(|o| o.theme_preference) != preference { ctx.set_theme(preference); ctx.request_repaint(); diff --git a/crates/ui-egui/tests/system_theme.rs b/crates/ui-egui/tests/system_theme.rs index 0d2cdea..c92ca91 100644 --- a/crates/ui-egui/tests/system_theme.rs +++ b/crates/ui-egui/tests/system_theme.rs @@ -99,7 +99,7 @@ fn system_tracks_live_os_changes_with_both_gridcraft_palettes_and_light_fallback system_theme(&mut h, reported); assert_appearance(&h, dark); assert_eq!(preferences(&h)["systemTheme"], true); - assert_eq!(h.ctx.options(|o| o.theme_preference), ThemePreference::System); + assert_eq!(h.ctx.theme(), if dark { Theme::Dark } else { Theme::Light }); } } @@ -112,7 +112,7 @@ fn ribbon_selects_system_and_fixed_modes_ignore_subsequent_os_changes() { h.run_steps(2); select_ribbon_theme(&mut h, "System"); assert_eq!(preferences(&h)["systemTheme"], true); - assert_eq!(h.ctx.options(|o| o.theme_preference), ThemePreference::System); + assert_eq!(h.ctx.theme(), Theme::Dark); assert_appearance(&h, true); for (label, dark, preference) in [("Dark", true, ThemePreference::Dark), ("Light", false, ThemePreference::Light)] { select_ribbon_theme(&mut h, label); From 55d41eb13c0b748630c40aaf9d67dbe062bc3c6f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 15:09:43 -0400 Subject: [PATCH 2/2] Desktop theme watcher starts only in the desktop app, not in every SheetApp (tests, embedders) (review fix-up for #203) Co-Authored-By: Claude Opus 5.5 --- apps/gridcraft/src/main.rs | 1 + crates/ui-egui/src/lib.rs | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index aa22826..b7e15cf 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -272,6 +272,7 @@ fn main() -> eframe::Result<()> { session.new_workbook(); } let mut app = SheetApp::new(session, services()); + app.watch_desktop_theme(); app.after_engine(); // Show warnings from files opened on the command line. let control_port = control_port; load_prefs(&mut app); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 24dbfeb..144622b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -234,7 +234,7 @@ impl SheetApp { fonts_set: false, fonts_han: None, effective_dark: false, - desktop_theme: desktop_theme::DesktopTheme::start(), + desktop_theme: desktop_theme::DesktopTheme::default(), desktop_dark: None, name_box: None, keytips: keytips::KeyTips::default(), @@ -338,6 +338,12 @@ impl SheetApp { } /// Runs a command and shows its error in a message box (for menu/ribbon clicks). + /// Follow the desktop's light/dark choice while the app runs (Linux; the desktop app calls + /// this once — tests and embedders don't start the watcher thread). + pub fn watch_desktop_theme(&mut self) { + self.desktop_theme = desktop_theme::DesktopTheme::start(); + } + pub fn run_or_alert(&mut self, id: &str, params: Json) { // Printing from the UI also hands the PDF to the host's print flow (web). if id == "file.print" {