Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e88979378 | ||
|
|
2c01dfab3e | ||
|
|
8d8be6ebb1 |
10 changed files with 492 additions and 55 deletions
6
Cargo.lock
generated
6
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
17
EVENTS.md
17
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": "<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
|
||||
|
||||
|
|
|
|||
29
README.md
29
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,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
126
src/current.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
214
src/lib.rs
214
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<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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
19
src/main.rs
19
src/main.rs
|
|
@ -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 {
|
||||
|
|
|
|||
15
src/theme.rs
15
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<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(|_| ())
|
||||
}
|
||||
|
|
|
|||
44
src/ui.rs
44
src/ui.rs
|
|
@ -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:#}")),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue