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:
parent
92d3362a6b
commit
ea9758a5d5
6 changed files with 241 additions and 14 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
182
bread-theme/src/layerrules.rs
Normal file
182
bread-theme/src/layerrules.rs
Normal 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());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue