background_picker/
lib.rs

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        // Create thread pool with optimal number of threads
98        let thread_pool = rayon::ThreadPoolBuilder::new()
99            .num_threads(num_cpus::get().max(MIN_THREAD_COUNT))
100            .build()?;
101        
102        // Set up thumbnail cache directory (freedesktop.org spec)
103        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            // Exit after pregeneration, don't show GUI
124            std::process::exit(0);
125        }
126        
127        Ok(app)
128    }
129    
130    pub fn get_thumbnail_cache_dir() -> Result<PathBuf> {
131        // Use freedesktop.org thumbnail specification
132        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        // Create the directory structure if it doesn't exist
139        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        // Look for existing thumbnails in multiple sizes
147        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        // Check in order of preference: normal (128x128), large (256x256), then fail
154        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        // Generate SHA1 hash of file URI as per freedesktop.org thumbnail spec
166        // This matches exactly what pcmanfm and other file managers use
167        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        // Clear existing data
183        {
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        // Pre-allocate collections to avoid repeated reallocations
195        let mut temp_images = Vec::new();
196        let mut temp_folders: HashMap<String, Vec<usize>> = HashMap::new();
197        
198        // Collect all image files first
199        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        // Update the main data structures
235        {
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        // Use rayon to process all images in parallel
275        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) // Process in chunks for progress reporting
283            .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); // (was_cached, was_generated)
291                                }
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                    // Check if thumbnail already exists
301                    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); // was cached
308                        }
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); // was cached
318                        }
319                    }
320                    
321                    // Generate new thumbnail
322                    if let Some(color_image) = Self::fast_thumbnail_generation(&abs_path, size) {
323                        // Save to cache
324                        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) // was generated
333                    } 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                // Show progress for large collections
343                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        // Count results
353        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!(); // New line after progress indicator
365        }
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        // Get absolute path for cache key generation
415        let abs_path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
416        
417        // First, look for existing thumbnails created by other applications (pcmanfm, etc.)
418        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        // Try to load from our own cache
428        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        // Generate new thumbnail and cache it
440        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        // Save to cache for future use
446        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                    // Resize cached thumbnail to target size if needed
479                    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        // Convert egui::ColorImage back to image format for caching
495        let [width, height] = color_image.size;
496        
497        // Pre-allocate vector with exact capacity for better performance
498        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            // Create parent directory if it doesn't exist
511            if let Some(parent) = cache_path.parent() {
512                let _ = fs::create_dir_all(parent);
513            }
514            
515            // Save with freedesktop.org thumbnail metadata
516            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        // Get file metadata for thumbnail spec compliance (currently unused but could be added later)
522        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        // Create PNG encoder with metadata
538        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            // Convert to RGB for PNG encoding
546            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        // Add metadata using external PNG tools would be ideal, but for now this basic save works
559        // The important part is using the correct hash and cache location
560    }
561    
562    pub fn fast_thumbnail_generation(path: &Path, size: u32) -> Option<egui::ColorImage> {
563        // Use image reader with auto format detection
564        let reader = image::io::Reader::open(path).ok()?
565            .with_guessed_format().ok()?;
566        
567        // Try to get dimensions first to avoid full decode if possible
568        let img = reader.decode().ok()?;
569        let (width, height) = (img.width(), img.height());
570        
571        // Early return for already small images
572        if width <= size && height <= size {
573            return Self::create_thumbnail_fast(img, size);
574        }
575        
576        // Calculate optimal resize strategy based on image size
577        let scale_factor = (width.max(height) as f32 / size as f32).max(1.0);
578        
579        if scale_factor > 8.0 {
580            // For very large images, use three-step resize for better quality/performance balance
581            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            // For large images, use two-step resize
589            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            // For moderately sized images, direct resize with higher quality filter
594            Self::create_thumbnail_fast(img, size)
595        }
596    }
597    
598    pub fn create_thumbnail_fast(img: image::DynamicImage, size: u32) -> Option<egui::ColorImage> {
599        // Use fastest resize algorithm for thumbnails
600        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        // Pre-allocate the pixel buffer for better performance
605        let pixel_count = width * height;
606        let raw_pixels = rgba.as_raw();
607        
608        if raw_pixels.len() != pixel_count * 4 {
609            return None; // Safety check
610        }
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        // Preload first few thumbnails when folder opens
637        let images_len = match self.images.read() {
638            Ok(images) => images.len(),
639            Err(_) => return,
640        };
641        
642        // Collect paths that need loading to minimize lock time
643        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        // Spawn loading tasks
664        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                // Clone folder data to avoid borrowing issues
733                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                                                // Clone the data we need
758                                                (
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                                        // Show placeholder for loading images
788                                        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 folder was just opened, preload some thumbnails
814                    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(); // Keep updating to process thumbnail results
824    }
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    // Check if command has any non-whitespace characters without allocating
837    if command.trim().is_empty() {
838        return Err(BackgroundPickerError::CommandExecution("Empty command".to_owned()));
839    }
840    Ok(())
841}