bread-theme: generate Hyprland layer rules from the shell theme (Phase 4a)

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.
This commit is contained in:
Breadway 2026-08-24 18:57:35 +08:00
parent 92d3362a6b
commit ea9758a5d5
6 changed files with 241 additions and 14 deletions

View file

@ -11,6 +11,8 @@
//! bread-theme print # render to stdout (no write)
//! bread-theme generate-output <OUTPUT> --image <PATH> [--shared]
//! bread-theme generate-output <OUTPUT> --from-json <PATH> [--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 <OUTPUT> --image <PATH> [--shared]\n\
\x20 bread-theme generate-output <OUTPUT> --from-json <WAL-OR-PALETTE.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/<OUTPUT>.json and themes/<OUTPUT>.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
}

View file

@ -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<PathBuf> {
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<PathBuf> {
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<T>(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());
});
}
}

View file

@ -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";

View file

@ -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)?;
}

View file

@ -403,13 +403,6 @@ pub fn list() -> Vec<ThemeSummary> {
#[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(),

View file

@ -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<f64>,
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<String>,
pub no_anim: bool,
}