//! 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 { 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>>, } 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("pdfcraft-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 { 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 { const PORTAL: &str = "org.freedesktop.portal.Desktop"; const SETTINGS: &str = "org.freedesktop.portal.Settings"; // The colour scheme under its GNOME schema name first: GNOME's portal answers it, and so // does XFCE's, which proxies the GSettings schemas but not the older freedesktop one. KDE // answers only the second. 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 }; // The reply is one variant wrapping a string. 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 { 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 { 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); } }