From ea9758a5d5182653414d44cbe54ca47e3da0a7b6 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 24 Aug 2026 18:57:35 +0800 Subject: [PATCH] bread-theme: generate Hyprland layer rules from the shell theme (Phase 4a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `bread-theme layerrules`, which writes the active shell theme's [compositor] table to ~/.config/hypr/layerrules.json (atomic write). This lets ~/.config/hypr/scripts/ui/rules.lua read theme-driven blur/transparency/ animation for the breadbar/breadbox layer-shell namespaces instead of having them hardcoded, following THEME_SYSTEM_PLAN.md §9. rules.lua keeps its previous hardcoded rules as a pcall-guarded fallback for when the JSON is missing or malformed (lives outside this repo, so not part of this commit). Also fixes a pre-existing test race: shell::tests and the new layerrules::tests both mutate XDG_CONFIG_HOME in parallel `cargo test` threads but previously used separate, unrelated locks (or none), so they could observe each other's env var changes mid-test. Both now share bread_theme::test_support::XDG_CONFIG_HOME_LOCK. --- bread-theme/src/bin/bread-theme.rs | 30 ++++- bread-theme/src/layerrules.rs | 182 +++++++++++++++++++++++++++++ bread-theme/src/lib.rs | 15 +++ bread-theme/src/output.rs | 2 +- bread-theme/src/shell/mod.rs | 14 +-- bread-theme/src/shell/types.rs | 12 +- 6 files changed, 241 insertions(+), 14 deletions(-) create mode 100644 bread-theme/src/layerrules.rs diff --git a/bread-theme/src/bin/bread-theme.rs b/bread-theme/src/bin/bread-theme.rs index 3d7a862..a6d5b49 100644 --- a/bread-theme/src/bin/bread-theme.rs +++ b/bread-theme/src/bin/bread-theme.rs @@ -11,6 +11,8 @@ //! bread-theme print # render to stdout (no write) //! bread-theme generate-output --image [--shared] //! bread-theme generate-output --from-json [--shared] +//! bread-theme layerrules # write the active theme's [compositor] table +//! # to ~/.config/hypr/layerrules.json (plan §9) use std::process::ExitCode; @@ -31,7 +33,7 @@ fn print_help() { eprintln!( "bread-theme — shared stylesheet generator\n\n\ USAGE:\n\ - \x20 bread-theme [generate|reload|path|print]\n\ + \x20 bread-theme [generate|reload|path|print|layerrules]\n\ \x20 bread-theme generate-output --image [--shared]\n\ \x20 bread-theme generate-output --from-json [--shared]\n\n\ generate render the pywal palette to the shared stylesheet (default)\n\ @@ -41,8 +43,14 @@ fn print_help() { generate-output write palettes/.json and themes/.css\n\ \x20 --image isolated `wal -i` (does not touch ~/.cache/wal)\n\ \x20 --from-json wal colors.json or a color1-6 object\n\ - \x20 --shared also write the session-global theme.css", - bread_theme::shared_css_path().display() + \x20 --shared also write the session-global theme.css\n\ + layerrules write the active shell theme's [compositor] table to\n\ + \x20 {} —\n\ + \x20 scripts/ui/rules.lua reads it for per-namespace blur/\n\ + \x20 animation, falling back to its hardcoded rules if this\n\ + \x20 is missing or malformed", + bread_theme::shared_css_path().display(), + bread_theme::layerrules_path().display() ); } @@ -170,6 +178,19 @@ fn finish_generate_output(output: &str, css: std::path::PathBuf, shared: bool) - } } +fn layerrules_cmd() -> ExitCode { + match bread_theme::write_layerrules_active() { + Ok(path) => { + eprintln!("bread-theme: wrote {}", path.display()); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("bread-theme: failed to write layer rules: {e}"); + ExitCode::FAILURE + } + } +} + fn main() -> ExitCode { let cmd = std::env::args().nth(1).unwrap_or_else(|| "generate".into()); match cmd.as_str() { @@ -188,13 +209,14 @@ fn main() -> ExitCode { // palette and recolour live — shared widgets *and* each app's own rules. "reload" => write_and_report("reloaded"), "generate-output" => generate_output_cmd(), + "layerrules" => layerrules_cmd(), "-h" | "--help" | "help" => { print_help(); ExitCode::SUCCESS } other => { eprintln!( - "bread-theme: unknown command '{other}' (try generate|reload|path|print|generate-output)" + "bread-theme: unknown command '{other}' (try generate|reload|path|print|generate-output|layerrules)" ); ExitCode::FAILURE } diff --git a/bread-theme/src/layerrules.rs b/bread-theme/src/layerrules.rs new file mode 100644 index 0000000..76bcca8 --- /dev/null +++ b/bread-theme/src/layerrules.rs @@ -0,0 +1,182 @@ +//! Generates `~/.config/hypr/layerrules.json` from the active shell theme's +//! `[compositor]` table (`THEME_SYSTEM_PLAN.md` §9). `scripts/ui/rules.lua` +//! reads this file and emits `hl.layer_rule` calls from it, keeping its own +//! hardcoded rules as a pcall-guarded fallback for when this file is missing +//! or malformed — so generation here never has to be perfect, only present. +//! +//! Scope (plan §9's appearance/placement boundary): a theme's `[compositor]` +//! table owns per-namespace *appearance* only — blur, ignore_alpha, +//! blur_popups, animation, no_anim (the [`crate::shell::LayerRule`] field +//! set). It never owns placement, workspace-assignment, or focus rules — +//! those stay Lua-side user policy (`rules.lua`'s `hl.window_rule` calls) +//! that this generator does not touch. + +use std::path::PathBuf; + +use crate::shell::ShellTheme; + +fn config_home() -> PathBuf { + if let Ok(v) = std::env::var("XDG_CONFIG_HOME") { + if !v.is_empty() { + return PathBuf::from(v); + } + } + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".config") +} + +/// `~/.config/hypr/layerrules.json` (or under `$XDG_CONFIG_HOME` if set) — +/// alongside `binds.json`, `settings.json`, `monitors.json`, and +/// `autostart.json`, the established flat-JSON-under-`hypr/` convention +/// those files already use (see `~/.config/hypr/scripts/input/binds.lua` for +/// the read side of that pattern, which `scripts/ui/rules.lua` now mirrors). +pub fn layerrules_path() -> PathBuf { + config_home().join("hypr").join("layerrules.json") +} + +/// Render `theme`'s `[compositor]` table as the JSON object +/// `scripts/ui/rules.lua` expects: keyed by layer-shell namespace (e.g. +/// `"breadbar"`, `"breadbox"`), each value the namespace's +/// [`crate::shell::LayerRule`] fields. `compositor_rules()` returns a +/// `BTreeMap`, so namespace order is stable (alphabetical) across runs and a +/// rewritten file diffs cleanly. +pub fn layerrules_json(theme: &ShellTheme) -> String { + serde_json::to_string_pretty(theme.compositor_rules()) + .expect("LayerRule serialization is infallible (no maps/floats that can fail)") +} + +/// [`layerrules_json`] + atomic write (tmp + rename), the same durability +/// [`crate::write_shared_css_from`] uses so a reload can never observe a +/// half-written file. +pub fn write_layerrules(theme: &ShellTheme) -> std::io::Result { + let path = layerrules_path(); + let json = layerrules_json(theme); + crate::output::atomic_write(&path, &json)?; + Ok(path) +} + +/// [`write_layerrules`] from the active theme ([`crate::shell::load`], which +/// never fails — a broken active theme falls back to the builtin). Used by +/// the `bread-theme layerrules` CLI subcommand. +pub fn write_layerrules_active() -> std::io::Result { + write_layerrules(&crate::shell::load()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn lock_xdg() -> std::sync::MutexGuard<'static, ()> { + // Shared with `shell::tests::isolated_xdg`, which also mutates + // XDG_CONFIG_HOME — must be the *same* lock, not a look-alike one, + // or the two modules' parallel tests race each other's env var + // reads (see `crate::test_support`'s doc comment). + crate::test_support::XDG_CONFIG_HOME_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()) + } + + fn with_config_home(f: impl FnOnce(&Path) -> T) -> T { + let _lock = lock_xdg(); + let dir = std::env::temp_dir().join(format!( + "bread-theme-layerrules-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let old = std::env::var("XDG_CONFIG_HOME").ok(); + std::env::set_var("XDG_CONFIG_HOME", &dir); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&dir))); + match old { + Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + let _ = std::fs::remove_dir_all(&dir); + match result { + Ok(v) => v, + Err(e) => std::panic::resume_unwind(e), + } + } + + #[test] + fn layerrules_path_sits_under_config_hypr() { + with_config_home(|dir| { + assert_eq!(layerrules_path(), dir.join("hypr").join("layerrules.json")); + }); + } + + /// `load_named("liquid-motion")` resolves through discovery (user dir, + /// then system dir, then the compiled-in builtin) — isolate + /// `XDG_CONFIG_HOME` to an empty dir so this can't pick up a real + /// `~/.config/bread/themes/liquid-motion/theme.toml` override and land + /// on a different `[compositor]` table than the builtin's. + fn builtin_theme() -> ShellTheme { + with_config_home(|_| crate::shell::load_named("liquid-motion").unwrap()) + } + + #[test] + fn layerrules_json_covers_all_six_builtin_namespaces() { + let theme = builtin_theme(); + let json = layerrules_json(&theme); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + let obj = value.as_object().expect("top-level object"); + for ns in [ + "breadbar", + "breadbar-osd", + "breadbar-notif", + "breadbar-panel", + "breadbar-dismiss", + "breadbox", + ] { + assert!(obj.contains_key(ns), "missing namespace {ns} in JSON"); + } + } + + #[test] + fn layerrules_json_shape_matches_breadbar_rule() { + let theme = builtin_theme(); + let json = layerrules_json(&theme); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + let bar = &value["breadbar"]; + assert_eq!(bar["blur"], true); + assert_eq!(bar["ignore_alpha"], 0.2); + assert_eq!(bar["blur_popups"], true); + assert_eq!(bar["animation"], "slide top"); + // no_anim is a plain bool (not Option), so it's always present, even + // when false — unlike ignore_alpha/animation which are omitted. + assert_eq!(bar["no_anim"], false); + + let dismiss = &value["breadbar-dismiss"]; + assert_eq!(dismiss["no_anim"], true); + // ignore_alpha/animation are unset for breadbar-dismiss, so the + // skip_serializing_if omits them entirely rather than writing null. + assert!(dismiss.get("ignore_alpha").is_none()); + assert!(dismiss.get("animation").is_none()); + } + + #[test] + fn write_layerrules_active_writes_atomically_and_is_reloadable() { + with_config_home(|dir| { + let path = write_layerrules_active().unwrap(); + assert_eq!(path, dir.join("hypr").join("layerrules.json")); + assert!(path.is_file()); + // No leftover .tmp file after the atomic rename. + assert!(!path.with_file_name("layerrules.json.tmp").exists()); + + let contents = std::fs::read_to_string(&path).unwrap(); + let value: serde_json::Value = serde_json::from_str(&contents).unwrap(); + assert!(value.as_object().unwrap().contains_key("breadbox")); + + // Rewriting (theme switch, pywal hook, etc.) must not fail or + // leave a stale temp file behind. + let path2 = write_layerrules_active().unwrap(); + assert_eq!(path, path2); + assert!(!path.with_file_name("layerrules.json.tmp").exists()); + }); + } +} diff --git a/bread-theme/src/lib.rs b/bread-theme/src/lib.rs index d20c955..12fa498 100644 --- a/bread-theme/src/lib.rs +++ b/bread-theme/src/lib.rs @@ -2,10 +2,12 @@ pub mod adw; #[cfg(feature = "gtk")] pub mod gtk; +mod layerrules; mod output; pub mod palette; pub mod shell; +pub use layerrules::{layerrules_json, layerrules_path, write_layerrules, write_layerrules_active}; pub use output::{ generate_output, load_palette_for, output_css_path, output_palette_path, palette_from_image, palette_from_json, palettes_dir, sanitize_output, themes_dir, write_output_css, @@ -13,6 +15,19 @@ pub use output::{ }; pub use palette::{load_palette, Palette}; +/// Env-var locks shared by any test module that mutates process-global +/// state (`std::env::set_var`) — `cargo test` runs a crate's tests in +/// parallel by default, so every module touching the *same* env var must +/// serialize through the *same* lock or their mutations race each other's +/// reads. `bread_theme::output`'s own `XDG_ENV_LOCK` guards `XDG_RUNTIME_DIR` +/// specifically and stays where it is; `XDG_CONFIG_HOME_LOCK` here is the +/// one shared by `shell::tests` and `layerrules::tests`, which both point +/// `XDG_CONFIG_HOME` at an isolated temp dir. +#[cfg(test)] +pub(crate) mod test_support { + pub(crate) static XDG_CONFIG_HOME_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +} + /// Design tokens from BREAD_DESIGN_SYSTEM.md. pub mod tokens { pub const FONT_FAMILY: &str = "Varela Round, sans-serif"; diff --git a/bread-theme/src/output.rs b/bread-theme/src/output.rs index 4f2f069..5389277 100644 --- a/bread-theme/src/output.rs +++ b/bread-theme/src/output.rs @@ -54,7 +54,7 @@ pub fn output_palette_path(output: &str) -> PathBuf { palettes_dir().join(format!("{}.json", sanitize_output(output))) } -fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> { +pub(crate) fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } diff --git a/bread-theme/src/shell/mod.rs b/bread-theme/src/shell/mod.rs index a241ca9..dc48683 100644 --- a/bread-theme/src/shell/mod.rs +++ b/bread-theme/src/shell/mod.rs @@ -403,13 +403,6 @@ pub fn list() -> Vec { #[cfg(test)] mod tests { use super::*; - use std::sync::Mutex; - - // Guards mutation of XDG_CONFIG_HOME / BREAD_SHELL_THEME, which are - // process-global — mirrors bread_theme::output's XDG_ENV_LOCK pattern - // (a different env var, same reason: cargo test runs a module's tests - // in parallel by default). - static ENV_LOCK: Mutex<()> = Mutex::new(()); struct EnvGuard { _lock: std::sync::MutexGuard<'static, ()>, @@ -437,7 +430,12 @@ mod tests { /// whatever's set in the outer test-runner environment. Held for the /// guard's lifetime. fn isolated_xdg() -> EnvGuard { - let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // Shared with `layerrules::tests`, which also isolates + // XDG_CONFIG_HOME — see `crate::test_support` for why this must be + // the *same* lock rather than a module-private one. + let lock = crate::test_support::XDG_CONFIG_HOME_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); let dir = std::env::temp_dir().join(format!( "bread-theme-shell-test-{}-{}", std::process::id(), diff --git a/bread-theme/src/shell/types.rs b/bread-theme/src/shell/types.rs index 06982a2..872ab40 100644 --- a/bread-theme/src/shell/types.rs +++ b/bread-theme/src/shell/types.rs @@ -181,9 +181,18 @@ pub struct Surface { /// ignore_alpha, blur_popups, animation, no_anim) — that Lua API isn't in /// hyprland-api.lua's type annotations, so this field set is evidenced by /// working usage, not documentation (plan §12 risk 3). -#[derive(Debug, Clone, PartialEq, Default)] +/// +/// Also `Serialize`: this is the per-namespace shape written to +/// `~/.config/hypr/layerrules.json` by `bread_theme::layerrules` (plan §9 +/// step 3-4), which `scripts/ui/rules.lua` parses back into `hl.layer_rule` +/// calls. `Option::None` fields are omitted rather than emitted as `null` — +/// the Lua JSON reader treats a missing key and a `null` value identically +/// (assigning `nil` into a table key is a no-op), so either encoding is +/// correct, but omitting keeps the file legible for hand inspection. +#[derive(Debug, Clone, PartialEq, Default, serde::Serialize)] pub struct LayerRule { pub blur: bool, + #[serde(skip_serializing_if = "Option::is_none")] pub ignore_alpha: Option, pub blur_popups: bool, /// Passed through verbatim to `hl.layer_rule`'s `animation` field @@ -192,6 +201,7 @@ pub struct LayerRule { /// evidenced by working usage in `rules.lua`, not documented (plan §12 /// risk 3); a closed Rust enum here would need updating in lockstep /// with Hyprland additions this crate has no way to know about. + #[serde(skip_serializing_if = "Option::is_none")] pub animation: Option, pub no_anim: bool, }