From 8d8be6ebb1ca53e20a2a6664183717d796f1fad7 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 13:06:17 +0800 Subject: [PATCH] Add per-output wallpaper set, persist, and theme Two monitors can keep different wallpapers. `set --output` targets one awww output, writes that output's bread-theme files, and updates ~/.config/breadpaper/current.json without clobbering the other. Global set still runs wal + bread-theme reload and stamps every known output. `apply` and listen-on-monitor-connect restore the saved map. The library picker binds to its monitor and applies to that output. --- Cargo.lock | 1 - Cargo.toml | 2 +- EVENTS.md | 17 ++-- README.md | 35 +++++--- src/current.rs | 126 ++++++++++++++++++++++++++++ src/lib.rs | 214 ++++++++++++++++++++++++++++++++++++++++++++--- src/main.rs | 19 ++++- src/theme.rs | 15 ++++ src/ui.rs | 44 ++++++++-- src/wallpaper.rs | 67 ++++++++++++++- 10 files changed, 488 insertions(+), 52 deletions(-) create mode 100644 src/current.rs diff --git a/Cargo.lock b/Cargo.lock index 5052a56..ab133f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,7 +84,6 @@ dependencies = [ [[package]] name = "bread-theme" version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "dirs", "gtk4", diff --git a/Cargo.toml b/Cargo.toml index f89a4c5..707e7d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,5 +12,5 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" gtk4 = { version = "0.11", features = ["v4_12"] } -bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] } +bread-theme = { path = "../bread-ecosystem/bread-theme", features = ["gtk"] } bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } diff --git a/EVENTS.md b/EVENTS.md index 6922dd6..37260ba 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -15,9 +15,10 @@ long-running `listen` subcommand holds a `subscribe` open. `breadpaper listen` is fail-silent if breadd is down: `subscribe` reconnects with backoff and simply delivers nothing until the daemon -comes back. The one-shot `set`/`get`/`library` path does not require -`listen`. Modules that want to change the wallpaper without a listener -can still shell out: +comes back. It also honors `bread.monitor.connected` by re-applying +`~/.config/breadpaper/current.json`. The one-shot `set`/`get`/`library` +/`apply` path does not require `listen`. Modules that want to change +the wallpaper without a listener can still shell out: ```lua bread.exec("breadpaper set /path/to/image.png") @@ -35,9 +36,9 @@ bread.wait("bread.paper.set.done", { timeout = 10000 }) | Event | Data | When | |-------|------|------| -| `bread.paper.changed` | `{ "path": "" }` | After a successful `set` (awww + wal + `bread-theme reload`), including when `listen` honors `bread.command.paper.set`. `path` is the canonical absolute path that was applied. Not emitted on `get`, and not emitted if any of the three steps fail. | -| `bread.paper.set.done` | `{ "path": "" }` | `bread.command.paper.set` was received and `set()` succeeded. `path` is the canonical absolute path that was applied. Not emitted by the one-shot CLI `set` — that path only publishes `changed`. | -| `bread.paper.set.failed` | `{ "error": "", "path"?: "" }` | `bread.command.paper.set` was received but `set()` failed, or `data.path` was missing/not a string. `path` is the requested (not canonical) path when one was supplied. | +| `bread.paper.changed` | `{ "path": "", "output"?: "" }` | After a successful `set` / `set --output` (awww + palette + theme write), including when `listen` honors `bread.command.paper.set` or restores `current.json` after `bread.monitor.connected`. `path` is the canonical absolute path that was applied. `output` is the compositor output name when only one monitor was targeted; omit (or `null`) means every output. Not emitted on `get`, and not emitted if the set fails. | +| `bread.paper.set.done` | `{ "path": "", "output"?: "" }` | `bread.command.paper.set` was received and `set()` / `set_on()` succeeded. `path` is the canonical absolute path that was applied. `output` is present when the command targeted one monitor. Not emitted by the one-shot CLI `set` — that path only publishes `changed`. | +| `bread.paper.set.failed` | `{ "error": "", "path"?: "", "output"?: "" }` | `bread.command.paper.set` was received but `set()` / `set_on()` failed, or `data.path` was missing/not a string. `path` is the requested (not canonical) path when one was supplied. `output` is echoed when the command included one. | | `bread.paper.library.done` | `{}` | `bread.command.paper.library` was received and a `breadpaper library` process was started. Not emitted by the one-shot CLI `library` / `browse`. | | `bread.paper.library.failed` | `{ "error": "" }` | `bread.command.paper.library` was received but the library process could not be spawned. | @@ -49,8 +50,8 @@ Honored only while `breadpaper listen` is running. A | Verb | Data | Effect | |------|------|--------| -| `set` | `{ "path": "..." }` | Calls the existing `set()` (awww + wal + `bread-theme reload`). Emits `bread.paper.set.done` / `.failed`. A successful set also emits `bread.paper.changed`. | -| `library` | `{}` | Spawns `breadpaper library` (GTK picker). Emits `bread.paper.library.done` once the process is started, or `bread.paper.library.failed` if the spawn fails. Clicking a thumbnail in that window is a normal `set` and publishes `bread.paper.changed`. | +| `set` | `{ "path": "...", "output"?: "..." }` | Missing `output` calls `set()` (all live outputs, global `wal -i`, `bread-theme reload`). A string `output` calls `set_on()` (that monitor only; no global wal unless it is the focused Hyprland monitor). Emits `bread.paper.set.done` / `.failed`. A successful set also emits `bread.paper.changed`. | +| `library` | `{}` | Spawns `breadpaper library` (GTK picker). Emits `bread.paper.library.done` once the process is started, or `bread.paper.library.failed` if the spawn fails. Clicking a thumbnail in that window applies to the picker's output and publishes `bread.paper.changed`. | ### Not implemented: slideshow / random / next diff --git a/README.md b/README.md index 7064d1d..f49b156 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,13 @@ Wallpaper setter for the bread desktop. One command sets the wallpaper via [awww](https://github.com/heywoodlh/awww), generates a palette with [pywal](https://github.com/dylanaraps/pywal) (`wal`), and runs -`bread-theme reload`. +`bread-theme reload`. Two monitors can keep different wallpapers (and +per-output bread-theme files); the last path per output is stored in +`~/.config/breadpaper/current.json`. -`set` / `get` stay one-shot CLI. `breadpaper library` (alias `browse`) -opens a GTK picker over the wallpaper directories. It is not a slideshow -daemon. +`set` / `get` / `apply` stay one-shot CLI. `breadpaper library` (alias +`browse`) opens a GTK picker over the wallpaper directories. It is not +a slideshow daemon. ## Dependencies @@ -18,7 +20,9 @@ Must be on `$PATH` for `set`: - `bread-theme` — theme reload (bakery package, not `breadd`) `library` also needs GTK4 (the window loads `bread-theme`'s shared -stylesheet). `get` only reads the path pywal stored at `~/.cache/wal/wal`. +stylesheet and follows the monitor it sits on). `get` without +`--output` still reads the path pywal stored at `~/.cache/wal/wal`. +`get --output NAME` reads `~/.config/breadpaper/current.json`. ## Install @@ -36,12 +40,16 @@ install -Dm755 target/release/breadpaper ~/.local/bin/breadpaper ## Usage ``` -breadpaper # shorthand for `set` -breadpaper set # awww + wal + bread-theme reload -breadpaper get # print the current wallpaper path -breadpaper library # GTK picker (alias: browse) -breadpaper library --dir PATH # also scan PATH (repeatable) -breadpaper listen # honor bread.command.paper.set / .library +breadpaper # shorthand for `set` (all outputs) +breadpaper --output NAME # one output +breadpaper set # awww + wal + bread-theme reload (all) +breadpaper set --output NAME +breadpaper get # print ~/.cache/wal/wal +breadpaper get --output NAME # path from current.json +breadpaper apply # restore current.json +breadpaper library # GTK picker (alias: browse) +breadpaper library --dir PATH # also scan PATH (repeatable) +breadpaper listen # honor bread.command.paper.set / .library ``` Supported formats: `png`, `jpg`, `jpeg`, `webp`, `gif`, `bmp`. @@ -63,8 +71,9 @@ library_dirs = [ ``` `BREADPAPER_LIBRARY_DIRS` (colon-separated) overrides the file. `--dir` -appends extra roots for that invocation. Clicking a thumbnail runs the -same `set` path as the CLI. +appends extra roots for that invocation. Clicking a thumbnail applies +to the monitor the picker is on (`set --output`); if the output cannot +be resolved it falls back to all outputs. ## Bread events diff --git a/src/current.rs b/src/current.rs new file mode 100644 index 0000000..f39d680 --- /dev/null +++ b/src/current.rs @@ -0,0 +1,126 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +/// Persisted wallpaper path per output (`~/.config/breadpaper/current.json`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Current { + #[serde(default)] + outputs: BTreeMap, +} + +impl Current { + pub fn path() -> PathBuf { + bread_utils::xdg::config_dir("breadpaper").join("current.json") + } + + pub fn load() -> Self { + Self::load_from(&Self::path()) + } + + /// Missing or unreadable file => empty map. + pub fn load_from(path: &Path) -> Self { + let Ok(text) = std::fs::read_to_string(path) else { + return Self::default(); + }; + serde_json::from_str(&text).unwrap_or_default() + } + + pub fn save(&self) -> Result<()> { + self.save_to(&Self::path()) + } + + pub fn save_to(&self, path: &Path) -> Result<()> { + let text = serde_json::to_string_pretty(self).context("serialize current.json")?; + let text = format!("{text}\n"); + bread_utils::atomic::write_atomic(path, &text, None) + .with_context(|| format!("write {}", path.display())) + } + + pub fn set_output(&mut self, output: impl Into, path: impl Into) { + self.outputs.insert(output.into(), path.into()); + } + + pub fn get_output(&self, output: &str) -> Option<&Path> { + self.outputs.get(output).map(PathBuf::as_path) + } + + pub fn all(&self) -> &BTreeMap { + &self.outputs + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "breadpaper-current-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn missing_file_is_empty_map() { + let dir = tmp_dir("missing"); + let path = dir.join("current.json"); + let cur = Current::load_from(&path); + assert!(cur.all().is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn roundtrip_pretty_json() { + let dir = tmp_dir("roundtrip"); + let path = dir.join("nested").join("current.json"); + let mut cur = Current::default(); + cur.set_output("eDP-1", "/abs/path/a.png"); + cur.set_output("HDMI-A-1", "/abs/path/b.png"); + cur.save_to(&path).unwrap(); + + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("\n \"outputs\"")); + assert!(text.contains("\n \"HDMI-A-1\"")); + assert!(text.contains("\n \"eDP-1\"")); + + let loaded = Current::load_from(&path); + assert_eq!( + loaded.get_output("eDP-1"), + Some(Path::new("/abs/path/a.png")) + ); + assert_eq!( + loaded.get_output("HDMI-A-1"), + Some(Path::new("/abs/path/b.png")) + ); + assert_eq!(loaded.all().len(), 2); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn set_one_output_does_not_drop_others() { + let dir = tmp_dir("keep"); + let path = dir.join("current.json"); + let mut cur = Current::default(); + cur.set_output("eDP-1", "/abs/a.png"); + cur.set_output("HDMI-A-1", "/abs/b.png"); + cur.save_to(&path).unwrap(); + + let mut cur = Current::load_from(&path); + cur.set_output("eDP-1", "/abs/c.png"); + cur.save_to(&path).unwrap(); + + let loaded = Current::load_from(&path); + assert_eq!(loaded.get_output("eDP-1"), Some(Path::new("/abs/c.png"))); + assert_eq!(loaded.get_output("HDMI-A-1"), Some(Path::new("/abs/b.png"))); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/lib.rs b/src/lib.rs index 26be507..60d1c9b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ mod config; +mod current; mod library; mod pywal; mod theme; @@ -30,12 +31,101 @@ pub fn library(extra_dirs: impl IntoIterator) -> Result<()> { ui::run(cfg.library_dirs) } +/// Set wallpaper + global pywal palette on every live output. pub fn set(path: &Path) -> Result<()> { let path = validate(path)?; apply_wallpaper(&path)?; generate_palette(&path)?; reload_theme()?; - emit_changed(&path); + + 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()); + } + } + } else { + let keys: Vec = cur.all().keys().cloned().collect(); + for output in keys { + cur.set_output(output, path.clone()); + } + } + cur.save()?; + + for output in cur.all().keys() { + if output != "*" { + theme::generate_for_output(output, &path)?; + } + } + + emit_changed(&path, None); + Ok(()) +} + +/// 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. +pub fn set_on(path: &Path, output: &str) -> Result<()> { + if output.is_empty() { + bail!("output name is empty"); + } + let path = validate(path)?; + wallpaper::apply_on(&path, output)?; + let palette = theme::generate_for_output(output, &path)?; + + let mut cur = current::Current::load(); + cur.set_output(output, path.clone()); + cur.save()?; + + if is_focused_output(output) { + theme::write_shared_from(&palette)?; + } + + emit_changed(&path, Some(output)); + Ok(()) +} + +/// Re-apply every wallpaper + per-output theme stored in current.json. +pub fn apply_saved() -> Result<()> { + let cur = current::Current::load(); + let mut first_err = None; + for (output, path) in cur.all() { + let result = restore_one(output, path); + match result { + Ok(()) => { + let name = (output != "*").then_some(output.as_str()); + emit_changed(path, name); + } + Err(e) => { + eprintln!("breadpaper: apply {output}: {e:#}"); + if first_err.is_none() { + first_err = Some(e); + } + } + } + } + match first_err { + Some(e) => Err(e), + None => Ok(()), + } +} + +fn restore_one(output: &str, path: &Path) -> Result<()> { + if output == "*" { + wallpaper::apply(path)?; + return Ok(()); + } + wallpaper::apply_on(path, output)?; + let palette = theme::generate_for_output(output, path)?; + if is_focused_output(output) { + theme::write_shared_from(&palette)?; + } Ok(()) } @@ -44,6 +134,11 @@ pub fn set(path: &Path) -> Result<()> { pub fn listen() -> Result<()> { let client = BreadClient::connect(APP_ID); let _subscription = client.subscribe("bread.command.paper.**", handle_command); + let _monitors = client.subscribe("bread.monitor.connected", |_| { + if let Err(e) = apply_saved() { + eprintln!("breadpaper: apply_saved on monitor connect failed: {e:#}"); + } + }); loop { thread::park(); } @@ -51,11 +146,14 @@ pub fn listen() -> Result<()> { /// Fire-and-forget `bread.paper.changed`. Silent no-op if breadd is down /// (`BreadClient::emit` never blocks or errors the caller). -fn emit_changed(path: &Path) { - BreadClient::connect(APP_ID).emit( - "bread.paper.changed", - json!({ "path": path.to_string_lossy() }), - ); +/// +/// `output` is `None` when the wallpaper was applied to every output. +fn emit_changed(path: &Path, output: Option<&str>) { + let mut data = json!({ "path": path.to_string_lossy() }); + if let Some(name) = output { + data["output"] = json!(name); + } + BreadClient::connect(APP_ID).emit("bread.paper.changed", data); } fn handle_command(event: BreadEvent) { @@ -80,21 +178,31 @@ fn handle_set(data: &Value) { ); return; }; + let output = data.get("output").and_then(Value::as_str); let path = Path::new(path_str); - match set(path) { + let result = match output { + Some(name) => set_on(path, name), + None => set(path), + }; + match result { Ok(()) => { let applied = path .canonicalize() .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_else(|_| path_str.to_string()); - client.emit("bread.paper.set.done", json!({ "path": applied })); + let mut payload = json!({ "path": applied }); + if let Some(name) = output { + payload["output"] = json!(name); + } + client.emit("bread.paper.set.done", payload); } Err(e) => { eprintln!("breadpaper: bread.command.paper.set failed: {e:#}"); - client.emit( - "bread.paper.set.failed", - json!({ "error": format!("{e:#}"), "path": path_str }), - ); + let mut payload = json!({ "error": format!("{e:#}"), "path": path_str }); + if let Some(name) = output { + payload["output"] = json!(name); + } + client.emit("bread.paper.set.failed", payload); } } } @@ -149,6 +257,14 @@ pub fn get() -> Result { Ok(PathBuf::from(contents.trim())) } +/// Wallpaper path last persisted for `output` in current.json. +pub fn get_on(output: &str) -> Result { + current::Current::load() + .get_output(output) + .map(Path::to_path_buf) + .with_context(|| format!("no wallpaper saved for output {output}")) +} + pub fn apply_wallpaper(path: &Path) -> Result<()> { wallpaper::apply(path) } @@ -183,15 +299,52 @@ fn validate(path: &Path) -> Result { Ok(canonical) } +fn live_outputs() -> Vec { + if let Some(names) = hypr_output_names() { + return names; + } + wallpaper::query_outputs() +} + +fn hypr_output_names() -> Option> { + let v = bread_utils::hypr::request_json("j/monitors")?; + let names: Vec = v + .as_array()? + .iter() + .filter_map(|m| m.get("name").and_then(|n| n.as_str()).map(str::to_string)) + .collect(); + if names.is_empty() { None } else { Some(names) } +} + +fn is_focused_output(output: &str) -> bool { + bread_utils::hypr::focused_monitor() + .map(|m| m.name == output) + .unwrap_or(false) +} + #[cfg(test)] mod tests { use super::*; + fn tmp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "breadpaper-lib-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + #[test] fn emit_changed_is_silent_without_breadd() { // BreadClient::emit must never panic or error just because the // socket is missing — this is the fail-silent contract. - emit_changed(Path::new("/tmp/wallpaper.png")); + emit_changed(Path::new("/tmp/wallpaper.png"), None); + emit_changed(Path::new("/tmp/wallpaper.png"), Some("eDP-1")); } #[test] @@ -230,6 +383,16 @@ mod tests { handle_set(&json!({ "path": 1 })); } + #[test] + fn handle_set_with_output_vs_without_is_silent_without_breadd() { + // Missing files fail in validate — never reaches awww/wal. + handle_set(&json!({ "path": "/no/such/breadpaper-wallpaper.png" })); + handle_set(&json!({ + "path": "/no/such/breadpaper-wallpaper.png", + "output": "eDP-1" + })); + } + #[test] fn handle_command_library_is_silent_without_breadd() { handle_command(BreadEvent { @@ -238,4 +401,29 @@ mod tests { data: json!({}), }); } + + #[test] + fn validate_rejects_bad_extensions() { + let dir = tmp_dir("validate"); + let txt = dir.join("notes.txt"); + std::fs::write(&txt, b"x").unwrap(); + assert!(validate(&txt).is_err()); + + let png = dir.join("ok.png"); + std::fs::write(&png, b"x").unwrap(); + assert_eq!(validate(&png).unwrap(), png.canonicalize().unwrap()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn handle_set_bad_extension_with_output_is_silent_without_breadd() { + let dir = tmp_dir("bad-ext"); + let txt = dir.join("notes.txt"); + std::fs::write(&txt, b"x").unwrap(); + handle_set(&json!({ + "path": txt.to_string_lossy(), + "output": "HDMI-A-1" + })); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/src/main.rs b/src/main.rs index 8069a89..899386f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,10 @@ struct Cli { /// Image file to set as wallpaper (shorthand for `set`) path: Option, + /// Restrict set/get to one compositor output + #[arg(long, value_name = "NAME", global = true)] + output: Option, + #[command(subcommand)] command: Option, } @@ -23,6 +27,8 @@ enum Command { Set { path: PathBuf }, /// Print the current wallpaper path Get, + /// Re-apply wallpapers and per-output themes from current.json + Apply, /// Honor bread.command.paper.set / .library until killed Listen, /// Open the wallpaper library (alias: browse) @@ -38,12 +44,17 @@ fn main() { let cli = Cli::parse(); let result = match (cli.command, cli.path) { - (Some(Command::Set { path }), _) | (None, Some(path)) => breadpaper::set(&path), + (Some(Command::Set { path }), _) | (None, Some(path)) => match cli.output.as_deref() { + Some(output) => breadpaper::set_on(&path, output), + None => breadpaper::set(&path), + }, (Some(Command::Listen), _) => breadpaper::listen(), (Some(Command::Library { dirs }), _) => breadpaper::library(dirs), - (Some(Command::Get), _) | (None, None) => { - breadpaper::get().map(|p| println!("{}", p.display())) - } + (Some(Command::Apply), _) => breadpaper::apply_saved(), + (Some(Command::Get), _) | (None, None) => match cli.output.as_deref() { + Some(output) => breadpaper::get_on(output).map(|p| println!("{}", p.display())), + None => breadpaper::get().map(|p| println!("{}", p.display())), + }, }; if let Err(e) = result { diff --git a/src/theme.rs b/src/theme.rs index 5d06076..a104e17 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,6 +1,8 @@ +use std::path::Path; use std::process::Command; use anyhow::{Context, Result, bail}; +use bread_theme::Palette; pub fn reload() -> Result<()> { let status = Command::new("bread-theme") @@ -13,3 +15,16 @@ pub fn reload() -> Result<()> { } Ok(()) } + +/// Per-output palette + bread-theme files. Does not run `wal -i`. +pub fn generate_for_output(output: &str, path: &Path) -> Result { + bread_theme::generate_output(output, path) + .with_context(|| format!("bread-theme generate_output({output}, {})", path.display()))?; + Ok(bread_theme::load_palette_for(output)) +} + +pub fn write_shared_from(palette: &Palette) -> Result<()> { + bread_theme::write_shared_css_from(palette) + .context("bread-theme write_shared_css_from") + .map(|_| ()) +} diff --git a/src/ui.rs b/src/ui.rs index 5193541..01e5365 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -118,6 +118,7 @@ fn present(app: &Application, dirs: Vec) { root.append(&stack); window.set_child(Some(&root)); + bread_theme::gtk::bind_window_auto(&window); let dirs = Rc::new(dirs); let reload = { @@ -127,6 +128,7 @@ fn present(app: &Application, dirs: Vec) { let status = status.clone(); let stack = stack.clone(); let empty = empty.clone(); + let window = window.clone(); Rc::new(move || { let papers = library::scan(&dirs); summary.set_text(&dirs_summary(&dirs, papers.len())); @@ -136,30 +138,44 @@ fn present(app: &Application, dirs: Vec) { } else { stack.set_visible_child_name("grid"); } - fill_grid(&flow, &papers, &status); + fill_grid(&flow, &papers, &status, &window); }) }; + window.present(); reload(); { let reload = reload.clone(); refresh.connect_clicked(move |_| reload()); } - - window.present(); } -fn fill_grid(flow: &FlowBox, papers: &[Wallpaper], status: &Label) { +fn fill_grid(flow: &FlowBox, papers: &[Wallpaper], status: &Label, host: &impl IsA) { while let Some(child) = flow.first_child() { flow.remove(&child); } - let current = crate::get().ok(); + let current = current_path_for(host); for paper in papers { let is_current = current.as_deref() == Some(paper.path.as_path()); flow.insert(&tile(paper, is_current, flow, status), -1); } } +fn current_path_for(widget: &impl IsA) -> Option { + target_output(widget) + .and_then(|output| { + crate::current::Current::load() + .get_output(&output) + .map(Path::to_path_buf) + }) + .or_else(|| crate::get().ok()) +} + +fn target_output(widget: &impl IsA) -> Option { + bread_theme::gtk::output_for_widget(widget) + .or_else(|| bread_utils::hypr::focused_monitor().map(|m| m.name)) +} + fn tile(paper: &Wallpaper, is_current: bool, flow: &FlowBox, status: &Label) -> Button { let btn = Button::new(); btn.add_css_class("wallpaper-tile"); @@ -189,7 +205,11 @@ fn tile(paper: &Wallpaper, is_current: bool, flow: &FlowBox, status: &Label) -> return; } clicked.set_sensitive(false); - status.set_text(&format!("Applying {pretty}…")); + let output = target_output(clicked); + status.set_text(&match output.as_deref() { + Some(name) => format!("Applying {pretty} on {name}…"), + None => format!("Applying {pretty}…"), + }); let path = path.clone(); let pretty = pretty.clone(); let status = status.clone(); @@ -197,11 +217,19 @@ fn tile(paper: &Wallpaper, is_current: bool, flow: &FlowBox, status: &Label) -> let clicked = clicked.clone(); gtk4::glib::spawn_future_local(async move { let path_thread = path.clone(); - let result = gtk4::gio::spawn_blocking(move || crate::set(&path_thread)).await; + let output_thread = output.clone(); + let result = gtk4::gio::spawn_blocking(move || match output_thread.as_deref() { + Some(name) => crate::set_on(&path_thread, name), + None => crate::set(&path_thread), + }) + .await; clicked.set_sensitive(true); match result { Ok(Ok(())) => { - status.set_text(&format!("Applied {pretty}")); + status.set_text(&match output.as_deref() { + Some(name) => format!("Applied {pretty} on {name}"), + None => format!("Applied {pretty}"), + }); mark_current(&flow, &path); } Ok(Err(e)) => status.set_text(&format!("{e:#}")), diff --git a/src/wallpaper.rs b/src/wallpaper.rs index f59fae1..fcf5d4b 100644 --- a/src/wallpaper.rs +++ b/src/wallpaper.rs @@ -4,14 +4,73 @@ use std::process::Command; use anyhow::{Context, Result, bail}; pub fn apply(path: &Path) -> Result<()> { - let status = Command::new("awww") - .arg("img") - .arg(path) + run_awww(Command::new("awww").arg("img").arg(path)) +} + +pub fn apply_on(path: &Path, output: &str) -> Result<()> { + run_awww( + Command::new("awww") + .arg("img") + .arg(path) + .arg("--outputs") + .arg(output), + ) +} + +/// Output names from `awww query`. Empty if the daemon isn't running. +pub fn query_outputs() -> Vec { + let Ok(out) = Command::new("awww").arg("query").output() else { + return Vec::new(); + }; + if !out.status.success() { + return Vec::new(); + } + parse_awww_query(&String::from_utf8_lossy(&out.stdout)) +} + +fn run_awww(cmd: &mut Command) -> Result<()> { + let status = cmd .status() .context("failed to run awww — is awww-daemon running?")?; - if !status.success() { bail!("awww img exited with {}", status); } Ok(()) } + +fn parse_awww_query(stdout: &str) -> Vec { + stdout.lines().filter_map(parse_awww_query_line).collect() +} + +/// `awww query` lines look like `: eDP-1: 1920x1080, scale: 1, ...`. +fn parse_awww_query_line(line: &str) -> Option { + let rest = line.trim().strip_prefix(':').unwrap_or(line).trim(); + let name = rest.split(':').next()?.trim(); + if name.is_empty() { + None + } else { + Some(name.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_awww_query_names() { + let sample = "\ +: eDP-1: 1920x1080, scale: 1, currently displaying: image: /a.png +: HDMI-A-1: 2560x1440, scale: 1, currently displaying: image: /b.png +"; + assert_eq!( + parse_awww_query(sample), + vec!["eDP-1".to_string(), "HDMI-A-1".to_string()] + ); + } + + #[test] + fn parse_awww_query_skips_blank() { + assert!(parse_awww_query("\n \n").is_empty()); + } +}