Add GTK wallpaper library
All checks were successful
check / check (push) Successful in 2m28s
dev release / build (push) Successful in 49s

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:
Breadway 2026-08-16 00:26:50 +08:00
parent 01c7e5b73f
commit 1efc721990
15 changed files with 1398 additions and 31 deletions

252
src/config.rs Normal file
View 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")]
);
}
}

View file

@ -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
View 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);
}
}

View file

@ -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
View 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(&gtk4::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()
)
}