Compare commits

...

3 commits

Author SHA1 Message Date
Breadway
4e88979378 Bump version to v0.1.14
Some checks failed
beta (rc) release / build (push) Has been skipped
dev release / build (push) Failing after 10s
release / build (push) Successful in 34s
2026-08-16 14:08:37 +08:00
Breadway
2c01dfab3e Pin bread-theme to v0.7.4 for per-output palettes
All checks were successful
dev release / build (push) Successful in 47s
2026-08-16 13:23:05 +08:00
Breadway
8d8be6ebb1 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.
2026-08-16 13:06:17 +08:00
10 changed files with 492 additions and 55 deletions

6
Cargo.lock generated
View file

@ -83,8 +83,8 @@ dependencies = [
[[package]]
name = "bread-theme"
version = "0.7.2"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
version = "0.7.4"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e"
dependencies = [
"dirs",
"gtk4",
@ -105,7 +105,7 @@ dependencies = [
[[package]]
name = "breadpaper"
version = "0.1.12"
version = "0.1.14"
dependencies = [
"anyhow",
"bread-theme",

View file

@ -1,6 +1,6 @@
[package]
name = "breadpaper"
version = "0.1.12"
version = "0.1.14"
edition = "2024"
description = "Wallpaper manager for the bread desktop"
license = "MIT"
@ -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 = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] }
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] }

View file

@ -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": "<wallpaper>" }` | 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": "<wallpaper>" }` | `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": "<message>", "path"?: "<requested>" }` | `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": "<wallpaper>", "output"?: "<name>" }` | 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": "<wallpaper>", "output"?: "<name>" }` | `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": "<message>", "path"?: "<requested>", "output"?: "<name>" }` | `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": "<message>" }` | `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

View file

@ -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,9 +40,13 @@ install -Dm755 target/release/breadpaper ~/.local/bin/breadpaper
## Usage
```
breadpaper <path> # shorthand for `set`
breadpaper set <path> # awww + wal + bread-theme reload
breadpaper get # print the current wallpaper path
breadpaper <path> # shorthand for `set` (all outputs)
breadpaper <path> --output NAME # one output
breadpaper set <path> # awww + wal + bread-theme reload (all)
breadpaper set <path> --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
@ -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

126
src/current.rs Normal file
View file

@ -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<String, PathBuf>,
}
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<String>, path: impl Into<PathBuf>) {
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<String, PathBuf> {
&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);
}
}

View file

@ -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<Item = PathBuf>) -> 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<String> = 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<PathBuf> {
Ok(PathBuf::from(contents.trim()))
}
/// Wallpaper path last persisted for `output` in current.json.
pub fn get_on(output: &str) -> Result<PathBuf> {
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<PathBuf> {
Ok(canonical)
}
fn live_outputs() -> Vec<String> {
if let Some(names) = hypr_output_names() {
return names;
}
wallpaper::query_outputs()
}
fn hypr_output_names() -> Option<Vec<String>> {
let v = bread_utils::hypr::request_json("j/monitors")?;
let names: Vec<String> = 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);
}
}

View file

@ -13,6 +13,10 @@ struct Cli {
/// Image file to set as wallpaper (shorthand for `set`)
path: Option<PathBuf>,
/// Restrict set/get to one compositor output
#[arg(long, value_name = "NAME", global = true)]
output: Option<String>,
#[command(subcommand)]
command: Option<Command>,
}
@ -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 {

View file

@ -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<Palette> {
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(|_| ())
}

View file

@ -118,6 +118,7 @@ fn present(app: &Application, dirs: Vec<PathBuf>) {
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<PathBuf>) {
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<PathBuf>) {
} 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<gtk4::Widget>) {
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<gtk4::Widget>) -> Option<PathBuf> {
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<gtk4::Widget>) -> Option<String> {
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:#}")),

View file

@ -4,14 +4,73 @@ use std::process::Command;
use anyhow::{Context, Result, bail};
pub fn apply(path: &Path) -> Result<()> {
let status = Command::new("awww")
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<String> {
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<String> {
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<String> {
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());
}
}