Merge pull request #203 from gyurix/system-theme-detection

Linux: the display theme asks the desktop, not winit
This commit is contained in:
Brandon Thomas 2026-10-11 15:09:46 -04:00
commit 1d3abfa4a7
6 changed files with 166 additions and 3 deletions

1
Cargo.lock generated
View File

@ -1599,6 +1599,7 @@ dependencies = [
"sys-locale",
"toml",
"web-sys",
"zbus",
]
[[package]]

View File

@ -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);

View File

@ -18,6 +18,12 @@ log = { workspace = true }
image = { workspace = true }
skrifa = { 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"] }

View File

@ -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<bool> {
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<Receiver<Option<bool>>>,
}
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<bool> {
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<bool> {
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<bool> {
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<bool> {
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);
}
}

View File

@ -39,6 +39,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)]
@ -194,6 +195,10 @@ pub struct SheetApp {
/// only when it changes this (see `theme::HanOrder`).
fonts_han: Option<theme::HanOrder>,
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<bool>,
pub name_box: Option<String>,
/// Transient ribbon keyboard navigation; never saved with UI preferences.
pub keytips: keytips::KeyTips,
@ -229,6 +234,8 @@ impl SheetApp {
fonts_set: false,
fonts_han: None,
effective_dark: false,
desktop_theme: desktop_theme::DesktopTheme::default(),
desktop_dark: None,
name_box: None,
keytips: keytips::KeyTips::default(),
shots: control::Shots::default(),
@ -331,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" {
@ -691,7 +704,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();

View File

@ -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);