1use clap::Parser;
2use eframe::egui;
3use image::imageops::FilterType;
4use image::ImageEncoder;
5use rayon::prelude::*;
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9use std::sync::{Arc, RwLock};
10use std::fs;
11use std::io::{self, Write};
12use std::time::SystemTime;
13use walkdir::WalkDir;
14
15#[derive(Debug, thiserror::Error)]
16pub enum BackgroundPickerError {
17 #[error("Failed to create thread pool: {0}")]
18 ThreadPoolCreation(#[from] rayon::ThreadPoolBuildError),
19
20 #[error("Failed to create thumbnail cache directory: {0}")]
21 CacheDirectoryCreation(#[from] std::io::Error),
22
23 #[error("Failed to generate thumbnail for {path}: {source}")]
24 ThumbnailGeneration {
25 path: PathBuf,
26 source: Box<dyn std::error::Error + Send + Sync>,
27 },
28
29 #[error("Failed to save selected image path: {0}")]
30 SaveSelectedImage(std::io::Error),
31
32 #[error("Command execution failed: {0}")]
33 CommandExecution(String),
34
35 #[error("Invalid image file: {0}")]
36 InvalidImageFile(PathBuf),
37
38 #[error("Lock acquisition failed")]
39 LockAcquisition,
40}
41
42pub type Result<T> = std::result::Result<T, BackgroundPickerError>;
43
44const IMAGE_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "gif", "bmp", "webp"];
45const DEFAULT_PRELOAD_COUNT: usize = 8;
46const CHUNK_SIZE: usize = 100;
47const MIN_THREAD_COUNT: usize = 4;
48const PROGRESS_THRESHOLD: usize = 50;
49
50#[derive(Parser, Clone)]
51#[command(name = "background-picker")]
52#[command(about = "A GUI tool for selecting desktop backgrounds")]
53pub struct Args {
54 #[arg(short, long, default_value = ".")]
55 pub directory: PathBuf,
56
57 #[arg(short, long, default_value = "150")]
58 pub thumbnail_size: u32,
59
60 #[arg(short, long, default_value = "feh --bg-max")]
61 pub command: String,
62
63 #[arg(short, long, default_value = "selected-background.txt")]
64 pub selected_image_file: PathBuf,
65
66 #[arg(long, help = "Enable debug output")]
67 pub debug: bool,
68
69 #[arg(long, help = "Pre-generate all thumbnails and exit (don't show GUI)")]
70 pub pregenerate: bool,
71}
72
73
74#[derive(Clone)]
75pub struct ImageInfo {
76 pub path: PathBuf,
77 pub thumbnail: Option<egui::TextureHandle>,
78 pub relative_path: String,
79 pub loading: bool,
80}
81
82pub struct BackgroundPickerApp {
83 pub args: Args,
84 pub images: Arc<RwLock<Vec<ImageInfo>>>,
85 pub folder_tree: HashMap<String, Vec<usize>>,
86 pub loading: bool,
87 pub thumbnail_sender: std::sync::mpsc::Sender<(usize, egui::ColorImage)>,
88 pub thumbnail_receiver: std::sync::mpsc::Receiver<(usize, egui::ColorImage)>,
89 pub thread_pool: rayon::ThreadPool,
90 pub cache_dir: PathBuf,
91}
92
93impl BackgroundPickerApp {
94 pub fn new(_cc: &eframe::CreationContext<'_>, args: Args) -> Result<Self> {
95 let (thumbnail_sender, thumbnail_receiver) = std::sync::mpsc::channel();
96
97 let thread_pool = rayon::ThreadPoolBuilder::new()
99 .num_threads(num_cpus::get().max(MIN_THREAD_COUNT))
100 .build()?;
101
102 let cache_dir = Self::get_thumbnail_cache_dir()?;
104 if args.debug {
105 println!("Using thumbnail cache directory: {:?}", cache_dir);
106 }
107
108 let mut app = Self {
109 args,
110 images: Arc::new(RwLock::new(Vec::new())),
111 folder_tree: HashMap::new(),
112 loading: true,
113 thumbnail_sender,
114 thumbnail_receiver,
115 thread_pool,
116 cache_dir,
117 };
118
119 app.scan_images()?;
120
121 if app.args.pregenerate {
122 app.pregenerate_all_thumbnails()?;
123 std::process::exit(0);
125 }
126
127 Ok(app)
128 }
129
130 pub fn get_thumbnail_cache_dir() -> Result<PathBuf> {
131 let cache_home = dirs::cache_dir()
133 .or_else(|| dirs::home_dir().map(|h| h.join(".cache")))
134 .unwrap_or_else(|| PathBuf::from(".cache"));
135
136 let normal_dir = cache_home.join("thumbnails").join("normal");
137
138 fs::create_dir_all(&normal_dir)
140 .map_err(BackgroundPickerError::CacheDirectoryCreation)?;
141
142 Ok(normal_dir)
143 }
144
145 pub fn find_existing_thumbnail(file_path: &Path) -> Option<PathBuf> {
146 let cache_home = dirs::cache_dir()?;
148 let thumbnails_dir = cache_home.join("thumbnails");
149
150 let hash = Self::get_thumbnail_hash(file_path)?;
151 let thumbnail_name = format!("{}.png", hash);
152
153 for size_dir in &["normal", "large"] {
155 let thumbnail_path = thumbnails_dir.join(size_dir).join(&thumbnail_name);
156 if thumbnail_path.exists() && Self::is_thumbnail_cache_valid_static(file_path, &thumbnail_path) {
157 return Some(thumbnail_path);
158 }
159 }
160
161 None
162 }
163
164 pub fn get_thumbnail_hash(file_path: &Path) -> Option<String> {
165 let canonicalized = fs::canonicalize(file_path).unwrap_or_else(|_| file_path.to_path_buf());
168 let file_uri = format!("file://{}", canonicalized.to_string_lossy());
169
170 use sha1::{Digest, Sha1};
171 let mut hasher = Sha1::new();
172 hasher.update(file_uri.as_bytes());
173 let result = hasher.finalize();
174 Some(format!("{:x}", result))
175 }
176
177
178
179 pub fn scan_images(&mut self) -> Result<()> {
180 let base_path = &self.args.directory;
181
182 {
184 let mut images = self.images.write()
185 .map_err(|_| BackgroundPickerError::LockAcquisition)?;
186 images.clear();
187 }
188 self.folder_tree.clear();
189
190 if self.args.debug {
191 println!("Scanning directory: {:?}", base_path);
192 }
193
194 let mut temp_images = Vec::new();
196 let mut temp_folders: HashMap<String, Vec<usize>> = HashMap::new();
197
198 for entry in WalkDir::new(&self.args.directory)
200 .into_iter()
201 .filter_map(|e| e.ok())
202 .filter(|e| e.file_type().is_file())
203 {
204 if let Some(ext) = entry.path().extension() {
205 let ext_str = ext.to_string_lossy();
206 if IMAGE_EXTENSIONS.iter().any(|&valid_ext| valid_ext.eq_ignore_ascii_case(&ext_str)) {
207 let relative_path = entry.path()
208 .strip_prefix(base_path)
209 .map(|p| p.to_string_lossy().into_owned())
210 .unwrap_or_else(|_| entry.path().to_string_lossy().into_owned());
211
212 let folder = entry.path()
213 .parent()
214 .and_then(|p| p.strip_prefix(base_path).ok())
215 .map(|p| p.to_string_lossy().into_owned())
216 .unwrap_or_else(|| ".".to_owned());
217
218 let image_index = temp_images.len();
219 temp_images.push(ImageInfo {
220 path: entry.path().to_path_buf(),
221 thumbnail: None,
222 relative_path,
223 loading: false,
224 });
225
226 temp_folders
227 .entry(folder)
228 .or_default()
229 .push(image_index);
230 }
231 }
232 }
233
234 {
236 let mut images = self.images.write()
237 .map_err(|_| BackgroundPickerError::LockAcquisition)?;
238 *images = temp_images;
239 }
240 self.folder_tree = temp_folders;
241
242 if self.args.debug {
243 println!("Found {} images in {} folders",
244 self.images.read().map(|i| i.len()).unwrap_or(0),
245 self.folder_tree.len());
246 }
247
248 self.loading = false;
249 Ok(())
250 }
251
252 pub fn pregenerate_all_thumbnails(&mut self) -> Result<()> {
253 let total_images = self.images.read()
254 .map_err(|_| BackgroundPickerError::LockAcquisition)?
255 .len();
256
257 if total_images == 0 {
258 if self.args.debug {
259 println!("No images found to pregenerate thumbnails for");
260 }
261 return Ok(());
262 }
263
264 if self.args.debug {
265 println!("Pre-generating thumbnails for {} images...", total_images);
266 } else {
267 println!("Generating thumbnails for {} images...", total_images);
268 }
269
270 let start_time = std::time::Instant::now();
271 let mut generated_count = 0;
272 let mut cached_count = 0;
273
274 let cache_dir = &self.cache_dir;
276 let size = self.args.thumbnail_size;
277 let debug = self.args.debug;
278 let images = Arc::clone(&self.images);
279
280 let results: Vec<(bool, bool)> = (0..total_images)
281 .collect::<Vec<_>>()
282 .par_chunks(CHUNK_SIZE) .enumerate()
284 .flat_map(|(chunk_idx, chunk)| {
285 let chunk_results: Vec<(bool, bool)> = chunk.par_iter().map(|&index| {
286 let path = {
287 match images.read() {
288 Ok(images_guard) => {
289 if index >= images_guard.len() {
290 return (false, false); }
292 images_guard[index].path.clone()
293 }
294 Err(_) => return (false, false),
295 }
296 };
297
298 let abs_path = std::fs::canonicalize(&path).unwrap_or_else(|_| path.to_path_buf());
299
300 if let Some(existing_thumbnail) = Self::find_existing_thumbnail(&abs_path) {
302 if Self::load_cached_thumbnail(&existing_thumbnail, size).is_some() {
303 if debug {
304 println!(" [{}] Found existing thumbnail: {:?}",
305 index + 1, path.file_name().unwrap_or_default());
306 }
307 return (true, false); }
309 }
310
311 if let Some(cache_path) = Self::get_cached_thumbnail_path_static(&abs_path, cache_dir) {
312 if Self::is_thumbnail_cache_valid_static(&abs_path, &cache_path) && Self::load_cached_thumbnail(&cache_path, size).is_some() {
313 if debug {
314 println!(" [{}] Found cached thumbnail: {:?}",
315 index + 1, path.file_name().unwrap_or_default());
316 }
317 return (true, false); }
319 }
320
321 if let Some(color_image) = Self::fast_thumbnail_generation(&abs_path, size) {
323 if let Some(cache_path) = Self::get_cached_thumbnail_path_static(&abs_path, cache_dir) {
325 Self::save_thumbnail_to_cache(&color_image, &cache_path, &abs_path);
326 }
327
328 if debug {
329 println!(" [{}] Generated thumbnail: {:?}",
330 index + 1, path.file_name().unwrap_or_default());
331 }
332 (false, true) } else {
334 if debug {
335 println!(" [{}] Failed to generate thumbnail: {:?}",
336 index + 1, path.file_name().unwrap_or_default());
337 }
338 (false, false)
339 }
340 }).collect();
341
342 if !debug && total_images > PROGRESS_THRESHOLD {
344 let completed = (chunk_idx + 1) * CHUNK_SIZE.min(total_images);
345 print!("\rProgress: {}/{} images processed", completed, total_images);
346 io::stdout().flush().ok();
347 }
348
349 chunk_results
350 }).collect();
351
352 for (was_cached, was_generated) in results {
354 if was_cached {
355 cached_count += 1;
356 } else if was_generated {
357 generated_count += 1;
358 }
359 }
360
361 let elapsed = start_time.elapsed();
362
363 if !self.args.debug && total_images > PROGRESS_THRESHOLD {
364 println!(); }
366
367 if self.args.debug {
368 println!("Thumbnail pregeneration complete:");
369 println!(" - {} thumbnails found in cache", cached_count);
370 println!(" - {} thumbnails generated", generated_count);
371 println!(" - {} thumbnails failed", total_images - cached_count - generated_count);
372 println!(" - Time elapsed: {:.2}s", elapsed.as_secs_f64());
373 } else {
374 println!("Thumbnail generation complete: {} cached, {} generated ({:.1}s)",
375 cached_count, generated_count, elapsed.as_secs_f64());
376 }
377
378 Ok(())
379 }
380
381 pub fn load_thumbnail(&mut self, _ctx: &egui::Context, index: usize) {
382 let images_len = self.images.read().map(|images| images.len()).unwrap_or(0);
383 if index >= images_len {
384 return;
385 }
386
387 let (should_load, path) = {
388 if let Ok(mut images) = self.images.write() {
389 if images[index].thumbnail.is_some() || images[index].loading {
390 return;
391 }
392 images[index].loading = true;
393 (true, images[index].path.clone())
394 } else {
395 return;
396 }
397 };
398
399 if should_load {
400 let sender = self.thumbnail_sender.clone();
401 let size = self.args.thumbnail_size;
402 let cache_dir = self.cache_dir.clone();
403 let debug = self.args.debug;
404
405 self.thread_pool.spawn(move || {
406 if let Some(color_image) = Self::load_or_generate_thumbnail(&path, size, &cache_dir, debug) {
407 let _ = sender.send((index, color_image));
408 }
409 });
410 }
411 }
412
413 pub fn load_or_generate_thumbnail(path: &Path, size: u32, cache_dir: &Path, debug: bool) -> Option<egui::ColorImage> {
414 let abs_path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
416
417 if let Some(existing_thumbnail) = Self::find_existing_thumbnail(&abs_path) {
419 if let Some(cached_image) = Self::load_cached_thumbnail(&existing_thumbnail, size) {
420 if debug {
421 println!("Loaded existing system thumbnail for {:?}", path.file_name().unwrap_or_default());
422 }
423 return Some(cached_image);
424 }
425 }
426
427 if let Some(cache_path) = Self::get_cached_thumbnail_path_static(&abs_path, cache_dir) {
429 if Self::is_thumbnail_cache_valid_static(&abs_path, &cache_path) {
430 if let Some(cached_image) = Self::load_cached_thumbnail(&cache_path, size) {
431 if debug {
432 println!("Loaded our cached thumbnail for {:?}", path.file_name().unwrap_or_default());
433 }
434 return Some(cached_image);
435 }
436 }
437 }
438
439 if debug {
441 println!("Generating new thumbnail for {:?}", path.file_name().unwrap_or_default());
442 }
443 let color_image = Self::fast_thumbnail_generation(&abs_path, size)?;
444
445 if let Some(cache_path) = Self::get_cached_thumbnail_path_static(&abs_path, cache_dir) {
447 Self::save_thumbnail_to_cache(&color_image, &cache_path, &abs_path);
448 }
449
450 Some(color_image)
451 }
452
453 pub fn get_cached_thumbnail_path_static(file_path: &Path, cache_dir: &Path) -> Option<PathBuf> {
454 let hash = Self::get_thumbnail_hash(file_path)?;
455 Some(cache_dir.join(format!("{}.png", hash)))
456 }
457
458 pub fn is_thumbnail_cache_valid_static(original_path: &Path, cache_path: &Path) -> bool {
459 if !cache_path.exists() {
460 return false;
461 }
462
463 let original_modified = fs::metadata(original_path)
464 .and_then(|m| m.modified())
465 .unwrap_or(SystemTime::UNIX_EPOCH);
466
467 let cache_modified = fs::metadata(cache_path)
468 .and_then(|m| m.modified())
469 .unwrap_or(SystemTime::UNIX_EPOCH);
470
471 cache_modified >= original_modified
472 }
473
474 pub fn load_cached_thumbnail(cache_path: &Path, target_size: u32) -> Option<egui::ColorImage> {
475 match image::io::Reader::open(cache_path) {
476 Ok(reader) => {
477 if let Ok(img) = reader.with_guessed_format().ok()?.decode() {
478 let resized = if img.width() != target_size || img.height() != target_size {
480 img.resize(target_size, target_size, FilterType::Nearest)
481 } else {
482 img
483 };
484 Self::create_thumbnail_fast(resized, target_size)
485 } else {
486 None
487 }
488 }
489 Err(_) => None,
490 }
491 }
492
493 pub fn save_thumbnail_to_cache(color_image: &egui::ColorImage, cache_path: &Path, original_path: &Path) {
494 let [width, height] = color_image.size;
496
497 let mut pixels = Vec::with_capacity(color_image.pixels.len() * 4);
499 for pixel in &color_image.pixels {
500 pixels.extend_from_slice(&[pixel.r(), pixel.g(), pixel.b(), pixel.a()]);
501 }
502
503 if let Some(img_buffer) = image::RgbaImage::from_raw(
504 width as u32,
505 height as u32,
506 pixels
507 ) {
508 let dynamic_img = image::DynamicImage::ImageRgba8(img_buffer);
509
510 if let Some(parent) = cache_path.parent() {
512 let _ = fs::create_dir_all(parent);
513 }
514
515 Self::save_thumbnail_with_metadata(&dynamic_img, cache_path, original_path);
517 }
518 }
519
520 pub fn save_thumbnail_with_metadata(img: &image::DynamicImage, cache_path: &Path, original_path: &Path) {
521 let _file_uri = format!("file://{}",
523 fs::canonicalize(original_path)
524 .unwrap_or_else(|_| original_path.to_path_buf())
525 .to_string_lossy()
526 );
527
528 let _file_size = fs::metadata(original_path)
529 .map(|m| m.len())
530 .unwrap_or(0);
531
532 let _mtime = fs::metadata(original_path)
533 .and_then(|m| m.modified())
534 .map(|t| t.duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_secs())
535 .unwrap_or(0);
536
537 use std::io::BufWriter;
539 use std::fs::File;
540
541 if let Ok(file) = File::create(cache_path) {
542 let writer = BufWriter::new(file);
543 let encoder = image::codecs::png::PngEncoder::new(writer);
544
545 let rgb_img = img.to_rgb8();
547
548 if let Err(e) = encoder.write_image(
549 rgb_img.as_raw(),
550 img.width(),
551 img.height(),
552 image::ColorType::Rgb8,
553 ) {
554 eprintln!("Failed to save thumbnail for {:?}: {}", original_path, e);
555 }
556 }
557
558 }
561
562 pub fn fast_thumbnail_generation(path: &Path, size: u32) -> Option<egui::ColorImage> {
563 let reader = image::io::Reader::open(path).ok()?
565 .with_guessed_format().ok()?;
566
567 let img = reader.decode().ok()?;
569 let (width, height) = (img.width(), img.height());
570
571 if width <= size && height <= size {
573 return Self::create_thumbnail_fast(img, size);
574 }
575
576 let scale_factor = (width.max(height) as f32 / size as f32).max(1.0);
578
579 if scale_factor > 8.0 {
580 let first_step = (size as f32 * 4.0) as u32;
582 let second_step = (size as f32 * 2.0) as u32;
583
584 let step1 = img.resize(first_step, first_step, FilterType::Nearest);
585 let step2 = step1.resize(second_step, second_step, FilterType::Triangle);
586 Self::create_thumbnail_fast(step2, size)
587 } else if scale_factor > 4.0 {
588 let intermediate_size = size * 2;
590 let intermediate = img.resize(intermediate_size, intermediate_size, FilterType::Nearest);
591 Self::create_thumbnail_fast(intermediate, size)
592 } else {
593 Self::create_thumbnail_fast(img, size)
595 }
596 }
597
598 pub fn create_thumbnail_fast(img: image::DynamicImage, size: u32) -> Option<egui::ColorImage> {
599 let thumbnail = img.resize(size, size, FilterType::Nearest);
601 let rgba = thumbnail.to_rgba8();
602 let (width, height) = (thumbnail.width() as usize, thumbnail.height() as usize);
603
604 let pixel_count = width * height;
606 let raw_pixels = rgba.as_raw();
607
608 if raw_pixels.len() != pixel_count * 4 {
609 return None; }
611
612 Some(egui::ColorImage::from_rgba_unmultiplied(
613 [width, height],
614 raw_pixels,
615 ))
616 }
617
618 pub fn process_thumbnail_results(&mut self, ctx: &egui::Context) {
619 while let Ok((index, color_image)) = self.thumbnail_receiver.try_recv() {
620 let texture = ctx.load_texture(
621 format!("thumbnail_{}", index),
622 color_image,
623 egui::TextureOptions::default(),
624 );
625
626 if let Ok(mut images) = self.images.write() {
627 if index < images.len() {
628 images[index].thumbnail = Some(texture);
629 images[index].loading = false;
630 }
631 }
632 }
633 }
634
635 pub fn preload_batch(&mut self, indices: &[usize]) {
636 let images_len = match self.images.read() {
638 Ok(images) => images.len(),
639 Err(_) => return,
640 };
641
642 let mut paths_to_load = Vec::new();
644
645 {
646 let mut images = match self.images.write() {
647 Ok(images) => images,
648 Err(_) => return,
649 };
650
651 for &index in indices.iter().take(DEFAULT_PRELOAD_COUNT) {
652 if index >= images_len {
653 continue;
654 }
655
656 if images[index].thumbnail.is_none() && !images[index].loading {
657 images[index].loading = true;
658 paths_to_load.push((index, images[index].path.clone()));
659 }
660 }
661 }
662
663 let sender = self.thumbnail_sender.clone();
665 let size = self.args.thumbnail_size;
666 let cache_dir = self.cache_dir.clone();
667 let debug = self.args.debug;
668
669 for (index, path) in paths_to_load {
670 let sender = sender.clone();
671 let cache_dir = cache_dir.clone();
672
673 self.thread_pool.spawn(move || {
674 if let Some(color_image) = Self::load_or_generate_thumbnail(&path, size, &cache_dir, debug) {
675 let _ = sender.send((index, color_image));
676 }
677 });
678 }
679 }
680
681 pub fn set_background(&self, path: &Path) -> Result<()> {
682 let command_parts: Vec<&str> = self.args.command.split_whitespace().collect();
683 if command_parts.is_empty() {
684 return Err(BackgroundPickerError::CommandExecution("Empty command".to_owned()));
685 }
686
687 let mut cmd = Command::new(command_parts[0]);
688 cmd.args(&command_parts[1..]);
689 cmd.arg(path);
690
691 let output = cmd.output()
692 .map_err(|e| BackgroundPickerError::CommandExecution(e.to_string()))?;
693
694 if !output.status.success() {
695 let error_msg = String::from_utf8_lossy(&output.stderr);
696 return Err(BackgroundPickerError::CommandExecution(error_msg.into_owned()));
697 }
698
699 Ok(())
700 }
701
702 pub fn save_selected_image(&self, path: &Path) -> Result<()> {
703 if let Some(parent) = self.args.selected_image_file.parent() {
704 fs::create_dir_all(parent)
705 .map_err(BackgroundPickerError::SaveSelectedImage)?;
706 }
707
708 let path_str = path.to_string_lossy();
709 fs::write(&self.args.selected_image_file, path_str.as_bytes())
710 .map_err(BackgroundPickerError::SaveSelectedImage)?;
711
712 Ok(())
713 }
714}
715
716impl eframe::App for BackgroundPickerApp {
717 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
718 self.process_thumbnail_results(ctx);
719
720 egui::CentralPanel::default().show(ctx, |ui| {
721 if self.loading {
722 ui.centered_and_justified(|ui| {
723 ui.label("Scanning for images...");
724 });
725 return;
726 }
727
728 ui.heading("Background Picker");
729 ui.separator();
730
731 egui::ScrollArea::vertical().show(ui, |ui| {
732 let folders: Vec<(String, Vec<usize>)> = self.folder_tree.iter()
734 .map(|(k, v)| (k.clone(), v.clone()))
735 .collect();
736
737 for (folder, image_indices) in folders {
738 let folder_label = if folder == "." {
739 format!("Root ({} images)", image_indices.len())
740 } else {
741 format!("{} ({} images)", folder, image_indices.len())
742 };
743
744 let header_response = egui::CollapsingHeader::new(folder_label)
745 .default_open(false)
746 .show(ui, |ui| {
747 ui.horizontal_wrapped(|ui| {
748 for index in &image_indices {
749 self.load_thumbnail(ctx, *index);
750
751 let image_info = {
752 match self.images.read() {
753 Ok(images) => {
754 if *index >= images.len() {
755 continue;
756 }
757 (
759 images[*index].loading,
760 images[*index].path.clone(),
761 images[*index].relative_path.clone(),
762 images[*index].thumbnail.clone()
763 )
764 }
765 Err(_) => continue,
766 }
767 };
768
769 let (is_loading, path, relative_path, texture_ref) = image_info;
770
771 if let Some(texture) = texture_ref {
772 let image_button = egui::ImageButton::new(&texture)
773 .frame(true);
774
775 let button_response = ui.add(image_button);
776 if button_response.clicked() {
777 if let Err(e) = self.set_background(&path) {
778 eprintln!("Failed to set background: {}", e);
779 } else {
780 let _ = self.save_selected_image(&path);
781 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
782 }
783 }
784
785 button_response.on_hover_text(&relative_path);
786 } else {
787 let size = self.args.thumbnail_size as f32;
789 let (rect, response) = ui.allocate_exact_size(
790 egui::Vec2::splat(size),
791 egui::Sense::hover()
792 );
793 ui.painter().rect_filled(
794 rect,
795 egui::Rounding::same(5.0),
796 egui::Color32::LIGHT_GRAY
797 );
798
799 let loading_text = if is_loading { "Loading..." } else { "Click to load" };
800 ui.painter().text(
801 rect.center(),
802 egui::Align2::CENTER_CENTER,
803 loading_text,
804 egui::FontId::default(),
805 egui::Color32::DARK_GRAY
806 );
807 response.on_hover_text(&relative_path);
808 }
809 }
810 });
811 });
812
813 if let Some(body_response) = header_response.body_response {
815 if body_response.rect.height() > 0.0 {
816 self.preload_batch(&image_indices);
817 }
818 }
819 }
820 });
821 });
822
823 ctx.request_repaint(); }
825
826}
827
828pub fn is_image_file(path: &Path) -> bool {
829 path.extension()
830 .and_then(|ext| ext.to_str())
831 .map(|ext_str| IMAGE_EXTENSIONS.iter().any(|&valid_ext| valid_ext.eq_ignore_ascii_case(ext_str)))
832 .unwrap_or(false)
833}
834
835pub fn validate_command(command: &str) -> Result<()> {
836 if command.trim().is_empty() {
838 return Err(BackgroundPickerError::CommandExecution("Empty command".to_owned()));
839 }
840 Ok(())
841}