Merge pull request #623 from ahmedhosnypro/fix/system-theme-linux

Fix the system theme mode always drawing light on Linux and the BSDs
This commit is contained in:
BFlat
2026-10-26 10:14:53 +07:00
committed by GitHub
6 changed files with 163 additions and 15 deletions
Generated
+1
View File
@@ -1527,6 +1527,7 @@ dependencies = [
"windows",
"winit",
"winresource",
"zbus",
]
[[package]]
+6
View File
@@ -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
+13 -10
View File
@@ -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<Mutex<Option<egui::Context>>> = 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<Mutex<Option<egui::Context>>>| {
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;
}
+92 -1
View File
@@ -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<Prefs>,
/// The system's reduce-motion setting (read in the background at startup).
reduce_motion: Arc<AtomicBool>,
/// The system's colour scheme ([`watch_system_dark`]): 0 unknown, 1 dark, 2 light.
dark: Arc<AtomicU8>,
/// The system's interface language (read at startup).
language: Option<String>,
/// 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<u32> {
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::<Value>()?))
};
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<bool> {
match self.dark.load(Ordering::Relaxed) {
DARK_DARK => Some(true),
DARK_LIGHT => Some(false),
_ => None,
}
}
fn gamepad_held(&mut self) -> u32 {
crate::gamepad::held()
}
+9 -3
View File
@@ -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<bool> {
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;
+42 -1
View File
@@ -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<Theme>) {
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<bool>);
impl Services for Scheme {
fn system_dark(&self) -> Option<bool> {
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]