From 216efab838a857d5fb168794c3c7fe1579091aac Mon Sep 17 00:00:00 2001 From: Luniiya <86487331+luniiya@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:18:31 +0200 Subject: [PATCH] UI: Add system appearance and light themes --- Cargo.lock | 1 + Cargo.toml | 3 + apps/lightcraft-web/src/web.rs | 2 + apps/lightcraft/Cargo.toml | 3 + apps/lightcraft/src/appearance.rs | 198 +++++++++++++++++++++++++ apps/lightcraft/src/main.rs | 2 + crates/ui-egui/locales/de.json | 15 +- crates/ui-egui/locales/es.json | 15 +- crates/ui-egui/locales/ja.json | 15 +- crates/ui-egui/locales/pt-br.json | 15 +- crates/ui-egui/locales/ru.json | 15 +- crates/ui-egui/locales/zh-hans.json | 15 +- crates/ui-egui/locales/zh-hant.json | 15 +- crates/ui-egui/src/headless.rs | 10 ++ crates/ui-egui/src/icons.rs | 24 +++ crates/ui-egui/src/lib.rs | 24 +++ crates/ui-egui/src/menubar.rs | 24 +++ crates/ui-egui/src/menus.rs | 42 ++++++ crates/ui-egui/src/panels/dialogs.rs | 12 +- crates/ui-egui/src/panels/settings.rs | 194 +++++++++++++++++++++++- crates/ui-egui/src/panels/topbar.rs | 23 ++- crates/ui-egui/src/state.rs | 93 ++++++++++++ crates/ui-egui/src/tests_appearance.rs | 197 ++++++++++++++++++++++++ crates/ui-egui/src/theme.rs | 165 ++++++++++++++++++++- docs/control-protocol.md | 7 + docs/parity.md | 2 +- docs/whats-new.md | 8 + 27 files changed, 1118 insertions(+), 21 deletions(-) create mode 100644 apps/lightcraft/src/appearance.rs create mode 100644 crates/ui-egui/src/tests_appearance.rs diff --git a/Cargo.lock b/Cargo.lock index 45d8ff15c..a2cbd8250 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2990,6 +2990,7 @@ dependencies = [ "rfd", "serde_json", "winresource", + "zbus", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5f7c85db6..02cd279ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -84,6 +84,9 @@ eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" rfd = "0.17" muda = { version = "0.21", default-features = false } # native menu bar (macOS: objc2/AppKit, pure Rust; no GTK) +# Linux: the desktop's dark/light setting from the XDG settings portal (Settings ▸ Interface ▸ +# Appearance Mode ▸ Auto). Pure Rust; the version accesskit already brings in, pinned to it. +zbus = "=5.19.0" web-time = "1" # web (apps/lightcraft-web) diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 9960fdd54..190ad1583 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -229,6 +229,8 @@ fn services(originals: Originals, backend: Option, files: Files, frozen w.open_with_url_and_target(url, "_blank").map(|_| ()).map_err(|_| "the browser blocked the new tab".to_string()) })), pick_folder: None, + // the browser reports prefers-color-scheme to egui + system_theme: None, } } diff --git a/apps/lightcraft/Cargo.toml b/apps/lightcraft/Cargo.toml index e4d7f2b3b..c6219f9da 100644 --- a/apps/lightcraft/Cargo.toml +++ b/apps/lightcraft/Cargo.toml @@ -35,5 +35,8 @@ winresource = "0.1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } +[target.'cfg(target_os = "linux")'.dependencies] +zbus = { workspace = true } + [target.'cfg(target_os = "macos")'.dependencies] muda = { workspace = true } diff --git a/apps/lightcraft/src/appearance.rs b/apps/lightcraft/src/appearance.rs new file mode 100644 index 000000000..74333a301 --- /dev/null +++ b/apps/lightcraft/src/appearance.rs @@ -0,0 +1,198 @@ +//! The desktop's dark/light setting for Settings ▸ Interface ▸ Appearance Mode ▸ Auto, on Linux +//! desktops whose windowing layer reports no theme to egui (many Wayland compositors). +//! +//! The XDG settings portal is read once, then its `SettingChanged` signal is followed on a worker +//! thread that wakes the UI only when the value changes. Nothing is polled. `gsettings` is a +//! one-shot fallback at start-up, run by absolute path, only when the portal gives no answer. +//! Elsewhere egui's own report (`Context::system_theme`) is used. + +#[cfg(target_os = "linux")] +const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance"; +#[cfg(target_os = "linux")] +const PORTAL_KEY: &str = "color-scheme"; + +#[cfg(target_os = "linux")] +fn portal_proxy() -> Option> { + let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?; + zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok() +} + +#[cfg(target_os = "linux")] +fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option { + let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?; + decode(portal_code(value)?) +} + +/// The `u32` inside a portal value. `Settings.Read` answers a variant, and some portals nest +/// another variant inside it. +#[cfg(target_os = "linux")] +fn portal_code(value: zbus::zvariant::OwnedValue) -> Option { + let mut value: zbus::zvariant::Value<'_> = value.into(); + for _ in 0..4 { + match value { + zbus::zvariant::Value::Value(inner) => value = *inner, + other => return u32::try_from(other).ok(), + } + } + None +} + +/// The portal's `color-scheme`: 0 = no preference, 1 = prefer dark, 2 = prefer light. +#[cfg(target_os = "linux")] +fn decode(code: u32) -> Option { + match code { + 1 => Some(egui::Theme::Dark), + 2 => Some(egui::Theme::Light), + _ => None, + } +} + +/// One-shot fallback for desktops without the settings portal. It runs once at start-up, by +/// absolute path, and is never repeated. +#[cfg(target_os = "linux")] +fn gtk_theme() -> Option { + let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?; + let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?; + if !output.status.success() { + return None; + } + parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?) +} + +#[cfg(target_os = "linux")] +fn parse_gtk_scheme(s: &str) -> Option { + match s.trim().trim_matches('\'') { + "prefer-dark" => Some(egui::Theme::Dark), + "prefer-light" => Some(egui::Theme::Light), + _ => None, + } +} + +#[cfg(target_os = "linux")] +fn code_of(theme: Option) -> u8 { + match theme { + Some(egui::Theme::Dark) => 1, + Some(egui::Theme::Light) => 2, + None => 0, + } +} + +/// Wake the first frame after a slow initial Read as well as subsequent real changes. +#[cfg(target_os = "linux")] +fn publish(value: &std::sync::atomic::AtomicU8, wake: &std::sync::OnceLock, code: u8) { + if value.swap(code, std::sync::atomic::Ordering::Relaxed) != code + && let Some(ctx) = wake.get() + { + ctx.request_repaint(); + } +} + +/// The host's system-appearance service (Linux), or `None` where egui's report is enough. +pub fn service() -> Option { + #[cfg(target_os = "linux")] + { + use std::sync::{ + Arc, OnceLock, + atomic::{AtomicU8, Ordering}, + }; + let value = Arc::new(AtomicU8::new(0)); + let wake: Arc> = Arc::new(OnceLock::new()); + let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake)); + let (ready, wait) = std::sync::mpsc::channel(); + if std::thread::Builder::new() + .name("appearance-portal".into()) + .spawn(move || { + let proxy = portal_proxy(); + // Subscribe before Read so a change during startup is queued rather than lost. + let signals = proxy.as_ref().and_then(|proxy| proxy.receive_signal("SettingChanged").ok()); + let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme); + publish(&worker_value, &worker_wake, code_of(initial)); + let _ = ready.send(()); + let Some(signals) = signals else { return }; + // Blocks until the portal sends a signal: no polling. + for message in signals { + let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else { + continue; + }; + if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY { + continue; + } + let code = code_of(portal_code(changed).and_then(decode)); + publish(&worker_value, &worker_wake, code); + } + }) + .is_err() + { + return None; + } + // The first frame shows the right theme when the portal answers quickly; a slow one + // only delays the switch (the worker wakes the UI when the value changes). + let _ = wait.recv_timeout(std::time::Duration::from_millis(250)); + Some(Box::new(move |ctx| { + let _ = wake.set(ctx.clone()); + decode(u32::from(value.load(Ordering::Relaxed))) + })) + } + #[cfg(not(target_os = "linux"))] + { + None + } +} + +#[cfg(test)] +mod tests { + #[cfg(target_os = "linux")] + #[test] + fn delayed_initial_read_repaints_only_on_change() { + use std::sync::{ + Arc, OnceLock, + atomic::{AtomicU8, AtomicUsize, Ordering}, + }; + let ctx = egui::Context::default(); + let settle = |ctx: &egui::Context| { + for _ in 0..8 { + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + } + }; + settle(&ctx); + let calls = Arc::new(AtomicUsize::new(0)); + let observed = Arc::clone(&calls); + ctx.set_request_repaint_callback(move |_| { + observed.fetch_add(1, Ordering::Relaxed); + }); + let wake = OnceLock::new(); + assert!(wake.set(ctx.clone()).is_ok()); + let value = AtomicU8::new(0); + super::publish(&value, &wake, 2); + assert_eq!(value.load(Ordering::Relaxed), 2); + let after_change = calls.load(Ordering::Relaxed); + assert!(after_change > 0); + settle(&ctx); + let after_settle = calls.load(Ordering::Relaxed); + super::publish(&value, &wake, 2); + assert_eq!(calls.load(Ordering::Relaxed), after_settle); + } + + #[cfg(target_os = "linux")] + #[test] + fn portal_values_and_gsettings_output_decode() { + assert_eq!(super::decode(0), None); + assert_eq!(super::decode(1), Some(egui::Theme::Dark)); + assert_eq!(super::decode(2), Some(egui::Theme::Light)); + assert_eq!(super::decode(9), None); + let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2))))); + assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2)); + let plain = zbus::zvariant::OwnedValue::from(1u32); + assert_eq!(super::portal_code(plain), Some(1)); + let wrong = zbus::zvariant::OwnedValue::try_from(zbus::zvariant::Value::from("dark")).unwrap(); + assert_eq!(super::portal_code(wrong), None); + assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light)); + assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark)); + assert_eq!(super::parse_gtk_scheme("'default'\n"), None); + assert_eq!(super::parse_gtk_scheme(""), None); + for theme in [None, Some(egui::Theme::Dark), Some(egui::Theme::Light)] { + assert_eq!(super::decode(u32::from(super::code_of(theme))), theme); + } + } +} diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 832b158b3..9c5da8162 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -23,6 +23,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod alloc_release; +mod appearance; mod control_server; mod dialog_filter; mod logging; @@ -499,6 +500,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services // the library is a folder on disk: backed up with the user's other files backup_library: None, restore_library: None, + system_theme: appearance::service(), } } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index c24b59cc0..81376a649 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1889,5 +1889,18 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Appearance": "Erscheinungsbild", + "Appearance Mode": "Erscheinungsmodus", + "Sync with System": "Mit System synchronisieren", + "Light Mode": "Heller Modus", + "Dark Mode": "Dunkler Modus", + "Next Appearance Mode": "Nächster Erscheinungsmodus", + "Light Theme": "Helles Design", + "Dark Theme": "Dunkles Design", + "In Use": "In Verwendung", + "Charcoal": "Anthrazit", + "Midnight": "Mitternacht", + "Silver": "Silber", + "Paper": "Papier" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 387ac9717..6c07bf636 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1734,5 +1734,18 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Appearance": "Apariencia", + "Appearance Mode": "Modo de apariencia", + "Sync with System": "Sincronizar con el sistema", + "Light Mode": "Modo claro", + "Dark Mode": "Modo oscuro", + "Next Appearance Mode": "Siguiente modo de apariencia", + "Light Theme": "Tema claro", + "Dark Theme": "Tema oscuro", + "In Use": "En uso", + "Charcoal": "Carbón", + "Midnight": "Medianoche", + "Silver": "Plata", + "Paper": "Papel" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index b05baac24..52721325c 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1734,5 +1734,18 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Appearance": "外観", + "Appearance Mode": "外観モード", + "Sync with System": "システムと同期", + "Light Mode": "ライトモード", + "Dark Mode": "ダークモード", + "Next Appearance Mode": "次の外観モード", + "Light Theme": "ライトテーマ", + "Dark Theme": "ダークテーマ", + "In Use": "使用中", + "Charcoal": "チャコール", + "Midnight": "ミッドナイト", + "Silver": "シルバー", + "Paper": "ペーパー" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index ab7f39734..df1c2d3ce 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1734,5 +1734,18 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Appearance": "Aparência", + "Appearance Mode": "Modo de aparência", + "Sync with System": "Sincronizar com o sistema", + "Light Mode": "Modo claro", + "Dark Mode": "Modo escuro", + "Next Appearance Mode": "Próximo modo de aparência", + "Light Theme": "Tema claro", + "Dark Theme": "Tema escuro", + "In Use": "Em uso", + "Charcoal": "Carvão", + "Midnight": "Meia-noite", + "Silver": "Prata", + "Paper": "Papel" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 517e2613f..8aa36cb03 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1407,5 +1407,18 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Appearance": "Оформление", + "Appearance Mode": "Режим оформления", + "Sync with System": "Как в системе", + "Light Mode": "Светлый режим", + "Dark Mode": "Тёмный режим", + "Next Appearance Mode": "Следующий режим оформления", + "Light Theme": "Светлая тема", + "Dark Theme": "Тёмная тема", + "In Use": "Используется", + "Charcoal": "Графит", + "Midnight": "Полночь", + "Silver": "Серебро", + "Paper": "Бумага" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 46728b628..d67a5e6a7 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1734,5 +1734,18 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Appearance": "外观", + "Appearance Mode": "外观模式", + "Sync with System": "与系统同步", + "Light Mode": "浅色模式", + "Dark Mode": "深色模式", + "Next Appearance Mode": "下一个外观模式", + "Light Theme": "浅色主题", + "Dark Theme": "深色主题", + "In Use": "使用中", + "Charcoal": "炭灰", + "Midnight": "午夜", + "Silver": "银色", + "Paper": "纸白" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 083c98c2e..2bc551d01 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1734,5 +1734,18 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Appearance": "外觀", + "Appearance Mode": "外觀模式", + "Sync with System": "與系統同步", + "Light Mode": "淺色模式", + "Dark Mode": "深色模式", + "Next Appearance Mode": "下一個外觀模式", + "Light Theme": "淺色主題", + "Dark Theme": "深色主題", + "In Use": "使用中", + "Charcoal": "炭灰", + "Midnight": "午夜", + "Silver": "銀色", + "Paper": "紙白" } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index e6929269f..a910b4175 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2662,10 +2662,20 @@ mod tests { assert_eq!(h.app.ui.settings.startup_view, crate::state::StartupView::Detail); // Interface: filmstrip names off, grid badges always h.request("ui.clickWidget", json!({"id": "button:settingsTab-interface"}), t); + // Appearance cards sit above these controls; scroll the compact settings viewport. + h.request("ui.hoverWidget", json!({"id": "radio:theme-charcoal"}), t); + h.request("ui.scroll", json!({"dy": -350.0}), t); + for _ in 0..4 { + h.step(); + } h.request("ui.clickWidget", json!({"id": "check:settings.filmNames"}), t); assert!(!h.app.ui.settings.film_names); h.request("ui.clickWidget", json!({"id": "button:settingsGridBadges-1"}), t); assert_eq!(h.app.ui.settings.grid_badges, crate::state::GridBadges::Always); + h.request("ui.scroll", json!({"dy": 350.0}), t); + for _ in 0..4 { + h.step(); + } // Performance: the thumbnail cache size goes to the library preferences h.request("ui.clickWidget", json!({"id": "button:settingsTab-performance"}), t); h.request("ui.clickWidget", json!({"id": "button:settingsCache-0"}), t); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index acbbba02b..87bbf9f16 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -80,6 +80,12 @@ pub enum Icon { Chat, /// Face boxes on/off: four corner brackets round a small face. FaceBox, + /// Appearance Mode ▸ Auto (follow the system): a monitor on its stand. + Monitor, + /// Appearance Mode ▸ Light: a sun (a disc with eight rays). + Sun, + /// Appearance Mode ▸ Dark: a crescent moon. + Moon, } struct Pen<'a> { @@ -159,6 +165,24 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.line(&[(4.0, 10.0), (16.0, 10.0)]); pen.line(&[(11.0, 5.0), (16.0, 10.0), (11.0, 15.0)]); } + Monitor => { + pen.rect(2.5, 3.5, 17.5, 13.5, 1.5); + pen.line(&[(10.0, 13.5), (10.0, 16.5)]); + pen.line(&[(6.5, 16.5), (13.5, 16.5)]); + } + Sun => { + pen.circle(10.0, 10.0, 3.2); + for i in 0..8 { + let a = (i as f32 * 45.0).to_radians(); + let (c, s) = (a.cos(), a.sin()); + pen.line(&[(10.0 + 5.4 * c, 10.0 + 5.4 * s), (10.0 + 7.4 * c, 10.0 + 7.4 * s)]); + } + } + Moon => { + // outer arc of the disc, then the inner arc of the bite, meeting at the horns + pen.arc(10.0, 10.0, 6.5, 2.9, 263.8); + pen.arc(13.4, 6.4, 5.0, 214.9, 51.8); + } Search => { pen.circle(8.5, 8.5, 5.0); pen.line(&[(12.2, 12.2), (16.5, 16.5)]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 705818911..554d2e614 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,6 +32,8 @@ pub mod theme; pub mod titlebar; pub mod widgets; +#[cfg(test)] +mod tests_appearance; #[cfg(test)] mod tests_curve; #[cfg(test)] @@ -98,6 +100,11 @@ pub type OpenWithFn = Box Result<(), String>>; /// storage); the work may finish asynchronously. pub type HostAction = Box Result>; +/// The system's dark/light appearance where egui doesn't report one (Linux desktops whose +/// windowing layer says nothing, e.g. many Wayland compositors). The host answers from what it +/// last heard and wakes the UI itself when that changes; it is never polled for changes. +pub type SystemThemeFn = Box Option>; + /// Platform services injected by the host app (desktop or web). #[derive(Default)] pub struct Services { @@ -145,6 +152,8 @@ pub struct Services { pub backup_library: Option, /// File ▸ Restore Library from Backup… (web only; keeps the current library). pub restore_library: Option, + /// The system appearance for Appearance Mode ▸ Auto, ahead of egui's own report. + pub system_theme: Option, } #[derive(Clone, Copy, Debug, Default)] @@ -629,6 +638,20 @@ impl LightcraftApp { } } + /// The system's appearance: the host's answer first, then egui's (none: unknown). + pub fn system_theme(&self, ctx: &egui::Context) -> Option { + self.services.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme()) + } + + /// Restyle `ctx` when the theme the settings select (Appearance Mode, the dark and light + /// themes, the system's appearance in Auto) is not the one it shows. A no-op otherwise. + pub fn sync_theme(&mut self, ctx: &egui::Context) { + let wanted = self.ui.settings.theme(self.system_theme(ctx)); + if theme::current(ctx) != Some(wanted) { + theme::apply_kind(ctx, wanted); + } + } + /// Draw the UI into an offscreen context at the window's size; with `capture`, rasterize it /// on the CPU (photo textures come from the renderer's CPU copies). pub fn headless_screenshot(&mut self, main: &egui::Context, capture: bool) -> Option { @@ -715,6 +738,7 @@ impl LightcraftApp { } else { self.fonts_ready = true; } + self.sync_theme(ctx); panels::notices::logic(self); // closing the window (or Quit) with changes only in memory: retry, else ask first if ctx.input(|i| i.viewport().close_requested()) && !panels::notices::may_close(self) { diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 7d964856e..67b61fc54 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -128,6 +128,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.histogram", "view.navigator", "view.infoOverlay", + "@Appearance", "---", "view.fullScreenPreview", "view.enterFullScreen", @@ -455,6 +456,29 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { v.push(item("dialog.labelNames", Value::Null, "Edit Label Names…", None, true, None)); v } + "Appearance" => { + use crate::state::AppearanceMode; + use crate::theme::ThemeKind; + let s = &app.ui.settings; + let mut v: Vec = [ + ("auto", "Sync with System", AppearanceMode::Auto), + ("light", "Light Mode", AppearanceMode::Light), + ("dark", "Dark Mode", AppearanceMode::Dark), + ] + .into_iter() + .map(|(mode, label, m)| item("view.appearance", json!({"mode": mode}), label, None, true, Some(s.appearance_mode == m))) + .collect(); + v.push(item("view.appearance", Value::Null, "Next Appearance Mode", None, true, None)); + // the saved theme of each family is checked + for group in [[ThemeKind::Charcoal, ThemeKind::Midnight], [ThemeKind::Silver, ThemeKind::Paper]] { + v.push(MenuNode::Separator); + for k in group { + let on = if k.is_dark() { s.dark_theme.kind() == k } else { s.light_theme.kind() == k }; + v.push(item("view.theme", json!({"theme": k.id()}), k.label(), None, true, Some(on))); + } + } + v + } "Grid Info" => ["filename", "exposure", "date"] .into_iter() .zip(["File Name", "Exposure (shutter · aperture · ISO)", "Capture Date"]) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5b79548b9..3511dc3ce 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -136,6 +136,11 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.secondWindow", "Second Window", Some("Cmd+F11"), "Window"), ("tool.keywordPainter", "Keyword Painter", None, ""), ("view.gridInfo", "Grid Info", None, ""), + // {mode?: auto | light | dark}: Appearance Mode (cycles Auto, Light, Dark when omitted; the + // top bar's appearance button). View ▸ Appearance lists the modes. + ("view.appearance", "Next Appearance Mode", None, ""), + // {theme: charcoal | midnight | silver | paper}: pick a theme and fix the mode to its family. + ("view.theme", "Theme", None, ""), ("dialog.allMetadata", "All Metadata…", None, "Photo"), ("dialog.faceModel", "Add Face Model…", None, ""), ("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"), @@ -253,6 +258,41 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { Some([c(0)?, c(1)?, c(2)?]) } +fn appearance_json(app: &LightcraftApp) -> Value { + let s = &app.ui.settings; + json!({"mode": s.appearance_mode, "darkTheme": s.dark_theme, "lightTheme": s.light_theme}) +} + +/// `view.appearance {mode?}`: set Appearance Mode, or step to the next one (Auto, Light, Dark). +/// The theme follows on the next frame ([`LightcraftApp::sync_theme`]). +fn appearance_command(app: &mut LightcraftApp, p: &Value) -> Result { + use crate::state::AppearanceMode; + let mode = match p.get("mode") { + None | Some(Value::Null) => app.ui.settings.appearance_mode.next(), + Some(v) => match v.as_str() { + Some("auto") => AppearanceMode::Auto, + Some("light") => AppearanceMode::Light, + Some("dark") => AppearanceMode::Dark, + _ => return Err(format!("view.appearance: unknown mode {v} (auto|light|dark)")), + }, + }; + app.ui.settings.appearance_mode = mode; + Ok(appearance_json(app)) +} + +/// `view.theme {theme}`: pick a theme; the mode becomes its family's (Dark or Light). +fn theme_command(app: &mut LightcraftApp, p: &Value) -> Result { + let ids = crate::theme::ThemeKind::ALL.map(|k| k.id()).join("|"); + let Some(id) = p.get("theme").and_then(Value::as_str) else { + return Err(format!("view.theme: `theme` is required ({ids})")); + }; + let Some(kind) = crate::theme::ThemeKind::ALL.into_iter().find(|k| k.id() == id) else { + return Err(format!("view.theme: unknown theme `{id}` ({ids})")); + }; + app.ui.settings.select_theme(kind); + Ok(appearance_json(app)) +} + /// Handle UI commands; `None` means "not a UI command — send it to the engine". pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option> { if matches!(id, "library.inspectLightroom" | "library.importLightroom") { @@ -310,6 +350,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option appearance_command(app, p), + "view.theme" => theme_command(app, p), "view.gridToggle" => { app.ui.view = if app.ui.view == ViewMode::PhotoGrid { ViewMode::SquareGrid } else { ViewMode::PhotoGrid }; Ok(Value::Null) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index fa2e6ad9e..c102555ef 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -115,11 +115,19 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { // own so the size it is given doesn't carry over to the other dialogs. Its content scrolls // rather than growing the window when the options below the grid get taller (e.g. Copy). let import = matches!(dlg, Dialog::Import { .. }); - let window_id = egui::Id::new(if import { "lightcraft-import-dialog" } else { "lightcraft-dialog" }); + let settings = matches!(dlg, Dialog::Settings { .. }); + let window_id = egui::Id::new(if import { + "lightcraft-import-dialog" + } else if settings { + "lightcraft-settings-dialog" + } else { + "lightcraft-dialog" + }); let shown = egui::Window::new(crate::i18n::tr(&title)).id(window_id) .collapsible(false) .resizable(import) - .vscroll(import) + .vscroll(import || settings) + .max_size(if settings { (ctx.content_rect().size() - egui::vec2(48.0, 80.0)).max(egui::vec2(240.0, 180.0)) } else { egui::Vec2::splat(f32::INFINITY) }) .resize(|r| if import { r.default_height(crate::import::DIALOG_SIZE[1]) } else { r }) .frame(frame) .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 2774a9622..159bbc2a6 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -34,9 +34,8 @@ const LABEL_W: f32 = 150.0; /// The dialog body for `tab` (the tab bar switches `tab`). pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) { let t = Tokens::get(ui.ctx()); - ui.set_min_width(560.0); - ui.set_min_height(330.0); - ui.horizontal(|ui| { + ui.set_width((ui.ctx().content_rect().width() - 64.0).clamp(240.0, 560.0)); + ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 4.0; for (id, label) in TABS { if crate::widgets::text_button(ui, &format!("settingsTab-{id}"), label, tab == id).clicked() { @@ -533,6 +532,8 @@ fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { // ---------------------------------------------------------------------------------- Interface fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let system = app.system_theme(ui.ctx()); + appearance(ui, t, &mut app.ui.settings, system); heading(ui, t, crate::i18n::tr("Filmstrip")); check(ui, "settings.filmNames", &mut app.ui.settings.film_names, "Show file names"); check(ui, "settings.filmBadges", &mut app.ui.settings.film_badges, "Show ratings, flags and edit badges"); @@ -554,6 +555,193 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { }); } +/// Appearance Mode (`button:settingsAppearance-{0,1,2}`: Auto, Dark, Light) above the light and +/// dark theme cards (`radio:theme-{id}`), each with a preview of the chosen theme. The card in +/// use is outlined. Applied on the next frame ([`LightcraftApp::sync_theme`]). +fn appearance(ui: &mut egui::Ui, t: &Tokens, s: &mut crate::state::AppSettings, system: Option) { + use crate::state::{AppearanceMode as M, DarkTheme, LightTheme}; + heading(ui, t, crate::i18n::tr("Appearance")); + ui.label(RichText::new(crate::i18n::tr("Appearance Mode")).color(t.text_label)); + ui.horizontal_wrapped(|ui| { + choices(ui, "settingsAppearance", &[(M::Auto, "Sync with System"), (M::Dark, "Dark Mode"), (M::Light, "Light Mode")], &mut s.appearance_mode); + }); + ui.add_space(4.0); + let light_active = s.theme(system) == s.light_theme.kind(); + let available = ui.available_width(); + let mut cards = |ui: &mut egui::Ui, width: f32| { + ui.spacing_mut().item_spacing.x = 12.0; + let light: Vec<_> = LightTheme::ALL.iter().map(|l| (*l, l.kind())).collect(); + theme_card(ui, t, "Light Theme", light_active, &light, &mut s.light_theme, width); + let dark: Vec<_> = DarkTheme::ALL.iter().map(|d| (*d, d.kind())).collect(); + theme_card(ui, t, "Dark Theme", !light_active, &dark, &mut s.dark_theme, width); + }; + if available < 440.0 { + ui.vertical(|ui| cards(ui, available)); + } else { + ui.horizontal_top(|ui| cards(ui, ((available - 12.0) / 2.0).floor())); + } + ui.add_space(2.0); +} + +/// One family's card: its title (and "Active" when it is the one shown), a preview of the +/// selected theme and a radio button per theme. +fn theme_card( + ui: &mut egui::Ui, + t: &Tokens, + title: &str, + active: bool, + options: &[(V, crate::theme::ThemeKind)], + value: &mut V, + width: f32, +) { + egui::Frame::new() + .fill(t.inset) + .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.field_border })) + .corner_radius(6) + .inner_margin(8) + .show(ui, |ui| { + ui.vertical(|ui| { + let inner = (width - 18.0).clamp(120.0, 240.0); + ui.set_width(inner); + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::tr(title)).font(t.semibold(12.0)).color(t.text)); + if active { + ui.label(RichText::new(crate::i18n::tr("In Use")).size(11.0).color(t.accent)); + } + }); + let shown = options.iter().find(|(v, _)| v == value).or(options.first()).map(|(_, k)| *k).unwrap_or_default(); + theme_preview(ui, shown, inner); + ui.horizontal(|ui| { + for (v, kind) in options { + let r = ui.radio_value(value, *v, crate::i18n::tr(kind.label())); + register(ui.ctx(), format!("radio:theme-{}", kind.id()), r.rect); + } + }); + }); + }); +} + +/// A small LightCraft window painted with `kind`'s tokens: the top bar with its search field, the +/// photo with the filmstrip under it, the Edit panel (histogram and sliders), the tool strip and +/// the bottom bar. +fn theme_preview(ui: &mut egui::Ui, kind: crate::theme::ThemeKind, width: f32) { + use egui::{Color32, Rect, Stroke, StrokeKind, pos2, vec2}; + let p = Tokens::for_kind(kind); + let (rect, _) = ui.allocate_exact_size(vec2(width, (width * 0.56).round().min(130.0)), egui::Sense::hover()); + let painter = ui.painter_at(rect); + let bar = |y: f32, x: f32, w: f32, c: Color32| painter.rect_filled(Rect::from_min_size(pos2(x, y), vec2(w, 2.0)), 1.0, c); + painter.rect_filled(rect, 3.0, p.canvas); + // top bar: sidebar and back icons, the centred search field, icons on the right + let top = Rect::from_min_size(rect.min, vec2(rect.width(), 13.0)); + painter.rect_filled(top, 0.0, p.chrome); + for x in [6.0, 14.0, 20.0] { + painter.rect_filled(Rect::from_min_size(top.min + vec2(x, 4.5), vec2(4.0, 4.0)), 1.0, p.icon); + } + let search = Rect::from_center_size(top.center(), vec2(rect.width() * 0.34, 8.0)); + painter.rect(search, 2.0, p.field, Stroke::new(1.0, p.field_border), StrokeKind::Inside); + bar(search.center().y - 1.0, search.center().x - 9.0, 18.0, p.text_dim); + for i in 0..4 { + painter.rect_filled(Rect::from_min_size(pos2(top.right() - 10.0 - i as f32 * 9.0, top.top() + 4.5), vec2(4.0, 4.0)), 2.0, p.icon); + } + // bottom bar: view buttons, rating stars, a button + let bottom = Rect::from_min_max(pos2(rect.left(), rect.bottom() - 12.0), rect.max); + painter.rect_filled(bottom, 0.0, p.chrome); + for x in [6.0, 13.0, 20.0] { + painter.rect_filled(Rect::from_min_size(bottom.min + vec2(x, 4.0), vec2(4.0, 4.0)), 1.0, p.icon); + } + let stars_x = bottom.center().x - 30.0; + for i in 0..5 { + painter.circle_filled(pos2(stars_x + i as f32 * 5.0, bottom.center().y), 1.4, p.star); + } + let copy = Rect::from_min_size(pos2(stars_x + 28.0, bottom.top() + 2.5), vec2(30.0, 7.0)); + painter.rect(copy, 3.0, p.button, Stroke::new(1.0, p.button_border), StrokeKind::Inside); + // tool strip on the right edge, the first tool (Edit) active + let strip = Rect::from_min_max(pos2(rect.right() - 12.0, top.bottom()), pos2(rect.right(), bottom.top())); + painter.rect_filled(strip, 0.0, p.chrome); + painter.line_segment([strip.left_top(), strip.left_bottom()], Stroke::new(1.0, p.divider)); + for i in 0..5 { + let r = Rect::from_min_size(pos2(strip.left() + 3.0, strip.top() + 4.0 + i as f32 * 9.0), vec2(6.0, 6.0)); + if i == 0 { + painter.rect_filled(r.expand(1.5), 1.5, p.tool_active); + } + painter.rect_stroke(r, 1.0, Stroke::new(1.0, if i == 0 { p.text } else { p.icon }), StrokeKind::Inside); + } + // Edit panel: histogram, then labelled sliders + let panel = Rect::from_min_max(pos2(strip.left() - (rect.width() * 0.27).round(), top.bottom()), pos2(strip.left(), bottom.top())); + painter.rect_filled(panel, 0.0, p.chrome); + painter.line_segment([panel.left_top(), panel.left_bottom()], Stroke::new(1.0, p.divider)); + let hist = Rect::from_min_size(panel.min + vec2(4.0, 4.0), vec2(panel.width() - 8.0, 14.0)); + painter.rect_filled(hist, 1.0, p.inset); + let curve = [0.2, 0.55, 0.9, 0.7, 0.5, 0.62, 0.4, 0.3, 0.18, 0.1]; + let mut pts = vec![hist.left_bottom()]; + for (i, h) in curve.iter().enumerate() { + pts.push(pos2(hist.left() + hist.width() * i as f32 / (curve.len() - 1) as f32, hist.bottom() - hist.height() * h)); + } + pts.push(hist.right_bottom()); + // the area under the curve, a column per stretch between two points + for pair in pts.windows(2) { + if let [a, b] = pair { + let r = Rect::from_min_max(pos2(a.x, a.y.max(b.y)), pos2(b.x, hist.bottom())); + painter.rect_filled(r, 0.0, p.track.gamma_multiply(0.5)); + } + } + painter.add(egui::Shape::line(pts, Stroke::new(1.0, p.text_dim))); + let sliders = [0.55, 0.62, 0.35, 0.7, 0.6, 0.45]; + let mut y = hist.bottom() + 7.0; + bar(y, panel.left() + 5.0, 14.0, p.text); + y += 6.0; + for v in sliders { + if y + 7.0 > panel.bottom() { + break; + } + bar(y, panel.left() + 5.0, 12.0, p.text_label); + bar(y, panel.right() - 10.0, 5.0, p.text_dim); + let track_y = y + 5.0; + painter.line_segment([pos2(panel.left() + 5.0, track_y), pos2(panel.right() - 5.0, track_y)], Stroke::new(1.0, p.track)); + painter.circle_filled(pos2(panel.left() + 5.0 + (panel.width() - 10.0) * v, track_y), 1.8, p.thumb); + y += 10.0; + } + // the photo (its own colours: a photo looks the same in every theme) above the filmstrip + let film = Rect::from_min_max(pos2(rect.left(), bottom.top() - 18.0), pos2(panel.left(), bottom.top())); + let stage = Rect::from_min_max(pos2(rect.left(), top.bottom()), pos2(panel.left(), film.top())); + let photo = Rect::from_center_size(stage.center(), vec2(stage.width() * 0.62, stage.height() - 8.0)); + painter.rect_filled(photo, 0.0, Color32::from_rgb(0x6f, 0x8f, 0xb4)); + let horizon = photo.top() + photo.height() * 0.62; + painter.add(egui::Shape::convex_polygon( + vec![ + pos2(photo.left(), horizon), + pos2(photo.left() + photo.width() * 0.3, photo.top() + photo.height() * 0.3), + pos2(photo.left() + photo.width() * 0.55, horizon), + ], + Color32::from_rgb(0x4a, 0x55, 0x66), + Stroke::NONE, + )); + painter.add(egui::Shape::convex_polygon( + vec![ + pos2(photo.left() + photo.width() * 0.35, horizon), + pos2(photo.left() + photo.width() * 0.68, photo.top() + photo.height() * 0.2), + pos2(photo.right(), horizon), + ], + Color32::from_rgb(0x5a, 0x63, 0x72), + Stroke::NONE, + )); + painter.rect_filled(Rect::from_min_max(pos2(photo.left(), horizon), photo.max), 0.0, Color32::from_rgb(0x3d, 0x5a, 0x3a)); + painter.line_segment([film.left_top(), film.right_top()], Stroke::new(1.0, p.divider)); + for i in 0..5 { + let cell = Rect::from_min_size(pos2(film.left() + 4.0 + i as f32 * 17.0, film.top() + 3.0), vec2(15.0, 12.0)); + if cell.right() > film.right() - 2.0 { + break; + } + let selected = i == 1; + painter.rect_filled(cell, 0.0, if selected { p.cell_selected } else { p.cell }); + let thumb = cell.shrink(2.5); + painter.rect_filled(thumb, 0.0, Color32::from_rgb(0x6f + 8 * i as u8, 0x80, 0x8f)); + if selected { + painter.rect_stroke(thumb, 0.0, Stroke::new(1.0, p.pick), StrokeKind::Outside); + } + } +} + // ------------------------------------------------------------------------------- Open Library… /// `app.openLibrary {path?}`: close the current library and open (or create) the one at `path`, diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index d06a75594..cfdeb27cb 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -53,7 +53,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if app.integrated_titlebar { window_handle(app, ui, full, left as f32, 12.0); } - let mut sw = 640.0f32.min(full.width() - 460.0).max(200.0); + // room for the filter icon and the six icons on the right + let mut sw = 640.0f32.min(full.width() - 540.0).max(200.0); if !app.native_menu { // leave room for the in-window menus left of the (centred) search field let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui) + 24.0; @@ -189,6 +190,26 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } x -= 40.0; } + // Appearance Mode: a click steps Auto → Light → Dark → Auto; the icon shows the mode + let (icon, mode) = appearance_icon(app.ui.settings.appearance_mode); + let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); + let tip = format!("{}: {}", crate::i18n::tr("Appearance Mode"), crate::i18n::tr(mode)); + let resp = ui.interact(r, egui::Id::new(("top", "appearance")), Sense::click()).on_hover_text(tip); + register(ui.ctx(), "icon:appearance", r); + paint(ui.painter(), r.shrink(5.0), icon, if resp.hovered() { t.text } else { t.icon }); + if resp.clicked() { + let _ = app.run("view.appearance", json!({})); + } let _ = Align2::CENTER_CENTER; }); } + +/// The appearance button's icon and the mode's name for its tooltip. +pub fn appearance_icon(mode: crate::state::AppearanceMode) -> (Icon, &'static str) { + use crate::state::AppearanceMode; + match mode { + AppearanceMode::Auto => (Icon::Monitor, "Sync with System"), + AppearanceMode::Light => (Icon::Sun, "Light Mode"), + AppearanceMode::Dark => (Icon::Moon, "Dark Mode"), + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 83b3df292..c610ddd5f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -127,6 +127,67 @@ impl InfoOverlay { } } +/// Whether the interface uses the dark or the light theme (Settings ▸ Interface ▸ Appearance). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AppearanceMode { + /// Follow the operating system's dark/light setting (dark when it gives no answer). + Auto, + /// The dark theme, whatever the system says (the default: LightCraft's look so far). + #[default] + Dark, + Light, +} + +impl AppearanceMode { + /// The header button's order: Auto, Light, Dark, then Auto again. + pub fn next(self) -> AppearanceMode { + match self { + AppearanceMode::Auto => AppearanceMode::Light, + AppearanceMode::Light => AppearanceMode::Dark, + AppearanceMode::Dark => AppearanceMode::Auto, + } + } +} + +/// The theme the dark appearance uses. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DarkTheme { + #[default] + Charcoal, + Midnight, +} + +/// The theme the light appearance uses. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LightTheme { + #[default] + Silver, + Paper, +} + +impl DarkTheme { + pub const ALL: [DarkTheme; 2] = [DarkTheme::Charcoal, DarkTheme::Midnight]; + pub fn kind(self) -> crate::theme::ThemeKind { + match self { + DarkTheme::Charcoal => crate::theme::ThemeKind::Charcoal, + DarkTheme::Midnight => crate::theme::ThemeKind::Midnight, + } + } +} + +impl LightTheme { + pub const ALL: [LightTheme; 2] = [LightTheme::Silver, LightTheme::Paper]; + pub fn kind(self) -> crate::theme::ThemeKind { + match self { + LightTheme::Silver => crate::theme::ThemeKind::Silver, + LightTheme::Paper => crate::theme::ThemeKind::Paper, + } + } +} + /// App-level preferences (Settings dialog). They belong to the app, not a library, and are /// saved with the UI state in the app's config folder (`ui.json`, key `settings`); library /// preferences (import defaults, XMP, cache size) live in the library's `prefs.json`. @@ -156,6 +217,12 @@ pub struct AppSettings { pub grid_badges: GridBadges, /// Shortcuts the user changed (Help ▸ Keyboard Shortcuts): command id → shortcut, `""` = none. pub keymap: crate::shortcuts::Keymap, + /// Auto (follow the system), Dark or Light. + pub appearance_mode: AppearanceMode, + /// The theme of the dark appearance. + pub dark_theme: DarkTheme, + /// The theme of the light appearance. + pub light_theme: LightTheme, } impl Default for AppSettings { @@ -172,6 +239,9 @@ impl Default for AppSettings { film_badges: true, grid_badges: GridBadges::Auto, keymap: Default::default(), + appearance_mode: AppearanceMode::Dark, + dark_theme: DarkTheme::Charcoal, + light_theme: LightTheme::Silver, } } } @@ -196,6 +266,29 @@ pub const LOUPE_EDGE_STEP: usize = 64; pub const STANDARD_PREVIEW_EDGE: u32 = 2560; impl AppSettings { + /// The theme to show: the light theme in Light mode, or in Auto when `system` is light; the + /// dark theme otherwise (Auto with no answer from the system included). + pub fn theme(&self, system: Option) -> crate::theme::ThemeKind { + let light = match self.appearance_mode { + AppearanceMode::Light => true, + AppearanceMode::Dark => false, + AppearanceMode::Auto => system == Some(egui::Theme::Light), + }; + if light { self.light_theme.kind() } else { self.dark_theme.kind() } + } + + /// Pick `kind` and fix the mode to its family (the View ▸ Theme items). + pub fn select_theme(&mut self, kind: crate::theme::ThemeKind) { + use crate::theme::ThemeKind; + match kind { + ThemeKind::Charcoal => self.dark_theme = DarkTheme::Charcoal, + ThemeKind::Midnight => self.dark_theme = DarkTheme::Midnight, + ThemeKind::Silver => self.light_theme = LightTheme::Silver, + ThemeKind::Paper => self.light_theme = LightTheme::Paper, + } + self.appearance_mode = if kind.is_dark() { AppearanceMode::Dark } else { AppearanceMode::Light }; + } + /// Long edge (pixels) to render the loupe at when it is drawn `wanted_px` wide on screen: /// that size (rounded up to [`LOUPE_EDGE_STEP`], so resizing a window doesn't re-render at /// every pixel), never above the photo's own `native_long_edge`, the user's limit, the ceiling diff --git a/crates/ui-egui/src/tests_appearance.rs b/crates/ui-egui/src/tests_appearance.rs new file mode 100644 index 000000000..2f4ab65ae --- /dev/null +++ b/crates/ui-egui/src/tests_appearance.rs @@ -0,0 +1,197 @@ +//! Appearance Mode: Auto (follow the system), Dark or Light, each with its own saved theme. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::{AppSettings, AppearanceMode, DarkTheme, LightTheme, UiState}; +use crate::theme::ThemeKind; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn app() -> LightcraftApp { + LightcraftApp::new(lightcraft_engine::Session::new(), Services { png: None, ..Default::default() }) +} + +/// One frame of logic on `ctx`: where the theme is applied. +fn frame(app: &mut LightcraftApp, ctx: &egui::Context) { + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())); + output.textures_delta.clear(); +} + +#[test] +fn new_users_keep_the_dark_look_and_old_settings_load_unchanged() { + let s = AppSettings::default(); + assert_eq!((s.appearance_mode, s.dark_theme, s.light_theme), (AppearanceMode::Dark, DarkTheme::Charcoal, LightTheme::Silver)); + assert_eq!(s.theme(Some(egui::Theme::Light)), ThemeKind::Charcoal, "Auto is opt-in"); + // a ui.json saved before appearance modes existed + let old: UiState = serde_json::from_str(r#"{"settings": {"gridBadges": "always", "confirmDelete": true}}"#).unwrap(); + assert_eq!(old.settings.appearance_mode, AppearanceMode::Dark); + assert_eq!(old.settings.dark_theme, DarkTheme::Charcoal); + assert_eq!(old.settings.light_theme, LightTheme::Silver); + assert!(old.settings.confirm_delete); + // and the Charcoal tokens are the ones LightCraft always had + assert_eq!(crate::theme::Tokens::for_kind(ThemeKind::Charcoal).chrome, crate::theme::Tokens::default().chrome); +} + +#[test] +fn choices_validate_and_survive_a_restart() { + let mut a = app(); + let ctx = egui::Context::default(); + let set = |a: &mut LightcraftApp, p: serde_json::Value| { + let (req, _) = crate::control::ControlRequest::new("ui.set", p); + match crate::control::handle(a, &ctx, &req) { + crate::control::Outcome::Done(reply) => reply, + _ => panic!("expected a reply"), + } + }; + let ok = set(&mut a, json!({"settings": {"appearanceMode": "auto", "darkTheme": "midnight", "lightTheme": "paper"}})); + assert_eq!(ok["ok"], true, "{ok}"); + assert_eq!(a.ui.settings.appearance_mode, AppearanceMode::Auto); + for bad in [json!({"appearanceMode": "sepia"}), json!({"darkTheme": "paper"}), json!({"lightTheme": "midnight"}), json!({"appearanceMode": 3})] { + let reply = set(&mut a, json!({ "settings": bad })); + assert_eq!(reply["ok"], false, "{bad} accepted"); + } + assert_eq!((a.ui.settings.dark_theme, a.ui.settings.light_theme), (DarkTheme::Midnight, LightTheme::Paper), "a rejected value changes nothing"); + let saved = serde_json::to_string(&a.ui).unwrap(); + assert!(saved.contains(r#""appearanceMode":"auto""#) && saved.contains(r#""darkTheme":"midnight""#) && saved.contains(r#""lightTheme":"paper""#)); + let restarted: UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(restarted.settings, a.ui.settings); +} + +#[test] +fn the_button_cycles_auto_light_dark_without_losing_theme_choices() { + let mut a = app(); + let ctx = egui::Context::default(); + a.services.system_theme = Some(Box::new(|_| Some(egui::Theme::Dark))); + a.run("view.theme", json!({"theme": "paper"})).unwrap(); + a.run("view.theme", json!({"theme": "midnight"})).unwrap(); + assert_eq!(a.ui.settings.appearance_mode, AppearanceMode::Dark, "a dark theme fixes the mode to Dark"); + for (mode, shown) in + [(AppearanceMode::Auto, ThemeKind::Midnight), (AppearanceMode::Light, ThemeKind::Paper), (AppearanceMode::Dark, ThemeKind::Midnight)] + { + let r = a.run("view.appearance", json!({})).unwrap(); + assert_eq!(a.ui.settings.appearance_mode, mode); + assert_eq!(r["darkTheme"], "midnight"); + assert_eq!(r["lightTheme"], "paper"); + frame(&mut a, &ctx); + assert_eq!(crate::theme::current(&ctx), Some(shown)); + assert_eq!(crate::theme::Tokens::get(&ctx).chrome, crate::theme::Tokens::for_kind(shown).chrome); + let icon = match mode { + AppearanceMode::Auto => crate::icons::Icon::Monitor, + AppearanceMode::Light => crate::icons::Icon::Sun, + AppearanceMode::Dark => crate::icons::Icon::Moon, + }; + assert_eq!(crate::panels::topbar::appearance_icon(mode).0, icon); + } + // a light theme from the menu fixes Light + a.run("view.theme", json!({"theme": "silver"})).unwrap(); + assert_eq!( + (a.ui.settings.appearance_mode, a.ui.settings.light_theme, a.ui.settings.dark_theme), + (AppearanceMode::Light, LightTheme::Silver, DarkTheme::Midnight) + ); + // bad parameters are errors, never a panic, and change nothing + for p in [json!({}), json!({"theme": 4}), json!({"theme": "neon"})] { + assert!(a.run("view.theme", p.clone()).is_err(), "{p}"); + } + for p in [json!({"mode": "sepia"}), json!({"mode": 1}), json!({"mode": []})] { + assert!(a.run("view.appearance", p.clone()).is_err(), "{p}"); + } + assert_eq!(a.ui.settings.appearance_mode, AppearanceMode::Light); + a.run("view.appearance", json!({"mode": "auto"})).unwrap(); + assert_eq!(a.ui.settings.appearance_mode, AppearanceMode::Auto); +} + +#[test] +fn auto_follows_the_system_and_falls_back_to_dark() { + use std::sync::{Arc, Mutex}; + let system = Arc::new(Mutex::new(Some(egui::Theme::Light))); + let mut a = app(); + let reported = Arc::clone(&system); + a.services.system_theme = Some(Box::new(move |_| *reported.lock().unwrap())); + a.run("view.appearance", json!({"mode": "auto"})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut a, &ctx); + assert_eq!(crate::theme::current(&ctx), Some(ThemeKind::Silver)); + assert_eq!(ctx.theme(), egui::Theme::Light); + *system.lock().unwrap() = Some(egui::Theme::Dark); + frame(&mut a, &ctx); + assert_eq!(crate::theme::current(&ctx), Some(ThemeKind::Charcoal)); + assert_eq!(ctx.theme(), egui::Theme::Dark); + // no answer from the host or from egui: dark + *system.lock().unwrap() = None; + frame(&mut a, &ctx); + assert_eq!(crate::theme::current(&ctx), Some(ThemeKind::Charcoal)); + // without a host service, egui's report is used + let mut plain = app(); + plain.ui.settings.appearance_mode = AppearanceMode::Auto; + let ctx = egui::Context::default(); + let input = egui::RawInput { system_theme: Some(egui::Theme::Light), ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| { + assert_eq!(plain.system_theme(ui.ctx()), Some(egui::Theme::Light)); + plain.logic(ui.ctx()); + }); + output.textures_delta.clear(); + assert_eq!(crate::theme::current(&ctx), Some(ThemeKind::Silver)); +} + +#[test] +fn view_menu_lists_modes_and_themes_with_checks() { + let mut a = app(); + a.run("view.theme", json!({"theme": "paper"})).unwrap(); + let menus = crate::menubar::menu_bar(&a); + let text = serde_json::to_string(&menus).unwrap(); + for label in ["Appearance", "Sync with System", "Light Mode", "Dark Mode", "Next Appearance Mode", "Charcoal", "Midnight", "Silver", "Paper"] { + assert!(text.contains(&format!("\"{label}\"")), "{label} missing from the menus"); + } + assert!(text.contains(r#""params":{"theme":"paper"}"#)); +} + +#[test] +fn the_top_bar_button_and_settings_cards_drive_the_mode() { + let mut h = Headless::new(app(), [1400.0, 900.0], 1.0); + h.settle(Duration::from_secs(60)); + assert_eq!(h.request("ui.clickWidget", json!({"id": "icon:appearance"}), T)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.appearance_mode, AppearanceMode::Auto); + assert_eq!(h.request("ui.clickWidget", json!({"id": "icon:appearance"}), T)["ok"], true); + h.step(); + h.step(); + assert_eq!(h.app.ui.settings.appearance_mode, AppearanceMode::Light); + assert_eq!(crate::theme::current(&h.view.ctx), Some(ThemeKind::Silver)); + // Settings ▸ Interface: the mode buttons and the theme radios + h.app.ui.dialog = Some(crate::state::Dialog::Settings { tab: "interface".into() }); + h.step(); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "radio:theme-paper"}), T)["ok"], true); + h.step(); + h.step(); + assert_eq!(h.app.ui.settings.light_theme, LightTheme::Paper); + assert_eq!(crate::theme::current(&h.view.ctx), Some(ThemeKind::Paper)); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:settingsAppearance-1"}), T)["ok"], true); + h.step(); + h.step(); + assert_eq!(h.app.ui.settings.appearance_mode, AppearanceMode::Dark); + assert_eq!(crate::theme::current(&h.view.ctx), Some(ThemeKind::Charcoal)); +} + +#[test] +fn interface_settings_fit_small_windows_and_stack_theme_choices() { + // Desktop windows have a 900×560 minimum; also stress a smaller headless viewport. + for size in [[900.0, 560.0], [640.0, 480.0]] { + let mut h = Headless::new(app(), size, 1.0); + h.app.ui.dialog = Some(crate::state::Dialog::Settings { tab: "interface".into() }); + for _ in 0..4 { + h.step(); + } + let window = h.app.widgets.iter().find(|(id, _)| id == "dialog:window").unwrap().1; + assert!(window.width() <= size[0], "{size:?}: {window:?}"); + assert!(window.height() <= size[1], "{size:?}: {window:?}"); + let silver = h.app.widgets.iter().find(|(id, _)| id == "radio:theme-silver").unwrap().1; + let mode = h.app.widgets.iter().find(|(id, _)| id == "button:settingsAppearance-0").unwrap().1; + assert!(silver.top() > mode.bottom() + 80.0, "theme choices must sit below their preview"); + assert!(silver.right() <= window.right(), "theme choices stay inside the modal"); + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index b04ef1be2..a3a2518f3 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -1,5 +1,9 @@ //! Design tokens (colours, sizes, fonts). Values measured from black-box observation of the //! reference app's dark theme (see plan/lightroom/10-observed-ui.md); everything is our own code. +//! +//! Themes come in two families, picked by the Appearance Mode setting (Settings ▸ Interface): +//! - dark: **Charcoal** (the default, the observed dark look) and **Midnight** (near black); +//! - light: **Silver** (light grey chrome) and **Paper** (near white). use std::sync::Arc; @@ -75,6 +79,44 @@ pub struct Tokens { pub film_h: f32, } +/// An interface theme. Each belongs to the dark or the light family. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum ThemeKind { + #[default] + Charcoal, + Midnight, + Silver, + Paper, +} + +impl ThemeKind { + pub const ALL: [ThemeKind; 4] = [ThemeKind::Charcoal, ThemeKind::Midnight, ThemeKind::Silver, ThemeKind::Paper]; + + pub fn is_dark(self) -> bool { + matches!(self, ThemeKind::Charcoal | ThemeKind::Midnight) + } + + /// The theme's id (settings value and command suffix). + pub fn id(self) -> &'static str { + match self { + ThemeKind::Charcoal => "charcoal", + ThemeKind::Midnight => "midnight", + ThemeKind::Silver => "silver", + ThemeKind::Paper => "paper", + } + } + + /// The English display name. + pub fn label(self) -> &'static str { + match self { + ThemeKind::Charcoal => "Charcoal", + ThemeKind::Midnight => "Midnight", + ThemeKind::Silver => "Silver", + ThemeKind::Paper => "Paper", + } + } +} + impl Default for Tokens { fn default() -> Self { Tokens { @@ -118,6 +160,92 @@ impl Default for Tokens { } impl Tokens { + /// The tokens of `kind`. Metrics are shared; only colours change. + pub fn for_kind(kind: ThemeKind) -> Tokens { + let rgb = |v: u32| Color32::from_rgb((v >> 16) as u8, (v >> 8) as u8, v as u8); + let base = Tokens::default(); + match kind { + ThemeKind::Charcoal => base, + ThemeKind::Midnight => Tokens { + chrome: rgb(0x1e1e1e), + canvas: rgb(0x121212), + grid_bg: rgb(0x080808), + cell: rgb(0x121212), + cell_selected: rgb(0x262626), + divider: rgb(0x0e0e0e), + inset: rgb(0x181818), + field: rgb(0x161616), + field_border: rgb(0x333333), + button: rgb(0x191919), + button_border: rgb(0x404040), + hover: rgb(0x2e2e2e), + pressed: rgb(0x343434), + tool_active: rgb(0x343434), + ..base + }, + ThemeKind::Silver => Tokens { + chrome: rgb(0xe6e6e6), + canvas: rgb(0xcfcfcf), + grid_bg: rgb(0xd9d9d9), + cell: rgb(0xcbcbcb), + cell_selected: rgb(0xb4b4b4), + divider: rgb(0xc4c4c4), + inset: rgb(0xdcdcdc), + field: rgb(0xf6f6f6), + field_border: rgb(0xb8b8b8), + button: rgb(0xf2f2f2), + button_border: rgb(0xb0b0b0), + hover: rgb(0xd2d2d2), + pressed: rgb(0xc4c4c4), + tool_active: rgb(0xcacaca), + text: rgb(0x1c1c1c), + text_label: rgb(0x383838), + text_dim: rgb(0x6a6a6a), + text_disabled: rgb(0xa2a2a2), + icon: rgb(0x555555), + track: rgb(0xa8a8a8), + thumb: rgb(0x5e5e5e), + thumb_hover: rgb(0x262626), + accent: rgb(0x0a62d0), + star: rgb(0x3a3a3a), + pick: rgb(0x2a2a2a), + reject: rgb(0xc83434), + caution: rgb(0xb0761a), + ..base + }, + ThemeKind::Paper => Tokens { + chrome: rgb(0xfafafa), + canvas: rgb(0xececec), + grid_bg: rgb(0xf2f2f2), + cell: rgb(0xe8e8e8), + cell_selected: rgb(0xd2d2d2), + divider: rgb(0xe0e0e0), + inset: rgb(0xf0f0f0), + field: rgb(0xffffff), + field_border: rgb(0xcccccc), + button: rgb(0xffffff), + button_border: rgb(0xc6c6c6), + hover: rgb(0xebebeb), + pressed: rgb(0xdedede), + tool_active: rgb(0xe2e2e2), + text: rgb(0x161616), + text_label: rgb(0x343434), + text_dim: rgb(0x707070), + text_disabled: rgb(0xababab), + icon: rgb(0x585858), + track: rgb(0xbdbdbd), + thumb: rgb(0x606060), + thumb_hover: rgb(0x202020), + accent: rgb(0x0a62d0), + star: rgb(0x3a3a3a), + pick: rgb(0x2a2a2a), + reject: rgb(0xc83434), + caution: rgb(0xb0761a), + ..base + }, + } + } + pub fn get(ctx: &egui::Context) -> Tokens { ctx.data(|d| d.get_temp::(egui::Id::NULL)).unwrap_or_default() } @@ -215,10 +343,30 @@ fn craft_font_name(f: &lightcraft_engine::CraftFont) -> String { format!("craft-fonts {} {}", f.family, f.style) } +fn theme_id() -> egui::Id { + egui::Id::new("lightcraft-theme") +} + +/// The theme `ctx` is styled with (none before the first [`apply_kind`]). +pub fn current(ctx: &egui::Context) -> Option { + ctx.data(|d| d.get_temp::(theme_id())) +} + +/// Style `ctx` with the default theme. pub fn apply(ctx: &egui::Context) { - let t = Tokens::default(); - ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t)); - let mut v = Visuals::dark(); + apply_kind(ctx, ThemeKind::default()); +} + +/// Style `ctx` with `kind`. The visuals go to both of egui's styles, so egui following the +/// system's dark/light setting on its own never swaps in its stock look. +pub fn apply_kind(ctx: &egui::Context, kind: ThemeKind) { + let t = Tokens::for_kind(kind); + ctx.data_mut(|d| { + d.insert_temp(egui::Id::NULL, t); + d.insert_temp(theme_id(), kind); + }); + let dark = kind.is_dark(); + let mut v = if dark { Visuals::dark() } else { Visuals::light() }; v.panel_fill = t.chrome; v.window_fill = t.chrome; v.extreme_bg_color = t.field; @@ -229,8 +377,9 @@ pub fn apply(ctx: &egui::Context) { v.selection.bg_fill = t.accent.gamma_multiply(0.6); v.selection.stroke = Stroke::new(1.0, t.accent); v.override_text_color = Some(t.text_label); - v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(140) }; - v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 30, spread: 0, color: Color32::from_black_alpha(160) }; + let (popup, window) = if dark { (140, 160) } else { (50, 60) }; + v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(popup) }; + v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 30, spread: 0, color: Color32::from_black_alpha(window) }; let w = &mut v.widgets; for (wv, fill) in [ (&mut w.noninteractive, t.chrome), @@ -246,8 +395,10 @@ pub fn apply(ctx: &egui::Context) { } w.noninteractive.bg_stroke = Stroke::new(1.0, t.divider); w.inactive.bg_stroke = Stroke::new(1.0, t.button_border); - ctx.set_visuals(v); - ctx.global_style_mut(|s| { + ctx.set_visuals_of(egui::Theme::Dark, v.clone()); + ctx.set_visuals_of(egui::Theme::Light, v); + ctx.set_theme(if dark { egui::Theme::Dark } else { egui::Theme::Light }); + ctx.all_styles_mut(|s| { s.spacing.item_spacing = egui::vec2(6.0, 4.0); s.spacing.button_padding = egui::vec2(8.0, 3.0); s.spacing.interact_size.y = 22.0; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4247b559e..fe95e0e32 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -55,6 +55,13 @@ scrolling pans in both axes and respects the operating system's scrolling direct These gestures work in Detail (including editing tools and full-screen preview), Compare and Reference views, and only apply over their image areas. Panning stops at the image edges. +The interface theme is `view.appearance {mode?: "auto" | "light" | "dark"}` (no mode: the next one, +Auto → Light → Dark → Auto, like the top bar's `icon:appearance` button) and `view.theme {theme: +"charcoal" | "midnight" | "silver" | "paper"}`, which picks a theme and fixes the mode to its family. +Both reply `{mode, darkTheme, lightTheme}`; the same values are `settings.appearanceMode`, +`settings.darkTheme` and `settings.lightTheme` in `ui.set`. Auto follows the system (on Linux the +XDG desktop portal, watched for changes rather than polled) and is dark when the system says nothing. + ### When the library can't be saved With a persistent library, every command that changes something is written to the catalog journal (fsynced) before diff --git a/docs/parity.md b/docs/parity.md index 4c27755b5..9217f0ff3 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -486,7 +486,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-PREF-GENERAL | General settings | P0 | ✅ | `cmd:app.settings`, `cmd:app.openLibrary`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/notices.rs` | General / Import / Performance / Interface tabs; app settings in ui.json (written atomically, saved when the library changes and every few seconds), library settings in prefs.json; a damaged settings file is kept as `.corrupt-