From fe27c375e3d5cb2d84c61c9119621dba8f87a33e Mon Sep 17 00:00:00 2001 From: Luniiya <86487331+luniiya@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:15:26 +0200 Subject: [PATCH] feat: standardise system appearance modes with PhotoCraft --- ATTRIBUTION.md | 2 + Cargo.lock | 1 + apps/effectcraft/Cargo.toml | 5 + apps/effectcraft/src/appearance.rs | 154 +++++++++++++++++++++++ apps/effectcraft/src/main.rs | 2 + crates/engine/src/prefs.rs | 108 ++++++++++++++++- crates/engine/src/tests_settings.rs | 54 +++++++++ crates/ui-egui/src/header.rs | 15 ++- crates/ui-egui/src/i18n/ui.rs | 16 +++ crates/ui-egui/src/icons.rs | 25 ++++ crates/ui-egui/src/lib.rs | 121 +++++++++++++++++-- crates/ui-egui/src/menus.rs | 5 + crates/ui-egui/src/panels/settings.rs | 168 +++++++++++++++++++++++++- crates/ui-egui/src/theme.rs | 24 +++- docs/preferences.md | 5 +- 15 files changed, 686 insertions(+), 19 deletions(-) create mode 100644 apps/effectcraft/src/appearance.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index ec7442d1..6750a327 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -118,3 +118,5 @@ under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and | `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), black) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) | | `docs/brand/artcraft-mark.png` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), blue) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) | | `docs/brand/artcraft-mark.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), blue) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) | + +- Appearance theme previews, drawn procedurally from original shapes: `crates/ui-egui/src/panels/settings.rs` (original work, MIT OR Apache-2.0). diff --git a/Cargo.lock b/Cargo.lock index 501b4805..e60dc81b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1151,6 +1151,7 @@ dependencies = [ "serde_json", "winit", "winresource", + "zbus", ] [[package]] diff --git a/apps/effectcraft/Cargo.toml b/apps/effectcraft/Cargo.toml index 5ca52d69..a7ccc74b 100644 --- a/apps/effectcraft/Cargo.toml +++ b/apps/effectcraft/Cargo.toml @@ -21,6 +21,11 @@ rfd = { workspace = true } cpal = { workspace = true } log = { workspace = true } +[target.'cfg(target_os = "linux")'.dependencies] +# Settings ▸ Appearance ▸ Sync with System: the XDG desktop portal's colour scheme. Already in +# Cargo.lock (accesskit, through eframe); pinned exactly to the same version. +zbus = "=5.19.0" + [build-dependencies] # Icon + VERSIONINFO for effectcraft.exe; build.rs only uses it when targeting Windows. winresource = "0.1" diff --git a/apps/effectcraft/src/appearance.rs b/apps/effectcraft/src/appearance.rs new file mode 100644 index 00000000..0c625646 --- /dev/null +++ b/apps/effectcraft/src/appearance.rs @@ -0,0 +1,154 @@ +//! The desktop's light or dark appearance on Linux, for Settings ▸ Appearance ▸ Appearance Mode ▸ +//! Sync with System. Wayland compositors often report no theme to winit, so this reads the XDG +//! desktop portal instead. Other platforms use 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 colour-scheme number inside a portal reply. +#[cfg(target_os = "linux")] +fn portal_code(value: zbus::zvariant::OwnedValue) -> Option { + let mut value: zbus::zvariant::Value<'_> = value.into(); + // `Settings.Read` answers with a variant, and some portals nest another variant inside it. + for _ in 0..4 { + match value { + zbus::zvariant::Value::Value(inner) => value = *inner, + other => return u32::try_from(other).ok(), + } + } + None +} + +/// The portal's colour scheme: 1 prefers dark, 2 prefers light, 0 (or anything else) says nothing. +#[cfg(target_os = "linux")] +fn decode(code: u32) -> Option { + match code { + 1 => Some(egui::Theme::Dark), + 2 => Some(egui::Theme::Light), + _ => None, + } +} + +/// 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, + } +} + +/// Reads the colour scheme once, then follows the portal's `SettingChanged` signal. There is no +/// polling: the worker sleeps in the signal iterator and wakes the UI only when the value changes. +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 reading so a change during startup stays queued. + 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); + // Startup can finish after the UI's bounded wait: wake its first frame too. + if worker_value.swap(code_of(initial), Ordering::Relaxed) != code_of(initial) + && let Some(ctx) = worker_wake.get() + { + ctx.request_repaint(); + } + let _ = ready.send(()); + let Some(signals) = signals else { return }; + 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)); + if worker_value.swap(code, Ordering::Relaxed) != code + && let Some(ctx) = worker_wake.get() + { + ctx.request_repaint(); + } + } + }) + .is_err() + { + return None; + } + // The first frame already has the answer when the portal replies quickly. + 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 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)); + assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::from(1u32)), Some(1)); + assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(zbus::zvariant::Value::from("dark")).unwrap()), 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::code_of(super::decode(2)), 2); + } +} diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 72afab5a..434a7bab 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -11,6 +11,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] // Built everywhere so its tests run on every platform; only Linux AppImages use it. +mod appearance; #[cfg_attr(not(target_os = "linux"), allow(dead_code))] mod appimage; mod audio_out; @@ -179,6 +180,7 @@ fn main() -> eframe::Result { app.hooks.pick_open_project = Some(Box::new(|| { rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file().map(|p| p.to_string_lossy().to_string()) })); + app.hooks.system_theme = appearance::service(); app.hooks.audio_device = Some(Box::new(audio_out::open)); app.hooks.audio_devices = Some(Box::new(audio_out::devices)); app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))); diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 4ee6cc97..6958c2ae 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -32,11 +32,28 @@ pub const UI_SCALES: &[(&str, &str)] = &[("75%", "75"), ("100%", "100"), ("125%" /// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value). pub const WINDOW_GRAPHICS: &[(&str, &str)] = &[("Automatic", "auto"), ("OpenGL (compatibility)", "gl")]; +/// Settings ▸ Appearance ▸ Appearance Mode: (label, `appearance.appearanceMode` value). Auto +/// follows the operating system's light or dark appearance. +pub const APPEARANCE_MODES: &[(&str, &str)] = &[("Sync with System", "auto"), ("Dark", "dark"), ("Light", "light")]; + +/// The dark family of themes: (label, `appearance.darkTheme` value). +pub const DARK_THEMES: &[(&str, &str)] = &[("Dark", "dark"), ("Darker", "darker")]; + +/// The light family of themes: (label, `appearance.lightTheme` value). +pub const LIGHT_THEMES: &[(&str, &str)] = &[("Light", "light")]; + +/// Every theme (the legacy single-theme `appearance.theme`). +pub const THEMES: &[(&str, &str)] = &[("Dark", "dark"), ("Darker", "darker"), ("Light", "light")]; + /// The settings whose value must be one of their choices. fn choices(key: &str) -> Option<&'static [(&'static str, &'static str)]> { match key { "general.language" => Some(LANGUAGES), "startup.windowGraphics" => Some(WINDOW_GRAPHICS), + "appearance.appearanceMode" => Some(APPEARANCE_MODES), + "appearance.darkTheme" => Some(DARK_THEMES), + "appearance.lightTheme" => Some(LIGHT_THEMES), + "appearance.theme" => Some(THEMES), _ => None, } } @@ -140,8 +157,16 @@ page!(Previews { }); page!(Appearance { - /// `dark`, `darker` or `light`. + /// The single theme before appearance modes (`dark`, `darker` or `light`), kept for older + /// settings files and automation: setting it picks its family's mode and theme. While the mode + /// is Dark or Light it mirrors the theme in use. theme: String = "dark".into(), + /// `auto` (follow the operating system), `dark` or `light`. New installs stay Dark. + appearance_mode: String = "dark".into(), + /// The theme used in dark appearance: `dark` or `darker`. + dark_theme: String = "dark".into(), + /// The theme used in light appearance: `light`. + light_theme: String = "light".into(), /// User interface brightness, -1 (darker) … 1 (lighter). brightness: f64 = 0.0, /// The size of the whole interface in percent (75–200), on top of the display's own scale. @@ -408,10 +433,61 @@ pub fn migrate(mut v: Value) -> Value { obj.insert("labels".into(), serde_json::to_value(labels).unwrap_or_default()); } } + // Settings saved before appearance modes had one theme: keep showing it, as a fixed Dark or + // Light mode, instead of switching established users to Auto. + if let Some(a) = obj.get_mut("appearance").and_then(Value::as_object_mut) + && !a.contains_key("appearanceMode") + && let Some(theme) = a.get("theme").and_then(Value::as_str).map(str::to_string) + && let Some((mode, slot)) = theme_family(&theme) + { + a.insert("appearanceMode".into(), json!(mode)); + a.insert(slot.into(), json!(theme)); + } obj.insert("version".into(), json!(PREFS_VERSION)); v } +/// The appearance mode and the per-mode setting a theme belongs to (`darker` → (`dark`, +/// `darkTheme`)); `None` for an unknown theme. +pub fn theme_family(theme: &str) -> Option<(&'static str, &'static str)> { + if is_choice(DARK_THEMES, theme) { + Some(("dark", "darkTheme")) + } else if is_choice(LIGHT_THEMES, theme) { + Some(("light", "lightTheme")) + } else { + None + } +} + +impl Appearance { + /// Show the legacy single `theme`: fix the mode to its family and remember it as that + /// family's theme. An unknown theme changes nothing. + pub fn select_theme(&mut self) { + match theme_family(&self.theme) { + Some((mode, "darkTheme")) => { + self.appearance_mode = mode.into(); + self.dark_theme = self.theme.clone(); + } + Some((mode, _)) => { + self.appearance_mode = mode.into(); + self.light_theme = self.theme.clone(); + } + None => {} + } + } + + /// The theme to show: the light or dark family's choice by the mode; Auto follows `system_light` + /// (`None` = the system said nothing: dark). + pub fn resolved_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 } + } +} + impl Prefs { /// Parse a settings document (migrating older layouts; unknown keys are kept, bad values /// fall back to defaults). @@ -459,6 +535,24 @@ impl Prefs { let a = &mut self.auto_save; a.interval_minutes = a.interval_minutes.clamp(1, 240); a.max_versions = a.max_versions.clamp(1, 99); + let ap = &mut self.appearance; + if !is_choice(APPEARANCE_MODES, &ap.appearance_mode) { + ap.appearance_mode = Appearance::default().appearance_mode; + } + if !is_choice(DARK_THEMES, &ap.dark_theme) { + ap.dark_theme = Appearance::default().dark_theme; + } + if !is_choice(LIGHT_THEMES, &ap.light_theme) { + ap.light_theme = Appearance::default().light_theme; + } + // The legacy single theme mirrors the theme a fixed mode shows (Auto depends on the + // system, so it keeps the last one). + match ap.appearance_mode.as_str() { + "dark" => ap.theme = ap.dark_theme.clone(), + "light" => ap.theme = ap.light_theme.clone(), + _ if !is_choice(THEMES, &ap.theme) => ap.theme = ap.dark_theme.clone(), + _ => {} + } self.appearance.brightness = self.appearance.brightness.clamp(-1.0, 1.0); self.appearance.ui_scale = self.appearance.ui_scale.clamp(75, 200); let defaults = default_labels(); @@ -498,12 +592,20 @@ impl Prefs { let all: Vec<&str> = c.iter().map(|(_, v)| *v).collect(); return Err(format!("`{key}` expects one of {}", all.join(", "))); } + // Older clients set the single theme (by key, or inside the page object): it still picks + // a visible theme, fixing the mode to its family. + let legacy_theme = key == "appearance.theme" || (key == "appearance" && value.get("theme").is_some() && value.get("appearanceMode").is_none()); let mut v = serde_json::to_value(&*self).map_err(|e| e.to_string())?; let ptr = format!("/{}", key.replace('.', "/")); let slot = v.pointer_mut(&ptr).ok_or_else(|| format!("unknown setting `{key}`"))?; let value = coerce(slot, value).ok_or_else(|| format!("`{key}` expects a {}", kind_name(slot)))?; *slot = value; let mut next: Prefs = serde_json::from_value(v).map_err(|e| format!("`{key}`: {e}"))?; + let (a, b) = (&self.appearance, &next.appearance); + let only_theme_changed = a.theme != b.theme && a.appearance_mode == b.appearance_mode && a.dark_theme == b.dark_theme && a.light_theme == b.light_theme; + if legacy_theme || only_theme_changed { + next.appearance.select_theme(); + } next.normalize(); *self = next; Ok(()) @@ -1014,7 +1116,9 @@ pub fn pages() -> Vec { id: "appearance", title: "Appearance", items: vec![ - s("appearance.theme", "Theme", Kind::Choice(&[("Dark", "dark"), ("Darker", "darker"), ("Light", "light")]), true), + s("appearance.appearanceMode", "Appearance Mode", Kind::Choice(APPEARANCE_MODES), true), + s("appearance.lightTheme", "Light Theme", Kind::Choice(LIGHT_THEMES), true), + s("appearance.darkTheme", "Dark Theme", Kind::Choice(DARK_THEMES), true), s("appearance.brightness", "Brightness", Kind::Slider(-1.0, 1.0), true), s("appearance.uiScale", "UI Scale", Kind::Choice(UI_SCALES), true), Section("Labels and Colors"), diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index ee7a3fca..2a4f0c8a 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -109,6 +109,60 @@ fn ui_scale_is_a_percentage_from_75_to_200() { assert_eq!(Prefs::from_json(r#"{"version": 2, "appearance": {"theme": "light"}}"#).appearance.ui_scale, 100); } +/// Settings ▸ Appearance: new installs stay Dark (Sync with System is opt-in), and settings saved +/// with a single theme load as a fixed mode showing that theme. +#[test] +fn appearance_defaults_and_legacy_theme_migrate() { + let p = Prefs::default(); + assert_eq!((p.appearance.appearance_mode.as_str(), p.appearance.dark_theme.as_str(), p.appearance.light_theme.as_str()), ("dark", "dark", "light")); + assert_eq!(p.appearance.resolved_theme(Some(true)), "dark"); + for (saved, mode, dark, light) in [("darker", "dark", "darker", "light"), ("light", "light", "dark", "light"), ("dark", "dark", "dark", "light")] { + let p = Prefs::from_json(&json!({"version": 2, "appearance": {"theme": saved}}).to_string()); + assert_eq!(p.appearance.appearance_mode, mode, "{saved}"); + assert_eq!(p.appearance.dark_theme, dark, "{saved}"); + assert_eq!(p.appearance.light_theme, light, "{saved}"); + assert_eq!(p.appearance.theme, saved); + } + // The flat layout of the earliest builds migrates too. + assert_eq!(Prefs::from_json(r#"{"theme": "light"}"#).appearance.appearance_mode, "light"); + // A file that already has a mode keeps it; garbage falls back to the defaults. + let p = Prefs::from_json(r#"{"version": 2, "appearance": {"theme": "dark", "appearanceMode": "auto", "darkTheme": "darker"}}"#); + assert_eq!((p.appearance.appearance_mode.as_str(), p.appearance.dark_theme.as_str()), ("auto", "darker")); + let p = Prefs::from_json(r#"{"version": 2, "appearance": {"appearanceMode": "sepia", "darkTheme": "light", "lightTheme": 7}}"#); + assert_eq!((p.appearance.appearance_mode.as_str(), p.appearance.dark_theme.as_str(), p.appearance.light_theme.as_str()), ("dark", "dark", "light")); +} + +/// The appearance settings take only their choices, survive a restart, and old clients that set +/// the single theme still pick a visible theme without losing the other family's choice. +#[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": "darker", "appearance.lightTheme": "light"}})) + .unwrap(); + assert_eq!(s.prefs.appearance.appearance_mode, "auto"); + assert_eq!(s.prefs.appearance.dark_theme, "darker"); + assert_eq!(s.prefs.appearance.resolved_theme(Some(true)), "light"); + assert_eq!(s.prefs.appearance.resolved_theme(Some(false)), "darker"); + assert_eq!(s.prefs.appearance.resolved_theme(None), "darker", "no system answer: dark"); + assert_eq!(Prefs::from_json(&s.prefs.to_json()).appearance, s.prefs.appearance); + for (key, bad) in + [("appearance.darkTheme", "light"), ("appearance.lightTheme", "darker"), ("appearance.appearanceMode", "sepia"), ("appearance.theme", "neon")] + { + assert!(s.execute("prefs.set", json!({"key": key, "value": bad})).is_err(), "{key} = {bad}"); + } + s.execute("prefs.set", json!({"key": "appearance.theme", "value": "light"})).unwrap(); + assert_eq!(s.prefs.appearance.appearance_mode, "light"); + assert_eq!(s.prefs.appearance.dark_theme, "darker"); + s.execute("prefs.set", json!({"key": "appearance", "value": {"theme": "dark"}})).unwrap(); + assert_eq!(s.prefs.appearance.appearance_mode, "dark"); + assert_eq!(s.prefs.appearance.dark_theme, "dark"); + // A fixed mode mirrors its theme in the single-theme key for old readers. + s.execute("prefs.set", json!({"key": "appearance.appearanceMode", "value": "light"})).unwrap(); + assert_eq!(s.prefs.get("appearance.theme"), Some(json!("light"))); + s.prefs.reset(Some("appearance")).unwrap(); + assert_eq!(s.prefs.appearance, crate::prefs::Appearance::default()); +} + #[test] fn every_schema_key_exists_and_docs_list_the_todo_settings() { let p = Prefs::default(); diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 0ea37835..1b93658f 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -1,7 +1,7 @@ //! The Tools bar: home, the tool slots, tool options, snapping, workspaces and the community //! buttons (Discord is always one click away). -use crate::i18n::tr; +use crate::i18n::{tr, tr_args}; use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::json; @@ -198,6 +198,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add(&format!("header.{}", &id[4..]), r, tip); rx = r.min.x - 4.0; } + // Appearance: Sync with System, Light, Dark (a monitor, sun or moon), one click to the next. + let (icon, mode) = match app.session.prefs.appearance.appearance_mode.as_str() { + "auto" => (Icon::Monitor, tr("Sync with System")), + "light" => (Icon::Sun, tr("Light")), + _ => (Icon::Moon, tr("Dark")), + }; + let tip = tr_args("Appearance Mode: {}", &[&mode]); + let r = Rect::from_min_max(pos2(rx - 26.0, cy - 13.0), pos2(rx, cy + 13.0)); + if widgets::icon_button(ui, r, icon, false, &t, egui::Id::new("hdr-appearance")).on_hover_text(&tip).clicked() { + app.cycle_appearance(ui.ctx()); + } + app.auto.add("header.appearance", r, &tip); + rx = r.min.x - 4.0; rx -= 10.0; p.line_segment([pos2(rx, cy - 10.0), pos2(rx, cy + 10.0)], Stroke::new(1.0, t.separator)); rx -= 10.0; diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 4afb43b1..a9158508 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -88,6 +88,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ), ("About EffectCraft", "关于 EffectCraft"), ("About...", "关于..."), + ("Active", "使用中"), ("Active Camera", "活动摄像机"), ("Adapt Feature On Every Frame", "每帧都适应特征"), ("Adaptive Sample Limit:", "自适应采样限制:"), @@ -109,6 +110,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("All six face cameras turn together; the scene is not changed.", "六个面摄像机一起旋转;场景不会改变。"), ("Anchor:", "定位点:"), ("Aperture", "光圈"), + ("Appearance Mode", "外观模式"), + ("Appearance Mode: {}", "外观模式:{}"), ("Apply Dimensions:", "应用尺寸:"), ("Apply Motion To:", "将运动应用于:"), ("Arrange", "排列"), @@ -167,6 +170,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Continuous (Lock Outgoing to Incoming)", "连续(将出点锁定到入点)"), ("Cue Point", "提示点"), ("Current Render", "当前渲染"), + ("Dark", "深色"), + ("Dark Theme", "深色主题"), ("Darker", "更深"), ("Default Output:", "默认输出:"), ("Default: {}", "默认:{}"), @@ -263,6 +268,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Leave all attributes in \"{}\"", "将所有属性保留在“{}”中"), ("Licence: {}", "许可:{}"), ("Ligatures", "连字"), + ("Light", "浅色"), + ("Light Theme", "浅色主题"), ("Light Type", "灯光类型"), ("Lighter", "更浅"), ("Link Selected Property", "链接所选属性"), @@ -439,6 +446,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Stylistic Sets", "样式集"), ("Subpixel Positioning", "次像素定位"), ("Swap Paint Colors (X)", "交换绘画颜色(X)"), + ("Sync with System", "与系统同步"), ("System Default", "系统默认"), ("Table", "表格"), ("Template name", "模板名称"), @@ -505,6 +513,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ), ("About EffectCraft", "關於 EffectCraft"), ("About...", "關於..."), + ("Active", "使用中"), ("Active Camera", "活動攝影機"), ("Adapt Feature On Every Frame", "每個影格都適應特徵"), ("Adaptive Sample Limit:", "自適應取樣限制:"), @@ -526,6 +535,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("All six face cameras turn together; the scene is not changed.", "六個面攝影機一起旋轉;場景不會改變。"), ("Anchor:", "釘選點:"), ("Aperture", "光圈"), + ("Appearance Mode", "外觀模式"), + ("Appearance Mode: {}", "外觀模式:{}"), ("Apply Dimensions:", "應用尺寸:"), ("Apply Motion To:", "將運動應用於:"), ("Arrange", "排列"), @@ -584,6 +595,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Continuous (Lock Outgoing to Incoming)", "連續(將出點鎖定到入點)"), ("Cue Point", "提示點"), ("Current Render", "目前算圖"), + ("Dark", "深色"), + ("Dark Theme", "深色主題"), ("Darker", "更深"), ("Default Output:", "預設輸出:"), ("Default: {}", "預設:{}"), @@ -680,6 +693,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Leave all attributes in \"{}\"", "將所有屬性保留在“{}”中"), ("Licence: {}", "許可:{}"), ("Ligatures", "連字"), + ("Light", "淺色"), + ("Light Theme", "淺色主題"), ("Light Type", "燈光類型"), ("Lighter", "更淺"), ("Link Selected Property", "鏈接所選屬性"), @@ -856,6 +871,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Stylistic Sets", "樣式集"), ("Subpixel Positioning", "次像素定位"), ("Swap Paint Colors (X)", "交換繪畫顏色(X)"), + ("Sync with System", "與系統同步"), ("System Default", "系統預設"), ("Table", "表格"), ("Template name", "範本名稱"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c2e335d2..52066f85 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -112,6 +112,13 @@ pub enum Icon { Globe, Code, Sparkle, + // appearance modes + /// Sync with System: a display on a stand. + Monitor, + /// Light: a disc with eight rays. + Sun, + /// Dark: a crescent. + Moon, } pub struct Pen16<'a> { @@ -616,6 +623,24 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { Sparkle => { pen.fill(&[(8.0, 1.5), (9.4, 6.6), (14.5, 8.0), (9.4, 9.4), (8.0, 14.5), (6.6, 9.4), (1.5, 8.0), (6.6, 6.6)]); } + Monitor => { + pen.rrect(1.8, 2.5, 14.2, 11.0, 1.2); + pen.line(&[(8.0, 11.0), (8.0, 13.8)]); + pen.line(&[(5.0, 13.8), (11.0, 13.8)]); + } + Sun => { + pen.circle(8.0, 8.0, 2.8); + for i in 0..8 { + let a = std::f32::consts::FRAC_PI_4 * i as f32; + let (c, s) = (a.cos(), a.sin()); + pen.line(&[(8.0 + 4.6 * c, 8.0 + 4.6 * s), (8.0 + 6.4 * c, 8.0 + 6.4 * s)]); + } + } + Moon => { + // The outer edge of a disc, and the inner edge of the disc that eclipses it. + pen.arc(8.0, 8.0, 5.8, -20.0, 250.0); + pen.arc(10.6, 2.4, 4.6, 52.0, 178.0); + } PenAdd | PenDelete => { // A small pen nib with a plus or minus beside it. pen.closed(&[(6.0, 2.5), (9.0, 8.0), (6.0, 12.0), (3.0, 8.0)]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 82be8a32..60c2aa2a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -92,9 +92,16 @@ pub enum Dialog { DeleteItems, } +/// The desktop's light or dark appearance where egui cannot see it (Wayland compositors that send +/// winit no theme). `None` = the host does not know. +pub type SystemThemeFn = Box Option>; + /// Host hooks provided by the native app (file pickers etc.). #[derive(Default)] pub struct Hooks { + /// The system appearance for Settings ▸ Appearance ▸ Sync with System (the desktop app reads + /// the Linux desktop portal); without it, what egui reports. + pub system_theme: Option, pub pick_files: Option Vec>>, pub pick_save: Option Option>>, pub pick_open_project: Option Option>>, @@ -269,7 +276,8 @@ pub struct EffectcraftApp { /// Last reveal shortcut and when (double-press shortcuts such as LL). pub(crate) last_reveal: Option<(String, f64)>, /// Settings revision applied to the theme, tooltips and caches. - applied_prefs: Option, + /// The settings revision and system appearance (light?) last applied. + applied_prefs: Option<(u64, Option)>, /// A previous run that didn't exit cleanly (shown by `Dialog::Recovery`). pub recovery: Option, /// Docked groups laid out last frame: (active panel, group rect) — `~` maximizes the one @@ -367,13 +375,16 @@ impl EffectcraftApp { /// Apply changed settings: theme and brightness, label colours, tool tips, preview caches. pub fn apply_prefs(&mut self, ctx: &egui::Context) { - if self.applied_prefs == Some(self.session.prefs_revision) { + // Sync with System re-applies when the system appearance changes (the host wakes the UI; + // nothing polls). + let system = if self.session.prefs.appearance.appearance_mode == "auto" { self.system_light(ctx) } else { None }; + if self.applied_prefs == Some((self.session.prefs_revision, system)) { return; } - self.applied_prefs = Some(self.session.prefs_revision); + self.applied_prefs = Some((self.session.prefs_revision, system)); let p = &self.session.prefs; - self.ui.theme = theme::ThemeKind::from_name(&p.appearance.theme).unwrap_or_default(); - self.tokens = Tokens::from_prefs(p); + self.ui.theme = theme::ThemeKind::from_name(p.appearance.resolved_theme(system)).unwrap_or_default(); + self.tokens = Tokens::from_prefs(p, self.ui.theme); theme::apply_visuals(ctx, &self.tokens); let tips = p.general.show_tool_tips; ctx.all_styles_mut(|s| s.interaction.tooltip_delay = if tips { 0.5 } else { f32::INFINITY }); @@ -399,13 +410,32 @@ impl EffectcraftApp { self } + /// Show theme `k`: the appearance mode becomes its family's (Dark or Light), and `k` that + /// family's theme. pub fn set_theme(&mut self, ctx: &egui::Context, k: theme::ThemeKind) { - let name = match k { - theme::ThemeKind::Dark => "dark", - theme::ThemeKind::Darker => "darker", - theme::ThemeKind::Light => "light", + if let Err(e) = self.set_pref("appearance.theme", json!(k.name())) { + self.ui.status = e; + } + self.apply_prefs(ctx); + } + + /// The system appearance: light (`Some(true)`), dark, or unknown. The host's answer (the Linux + /// desktop portal) wins over egui's, which Wayland compositors often leave empty. + pub fn system_light(&self, ctx: &egui::Context) -> Option { + self.hooks.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme()).map(|t| t == egui::Theme::Light) + } + + /// The Tools bar's appearance button (`view.theme.toggle`): Sync with System, then Light, then + /// Dark, then Sync with System again. The light and dark theme choices are kept. + pub fn cycle_appearance(&mut self, ctx: &egui::Context) { + let next = match self.session.prefs.appearance.appearance_mode.as_str() { + "auto" => "light", + "light" => "dark", + _ => "auto", }; - let _ = self.set_pref("appearance.theme", json!(name)); + if let Err(e) = self.set_pref("appearance.appearanceMode", json!(next)) { + self.ui.status = e; + } self.apply_prefs(ctx); } @@ -1713,3 +1743,74 @@ mod gpu_failure_tests { assert_eq!(app.session.render(comp, Tick::ZERO, RenderOpts::default()).data, before.data); } } + +#[cfg(test)] +mod appearance_tests { + use super::*; + use crate::theme::ThemeKind; + + fn app() -> EffectcraftApp { + EffectcraftApp::new(effectcraft_engine::Session::default()) + } + + /// The Tools bar's appearance button: Sync with System, Light, Dark, then Sync with System + /// again, showing the saved theme of each family without losing either choice. + #[test] + fn appearance_button_cycles_modes_without_losing_theme_choices() { + let ctx = egui::Context::default(); + let mut app = app(); + app.hooks.system_theme = Some(Box::new(|_| Some(egui::Theme::Dark))); + app.set_pref("appearance.darkTheme", json!("darker")).unwrap(); + app.apply_prefs(&ctx); + assert_eq!(app.ui.theme, ThemeKind::Darker); + for (mode, shown) in [("auto", ThemeKind::Darker), ("light", ThemeKind::Light), ("dark", ThemeKind::Darker), ("auto", ThemeKind::Darker)] { + menus::invoke(&mut app, &ctx, "view.theme.toggle", json!({})).unwrap(); + assert_eq!(app.session.prefs.appearance.appearance_mode, mode); + assert_eq!(app.ui.theme, shown, "{mode}"); + assert_eq!(app.tokens.kind, shown); + assert_eq!(app.session.prefs.appearance.dark_theme, "darker"); + assert_eq!(app.session.prefs.appearance.light_theme, "light"); + } + } + + /// Sync with System follows the host's answer (the Linux portal) when it changes, and falls + /// back to Dark when nothing reports a system appearance. + #[test] + fn auto_follows_a_stubbed_system_theme() { + use std::sync::{Arc, Mutex}; + let ctx = egui::Context::default(); + let mut app = app(); + let system = Arc::new(Mutex::new(Some(egui::Theme::Light))); + let read = Arc::clone(&system); + app.hooks.system_theme = Some(Box::new(move |_| *read.lock().unwrap_or_else(std::sync::PoisonError::into_inner))); + app.set_pref("appearance.appearanceMode", json!("auto")).unwrap(); + app.apply_prefs(&ctx); + assert_eq!(app.ui.theme, ThemeKind::Light); + *system.lock().unwrap() = Some(egui::Theme::Dark); + app.apply_prefs(&ctx); + assert_eq!(app.ui.theme, ThemeKind::Dark, "a system change applies without a settings change"); + *system.lock().unwrap() = None; + app.apply_prefs(&ctx); + assert_eq!(app.ui.theme, ThemeKind::Dark); + // A fixed mode ignores the system. + *system.lock().unwrap() = Some(egui::Theme::Light); + app.set_pref("appearance.appearanceMode", json!("dark")).unwrap(); + app.apply_prefs(&ctx); + assert_eq!(app.ui.theme, ThemeKind::Dark); + } + + /// Picking a theme (`view.theme.*`, `ui.set {theme}`) fixes the mode to its family. + #[test] + fn picking_a_theme_fixes_its_mode() { + let ctx = egui::Context::default(); + let mut app = app(); + app.set_pref("appearance.appearanceMode", json!("auto")).unwrap(); + menus::invoke(&mut app, &ctx, "view.theme.light", json!({})).unwrap(); + assert_eq!(app.session.prefs.appearance.appearance_mode, "light"); + assert_eq!(app.ui.theme, ThemeKind::Light); + menus::invoke(&mut app, &ctx, "view.theme.darker", json!({})).unwrap(); + assert_eq!(app.session.prefs.appearance.appearance_mode, "dark"); + assert_eq!(app.session.prefs.appearance.dark_theme, "darker"); + assert_eq!(app.ui.theme, ThemeKind::Darker); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a31f4b39..dc336d22 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -46,6 +46,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("view.safeMargins", "Title/Action Safe", [], None), uic!("view.transparencyGrid", "Transparency Grid", [], None), uic!("view.fastPreviews", "Fast Previews", [], None), + uic!("view.theme.toggle", "Next Appearance Mode", [], None), uic!("view.theme.dark", "Theme: Dark", [], None), uic!("view.theme.darker", "Theme: Darker", [], None), uic!("view.theme.light", "Theme: Light", [], None), @@ -463,6 +464,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V app.ui.viewer.res = Resolution::Auto; return Ok(Value::Null); } + if id == "view.theme.toggle" { + app.cycle_appearance(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")?; app.set_theme(ctx, k); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index ac79d8f0..79139ee9 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -8,12 +8,12 @@ //! `settings.cancel`, `settings.previous`, `settings.next`. use crate::i18n::{tr, tr_args}; -use effectcraft_engine::prefs::{Item, Kind, Page, pages}; +use effectcraft_engine::prefs::{APPEARANCE_MODES, DARK_THEMES, Item, Kind, LIGHT_THEMES, Page, pages}; use effectcraft_engine::segment::Task; use egui::{Color32, RichText, vec2}; use serde_json::{Value, json}; -use crate::theme::Tokens; +use crate::theme::{ThemeKind, Tokens}; use crate::{Dialog, EffectcraftApp}; /// Open the dialog on a page, remembering the settings for Cancel. @@ -88,6 +88,10 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value ui.label(RichText::new(tr("No device list from this host; the system default output is used.")).color(t.text_dim)); } } + // Appearance Mode with the light and dark theme cards (the per-mode themes are + // chosen on the cards). + Item::Setting { key: "appearance.appearanceMode", .. } => appearance_ui(app, ui, cur, t, acts), + Item::Setting { key: "appearance.lightTheme" | "appearance.darkTheme", .. } => {} Item::Setting { key, label, kind, live } => { let v = get(key); let id = format!("settings.{key}"); @@ -201,6 +205,166 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value } } +/// A small EffectCraft window painted with theme `kind`'s tokens: the Tools bar, the Project +/// panel, the Composition panel, the Preview and Properties column and the Timeline with its +/// layer bars. +fn theme_preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) { + use egui::{Rect, pos2}; + let p = Tokens::for_kind(kind); + let (rect, _) = ui.allocate_exact_size(vec2(width, 118.0), egui::Sense::hover()); + let painter = ui.painter_at(rect); + painter.rect_filled(rect, 3.0, p.app_bg); + let bar = Rect::from_min_size(rect.min, vec2(rect.width(), 13.0)); + painter.rect_filled(bar, 0.0, p.header_bg); + // The brand mark, the tools (the Selection tool is active) and the Discord pill. + painter.rect_filled(Rect::from_min_size(bar.min + vec2(4.0, 3.0), vec2(7.0, 7.0)), 2.0, p.accent); + for i in 0..7 { + let c = if i == 0 { p.accent } else { p.icon }; + painter.rect_filled(Rect::from_min_size(bar.min + vec2(16.0 + i as f32 * 8.0, 4.0), vec2(5.0, 5.0)), 1.0, c); + } + painter.rect_filled( + Rect::from_min_max(pos2(bar.right() - 22.0, bar.top() + 3.0), pos2(bar.right() - 4.0, bar.bottom() - 3.0)), + 3.5, + egui::Color32::from_rgb(0x58, 0x65, 0xf2), + ); + let gap = 2.0; + let body = Rect::from_min_max(pos2(rect.left() + gap, bar.bottom() + gap), pos2(rect.right() - gap, rect.bottom() - gap)); + let right_w = (body.width() * 0.22).round(); + let timeline_h = (body.height() * 0.42).round(); + let left_w = (body.width() * 0.2).round(); + let upper = Rect::from_min_max(body.min, pos2(body.right() - right_w - gap, body.bottom() - timeline_h - gap)); + let panel = |r: Rect, title_w: f32| { + painter.rect_filled(r, 2.0, p.panel_bg); + painter.rect_filled(Rect::from_min_size(r.min + vec2(4.0, 3.0), vec2(title_w, 2.0)), 1.0, p.tab_text_active); + }; + // Project panel: rows with label swatches. + let project = Rect::from_min_max(upper.min, pos2(upper.left() + left_w, upper.bottom())); + panel(project, 14.0); + for (i, label) in [5usize, 5, 2, 2].into_iter().enumerate() { + let y = project.top() + 10.0 + i as f32 * 6.0; + painter.rect_filled(Rect::from_min_size(pos2(project.left() + 4.0, y), vec2(3.0, 3.0)), 0.5, p.labels.get(label).copied().unwrap_or(p.icon)); + painter.rect_filled(Rect::from_min_size(pos2(project.left() + 9.0, y + 0.5), vec2(project.width() * 0.45, 2.0)), 1.0, p.text_dim); + } + // Composition panel: the pasteboard and the comp frame with a title. + let comp = Rect::from_min_max(pos2(project.right() + gap, upper.top()), upper.max); + panel(comp, 22.0); + let paste = Rect::from_min_max(comp.min + vec2(2.0, 8.0), comp.max - vec2(2.0, 2.0)); + painter.rect_filled(paste, 0.0, p.pasteboard); + let frame_h = (paste.height() - 6.0).max(4.0); + let frame = Rect::from_center_size(paste.center(), vec2((frame_h * 16.0 / 9.0).min(paste.width() - 6.0), frame_h)); + painter.rect_filled(frame, 0.0, egui::Color32::from_rgb(0x14, 0x1c, 0x4a)); + painter.circle_stroke(frame.center(), frame.height() * 0.36, egui::Stroke::new(0.8, egui::Color32::from_rgb(0x4a, 0x7c, 0xe8))); + painter.rect_filled(Rect::from_center_size(frame.center(), vec2(frame.width() * 0.5, 2.5)), 1.0, egui::Color32::WHITE); + // Preview and Properties. + let column = Rect::from_min_max(pos2(body.right() - right_w, body.top()), body.max); + let preview = Rect::from_min_max(column.min, pos2(column.right(), column.top() + column.height() * 0.3)); + panel(preview, 12.0); + painter.rect_filled(Rect::from_min_size(preview.left_bottom() + vec2(4.0, -5.0), vec2(preview.width() * 0.25, 1.5)), 0.5, p.cache_green); + let props = Rect::from_min_max(pos2(column.left(), preview.bottom() + gap), column.max); + panel(props, 18.0); + for i in 0..5 { + let y = props.top() + 10.0 + i as f32 * 6.0; + painter.rect_filled(Rect::from_min_size(pos2(props.left() + 4.0, y), vec2(props.width() * 0.35, 2.0)), 1.0, p.text_dim); + painter.rect_filled(Rect::from_min_size(pos2(props.left() + props.width() * 0.55, y), vec2(props.width() * 0.25, 2.0)), 1.0, p.hot_text); + } + // Timeline: the layer list and the time graph, with coloured layer bars and the time marker. + let timeline = Rect::from_min_max(pos2(body.left(), upper.bottom() + gap), pos2(upper.right(), body.bottom())); + panel(timeline, 18.0); + painter.rect_filled(Rect::from_min_size(timeline.min + vec2(4.0, 8.0), vec2(14.0, 3.0)), 1.0, p.timecode); + let graph = Rect::from_min_max(pos2(timeline.left() + timeline.width() * 0.38, timeline.top() + 8.0), timeline.max - vec2(2.0, 2.0)); + painter.rect_filled(graph, 0.0, p.tl_bg); + painter.rect_filled(Rect::from_min_size(graph.min, vec2(graph.width(), 4.0)), 0.0, p.tl_ruler_bg); + for (i, label) in [1usize, 1, 8, 8, 1].into_iter().enumerate() { + let y = graph.top() + 7.0 + i as f32 * 5.5; + if y + 3.5 > graph.bottom() { + break; + } + let swatch = p.labels.get(label).copied().unwrap_or(p.icon); + let row_l = timeline.left() + 4.0; + if i == 1 { + painter.rect_filled(Rect::from_min_max(pos2(timeline.left(), y - 1.0), pos2(graph.left(), y + 4.5)), 0.0, p.row_selected); + } + painter.rect_filled(Rect::from_min_size(pos2(row_l, y), vec2(3.0, 3.0)), 0.5, swatch); + painter.rect_filled(Rect::from_min_size(pos2(row_l + 6.0, y + 0.5), vec2(graph.left() - row_l - 14.0, 2.0)), 1.0, p.text_dim); + let bar = Rect::from_min_max(pos2(graph.left() + 1.0, y), pos2(graph.right() - 1.0, y + 3.5)); + painter.rect_filled(bar, 1.0, swatch.gamma_multiply(0.8)); + } + let cti = graph.left() + graph.width() * 0.3; + painter.line_segment([pos2(cti, graph.top()), pos2(cti, graph.bottom())], egui::Stroke::new(1.0, p.cti)); +} + +/// One theme family's card: its (translated) name, Active when the appearance shows it now, a preview of the +/// chosen theme and a radio button per theme. +fn theme_card( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + t: &Tokens, + key: &str, + title: &str, + active: bool, + cur: &str, + options: &[(&str, &str)], + acts: &mut Vec, +) { + egui::Frame::new() + .fill(t.field_bg) + .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.field_border })) + .corner_radius(6.0) + .inner_margin(egui::Margin::same(8)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label(RichText::new(title).font(Tokens::semibold(12.5)).color(t.text)); + if active { + ui.label(RichText::new(tr("Active")).color(t.accent).small()); + } + }); + ui.add_space(4.0); + let kind = ThemeKind::from_name(cur).unwrap_or(if key.ends_with("lightTheme") { ThemeKind::Light } else { ThemeKind::Dark }); + theme_preview(ui, kind, ui.available_width()); + ui.add_space(4.0); + for (label, value) in options { + let r = ui.radio(cur == *value, tr(label)); + reg(app, &format!("settings.{key}.{value}"), &r, label); + if r.clicked() && cur != *value { + acts.push(Act::Set(key.into(), json!(value))); + } + } + }); +} + +/// Settings ▸ Appearance: Appearance Mode (Sync with System, Dark, Light) above the light and dark +/// theme cards. +fn appearance_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, t: &Tokens, acts: &mut Vec) { + let get = |key: &str| cur.pointer(&format!("/appearance/{key}")).and_then(Value::as_str).unwrap_or_default().to_string(); + let (mode, light, dark) = (get("appearanceMode"), get("lightTheme"), get("darkTheme")); + ui.horizontal(|ui| { + ui.add_sized(vec2(250.0, 18.0), egui::Label::new(format!("{}:", tr("Appearance Mode"))).truncate()); + let shown = APPEARANCE_MODES.iter().find(|(_, v)| *v == mode).map_or(mode.as_str(), |(l, _)| *l); + let r = egui::ComboBox::from_id_salt(("settings", "appearance.appearanceMode")) + .selected_text(tr(shown)) + .width(220.0) + .show_ui(ui, |ui| { + for (l, v) in APPEARANCE_MODES { + if ui.selectable_label(*v == mode, tr(l)).clicked() { + acts.push(Act::Set("appearance.appearanceMode".into(), json!(v))); + } + } + }) + .response; + reg(app, "settings.appearance.appearanceMode", &r, "Appearance Mode"); + }); + ui.add_space(6.0); + let system_light = app.system_light(ui.ctx()); + let light_active = mode == "light" || (mode == "auto" && system_light == Some(true)); + ui.columns(2, |cols| { + let [l, d] = cols else { return }; + theme_card(app, l, t, "appearance.lightTheme", tr("Light Theme"), light_active, &light, LIGHT_THEMES, acts); + theme_card(app, d, t, "appearance.darkTheme", tr("Dark Theme"), !light_active, &dark, DARK_THEMES, acts); + }); + ui.add_space(6.0); +} + /// Bytes as "12.3 MB". pub fn human_bytes(b: u64) -> String { let units = ["bytes", "KB", "MB", "GB", "TB"]; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 74ab0b7e..a37bead6 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -26,6 +26,19 @@ impl ThemeKind { _ => None, } } + + /// The settings value (`appearance.theme`, `appearance.darkTheme`, `appearance.lightTheme`). + pub fn name(self) -> &'static str { + match self { + ThemeKind::Dark => "dark", + ThemeKind::Darker => "darker", + ThemeKind::Light => "light", + } + } + + pub fn is_light(self) -> bool { + self == ThemeKind::Light + } } #[derive(Clone, Copy, Debug, PartialEq)] @@ -213,8 +226,9 @@ impl Tokens { } /// Theme tokens for the current settings: theme, UI brightness and label colours. - pub fn from_prefs(p: &effectcraft_engine::prefs::Prefs) -> Tokens { - let kind = ThemeKind::from_name(&p.appearance.theme).unwrap_or_default(); + /// The tokens for theme `kind` (resolved from the appearance mode) with the rest of the + /// Appearance settings: brightness, label colours, gradients. + pub fn from_prefs(p: &effectcraft_engine::prefs::Prefs, kind: ThemeKind) -> Tokens { let mut t = Tokens::for_kind(kind).with_brightness(p.appearance.brightness as f32); for (i, l) in effectcraft_color::Label::ALL.iter().enumerate() { let [r, g, b] = p.label_rgb(*l); @@ -387,7 +401,11 @@ fn is_chinese(language: &str) -> bool { } 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.is_light(); + // egui keeps one style per light/dark theme and by default picks it by the system appearance; + // pin it to ours so a fixed Dark or Light mode isn't swapped for egui's defaults. + ctx.set_theme(if light { egui::ThemePreference::Light } else { egui::ThemePreference::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/docs/preferences.md b/docs/preferences.md index 9e3d4147..416eff24 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -54,7 +54,10 @@ Commands (CLI, MCP, control channel): without input or edits, the viewer renders the work area into the RAM preview in the background, from the current time on, until it is cached or the budget is full - `previews.fastPreviews`: Fast Previews (Draft 3D, Faster Effects) -- `appearance.theme`: Theme +- `appearance.appearanceMode`: Appearance Mode (`auto` / `dark` / `light`). `auto` (Sync with System) follows the operating system's light or dark appearance while the app runs: on Linux the desktop app reads the XDG desktop portal (`org.freedesktop.appearance` `color-scheme`) once and then listens for its `SettingChanged` signal on a worker thread, repainting only when the value changes (nothing polls); `gsettings` runs at most once at start-up, by absolute path, when the portal gives no answer. Elsewhere it uses what the window system reports. With no answer it shows the dark theme. New installs stay `dark`; Sync with System is opt-in. The Tools bar's appearance button (a monitor, sun or moon) cycles Sync with System, Light and Dark (`view.theme.toggle`, Next Appearance Mode). +- `appearance.darkTheme`: Dark Theme (`dark` / `darker`), shown in dark appearance +- `appearance.lightTheme`: Light Theme (`light`), shown in light appearance. Settings ▸ Appearance shows the mode above a light and a dark theme card, each with a preview of the EffectCraft window in that theme. +- `appearance.theme`: the single theme from before appearance modes, kept for older settings files and automation. A saved file with only `theme` loads as a fixed Dark or Light mode showing it; setting it (`prefs.set`, `ui.set {"theme": …}`, `view.theme.dark|darker|light`) selects that theme and fixes the mode to its family. While the mode is Dark or Light it mirrors the theme in use. - `appearance.brightness`: Brightness - `appearance.uiScale`: UI Scale, the size of the whole interface (75–200 %, on top of the display's own scaling), applied as soon as it is chosen