diff --git a/src/current.rs b/src/current.rs index f39d680..f43f765 100644 --- a/src/current.rs +++ b/src/current.rs @@ -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 { &self.outputs } diff --git a/src/lib.rs b/src/lib.rs index 60d1c9b..5f113c6 100644 --- a/src/lib.rs +++ b/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 = 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 = 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/.json` + `themes/.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 { + cur.all() + .keys() + .filter(|k| k.as_str() != "*" && !connected.iter().any(|c| c == *k)) + .cloned() + .collect() +} + +/// Remove `current.json` entries and their `palettes/.json` / +/// `themes/.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");