fix(theme): complete Linux System appearance tracking (#1017)
* fix(theme): complete Linux System appearance tracking Follow up on #881 for #446 with the standard numeric appearance portal setting and event-driven updates that wake idle windows. Keep manual modes independent, cache their desktop updates without repainting, and cancel the worker on close. Retain inherited native window appearance and populate both egui style branches with the chosen palette. Preserve saved System, the Light default and unknown source fallback, and avoid desktop session access in headless UI contexts. Assisted-by: Codex (gpt-6.1-sol) * Desktop theme: keep the GNOME colour-scheme fallback XFCE needs The portal tracker read only org.freedesktop.appearance, which XFCE's portal doesn't answer (it proxies the GSettings schemas). It now also reads and subscribes to org.gnome.desktop.interface color-scheme and uses it when the freedesktop setting states no preference, as main's reader did. KDE answers only the freedesktop one, which wins when it states a preference. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
881682c495
commit
49217aec6c
|
|
@ -3841,6 +3841,8 @@ dependencies = [
|
|||
name = "pdfcraft-platform"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-lite",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -169,6 +169,8 @@ Newest first: the date, what moved, and the new overall estimate.
|
|||
- **2026-10-10 (M12, XFA rows and areas):** Dynamic XFA forms lay out a repeating subform inside a top-to-bottom container in positioned content (a table in an area) with all its instances, drawn and measured, instead of one; and areas no longer appear in data paths (`pdfcraft_xfa::data_path`), since they are not data scopes. Before, a form whose fields sit in an area read none of the values Adobe's viewers saved, wrote what was filled here under a new element named after the area, which Adobe's viewers would most likely not read, and a FormCalc total looping over the table's rows failed on the rows that were missing. Layout, the script tree, reading and writing the data and the engine's value and row operations share the one path rule. Synthetic tests cover the layout, the path rule and the whole round trip (rows, Adobe-written values, a FormCalc total over all rows, the written data, reopening). Still open for such forms: XFA script objects (`<variables><script>`), which aren't supported. Overall estimate unchanged (about 30–35%).
|
||||
|
||||
- **2026-10-11 (M10, Windows printing follow-up to #847):** Windows print jobs print each sheet at 100% on the driver's paper closest to the sheet's size, landscape for wide sheets; they had been shrunk into .NET's default 1-inch margins (about 76% on Letter) and not rotated. Sheets are capped at about 64 megapixels so a poster-sized sheet can't exhaust memory. PowerShell runs without a console window, and the Print dialog lists printers and spools jobs off the UI thread (the desktop app's `printer_source`), so neither Ctrl+P nor a long job freezes the app. Kittest regression for the background printer list; the Windows rendering path is still checked only by the Windows CI job. Estimates unchanged.
|
||||
- **2026-10-10 (M3, System appearance completion, #446/#881):** The Linux portal introduced by #881 now uses the standard numeric appearance setting and event-driven updates, with idle-window wakeups for initial and changed results. When that setting states no preference it falls back to the GNOME schema's `color-scheme`, which XFCE's portal answers instead. Native windows retain inherited appearance and both egui style branches use the selected palette. Manual choices and the Light default remain; unknown System appearance resolves to Light. Controlled protocol and UI checks cover the remaining gaps; the supplied EndeavourOS/GNOME report has not been reproduced on that desktop. Estimates unchanged.
|
||||
|
||||
- **2026-10-10 (M9, #179 follow-up):** A Windows store certificate that can't sign is no longer left out of the Sign dialog silently: `windows::list` names it with the reason and Windows error code (no private key, a P-521/Brainpool/Ed25519 key, a key CNG can't open), the picker shows it greyed and unselectable, and `sign_windows_ids` returns it under `unusable`. A key that needs a permission prompt to open is listed instead of skipped. Reported by a user whose CA-issued identity showed in Acrobat but not here; a plain .p12 import into the CNG and legacy Microsoft providers was verified to list, so the next report will carry the reason. Store test adds a key-less certificate; overall estimate unchanged (about 30–35%).
|
||||
- **2026-10-10 (M10.2, JPEG scanning follow-up to #729):** Skip marker fill bytes and keep reading JFIF, APP14 and ICC metadata after the frame header, stopping before scan data or at EOI. Invalid segment lengths return errors; damaged or mismatched ICC profiles retain #729's device-space fallback. Synthetic regression cases added but not run at the contributor's request. Estimates unchanged.
|
||||
- **2026-10-10 (M13, desktop action outputs):** Desktop Action Wizard keeps every result when input basenames repeat, or when the destination already has a result from an earlier run. It preserves the original filename and extension for a free name and adds numbered suffixes for occupied names; the bounded search returns an error rather than reusing an occupied path. A real-shell regression drives Start twice and reopens the three-page and one-page results, checking that the original inputs and earlier output remain intact. This covers the desktop writer separately from the CLI/MCP action_run repair in #877. Milestone and effort estimates unchanged.
|
||||
|
|
@ -343,6 +345,7 @@ Newest first: the date, what moved, and the new overall estimate.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | minor | Recorded the remaining System appearance repair after #881; estimates unchanged |
|
||||
| 2026-10-10 | minor | Readiness table with hours per audience; full number restated as the additive weighted sum over dimensions (47% → 49%; method aligned with the standard, no new evidence) |
|
||||
| 2026-10-10 | minor | Essentials-user score added (≈ 66%) |
|
||||
| 2026-10-10 | minor | Gate consistency check: convert and share re-judged partial, not blocking; stage pre-alpha → alpha |
|
||||
|
|
|
|||
|
|
@ -301,6 +301,8 @@ fn app_creator<'a>(
|
|||
Box::new(move |cc| {
|
||||
started.set(true);
|
||||
let mut app = PdfCraftApp::new();
|
||||
#[cfg(target_os = "linux")]
|
||||
app.enable_desktop_theme();
|
||||
// The system's reopen-windows choice (macOS) starts the preference; a saved choice replaces it.
|
||||
app.reopen_last_session = reopen::system_default();
|
||||
if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string(LEGACY_STORAGE_KEY))) {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,14 @@ workspace = true
|
|||
[dependencies]
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# The desktop's light/dark choice (see `desktop_theme.rs`). Already in the tree, with the features
|
||||
# this needs, through accesskit's accessibility bus; pinned to the same version so nothing is
|
||||
# built twice.
|
||||
zbus = "=5.19.0"
|
||||
# Already in the dependency graph through the accessibility integration. The portal watcher
|
||||
# uses the async API so dropping it can cancel an idle subscription without polling.
|
||||
zbus = { version = "=5.19.0", default-features = false, features = ["async-io"] }
|
||||
async-channel = "2"
|
||||
futures-lite = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
# Exercise the actual portal wire decoder and cancellable worker on every native test host.
|
||||
zbus = { version = "=5.19.0", default-features = false, features = ["async-io"] }
|
||||
async-channel = "2"
|
||||
futures-lite = "2"
|
||||
|
|
|
|||
|
|
@ -1,136 +1,550 @@
|
|||
//! The desktop's own light/dark choice, for an interface setting that follows it.
|
||||
//! Linux desktop appearance through the XDG Settings portal. Other platforms use native window
|
||||
//! events. No desktop polling or UI dependency is needed here.
|
||||
//!
|
||||
//! 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.
|
||||
//! Protocol reference: <https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Settings.html>.
|
||||
|
||||
use std::sync::mpsc::{Receiver, TryRecvError, channel};
|
||||
use std::time::Duration;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
|
||||
/// 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()
|
||||
/// A concrete appearance requested by the desktop. An absent value means no preference or no
|
||||
/// available appearance source; the UI owns its fallback and its saved manual choice.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DesktopAppearance {
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
struct Shared {
|
||||
appearance: AtomicU8,
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
published: AtomicBool,
|
||||
active: AtomicBool,
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
wake: Box<dyn Fn() + Send + Sync>,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
fn new(wake: impl Fn() + Send + Sync + 'static) -> Self {
|
||||
#[cfg(not(any(target_os = "linux", test)))]
|
||||
let _ = wake;
|
||||
Self {
|
||||
appearance: AtomicU8::new(0),
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
published: AtomicBool::new(false),
|
||||
active: AtomicBool::new(true),
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
wake: Box::new(wake),
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self) -> Option<DesktopAppearance> {
|
||||
match self.appearance.load(Ordering::Acquire) {
|
||||
1 => Some(DesktopAppearance::Dark),
|
||||
2 => Some(DesktopAppearance::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn publish(&self, appearance: Option<DesktopAppearance>) {
|
||||
let encoded = match appearance {
|
||||
Some(DesktopAppearance::Dark) => 1,
|
||||
Some(DesktopAppearance::Light) => 2,
|
||||
None => 0,
|
||||
};
|
||||
let changed = self.appearance.swap(encoded, Ordering::AcqRel) != encoded;
|
||||
let first = !self.published.swap(true, Ordering::AcqRel);
|
||||
// Publish before waking: an otherwise idle UI must see the new answer on its next frame.
|
||||
if (first || changed) && self.active.load(Ordering::Acquire) {
|
||||
(self.wake)();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An event-driven appearance cache. Start when System is first selected and keep it alive while
|
||||
/// the window exists. Manual modes can suppress wakeups while preserving the latest desktop value.
|
||||
pub struct DesktopTheme {
|
||||
/// `None` when no watcher thread could be started; the one-shot read still works.
|
||||
changes: Option<Receiver<Option<bool>>>,
|
||||
shared: Arc<Shared>,
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
stop: Option<async_channel::Sender<()>>,
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
worker: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
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;
|
||||
/// Start a Linux portal subscription off the UI thread. The first answer and subsequent
|
||||
/// changes call `wake` while active. Missing services and thread-start failure yield `None`.
|
||||
/// On other platforms this is inert because the window integration owns appearance events.
|
||||
pub fn start(wake: impl Fn() + Send + Sync + 'static) -> Self {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Self::spawn(wake, portal::watch)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
Self {
|
||||
shared: Arc::new(Shared::new(wake)),
|
||||
#[cfg(test)]
|
||||
stop: None,
|
||||
#[cfg(test)]
|
||||
worker: None,
|
||||
}
|
||||
loop {
|
||||
std::thread::sleep(POLL);
|
||||
let now = read();
|
||||
if now != last {
|
||||
last = now;
|
||||
if tx.send(now).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the latest desktop answer without blocking or making a D-Bus call.
|
||||
pub fn get(&self) -> Option<DesktopAppearance> {
|
||||
self.shared.get()
|
||||
}
|
||||
|
||||
/// Allow UI wakeups only while following the desktop. Events still update the cache in
|
||||
/// manual mode, so selecting System again can immediately resolve the current appearance.
|
||||
pub fn set_active(&self, active: bool) {
|
||||
self.shared.active.store(active, Ordering::Release);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn spawn<F, T>(wake: impl Fn() + Send + Sync + 'static, task: T) -> Self
|
||||
where
|
||||
F: std::future::Future<Output = ()> + 'static,
|
||||
T: FnOnce(Arc<Shared>) -> F + Send + 'static,
|
||||
{
|
||||
let shared = Arc::new(Shared::new(wake));
|
||||
let thread_shared = Arc::clone(&shared);
|
||||
let (stop, stopped) = async_channel::bounded::<()>(1);
|
||||
let worker = std::thread::Builder::new().name("pdfcraft-desktop-theme".into()).spawn(move || {
|
||||
futures_lite::future::block_on(futures_lite::future::race(
|
||||
async {
|
||||
let _ = stopped.recv().await;
|
||||
},
|
||||
task(thread_shared),
|
||||
));
|
||||
});
|
||||
Self { shared, stop: Some(stop), worker: worker.ok() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DesktopTheme {
|
||||
fn drop(&mut self) {
|
||||
self.set_active(false);
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
{
|
||||
if let Some(stop) = self.stop.take() {
|
||||
stop.close();
|
||||
}
|
||||
if let Some(worker) = self.worker.take() {
|
||||
// Cancellation also interrupts connection setup, the initial read, and idle waits.
|
||||
// A worker panic does not propagate into the window or its open document.
|
||||
let _ = worker.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
mod portal {
|
||||
use super::{DesktopAppearance, Shared};
|
||||
use futures_lite::StreamExt;
|
||||
use std::sync::Arc;
|
||||
use zbus::zvariant::{OwnedValue, Value};
|
||||
|
||||
const DESTINATION: &str = "org.freedesktop.portal.Desktop";
|
||||
const PATH: &str = "/org/freedesktop/portal/desktop";
|
||||
const INTERFACE: &str = "org.freedesktop.portal.Settings";
|
||||
const NAMESPACE: &str = "org.freedesktop.appearance";
|
||||
const KEY: &str = "color-scheme";
|
||||
/// The colour scheme under its GNOME schema name, as a string. GNOME's portal answers it, and
|
||||
/// so does XFCE's, which proxies the GSettings schemas but not the freedesktop namespace. KDE
|
||||
/// answers only the freedesktop one, so that one wins when it states a preference.
|
||||
const GNOME_NAMESPACE: &str = "org.gnome.desktop.interface";
|
||||
|
||||
/// Standard uint32 semantics, including future values treated as no preference.
|
||||
fn numeric(value: &Value<'_>) -> Option<Option<DesktopAppearance>> {
|
||||
match value {
|
||||
Value::U32(1) => Some(Some(DesktopAppearance::Dark)),
|
||||
Value::U32(2) => Some(Some(DesktopAppearance::Light)),
|
||||
Value::U32(_) => Some(None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// GNOME's string values; anything but the two preferences (`default`) is no preference.
|
||||
fn gnome_scheme(value: &Value<'_>) -> Option<Option<DesktopAppearance>> {
|
||||
match value {
|
||||
Value::Str(scheme) => Some(match scheme.as_str() {
|
||||
"prefer-dark" => Some(DesktopAppearance::Dark),
|
||||
"prefer-light" => Some(DesktopAppearance::Light),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The freedesktop answer when it states a preference, otherwise the GNOME schema's.
|
||||
fn combined(freedesktop: Option<DesktopAppearance>, gnome: Option<DesktopAppearance>) -> Option<DesktopAppearance> {
|
||||
freedesktop.or(gnome)
|
||||
}
|
||||
|
||||
fn read_gnome_reply(reply: &zbus::Message) -> Option<DesktopAppearance> {
|
||||
let answer = reply.body().deserialize::<OwnedValue>().ok()?;
|
||||
// Same two variant layers as the freedesktop Read.
|
||||
let Value::Value(inner) = &*answer else { return None };
|
||||
gnome_scheme(inner).flatten()
|
||||
}
|
||||
|
||||
fn gnome_setting_changed(message: &zbus::Message) -> Option<Option<DesktopAppearance>> {
|
||||
let body = message.body();
|
||||
let (namespace, key, value) = body.deserialize::<(&str, &str, OwnedValue)>().ok()?;
|
||||
if namespace != GNOME_NAMESPACE || key != KEY {
|
||||
return None;
|
||||
}
|
||||
gnome_scheme(&value)
|
||||
}
|
||||
|
||||
fn read_reply(reply: &zbus::Message) -> Option<DesktopAppearance> {
|
||||
let answer = reply.body().deserialize::<OwnedValue>().ok()?;
|
||||
// Legacy Read is available in Settings version 1 and returns two variant layers. The
|
||||
// OwnedValue decoder removes the outer layer; unwrap exactly the documented inner one.
|
||||
let Value::Value(inner) = &*answer else { return None };
|
||||
numeric(inner).flatten()
|
||||
}
|
||||
|
||||
fn setting_changed(message: &zbus::Message) -> Option<Option<DesktopAppearance>> {
|
||||
let body = message.body();
|
||||
let (namespace, key, value) = body.deserialize::<(&str, &str, OwnedValue)>().ok()?;
|
||||
if namespace != NAMESPACE || key != KEY {
|
||||
return None;
|
||||
}
|
||||
// SettingChanged has one variant layer, unlike legacy Read. Malformed signals do not
|
||||
// discard a valid cached appearance; numeric no-preference signals do clear it.
|
||||
numeric(&value)
|
||||
}
|
||||
|
||||
pub(super) async fn watch(shared: Arc<Shared>) {
|
||||
if watch_inner(&shared).await.is_err() {
|
||||
shared.publish(None);
|
||||
}
|
||||
}
|
||||
|
||||
async fn watch_inner(shared: &Shared) -> zbus::Result<()> {
|
||||
let connection = zbus::connection::Builder::session()?.method_timeout(std::time::Duration::from_secs(5)).build().await?;
|
||||
watch_connection(shared, connection).await
|
||||
}
|
||||
|
||||
fn setting_rule(namespace: &'static str) -> zbus::Result<zbus::MatchRule<'static>> {
|
||||
Ok(zbus::MatchRule::builder()
|
||||
.msg_type(zbus::message::Type::Signal)
|
||||
.sender(DESTINATION)?
|
||||
.path(PATH)?
|
||||
.interface(INTERFACE)?
|
||||
.member("SettingChanged")?
|
||||
.arg(0, namespace)?
|
||||
.arg(1, KEY)?
|
||||
.build())
|
||||
}
|
||||
|
||||
async fn watch_connection(shared: &Shared, connection: zbus::Connection) -> zbus::Result<()> {
|
||||
// Install the matches before reading. Unlike Proxy::receive_signal this does not require
|
||||
// the portal to already own its bus name, so Read can activate an installed portal.
|
||||
let freedesktop_changes = zbus::MessageStream::for_match_rule(setting_rule(NAMESPACE)?, &connection, Some(64)).await?;
|
||||
let gnome_changes = zbus::MessageStream::for_match_rule(setting_rule(GNOME_NAMESPACE)?, &connection, Some(64)).await?;
|
||||
let mut changes = freedesktop_changes.or(gnome_changes);
|
||||
let reply = connection.call_method(Some(DESTINATION), PATH, Some(INTERFACE), "Read", &(NAMESPACE, KEY)).await;
|
||||
let mut freedesktop = reply.as_ref().ok().and_then(read_reply);
|
||||
let reply = connection.call_method(Some(DESTINATION), PATH, Some(INTERFACE), "Read", &(GNOME_NAMESPACE, KEY)).await;
|
||||
let mut gnome = reply.as_ref().ok().and_then(read_gnome_reply);
|
||||
shared.publish(combined(freedesktop, gnome));
|
||||
while let Some(message) = changes.next().await {
|
||||
let message = message?;
|
||||
// Keep every subscribed update, including those queued during Read. A Read handler
|
||||
// can capture an old value and emit a new one before returning its older snapshot.
|
||||
if let Some(appearance) = setting_changed(&message) {
|
||||
freedesktop = appearance;
|
||||
} else if let Some(appearance) = gnome_setting_changed(&message) {
|
||||
gnome = appearance;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
shared.publish(combined(freedesktop, gnome));
|
||||
}
|
||||
shared.publish(None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn reply(value: Value<'_>) -> zbus::Message {
|
||||
let call = zbus::Message::method_call(PATH, "Read").unwrap().build(&(NAMESPACE, KEY)).unwrap();
|
||||
zbus::Message::method_return(&call.header()).unwrap().build(&value).unwrap()
|
||||
}
|
||||
|
||||
fn signal(namespace: &str, key: &str, value: Value<'_>) -> zbus::Message {
|
||||
zbus::Message::signal(PATH, INTERFACE, "SettingChanged").unwrap().build(&(namespace, key, value)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_read_decodes_documented_double_variant_numeric_values() {
|
||||
// Keep the native subscription implementation typechecked on non-Linux test hosts.
|
||||
let _native_backend = watch;
|
||||
for (number, expected) in [(0, None), (1, Some(DesktopAppearance::Dark)), (2, Some(DesktopAppearance::Light)), (u32::MAX, None)] {
|
||||
assert_eq!(read_reply(&reply(Value::Value(Box::new(Value::U32(number))))), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_read_refuses_strings_wrong_types_and_extra_nesting() {
|
||||
assert_eq!(read_reply(&reply(Value::Value(Box::new(Value::from("prefer-dark"))))), None);
|
||||
assert_eq!(read_reply(&reply(Value::Value(Box::new(Value::I32(1))))), None);
|
||||
assert_eq!(read_reply(&reply(Value::Value(Box::new(Value::Value(Box::new(Value::U32(1))))))), None);
|
||||
assert_eq!(read_reply(&reply(Value::U32(1))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signals_distinguish_no_preference_from_unrelated_or_malformed_updates() {
|
||||
assert_eq!(setting_changed(&signal(NAMESPACE, KEY, Value::U32(1))), Some(Some(DesktopAppearance::Dark)));
|
||||
assert_eq!(setting_changed(&signal(NAMESPACE, KEY, Value::U32(2))), Some(Some(DesktopAppearance::Light)));
|
||||
assert_eq!(setting_changed(&signal(NAMESPACE, KEY, Value::U32(0))), Some(None));
|
||||
assert_eq!(setting_changed(&signal(NAMESPACE, KEY, Value::U32(1234))), Some(None));
|
||||
assert_eq!(setting_changed(&signal("org.gnome.desktop.interface", KEY, Value::U32(1))), None);
|
||||
assert_eq!(setting_changed(&signal(NAMESPACE, "contrast", Value::U32(1))), None);
|
||||
assert_eq!(setting_changed(&signal(NAMESPACE, KEY, Value::from("prefer-dark"))), None);
|
||||
assert_eq!(setting_changed(&signal(NAMESPACE, KEY, Value::Value(Box::new(Value::U32(1))))), None);
|
||||
let wrong_body = zbus::Message::signal(PATH, INTERFACE, "SettingChanged").unwrap().build(&(NAMESPACE, KEY)).unwrap();
|
||||
assert_eq!(setting_changed(&wrong_body), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gnome_schema_answers_when_the_freedesktop_one_states_no_preference() {
|
||||
// XFCE's portal proxies only the GSettings schemas; its answer is a string.
|
||||
assert_eq!(read_gnome_reply(&reply(Value::Value(Box::new(Value::from("prefer-dark"))))), Some(DesktopAppearance::Dark));
|
||||
assert_eq!(read_gnome_reply(&reply(Value::Value(Box::new(Value::from("prefer-light"))))), Some(DesktopAppearance::Light));
|
||||
assert_eq!(read_gnome_reply(&reply(Value::Value(Box::new(Value::from("default"))))), None);
|
||||
assert_eq!(read_gnome_reply(&reply(Value::Value(Box::new(Value::U32(1))))), None);
|
||||
assert_eq!(read_gnome_reply(&reply(Value::from("prefer-dark"))), None);
|
||||
assert_eq!(gnome_setting_changed(&signal(GNOME_NAMESPACE, KEY, Value::from("prefer-dark"))), Some(Some(DesktopAppearance::Dark)));
|
||||
assert_eq!(gnome_setting_changed(&signal(GNOME_NAMESPACE, KEY, Value::from("default"))), Some(None));
|
||||
assert_eq!(gnome_setting_changed(&signal(GNOME_NAMESPACE, "gtk-theme", Value::from("prefer-dark"))), None);
|
||||
assert_eq!(gnome_setting_changed(&signal(NAMESPACE, KEY, Value::from("prefer-dark"))), None);
|
||||
assert_eq!(gnome_setting_changed(&signal(GNOME_NAMESPACE, KEY, Value::U32(1))), None);
|
||||
// The freedesktop answer wins when it states one (KDE answers only that one).
|
||||
assert_eq!(combined(Some(DesktopAppearance::Light), Some(DesktopAppearance::Dark)), Some(DesktopAppearance::Light));
|
||||
assert_eq!(combined(None, Some(DesktopAppearance::Dark)), Some(DesktopAppearance::Dark));
|
||||
assert_eq!(combined(None, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
#[ignore = "requires dbus-daemon; uses only a private test session bus"]
|
||||
fn private_bus_delayed_read_keeps_queued_changes_and_wakes_idle_tracker() {
|
||||
use crate::desktop_theme::DesktopTheme;
|
||||
use std::io::BufRead;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{OnceLock, Weak, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
struct Daemon(std::process::Child);
|
||||
impl Drop for Daemon {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
});
|
||||
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,
|
||||
struct Settings {
|
||||
captured: mpsc::Sender<()>,
|
||||
release: async_channel::Receiver<()>,
|
||||
}
|
||||
#[zbus::interface(name = "org.freedesktop.portal.Settings")]
|
||||
impl Settings {
|
||||
async fn read(&self, namespace: &str, key: &str) -> zbus::fdo::Result<OwnedValue> {
|
||||
if namespace != NAMESPACE || key != KEY {
|
||||
return Err(zbus::fdo::Error::InvalidArgs("unknown setting".into()));
|
||||
}
|
||||
// Capture Light before a later Dark signal, then deliberately return the
|
||||
// stale snapshot after that signal. Reply ordering cannot date this value.
|
||||
let snapshot = Value::Value(Box::new(Value::U32(2)));
|
||||
self.captured.send(()).map_err(|error| zbus::fdo::Error::Failed(error.to_string()))?;
|
||||
self.release.recv().await.map_err(|error| zbus::fdo::Error::Failed(error.to_string()))?;
|
||||
OwnedValue::try_from(snapshot).map_err(|error| zbus::fdo::Error::Failed(error.to_string()))
|
||||
}
|
||||
}
|
||||
fn wait_for(tracker: &DesktopTheme, appearance: Option<DesktopAppearance>) {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while tracker.get() != appearance {
|
||||
assert!(Instant::now() < deadline, "tracker did not consume the setting signal");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
fn emit(connection: &zbus::Connection, number: u32) {
|
||||
futures_lite::future::block_on(connection.emit_signal(
|
||||
None::<&str>,
|
||||
PATH,
|
||||
INTERFACE,
|
||||
"SettingChanged",
|
||||
&(NAMESPACE, KEY, OwnedValue::from(number)),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let child = Command::new("dbus-daemon")
|
||||
.args(["--session", "--nofork", "--print-address=1"])
|
||||
// Abstract sockets disappear with the daemon, even if a test fails.
|
||||
.arg(format!("--address=unix:abstract=pdfcraft-theme-test-{}", std::process::id()))
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut daemon = Daemon(child);
|
||||
let mut address = String::new();
|
||||
std::io::BufReader::new(daemon.0.stdout.take().unwrap()).read_line(&mut address).unwrap();
|
||||
assert!(!address.trim().is_empty());
|
||||
let (captured, capture_seen) = mpsc::channel();
|
||||
let (release, released) = async_channel::bounded(1);
|
||||
let server = futures_lite::future::block_on(
|
||||
zbus::connection::Builder::address(address.trim())
|
||||
.unwrap()
|
||||
.name(DESTINATION)
|
||||
.unwrap()
|
||||
.serve_at(PATH, Settings { captured, release: released })
|
||||
.unwrap()
|
||||
.build(),
|
||||
)
|
||||
.unwrap();
|
||||
let client = futures_lite::future::block_on(
|
||||
zbus::connection::Builder::address(address.trim()).unwrap().method_timeout(Duration::from_secs(5)).build(),
|
||||
)
|
||||
.unwrap();
|
||||
let source = Arc::new(OnceLock::<Weak<Shared>>::new());
|
||||
let observed = Arc::clone(&source);
|
||||
let (wake, woke) = mpsc::channel();
|
||||
let tracker = DesktopTheme::spawn(
|
||||
move || {
|
||||
wake.send(observed.get().unwrap().upgrade().unwrap().get()).unwrap();
|
||||
},
|
||||
move |shared| async move {
|
||||
watch_connection(&shared, client).await.unwrap();
|
||||
},
|
||||
);
|
||||
source.set(Arc::downgrade(&tracker.shared)).unwrap();
|
||||
capture_seen.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
emit(&server, 1);
|
||||
release.send_blocking(()).unwrap();
|
||||
assert_eq!(woke.recv_timeout(Duration::from_secs(5)).unwrap(), Some(DesktopAppearance::Light));
|
||||
assert_eq!(woke.recv_timeout(Duration::from_secs(5)).unwrap(), Some(DesktopAppearance::Dark));
|
||||
assert_eq!(tracker.get(), Some(DesktopAppearance::Dark));
|
||||
|
||||
emit(&server, 2);
|
||||
assert_eq!(woke.recv_timeout(Duration::from_secs(5)).unwrap(), Some(DesktopAppearance::Light));
|
||||
emit(&server, 0);
|
||||
assert_eq!(woke.recv_timeout(Duration::from_secs(5)).unwrap(), None);
|
||||
tracker.set_active(false);
|
||||
emit(&server, 1);
|
||||
wait_for(&tracker, Some(DesktopAppearance::Dark));
|
||||
emit(&server, 2);
|
||||
wait_for(&tracker, Some(DesktopAppearance::Light));
|
||||
assert!(matches!(woke.try_recv(), Err(mpsc::TryRecvError::Empty)));
|
||||
tracker.set_active(true);
|
||||
assert_eq!(tracker.get(), Some(DesktopAppearance::Light));
|
||||
emit(&server, 1);
|
||||
assert_eq!(woke.recv_timeout(Duration::from_secs(5)).unwrap(), Some(DesktopAppearance::Dark));
|
||||
// Closing the tracker must cancel an idle bus subscription and join the worker.
|
||||
drop(tracker);
|
||||
drop(server);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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";
|
||||
// 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<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::*;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
/// 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);
|
||||
fn initial_and_changed_results_wake_after_publication_but_duplicates_do_not() {
|
||||
let wakes = Arc::new(AtomicUsize::new(0));
|
||||
let observed = Arc::clone(&wakes);
|
||||
let source = Arc::new(std::sync::OnceLock::<std::sync::Weak<Shared>>::new());
|
||||
let observed_source = Arc::clone(&source);
|
||||
let (snapshot, snapshots) = std::sync::mpsc::channel();
|
||||
let shared = Arc::new(Shared::new(move || {
|
||||
snapshot.send(observed_source.get().unwrap().upgrade().unwrap().get()).unwrap();
|
||||
observed.fetch_add(1, Ordering::Relaxed);
|
||||
}));
|
||||
source.set(Arc::downgrade(&shared)).unwrap();
|
||||
shared.publish(None);
|
||||
assert_eq!(snapshots.try_recv().unwrap(), None);
|
||||
assert_eq!(wakes.load(Ordering::Relaxed), 1);
|
||||
shared.publish(None);
|
||||
assert_eq!(wakes.load(Ordering::Relaxed), 1);
|
||||
shared.publish(Some(DesktopAppearance::Dark));
|
||||
assert_eq!(snapshots.try_recv().unwrap(), Some(DesktopAppearance::Dark));
|
||||
assert_eq!(shared.get(), Some(DesktopAppearance::Dark));
|
||||
assert_eq!(wakes.load(Ordering::Relaxed), 2);
|
||||
shared.publish(Some(DesktopAppearance::Dark));
|
||||
assert_eq!(wakes.load(Ordering::Relaxed), 2);
|
||||
shared.publish(Some(DesktopAppearance::Light));
|
||||
assert_eq!(snapshots.try_recv().unwrap(), Some(DesktopAppearance::Light));
|
||||
assert_eq!(shared.get(), Some(DesktopAppearance::Light));
|
||||
assert_eq!(wakes.load(Ordering::Relaxed), 3);
|
||||
shared.publish(None);
|
||||
assert_eq!(snapshots.try_recv().unwrap(), None);
|
||||
assert_eq!(shared.get(), None);
|
||||
assert_eq!(wakes.load(Ordering::Relaxed), 4);
|
||||
assert!(matches!(snapshots.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_mode_caches_changes_without_waking_and_system_resumes_latest_value() {
|
||||
let wakes = Arc::new(AtomicUsize::new(0));
|
||||
let observed = Arc::clone(&wakes);
|
||||
let tracker = DesktopTheme::spawn(
|
||||
move || {
|
||||
observed.fetch_add(1, Ordering::Relaxed);
|
||||
},
|
||||
|_| futures_lite::future::pending(),
|
||||
);
|
||||
tracker.set_active(false);
|
||||
tracker.shared.publish(Some(DesktopAppearance::Dark));
|
||||
tracker.shared.publish(Some(DesktopAppearance::Light));
|
||||
assert_eq!(tracker.get(), Some(DesktopAppearance::Light));
|
||||
assert_eq!(wakes.load(Ordering::Relaxed), 0);
|
||||
tracker.set_active(true);
|
||||
assert_eq!(tracker.get(), Some(DesktopAppearance::Light));
|
||||
tracker.shared.publish(Some(DesktopAppearance::Dark));
|
||||
assert_eq!(wakes.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_tracker_cancels_and_joins_an_idle_worker() {
|
||||
struct Finished(Arc<AtomicBool>);
|
||||
impl Drop for Finished {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
let finished = Arc::new(AtomicBool::new(false));
|
||||
let worker_finished = Arc::clone(&finished);
|
||||
let (ready, waiting) = std::sync::mpsc::channel();
|
||||
let tracker = DesktopTheme::spawn(
|
||||
|| {},
|
||||
move |_| async move {
|
||||
let _finished = Finished(worker_finished);
|
||||
ready.send(()).unwrap();
|
||||
futures_lite::future::pending::<()>().await;
|
||||
},
|
||||
);
|
||||
waiting.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
|
||||
drop(tracker);
|
||||
assert!(finished.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
/// 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);
|
||||
fn native_non_linux_tracker_is_inert() {
|
||||
let tracker = DesktopTheme::start(|| panic!("non-Linux tracker must not wake"));
|
||||
assert_eq!(tracker.get(), None);
|
||||
tracker.set_active(false);
|
||||
tracker.set_active(true);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -455,10 +455,11 @@ pub struct PdfCraftApp {
|
|||
/// Resolved colours, including the current OS theme when following the system.
|
||||
pub theme: ThemeKind,
|
||||
pub theme_preference: ThemePreference,
|
||||
/// The desktop's light/dark choice, watched while the app runs (Linux has no winit answer).
|
||||
desktop_theme: pdfcraft_platform::desktop_theme::DesktopTheme,
|
||||
/// The last answer [`desktop_theme`] gave, so a frame that asks costs nothing.
|
||||
desktop_dark: Option<bool>,
|
||||
/// The Linux portal is enabled only by the native host, and starts on the first System choice.
|
||||
#[cfg(target_os = "linux")]
|
||||
desktop_theme: Option<pdfcraft_platform::desktop_theme::DesktopTheme>,
|
||||
#[cfg(target_os = "linux")]
|
||||
desktop_theme_enabled: bool,
|
||||
/// How far a mouse-wheel notch scrolls.
|
||||
pub scroll_speed: scroll_speed::ScrollSpeed,
|
||||
/// Interface language preference: `auto` (follow the system) or a code from [`i18n::LANGUAGES`].
|
||||
|
|
@ -734,12 +735,6 @@ impl Default for PdfCraftApp {
|
|||
}
|
||||
}
|
||||
|
||||
/// What the operating system says about light and dark, as "draw dark". `None` means nobody
|
||||
/// answered, not a preference, so the caller keeps what it chose.
|
||||
fn system_theme(ctx: &egui::Context, desktop_dark: Option<bool>) -> Option<bool> {
|
||||
ctx.system_theme().map(|theme| theme == egui::Theme::Dark).or(desktop_dark)
|
||||
}
|
||||
|
||||
/// A restored colour: an `[r, g, b]` array of finite numbers, each clamped to 0–1 (restored
|
||||
/// settings are untrusted). Anything else (a string, a wrong length, a null or a non-finite
|
||||
/// number) is refused whole, so the caller keeps its default.
|
||||
|
|
@ -782,8 +777,10 @@ impl PdfCraftApp {
|
|||
comment_prefs: Default::default(),
|
||||
theme: ThemeKind::Light,
|
||||
theme_preference: ThemePreference::Light,
|
||||
desktop_theme: pdfcraft_platform::desktop_theme::DesktopTheme::start(),
|
||||
desktop_dark: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
desktop_theme: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
desktop_theme_enabled: false,
|
||||
scroll_speed: scroll_speed::ScrollSpeed::default(),
|
||||
language: i18n::AUTO.to_string(),
|
||||
ui_scale: None,
|
||||
|
|
@ -1424,14 +1421,21 @@ impl PdfCraftApp {
|
|||
|
||||
pub fn set_theme_preference(&mut self, preference: ThemePreference) {
|
||||
self.theme_preference = preference;
|
||||
// Before the first frame there is no context to ask, so the last choice stands.
|
||||
let system = self.ctx.as_ref().and_then(|ctx| system_theme(ctx, self.desktop_dark));
|
||||
self.theme = preference.resolve(system, self.theme);
|
||||
if let Some(ctx) = &self.ctx {
|
||||
theme::apply(ctx, self.theme);
|
||||
if let Some(ctx) = self.ctx.clone() {
|
||||
self.sync_theme(&ctx);
|
||||
theme::apply(&ctx, self.theme);
|
||||
} else {
|
||||
self.theme = preference.resolve(None, ThemeKind::Light);
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable the Linux desktop portal in a native window. Headless contexts keep
|
||||
/// their supplied input and never connect to the user's session bus.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn enable_desktop_theme(&mut self) {
|
||||
self.desktop_theme_enabled = true;
|
||||
}
|
||||
|
||||
/// Draw the running job's progress card and pass a Cancel click on to the job.
|
||||
fn show_progress(&mut self, ctx: &egui::Context) {
|
||||
if widgets::progress_notice(self, ctx) {
|
||||
|
|
@ -1440,10 +1444,23 @@ impl PdfCraftApp {
|
|||
}
|
||||
|
||||
fn sync_theme(&mut self, ctx: &egui::Context) {
|
||||
if let Some(dark) = self.desktop_theme.take_change() {
|
||||
self.desktop_dark = Some(dark);
|
||||
#[cfg_attr(not(target_os = "linux"), expect(unused_mut))]
|
||||
let mut system = ctx.system_theme().map(|theme| theme == egui::Theme::Dark);
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let active = self.theme_preference == ThemePreference::System;
|
||||
if self.desktop_theme_enabled && active && self.desktop_theme.is_none() {
|
||||
let ctx = ctx.clone();
|
||||
self.desktop_theme = Some(pdfcraft_platform::desktop_theme::DesktopTheme::start(move || ctx.request_repaint()));
|
||||
}
|
||||
if let Some(desktop) = &self.desktop_theme {
|
||||
desktop.set_active(active);
|
||||
// The standard portal supplies Linux desktop appearance. No preference
|
||||
// must clear an earlier answer rather than retaining stale dark chrome.
|
||||
system = desktop.get().map(|appearance| appearance == pdfcraft_platform::desktop_theme::DesktopAppearance::Dark);
|
||||
}
|
||||
}
|
||||
let kind = self.theme_preference.resolve(system_theme(ctx, self.desktop_dark), self.theme);
|
||||
let kind = self.theme_preference.resolve(system, ThemeKind::Light);
|
||||
if kind != self.theme {
|
||||
self.theme = kind;
|
||||
theme::apply(ctx, kind);
|
||||
|
|
|
|||
|
|
@ -23,8 +23,7 @@ pub enum ThemePreference {
|
|||
|
||||
impl ThemePreference {
|
||||
/// The colours to draw. `system` is what the operating system says, `None` when it says
|
||||
/// nothing; `fallback` is then what the app last drew, so an unanswering desktop leaves the
|
||||
/// user's choice alone.
|
||||
/// nothing; `fallback` is supplied by the caller. PdfCraft uses Light for an unknown desktop.
|
||||
pub fn resolve(self, system: Option<bool>, fallback: ThemeKind) -> ThemeKind {
|
||||
match self {
|
||||
Self::Light => ThemeKind::Light,
|
||||
|
|
@ -272,8 +271,10 @@ pub fn semibold(size: f32) -> FontId {
|
|||
}
|
||||
|
||||
pub fn apply(ctx: &egui::Context, kind: ThemeKind) {
|
||||
// egui must use the same theme for popup/menu styles as our custom chrome.
|
||||
ctx.set_theme(if kind == ThemeKind::Dark { egui::Theme::Dark } else { egui::Theme::Light });
|
||||
// Keep the native window inherited. A concrete egui theme pins macOS's
|
||||
// NSWindow appearance and prevents winit from delivering ThemeChanged.
|
||||
// Our saved choice owns the palette; both egui branches receive it below.
|
||||
ctx.set_theme(egui::ThemePreference::System);
|
||||
let t = Tokens::for_kind(kind);
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("pdfcraft-theme"), t));
|
||||
let mut v = if t.dark() { Visuals::dark() } else { Visuals::light() };
|
||||
|
|
@ -326,6 +327,9 @@ pub fn apply(ctx: &egui::Context, kind: ThemeKind) {
|
|||
s.text_styles.insert(egui::TextStyle::Heading, semibold(17.0));
|
||||
s.interaction.tooltip_delay = 0.35;
|
||||
});
|
||||
let style = ctx.global_style();
|
||||
ctx.set_style_of(egui::Theme::Light, style.clone());
|
||||
ctx.set_style_of(egui::Theme::Dark, style);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -21,7 +21,12 @@ fn assert_theme(h: &Harness<'static, PdfCraftApp>, preference: ThemePreference,
|
|||
assert_eq!(h.state().theme, kind);
|
||||
assert_eq!(Tokens::get(&h.ctx).kind, kind);
|
||||
assert_eq!(h.ctx.global_style().visuals.dark_mode, kind == ThemeKind::Dark);
|
||||
assert_eq!(h.ctx.theme(), if kind == ThemeKind::Dark { egui::Theme::Dark } else { egui::Theme::Light });
|
||||
assert_eq!(h.ctx.options(|o| o.theme_preference), egui::ThemePreference::System);
|
||||
for branch in [egui::Theme::Light, egui::Theme::Dark] {
|
||||
let style = h.ctx.style_of(branch);
|
||||
assert_eq!(style.visuals.dark_mode, kind == ThemeKind::Dark);
|
||||
assert_eq!(style.visuals.panel_fill, Tokens::for_kind(kind).panel);
|
||||
}
|
||||
}
|
||||
|
||||
/// A click on the title bar's theme button cycles Light, Dark, System.
|
||||
|
|
@ -111,13 +116,91 @@ fn commands_and_options_apply_before_the_first_frame() {
|
|||
assert_eq!(app.theme, ThemeKind::Dark);
|
||||
app.run_command("view.theme.system");
|
||||
assert_eq!(app.theme_preference, ThemePreference::System);
|
||||
assert_eq!(app.theme, ThemeKind::Light);
|
||||
// The existing palette/control toggle also goes through the preference setter.
|
||||
app.run_command("view.theme");
|
||||
assert_eq!(app.theme_preference, ThemePreference::Light);
|
||||
assert_eq!(app.theme, ThemeKind::Light);
|
||||
assert_eq!(app.theme_preference, ThemePreference::Dark);
|
||||
assert_eq!(app.theme, ThemeKind::Dark);
|
||||
app.run_command("view.theme.light");
|
||||
app.set_option("theme", "dark").unwrap();
|
||||
assert!(app.set_option("theme", "purple").unwrap_err().contains("light, dark, or system"));
|
||||
assert_eq!(app.theme_preference, ThemePreference::Dark);
|
||||
assert_eq!(app.theme, ThemeKind::Dark);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restored_system_tracks_native_input_without_rewriting_saved_preference() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1100.0, 800.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.restore(r#"{"theme":"System"}"#);
|
||||
app
|
||||
});
|
||||
for (input, expected) in [
|
||||
(Some(egui::Theme::Dark), ThemeKind::Dark),
|
||||
(Some(egui::Theme::Light), ThemeKind::Light),
|
||||
(None, ThemeKind::Light),
|
||||
(Some(egui::Theme::Dark), ThemeKind::Dark),
|
||||
] {
|
||||
h.input_mut().system_theme = input;
|
||||
h.run_steps(3);
|
||||
assert_theme(&h, ThemePreference::System, expected);
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(&h.state().persist()).unwrap()["theme"], "System");
|
||||
}
|
||||
for (manual, expected) in [(ThemePreference::Light, ThemeKind::Light), (ThemePreference::Dark, ThemeKind::Dark)] {
|
||||
h.state_mut().set_theme_preference(manual);
|
||||
for input in [Some(egui::Theme::Dark), Some(egui::Theme::Light), None] {
|
||||
h.input_mut().system_theme = input;
|
||||
h.run_steps(3);
|
||||
assert_theme(&h, manual, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_system_uses_light_after_a_manual_dark_choice() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_theme_preference(ThemePreference::Dark);
|
||||
app.set_theme_preference(ThemePreference::System);
|
||||
assert_eq!(app.theme, ThemeKind::Light);
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(&app.persist()).unwrap()["theme"], "System");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_appearance_remains_inherited_for_every_saved_choice() {
|
||||
#[derive(Clone)]
|
||||
struct NativeCommands(std::sync::Arc<std::sync::Mutex<Vec<egui::SystemTheme>>>);
|
||||
impl egui::Plugin for NativeCommands {
|
||||
fn debug_name(&self) -> &'static str {
|
||||
"theme-command-proof"
|
||||
}
|
||||
fn output_hook(&mut self, _ctx: &egui::Context, output: &mut egui::FullOutput) {
|
||||
for command in output.viewport_output.values().flat_map(|v| &v.commands) {
|
||||
if let egui::ViewportCommand::SetTheme(theme) = command {
|
||||
self.0.lock().unwrap().push(*theme);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for preference in [ThemePreference::System, ThemePreference::Light, ThemePreference::Dark] {
|
||||
let commands = NativeCommands(Default::default());
|
||||
let observed = commands.clone();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1100.0, 800.0)).build_eframe(move |cc| {
|
||||
cc.egui_ctx.add_plugin(commands);
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.set_theme_preference(preference);
|
||||
app
|
||||
});
|
||||
h.input_mut().system_theme = Some(egui::Theme::Light);
|
||||
h.run_steps(3);
|
||||
h.input_mut().system_theme = Some(egui::Theme::Dark);
|
||||
h.run_steps(3);
|
||||
// Observe the actual app's startup and later viewport output. This proves
|
||||
// native window intent, not operating-system ThemeChanged delivery.
|
||||
let commands = observed.0.lock().unwrap();
|
||||
assert!(!commands.is_empty());
|
||||
assert!(commands.iter().all(|theme| *theme == egui::SystemTheme::SystemDefault), "{commands:?}");
|
||||
assert_eq!(h.ctx.options(|o| o.theme_preference), egui::ThemePreference::System);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,28 @@ the low-disk artifacts, run `cargo clean --profile low-disk`.
|
|||
The UI crate and complete desktop/web builds require Rust 1.95 or later, matching the pinned
|
||||
`craft-ui` dependency. The workspace's core-crate minimum is unchanged.
|
||||
|
||||
## Interface appearance
|
||||
|
||||
Display theme saves System, Light or Dark separately from the palette currently drawn. New
|
||||
installations still default to Light. System follows native appearance events on macOS and
|
||||
Windows. The native window keeps its inherited appearance in every mode; both egui style branches
|
||||
use the selected application palette, so desktop changes cannot override a manual choice.
|
||||
|
||||
On Linux the native app starts an XDG Settings portal subscription when System is first selected.
|
||||
It reads `org.freedesktop.appearance` / `color-scheme`, where numeric `1` requests Dark, `2` requests
|
||||
Light, and `0` means no preference. When that states no preference or is missing, it falls back to
|
||||
`org.gnome.desktop.interface` / `color-scheme` (`prefer-dark`, `prefer-light`, `default`), which
|
||||
XFCE's portal answers instead of the freedesktop namespace; KDE answers only the freedesktop one. The subscription is installed before the initial read. Initial
|
||||
results and later `SettingChanged` signals wake an idle window. There is no periodic desktop
|
||||
polling, and signals received in a manual mode update the cache without requesting frames.
|
||||
|
||||
System falls back to Light if no appearance is available, including an absent portal, an
|
||||
unsupported appearance backend, no preference, an unknown value or a failed connection. This
|
||||
does not rewrite the saved System choice. The watcher cancels when its window closes; it does not
|
||||
reconnect after a session-bus disconnection. Headless UI contexts use their supplied egui input and
|
||||
do not contact a desktop session bus. Page and document colours retain their existing behavior.
|
||||
|
||||
|
||||
## Logs
|
||||
|
||||
The desktop app writes its `log` records to standard error and to `logs/pdfcraft.log` in its
|
||||
|
|
|
|||
|
|
@ -73,9 +73,9 @@ total matches the parity estimate.
|
|||
| M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates |
|
||||
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs |
|
||||
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; late browser startup downloads preserve the current document and Save target (#171). |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). |
|
||||
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. |
|
||||
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web |
|
||||
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools.<br>Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA |
|
||||
|
|
@ -142,6 +142,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
|
|||
| 2026-10-11 | minor | M13: reject malformed CLI --root options before dispatch. Estimates unchanged. |
|
||||
| 2026-10-11 | minor | M14: Traditional Chinese accessibility statuses, script-rule meaning and Taiwan form/table terminology; added a missing table-row explanation and catalog regressions. Estimates unchanged. |
|
||||
| 2026-10-10 | minor | M14: localized shortcut key names (#496), including runtime switching and native layout checks. Estimates unchanged. |
|
||||
| 2026-10-10 | minor | M3 records the System appearance completion after #881; estimates unchanged |
|
||||
| 2026-10-10 | minor | Re-judged convert and share with the cross-app gate rule (only a workflow that can't be completed at all blocks): partial, not blocking → stage alpha |
|
||||
| 2026-10-10 | minor | Added the alpha gate (six core workflows; convert and share fails → pre-alpha, 40–70 h to alpha) and rank 0 in the upcoming milestones |
|
||||
| 2026-10-10 | major | Created from ROADMAP.md §Milestones, §Critical path, §Risks and §Where we're lacking. Merged the duplicated M1, M2, M3 and M14 rows, re-measured Done and Remaining, added Current focus and the ranked upcoming milestones |
|
||||
|
|
|
|||
Loading…
Reference in New Issue