Linux: Theme > System follows the desktop's light/dark setting (#641)

Squash of PR #641 by @itrycodinganditsbad.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Fabio Lussi 2026-10-12 01:53:48 +02:00 committed by GitHub
parent 2fa967db65
commit 370150186b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 210 additions and 1 deletions

1
Cargo.lock generated
View File

@ -554,6 +554,7 @@ dependencies = [
"sys-locale",
"winit",
"winresource",
"zbus",
]
[[package]]

View File

@ -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 }

View File

@ -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<Receiver<Option<egui::Theme>>>,
/// The desktop's last answer.
current: Option<egui::Theme>,
}
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<egui::Theme> {
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<egui::Theme> {
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<Option<egui::Theme>> {
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<egui::Theme> {
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));
}
}

View File

@ -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<String>,
#[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<egui::Theme>) {
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);
}
}