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:
parent
4e88979378
commit
290e7e7985
2 changed files with 119 additions and 21 deletions
|
|
@ -47,6 +47,12 @@ impl Current {
|
|||
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> {
|
||||
&self.outputs
|
||||
}
|
||||
|
|
|
|||
134
src/lib.rs
134
src/lib.rs
|
|
@ -38,39 +38,98 @@ pub fn set(path: &Path) -> Result<()> {
|
|||
generate_palette(&path)?;
|
||||
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();
|
||||
if cur.all().is_empty() {
|
||||
let live = live_outputs();
|
||||
if live.is_empty() {
|
||||
cur.set_output("*", path.clone());
|
||||
} else {
|
||||
for output in live {
|
||||
cur.set_output(output, path.clone());
|
||||
|
||||
// Prune only from a compositor-authoritative list; the awww fallback is
|
||||
// fine for *writing* per-output files but not for removal decisions.
|
||||
let connected = hypr_output_names().filter(|names| !names.is_empty());
|
||||
if let Some(ref names) = connected {
|
||||
prune_disconnected_outputs(&mut cur, names);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
let keys: Vec<String> = cur.all().keys().cloned().collect();
|
||||
for output in keys {
|
||||
cur.set_output(output, path.clone());
|
||||
for output in &targets {
|
||||
cur.set_output(output.clone(), path.clone());
|
||||
write_output_theme(output, &palette)?;
|
||||
}
|
||||
}
|
||||
cur.save()?;
|
||||
|
||||
for output in cur.all().keys() {
|
||||
if output != "*" {
|
||||
theme::generate_for_output(output, &path)?;
|
||||
}
|
||||
}
|
||||
|
||||
emit_changed(&path, None);
|
||||
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: ¤t::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.
|
||||
///
|
||||
/// Does not run global `wal -i`. If `output` is the focused Hyprland
|
||||
/// monitor, the shared stylesheet is updated from that output's palette
|
||||
/// so unbound apps match the focused screen.
|
||||
/// For a non-focused output this only writes that output's per-monitor
|
||||
/// files. If `output` is the focused Hyprland monitor it additionally
|
||||
/// 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<()> {
|
||||
if output.is_empty() {
|
||||
bail!("output name is empty");
|
||||
|
|
@ -84,13 +143,26 @@ pub fn set_on(path: &Path, output: &str) -> Result<()> {
|
|||
cur.save()?;
|
||||
|
||||
if is_focused_output(output) {
|
||||
theme::write_shared_from(&palette)?;
|
||||
sync_focused_global(&path, &palette)?;
|
||||
}
|
||||
|
||||
emit_changed(&path, Some(output));
|
||||
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.
|
||||
pub fn apply_saved() -> Result<()> {
|
||||
let cur = current::Current::load();
|
||||
|
|
@ -124,7 +196,7 @@ fn restore_one(output: &str, path: &Path) -> Result<()> {
|
|||
wallpaper::apply_on(path, output)?;
|
||||
let palette = theme::generate_for_output(output, path)?;
|
||||
if is_focused_output(output) {
|
||||
theme::write_shared_from(&palette)?;
|
||||
sync_focused_global(path, &palette)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -415,6 +487,26 @@ mod tests {
|
|||
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]
|
||||
fn handle_set_bad_extension_with_output_is_silent_without_breadd() {
|
||||
let dir = tmp_dir("bad-ext");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue