Add GTK wallpaper library
Scan ~/Pictures/Wallpapers and /usr/share/backgrounds/bos (configurable) and open a bread-theme GTK picker via `breadpaper library` (alias browse). Clicking a thumbnail runs the existing set path. listen honors bread.command.paper.library by spawning that picker.
This commit is contained in:
parent
01c7e5b73f
commit
1efc721990
15 changed files with 1398 additions and 31 deletions
252
src/config.rs
Normal file
252
src/config.rs
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// User library directory used when no config file is present.
|
||||
pub const DEFAULT_USER_LIBRARY: &str = "Pictures/Wallpapers";
|
||||
|
||||
/// Packaged BOS backgrounds, scanned when the directory exists.
|
||||
pub const DEFAULT_SYSTEM_LIBRARY: &str = "/usr/share/backgrounds/bos";
|
||||
|
||||
/// Colon-separated override of [`Config::library_dirs`]. Empty means "use
|
||||
/// the config file / defaults".
|
||||
pub const LIBRARY_DIRS_ENV: &str = "BREADPAPER_LIBRARY_DIRS";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Config {
|
||||
pub library_dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct ConfigFile {
|
||||
#[serde(default)]
|
||||
library_dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
library_dirs: default_library_dirs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn path() -> PathBuf {
|
||||
bread_utils::xdg::config_dir("breadpaper").join("config.toml")
|
||||
}
|
||||
|
||||
pub fn load() -> Self {
|
||||
Self::load_from(&Self::path())
|
||||
}
|
||||
|
||||
pub fn load_from(path: &Path) -> Self {
|
||||
let mut cfg = match std::fs::read_to_string(path) {
|
||||
Ok(text) => match toml::from_str::<ConfigFile>(&text) {
|
||||
Ok(parsed) if !parsed.library_dirs.is_empty() => Self {
|
||||
library_dirs: parsed.library_dirs,
|
||||
},
|
||||
Ok(_) => Self::default(),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"breadpaper: {} failed to parse ({e}); using defaults",
|
||||
path.display()
|
||||
);
|
||||
Self::default()
|
||||
}
|
||||
},
|
||||
Err(_) => Self::default(),
|
||||
};
|
||||
|
||||
if let Some(dirs) = env_library_dirs() {
|
||||
cfg.library_dirs = dirs;
|
||||
}
|
||||
|
||||
cfg.library_dirs = cfg
|
||||
.library_dirs
|
||||
.into_iter()
|
||||
.map(expand_tilde)
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.collect();
|
||||
cfg
|
||||
}
|
||||
|
||||
pub fn with_extra_dirs(mut self, extra: impl IntoIterator<Item = PathBuf>) -> Self {
|
||||
self.library_dirs
|
||||
.extend(extra.into_iter().map(expand_tilde));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_library_dirs() -> Vec<PathBuf> {
|
||||
vec![
|
||||
bread_utils::xdg::home_dir().join(DEFAULT_USER_LIBRARY),
|
||||
PathBuf::from(DEFAULT_SYSTEM_LIBRARY),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn expand_tilde(path: PathBuf) -> PathBuf {
|
||||
let Some(s) = path.to_str() else {
|
||||
return path;
|
||||
};
|
||||
if s == "~" {
|
||||
return bread_utils::xdg::home_dir();
|
||||
}
|
||||
if let Some(rest) = s.strip_prefix("~/") {
|
||||
return bread_utils::xdg::home_dir().join(rest);
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn env_library_dirs() -> Option<Vec<PathBuf>> {
|
||||
let raw = std::env::var(LIBRARY_DIRS_ENV).ok()?;
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let dirs: Vec<PathBuf> = raw
|
||||
.split(':')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.collect();
|
||||
if dirs.is_empty() { None } else { Some(dirs) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn tmp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"breadpaper-config-{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 default_dirs_are_pictures_wallpapers_and_bos_backgrounds() {
|
||||
let dirs = Config::default().library_dirs;
|
||||
assert!(
|
||||
dirs.iter().any(|d| d.ends_with(DEFAULT_USER_LIBRARY)),
|
||||
"missing ~/{DEFAULT_USER_LIBRARY} in {dirs:?}"
|
||||
);
|
||||
assert!(
|
||||
dirs.iter().any(|d| d == Path::new(DEFAULT_SYSTEM_LIBRARY)),
|
||||
"missing {DEFAULT_SYSTEM_LIBRARY} in {dirs:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_prefix() {
|
||||
let home = bread_utils::xdg::home_dir();
|
||||
assert_eq!(
|
||||
expand_tilde(PathBuf::from("~/Pictures/Wallpapers")),
|
||||
home.join("Pictures/Wallpapers")
|
||||
);
|
||||
assert_eq!(expand_tilde(PathBuf::from("~")), home);
|
||||
let abs = PathBuf::from("/usr/share/backgrounds/bos");
|
||||
assert_eq!(expand_tilde(abs.clone()), abs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_missing_file_uses_defaults() {
|
||||
let _lock = env_lock();
|
||||
let prev = std::env::var_os(LIBRARY_DIRS_ENV);
|
||||
unsafe { std::env::remove_var(LIBRARY_DIRS_ENV) };
|
||||
let cfg = Config::load_from(&PathBuf::from("/no/such/breadpaper-config.toml"));
|
||||
if let Some(v) = prev {
|
||||
unsafe { std::env::set_var(LIBRARY_DIRS_ENV, v) };
|
||||
}
|
||||
assert_eq!(cfg.library_dirs, default_library_dirs());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_parses_library_dirs_and_expands_tilde() {
|
||||
let _lock = env_lock();
|
||||
let prev = std::env::var_os(LIBRARY_DIRS_ENV);
|
||||
unsafe { std::env::remove_var(LIBRARY_DIRS_ENV) };
|
||||
|
||||
let dir = tmp_dir("parse");
|
||||
let path = dir.join("config.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"library_dirs = [\"~/custom/walls\", \"/opt/walls\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = Config::load_from(&path);
|
||||
if let Some(v) = prev {
|
||||
unsafe { std::env::set_var(LIBRARY_DIRS_ENV, v) };
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert_eq!(
|
||||
cfg.library_dirs,
|
||||
vec![
|
||||
bread_utils::xdg::home_dir().join("custom/walls"),
|
||||
PathBuf::from("/opt/walls"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_library_dirs_key_falls_back_to_defaults() {
|
||||
let _lock = env_lock();
|
||||
let prev = std::env::var_os(LIBRARY_DIRS_ENV);
|
||||
unsafe { std::env::remove_var(LIBRARY_DIRS_ENV) };
|
||||
|
||||
let dir = tmp_dir("empty");
|
||||
let path = dir.join("config.toml");
|
||||
std::fs::write(&path, "library_dirs = []\n").unwrap();
|
||||
let cfg = Config::load_from(&path);
|
||||
if let Some(v) = prev {
|
||||
unsafe { std::env::set_var(LIBRARY_DIRS_ENV, v) };
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert_eq!(cfg.library_dirs, default_library_dirs());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_overrides_config_file() {
|
||||
let _lock = env_lock();
|
||||
let prev = std::env::var_os(LIBRARY_DIRS_ENV);
|
||||
unsafe { std::env::set_var(LIBRARY_DIRS_ENV, "/tmp/a:/tmp/b") };
|
||||
|
||||
let dir = tmp_dir("env");
|
||||
let path = dir.join("config.toml");
|
||||
std::fs::write(&path, "library_dirs = [\"/from/file\"]\n").unwrap();
|
||||
let cfg = Config::load_from(&path);
|
||||
match prev {
|
||||
Some(v) => unsafe { std::env::set_var(LIBRARY_DIRS_ENV, v) },
|
||||
None => unsafe { std::env::remove_var(LIBRARY_DIRS_ENV) },
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert_eq!(
|
||||
cfg.library_dirs,
|
||||
vec![PathBuf::from("/tmp/a"), PathBuf::from("/tmp/b")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_extra_dirs_appends() {
|
||||
let cfg = Config {
|
||||
library_dirs: vec![PathBuf::from("/a")],
|
||||
}
|
||||
.with_extra_dirs([PathBuf::from("/b")]);
|
||||
assert_eq!(
|
||||
cfg.library_dirs,
|
||||
vec![PathBuf::from("/a"), PathBuf::from("/b")]
|
||||
);
|
||||
}
|
||||
}
|
||||
69
src/lib.rs
69
src/lib.rs
|
|
@ -1,13 +1,21 @@
|
|||
mod config;
|
||||
mod library;
|
||||
mod pywal;
|
||||
mod theme;
|
||||
mod ui;
|
||||
mod wallpaper;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(not(test))]
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use bread_utils::bread_client::{BreadClient, BreadEvent};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub use config::{Config, DEFAULT_SYSTEM_LIBRARY, DEFAULT_USER_LIBRARY};
|
||||
pub use library::{Wallpaper, scan};
|
||||
|
||||
/// App id in bread's sibling-app registry (`KNOWN_APPS`). Events publish as
|
||||
/// `bread.paper.*`. See `EVENTS.md`.
|
||||
|
|
@ -15,6 +23,13 @@ const APP_ID: &str = "paper";
|
|||
|
||||
const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "webp", "gif", "bmp"];
|
||||
|
||||
/// Open the GTK wallpaper library. Extra dirs are appended to the configured
|
||||
/// scan list (`~/.config/breadpaper/config.toml`, then defaults).
|
||||
pub fn library(extra_dirs: impl IntoIterator<Item = PathBuf>) -> Result<()> {
|
||||
let cfg = Config::load().with_extra_dirs(extra_dirs);
|
||||
ui::run(cfg.library_dirs)
|
||||
}
|
||||
|
||||
pub fn set(path: &Path) -> Result<()> {
|
||||
let path = validate(path)?;
|
||||
apply_wallpaper(&path)?;
|
||||
|
|
@ -49,6 +64,7 @@ fn handle_command(event: BreadEvent) {
|
|||
};
|
||||
match verb {
|
||||
"set" => handle_set(&event.data),
|
||||
"library" => handle_library(),
|
||||
other => {
|
||||
eprintln!("breadpaper: ignoring unrecognized command verb '{other}'");
|
||||
}
|
||||
|
|
@ -83,6 +99,46 @@ fn handle_set(data: &Value) {
|
|||
}
|
||||
}
|
||||
|
||||
fn handle_library() {
|
||||
let client = BreadClient::connect(APP_ID);
|
||||
match open_library() {
|
||||
Ok(()) => client.emit("bread.paper.library.done", json!({})),
|
||||
Err(e) => {
|
||||
eprintln!("breadpaper: bread.command.paper.library failed: {e:#}");
|
||||
client.emit(
|
||||
"bread.paper.library.failed",
|
||||
json!({ "error": format!("{e:#}") }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a one-shot `breadpaper library` so the listen loop can stay a
|
||||
/// park() thread. GTK needs its own process (and argv) — mixing it into
|
||||
/// `listen` would steal the main thread.
|
||||
fn open_library() -> Result<()> {
|
||||
spawn_library()
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn spawn_library() -> Result<()> {
|
||||
let exe = std::env::current_exe().context("cannot resolve breadpaper executable")?;
|
||||
Command::new(exe)
|
||||
.arg("library")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.context("failed to spawn breadpaper library")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn spawn_library() -> Result<()> {
|
||||
// cargo test's current_exe is the test harness, not breadpaper.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get() -> Result<PathBuf> {
|
||||
let home = std::env::var("HOME").context("HOME not set")?;
|
||||
let wal_file = PathBuf::from(home).join(".cache/wal/wal");
|
||||
|
|
@ -173,4 +229,13 @@ mod tests {
|
|||
handle_set(&json!({}));
|
||||
handle_set(&json!({ "path": 1 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_command_library_is_silent_without_breadd() {
|
||||
handle_command(BreadEvent {
|
||||
event: "bread.command.paper.library".into(),
|
||||
timestamp: 0,
|
||||
data: json!({}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
168
src/library.rs
Normal file
168
src/library.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::IMAGE_EXTENSIONS;
|
||||
|
||||
/// Caps how many files the picker ever lists. The library is organized in
|
||||
/// subfolders (show/series), so the walk is recursive — without a bound a
|
||||
/// huge Pictures tree would stall the window.
|
||||
pub const MAX_LIBRARY_ITEMS: usize = 200;
|
||||
pub const MAX_SCAN_DEPTH: usize = 4;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Wallpaper {
|
||||
pub path: PathBuf,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
pub fn is_wallpaper_file(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| {
|
||||
IMAGE_EXTENSIONS
|
||||
.iter()
|
||||
.any(|ext| ext.eq_ignore_ascii_case(e))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Recursively collect images under `dirs`. Missing directories are skipped.
|
||||
/// Results are sorted by filename (case-insensitive), then full path.
|
||||
pub fn scan(dirs: &[PathBuf]) -> Vec<Wallpaper> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for dir in dirs {
|
||||
if !dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
walk(dir, MAX_SCAN_DEPTH, &mut out, &mut seen);
|
||||
if out.len() >= MAX_LIBRARY_ITEMS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| {
|
||||
a.name
|
||||
.to_lowercase()
|
||||
.cmp(&b.name.to_lowercase())
|
||||
.then_with(|| a.path.cmp(&b.path))
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, depth: usize, out: &mut Vec<Wallpaper>, seen: &mut HashSet<PathBuf>) {
|
||||
if depth == 0 || out.len() >= MAX_LIBRARY_ITEMS {
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
let mut entries: Vec<_> = entries.flatten().collect();
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
for entry in entries {
|
||||
if out.len() >= MAX_LIBRARY_ITEMS {
|
||||
return;
|
||||
}
|
||||
let path = entry.path();
|
||||
let name = entry.file_name();
|
||||
if name.to_string_lossy().starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
if path.is_dir() {
|
||||
walk(&path, depth - 1, out, seen);
|
||||
continue;
|
||||
}
|
||||
if !is_wallpaper_file(&path) {
|
||||
continue;
|
||||
}
|
||||
let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
|
||||
if !seen.insert(canonical.clone()) {
|
||||
continue;
|
||||
}
|
||||
out.push(Wallpaper {
|
||||
name: path
|
||||
.file_stem()
|
||||
.or_else(|| path.file_name())
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default(),
|
||||
path: canonical,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tmp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"breadpaper-scan-{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
|
||||
}
|
||||
|
||||
fn touch(path: &Path) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
std::fs::write(path, []).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_wallpaper_file_accepts_known_extensions() {
|
||||
assert!(is_wallpaper_file(Path::new("a.PNG")));
|
||||
assert!(is_wallpaper_file(Path::new("b.jpeg")));
|
||||
assert!(is_wallpaper_file(Path::new("c.webp")));
|
||||
assert!(!is_wallpaper_file(Path::new("d.txt")));
|
||||
assert!(!is_wallpaper_file(Path::new("noext")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_skips_missing_dirs() {
|
||||
assert!(scan(&[PathBuf::from("/no/such/breadpaper-walls")]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_finds_images_and_ignores_other_files() {
|
||||
let dir = tmp_dir("find");
|
||||
touch(&dir.join("keep.png"));
|
||||
touch(&dir.join("notes.txt"));
|
||||
touch(&dir.join(".hidden.jpg"));
|
||||
touch(&dir.join("nested").join("deep.jpg"));
|
||||
let found = scan(std::slice::from_ref(&dir));
|
||||
let names: Vec<_> = found.iter().map(|w| w.name.as_str()).collect();
|
||||
assert!(names.contains(&"keep"), "{names:?}");
|
||||
assert!(names.contains(&"deep"), "{names:?}");
|
||||
assert!(
|
||||
!names
|
||||
.iter()
|
||||
.any(|n| n.contains("notes") || n.contains("hidden"))
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_dedups_the_same_file_via_two_roots() {
|
||||
let dir = tmp_dir("dedup");
|
||||
touch(&dir.join("one.png"));
|
||||
let found = scan(&[dir.clone(), dir.clone()]);
|
||||
assert_eq!(found.len(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_respects_item_cap() {
|
||||
let dir = tmp_dir("cap");
|
||||
for i in 0..(MAX_LIBRARY_ITEMS + 10) {
|
||||
touch(&dir.join(format!("{i:04}.png")));
|
||||
}
|
||||
let found = scan(std::slice::from_ref(&dir));
|
||||
assert_eq!(found.len(), MAX_LIBRARY_ITEMS);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
20
src/main.rs
20
src/main.rs
|
|
@ -4,7 +4,11 @@ use std::process;
|
|||
use clap::{Parser, Subcommand};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "breadpaper", version, about = "Wallpaper manager for the bread desktop")]
|
||||
#[command(
|
||||
name = "breadpaper",
|
||||
version,
|
||||
about = "Wallpaper manager for the bread desktop"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Image file to set as wallpaper (shorthand for `set`)
|
||||
path: Option<PathBuf>,
|
||||
|
|
@ -16,13 +20,18 @@ struct Cli {
|
|||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Set wallpaper, generate pywal palette, and reload bread themes
|
||||
Set {
|
||||
path: PathBuf,
|
||||
},
|
||||
Set { path: PathBuf },
|
||||
/// Print the current wallpaper path
|
||||
Get,
|
||||
/// Honor bread.command.paper.set until killed
|
||||
/// Honor bread.command.paper.set / .library until killed
|
||||
Listen,
|
||||
/// Open the wallpaper library (alias: browse)
|
||||
#[command(visible_alias = "browse")]
|
||||
Library {
|
||||
/// Extra directory to scan (repeatable; added to configured dirs)
|
||||
#[arg(short, long = "dir", value_name = "DIR")]
|
||||
dirs: Vec<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() {
|
||||
|
|
@ -31,6 +40,7 @@ fn main() {
|
|||
let result = match (cli.command, cli.path) {
|
||||
(Some(Command::Set { path }), _) | (None, Some(path)) => 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()))
|
||||
}
|
||||
|
|
|
|||
272
src/ui.rs
Normal file
272
src/ui.rs
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
use std::cell::RefCell;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
|
||||
use anyhow::Result;
|
||||
use gtk4::gdk_pixbuf::Pixbuf;
|
||||
use gtk4::gio::ApplicationFlags;
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{
|
||||
Align, Application, ApplicationWindow, Box as GBox, Button, ContentFit, CssProvider, FlowBox,
|
||||
FlowBoxChild, HeaderBar, Label, Orientation, Picture, PolicyType, ScrolledWindow,
|
||||
SelectionMode, Stack,
|
||||
};
|
||||
|
||||
use crate::library::{self, Wallpaper};
|
||||
|
||||
const APP_ID: &str = "com.breadway.breadpaper";
|
||||
const THUMB_W: i32 = 240;
|
||||
const THUMB_H: i32 = 135;
|
||||
|
||||
const APP_CSS: &str = "\
|
||||
headerbar {\
|
||||
background-color: @bg; color: @on-bg; box-shadow: none;\
|
||||
border-bottom: 1px solid alpha(@on-bg, 0.08);\
|
||||
}\n\
|
||||
.library-chrome { padding: 12px 16px 8px 16px; }\n\
|
||||
.library-grid { padding: 8px 12px 16px 12px; }\n\
|
||||
.library-empty { padding: 32px 24px; }\n\
|
||||
.wallpaper-tile {\
|
||||
padding: 0; background-color: @surface; color: @on-surface;\
|
||||
border-radius: 8px;\
|
||||
}\n\
|
||||
.wallpaper-tile:hover { background-color: alpha(@on-surface, 0.14); }\n\
|
||||
.wallpaper-tile.current { box-shadow: inset 0 0 0 2px @accent; }\n\
|
||||
.wallpaper-name { padding: 8px 10px; font-size: 12px; }\n\
|
||||
";
|
||||
|
||||
thread_local! {
|
||||
static APP_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
pub fn run(dirs: Vec<PathBuf>) -> Result<()> {
|
||||
let app = Application::builder()
|
||||
.application_id(APP_ID)
|
||||
.flags(ApplicationFlags::empty())
|
||||
.build();
|
||||
|
||||
app.connect_activate(move |app| present(app, dirs.clone()));
|
||||
// Clap already consumed argv; do not let GApplication re-parse `library --dir`.
|
||||
let _ = app.run_with_args(&["breadpaper"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn present(app: &Application, dirs: Vec<PathBuf>) {
|
||||
bread_theme::gtk::apply_shared();
|
||||
APP_PROVIDER.with(|cell| bread_theme::gtk::apply_css(APP_CSS, cell));
|
||||
|
||||
let window = ApplicationWindow::builder()
|
||||
.application(app)
|
||||
.title("Wallpapers")
|
||||
.default_width(960)
|
||||
.default_height(640)
|
||||
.build();
|
||||
|
||||
let header = HeaderBar::new();
|
||||
window.set_titlebar(Some(&header));
|
||||
|
||||
let refresh = Button::with_label("Refresh");
|
||||
header.pack_end(&refresh);
|
||||
|
||||
let root = GBox::new(Orientation::Vertical, 0);
|
||||
|
||||
let chrome = GBox::new(Orientation::Vertical, 6);
|
||||
chrome.add_css_class("library-chrome");
|
||||
|
||||
let summary = Label::new(None);
|
||||
summary.set_xalign(0.0);
|
||||
summary.set_wrap(true);
|
||||
summary.add_css_class("dim");
|
||||
chrome.append(&summary);
|
||||
|
||||
let status = Label::new(Some("Click a wallpaper to apply it."));
|
||||
status.set_xalign(0.0);
|
||||
status.set_wrap(true);
|
||||
chrome.append(&status);
|
||||
root.append(&chrome);
|
||||
|
||||
let flow = FlowBox::new();
|
||||
flow.set_selection_mode(SelectionMode::None);
|
||||
flow.set_homogeneous(true);
|
||||
flow.set_max_children_per_line(6);
|
||||
flow.set_min_children_per_line(2);
|
||||
flow.set_row_spacing(12);
|
||||
flow.set_column_spacing(12);
|
||||
flow.set_halign(Align::Fill);
|
||||
flow.add_css_class("library-grid");
|
||||
|
||||
let scrolled = ScrolledWindow::builder()
|
||||
.hscrollbar_policy(PolicyType::Never)
|
||||
.vscrollbar_policy(PolicyType::Automatic)
|
||||
.vexpand(true)
|
||||
.hexpand(true)
|
||||
.child(&flow)
|
||||
.build();
|
||||
|
||||
let empty = Label::new(None);
|
||||
empty.set_wrap(true);
|
||||
empty.set_justify(gtk4::Justification::Center);
|
||||
empty.add_css_class("dim");
|
||||
empty.add_css_class("library-empty");
|
||||
empty.set_hexpand(true);
|
||||
empty.set_vexpand(true);
|
||||
|
||||
let stack = Stack::new();
|
||||
stack.set_vexpand(true);
|
||||
stack.add_named(&scrolled, Some("grid"));
|
||||
stack.add_named(&empty, Some("empty"));
|
||||
root.append(&stack);
|
||||
|
||||
window.set_child(Some(&root));
|
||||
|
||||
let dirs = Rc::new(dirs);
|
||||
let reload = {
|
||||
let dirs = dirs.clone();
|
||||
let flow = flow.clone();
|
||||
let summary = summary.clone();
|
||||
let status = status.clone();
|
||||
let stack = stack.clone();
|
||||
let empty = empty.clone();
|
||||
Rc::new(move || {
|
||||
let papers = library::scan(&dirs);
|
||||
summary.set_text(&dirs_summary(&dirs, papers.len()));
|
||||
empty.set_text(&empty_message(&dirs));
|
||||
if papers.is_empty() {
|
||||
stack.set_visible_child_name("empty");
|
||||
} else {
|
||||
stack.set_visible_child_name("grid");
|
||||
}
|
||||
fill_grid(&flow, &papers, &status);
|
||||
})
|
||||
};
|
||||
|
||||
reload();
|
||||
{
|
||||
let reload = reload.clone();
|
||||
refresh.connect_clicked(move |_| reload());
|
||||
}
|
||||
|
||||
window.present();
|
||||
}
|
||||
|
||||
fn fill_grid(flow: &FlowBox, papers: &[Wallpaper], status: &Label) {
|
||||
while let Some(child) = flow.first_child() {
|
||||
flow.remove(&child);
|
||||
}
|
||||
let current = crate::get().ok();
|
||||
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 tile(paper: &Wallpaper, is_current: bool, flow: &FlowBox, status: &Label) -> Button {
|
||||
let btn = Button::new();
|
||||
btn.add_css_class("wallpaper-tile");
|
||||
btn.set_widget_name(&paper.path.to_string_lossy());
|
||||
btn.set_tooltip_text(Some(&paper.path.to_string_lossy()));
|
||||
if is_current {
|
||||
btn.add_css_class("current");
|
||||
}
|
||||
|
||||
let col = GBox::new(Orientation::Vertical, 0);
|
||||
col.append(&thumbnail(&paper.path));
|
||||
|
||||
let name = Label::new(Some(&paper.name));
|
||||
name.add_css_class("wallpaper-name");
|
||||
name.set_xalign(0.0);
|
||||
name.set_ellipsize(gtk4::pango::EllipsizeMode::End);
|
||||
name.set_max_width_chars(24);
|
||||
col.append(&name);
|
||||
btn.set_child(Some(&col));
|
||||
|
||||
let path = paper.path.clone();
|
||||
let pretty = paper.name.clone();
|
||||
let status = status.clone();
|
||||
let flow = flow.clone();
|
||||
btn.connect_clicked(move |clicked| {
|
||||
if !clicked.is_sensitive() {
|
||||
return;
|
||||
}
|
||||
clicked.set_sensitive(false);
|
||||
status.set_text(&format!("Applying {pretty}…"));
|
||||
let path = path.clone();
|
||||
let pretty = pretty.clone();
|
||||
let status = status.clone();
|
||||
let flow = flow.clone();
|
||||
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;
|
||||
clicked.set_sensitive(true);
|
||||
match result {
|
||||
Ok(Ok(())) => {
|
||||
status.set_text(&format!("Applied {pretty}"));
|
||||
mark_current(&flow, &path);
|
||||
}
|
||||
Ok(Err(e)) => status.set_text(&format!("{e:#}")),
|
||||
Err(_) => status.set_text("Failed to apply wallpaper"),
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
btn
|
||||
}
|
||||
|
||||
fn thumbnail(path: &Path) -> Picture {
|
||||
let picture = match Pixbuf::from_file_at_scale(path, THUMB_W, THUMB_H, true) {
|
||||
Ok(pb) => Picture::for_paintable(>k4::gdk::Texture::for_pixbuf(&pb)),
|
||||
Err(_) => Picture::for_filename(path),
|
||||
};
|
||||
picture.set_content_fit(ContentFit::Cover);
|
||||
picture.set_size_request(THUMB_W, THUMB_H);
|
||||
picture.set_can_shrink(true);
|
||||
picture.set_hexpand(true);
|
||||
picture
|
||||
}
|
||||
|
||||
fn mark_current(flow: &FlowBox, current: &Path) {
|
||||
let current = current.to_string_lossy();
|
||||
let mut i = 0;
|
||||
while let Some(wrapper) = flow.child_at_index(i) {
|
||||
if let Some(btn) = wrapper
|
||||
.downcast_ref::<FlowBoxChild>()
|
||||
.and_then(|c| c.child())
|
||||
.and_then(|w| w.downcast::<Button>().ok())
|
||||
{
|
||||
if btn.widget_name() == current.as_ref() {
|
||||
btn.add_css_class("current");
|
||||
} else {
|
||||
btn.remove_css_class("current");
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn dirs_summary(dirs: &[PathBuf], count: usize) -> String {
|
||||
let listed = dirs
|
||||
.iter()
|
||||
.map(|d| {
|
||||
if d.is_dir() {
|
||||
d.display().to_string()
|
||||
} else {
|
||||
format!("{} (missing)", d.display())
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ");
|
||||
format!("{count} wallpaper(s) · {listed}")
|
||||
}
|
||||
|
||||
fn empty_message(dirs: &[PathBuf]) -> String {
|
||||
let listed = dirs
|
||||
.iter()
|
||||
.map(|d| d.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!(
|
||||
"No wallpapers found.\nAdd png/jpg/webp/gif/bmp files under:\n{listed}\n\nOr set library_dirs in {}",
|
||||
crate::config::Config::path().display()
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue