mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-26 11:46:49 -06:00
Linux: the display theme asks the desktop, not winit
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.
This commit is contained in:
Generated
+1
@@ -1546,6 +1546,7 @@ dependencies = [
|
||||
"sys-locale",
|
||||
"toml",
|
||||
"web-sys",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<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,
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user