Compare commits

..

No commits in common. "main" and "v0.1.13" have entirely different histories.

10 changed files with 55 additions and 492 deletions

6
Cargo.lock generated
View file

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

View file

@ -1,6 +1,6 @@
[package] [package]
name = "breadpaper" name = "breadpaper"
version = "0.1.14" version = "0.1.12"
edition = "2024" edition = "2024"
description = "Wallpaper manager for the bread desktop" description = "Wallpaper manager for the bread desktop"
license = "MIT" license = "MIT"
@ -12,5 +12,5 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
toml = "0.8" toml = "0.8"
gtk4 = { version = "0.11", features = ["v4_12"] } gtk4 = { version = "0.11", features = ["v4_12"] }
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] } bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] }
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] }

View file

@ -15,10 +15,9 @@ long-running `listen` subcommand holds a `subscribe` open.
`breadpaper listen` is fail-silent if breadd is down: `subscribe` `breadpaper listen` is fail-silent if breadd is down: `subscribe`
reconnects with backoff and simply delivers nothing until the daemon reconnects with backoff and simply delivers nothing until the daemon
comes back. It also honors `bread.monitor.connected` by re-applying comes back. The one-shot `set`/`get`/`library` path does not require
`~/.config/breadpaper/current.json`. The one-shot `set`/`get`/`library` `listen`. Modules that want to change the wallpaper without a listener
/`apply` path does not require `listen`. Modules that want to change can still shell out:
the wallpaper without a listener can still shell out:
```lua ```lua
bread.exec("breadpaper set /path/to/image.png") bread.exec("breadpaper set /path/to/image.png")
@ -36,9 +35,9 @@ bread.wait("bread.paper.set.done", { timeout = 10000 })
| Event | Data | When | | Event | Data | When |
|-------|------|------| |-------|------|------|
| `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.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>", "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.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>", "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.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.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.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. | | `bread.paper.library.failed` | `{ "error": "<message>" }` | `bread.command.paper.library` was received but the library process could not be spawned. |
@ -50,8 +49,8 @@ Honored only while `breadpaper listen` is running. A
| Verb | Data | Effect | | Verb | Data | Effect |
|------|------|--------| |------|------|--------|
| `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`. | | `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 applies to the picker's output and publishes `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`. |
### Not implemented: slideshow / random / next ### Not implemented: slideshow / random / next

View file

@ -3,13 +3,11 @@
Wallpaper setter for the bread desktop. One command sets the wallpaper Wallpaper setter for the bread desktop. One command sets the wallpaper
via [awww](https://github.com/heywoodlh/awww), generates a palette with via [awww](https://github.com/heywoodlh/awww), generates a palette with
[pywal](https://github.com/dylanaraps/pywal) (`wal`), and runs [pywal](https://github.com/dylanaraps/pywal) (`wal`), and runs
`bread-theme reload`. Two monitors can keep different wallpapers (and `bread-theme reload`.
per-output bread-theme files); the last path per output is stored in
`~/.config/breadpaper/current.json`.
`set` / `get` / `apply` stay one-shot CLI. `breadpaper library` (alias `set` / `get` stay one-shot CLI. `breadpaper library` (alias `browse`)
`browse`) opens a GTK picker over the wallpaper directories. It is not opens a GTK picker over the wallpaper directories. It is not a slideshow
a slideshow daemon. daemon.
## Dependencies ## Dependencies
@ -20,9 +18,7 @@ Must be on `$PATH` for `set`:
- `bread-theme` — theme reload (bakery package, not `breadd`) - `bread-theme` — theme reload (bakery package, not `breadd`)
`library` also needs GTK4 (the window loads `bread-theme`'s shared `library` also needs GTK4 (the window loads `bread-theme`'s shared
stylesheet and follows the monitor it sits on). `get` without stylesheet). `get` only reads the path pywal stored at `~/.cache/wal/wal`.
`--output` still reads the path pywal stored at `~/.cache/wal/wal`.
`get --output NAME` reads `~/.config/breadpaper/current.json`.
## Install ## Install
@ -40,16 +36,12 @@ install -Dm755 target/release/breadpaper ~/.local/bin/breadpaper
## Usage ## Usage
``` ```
breadpaper <path> # shorthand for `set` (all outputs) breadpaper <path> # shorthand for `set`
breadpaper <path> --output NAME # one output breadpaper set <path> # awww + wal + bread-theme reload
breadpaper set <path> # awww + wal + bread-theme reload (all) breadpaper get # print the current wallpaper path
breadpaper set <path> --output NAME breadpaper library # GTK picker (alias: browse)
breadpaper get # print ~/.cache/wal/wal breadpaper library --dir PATH # also scan PATH (repeatable)
breadpaper get --output NAME # path from current.json breadpaper listen # honor bread.command.paper.set / .library
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`. Supported formats: `png`, `jpg`, `jpeg`, `webp`, `gif`, `bmp`.
@ -71,9 +63,8 @@ library_dirs = [
``` ```
`BREADPAPER_LIBRARY_DIRS` (colon-separated) overrides the file. `--dir` `BREADPAPER_LIBRARY_DIRS` (colon-separated) overrides the file. `--dir`
appends extra roots for that invocation. Clicking a thumbnail applies appends extra roots for that invocation. Clicking a thumbnail runs the
to the monitor the picker is on (`set --output`); if the output cannot same `set` path as the CLI.
be resolved it falls back to all outputs.
## Bread events ## Bread events

View file

@ -1,126 +0,0 @@
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,5 +1,4 @@
mod config; mod config;
mod current;
mod library; mod library;
mod pywal; mod pywal;
mod theme; mod theme;
@ -31,101 +30,12 @@ pub fn library(extra_dirs: impl IntoIterator<Item = PathBuf>) -> Result<()> {
ui::run(cfg.library_dirs) ui::run(cfg.library_dirs)
} }
/// Set wallpaper + global pywal palette on every live output.
pub fn set(path: &Path) -> Result<()> { pub fn set(path: &Path) -> Result<()> {
let path = validate(path)?; let path = validate(path)?;
apply_wallpaper(&path)?; apply_wallpaper(&path)?;
generate_palette(&path)?; generate_palette(&path)?;
reload_theme()?; 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(()) Ok(())
} }
@ -134,11 +44,6 @@ fn restore_one(output: &str, path: &Path) -> Result<()> {
pub fn listen() -> Result<()> { pub fn listen() -> Result<()> {
let client = BreadClient::connect(APP_ID); let client = BreadClient::connect(APP_ID);
let _subscription = client.subscribe("bread.command.paper.**", handle_command); 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 { loop {
thread::park(); thread::park();
} }
@ -146,14 +51,11 @@ pub fn listen() -> Result<()> {
/// Fire-and-forget `bread.paper.changed`. Silent no-op if breadd is down /// Fire-and-forget `bread.paper.changed`. Silent no-op if breadd is down
/// (`BreadClient::emit` never blocks or errors the caller). /// (`BreadClient::emit` never blocks or errors the caller).
/// fn emit_changed(path: &Path) {
/// `output` is `None` when the wallpaper was applied to every output. BreadClient::connect(APP_ID).emit(
fn emit_changed(path: &Path, output: Option<&str>) { "bread.paper.changed",
let mut data = json!({ "path": path.to_string_lossy() }); 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) { fn handle_command(event: BreadEvent) {
@ -178,31 +80,21 @@ fn handle_set(data: &Value) {
); );
return; return;
}; };
let output = data.get("output").and_then(Value::as_str);
let path = Path::new(path_str); let path = Path::new(path_str);
let result = match output { match set(path) {
Some(name) => set_on(path, name),
None => set(path),
};
match result {
Ok(()) => { Ok(()) => {
let applied = path let applied = path
.canonicalize() .canonicalize()
.map(|p| p.to_string_lossy().into_owned()) .map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| path_str.to_string()); .unwrap_or_else(|_| path_str.to_string());
let mut payload = json!({ "path": applied }); client.emit("bread.paper.set.done", json!({ "path": applied }));
if let Some(name) = output {
payload["output"] = json!(name);
}
client.emit("bread.paper.set.done", payload);
} }
Err(e) => { Err(e) => {
eprintln!("breadpaper: bread.command.paper.set failed: {e:#}"); eprintln!("breadpaper: bread.command.paper.set failed: {e:#}");
let mut payload = json!({ "error": format!("{e:#}"), "path": path_str }); client.emit(
if let Some(name) = output { "bread.paper.set.failed",
payload["output"] = json!(name); json!({ "error": format!("{e:#}"), "path": path_str }),
} );
client.emit("bread.paper.set.failed", payload);
} }
} }
} }
@ -257,14 +149,6 @@ pub fn get() -> Result<PathBuf> {
Ok(PathBuf::from(contents.trim())) 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<()> { pub fn apply_wallpaper(path: &Path) -> Result<()> {
wallpaper::apply(path) wallpaper::apply(path)
} }
@ -299,52 +183,15 @@ fn validate(path: &Path) -> Result<PathBuf> {
Ok(canonical) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn emit_changed_is_silent_without_breadd() { fn emit_changed_is_silent_without_breadd() {
// BreadClient::emit must never panic or error just because the // BreadClient::emit must never panic or error just because the
// socket is missing — this is the fail-silent contract. // socket is missing — this is the fail-silent contract.
emit_changed(Path::new("/tmp/wallpaper.png"), None); emit_changed(Path::new("/tmp/wallpaper.png"));
emit_changed(Path::new("/tmp/wallpaper.png"), Some("eDP-1"));
} }
#[test] #[test]
@ -383,16 +230,6 @@ mod tests {
handle_set(&json!({ "path": 1 })); 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] #[test]
fn handle_command_library_is_silent_without_breadd() { fn handle_command_library_is_silent_without_breadd() {
handle_command(BreadEvent { handle_command(BreadEvent {
@ -401,29 +238,4 @@ mod tests {
data: json!({}), 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,10 +13,6 @@ struct Cli {
/// Image file to set as wallpaper (shorthand for `set`) /// Image file to set as wallpaper (shorthand for `set`)
path: Option<PathBuf>, path: Option<PathBuf>,
/// Restrict set/get to one compositor output
#[arg(long, value_name = "NAME", global = true)]
output: Option<String>,
#[command(subcommand)] #[command(subcommand)]
command: Option<Command>, command: Option<Command>,
} }
@ -27,8 +23,6 @@ enum Command {
Set { path: PathBuf }, Set { path: PathBuf },
/// Print the current wallpaper path /// Print the current wallpaper path
Get, Get,
/// Re-apply wallpapers and per-output themes from current.json
Apply,
/// Honor bread.command.paper.set / .library until killed /// Honor bread.command.paper.set / .library until killed
Listen, Listen,
/// Open the wallpaper library (alias: browse) /// Open the wallpaper library (alias: browse)
@ -44,17 +38,12 @@ fn main() {
let cli = Cli::parse(); let cli = Cli::parse();
let result = match (cli.command, cli.path) { let result = match (cli.command, cli.path) {
(Some(Command::Set { path }), _) | (None, Some(path)) => match cli.output.as_deref() { (Some(Command::Set { path }), _) | (None, Some(path)) => breadpaper::set(&path),
Some(output) => breadpaper::set_on(&path, output),
None => breadpaper::set(&path),
},
(Some(Command::Listen), _) => breadpaper::listen(), (Some(Command::Listen), _) => breadpaper::listen(),
(Some(Command::Library { dirs }), _) => breadpaper::library(dirs), (Some(Command::Library { dirs }), _) => breadpaper::library(dirs),
(Some(Command::Apply), _) => breadpaper::apply_saved(), (Some(Command::Get), _) | (None, None) => {
(Some(Command::Get), _) | (None, None) => match cli.output.as_deref() { breadpaper::get().map(|p| println!("{}", p.display()))
Some(output) => breadpaper::get_on(output).map(|p| println!("{}", p.display())), }
None => breadpaper::get().map(|p| println!("{}", p.display())),
},
}; };
if let Err(e) = result { if let Err(e) = result {

View file

@ -1,8 +1,6 @@
use std::path::Path;
use std::process::Command; use std::process::Command;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use bread_theme::Palette;
pub fn reload() -> Result<()> { pub fn reload() -> Result<()> {
let status = Command::new("bread-theme") let status = Command::new("bread-theme")
@ -15,16 +13,3 @@ pub fn reload() -> Result<()> {
} }
Ok(()) 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,7 +118,6 @@ fn present(app: &Application, dirs: Vec<PathBuf>) {
root.append(&stack); root.append(&stack);
window.set_child(Some(&root)); window.set_child(Some(&root));
bread_theme::gtk::bind_window_auto(&window);
let dirs = Rc::new(dirs); let dirs = Rc::new(dirs);
let reload = { let reload = {
@ -128,7 +127,6 @@ fn present(app: &Application, dirs: Vec<PathBuf>) {
let status = status.clone(); let status = status.clone();
let stack = stack.clone(); let stack = stack.clone();
let empty = empty.clone(); let empty = empty.clone();
let window = window.clone();
Rc::new(move || { Rc::new(move || {
let papers = library::scan(&dirs); let papers = library::scan(&dirs);
summary.set_text(&dirs_summary(&dirs, papers.len())); summary.set_text(&dirs_summary(&dirs, papers.len()));
@ -138,44 +136,30 @@ fn present(app: &Application, dirs: Vec<PathBuf>) {
} else { } else {
stack.set_visible_child_name("grid"); stack.set_visible_child_name("grid");
} }
fill_grid(&flow, &papers, &status, &window); fill_grid(&flow, &papers, &status);
}) })
}; };
window.present();
reload(); reload();
{ {
let reload = reload.clone(); let reload = reload.clone();
refresh.connect_clicked(move |_| reload()); refresh.connect_clicked(move |_| reload());
} }
window.present();
} }
fn fill_grid(flow: &FlowBox, papers: &[Wallpaper], status: &Label, host: &impl IsA<gtk4::Widget>) { fn fill_grid(flow: &FlowBox, papers: &[Wallpaper], status: &Label) {
while let Some(child) = flow.first_child() { while let Some(child) = flow.first_child() {
flow.remove(&child); flow.remove(&child);
} }
let current = current_path_for(host); let current = crate::get().ok();
for paper in papers { for paper in papers {
let is_current = current.as_deref() == Some(paper.path.as_path()); let is_current = current.as_deref() == Some(paper.path.as_path());
flow.insert(&tile(paper, is_current, flow, status), -1); 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 { fn tile(paper: &Wallpaper, is_current: bool, flow: &FlowBox, status: &Label) -> Button {
let btn = Button::new(); let btn = Button::new();
btn.add_css_class("wallpaper-tile"); btn.add_css_class("wallpaper-tile");
@ -205,11 +189,7 @@ fn tile(paper: &Wallpaper, is_current: bool, flow: &FlowBox, status: &Label) ->
return; return;
} }
clicked.set_sensitive(false); clicked.set_sensitive(false);
let output = target_output(clicked); status.set_text(&format!("Applying {pretty}"));
status.set_text(&match output.as_deref() {
Some(name) => format!("Applying {pretty} on {name}"),
None => format!("Applying {pretty}"),
});
let path = path.clone(); let path = path.clone();
let pretty = pretty.clone(); let pretty = pretty.clone();
let status = status.clone(); let status = status.clone();
@ -217,19 +197,11 @@ fn tile(paper: &Wallpaper, is_current: bool, flow: &FlowBox, status: &Label) ->
let clicked = clicked.clone(); let clicked = clicked.clone();
gtk4::glib::spawn_future_local(async move { gtk4::glib::spawn_future_local(async move {
let path_thread = path.clone(); let path_thread = path.clone();
let output_thread = output.clone(); let result = gtk4::gio::spawn_blocking(move || crate::set(&path_thread)).await;
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); clicked.set_sensitive(true);
match result { match result {
Ok(Ok(())) => { Ok(Ok(())) => {
status.set_text(&match output.as_deref() { status.set_text(&format!("Applied {pretty}"));
Some(name) => format!("Applied {pretty} on {name}"),
None => format!("Applied {pretty}"),
});
mark_current(&flow, &path); mark_current(&flow, &path);
} }
Ok(Err(e)) => status.set_text(&format!("{e:#}")), Ok(Err(e)) => status.set_text(&format!("{e:#}")),

View file

@ -4,73 +4,14 @@ use std::process::Command;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
pub fn apply(path: &Path) -> Result<()> { pub fn apply(path: &Path) -> Result<()> {
run_awww(Command::new("awww").arg("img").arg(path)) let status = 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() .status()
.context("failed to run awww — is awww-daemon running?")?; .context("failed to run awww — is awww-daemon running?")?;
if !status.success() { if !status.success() {
bail!("awww img exited with {}", status); bail!("awww img exited with {}", status);
} }
Ok(()) 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());
}
}