Browse / github.com/storytold/photocraft / patch
feat(ui): follow a desktop theme file and tint the UI with the desktop accent in Linux
feat(ui): follow a desktop theme file and tint the UI with the desktop accent in Linux ## What Opt-in desktop theming for PhotoCraft's UI: - **Follow a theme file** — a `theme.json` colour file (`~/.config/photocraft/theme.json`) recolors the UI in release builds; removing the file falls back to the selected stock theme. - **Accent colors as the master switch** — Preferences › Interface › Accent colors gates all desktop theming. On: the theme file's palette applies live, its own accent winning over the desktop portal accent (the GNOME portal accent applies only in a GNOME session). Off + Apply: the shipped stock accents and theme return. - Selected tool icons draw in the
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Jazzmedo (imported from pull request #9752, open)
- Base
7ac7b8b106acccf6826d45ed5c9e481dfe341035- Size
- +788 −24 in 12 files
- Review
- pending
- Shared
- 2026-10-28
- Id
30bc71c6846e08b726469c38
How it went
No other operator has reported using it yet.
Diff
raw · apps/photocraft/src/appearance.rs apps/photocraft/src/caption_style.rs apps/photocraft/src/main.rs apps/photocraft/src/services.rs contributors/people.toml crates/engine/src/prefs.rs crates/engine/src/prefs/tests.rs crates/ui-egui/src/icons.rs crates/ui-egui/src/theme.rs crates/ui-egui/src/theme/tests.rs docs/development.md docs/ui-design.md
@@ -146,8 +146,299 @@ fn start(worker: impl FnOnce(&Publisher) + Send + 'static) -> Option<photocraft_ })) } +// ---- Desktop accent colour ----+//+// The same portal namespace carries `accent-color` on GNOME 47+ and Plasma 6+. When the portal+// has no answer, GNOME's named accent or KDE's `kdeglobals` are read once at start-up; those+// fallbacks do not update live (only the portal path repaints on change).++#[cfg(target_os = "linux")]+const ACCENT_PORTAL_KEY: &str = "accent-color";++/// GNOME's nine named accents; the empty string is the default appearance (blue).+#[cfg(target_os = "linux")]+fn gnome_named_accent(name: &str) -> Option<egui::Color32> {+ let (r, g, b) = match name.trim().trim_matches('\'') {+ "" | "blue" => (0x35, 0x84, 0xe4),+ "teal" => (0x21, 0x90, 0xa4),+ "green" => (0x3a, 0x94, 0x4a),+ "yellow" => (0xc8, 0x88, 0x00),+ "orange" => (0xed, 0x5b, 0x00),+ "red" => (0xe6, 0x2d, 0x42),+ "pink" => (0xd5, 0x61, 0x99),+ "purple" => (0x91, 0x41, 0xac),+ "slate" => (0x6f, 0x83, 0x96),+ _ => return None,+ };+ Some(egui::Color32::from_rgb(r, g, b))+}++/// Accepts `#rrggbb`, `rrggbb` and `r,g,b` text (KDE writes the last form), plus `(f,f,f)` and+/// `(r,g,b)` tuples that some portals stringify.+#[cfg(target_os = "linux")]+fn parse_accent_text(text: &str) -> Option<egui::Color32> {+ if text.trim_start().starts_with('(')+ && let Some((r, g, b)) = parse_accent_floats_tuple(text)+ {+ // The portal's spec form is 0..1 floats; a stringified byte triple is also accepted.+ return if r <= 1.0 && g <= 1.0 && b <= 1.0 { accent_from_floats(r, g, b) } else { Some(egui::Color32::from_rgb(r as u8, g as u8, b as u8)) };+ }+ let text = text.trim().trim_start_matches('#');+ let radix = if text.contains(',') { 10 } else { 16 };+ let (r, g, b) = if text.contains(',') {+ let mut parts = text.split(',');+ (parts.next()?.trim(), parts.next()?.trim(), parts.next()?.trim())+ } else if text.len() >= 6 {+ (text.get(0..2)?, text.get(2..4)?, text.get(4..6)?)+ } else {+ return None;+ };+ Some(egui::Color32::from_rgb(u8::from_str_radix(r, radix).ok()?, u8::from_str_radix(g, radix).ok()?, u8::from_str_radix(b, radix).ok()?))+}++#[cfg(target_os = "linux")]+fn accent_from_floats(r: f64, g: f64, b: f64) -> Option<egui::Color32> {+ let channel = |v: f64| u8::try_from((v.clamp(0.0, 1.0) * 255.0).round() as i64).ok();+ Some(egui::Color32::from_rgb(channel(r)?, channel(g)?, channel(b)?))+}++#[cfg(target_os = "linux")]+fn parse_accent_floats_tuple(text: &str) -> Option<(f64, f64, f64)> {+ let text = text.trim_start_matches('(').trim_end_matches(')');+ let (r, rest) = text.split_once(',')?;+ let (g, b) = rest.split_once(',')?;+ Some((r.trim().parse().ok()?, g.trim().parse().ok()?, b.trim().parse().ok()?))+}++/// The accent from the portal's `Read` result, unwrapping nested variants. The spec value is an+/// `(ddd)` of 0..1 floats; a text form is accepted for portals that predate it.+#[cfg(target_os = "linux")]+fn portal_accent(proxy: &zbus::blocking::Proxy<'_>) -> Option<egui::Color32> {+ let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, ACCENT_PORTAL_KEY)).ok()?;+ decode_accent_value(value)+}++#[cfg(target_os = "linux")]+fn decode_accent_value(value: zbus::zvariant::OwnedValue) -> Option<egui::Color32> {+ let mut value: zbus::zvariant::Value<'_> = value.into();+ for _ in 0..4 {+ match value {+ zbus::zvariant::Value::Value(inner) => value = *inner,+ other => {+ if let Ok((r, g, b)) = <(f64, f64, f64)>::try_from(other.clone()) {+ return accent_from_floats(r, g, b);+ }+ if let Ok(text) = <&str>::try_from(&other) {+ return parse_accent_text(text);+ }+ if let Ok(text) = <String>::try_from(other) {+ return parse_accent_text(&text);+ }+ return None;+ }+ }+ }+ None+}++/// GNOME fallback: the named accent from `gsettings`, read only on the accent worker — and only+/// in a GNOME session, where an unset accent legitimately means the default blue. `gsettings`+/// ships with GLib everywhere (Kubuntu included), and outside GNOME it answers with that same+/// empty string, which would hide the desktop's real accent behind GNOME's blue.+#[cfg(target_os = "linux")]+fn gnome_accent() -> Option<egui::Color32> {+ if !gnome_session(&|k| std::env::var(k).ok()) {+ return None;+ }+ gnome_named_accent(&gsettings_get("org.gnome.desktop.interface", "accent-color")?)+}++/// Whether the session reports itself as GNOME.+#[cfg(target_os = "linux")]+fn gnome_session(var: &dyn Fn(&str) -> Option<String>) -> bool {+ var("XDG_CURRENT_DESKTOP").is_some_and(|d| d.to_uppercase().contains("GNOME"))+}++#[cfg(target_os = "linux")]+fn gsettings_get(schema: &str, key: &str) -> Option<String> {+ 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", schema, key]).output().ok()?;+ if !output.status.success() {+ return None;+ }+ Some(std::str::from_utf8(&output.stdout).ok()?.trim().to_owned())+}++/// KDE fallback: `AccentColor` from `kdeglobals` (`General`), falling back to the scheme's+/// selection background. Read once at start-up with the environment injected for tests.+#[cfg(target_os = "linux")]+fn kde_accent(var: &dyn Fn(&str) -> Option<String>) -> Option<egui::Color32> {+ let config = var("XDG_CONFIG_HOME")+ .filter(|d| std::path::Path::new(d).is_absolute())+ .map(std::path::PathBuf::from)+ .or_else(|| var("HOME").map(|h| std::path::Path::new(&h).join(".config")))?;+ let text = crate::caption_style::read_text(&config.join("kdeglobals")).unwrap_or_default();+ parse_accent_text(+ &crate::caption_style::ini_value(&text, "General", "AccentColor")+ .or_else(|| crate::caption_style::ini_value(&text, "Colors:View", "SelectionBackground"))?,+ )+}++/// pywal fallback: `color4` from `~/.cache/wal/colors.json` (the palette adapters' accent).+#[cfg(target_os = "linux")]+fn wal_accent(var: &dyn Fn(&str) -> Option<String>) -> Option<egui::Color32> {+ let home = var("HOME").filter(|d| std::path::Path::new(d).is_absolute())?;+ let path = std::path::Path::new(&home).join(".cache/wal/colors.json");+ let json: serde_json::Value = serde_json::from_str(&crate::caption_style::read_text(&path)?).ok()?;+ parse_accent_text(json.get("color4")?.as_str()?)+}++/// Reads the desktop accent once, then follows the portal's `accent-color` changes the same way+/// [`service`] follows the colour scheme. While the portal answers, its accent is authoritative;+/// while it does not, the fallback settings files are polled, so an accent picked in the+/// desktop's settings later still reaches the app.+pub fn accent_service() -> Option<photocraft_ui_egui::AccentFn> {+ #[cfg(target_os = "linux")]+ {+ accent_start(|publisher| {+ let proxy = portal_proxy();+ // Subscribe before the first read, so a change during start-up isn't lost.+ let mut signals = None;+ let via_portal = portal_startup(+ || signals = proxy.as_ref().and_then(|proxy| proxy.receive_signal("SettingChanged").ok()),+ || proxy.as_ref().and_then(portal_accent),+ );+ let initial = via_portal.or_else(fallback_accent);+ if let Some(c) = initial {+ log::info!("desktop accent #{:02x}{:02x}{:02x}", c.r(), c.g(), c.b());+ }+ publisher.publish(initial);+ // The portal signals accent changes on GNOME 47+ and Plasma; where it has no answer+ // (older portals, or accents read from files), the fallback files are polled instead.+ let portal_authoritative = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(via_portal.is_some()));+ let mut files_watched = false;+ if via_portal.is_none() {+ publisher.clone().watch_files(std::sync::Arc::clone(&portal_authoritative));+ files_watched = true;+ }+ 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 != ACCENT_PORTAL_KEY {+ continue;+ }+ let decoded = decode_accent_value(changed);+ let (portal_now, watch_now) = after_signal(decoded.is_some(), files_watched);+ if watch_now && !files_watched {+ publisher.clone().watch_files(std::sync::Arc::clone(&portal_authoritative));+ }+ files_watched = watch_now;+ portal_authoritative.store(portal_now, std::sync::atomic::Ordering::Relaxed);+ publisher.publish(decoded.or_else(fallback_accent));+ }+ })+ }+ #[cfg(not(target_os = "linux"))]+ {+ None+ }+}++/// What a portal accent signal leaves behind: whether the portal still owns the accent, and+/// whether the fallback file poller must be running. A portal that stops answering hands the+/// accent to the fallback files from then on; one that answers again takes it back.+#[cfg(target_os = "linux")]+fn after_signal(portal_answered: bool, files_watched: bool) -> (bool, bool) {+ (portal_answered, files_watched || !portal_answered)+}++/// The accent from the desktop's settings files, when the portal has none: GNOME's named accent,+/// KDE's `kdeglobals`, then pywal's palette. Called on the worker threads only.+#[cfg(target_os = "linux")]+fn fallback_accent() -> Option<egui::Color32> {+ let env = |k: &str| std::env::var(k).ok();+ gnome_accent().or_else(|| kde_accent(&env)).or_else(|| wal_accent(&env))+}++/// The worker's handle on the shared accent. Packed as `0x0100_0000 | rgb`, with 0 meaning+/// "no accent", so even a pure black accent counts as present.+#[cfg(target_os = "linux")]+#[derive(Clone)]+struct AccentPublisher {+ value: std::sync::Arc<std::sync::atomic::AtomicU32>,+ wake: std::sync::Arc<std::sync::OnceLock<egui::Context>>,+ ready: std::sync::mpsc::Sender<()>,+}++#[cfg(target_os = "linux")]+impl AccentPublisher {+ fn publish(&self, accent: Option<egui::Color32>) {+ let packed = accent.map_or(0, |c| 0x0100_0000 | (u32::from(c.r()) << 16) | (u32::from(c.g()) << 8) | u32::from(c.b()));+ if self.value.swap(packed, std::sync::atomic::Ordering::Relaxed) != packed+ && let Some(ctx) = self.wake.get()+ {+ ctx.request_repaint();+ }+ let _ = self.ready.send(());+ }++ /// Follow the settings files the portal does not signal (`kdeglobals`, pywal), polling every+ /// couple of seconds; `publish` only wakes the UI on a real change. While+ /// `portal_authoritative` is set, the poller stays quiet so the portal's accent wins.+ fn watch_files(self, portal_authoritative: std::sync::Arc<std::sync::atomic::AtomicBool>) {+ let _ = std::thread::Builder::new().name("accent-files".into()).spawn(move || {+ loop {+ std::thread::sleep(std::time::Duration::from_secs(2));+ if !portal_authoritative.load(std::sync::atomic::Ordering::Relaxed) {+ self.publish(fallback_accent());+ }+ }+ });+ }+}++/// Runs `worker` on its own thread and waits up to 250 ms for its first value, so a fast desktop+/// answers before the first frame.+#[cfg(target_os = "linux")]+fn accent_start(worker: impl FnOnce(&AccentPublisher) + Send + 'static) -> Option<photocraft_ui_egui::AccentFn> {+ use std::sync::{Arc, OnceLock, atomic::AtomicU32};+ let value = Arc::new(AtomicU32::new(0));+ let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());+ let (ready, wait) = std::sync::mpsc::channel();+ let publisher = AccentPublisher { value: Arc::clone(&value), wake: Arc::clone(&wake), ready };+ std::thread::Builder::new().name("accent-portal".into()).spawn(move || worker(&publisher)).ok()?;+ let _ = wait.recv_timeout(std::time::Duration::from_millis(250));+ Some(Box::new(move |ctx| {+ let _ = wake.set(ctx.clone());+ unpack_accent(value.load(std::sync::atomic::Ordering::Relaxed))+ }))+}++#[cfg(target_os = "linux")]+fn unpack_accent(packed: u32) -> Option<egui::Color32> {+ if packed & 0x0100_0000 == 0 {+ return None;+ }+ Some(egui::Color32::from_rgb(((packed >> 16) & 0xff) as u8, ((packed >> 8) & 0xff) as u8, (packed & 0xff) as u8))+}+ #[cfg(test)] mod tests {+ #[cfg(target_os = "linux")]+ #[test]+ fn a_portal_that_stops_answering_hands_the_accent_to_the_fallback_files() {+ use super::after_signal;+ // A portal that keeps answering stays authoritative and never needs the file poller.+ assert_eq!(after_signal(true, false), (true, false));+ assert_eq!(after_signal(true, true), (true, true));+ // A signal without a portal accent hands the accent to the fallback files — once,+ // even if the initial portal read had an accent and never started the poller.+ assert_eq!(after_signal(false, false), (false, true));+ assert_eq!(after_signal(false, true), (false, true));+ }+ #[cfg(target_os = "linux")] #[test] fn startup_keeps_changes_emitted_during_initial_read() {@@ -190,6 +481,84 @@ mod tests { assert_eq!(super::parse_gtk_scheme("'not-a-scheme'"), None); } + #[cfg(target_os = "linux")]+ #[test]+ fn accent_text_forms() {+ assert_eq!(super::parse_accent_text("#3584e4"), Some(egui::Color32::from_rgb(0x35, 0x84, 0xe4)));+ assert_eq!(super::parse_accent_text("3584e4"), Some(egui::Color32::from_rgb(0x35, 0x84, 0xe4)));+ // KDE's byte triple must not be mistaken for the portal's 0..1 floats.+ assert_eq!(super::parse_accent_text("237,91,0"), Some(egui::Color32::from_rgb(237, 91, 0)));+ assert_eq!(super::parse_accent_text("(0.5, 0.25, 0.0)"), Some(egui::Color32::from_rgb(128, 64, 0)));+ assert_eq!(super::parse_accent_text("(237, 91, 0)"), Some(egui::Color32::from_rgb(237, 91, 0)));+ assert_eq!(super::parse_accent_text("#358"), None);+ assert_eq!(super::parse_accent_text("nonsense"), None);+ }++ #[cfg(target_os = "linux")]+ #[test]+ fn gnome_named_accents() {+ let blue = egui::Color32::from_rgb(0x35, 0x84, 0xe4);+ assert_eq!(super::gnome_named_accent(""), Some(blue), "the default appearance is blue");+ assert_eq!(super::gnome_named_accent("'orange'"), Some(egui::Color32::from_rgb(0xed, 0x5b, 0x00)));+ assert_eq!(super::gnome_named_accent("magenta"), None);+ }++ /// The GNOME gsettings fallback applies only in a GNOME session: elsewhere gsettings (which+ /// ships with GLib everywhere) answers with the same empty "default" string and would hide+ /// the desktop's real accent behind GNOME's blue.+ #[cfg(target_os = "linux")]+ #[test]+ fn the_gnome_fallback_only_applies_in_a_gnome_session() {+ let env = &[("XDG_CURRENT_DESKTOP", "ubuntu:GNOME")];+ assert!(super::gnome_session(&|k| env.iter().find(|(n, _)| *n == k).map(|(_, v)| v.to_string())));+ let kde = &[("XDG_CURRENT_DESKTOP", "KDE")];+ assert!(!super::gnome_session(&|k| kde.iter().find(|(n, _)| *n == k).map(|(_, v)| v.to_string())));+ assert!(!super::gnome_session(&|_| None), "unset: not a GNOME session");+ }++ /// The KDE fallback reads `AccentColor` (byte triple or hex) with the scheme's selection+ /// background behind it, through an injected config directory.+ #[cfg(target_os = "linux")]+ #[test]+ fn kde_accent_reads_kdeglobals() {+ let dir = std::env::temp_dir().join(format!("photocraft-kde-accent-{}", std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ let var = |name: &str| (name == "XDG_CONFIG_HOME").then(|| dir.to_string_lossy().into_owned());+ std::fs::write(dir.join("kdeglobals"), "[General]\nAccentColor=237,91,0\n").unwrap();+ assert_eq!(super::kde_accent(&var), Some(egui::Color32::from_rgb(237, 91, 0)));+ std::fs::write(dir.join("kdeglobals"), "[General]\nKWinPaletteUseDark=true\n[Colors:View]\nSelectionBackground=#3584e4\n").unwrap();+ assert_eq!(super::kde_accent(&var), Some(egui::Color32::from_rgb(0x35, 0x84, 0xe4)));+ std::fs::remove_dir_all(&dir).ok();+ let no_home = |name: &str| (name == "HOME").then(|| "/photocraft-no-home".to_string());+ assert_eq!(super::kde_accent(&no_home), None, "no kdeglobals, no accent");+ }++ /// The "present" flag keeps a pure black accent from reading as "no accent".+ #[cfg(target_os = "linux")]+ #[test]+ fn accent_packing_round_trips_black() {+ assert_eq!(super::unpack_accent(0), None);+ let black = egui::Color32::BLACK;+ let packed = 0x0100_0000 | (u32::from(black.r()) << 16) | (u32::from(black.g()) << 8) | u32::from(black.b());+ assert_eq!(super::unpack_accent(packed), Some(black));+ assert_eq!(super::unpack_accent(0x0100_0000 | 0xed_5b_00), Some(egui::Color32::from_rgb(0xed, 0x5b, 0x00)));+ }++ /// The pywal fallback reads `color4` from the cache file, through an injected home.+ #[cfg(target_os = "linux")]+ #[test]+ fn wal_accent_reads_color4() {+ let home = std::env::temp_dir().join(format!("photocraft-wal-accent-{}", std::process::id()));+ std::fs::create_dir_all(home.join(".cache/wal")).unwrap();+ let var = |name: &str| (name == "HOME").then(|| home.to_string_lossy().into_owned());+ std::fs::write(home.join(".cache/wal/colors.json"), r##"{"color0": "#000000", "color4": "#7aa2f7", "special": {"background": "#1a1b26"}}"##).unwrap();+ assert_eq!(super::wal_accent(&var), Some(egui::Color32::from_rgb(0x7a, 0xa2, 0xf7)));+ let no_home = |name: &str| (name == "HOME").then(|| "/photocraft-no-home".to_string());+ assert_eq!(super::wal_accent(&no_home), None, "no palette, no accent");+ std::fs::remove_dir_all(&home).ok();+ }++ /// #2798: a first read that misses the start-up wait still wakes the idle UI. /// #2798: a first read that misses the start-up wait still wakes the idle UI. #[cfg(target_os = "linux")] #[test]@@ -63,7 +63,7 @@ fn family(desktop: Desktop, gtk_theme: Option<&str>) -> CaptionFamily { /// The value of `key` in `[section]` of an INI-style file (GTK's `settings.ini`, KDE's `kwinrc`). #[cfg(target_os = "linux")]-fn ini_value(text: &str, section: &str, key: &str) -> Option<String> {+pub(crate) fn ini_value(text: &str, section: &str, key: &str) -> Option<String> { let mut inside = false; for line in text.lines().map(str::trim) { if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {@@ -133,7 +133,7 @@ fn resolve(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option<String> } #[cfg(target_os = "linux")]-fn read_text(path: &std::path::Path) -> Option<String> {+pub(crate) fn read_text(path: &std::path::Path) -> Option<String> { crate::icon_theme::read_small(path).map(|b| String::from_utf8_lossy(&b).into_owned()) } @@ -476,6 +476,10 @@ fn main() -> eframe::Result { let session = Session::new(); PhotocraftApp::install_type_reset_hooks(&cc.egui_ctx); let mut app = PhotocraftApp::new(session, services);+ // Desktop palette tools rewrite this file on a theme change; the app follows it live (#8112).+ if let Some(dir) = app_dirs::config_dir() {+ app.follow_theme_file(dir.join("theme.json"));+ } app.min_frame_ms = uncapped.then_some(1000.0 / 240.0); app.integrated_titlebar = cfg!(target_os = "macos"); app.custom_titlebar = custom_titlebar;@@ -691,6 +691,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) -> photoshop_shortcuts: Some(Box::new(|| crate::photoshop_settings::live_keyboard_shortcuts(&|k| std::env::var_os(k)))), save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))), system_theme: crate::appearance::service(),+ accent_color: crate::appearance::accent_service(), append_text: Some(Box::new(|path: &str, text: &str| { use std::io::Write; let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path).map_err(|e| e.to_string())?;@@ -282,3 +282,6 @@ sync_github_name = false [people.jasallen] real_name = "Jason DeBoever" sync_github_name = true++[people.Jazzmedo]+display_name = "Jazzmedo (7anafi)"@@ -261,6 +261,9 @@ pub struct Interface { pub light_theme: LightTheme, /// Theme shortcuts shown in the appearance button's context menu. pub favorite_themes: Vec<Theme>,+ /// Tint the selected theme's accent family with the desktop accent colour (on by default;+ /// off restores the shipped accents).+ pub accent_colors: bool, /// Pasteboard colour in standard screen mode (`canvasCustomColor` when "custom"). pub canvas_color: CanvasColor, pub canvas_custom_color: String,@@ -315,6 +318,7 @@ impl Default for Interface { dark_theme: DarkTheme::ProMedium, light_theme: LightTheme::ProLight, favorite_themes: Vec::new(),+ accent_colors: true, canvas_color: CanvasColor::Default, canvas_custom_color: "#282828".into(), show_canvas_dots: true,@@ -64,6 +64,31 @@ fn canvas_dot_grid_can_be_disabled_persisted_and_reset() { assert!(s.prefs().interface.show_canvas_dots); } +/// The desktop-accent tint is on by default, the choice persists, and opting out restores the+/// shipped accents.+#[test]+fn accent_colors_preference_defaults_on_persists_and_resets() {+ let mut s = Session::new();+ assert!(s.prefs().interface.accent_colors, "the desktop accent is followed by default");+ assert_eq!(s.execute("prefs.get", json!({"path": "interface.accentColors"})).unwrap(), json!(true));++ assert!(s.execute("prefs.set", json!({"path": "interface.accentColors", "value": "yes"})).is_err());+ assert!(s.prefs().interface.accent_colors, "an invalid type must not change the preference");++ s.execute("prefs.set", json!({"path": "interface.accentColors", "value": false})).unwrap();+ assert!(!s.prefs().interface.accent_colors);+ let mut restored = Session::new();+ restored.load_prefs_json(&s.prefs_to_json()).unwrap();+ assert!(!restored.prefs().interface.accent_colors, "the choice must survive restart");++ let mut legacy = Session::new();+ legacy.load_prefs_json(r#"{"interface": {"theme": "studio"}}"#).unwrap();+ assert!(legacy.prefs().interface.accent_colors);++ s.execute("prefs.reset", json!({"path": "interface.accentColors"})).unwrap();+ assert!(s.prefs().interface.accent_colors);+}+ /// UI-217-10: the Home screen's recent-files list can be switched off and the choice persists. #[test] fn home_recent_files_preference_defaults_on_persists_and_resets() {@@ -163,7 +163,7 @@ pub fn button_chrome(ui: &egui::Ui, rect: Rect, selected: bool, hovered: bool) - ui.painter().rect_filled(rect, t.radius_sm, t.hover); } if selected {- t.accent_text+ t.icon } else if hovered { t.text } else {@@ -189,7 +189,7 @@ pub fn button_with_icon_size_tip(ui: &mut egui::Ui, name: &str, box_size: f32, i normal: ButtonVisuals { fill: Color32::TRANSPARENT, stroke: egui::Stroke::NONE, icon: t.icon }, hovered: ButtonVisuals { fill: t.hover, stroke: egui::Stroke::NONE, icon: t.text }, pressed: None,- selected: ButtonVisuals { fill: t.accent_soft, stroke: egui::Stroke::new(1.0, t.accent_border), icon: t.accent_text },+ selected: ButtonVisuals { fill: t.accent_soft, stroke: egui::Stroke::new(1.0, t.accent_border), icon: t.icon }, corner_radius: t.radius_sm.into(), focus_stroke: egui::Stroke::new(1.0, t.accent), };@@ -1008,10 +1008,39 @@ pub fn mono(size: f32) -> FontId { /// Apply a theme to egui's global style and publish its tokens. pub fn apply(ctx: &egui::Context, kind: ThemeKind) {+ apply_tokens(ctx, Tokens::for_kind(kind));+}++/// Retint the accent family of a stock palette with a desktop accent colour, so the+/// selected theme keeps its tuned surfaces in both modes while following the desktop's+/// accent. Text on accent fills picks black or white by WCAG contrast; `accent_text` keeps+/// the theme's own colour, as it is drawn on the theme's surfaces, not on accent fills.+pub fn tint_accent(t: &mut Tokens, accent: Color32) {+ let mix = |a: Color32, b: Color32, tt: f32| {+ let f = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * tt).round() as u8;+ Color32::from_rgb(f(a.r(), b.r()), f(a.g(), b.g()), f(a.b(), b.b()))+ };+ let linear = |v: u8| {+ let c = f32::from(v) / 255.0;+ if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }+ };+ let l = 0.2126 * linear(accent.r()) + 0.7152 * linear(accent.g()) + 0.0722 * linear(accent.b());+ // Whichever of black or white contrasts more with the accent, always at least 4.5:1.+ let on_accent = if (l + 0.05) / 0.05 >= 1.05 / (l + 0.05) { Color32::BLACK } else { Color32::WHITE };+ t.accent = accent;+ t.accent_border = accent;+ t.primary_bg = accent;+ t.primary_text = on_accent;+ t.accent_soft = mix(t.card, accent, 0.30);+ t.row_selected = mix(t.card, accent, 0.22);+}++/// Publish ready tokens and build egui's global style from them, so the visuals always match+/// `Tokens::get` — also for the live overrides, which recolour a stock palette.+pub(crate) fn apply_tokens(ctx: &egui::Context, t: Tokens) { // Keep native windows at SystemDefault so macOS continues reporting OS appearance changes, // including when an integration or restored egui state pinned its own theme preference. ctx.set_theme(egui::ThemePreference::System);- let t = Tokens::for_kind(kind); ctx.data_mut(|d| d.insert_temp(egui::Id::unique("photocraft-theme"), t)); let mut v = if t.dark() { Visuals::dark() } else { Visuals::light() }; v.panel_fill = t.chrome;@@ -1106,52 +1135,126 @@ pub fn canvas_bg(t: &Tokens) -> Color32 { #[cfg(test)] mod tests; -/// Development feature: live design-token overrides.+/// Live design-token overrides from a watched file. ///-/// Set `PHOTOCRAFT_THEME_FILE=/path/tokens.json` in a debug build; the file is polled and applied-/// on change, so colours, radii and sizes can be tuned without recompiling. Keys are `Tokens` field-/// names; values are `"#rrggbb"`, `"#rrggbbaa"` or numbers. Compiled out of release builds.-#[cfg(all(debug_assertions, not(target_arch = "wasm32")))]+/// Release builds follow the app's config-dir `theme.json`, so desktop palette tools (matugen,+/// pywal, base16) can recolour the app by rewriting it; the app shell hands the watcher that+/// default through [`PhotocraftApp::follow_theme_file`](crate::PhotocraftApp::follow_theme_file).+/// `PHOTOCRAFT_THEME_FILE` points the watcher at a different file. The file is polled and applied+/// on change, so colours, radii and sizes can be tuned without recompiling. Keys are `Tokens`+/// field names; values are `"#rrggbb"`, `"#rrggbbaa"` or numbers.+#[cfg(not(target_arch = "wasm32"))] pub mod live { use super::Tokens; use egui::Color32;- use std::time::SystemTime;+ use std::time::{Duration, SystemTime};++ /// How often the file is checked while it exists (palette tools rewrite it in place).+ const PRESENT_POLL: Duration = Duration::from_millis(400);+ /// How often it is checked while it is missing, so a dropped-in file is noticed.+ const ABSENT_POLL: Duration = Duration::from_secs(2); #[derive(Default)] pub struct LiveTokens { path: Option<std::path::PathBuf>, stamp: Option<SystemTime>, last_check: f64,+ last_kind: Option<super::ThemeKind>,+ /// The theme.json contents as last parsed, for the accent tint to layer over.+ file: Option<serde_json::Value>,+ /// Preferences › Interface › Accent colors is the master switch for desktop theming:+ /// when it is off the theme file is ignored and the stock themes apply.+ pub disabled: bool, } impl LiveTokens { pub fn from_env() -> Self {- Self { path: std::env::var_os("PHOTOCRAFT_THEME_FILE").map(Into::into), ..Default::default() }+ Self::from_env_for(|name| std::env::var_os(name))+ }++ /// `PHOTOCRAFT_THEME_FILE` opts in; the config-dir default arrives separately+ /// through [`Self::set_default`].+ fn from_env_for(env: impl Fn(&str) -> Option<std::ffi::OsString>) -> Self {+ Self { path: env("PHOTOCRAFT_THEME_FILE").filter(|p| !p.is_empty()).map(Into::into), ..Default::default() }+ }++ /// Follow this file when `PHOTOCRAFT_THEME_FILE` is unset. The app shell passes the+ /// config directory's theme.json; tests construct the watcher without one, so they never+ /// see the developer's file.+ pub fn set_default(&mut self, path: std::path::PathBuf) {+ if self.path.is_none() {+ self.path = Some(path);+ } } /// Re-apply overrides if the file changed. Returns true when tokens were updated. pub fn poll(&mut self, ctx: &egui::Context, kind: super::ThemeKind) -> bool {+ self.poll_at(ctx, kind, ctx.input(|i| i.time))+ }++ /// The theme file's overrides as last read, so the desktop accent tint+ /// ([`super::tint_accent`]) can re-apply them under itself.+ pub fn overrides(&self) -> Option<&serde_json::Value> {+ self.file.as_ref()+ }++ /// `poll` with an injectable clock, so tests can step past the throttle.+ fn poll_at(&mut self, ctx: &egui::Context, kind: super::ThemeKind, now: f64) -> bool { let Some(path) = &self.path else { return false };- let now = ctx.input(|i| i.time);- if now - self.last_check < 0.4 {- ctx.request_repaint_after(std::time::Duration::from_millis(400));+ if self.disabled {+ // Desktop theming is switched off: the stock theme applies, as if there were no+ // file. Re-enabled, the stamp mismatch below re-reads it at once.+ let had_stamp = self.stamp.take().is_some();+ let had_file = self.file.take().is_some();+ if had_stamp || had_file {+ super::apply(ctx, kind);+ }+ ctx.request_repaint_after(ABSENT_POLL);+ return had_stamp || had_file;+ }+ // Switching the base theme re-applies the stock palette, which would drop the+ // overrides; re-apply them at once instead of waiting for the throttle or a file change.+ let kind_changed = self.last_kind != Some(kind);+ if !kind_changed && now - self.last_check < 0.4 {+ ctx.request_repaint_after(PRESENT_POLL); return false; } self.last_check = now;- ctx.request_repaint_after(std::time::Duration::from_millis(400));- let Ok(meta) = std::fs::metadata(path) else { return false };+ self.last_kind = Some(kind);+ let Ok(meta) = std::fs::metadata(path) else {+ // The file is gone: fall back to the stock theme instead of keeping the last+ // overrides until restart.+ let had_overrides = self.stamp.take().is_some() || self.file.is_some();+ self.file = None;+ if had_overrides {+ super::apply(ctx, kind);+ }+ ctx.request_repaint_after(ABSENT_POLL);+ return had_overrides;+ }; let stamp = meta.modified().ok();- if stamp == self.stamp {+ if stamp == self.stamp && !kind_changed {+ ctx.request_repaint_after(PRESENT_POLL); return false; }+ let Ok(text) = std::fs::read_to_string(path) else {+ // The stamp is dropped rather than kept or stored, so the next poll retries:+ // a palette tool rewriting the file in place can fail one read transiently,+ // and a base-theme switch can force that read at an unchanged mtime.+ self.stamp = None;+ ctx.request_repaint_after(ABSENT_POLL);+ return false;+ };+ // Stored only once read: a malformed file keeps its stamp, so it is not re-read+ // and re-warned on every poll. self.stamp = stamp;- let Ok(text) = std::fs::read_to_string(path) else { return false };+ ctx.request_repaint_after(PRESENT_POLL); match serde_json::from_str::<serde_json::Value>(&text) { Ok(v) => {- super::apply(ctx, kind);- let mut t = Tokens::get(ctx);+ self.file = Some(v.clone());+ let mut t = Tokens::for_kind(kind); let unknown = apply_overrides(&mut t, &v);- ctx.data_mut(|d| d.insert_temp(egui::Id::unique("photocraft-theme"), t));+ super::apply_tokens(ctx, t); if !unknown.is_empty() { log::warn!("unknown token keys: {unknown:?}"); }@@ -1202,6 +1305,7 @@ pub mod live { card_border, field, field_border,+ histogram_bg, hover, pressed, text,@@ -1229,6 +1333,31 @@ pub mod live { #[cfg(test)] mod tests { use super::*;+ use std::ffi::OsString;++ fn temp_dir(name: &str) -> std::path::PathBuf {+ let dir = std::env::temp_dir().join(format!("photocraft-live-{name}-{}", std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ dir+ }++ fn write(path: &std::path::Path, json: &str) {+ std::fs::write(path, json).unwrap();+ }++ fn env_map(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<OsString> {+ let owned: Vec<(String, OsString)> = vars.iter().map(|(k, v)| (k.to_string(), OsString::from(*v))).collect();+ move |name: &str| owned.iter().find(|(k, _)| k == name).map(|(_, v)| v.clone())+ }++ /// Rewrite the file and bump its mtime deterministically: two writes in the same+ /// clock tick would otherwise look unchanged to the mtime poll.+ fn rewrite(path: &std::path::Path, json: &str, offset: std::time::Duration) {+ use std::fs::FileTimes;+ write(path, json);+ let f = std::fs::File::options().write(true).open(path).unwrap();+ f.set_times(FileTimes::new().set_modified(std::time::SystemTime::now() + offset)).unwrap();+ } #[test] fn overrides_apply_and_report_unknown() {@@ -1238,5 +1367,185 @@ pub mod live { assert_eq!(t.radius, 9.0); assert_eq!(unknown, vec!["nope".to_string()]); }++ #[test]+ fn first_poll_applies_an_existing_file() {+ let dir = temp_dir("first");+ let path = dir.join("theme.json");+ write(&path, r##"{"accent": "#ff0000"}"##);+ let ctx = egui::Context::default();+ let mut live = LiveTokens { path: Some(path), ..Default::default() };+ assert!(live.poll_at(&ctx, super::super::ThemeKind::Pro, 0.0));+ assert_eq!(Tokens::get(&ctx).accent, Color32::from_rgb(255, 0, 0));+ std::fs::remove_dir_all(dir).ok();+ }++ #[test]+ fn a_rewritten_file_reapplies_and_an_unchanged_one_is_throttled() {+ let dir = temp_dir("rewrite");+ let path = dir.join("theme.json");+ write(&path, r##"{"accent": "#ff0000"}"##);+ let ctx = egui::Context::default();+ let mut live = LiveTokens { path: Some(path.clone()), ..Default::default() };+ assert!(live.poll_at(&ctx, super::super::ThemeKind::Pro, 0.0));+ // Same stamp, inside the throttle: nothing happens.+ assert!(!live.poll_at(&ctx, super::super::ThemeKind::Pro, 0.1));+ // Same stamp, past the throttle: still nothing.+ assert!(!live.poll_at(&ctx, super::super::ThemeKind::Pro, 1.0));+ rewrite(&path, r##"{"accent": "#00ff00", "histogram_bg": "#101010"}"##, std::time::Duration::from_secs(5));+ assert!(live.poll_at(&ctx, super::super::ThemeKind::Pro, 2.0));+ let t = Tokens::get(&ctx);+ assert_eq!(t.accent, Color32::from_rgb(0, 255, 0));+ assert_eq!(t.histogram_bg, Color32::from_rgb(16, 16, 16));+ std::fs::remove_dir_all(dir).ok();+ }++ #[test]+ fn a_base_theme_switch_reapplies_without_a_file_change() {+ let dir = temp_dir("switch");+ let path = dir.join("theme.json");+ write(&path, r##"{"accent": "#ff0000"}"##);+ let ctx = egui::Context::default();+ let mut live = LiveTokens { path: Some(path), ..Default::default() };+ assert!(live.poll_at(&ctx, super::super::ThemeKind::Pro, 0.0));+ assert!(!live.poll_at(&ctx, super::super::ThemeKind::Pro, 0.1));+ assert!(live.poll_at(&ctx, super::super::ThemeKind::SoftDark, 0.2));+ assert_eq!(Tokens::get(&ctx).accent, Color32::from_rgb(255, 0, 0));+ std::fs::remove_dir_all(dir).ok();+ }++ /// The Accent colors preference is the master switch for desktop theming: off applies the+ /// stock theme (as if there were no file) and on picks the file up again at once.+ #[test]+ fn the_accent_preference_switches_the_theme_file_off() {+ let dir = temp_dir("master-switch");+ let path = dir.join("theme.json");+ write(&path, r##"{"accent": "#ff0000"}"##);+ let ctx = egui::Context::default();+ let mut live = LiveTokens { path: Some(path), ..Default::default() };+ assert!(live.poll_at(&ctx, super::super::ThemeKind::Pro, 0.0));+ assert_eq!(Tokens::get(&ctx).accent, Color32::from_rgb(255, 0, 0));++ live.disabled = true;+ assert!(live.poll_at(&ctx, super::super::ThemeKind::Pro, 0.5));+ assert_eq!(Tokens::get(&ctx).accent, Tokens::for_kind(super::super::ThemeKind::Pro).accent, "the stock theme applies");+ assert!(!live.poll_at(&ctx, super::super::ThemeKind::Pro, 1.0), "the file stays ignored");++ live.disabled = false;+ assert!(live.poll_at(&ctx, super::super::ThemeKind::Pro, 1.5), "re-enabled picks the file up at once");+ assert_eq!(Tokens::get(&ctx).accent, Color32::from_rgb(255, 0, 0));+ std::fs::remove_dir_all(dir).ok();+ }++ #[test]+ fn a_malformed_file_keeps_the_previous_tokens() {+ let dir = temp_dir("malformed");+ let path = dir.join("theme.json");+ write(&path, r##"{"accent": "#ff0000"}"##);+ let ctx = egui::Context::default();+ let mut live = LiveTokens { path: Some(path.clone()), ..Default::default() };+ assert!(live.poll_at(&ctx, super::super::ThemeKind::Pro, 0.0));+ rewrite(&path, "not json at all", std::time::Duration::from_secs(5));+ assert!(!live.poll_at(&ctx, super::super::ThemeKind::Pro, 1.0));+ assert_eq!(Tokens::get(&ctx).accent, Color32::from_rgb(255, 0, 0));+ std::fs::remove_dir_all(dir).ok();+ }++ #[test]+ fn a_failed_read_is_retried_on_the_next_poll() {+ let dir = temp_dir("failed-read");+ let path = dir.join("theme.json");+ // A directory at the watched path: the mtime poll sees a stamp, but reading fails.+ std::fs::create_dir_all(&path).unwrap();+ let ctx = egui::Context::default();+ let mut live = LiveTokens { path: Some(path.clone()), ..Default::default() };+ assert!(!live.poll_at(&ctx, super::super::ThemeKind::Pro, 0.0));+ assert!(live.stamp.is_none(), "a failed read must not store its stamp, or the 2 s retry never re-reads");+ std::fs::remove_dir_all(&path).unwrap();+ write(&path, r##"{"accent": "#00ff00"}"##);+ assert!(live.poll_at(&ctx, super::super::ThemeKind::Pro, 2.0), "the retry after the failure applies the file");+ assert_eq!(Tokens::get(&ctx).accent, Color32::from_rgb(0, 255, 0));+ std::fs::remove_dir_all(dir).ok();+ }++ #[test]+ fn a_failed_re_read_after_a_theme_switch_is_retried_at_an_unchanged_mtime() {+ let dir = temp_dir("failed-reread");+ let path = dir.join("theme.json");+ write(&path, r##"{"accent": "#ff0000"}"##);+ let ctx = egui::Context::default();+ let mut live = LiveTokens { path: Some(path.clone()), ..Default::default() };+ assert!(live.poll_at(&ctx, super::super::ThemeKind::Pro, 0.0));+ // Make reading fail without changing the mtime the poll compares: invalid UTF-8+ // fails read_to_string while metadata keeps reporting the same stamp. A helper,+ // because the sequence needs it twice at the same stamp.+ use std::io::Write as _;+ let stamp = std::fs::metadata(&path).unwrap().modified().unwrap();+ let write_at_stamp = |bytes: &[u8]| {+ let mut f = std::fs::File::options().write(true).open(&path).unwrap();+ f.write_all(bytes).unwrap();+ f.set_times(std::fs::FileTimes::new().set_modified(stamp)).unwrap();+ };+ // A base-theme switch re-reads even at an unchanged stamp, and that read fails.+ write_at_stamp(&[0xff, 0xfe, 0x00, 0x01]);+ assert!(!live.poll_at(&ctx, super::super::ThemeKind::SoftDark, 1.0));+ assert!(live.stamp.is_none(), "the failed re-read drops the old stamp, or the next poll skips the read");+ // Same mtime again, valid content: the retry must apply it despite the equal stamp.+ write_at_stamp(br##"{"accent": "#0000ff"}"##);+ assert!(live.poll_at(&ctx, super::super::ThemeKind::SoftDark, 2.0), "the retry applies the file at an unchanged mtime");+ assert_eq!(Tokens::get(&ctx).accent, Color32::from_rgb(0, 0, 255));+ // Another forced read that fails, then the file disappears: the last overrides+ // still drop to the stock theme, with no stamp left to say so — only the file.+ write_at_stamp(&[0xff, 0xfe, 0x00, 0x01]);+ assert!(!live.poll_at(&ctx, super::super::ThemeKind::Adwaita, 2.5));+ std::fs::remove_file(&path).unwrap();+ assert!(live.poll_at(&ctx, super::super::ThemeKind::Adwaita, 3.0), "the overrides from the last good read are dropped");+ assert_eq!(Tokens::get(&ctx).accent, Tokens::for_kind(super::super::ThemeKind::Adwaita).accent);+ std::fs::remove_dir_all(dir).ok();+ }++ #[test]+ fn a_missing_file_is_quiet() {+ let mut live = LiveTokens { path: Some(std::env::temp_dir().join("photocraft-live-nowhere/theme.json")), ..Default::default() };+ let ctx = egui::Context::default();+ assert!(!live.poll_at(&ctx, super::super::ThemeKind::Pro, 0.0));+ assert!(!live.poll_at(&ctx, super::super::ThemeKind::Pro, 10.0));+ }++ #[test]+ fn removing_the_file_falls_back_to_the_stock_theme() {+ let dir = temp_dir("fallback");+ let path = dir.join("theme.json");+ write(&path, r##"{"accent": "#ff0000"}"##);+ let ctx = egui::Context::default();+ let mut live = LiveTokens { path: Some(path.clone()), ..Default::default() };+ assert!(live.poll_at(&ctx, super::super::ThemeKind::Pro, 0.0));+ assert_eq!(Tokens::get(&ctx).accent, Color32::from_rgb(255, 0, 0));+ std::fs::remove_file(&path).unwrap();+ assert!(live.poll_at(&ctx, super::super::ThemeKind::Pro, 1.0));+ assert_eq!(Tokens::get(&ctx).accent, Tokens::for_kind(super::super::ThemeKind::Pro).accent);+ // Staying gone stays quiet.+ assert!(!live.poll_at(&ctx, super::super::ThemeKind::Pro, 2.0));+ std::fs::remove_dir_all(dir).ok();+ }++ #[test]+ fn the_env_var_sets_the_path_and_set_default_fills_an_unset_one() {+ let pinned = LiveTokens::from_env_for(env_map(&[("PHOTOCRAFT_THEME_FILE", "/tmp/pinned.json")]));+ assert_eq!(pinned.path, Some(std::path::PathBuf::from("/tmp/pinned.json")));++ // No env var: nothing is watched until the app shell offers the config-dir default.+ let mut quiet = LiveTokens::from_env_for(env_map(&[]));+ assert_eq!(quiet.path, None);++ let default = std::path::PathBuf::from("/conf/photocraft/theme.json");+ quiet.set_default(default.clone());+ assert_eq!(quiet.path, Some(default));++ // An explicit env var keeps precedence over the default.+ let mut pinned = pinned;+ pinned.set_default(std::path::PathBuf::from("/conf/photocraft/theme.json"));+ assert_eq!(pinned.path, Some(std::path::PathBuf::from("/tmp/pinned.json")));+ } } }@@ -148,6 +148,49 @@ fn field_selection_is_grey_only_for_the_monochrome_pro_themes() { assert_eq!(Tokens::for_kind(ThemeKind::Classic).field_selection(), Tokens::for_kind(ThemeKind::Classic).accent_soft); } +#[test]+fn tint_accent_recolours_only_the_accent_family() {+ for kind in [ThemeKind::Pro, ThemeKind::StudioLight, ThemeKind::AdwaitaDark] {+ let base = Tokens::for_kind(kind);+ let mut t = base;+ let accent = Color32::from_rgb(0xed, 0x5b, 0x00);+ tint_accent(&mut t, accent);+ assert_eq!(t.accent, accent);+ assert_eq!(t.accent_border, accent);+ assert_eq!(t.primary_bg, accent);+ assert_eq!(t.primary_text, Color32::BLACK, "orange takes black text: 6.1:1 against 3.4:1 for white ({kind:?})");+ assert_eq!(t.accent_text, base.accent_text, "accent labels keep the theme's own colour ({kind:?})");+ // The soft tints stay near the theme's own card colour, in both modes.+ let near = |a: Color32, b: Color32| {+ (a.r() as i32 - b.r() as i32).abs() < 90 && (a.g() as i32 - b.g() as i32).abs() < 90 && (a.b() as i32 - b.b() as i32).abs() < 90+ };+ assert!(near(t.accent_soft, t.card), "{kind:?} accent_soft {t:?} vs card {card:?}", card = base.card);+ assert!(near(t.row_selected, t.card), "{kind:?} row_selected drifts from the card colour");+ // Surfaces are untouched, so the stock contrast guarantees keep holding.+ assert_eq!(t.card, base.card);+ assert_eq!(t.text, base.text);+ assert_eq!(t.chrome, base.chrome);+ }+}++#[test]+fn tint_accent_picks_on_accent_text_by_contrast() {+ // Mid-tone desktop accents (GNOME's blue and green, and the orange) all take black:+ // white on them sits under 4.5:1, the luminance midpoint used to miss that.+ for (accent, want) in [+ (Color32::from_rgb(0x0a, 0x0a, 0x0a), Color32::WHITE),+ (Color32::from_rgb(0xf0, 0xf0, 0xf0), Color32::BLACK),+ (Color32::from_rgb(0x35, 0x84, 0xe4), Color32::BLACK),+ (Color32::from_rgb(0x3a, 0x94, 0x4a), Color32::BLACK),+ (Color32::from_rgb(0xed, 0x5b, 0x00), Color32::BLACK),+ ] {+ let mut t = Tokens::for_kind(ThemeKind::Pro);+ tint_accent(&mut t, accent);+ assert_eq!(t.primary_text, want);+ assert!(contrast(t.primary_text, t.primary_bg) >= 4.5, "{accent:?}: {}", contrast(t.primary_text, t.primary_bg));+ }+}+ #[test] fn theme_names_parse() { assert_eq!(ThemeKind::from_name("Classic"), Some(ThemeKind::Classic));@@ -170,7 +170,7 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | `PHOTOCRAFT_PRESENT_MODE=novsync` (or `--present-mode`) | Present mode for this launch: `fifo`, `autovsync`, or `novsync` (also `immediate`, `mailbox`: no vsync, the first of Immediate, Mailbox, Fifo the driver offers). Default: vsync, or no vsync when Preferences › Performance › Low-Latency Drawing is on (it ships off). For latency comparisons; shown in the stroke trace. A bad `--present-mode` is a usage error; a bad variable is logged and ignored | | `PHOTOCRAFT_FRAME_LATENCY=1` (or `--frame-latency`) | Frames the swap chain may queue, 1 to 3 (wgpu's GL backend ignores it). For latency comparisons; shown in the stroke trace. A bad `--frame-latency` is a usage error; a bad variable is logged and ignored | | `PHOTOCRAFT_PRINT_PORTAL=1` | Linux: File › Print always opens the system print dialog (the XDG print portal) instead of talking to CUPS; `0` never does. See [printing](printing.md). |-| `PHOTOCRAFT_THEME_FILE=tokens.json` | **Debug builds only:** live design-token overrides, re-read on change |+| `PHOTOCRAFT_THEME_FILE=tokens.json` | Live design-token overrides, re-read on change; overrides the default file the app follows, `theme.json` in the settings directory (the same directory as `preferences.json`) | ### Live design tokens @@ -178,7 +178,9 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. { "card": "#323232", "tab_strip": "#262626", "accent": "#378ef0", "radius": 4 } ``` -Keys are the field names of `theme::Tokens` (`crates/ui-egui/src/theme.rs`). Edit and save the file while the app runs to see changes immediately, with no recompile. This is compiled out of release builds.+Keys are the field names of `theme::Tokens` (`crates/ui-egui/src/theme.rs`). Edit and save the file while the app runs to see changes immediately, with no recompile.++Release builds watch `theme.json` in the settings directory — the same directory as `preferences.json` (`~/.config/photocraft/theme.json` on Linux, honouring `PHOTOCRAFT_CONFIG_DIR` and portable mode) — the same way, so the app can follow the desktop: palette tools such as matugen, pywal or base16 rewrite the file when the scheme changes and PhotoCraft recolours live, including when the selected theme or the system dark/light appearance switches. The file overrides the colours of whichever theme is selected; the selected theme still decides the dark or light chrome, so AppearanceMode › Auto keeps following the system. Missing keys keep the base theme's value; an unreadable file is skipped with a warning, keeping the last good colours; removing the file falls back to the selected theme at once. ### Stroke latency trace @@ -25,6 +25,10 @@ Window → Theme and macOS PhotoCraft → Appearance also offer Sync with system The toolbar's foreground and background colour chips follow the `Tokens::round_chips` flag. Pro and Classic leave it off and draw Photoshop's overlapping squares with Default Colors and Switch Colors above them. Studio, Studio Light, Solarized Dark, Adwaita, Adwaita Dark, Breeze Light, Breeze Dark, Soft Dark and Soft Light (the Studio-layout themes) turn it on and draw large round chips with a curved Switch Colors arrow centred right under them, its heads touching the chips: stacked vertically in one tool column, with Default Colors at their top-right, and side by side in two tool columns, with Default Colors at the toolbar's left edge. Another non-Pro theme can opt in by setting the flag. +Release builds also follow a theme file, `theme.json` in the settings directory (the same directory as `preferences.json`): keys are `Tokens` field names, values are hex colours or radii, and the file is re-read on change, so desktop palette tools (matugen, pywal, base16) recolour the app live. Its keys override the colours of whichever theme is selected — the theme still decides the dark or light chrome. `PHOTOCRAFT_THEME_FILE` points the watcher elsewhere (see [development](development.md#live-design-tokens)).++Preferences → Interface → Appearance also offers **Accent colors** (Linux only; the row is hidden where no desktop accent can be read) — the master switch for desktop theming. On (the default), the theme file below is followed and the accent family of whichever theme is selected is retinted with the desktop accent colour: the portal's `accent-color` (GNOME 47+ and Plasma signal changes), falling back to GNOME's named accent, KDE's `AccentColor` and pywal's `color4` — the last three are re-read every couple of seconds, so an accent picked in the desktop's settings after launch still reaches the app. The tint is derived separately for the dark and the light slot: surfaces and text keep the theme's tuned values, text on accent fills picks black or white by contrast, and the soft selection tints stay near the theme's own card colour. Off, the stock themes apply exactly as shipped — no theme file and no accent. The checkbox stages like the other rows and lands with Apply or OK, and a theme-file `accent` takes precedence over the desktop accent when both are present; otherwise, the desktop accent tints the selected theme's accent tokens, while all other theme-file overrides remain in effect.+ ## Rules - **Colours and radii come from tokens.** Read them with `Tokens::get(ctx)`; never hard-code a colour in a widget.