Merge pull request #203 from gyurix/system-theme-detection
Linux: the display theme asks the desktop, not winit
This commit is contained in:
commit
1d3abfa4a7
|
|
@ -1599,6 +1599,7 @@ dependencies = [
|
|||
"sys-locale",
|
||||
"toml",
|
||||
"web-sys",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue