diff --git a/Cargo.lock b/Cargo.lock index 9a3896b8..5a77cda3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1527,6 +1527,7 @@ dependencies = [ "windows", "winit", "winresource", + "zbus", ] [[package]] diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index c13c8832..bd8c09c8 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -36,6 +36,12 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col craft-update = { workspace = true } windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] } +# The system colour scheme (org.freedesktop.portal.Settings): winit reports no theme on Linux and +# the BSDs, so the interface's "system" mode follows the portal's instead. zbus comes in through +# accesskit already; this only makes it direct. +[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] +zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] } + # Finder double-clicks, Open With, Dock drops and `open -a FigCraft file.figcraft` arrive as a # kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This crate (also used by GridCraft, # EffectCraft and PhotoCraft) registers the handler behind a safe API (Objective-C interop needs diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 6c9bdf93..de999375 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -170,21 +170,23 @@ fn main() -> eframe::Result { // System fonts: index them in the background, then wake the UI (an idle window draws no frames) // to lay text out again with them. let ctx_slot: Arc>> = Arc::new(Mutex::new(None)); - let fonts_slot = ctx_slot.clone(); + // Wakes the window (once it is there) from another thread. + let wake = |slot: &Arc>>| { + let slot = slot.clone(); + move || { + if let Some(c) = slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() { + c.request_repaint(); + } + } + }; + let fonts_wake = wake(&ctx_slot); std::thread::spawn(move || { figcraft_text::scan_system_fonts(); - if let Some(c) = fonts_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() { - c.request_repaint(); - } + fonts_wake(); }); let mut session = Session::empty(); // Live sessions: the network threads wake the window when something arrives. - let wake_slot = ctx_slot.clone(); - session.live.install(Box::new(figcraft_collab_net::NetLink::new(Arc::new(move || { - if let Some(c) = wake_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() { - c.request_repaint(); - } - })))); + session.live.install(Box::new(figcraft_collab_net::NetLink::new(Arc::new(wake(&ctx_slot))))); for f in &args.files { if let Err(e) = session.execute("file.open", &json!({"path": f})) { log::error!("{f}: {e}"); @@ -196,6 +198,7 @@ fn main() -> eframe::Result { // The preferences first: they hold the online files a recovered document may be one of. let recovery = recovery::Shared::default(); let svc = services::Desktop::load(recovery.clone()); + svc.follow_system_dark(wake(&ctx_slot)); if let Some(prefs) = svc.prefs.clone() { session.prefs = prefs; } diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index d9cd53ea..0818c048 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -2,7 +2,7 @@ use std::cell::RefCell; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::{Arc, Mutex}; use figcraft_engine::{Prefs, Session}; @@ -16,6 +16,8 @@ pub struct Desktop { pub prefs: Option, /// The system's reduce-motion setting (read in the background at startup). reduce_motion: Arc, + /// The system's colour scheme ([`watch_system_dark`]): 0 unknown, 1 dark, 2 light. + dark: Arc, /// The system's interface language (read at startup). language: Option, /// Recovery copies of unsaved files (written at once after a panic). @@ -56,16 +58,33 @@ impl Desktop { let reduce_motion = Arc::new(AtomicBool::new(false)); let flag = reduce_motion.clone(); std::thread::spawn(move || flag.store(system_reduce_motion(), Ordering::Relaxed)); + let dark = Arc::new(AtomicU8::new(DARK_UNKNOWN)); Self { clipboard: arboard::Clipboard::new().ok(), dir, prefs, reduce_motion, + dark, language: system_language(), recovery, last_folder: Arc::default(), } } + + /// winit reports no system theme on Linux and the BSDs: there the freedesktop colour scheme + /// answers instead, read once and then watched in the background, calling `wake` when it + /// changes (an idle window draws no frames). Elsewhere this does nothing. + pub fn follow_system_dark(&self, wake: impl Fn() + Send + 'static) { + #[cfg(all(unix, not(target_os = "macos")))] + if let Err(e) = std::thread::Builder::new().name("figcraft-theme".into()).spawn({ + let dark = self.dark.clone(); + move || watch_system_dark(&dark, &wake) + }) { + log::info!("colour scheme: can't start the watcher: {e}"); + } + #[cfg(not(all(unix, not(target_os = "macos"))))] + let _ = wake; + } } /// The system's preferred interface languages, most preferred first, comma-separated @@ -146,6 +165,71 @@ fn command_says(program: &str, args: &[&str], want: &str) -> bool { .is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == want) } +/// [`Desktop::dark`]: the colour scheme is unknown, dark, or light. +const DARK_UNKNOWN: u8 = 0; +const DARK_DARK: u8 = 1; +const DARK_LIGHT: u8 = 2; + +/// The desktop's colour scheme through the freedesktop settings portal +/// (`org.freedesktop.appearance` / `color-scheme`: 1 dark, 2 light, 0 no preference, which is +/// light), read once and then watched for changes, each one calling `wake`. Without a session bus +/// or a portal the scheme stays unknown and the UI keeps its light fallback. +#[cfg(all(unix, not(target_os = "macos")))] +fn watch_system_dark(dark: &AtomicU8, wake: &dyn Fn()) { + use zbus::zvariant::Value; + const PORTAL: &str = "org.freedesktop.portal.Desktop"; + const SETTINGS: &str = "org.freedesktop.portal.Settings"; + const APPEARANCE: &str = "org.freedesktop.appearance"; + // `Read` (portals before 1.17, which lack `ReadOne`) wraps the value in one more variant. + let scheme = |v: &Value| match v { + Value::U32(x) => *x, + Value::Value(inner) => match **inner { + Value::U32(x) => x, + _ => 0, + }, + _ => 0, + }; + let store = |v: u32| { + let new = match v { + 1 => DARK_DARK, + 2 => DARK_LIGHT, + _ => DARK_UNKNOWN, + }; + if dark.swap(new, Ordering::Relaxed) != new { + wake(); + } + }; + let Ok(conn) = zbus::blocking::connection::Builder::session().and_then(|b| b.build()) else { + log::info!("colour scheme: no session bus: the system theme isn't followed"); + return; + }; + let read = |method: &str| -> zbus::Result { + let reply = conn.call_method(Some(PORTAL), "/org/freedesktop/portal/desktop", Some(SETTINGS), method, &(APPEARANCE, "color-scheme"))?; + let body = reply.body(); + Ok(scheme(&body.deserialize::()?)) + }; + match read("ReadOne").or_else(|_| read("Read")) { + Ok(v) => store(v), + Err(e) => log::info!("colour scheme: can't read the system setting: {e}"), + } + let watch = || -> zbus::Result<()> { + let rule = + zbus::MatchRule::builder().msg_type(zbus::message::Type::Signal).sender(PORTAL)?.interface(SETTINGS)?.member("SettingChanged")?.build(); + for msg in zbus::blocking::MessageIterator::for_match_rule(rule, &conn, Some(8))? { + let msg = msg?; + let body = msg.body(); + let Ok((namespace, key, value)) = body.deserialize::<(String, String, Value<'_>)>() else { continue }; + if namespace == APPEARANCE && key == "color-scheme" { + store(scheme(&value)); + } + } + Ok(()) + }; + if let Err(e) = watch() { + log::info!("colour scheme: can't watch the system setting: {e}"); + } +} + /// Show `path` in the file manager: selected in Finder and Explorer, its folder elsewhere. fn reveal(path: &Path) -> std::io::Result<()> { #[cfg(target_os = "macos")] @@ -372,6 +456,13 @@ impl Services for Desktop { fn reduce_motion(&self) -> bool { self.reduce_motion.load(Ordering::Relaxed) } + fn system_dark(&self) -> Option { + match self.dark.load(Ordering::Relaxed) { + DARK_DARK => Some(true), + DARK_LIGHT => Some(false), + _ => None, + } + } fn gamepad_held(&mut self) -> u32 { crate::gamepad::held() } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b7aca146..b366395e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -307,6 +307,11 @@ pub trait Services { fn reduce_motion(&self) -> bool { false } + /// The system asks for the dark interface (the freedesktop colour scheme): for platforms whose + /// windowing stack doesn't report a theme (winit gives none on Linux and the BSDs). + fn system_dark(&self) -> Option { + None + } /// After a panic in a frame: keep recovery copies of unsaved files. fn emergency_save(&mut self, _session: &Session) {} /// Keep the preferences now (the installed plugins or their settings changed), as the app @@ -1719,9 +1724,10 @@ impl FigApp { } let dark = match self.session.prefs.theme.as_str() { "dark" => true, - // The active visuals were set by us last frame (possibly for a manual theme). - // Read the integration's OS theme instead; without one, keep the light fallback. - "system" => ctx.system_theme() == Some(egui::Theme::Dark), + // The active visuals were set by us last frame (possibly for a manual theme). Read the + // integration's OS theme; winit reports none on Linux and the BSDs, where the platform + // services' colour scheme answers instead. Without either, keep the light fallback. + "system" => ctx.system_theme().map(|t| t == egui::Theme::Dark).or_else(|| self.services.system_dark()).unwrap_or(false), _ => false, }; self.ui.dark = dark; diff --git a/crates/ui-egui/src/theme_startup_tests.rs b/crates/ui-egui/src/theme_startup_tests.rs index c128a597..60587acb 100644 --- a/crates/ui-egui/src/theme_startup_tests.rs +++ b/crates/ui-egui/src/theme_startup_tests.rs @@ -4,7 +4,7 @@ use egui::{Context, RawInput, Theme}; use figcraft_engine::{Prefs, Session}; use serde_json::json; -use crate::{FigApp, NoServices, theme::Tokens}; +use crate::{FigApp, NoServices, Services, theme::Tokens}; fn frame(ctx: &Context, app: &mut FigApp, system: Option) { let mut output = ctx.run_ui(RawInput { system_theme: system, ..Default::default() }, |ui| app.ui(ui)); @@ -84,6 +84,47 @@ fn unavailable_system_theme_uses_the_existing_light_fallback() { assert_eq!(Tokens::get(&ctx), Tokens::light()); } +/// Services whose colour scheme answer stands in for the platform's (winit gives no theme on Linux +/// and the BSDs, where the freedesktop colour scheme takes over). +struct Scheme(Option); +impl Services for Scheme { + fn system_dark(&self) -> Option { + self.0 + } +} + +#[test] +fn without_a_system_theme_the_colour_scheme_services_decide() { + for (dark, expected) in [(Some(true), Tokens::dark()), (Some(false), Tokens::light())] { + let ctx = Context::default(); + let mut app = FigApp::new(Session::empty(), Box::new(Scheme(dark))); + app.session.execute("view.themeSystem", &json!({})).unwrap(); + frame(&ctx, &mut app, None); + assert_eq!(Tokens::get(&ctx), expected); + assert_eq!(app.ui.dark, expected.dark); + } +} + +#[test] +fn the_os_theme_wins_over_the_colour_scheme_services() { + for (os, scheme) in [(Theme::Light, Some(true)), (Theme::Dark, Some(false))] { + let ctx = Context::default(); + let mut app = FigApp::new(Session::empty(), Box::new(Scheme(scheme))); + app.session.execute("view.themeSystem", &json!({})).unwrap(); + frame(&ctx, &mut app, Some(os)); + assert_eq!(Tokens::get(&ctx).dark, os == Theme::Dark); + } +} + +#[test] +fn manual_themes_ignore_the_colour_scheme_services() { + let ctx = Context::default(); + let mut app = FigApp::new(Session::empty(), Box::new(Scheme(Some(false)))); + app.session.execute("view.themeDark", &json!({})).unwrap(); + frame(&ctx, &mut app, None); + assert_eq!(Tokens::get(&ctx), Tokens::dark()); +} + /// Checks that the test graph enables egui's `default_fonts` and `monochrome_emoji_fonts` /// features by asserting both a Latin and an emoji glyph are available. #[test]