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:
parent
b34fd6c807
commit
21da79c7d9
1 changed files with 124 additions and 87 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue