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.
This commit is contained in:
parent
5bc879d70e
commit
8d8be6ebb1
10 changed files with 488 additions and 52 deletions
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")
|
||||
.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<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