mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 05:16:56 -06:00
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:
Generated
+1
@@ -1527,6 +1527,7 @@ dependencies = [
|
||||
"windows",
|
||||
"winit",
|
||||
"winresource",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user