From 77fa159f492dd1ec153bbfb717ec4a42dc5bacaf Mon Sep 17 00:00:00 2001 From: Luniiya <86487331+luniiya@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:14:55 +0200 Subject: [PATCH] ui: standardise system appearance modes and theme previews --- Cargo.lock | 1 + apps/filmcraft/Cargo.toml | 5 + apps/filmcraft/src/appearance.rs | 188 +++++++++++++++++++ apps/filmcraft/src/main.rs | 4 + crates/engine/src/autosave.rs | 5 + crates/engine/src/settings.rs | 73 +++++++- crates/engine/src/settings_tests.rs | 56 ++++++ crates/ui-egui/src/header.rs | 10 ++ crates/ui-egui/src/i18n/es.tsv | 8 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/icons.rs | 32 ++++ crates/ui-egui/src/lib.rs | 30 +++- crates/ui-egui/src/menus.rs | 5 + crates/ui-egui/src/panels/settings.rs | 248 ++++++++++++++++++++++++++ crates/ui-egui/src/theme.rs | 6 +- crates/ui-egui/tests/settings_ui.rs | 26 ++- 17 files changed, 697 insertions(+), 6 deletions(-) create mode 100644 apps/filmcraft/src/appearance.rs diff --git a/Cargo.lock b/Cargo.lock index 2512f9b2..a488ba53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1684,6 +1684,7 @@ dependencies = [ "sys-locale", "winit", "winresource", + "zbus", ] [[package]] diff --git a/apps/filmcraft/Cargo.toml b/apps/filmcraft/Cargo.toml index 9e43d1dc..a6a979de 100644 --- a/apps/filmcraft/Cargo.toml +++ b/apps/filmcraft/Cargo.toml @@ -36,6 +36,11 @@ objc2 = "0.6" # Same winit as eframe: macOS event-loop options (no app activation when driven by an agent). winit = { version = "0.30", default-features = false } +[target.'cfg(target_os = "linux")'.dependencies] +# Settings ▸ Appearance ▸ Sync with system: the XDG desktop portal's colour scheme. The zbus the +# portal file dialogs (rfd) and accesskit already use. +zbus = "5.19" + # Icon + VERSIONINFO for filmcraft.exe (build.rs); only needed when building on Windows. [target.'cfg(windows)'.build-dependencies] winresource = "0.1" diff --git a/apps/filmcraft/src/appearance.rs b/apps/filmcraft/src/appearance.rs new file mode 100644 index 00000000..c2558d74 --- /dev/null +++ b/apps/filmcraft/src/appearance.rs @@ -0,0 +1,188 @@ +//! The system's light or dark appearance for Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with +//! system, on Linux desktops where winit reports none (Wayland compositors send no theme). +//! +//! The XDG desktop portal is read once (`org.freedesktop.portal.Settings.Read` of +//! `org.freedesktop.appearance` / `color-scheme`), then its `SettingChanged` signal is followed on a +//! worker thread, which wakes the UI only when the value changes. There is no polling. Without a +//! portal answer, `gsettings` runs once at start-up, by absolute path. Elsewhere the host returns no +//! service and the UI uses what egui reports. + +#[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 reply. `Read` returns 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: runs once, by absolute path. +#[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, + } +} + +/// Publish initial and subsequent answers alike: the initial Read may finish after the UI's +/// bounded startup wait, so it must also wake an already-created context. +#[cfg(target_os = "linux")] +fn publish(value: &std::sync::atomic::AtomicU8, code: u8, repaint: impl FnOnce()) { + if value.swap(code, std::sync::atomic::Ordering::Relaxed) != code { + repaint(); + } +} + +/// The host hook (`HostHooks::system_theme`), or `None` where egui's own report is used. +pub fn service() -> Option { + #[cfg(target_os = "linux")] + { + use std::sync::atomic::{AtomicU8, Ordering}; + use std::sync::{Arc, OnceLock}; + 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(); + let worker = move || { + let proxy = portal_proxy(); + // Subscribe before Read so a change during startup cannot be missed. + let signals = proxy.as_ref().and_then(|p| p.receive_signal("SettingChanged").ok()); + let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme); + publish(&worker_value, code_of(initial), || { + if let Some(ctx) = worker_wake.get() { + ctx.request_repaint(); + } + }); + let _ = ready.send(()); + let Some(signals) = signals else { return }; + // Sleeps in the signal iterator: 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, code, || { + if let Some(ctx) = worker_wake.get() { + ctx.request_repaint(); + } + }); + } + }; + let spawned = std::thread::Builder::new().name("appearance-portal".into()).spawn(move || { + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(worker)).is_err() { + log::warn!("the system appearance watcher stopped; Sync with system keeps the last value"); + } + }); + if spawned.is_err() { + return None; + } + // The first reading, so the first frame already shows the right theme (bounded wait). + let _ = wait.recv_timeout(std::time::Duration::from_millis(250)); + Some(Box::new(move |ctx: &egui::Context| { + 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 initial_and_signal_answers_repaint_only_on_changes() { + use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering}; + let repaints = AtomicUsize::new(0); + let value = AtomicU8::new(0); + let repaint = || { + repaints.fetch_add(1, Ordering::Relaxed); + }; + // A late initial Read takes the same publication path as later signals. + super::publish(&value, 2, repaint); + assert_eq!(repaints.load(Ordering::Relaxed), 1); + super::publish(&value, 2, repaint); + assert_eq!(repaints.load(Ordering::Relaxed), 1); + super::publish(&value, 1, repaint); + assert_eq!(repaints.load(Ordering::Relaxed), 2); + assert_eq!(value.load(Ordering::Relaxed), 1); + } + + #[cfg(target_os = "linux")] + #[test] + fn portal_and_gsettings_values_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); + assert_eq!((super::code_of(None), super::code_of(Some(egui::Theme::Dark)), super::code_of(Some(egui::Theme::Light))), (0, 1, 2)); + } +} diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index 18fb4d7b..ff521eff 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -23,6 +23,7 @@ #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] mod app_nap; +mod appearance; mod args; mod audio; mod audio_in; @@ -229,6 +230,9 @@ fn main() -> eframe::Result { app.hooks.open_path = Some(Box::new(open_path)); // Settings ▸ General ▸ Interface Language ▸ System Language (#218). app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect())); + // Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system on Linux desktops whose + // compositor reports no theme to winit (no polling: see appearance.rs). + app.hooks.system_theme = appearance::service(); app.hooks.raise_without_focus = Some(Box::new(|| { window_raise::raise_without_focus(); })); diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 73338487..0da3f3aa 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -455,6 +455,11 @@ impl Preferences { settings::sanitize(&mut v, &Self::default().to_value()); let mut p: Preferences = serde_json::from_value(v).map_err(|e| format!("`{key}`: {e}"))?; p.clamp(); + // Old clients that set the single Color Theme still pick the theme they name. + if key == "appearance.colorTheme" { + let theme = p.appearance.color_theme.clone(); + p.appearance.select_theme(&theme); + } *self = p; Ok(()) } diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 1e035035..23f91e29 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -88,8 +88,15 @@ impl Default for GeneralPrefs { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct AppearancePrefs { - /// "Color Theme": `darkest` (default) | `dark` | `light`. + /// Legacy single "Color Theme": `darkest` | `dark` | `light`. Setting it (old settings files, + /// automation, View ▸ Appearance) selects that theme and fixes the mode to its family. pub color_theme: String, + /// "Appearance Mode": `auto` (follow the operating system) | `dark` (default) | `light`. + pub appearance_mode: String, + /// The theme shown in dark mode: `darkest` (default) | `dark`. + pub dark_theme: String, + /// The theme shown in light mode: `light`. + pub light_theme: String, /// "Accessible color contrast": brighter secondary text and borders. pub accessible_contrast: bool, /// Highlight (accent) colour of selections, focus and primary buttons (`#rrggbb`). @@ -98,7 +105,48 @@ pub struct AppearancePrefs { impl Default for AppearancePrefs { fn default() -> Self { - Self { color_theme: "darkest".into(), accessible_contrast: false, highlight_color: DEFAULT_HIGHLIGHT.into() } + Self { + color_theme: "darkest".into(), + appearance_mode: "dark".into(), + dark_theme: "darkest".into(), + light_theme: "light".into(), + accessible_contrast: false, + highlight_color: DEFAULT_HIGHLIGHT.into(), + } + } +} + +impl AppearancePrefs { + /// The theme to show (`darkest` | `dark` | `light`): the light or dark theme of the mode. Auto + /// follows `system_light` (the operating system's appearance) and is dark without an answer. + pub fn shown_theme(&self, system_light: Option) -> &str { + let light = match self.appearance_mode.as_str() { + "light" => true, + "auto" => system_light == Some(true), + _ => false, + }; + if light { &self.light_theme } else { &self.dark_theme } + } + + /// Select `theme` (a Color Theme value) as the theme of its family and fix the mode to that + /// family. Unknown names are ignored. + pub fn select_theme(&mut self, theme: &str) { + if LIGHT_THEMES.iter().any(|(v, _)| *v == theme) { + self.light_theme = theme.into(); + self.appearance_mode = "light".into(); + } else if DARK_THEMES.iter().any(|(v, _)| *v == theme) { + self.dark_theme = theme.into(); + self.appearance_mode = "dark".into(); + } + } + + /// The mode after this one in the header button's cycle: Auto, Light, Dark, Auto… + pub fn next_mode(&self) -> &'static str { + match self.appearance_mode.as_str() { + "auto" => "light", + "light" => "dark", + _ => "auto", + } } } @@ -607,6 +655,9 @@ const OPENING: &[(&str, &str)] = &[("showOpenDialog", "Show Open Dialog"), ("sho const BIN_OPEN: &[(&str, &str)] = &[("openInPlace", "Open in place"), ("openNewTab", "Open new tab"), ("openNewWindow", "Open in new window")]; const PROJECT_OPEN: &[(&str, &str)] = &[("openNewTab", "Open new tab"), ("openNewWindow", "Open in new window")]; const THEMES: &[(&str, &str)] = &[("darkest", "Darkest"), ("dark", "Dark"), ("light", "Light")]; +pub const APPEARANCE_MODES: &[(&str, &str)] = &[("auto", "Sync with system"), ("dark", "Dark"), ("light", "Light")]; +pub const DARK_THEMES: &[(&str, &str)] = &[("darkest", "Darkest"), ("dark", "Dark")]; +pub const LIGHT_THEMES: &[(&str, &str)] = &[("light", "Light")]; const MIXDOWN: &[(&str, &str)] = &[("front", "Front Only"), ("frontRear", "Front + Rear"), ("frontLfe", "Front + LFE"), ("frontRearLfe", "Front + Rear + LFE")]; const AUDITION: &[(&str, &str)] = &[("scratch", "Scratch disk location for Captured Audio"), ("nextToMedia", "Next to original media files")]; const BUFFERS: &[(&str, &str)] = &[("64", "64"), ("128", "128"), ("256", "256"), ("512", "512"), ("1024", "1024"), ("2048", "2048"), ("4096", "4096")]; @@ -714,6 +765,12 @@ static CATEGORIES: &[Category] = &[ id: "appearance", title: "Appearance", rows: &[ + // Drawn by the dialog as the Appearance Mode selector above the light and dark theme + // cards; the fields below keep their keys, validation and `prefs.schema` entries. + Row::Custom("appearanceModes"), + f("appearance.appearanceMode", "Appearance Mode", Kind::Choice(APPEARANCE_MODES), true), + f("appearance.lightTheme", "Light Theme", Kind::Choice(LIGHT_THEMES), true), + f("appearance.darkTheme", "Dark Theme", Kind::Choice(DARK_THEMES), true), f("appearance.colorTheme", "Color Theme", Kind::Choice(THEMES), true), b("appearance.accessibleContrast", "Accessible color contrast", true), f("appearance.highlightColor", "Highlight Color", Kind::Color, true), @@ -1133,6 +1190,18 @@ pub fn migrate(v: &mut Value) { // pre/postroll; v2 stores them as reals, which serde reads either way. m.insert("version".into(), json!(2)); } + // Files from before appearance modes had one Color Theme: keep showing it, as a fixed Dark or + // Light mode, instead of switching established users to another theme or to Auto. + if let Some(a) = m.get_mut("appearance").and_then(Value::as_object_mut) + && !a.contains_key("appearanceMode") + && let Some(theme) = a.get("colorTheme").and_then(Value::as_str).map(str::to_string) + { + let mut p = AppearancePrefs::default(); + p.select_theme(&theme); + a.insert("appearanceMode".into(), json!(p.appearance_mode)); + a.insert("darkTheme".into(), json!(p.dark_theme)); + a.insert("lightTheme".into(), json!(p.light_theme)); + } } impl crate::autosave::Preferences { diff --git a/crates/engine/src/settings_tests.rs b/crates/engine/src/settings_tests.rs index d706e4a1..67d124de 100644 --- a/crates/engine/src/settings_tests.rs +++ b/crates/engine/src/settings_tests.rs @@ -551,3 +551,59 @@ fn hardware_decoding_setting_drives_the_decoder_switch() { assert!(filmcraft_codecs::hw::hardware_decoding()); assert!(settings::field("playback.hardwareDecoding").is_some_and(|f| f.wired && matches!(f.kind, Kind::Choice(_)))); } + +/// New users keep the dark Darkest theme (following the system is opt-in); a file saved before +/// appearance modes keeps showing its Color Theme as a fixed Dark or Light mode. +#[test] +fn appearance_defaults_and_legacy_theme_migrate() { + let a = Preferences::default().appearance; + assert_eq!((a.appearance_mode.as_str(), a.dark_theme.as_str(), a.light_theme.as_str()), ("dark", "darkest", "light")); + assert_eq!(a.shown_theme(Some(true)), "darkest", "Dark mode ignores a light system"); + let dir = tmp_dir("prefs-appearance-migrate"); + let path = dir.join("preferences.json"); + for (theme, mode, dark, light) in [("darkest", "dark", "darkest", "light"), ("dark", "dark", "dark", "light"), ("light", "light", "darkest", "light")] { + std::fs::write(&path, json!({"version": 2, "appearance": {"colorTheme": theme}}).to_string()).unwrap(); + let a = Preferences::load(&path).appearance; + assert_eq!((a.appearance_mode.as_str(), a.dark_theme.as_str(), a.light_theme.as_str()), (mode, dark, light), "{theme}"); + assert_eq!(a.shown_theme(None), theme); + } + // a v1 file without an appearance section, and a hostile theme, get the defaults + std::fs::write(&path, r#"{"autoSave":{"enabled":true}}"#).unwrap(); + assert_eq!(Preferences::load(&path).appearance, settings::AppearancePrefs::default()); + std::fs::write(&path, r#"{"appearance":{"colorTheme":"neon","appearanceMode":7,"darkTheme":"light"}}"#).unwrap(); + assert_eq!(Preferences::load(&path).appearance, settings::AppearancePrefs::default()); + // a file with a mode keeps it + std::fs::write(&path, r#"{"appearance":{"colorTheme":"darkest","appearanceMode":"auto","lightTheme":"light","darkTheme":"dark"}}"#).unwrap(); + let a = Preferences::load(&path).appearance; + assert_eq!((a.appearance_mode.as_str(), a.shown_theme(Some(true)), a.shown_theme(Some(false)), a.shown_theme(None)), ("auto", "light", "dark", "dark")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn appearance_choices_validate_and_legacy_theme_still_selects() { + let mut s = Session::default(); + s.execute("prefs.set", json!({"values": {"appearance.appearanceMode": "auto", "appearance.darkTheme": "dark"}})).unwrap(); + assert_eq!((s.prefs.appearance.appearance_mode.as_str(), s.prefs.appearance.dark_theme.as_str()), ("auto", "dark")); + for (key, value) in [ + ("appearance.darkTheme", json!("light")), + ("appearance.lightTheme", json!("darkest")), + ("appearance.appearanceMode", json!("system")), + ("appearance.appearanceMode", json!(1)), + ] { + assert!(s.execute("prefs.set", json!({"key": key, "value": value})).is_err(), "{key} {value}"); + } + // the single Color Theme of older clients selects that theme and fixes the mode to its family + set(&mut s, "appearance.colorTheme", json!("light")); + assert_eq!((s.prefs.appearance.appearance_mode.as_str(), s.prefs.appearance.light_theme.as_str()), ("light", "light")); + assert_eq!(s.prefs.appearance.dark_theme, "dark", "the dark choice is kept"); + set(&mut s, "appearance.colorTheme", json!("darkest")); + assert_eq!((s.prefs.appearance.appearance_mode.as_str(), s.prefs.appearance.dark_theme.as_str()), ("dark", "darkest")); + // the header button's cycle + let mut a = settings::AppearancePrefs::default(); + let mut seen = Vec::new(); + for _ in 0..4 { + a.appearance_mode = a.next_mode().into(); + seen.push(a.appearance_mode.clone()); + } + assert_eq!(seen, ["auto", "light", "dark", "auto"]); +} diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 8615b827..e3ccfda2 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -100,6 +100,16 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("quick-export-toggled"), true)); } crate::panels::export_mode::quick_export(app, ui.ctx(), pos2(qx - 340.0, rect.max.y + 4.0)); + // Appearance Mode: click for the next one (Auto, Light, Dark); the icon shows the current one. + let (icon, mode) = match app.session.prefs.appearance.appearance_mode.as_str() { + "auto" => (Icon::Monitor, tl!("Sync with system")), + "light" => (Icon::Sun, tl!("Light")), + _ => (Icon::Moon, tl!("Dark")), + }; + let tip = format!("{}: {mode}", tl!("Appearance Mode")); + if btn(ui, icon, "appearance", &tip, app).clicked() { + crate::panels::settings::cycle_appearance(app, ui.ctx()); + } let ws_resp = btn(ui, Icon::Workspaces, "workspaces", tl!("Workspaces"), app); // workspace name (caps) let ws = crate::i18n::t(&app.ui.workspace).to_uppercase(); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a39dc82e..f6c16e98 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3480,3 +3480,11 @@ New Sequence Nueva secuencia Tracks Pistas tracks pistas + +# System appearance modes and theme cards. + Active Activo + Appearance Mode Modo de apariencia + Dark Theme Tema oscuro + Light Theme Tema claro + Next Appearance Mode Siguiente modo de apariencia + Sync with system Sincronizar con el sistema diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 12a3ea2a..7b395164 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -93,3 +93,6 @@ OK OK Type Tool 文字ツール Vertical Type Tool 縦書き文字ツール + +# System appearance mode menu command. + Next Appearance Mode 次の外観モード diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 40dd8244..5426f18e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -352,3 +352,6 @@ Zoom In Ampliar Zoom Out Reduzir Zoom to Sequence Zoom para a sequência + +# System appearance mode menu command. + Next Appearance Mode Próximo modo de aparência diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 32cfa3e4..3da7ce6a 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -114,6 +114,10 @@ pub enum Icon { Network, /// Media Browser: recent directories (a clock face). Clock, + /// Appearance Mode: Auto (a display on a stand), Light (a sun), Dark (a crescent moon). + Monitor, + Sun, + Moon, } pub struct Pen16<'a> { @@ -725,6 +729,31 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.circle(8.0, 8.0, 6.0); pen.line(&[(8.0, 4.5), (8.0, 8.0), (10.5, 9.5)]); } + Monitor => { + pen.rect(2.0, 2.5, 14.0, 11.0); + pen.line(&[(8.0, 11.0), (8.0, 13.5)]); + pen.line(&[(5.0, 13.5), (11.0, 13.5)]); + } + Sun => { + pen.circle(8.0, 8.0, 2.8); + for i in 0..8 { + let a = (i as f32 * 45.0).to_radians(); + let (c, s) = (a.cos(), a.sin()); + pen.line(&[(8.0 + 4.6 * c, 8.0 + 4.6 * s), (8.0 + 6.2 * c, 8.0 + 6.2 * s)]); + } + } + Moon => { + // The outer circle's arc through the bottom left, back along a smaller circle that + // bites the top right out of it. + let arc = |cx: f32, cy: f32, r: f32, a0: f32, a1: f32| { + (0..=12).map(move |i| { + let a = (a0 + (a1 - a0) * i as f32 / 12.0).to_radians(); + (cx + r * a.cos(), cy + r * a.sin()) + }) + }; + let pts: Vec<(f32, f32)> = arc(8.0, 8.5, 5.5, 8.56, 261.44).chain(arc(11.2, 5.3, 4.6, 209.12, 60.88).skip(1)).collect(); + pen.closed(&pts); + } } } @@ -759,6 +788,9 @@ mod tests { use egui::epaint::{ColorMode, Shape}; const ALL: &[Icon] = &[ + Monitor, + Sun, + Moon, Selection, TrackSelectFwd, TrackSelectBack, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 83d18261..8fe4fbda 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -131,8 +131,15 @@ pub struct HostHooks { /// Open a file in its default application, or (`true`) reveal it in the file manager (Edit ▸ /// Edit Original, Help ▸ Reveal Log Files). pub open_path: Option Result<(), String>>>, + /// The operating system's light or dark appearance when egui cannot report it (Linux desktops + /// whose Wayland compositor sends no theme to winit). Without it, or without an answer, Auto + /// uses `egui::Context::system_theme`. + pub system_theme: Option, } +/// Reads the system appearance (Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system). +pub type SystemThemeFn = Box Option>; + /// The command a [`HostHooks::pick_file_for_relink`] caller runs with the chosen file, for hosts /// that can only run it later. #[derive(Clone, Debug)] @@ -506,6 +513,18 @@ impl FilmcraftApp { self.ui.dark = k != ThemeKind::Light; } + /// The system appearance: the host's reading, else what the windowing toolkit reports. + pub fn system_theme(&self, ctx: &egui::Context) -> Option { + self.hooks.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme()) + } + + /// The theme Settings ▸ Appearance selects: the light or dark theme of the Appearance Mode, + /// Auto following the system (dark when it gives no answer). + pub fn appearance_kind(&self, ctx: &egui::Context) -> ThemeKind { + let light = self.system_theme(ctx).map(|t| t == egui::Theme::Light); + ThemeKind::from_pref(self.session.prefs.appearance.shown_theme(light)) + } + fn apply_tooltips(&self, ctx: &egui::Context) { // Settings ▸ General ▸ Show Tool Tips let delay = if self.session.prefs.general.show_tool_tips { 0.5 } else { 1.0e9 }; @@ -515,13 +534,22 @@ impl FilmcraftApp { /// Make the UI follow the settings after they change (theme, tooltips, frame cache budget, /// play after rendering, audio device). pub fn apply_prefs(&mut self, ctx: &egui::Context) { + // Appearance Mode ▸ Sync with system: follow the system while running. The host reports + // changes as they happen (no polling) and wakes the UI only when the value changes. + if self.applied_prefs.is_some() { + let k = self.appearance_kind(ctx); + if k != self.tokens.kind { + self.set_theme(ctx, k); + } + } if self.applied_prefs.as_ref() == Some(&self.session.prefs) { return; } let p = self.session.prefs.clone(); let prev = self.applied_prefs.take(); if prev.as_ref().is_none_or(|q| q.appearance != p.appearance || q.general.show_tool_tips != p.general.show_tool_tips) { - self.set_theme(ctx, ThemeKind::from_pref(&p.appearance.color_theme)); + let k = self.appearance_kind(ctx); + self.set_theme(ctx, k); } if prev.as_ref().is_none_or(|q| q.general.interface_language != p.general.interface_language) { let language = match i18n::Language::parse(&p.general.interface_language) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d6046935..96566a15 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -93,6 +93,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("view.theme.dark", "Darkest", ["View", "Appearance"], None), uic!("view.theme.medium", "Medium", ["View", "Appearance"], None), uic!("view.theme.light", "Light", ["View", "Appearance"], None), + uic!("view.appearanceMode.next", "Next Appearance Mode", ["View", "Appearance"], None), uic!("window.workspace.editing", "Editing", ["Window", "Workspaces"], Some("Alt+Shift+1")), uic!("window.workspace.assembly", "Assembly", ["Window", "Workspaces"], Some("Alt+Shift+2")), uic!("window.workspace.color", "Color", ["Window", "Workspaces"], Some("Alt+Shift+3")), @@ -478,6 +479,10 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: } _ => {} } + if id == "view.appearanceMode.next" { + crate::panels::settings::cycle_appearance(app, ctx); + return Ok(json!({"appearanceMode": app.session.prefs.appearance.appearance_mode})); + } if let Some(th) = id.strip_prefix("view.theme.") { let k = crate::theme::ThemeKind::from_name(th).ok_or("unknown theme")?; crate::panels::settings::set_theme(app, ctx, k); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 241c5975..41395d86 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -79,6 +79,192 @@ pub fn set_theme(app: &mut FilmcraftApp, ctx: &egui::Context, k: crate::theme::T app.set_theme(ctx, k); } +/// The header's appearance button and View ▸ Appearance ▸ Next Appearance Mode: Auto, Light, Dark, +/// then Auto again. The light and dark theme choices are kept. +pub fn cycle_appearance(app: &mut FilmcraftApp, ctx: &egui::Context) { + let next = app.session.prefs.appearance.next_mode(); + if let Err(e) = app.session.execute("prefs.set", json!({"key": "appearance.appearanceMode", "value": next})) { + app.ui.status = e.to_string(); + } + app.apply_prefs(ctx); +} + +/// Keys the Appearance page draws as the mode selector and theme cards instead of plain fields. +const APPEARANCE_KEYS: &[&str] = &["appearance.appearanceMode", "appearance.lightTheme", "appearance.darkTheme", "appearance.colorTheme"]; + +/// A small FilmCraft editor painted with `t`'s colours: the header with the Import / Edit / Export +/// tabs, the Source and Program monitors, the Project panel, the tools, the Timeline with video and +/// audio clips and the playhead, and the audio meters (the Editing workspace). +fn theme_preview(ui: &mut Ui, t: &Tokens, width: f32) { + use egui::pos2; + let (rect, _) = ui.allocate_exact_size(vec2(width, 128.0), Sense::hover()); + let p = ui.painter_at(rect); + let bar = |r: Rect, w: f32, c: Color32| p.rect_filled(Rect::from_min_size(r.min, vec2(w, 2.0)), 1.0, c); + p.rect_filled(rect, 3.0, t.app_bg); + // header: home, the three mode tabs (Edit active, underlined), the title, quick actions + let header = Rect::from_min_size(rect.min, vec2(rect.width(), 12.0)); + p.rect_filled(header, CornerRadius { nw: 3, ne: 3, sw: 0, se: 0 }, t.header_bg); + p.rect_filled(Rect::from_center_size(pos2(header.left() + 7.0, header.center().y), vec2(4.0, 4.0)), 1.0, t.text_dim); + for (i, w) in [9.0, 6.0, 9.0].into_iter().enumerate() { + let x = header.left() + 14.0 + i as f32 * 13.0; + let c = if i == 1 { t.tab_text_active } else { t.tab_text }; + bar(Rect::from_min_size(pos2(x, header.center().y - 1.0), vec2(w, 2.0)), w, c); + if i == 1 { + p.rect_filled(Rect::from_min_size(pos2(x, header.bottom() - 2.5), vec2(w, 1.0)), 0.0, c); + } + } + bar(Rect::from_min_size(pos2(header.center().x - 16.0, header.center().y - 1.0), vec2(32.0, 2.0)), 32.0, t.tab_text_active); + for i in 0..4 { + p.rect_filled(Rect::from_center_size(pos2(header.right() - 7.0 - i as f32 * 8.0, header.center().y), vec2(4.0, 4.0)), 1.0, t.icon); + } + let body = Rect::from_min_max(pos2(rect.left() + 2.0, header.bottom() + 2.0), rect.max - vec2(2.0, 2.0)); + let split_y = body.top() + body.height() * 0.5; + let panel = |r: Rect, tab_w: f32| { + p.rect_filled(r, 2.0, t.panel_bg); + bar(Rect::from_min_size(r.min + vec2(4.0, 3.5), vec2(tab_w, 2.0)), tab_w, t.tab_text_active); + Rect::from_min_max(r.min + vec2(3.0, 9.0), r.max - vec2(3.0, 3.0)) + }; + // Source monitor (left) and Program monitor (right) + let source = panel(Rect::from_min_max(body.min, pos2(body.left() + body.width() * 0.36, split_y - 1.0)), 14.0); + p.rect_filled(source, 0.0, t.monitor_bg); + p.rect_filled(source.shrink2(vec2(6.0, 8.0)), 0.0, t.field_bg); + let program = panel(Rect::from_min_max(pos2(body.left() + body.width() * 0.36 + 2.0, body.top()), pos2(body.right(), split_y - 1.0)), 16.0); + p.rect_filled(program, 0.0, t.monitor_bg); + let frame = Rect::from_center_size(program.center() - vec2(0.0, 3.0), vec2((program.height() - 9.0) * 16.0 / 9.0, program.height() - 9.0)); + p.rect_filled(frame, 0.0, Color32::from_rgb(0x2c, 0x4a, 0x2e)); + p.rect_filled(Rect::from_min_max(pos2(frame.left(), frame.bottom() - frame.height() * 0.3), frame.max), 0.0, Color32::from_rgb(0x4a, 0x3c, 0x30)); + p.circle_filled(pos2(frame.left() + frame.width() * 0.62, frame.center().y + 2.0), frame.height() * 0.26, Color32::from_rgb(0xc8, 0x2a, 0x26)); + bar(Rect::from_min_size(pos2(program.left() + 2.0, program.bottom() - 3.0), vec2(14.0, 2.0)), 14.0, t.timecode); + // Project panel, tools, Timeline, meters + let low = split_y + 1.0; + let project = panel(Rect::from_min_max(pos2(body.left(), low), pos2(body.left() + body.width() * 0.24, body.bottom())), 12.0); + for row in 0..2 { + for col in 0..2 { + let cell = + Rect::from_min_size(project.min + vec2(col as f32 * (project.width() / 2.0), 2.0 + row as f32 * 18.0), vec2(project.width() / 2.0 - 3.0, 11.0)); + p.rect_filled(cell, 1.0, if (row + col) % 2 == 0 { t.field_bg } else { t.row_selected }); + bar(Rect::from_min_size(cell.left_bottom() + vec2(0.0, 2.0), vec2(cell.width() * 0.7, 2.0)), cell.width() * 0.7, t.text_faint); + } + } + let tools = Rect::from_min_max(pos2(project.right() + 5.0, low), pos2(project.right() + 15.0, body.bottom())); + p.rect_filled(tools, 2.0, t.panel_bg); + for i in 0..5 { + let c = if i == 0 { t.accent } else { t.icon }; + p.rect_filled(Rect::from_center_size(pos2(tools.center().x, tools.top() + 7.0 + i as f32 * 9.0), vec2(5.0, 5.0)), 1.0, c); + } + let meters = Rect::from_min_max(pos2(body.right() - 12.0, low), pos2(body.right(), body.bottom())); + p.rect_filled(meters, 2.0, t.panel_bg); + for (i, h) in [0.55, 0.62].into_iter().enumerate() { + let x = meters.left() + 3.0 + i as f32 * 4.0; + p.rect_filled( + Rect::from_min_max(pos2(x, meters.bottom() - 3.0 - (meters.height() - 6.0) * h), pos2(x + 2.0, meters.bottom() - 3.0)), + 0.0, + t.render_green, + ); + } + let tl = Rect::from_min_max(pos2(tools.right() + 2.0, low), pos2(meters.left() - 2.0, body.bottom())); + p.rect_filled(tl, 2.0, t.tl_bg); + bar(Rect::from_min_size(tl.min + vec2(4.0, 3.5), vec2(14.0, 2.0)), 14.0, t.tab_text_active); + bar(Rect::from_min_size(tl.min + vec2(4.0, 9.0), vec2(16.0, 2.0)), 16.0, t.timecode); + let heads_w = 22.0; + let ruler = Rect::from_min_max(pos2(tl.left() + heads_w, tl.top() + 7.0), pos2(tl.right() - 2.0, tl.top() + 13.0)); + p.rect_filled(ruler, 0.0, t.tl_ruler_bg); + let mut x = ruler.left() + 2.0; + while x < ruler.right() { + p.line_segment([pos2(x, ruler.bottom() - 2.0), pos2(x, ruler.bottom())], Stroke::new(1.0, t.tl_ruler_tick)); + x += 6.0; + } + let video = Label::Iris.rgb(); + let audio = Label::Caribbean.rgb(); + let music = Label::Forest.rgb(); + let track_h = ((tl.bottom() - ruler.bottom() - 3.0) / 4.0).max(4.0); + for i in 0..4 { + let y = ruler.bottom() + 1.0 + i as f32 * track_h; + let head = Rect::from_min_size(pos2(tl.left() + 2.0, y), vec2(heads_w - 3.0, track_h - 1.0)); + p.rect_filled(head, 1.0, t.tl_header_bg); + p.rect_filled(Rect::from_min_size(head.min + vec2(2.0, 1.5), vec2(5.0, track_h - 4.0)), 1.0, t.accent); + let lane = Rect::from_min_max(pos2(ruler.left(), y), pos2(ruler.right(), y + track_h - 1.0)); + p.rect_filled(lane, 0.0, if i % 2 == 0 { t.tl_track_bg } else { t.tl_track_bg_alt }); + let clips: &[(f32, f32)] = match i { + 0 => &[(0.55, 0.75)], + 1 | 2 => &[(0.0, 0.22), (0.23, 0.48), (0.49, 0.7), (0.71, 0.9)], + _ => &[(0.0, 0.9)], + }; + let c = match i { + 0 | 1 => video, + 2 => audio, + _ => music, + }; + for (a, b) in clips { + let r = Rect::from_min_max(pos2(lane.left() + lane.width() * a, lane.top() + 0.5), pos2(lane.left() + lane.width() * b, lane.bottom() - 0.5)); + p.rect_filled(r, 1.0, Color32::from_rgb(c[0], c[1], c[2]).gamma_multiply(0.85)); + } + } + let ph = ruler.left() + ruler.width() * 0.3; + p.line_segment([pos2(ph, ruler.top()), pos2(ph, tl.bottom() - 2.0)], Stroke::new(1.0, t.playhead)); +} + +/// One theme card of the Appearance page: title, "Active" while the mode shows it, the preview and +/// a radio button per theme (automation ids `settings..`). +#[allow(clippy::too_many_arguments)] +fn theme_card(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, key: &str, title: &str, opts: &[(&str, &str)], active: bool, width: f32) { + let t = app.tokens; + let current = choice_text(get(&d.values, key)); + let shown = opts.iter().find(|(v, _)| *v == current).or(opts.first()).map_or("darkest", |(v, _)| v); + let highlight = settings::parse_hex(&choice_text(get(&d.values, "appearance.highlightColor"))); + let contrast = get(&d.values, "appearance.accessibleContrast").as_bool().unwrap_or(false); + let look = Tokens::for_kind(crate::theme::ThemeKind::from_pref(shown)).with_appearance(highlight, contrast); + let r = Frame::new() + .fill(t.field_bg) + .stroke(Stroke::new(1.0, if active { t.accent } else { t.field_border })) + .corner_radius(CornerRadius::same(6)) + .inner_margin(Margin::same(10)) + .show(ui, |ui| { + ui.vertical(|ui| { + ui.set_width(width - 20.0); + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::t(title)).size(12.5).strong()); + if active { + ui.label(RichText::new(tl!("Active")).size(11.0).color(t.accent)); + } + }); + ui.add_space(4.0); + theme_preview(ui, &look, width - 20.0); + ui.add_space(4.0); + for (v, l) in opts { + let r = ui.radio(current == *v, RichText::new(crate::i18n::t(l)).size(12.5)); + app.auto.add(&format!("settings.{key}.{v}"), r.rect, l); + if r.clicked() { + put(&mut d.values, key, json!(v)); + } + } + }); + }); + app.auto.add(&format!("settings.{key}"), r.response.rect, title); +} + +/// Settings ▸ Appearance: the Appearance Mode selector above the light and dark theme cards. +fn appearance_modes(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft) { + let key = "appearance.appearanceMode"; + let mode = choice_text(get(&d.values, key)); + ui.horizontal(|ui| { + field_label(ui, "Appearance Mode"); + if let Some(v) = combo(app, ui, key, &mode, &static_opts(settings::APPEARANCE_MODES), 200.0) { + put(&mut d.values, key, json!(v)); + } + }); + let mode = choice_text(get(&d.values, key)); + let light_active = mode == "light" || (mode == "auto" && app.system_theme(ui.ctx()) == Some(egui::Theme::Light)); + ui.add_space(6.0); + let width = ((ui.available_width() - 12.0) / 2.0).max(200.0); + ui.horizontal_top(|ui| { + theme_card(app, ui, d, "appearance.lightTheme", "Light Theme", settings::LIGHT_THEMES, light_active, width); + ui.add_space(4.0); + theme_card(app, ui, d, "appearance.darkTheme", "Dark Theme", settings::DARK_THEMES, !light_active, width); + }); + ui.add_space(6.0); +} + /// Patch the open dialog's draft (`ui.set {"settings": {"page": "trim", "values": {key: value}}}`). pub fn patch(app: &mut FilmcraftApp, p: &Value) -> Result<(), String> { let d = app.ui.settings.as_mut().ok_or("the Settings dialog is not open")?; @@ -422,6 +608,7 @@ fn custom(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, name: &str ), ); } + "appearanceModes" => appearance_modes(app, ui, d), "autoSaveStatus" => { let status = app.session.execute("file.autoSaveStatus", json!({})).unwrap_or_default(); if let Some(dir) = status["autoSaveDir"].as_str() { @@ -467,6 +654,7 @@ fn rows(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, list: &[Row] continue; } match r { + Row::Field(f) if APPEARANCE_KEYS.contains(&f.key) => continue, Row::Field(f) => draw_field(app, ui, d, f), Row::Group(title, inner) => group(ui, &t, title, |ui| rows(app, ui, d, inner)), Row::Note(text) => note(ui, &t, text), @@ -595,6 +783,66 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::theme::ThemeKind; + + #[test] + fn appearance_button_cycles_modes_without_losing_theme_choices() { + let ctx = egui::Context::default(); + let mut app = FilmcraftApp::new(filmcraft_engine::Session::default()); + app.apply_prefs(&ctx); + assert_eq!((app.session.prefs.appearance.appearance_mode.as_str(), app.tokens.kind), ("dark", ThemeKind::Dark), "new users keep Darkest"); + app.session.execute("prefs.set", json!({"values": {"appearance.darkTheme": "dark"}})).unwrap(); + app.hooks.system_theme = Some(Box::new(|_| Some(egui::Theme::Dark))); + for (mode, shown) in [("auto", ThemeKind::Medium), ("light", ThemeKind::Light), ("dark", ThemeKind::Medium), ("auto", ThemeKind::Medium)] { + cycle_appearance(&mut app, &ctx); + assert_eq!(app.session.prefs.appearance.appearance_mode, mode); + assert_eq!(app.tokens.kind, shown, "{mode}"); + assert_eq!((app.session.prefs.appearance.dark_theme.as_str(), app.session.prefs.appearance.light_theme.as_str()), ("dark", "light")); + } + // View ▸ Appearance picks a theme and fixes the mode to its family + set_theme(&mut app, &ctx, ThemeKind::Light); + assert_eq!((app.session.prefs.appearance.appearance_mode.as_str(), app.tokens.kind), ("light", ThemeKind::Light)); + set_theme(&mut app, &ctx, ThemeKind::Dark); + assert_eq!((app.session.prefs.appearance.appearance_mode.as_str(), app.tokens.kind), ("dark", ThemeKind::Dark)); + assert_eq!(app.session.prefs.appearance.dark_theme, "darkest"); + } + + /// Auto follows the host's reading of the system appearance as it changes, and falls back to + /// dark without an answer. + #[test] + fn auto_follows_a_stubbed_system_theme() { + use std::sync::Arc; + use std::sync::atomic::{AtomicU8, Ordering}; + let ctx = egui::Context::default(); + let mut app = FilmcraftApp::new(filmcraft_engine::Session::default()); + let system = Arc::new(AtomicU8::new(2)); + let read = system.clone(); + app.hooks.system_theme = Some(Box::new(move |_| match read.load(Ordering::Relaxed) { + 1 => Some(egui::Theme::Dark), + 2 => Some(egui::Theme::Light), + _ => None, + })); + app.session.execute("prefs.set", json!({"key": "appearance.appearanceMode", "value": "auto"})).unwrap(); + app.apply_prefs(&ctx); + assert_eq!(app.tokens.kind, ThemeKind::Light); + assert!(!app.ui.dark); + assert_eq!(ctx.theme(), egui::Theme::Light, "egui's style follows FilmCraft's choice"); + system.store(1, Ordering::Relaxed); + app.apply_prefs(&ctx); + assert_eq!(app.tokens.kind, ThemeKind::Dark); + assert_eq!(ctx.theme(), egui::Theme::Dark); + system.store(2, Ordering::Relaxed); + app.apply_prefs(&ctx); + assert_eq!(app.tokens.kind, ThemeKind::Light); + system.store(0, Ordering::Relaxed); + app.apply_prefs(&ctx); + assert_eq!(app.tokens.kind, ThemeKind::Dark, "no answer: dark"); + // a fixed mode ignores the system + app.session.execute("prefs.set", json!({"key": "appearance.appearanceMode", "value": "dark"})).unwrap(); + system.store(2, Ordering::Relaxed); + app.apply_prefs(&ctx); + assert_eq!(app.tokens.kind, ThemeKind::Dark); + } #[test] fn diff_lists_changed_leaves() { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 3a81ae12..8781b3f5 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -299,7 +299,11 @@ fn add_craft_fonts(fonts: &mut FontDefinitions) { } pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) { - let mut v = if t.kind == ThemeKind::Light { Visuals::light() } else { Visuals::dark() }; + let light = t.kind == ThemeKind::Light; + // FilmCraft decides between light and dark itself (Settings ▸ Appearance ▸ Appearance Mode), so + // egui must not switch to its other built-in style when the system appearance changes. + ctx.set_theme(if light { egui::Theme::Light } else { egui::Theme::Dark }); + let mut v = if light { Visuals::light() } else { Visuals::dark() }; v.panel_fill = t.panel_bg; v.window_fill = t.panel_bg; v.extreme_bg_color = t.field_bg; diff --git a/crates/ui-egui/tests/settings_ui.rs b/crates/ui-egui/tests/settings_ui.rs index c8513618..34990635 100644 --- a/crates/ui-egui/tests/settings_ui.rs +++ b/crates/ui-egui/tests/settings_ui.rs @@ -250,17 +250,39 @@ fn appearance_labels_and_tooltips_take_effect() { let mut d = Driver::demo(); assert_eq!(d.inspect()["ui"]["dark"], true); d.menu("app.settings.appearance"); - d.click("settings.appearance.colorTheme"); - d.click("settings.appearance.colorTheme.light"); + // the Appearance Mode selector above the light and dark theme cards + assert!(d.has("settings.appearance.lightTheme.light") && d.has("settings.appearance.darkTheme.dark")); + assert!(!d.has("settings.appearance.colorTheme"), "the single Color Theme is not shown"); + // Each radio belongs below its preview, even though the cards are laid out side by side. + let elements = d.ok("ui.elements", json!({"prefix": "settings.appearance."})); + let top = |id: &str| elements.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap()["rect"][1].as_f64().unwrap(); + for (card, radio) in + [("settings.appearance.lightTheme", "settings.appearance.lightTheme.light"), ("settings.appearance.darkTheme", "settings.appearance.darkTheme.darkest")] + { + assert!(top(radio) > top(card) + 128.0, "{radio} sits below its preview"); + } + d.click("settings.appearance.darkTheme.dark"); + d.click("settings.appearance.appearanceMode"); + d.click("settings.appearance.appearanceMode.light"); d.ok("ui.set", json!({"settings": {"values": {"appearance.highlightColor": "#e0457b"}}})); d.click("settings.ok"); d.frames(2); assert_eq!(d.inspect()["ui"]["dark"], false, "Light theme applied"); + assert_eq!((d.pref("appearance.appearanceMode"), d.pref("appearance.darkTheme")), (json!("light"), json!("dark"))); + // the header button cycles Auto, Light, Dark + d.click("header.appearance"); + assert_eq!(d.pref("appearance.appearanceMode"), "dark"); + assert_eq!(d.harness.state().tokens.kind, filmcraft_ui_egui::theme::ThemeKind::Medium, "the saved dark theme"); + d.click("header.appearance"); + assert_eq!(d.pref("appearance.appearanceMode"), "auto"); + d.menu("view.appearanceMode.next"); + assert_eq!(d.pref("appearance.appearanceMode"), "light"); assert_eq!(d.pref("appearance.highlightColor"), "#e0457b"); assert_eq!(d.harness.state().tokens.accent, egui::Color32::from_rgb(0xe0, 0x45, 0x7b)); // View ▸ Appearance writes the setting d.menu("view.theme.dark"); assert_eq!(d.pref("appearance.colorTheme"), "darkest"); + assert_eq!((d.pref("appearance.appearanceMode"), d.pref("appearance.darkTheme")), (json!("dark"), json!("darkest"))); assert_eq!(d.inspect()["ui"]["dark"], true); // label names show in Edit ▸ Label