theme: canonical accent mapping + live per-output / monitor-move updates

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/<out>.json + themes/<out>.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.
This commit is contained in:
Breadway 2026-09-01 16:29:44 +08:00
parent b34fd6c807
commit 21da79c7d9

View file

@ -1,12 +1,23 @@
//! Bridges `bread-theme`'s pywal-derived palette into the webview as CSS //! Bridges `bread-theme`'s pywal-derived palette into the webview as CSS
//! custom properties, and keeps it live: `bread-theme`'s generator rewrites //! custom properties, and keeps it live.
//! its shared stylesheet with a temp-then-rename (atomic replace), which //!
//! kills a direct file watch (inotify reports DELETE_SELF and never //! `bread-theme` rewrites every file it generates with a temp-then-rename
//! re-arms) — so this watches the *parent directory* and filters by //! (atomic replace), which kills a direct file watch (inotify reports
//! filename instead, the same strategy `bread_theme::gtk::watch_theme_file` //! `DELETE_SELF` and never re-arms) — so this watches the *parent directory*
//! uses for the GTK apps. //! 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/<output>.json` + `themes/<output>.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 notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use std::path::Path;
use std::sync::Mutex;
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter, Manager};
/// Initial theme fetch — called once by the frontend at startup. /// 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)) 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<String> {
window window
.current_monitor() .current_monitor()
.ok() .ok()
.flatten() .flatten()
.and_then(|m| m.name().map(|s| s.to_string())) .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)) .map(|name| bread_theme::load_palette_for(&name))
.unwrap_or_else(bread_theme::load_palette) .unwrap_or_else(bread_theme::load_palette)
} }
fn render_theme_css(palette: &bread_theme::Palette) -> String { fn render_theme_css(palette: &bread_theme::Palette) -> String {
// bread-theme v0.7.1 exposes Palette + ink_on + tokens, but not the // Use bread-theme's own generators rather than a hand-rolled copy of the
// later css_custom_properties / css_tokens helpers (those landed after // `:root { --name: ... }` block. They format the canonical `color_pairs`
// the tag). Emit the same :root custom-property names the Svelte app // list every GTK app shares, so the webview accents on the same slot as
// already uses so a tag pin doesn't require a web-side rename. // the rest of the desktop:
format!("{}\n{}", css_custom_properties(palette), css_tokens()) // --accent = color4 (was color1 here — pywal's ANSI red, the slot
} // reserved for errors; made --accent == --red ==
// --on-accent == --on-red)
fn css_custom_properties(p: &bread_theme::Palette) -> String { // --red = color1 (now distinct from --accent again)
let pairs = [ // `css_tokens` additionally fixes the font-family list, which the local
("bg", p.background.as_str()), // copy emitted as one quoted family (`'Varela Round, sans-serif'`),
("fg", p.foreground.as_str()), // silently dropping the generic fallback.
("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::*;
format!( format!(
":root {{\n\ "{}\n{}",
\x20\x20--font-family: '{font}';\n\ bread_theme::css_custom_properties(palette),
\x20\x20--font-size-base: {base}px;\n\ bread_theme::css_tokens()
\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,
) )
} }
/// Start watching the shared theme file and emit `theme-changed` with the /// Render the current monitor's palette and push it to the webview.
/// freshly rendered CSS whenever it's rewritten (palette change from a new fn emit_current_theme(app: &AppHandle) {
/// wallpaper, or a manual `bread-theme reload`). Call once from `setup`. 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) { pub fn watch_and_emit(app: &AppHandle) {
let target = bread_theme::shared_css_path(); let shared = bread_theme::shared_css_path();
let Some(dir) = target.parent() else { return }; let Some(dir) = shared.parent().map(Path::to_path_buf) else {
let _ = std::fs::create_dir_all(dir); 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 app_for_watcher = app.clone();
let target_for_watcher = target.clone(); let shared_for_watcher = shared.clone();
let mut watcher = match RecommendedWatcher::new( let mut watcher = match RecommendedWatcher::new(
move |res: notify::Result<Event>| { move |res: notify::Result<Event>| {
let Ok(event) = res else { return }; let Ok(event) = res else { return };
// Rewrites land as CREATE/MODIFY/RENAME events touching the // Rewrites land as CREATE / MODIFY / RENAME events; the directory
// stylesheet's path specifically — the directory watch also // watch also sees unrelated siblings, so filter by path.
// sees unrelated siblings, so filter to the target file. if !matches!(
let touches_target = matches!(
event.kind, event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
) && event.paths.iter().any(|p| p == &target_for_watcher); ) {
if touches_target { return;
let css = app_for_watcher }
.get_webview_window("main") let relevant = event
.map(|w| render_theme_css(&palette_for_window(&w))) .paths
.unwrap_or_else(|| render_theme_css(&bread_theme::load_palette())); .iter()
let _ = app_for_watcher.emit("theme-changed", css); .any(|p| is_relevant_theme_path(p, &shared_for_watcher, &app_for_watcher));
if relevant {
emit_current_theme(&app_for_watcher);
} }
}, },
notify::Config::default(), 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!( tracing_or_eprintln(&format!(
"theme watcher: failed to watch {}: {e}", "theme watcher: failed to watch {}: {e}",
dir.display() 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 // Leaked to stay alive for the process lifetime — this app has exactly
// one theme watcher, created once at startup, never torn down. // one theme watcher, created once at startup, never torn down.
app.manage(WatcherHandle(watcher)); 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); struct WatcherHandle(#[allow(dead_code)] RecommendedWatcher);