Sync global wal state on focused set_on; dedupe and prune per-output theme

Focused-monitor set_on (audit #5): set_on generated the focused output's
palette and rewrote the shared theme.css but never touched
~/.cache/wal/colors.json. Apps rebuild their own CSS from load_palette()
(that file) when theme.css changes, so straight after a focused set_on
the shared sheet showed the new palette while every app's own chrome kept
the old one. The focused path now also runs a global wal -i (via
sync_focused_global), matching what set() already does; restore_one's
focused branch gets the same treatment.

set() redundancy and staleness (audit #7):
- Reuse the palette the global wal -i just wrote instead of re-running an
  isolated wal -i once per output for an identical result.
- Write palettes/<out>.json + themes/<out>.css for every compositor-
  connected output, not just those already in current.json, so a
  later-connected monitor still gets its files.
- Prune current.json entries and per-output palette/css files for
  monitors that are no longer connected. Pruning only runs off a
  Hyprland-authoritative output list, never the awww fallback or an empty
  result.

Adds Current::remove_output and unit tests for the stale-output selection.
This commit is contained in:
Breadway 2026-09-01 16:25:57 +08:00
parent 4e88979378
commit 290e7e7985
2 changed files with 119 additions and 21 deletions

View file

@ -47,6 +47,12 @@ impl Current {
self.outputs.get(output).map(PathBuf::as_path) self.outputs.get(output).map(PathBuf::as_path)
} }
/// Drop a persisted output (e.g. a monitor that is no longer connected).
/// Returns whether an entry was removed.
pub fn remove_output(&mut self, output: &str) -> bool {
self.outputs.remove(output).is_some()
}
pub fn all(&self) -> &BTreeMap<String, PathBuf> { pub fn all(&self) -> &BTreeMap<String, PathBuf> {
&self.outputs &self.outputs
} }

View file

@ -38,39 +38,98 @@ pub fn set(path: &Path) -> Result<()> {
generate_palette(&path)?; generate_palette(&path)?;
reload_theme()?; reload_theme()?;
// A global set applies one palette to every output — the one `wal -i`
// just wrote to `~/.cache/wal`. Reuse it rather than re-running an
// isolated `wal -i` once per output for an identical result.
let palette = bread_theme::load_palette();
let mut cur = current::Current::load(); let mut cur = current::Current::load();
if cur.all().is_empty() {
let live = live_outputs(); // Prune only from a compositor-authoritative list; the awww fallback is
if live.is_empty() { // fine for *writing* per-output files but not for removal decisions.
cur.set_output("*", path.clone()); let connected = hypr_output_names().filter(|names| !names.is_empty());
} else { if let Some(ref names) = connected {
for output in live { prune_disconnected_outputs(&mut cur, names);
cur.set_output(output, path.clone()); }
// Write per-output files for every currently connected output, not just
// the ones already in current.json (a later-connected monitor would
// otherwise never get a file).
let targets: Vec<String> = match connected {
Some(names) => names,
None => {
let fallback = live_outputs();
if fallback.is_empty() {
cur.all()
.keys()
.filter(|k| k.as_str() != "*")
.cloned()
.collect()
} else {
fallback
} }
} }
};
if targets.is_empty() {
cur.set_output("*", path.clone());
} else { } else {
let keys: Vec<String> = cur.all().keys().cloned().collect(); for output in &targets {
for output in keys { cur.set_output(output.clone(), path.clone());
cur.set_output(output, path.clone()); write_output_theme(output, &palette)?;
} }
} }
cur.save()?; cur.save()?;
for output in cur.all().keys() {
if output != "*" {
theme::generate_for_output(output, &path)?;
}
}
emit_changed(&path, None); emit_changed(&path, None);
Ok(()) Ok(())
} }
/// Write `palettes/<output>.json` + `themes/<output>.css` for a palette that
/// is already known (no isolated `wal -i` re-run).
fn write_output_theme(output: &str, palette: &bread_theme::Palette) -> Result<()> {
bread_theme::write_output_palette(output, palette)
.with_context(|| format!("write per-output palette for {output}"))?;
bread_theme::write_output_css(output, palette)
.with_context(|| format!("write per-output css for {output}"))?;
Ok(())
}
/// Persisted, non-wildcard outputs that are not in `connected`.
fn stale_outputs(cur: &current::Current, connected: &[String]) -> Vec<String> {
cur.all()
.keys()
.filter(|k| k.as_str() != "*" && !connected.iter().any(|c| c == *k))
.cloned()
.collect()
}
/// Remove `current.json` entries and their `palettes/<out>.json` /
/// `themes/<out>.css` files for outputs not in `connected`. Never touches the
/// `"*"` wildcard entry.
fn prune_disconnected_outputs(cur: &mut current::Current, connected: &[String]) {
for output in stale_outputs(cur, connected) {
cur.remove_output(&output);
for p in [
bread_theme::output_palette_path(&output),
bread_theme::output_css_path(&output),
] {
match std::fs::remove_file(&p) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => eprintln!("breadpaper: prune {}: {e}", p.display()),
}
}
}
}
/// Set wallpaper + per-output theme on a single compositor output. /// Set wallpaper + per-output theme on a single compositor output.
/// ///
/// Does not run global `wal -i`. If `output` is the focused Hyprland /// For a non-focused output this only writes that output's per-monitor
/// monitor, the shared stylesheet is updated from that output's palette /// files. If `output` is the focused Hyprland monitor it additionally
/// so unbound apps match the focused screen. /// syncs the session-global state (shared stylesheet + `~/.cache/wal`) from
/// that output's palette, so unbound apps and app-specific chrome match the
/// focused screen.
pub fn set_on(path: &Path, output: &str) -> Result<()> { pub fn set_on(path: &Path, output: &str) -> Result<()> {
if output.is_empty() { if output.is_empty() {
bail!("output name is empty"); bail!("output name is empty");
@ -84,13 +143,26 @@ pub fn set_on(path: &Path, output: &str) -> Result<()> {
cur.save()?; cur.save()?;
if is_focused_output(output) { if is_focused_output(output) {
theme::write_shared_from(&palette)?; sync_focused_global(&path, &palette)?;
} }
emit_changed(&path, Some(output)); emit_changed(&path, Some(output));
Ok(()) Ok(())
} }
/// The focused monitor defines the session-global look, so sync BOTH the
/// shared stylesheet AND pywal's global cache (`~/.cache/wal/colors.json`) to
/// this output's palette. Every app rebuilds its own CSS from `load_palette()`
/// (which reads that cache) when `theme.css` changes; without the cache sync
/// its app-specific chrome keeps the previous palette while shared-sheet
/// widgets move to the new one — a mixed palette. `set()` (global) already
/// keeps the two in lockstep; this makes a focused `set_on` consistent.
fn sync_focused_global(path: &Path, palette: &bread_theme::Palette) -> Result<()> {
generate_palette(path)?;
theme::write_shared_from(palette)?;
Ok(())
}
/// Re-apply every wallpaper + per-output theme stored in current.json. /// Re-apply every wallpaper + per-output theme stored in current.json.
pub fn apply_saved() -> Result<()> { pub fn apply_saved() -> Result<()> {
let cur = current::Current::load(); let cur = current::Current::load();
@ -124,7 +196,7 @@ fn restore_one(output: &str, path: &Path) -> Result<()> {
wallpaper::apply_on(path, output)?; wallpaper::apply_on(path, output)?;
let palette = theme::generate_for_output(output, path)?; let palette = theme::generate_for_output(output, path)?;
if is_focused_output(output) { if is_focused_output(output) {
theme::write_shared_from(&palette)?; sync_focused_global(path, &palette)?;
} }
Ok(()) Ok(())
} }
@ -415,6 +487,26 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[test]
fn stale_outputs_selects_absent_non_wildcard_entries_only() {
let mut cur = current::Current::default();
cur.set_output("*", "/abs/global.png");
cur.set_output("mon-a", "/abs/a.png");
cur.set_output("mon-b", "/abs/b.png");
// mon-b disconnected; "*" and the still-connected mon-a are kept.
let stale = stale_outputs(&cur, &["mon-a".to_string()]);
assert_eq!(stale, vec!["mon-b".to_string()]);
}
#[test]
fn stale_outputs_is_empty_when_all_connected() {
let mut cur = current::Current::default();
cur.set_output("mon-a", "/abs/a.png");
cur.set_output("mon-b", "/abs/b.png");
assert!(stale_outputs(&cur, &["mon-a".to_string(), "mon-b".to_string()]).is_empty());
}
#[test] #[test]
fn handle_set_bad_extension_with_output_is_silent_without_breadd() { fn handle_set_bad_extension_with_output_is_silent_without_breadd() {
let dir = tmp_dir("bad-ext"); let dir = tmp_dir("bad-ext");