From 370150186b0c623da5821d9f6e3961968e699f0e Mon Sep 17 00:00:00 2001 From: Fabio Lussi Date: Mon, 12 Oct 2026 01:53:48 +0200 Subject: [PATCH] Linux: Theme > System follows the desktop's light/dark setting (#641) Squash of PR #641 by @itrycodinganditsbad. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + apps/cadcraft/Cargo.toml | 4 + apps/cadcraft/src/desktop_theme.rs | 174 +++++++++++++++++++++++++++++ apps/cadcraft/src/main.rs | 32 +++++- 4 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 apps/cadcraft/src/desktop_theme.rs diff --git a/Cargo.lock b/Cargo.lock index afdd13e..6e9ffdd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -554,6 +554,7 @@ dependencies = [ "sys-locale", "winit", "winresource", + "zbus", ] [[package]] diff --git a/apps/cadcraft/Cargo.toml b/apps/cadcraft/Cargo.toml index 90b55b2..b867353 100644 --- a/apps/cadcraft/Cargo.toml +++ b/apps/cadcraft/Cargo.toml @@ -24,6 +24,10 @@ winresource = "0.1" [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] winit = { version = "0.30", default-features = false, features = ["x11"] } +# View ▸ Theme ▸ System: the desktop's light/dark choice from the XDG desktop portal +# (desktop_theme.rs). Already in Cargo.lock through accesskit; pinned to that version so nothing +# is built twice. +zbus = { version = "=5.19.0", default-features = false, features = ["async-io", "blocking-api"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } diff --git a/apps/cadcraft/src/desktop_theme.rs b/apps/cadcraft/src/desktop_theme.rs new file mode 100644 index 0000000..78ecca1 --- /dev/null +++ b/apps/cadcraft/src/desktop_theme.rs @@ -0,0 +1,174 @@ +//! The desktop's own light/dark choice, for View ▸ Theme ▸ System. +//! +//! winit reports no system theme on Linux and the BSDs (`RawInput::system_theme` stays `None`), +//! so the System choice fell back to dark whatever the desktop said. This asks the desktop itself: +//! the XDG Desktop Portal's Settings interface, which answers on GNOME, KDE and XFCE alike, over +//! X11 and Wayland alike. The answer is handed to egui as the system theme +//! (`App::raw_input_hook`), so the app and egui's own widgets follow it as they follow macOS and +//! Windows. +//! +//! Ported from PdfCraft (storytold/pdfcraft#881). + +use std::sync::mpsc::{Receiver, TryRecvError, channel}; +use std::time::Duration; + +/// How often the watcher re-reads the desktop's answer. The portal also publishes a change +/// signal, but subscribing needs a connection kept alive for the life of the window; one round +/// trip every few seconds is cheap and reacts soon enough after a theme switch. +const POLL: Duration = Duration::from_secs(4); + +/// Watches the desktop and reports its choice when it changes. +#[derive(Default)] +pub struct DesktopTheme { + /// `None` when no watcher thread could be started (or on a platform where winit answers). + changes: Option>>, + /// The desktop's last answer. + current: Option, +} + +impl DesktopTheme { + /// Start watching. The first answer is sent straight away; `wake` is called after each + /// answer, so an idle window repaints and picks it up. + pub fn start(wake: impl Fn() + Send + 'static) -> Self { + if !ASKS_THE_DESKTOP { + return Self::default(); + } + let (tx, rx) = channel(); + let started = std::thread::Builder::new().name("cadcraft-desktop-theme".into()).spawn(move || { + let mut last = read(); + if tx.send(last).is_err() { + return; + } + wake(); + loop { + std::thread::sleep(POLL); + let now = read(); + if now != last { + last = now; + if tx.send(now).is_err() { + return; + } + wake(); + } + } + }); + Self { changes: started.ok().map(|_| rx), current: None } + } + + /// The desktop's choice, `None` when it states none or there is nobody to ask (no session + /// bus, no portal). Polled once a frame; a desktop that stops answering keeps the last answer. + pub fn current(&mut self) -> Option { + if let Some(changes) = &self.changes { + loop { + match changes.try_recv() { + Ok(Some(theme)) => self.current = Some(theme), + Ok(None) => {} + Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break, + } + } + } + self.current + } +} + +/// Where winit has no answer and the portal does. +const ASKS_THE_DESKTOP: bool = cfg!(all(unix, not(target_os = "macos"))); + +/// Ask the portal once. D-Bus calls block, so this belongs off the UI thread: the watcher's +/// thread is its only caller. +#[cfg(all(unix, not(target_os = "macos")))] +fn read() -> Option { + const PORTAL: &str = "org.freedesktop.portal.Desktop"; + const PATH: &str = "/org/freedesktop/portal/desktop"; + const SETTINGS: &str = "org.freedesktop.portal.Settings"; + // The standard key first (GNOME and KDE answer it: 0 no preference, 1 dark, 2 light); then + // GNOME's own schema, which XFCE's portal proxies without the standard key. + const KEYS: [(&str, &str); 2] = [("org.freedesktop.appearance", "color-scheme"), ("org.gnome.desktop.interface", "color-scheme")]; + let connection = zbus::blocking::Connection::session().ok()?; + for (namespace, key) in KEYS { + // `ReadOne` (Settings version 2) answers the value; older portals only have `Read`, which + // wraps it in one more variant. `parse` unwraps either. + let reply = connection + .call_method(Some(PORTAL), PATH, Some(SETTINGS), "ReadOne", &(namespace, key)) + .or_else(|_| connection.call_method(Some(PORTAL), PATH, Some(SETTINGS), "Read", &(namespace, key))); + let Ok(reply) = reply else { continue }; + let Ok((answer,)) = reply.body().deserialize::<(zbus::zvariant::OwnedValue,)>() else { continue }; + if let Some(scheme) = parse(&answer, 0) { + return scheme; + } + } + None +} + +/// The portal's `color-scheme` answer: `Some(choice)` when it read as an answer (`Some(None)` +/// for "no preference"), `None` when it didn't. Variants are unwrapped a few levels deep. +#[cfg(all(unix, not(target_os = "macos")))] +fn parse(value: &zbus::zvariant::Value<'_>, depth: u8) -> Option> { + use zbus::zvariant::Value; + match value { + Value::U32(1) => Some(Some(egui::Theme::Dark)), + Value::U32(2) => Some(Some(egui::Theme::Light)), + Value::U32(_) => Some(None), + Value::Str(s) => Some(match s.as_str() { + "prefer-dark" => Some(egui::Theme::Dark), + "prefer-light" => Some(egui::Theme::Light), + _ => None, + }), + Value::Value(inner) if depth < 4 => parse(inner, depth + 1), + _ => None, + } +} + +#[cfg(not(all(unix, not(target_os = "macos"))))] +fn read() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The standard key answers a number, GNOME's schema a string; either may come wrapped in + /// variants (the older `Read`). Only "dark" and "light" are a choice. + #[test] + #[cfg(all(unix, not(target_os = "macos")))] + fn the_portal_answer_reads_as_a_choice_or_as_none() { + use zbus::zvariant::Value; + assert_eq!(parse(&Value::U32(1), 0), Some(Some(egui::Theme::Dark))); + assert_eq!(parse(&Value::U32(2), 0), Some(Some(egui::Theme::Light))); + assert_eq!(parse(&Value::U32(0), 0), Some(None)); + assert_eq!(parse(&Value::U32(7), 0), Some(None)); + assert_eq!(parse(&Value::from("prefer-dark"), 0), Some(Some(egui::Theme::Dark))); + assert_eq!(parse(&Value::from("prefer-light"), 0), Some(Some(egui::Theme::Light))); + assert_eq!(parse(&Value::from("default"), 0), Some(None)); + let wrapped = Value::Value(Box::new(Value::Value(Box::new(Value::U32(1))))); + assert_eq!(parse(&wrapped, 0), Some(Some(egui::Theme::Dark))); + assert_eq!(parse(&Value::Bool(true), 0), None); + let mut deep = Value::U32(1); + for _ in 0..10 { + deep = Value::Value(Box::new(deep)); + } + assert_eq!(parse(&deep, 0), None, "nesting is bounded"); + } + + /// 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().current(), None); + } + + /// Answers arrive as changes; "no preference" or silence keeps the last real answer. + #[test] + fn the_last_answer_stands_until_a_new_one() { + let (tx, rx) = channel(); + let mut watcher = DesktopTheme { changes: Some(rx), current: None }; + assert_eq!(watcher.current(), None); + tx.send(Some(egui::Theme::Light)).unwrap(); + assert_eq!(watcher.current(), Some(egui::Theme::Light)); + tx.send(Some(egui::Theme::Dark)).unwrap(); + tx.send(None).unwrap(); + assert_eq!(watcher.current(), Some(egui::Theme::Dark)); + drop(tx); + assert_eq!(watcher.current(), Some(egui::Theme::Dark)); + } +} diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index d7bb7ee..2354f17 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -15,6 +15,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod control_server; +mod desktop_theme; #[cfg(any(target_os = "windows", test))] mod graphics; mod logging; @@ -27,6 +28,8 @@ use cadcraft_ui_egui::{CadApp, Services, i18n}; struct App { cad: CadApp, + /// The desktop's light/dark choice where winit reports none (Linux, the BSDs). + desktop_theme: desktop_theme::DesktopTheme, workspace: workspace::Workspace, workspace_error: Option, #[cfg(target_os = "macos")] @@ -48,6 +51,14 @@ impl App { } } +/// View ▸ Theme ▸ System resolves against `raw.system_theme`, which winit leaves empty on Linux +/// and the BSDs: the desktop's answer, where it gives one, stands in for it. +fn follow_desktop(raw: &mut egui::RawInput, desktop: Option) { + if desktop.is_some() { + raw.system_theme = desktop; + } +} + impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { #[cfg(target_os = "macos")] @@ -66,6 +77,7 @@ impl eframe::App for App { } } fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + follow_desktop(raw, self.desktop_theme.current()); self.cad.raw_input_hook(raw); cadcraft_ui_egui::cmdline::capture_tab(ctx, raw); } @@ -298,7 +310,10 @@ fn main() -> eframe::Result { if app.session.docs.is_empty() { app.session.new_drawing(false); } + let ctx = cc.egui_ctx.clone(); + let desktop_theme = desktop_theme::DesktopTheme::start(move || ctx.request_repaint()); Ok(Box::new(App { + desktop_theme, cad: app, workspace, workspace_error, @@ -311,7 +326,7 @@ fn main() -> eframe::Result { #[cfg(test)] mod tests { - use super::{config_dir_from, control_port_from, filters_for_save}; + use super::{config_dir_from, control_port_from, filters_for_save, follow_desktop}; use std::ffi::OsString; use std::path::PathBuf; @@ -377,4 +392,19 @@ mod tests { Some(PathBuf::from("C:\\Users\\u\\AppData\\Roaming").join("CADCraft")) ); } + + /// View ▸ Theme ▸ System on Linux: winit reports no theme, so the desktop's answer stands in; + /// without one (or where winit answers and the desktop doesn't) the input is left alone. + #[test] + fn the_desktop_answers_for_the_system_theme() { + let mut raw = egui::RawInput::default(); + follow_desktop(&mut raw, Some(egui::Theme::Light)); + assert_eq!(raw.system_theme, Some(egui::Theme::Light)); + let mut raw = egui::RawInput { system_theme: Some(egui::Theme::Dark), ..Default::default() }; + follow_desktop(&mut raw, None); + assert_eq!(raw.system_theme, Some(egui::Theme::Dark)); + let mut raw = egui::RawInput::default(); + follow_desktop(&mut raw, None); + assert_eq!(raw.system_theme, None); + } }