From 21da79c7d9437b025843632c2803419559336412 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 1 Sep 2026 16:29:44 +0800 Subject: [PATCH] theme: canonical accent mapping + live per-output / monitor-move updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit findings #1 and #4. #1 (accent mapping): render_theme_css hand-rolled the `:root { --name }` block and mapped --accent, --red, --on-accent and --on-red all to pywal's color1 (ANSI red — the slot bread-theme reserves for errors), so the settings webview accented in a different hue from every GTK app on the same monitor, and destructive-action styling was indistinguishable from accent styling. Replace it with a direct call to bread_theme::css_custom_properties / css_tokens (both exported in the pinned v0.7.4), which format the canonical color_pairs list: --accent = color4, --red = color1. As a bonus css_tokens emits a correct font-family list instead of one over-quoted family. #4 (live updates): watch_and_emit watched only the shared theme.css, so `breadpaper set_on` on a non-focused monitor (which writes only palettes/.json + themes/.css) never reached a settings window parked there, and dragging the window between monitors never re-rendered. Watch the generated-theme dir recursively and re-render when the shared sheet or *this monitor's* per-output files change; add a WindowEvent::Moved handler that re-renders when the window lands on a different monitor. --- src/src/commands/theme.rs | 211 ++++++++++++++++++++++---------------- 1 file changed, 124 insertions(+), 87 deletions(-) diff --git a/src/src/commands/theme.rs b/src/src/commands/theme.rs index b6a9116..f1dd44f 100644 --- a/src/src/commands/theme.rs +++ b/src/src/commands/theme.rs @@ -1,12 +1,23 @@ //! Bridges `bread-theme`'s pywal-derived palette into the webview as CSS -//! custom properties, and keeps it live: `bread-theme`'s generator rewrites -//! its shared stylesheet with a temp-then-rename (atomic replace), which -//! kills a direct file watch (inotify reports DELETE_SELF and never -//! re-arms) — so this watches the *parent directory* and filters by -//! filename instead, the same strategy `bread_theme::gtk::watch_theme_file` -//! uses for the GTK apps. +//! custom properties, and keeps it live. +//! +//! `bread-theme` rewrites every file it generates with a temp-then-rename +//! (atomic replace), which kills a direct file watch (inotify reports +//! `DELETE_SELF` and never re-arms) — so this watches the *parent directory* +//! and filters by path instead, the same strategy `bread_theme::gtk`'s +//! `watch_theme_file` / `ensure_themes_watch` use for the GTK apps. +//! +//! Three inputs can change the colours the settings window should show: the +//! shared `theme.css` (global wallpaper change / `bread-theme reload`); the +//! `palettes/.json` + `themes/.css` for the monitor this +//! window is on (`breadpaper set_on` on any monitor, focused or not — which +//! never rewrites `theme.css`); and the window being dragged onto a +//! different monitor. The directory watch is recursive so it catches the +//! first two; a `WindowEvent::Moved` handler catches the third. use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use std::path::Path; +use std::sync::Mutex; use tauri::{AppHandle, Emitter, Manager}; /// Initial theme fetch — called once by the frontend at startup. @@ -15,108 +26,111 @@ pub fn get_theme_css(window: tauri::WebviewWindow) -> String { render_theme_css(&palette_for_window(&window)) } -fn palette_for_window(window: &tauri::WebviewWindow) -> bread_theme::Palette { +/// Connector name of the monitor currently showing this window, if known. +fn monitor_name(window: &tauri::WebviewWindow) -> Option { window .current_monitor() .ok() .flatten() .and_then(|m| m.name().map(|s| s.to_string())) +} + +fn palette_for_window(window: &tauri::WebviewWindow) -> bread_theme::Palette { + monitor_name(window) .map(|name| bread_theme::load_palette_for(&name)) .unwrap_or_else(bread_theme::load_palette) } fn render_theme_css(palette: &bread_theme::Palette) -> String { - // bread-theme v0.7.1 exposes Palette + ink_on + tokens, but not the - // later css_custom_properties / css_tokens helpers (those landed after - // the tag). Emit the same :root custom-property names the Svelte app - // already uses so a tag pin doesn't require a web-side rename. - format!("{}\n{}", css_custom_properties(palette), css_tokens()) -} - -fn css_custom_properties(p: &bread_theme::Palette) -> String { - let pairs = [ - ("bg", p.background.as_str()), - ("fg", p.foreground.as_str()), - ("surface", p.color0.as_str()), - ("overlay", p.color7.as_str()), - ("accent", p.color1.as_str()), - ("red", p.color1.as_str()), - ("green", p.color2.as_str()), - ("yellow", p.color3.as_str()), - ("blue", p.color4.as_str()), - ("pink", p.color5.as_str()), - ("teal", p.color6.as_str()), - ("on-bg", bread_theme::ink_on(&p.background)), - ("on-surface", bread_theme::ink_on(&p.color0)), - ("on-accent", bread_theme::ink_on(&p.color1)), - ("on-red", bread_theme::ink_on(&p.color1)), - ("on-overlay", bread_theme::ink_on(&p.color7)), - ]; - let vars: String = pairs - .iter() - .map(|(name, value)| format!(" --{name}: {value};\n")) - .collect(); - format!(":root {{\n{vars}}}\n") -} - -fn css_tokens() -> String { - use bread_theme::tokens::*; + // Use bread-theme's own generators rather than a hand-rolled copy of the + // `:root { --name: ... }` block. They format the canonical `color_pairs` + // list every GTK app shares, so the webview accents on the same slot as + // the rest of the desktop: + // --accent = color4 (was color1 here — pywal's ANSI red, the slot + // reserved for errors; made --accent == --red == + // --on-accent == --on-red) + // --red = color1 (now distinct from --accent again) + // `css_tokens` additionally fixes the font-family list, which the local + // copy emitted as one quoted family (`'Varela Round, sans-serif'`), + // silently dropping the generic fallback. format!( - ":root {{\n\ - \x20\x20--font-family: '{font}';\n\ - \x20\x20--font-size-base: {base}px;\n\ - \x20\x20--font-size-secondary: {sec}px;\n\ - \x20\x20--space-xs: {xs}px;\n\ - \x20\x20--space-sm: {sm}px;\n\ - \x20\x20--space-md: {md}px;\n\ - \x20\x20--space-lg: {lg}px;\n\ - \x20\x20--space-xl: {xl}px;\n\ - \x20\x20--radius-primary: {r1}px;\n\ - \x20\x20--radius-secondary: {r2}px;\n\ - \x20\x20--radius-tertiary: {r3}px;\n\ - \x20\x20--radius-pill: {pill}px;\n\ - }}\n", - font = FONT_FAMILY, - base = FONT_SIZE_BASE, - sec = FONT_SIZE_SECONDARY, - xs = SPACE_XS, - sm = SPACE_SM, - md = SPACE_MD, - lg = SPACE_LG, - xl = SPACE_XL, - r1 = RADIUS_PRIMARY, - r2 = RADIUS_SECONDARY, - r3 = RADIUS_TERTIARY, - pill = RADIUS_PILL, + "{}\n{}", + bread_theme::css_custom_properties(palette), + bread_theme::css_tokens() ) } -/// Start watching the shared theme file and emit `theme-changed` with the -/// freshly rendered CSS whenever it's rewritten (palette change from a new -/// wallpaper, or a manual `bread-theme reload`). Call once from `setup`. +/// Render the current monitor's palette and push it to the webview. +fn emit_current_theme(app: &AppHandle) { + let css = app + .get_webview_window("main") + .map(|w| render_theme_css(&palette_for_window(&w))) + .unwrap_or_else(|| render_theme_css(&bread_theme::load_palette())); + let _ = app.emit("theme-changed", css); +} + +/// True when a changed path is one this window should recolour for: the +/// shared `theme.css`, or the per-output palette/CSS for the monitor the +/// window is currently on. When the monitor can't be resolved, fall back to +/// reacting to any per-output `*.json` / `*.css` write so a change is never +/// missed. +fn is_relevant_theme_path(path: &Path, shared: &Path, app: &AppHandle) -> bool { + if path == shared { + return true; + } + match app + .get_webview_window("main") + .and_then(|w| monitor_name(&w)) + { + Some(name) => { + path == bread_theme::output_palette_path(&name) + || path == bread_theme::output_css_path(&name) + } + None => { + let ext = path.extension().and_then(|e| e.to_str()); + matches!(ext, Some("json") | Some("css")) + && path.parent().is_some_and(|d| { + d == bread_theme::palettes_dir() || d == bread_theme::themes_dir() + }) + } + } +} + +/// Watch the generated-theme directory tree and emit `theme-changed` with the +/// freshly rendered CSS whenever the shared stylesheet or this monitor's +/// per-output files are rewritten. Also re-renders when the window is dragged +/// to another monitor. Call once from `setup`. pub fn watch_and_emit(app: &AppHandle) { - let target = bread_theme::shared_css_path(); - let Some(dir) = target.parent() else { return }; - let _ = std::fs::create_dir_all(dir); + let shared = bread_theme::shared_css_path(); + let Some(dir) = shared.parent().map(Path::to_path_buf) else { + return; + }; + // The dirs must exist to be watched; `bread-theme generate` makes them at + // login, but create them here too so a settings window started first + // still arms the watch. + let _ = std::fs::create_dir_all(&dir); + let _ = std::fs::create_dir_all(bread_theme::palettes_dir()); + let _ = std::fs::create_dir_all(bread_theme::themes_dir()); let app_for_watcher = app.clone(); - let target_for_watcher = target.clone(); + let shared_for_watcher = shared.clone(); let mut watcher = match RecommendedWatcher::new( move |res: notify::Result| { let Ok(event) = res else { return }; - // Rewrites land as CREATE/MODIFY/RENAME events touching the - // stylesheet's path specifically — the directory watch also - // sees unrelated siblings, so filter to the target file. - let touches_target = matches!( + // Rewrites land as CREATE / MODIFY / RENAME events; the directory + // watch also sees unrelated siblings, so filter by path. + if !matches!( event.kind, EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) - ) && event.paths.iter().any(|p| p == &target_for_watcher); - if touches_target { - let css = app_for_watcher - .get_webview_window("main") - .map(|w| render_theme_css(&palette_for_window(&w))) - .unwrap_or_else(|| render_theme_css(&bread_theme::load_palette())); - let _ = app_for_watcher.emit("theme-changed", css); + ) { + return; + } + let relevant = event + .paths + .iter() + .any(|p| is_relevant_theme_path(p, &shared_for_watcher, &app_for_watcher)); + if relevant { + emit_current_theme(&app_for_watcher); } }, notify::Config::default(), @@ -128,7 +142,8 @@ pub fn watch_and_emit(app: &AppHandle) { } }; - if let Err(e) = watcher.watch(dir, RecursiveMode::NonRecursive) { + // Recursive: `palettes/` and `themes/` are subdirectories of `dir`. + if let Err(e) = watcher.watch(&dir, RecursiveMode::Recursive) { tracing_or_eprintln(&format!( "theme watcher: failed to watch {}: {e}", dir.display() @@ -139,6 +154,28 @@ pub fn watch_and_emit(app: &AppHandle) { // Leaked to stay alive for the process lifetime — this app has exactly // one theme watcher, created once at startup, never torn down. app.manage(WatcherHandle(watcher)); + + // Re-render when the window is dragged onto a different monitor. + // `Moved` fires continuously during a drag, so only act on an actual + // monitor change. + if let Some(window) = app.get_webview_window("main") { + let app_for_move = app.clone(); + let last_monitor = Mutex::new(monitor_name(&window)); + window.on_window_event(move |event| { + if !matches!(event, tauri::WindowEvent::Moved(_)) { + return; + } + let Some(w) = app_for_move.get_webview_window("main") else { + return; + }; + let now = monitor_name(&w); + let mut guard = last_monitor.lock().unwrap_or_else(|e| e.into_inner()); + if *guard != now { + *guard = now; + emit_current_theme(&app_for_move); + } + }); + } } struct WatcherHandle(#[allow(dead_code)] RecommendedWatcher);