diff --git a/src/large_image.rs b/src/large_image.rs index 1b7fbf6..6a2cf6b 100644 --- a/src/large_image.rs +++ b/src/large_image.rs @@ -30,6 +30,59 @@ pub const MB_TO_BYTES: u64 = 1024 * 1024; /// The image crate's memory limits use decimal MB, not binary MB. pub const DECIMAL_MB_TO_BYTES: u64 = 1000 * 1000; +/// Scale factor for HiDPI displays - decode at higher resolution than display size +/// for better quality on high-DPI screens. 1.5x provides good balance between +/// quality and memory usage and also prevets re-decoding on small windows size adjustments. +const DISPLAY_SCALE_FACTOR: f32 = 1.5; + +/// Calculate optimal target dimensions for decoding based on display size. +/// Returns None if no resizing is needed (image is smaller than display). +/// +/// This helps reduce memory usage by decoding large images at a resolution +/// appropriate for the display, rather than always using full resolution. +pub fn calculate_target_dimensions( + image_width: u32, + image_height: u32, + display_width: u32, + display_height: u32, +) -> Option<(u32, u32)> { + let target_width = (display_width as f32 * DISPLAY_SCALE_FACTOR) as u32; + let target_height = (display_height as f32 * DISPLAY_SCALE_FACTOR) as u32; + + if image_width <= target_width && image_height <= target_height { + return None; + } + + let image_aspect = image_width as f32 / image_height as f32; + let target_aspect = target_width as f32 / target_height as f32; + + let (new_width, new_height) = if image_aspect > target_aspect { + let w = target_width; + let h = (target_width as f32 / image_aspect) as u32; + (w, h) + } else { + let h = target_height; + let w = (target_height as f32 * image_aspect) as u32; + (w, h) + }; + + let new_width = new_width.max(1); + let new_height = new_height.max(1); + + log::info!( + "Calculated target dimensions: {}x{} -> {}x{} (display: {}x{}, scale: {}x)", + image_width, + image_height, + new_width, + new_height, + display_width, + display_height, + DISPLAY_SCALE_FACTOR + ); + + Some((new_width, new_height)) +} + /// Check if an image's dimensions would exceed the available memory budget. /// Returns true if the image is too large to decode. pub fn exceeds_memory_limit(width: u32, height: u32, memory_limit_mb: u64) -> bool { @@ -208,7 +261,10 @@ pub fn check_memory_available(width: u32, height: u32) -> (bool, Option) /// This function is used for gallery mode where full-resolution images need to be loaded. /// It uses the full memory budget (GALLERY_MEMORY_LIMIT_MB) since only one image /// decodes at a time in gallery mode. -pub async fn decode_large_image(path: PathBuf) -> Option<(PathBuf, u32, u32, Vec)> { +pub async fn decode_large_image( + path: PathBuf, + target_dimensions: Option<(u32, u32)>, +) -> Option<(PathBuf, u32, u32, Vec)> { // Decode image in blocking thread pool (CPU-intensive work should not block) tokio::task::spawn_blocking(move || { log::info!("Starting async decode of {}", path.display()); @@ -227,16 +283,46 @@ pub async fn decode_large_image(path: PathBuf) -> Option<(PathBuf, u32, u32, Vec match reader.decode() { Ok(img) => { let rgba = img.into_rgba8(); - let width = rgba.width(); - let height = rgba.height(); - let pixels = rgba.into_raw(); + let orig_width = rgba.width(); + let orig_height = rgba.height(); - log::info!( - "Decoded {}x{} image: {}", - width, - height, - path.display() - ); + // Resize if target dimensions provided + let (final_img, width, height) = if let Some((target_w, target_h)) = target_dimensions { + log::info!( + "Resizing {}x{} -> {}x{} for memory optimization: {}", + orig_width, orig_height, target_w, target_h, + path.display() + ); + + // Use Lanczos3 for high-quality downsampling + let resized = image::imageops::resize( + &rgba, + target_w, + target_h, + image::imageops::FilterType::Lanczos3, + ); + + let resized_w = resized.width(); + let resized_h = resized.height(); + + log::info!( + "Resize complete: {}x{} image now uses ~{} MB instead of ~{} MB", + resized_w, resized_h, + (resized_w as u64 * resized_h as u64 * 4) / MB_TO_BYTES, + (orig_width as u64 * orig_height as u64 * 4) / MB_TO_BYTES + ); + + (resized, resized_w, resized_h) + } else { + log::info!( + "Decoded {}x{} image at full resolution: {}", + orig_width, orig_height, + path.display() + ); + (rgba, orig_width, orig_height) + }; + + let pixels = final_img.into_raw(); Some((path, width, height, pixels)) } Err(e) => { @@ -269,8 +355,14 @@ pub struct LargeImageManager { decoding_images: HashSet, /// Cache of decoded image handles decoded_images: HashMap, + /// Display dimensions used for each decoded image (for resize detection) + decoded_display_sizes: HashMap, /// Errors encountered during decoding decode_errors: HashMap, + /// Generation counter for each decode to support cancellation. + /// When a new decode is started for the same path, the generation is incremented. + /// Only decodes matching the current generation are accepted when they complete. + decode_generations: HashMap, } impl LargeImageManager { @@ -290,9 +382,40 @@ impl LargeImageManager { self.decode_errors.get(path) } - pub fn store_decoded(&mut self, path: PathBuf, handle: widget::image::Handle) { + /// Store a decoded image if the generation matches (not superseded by newer decode). + /// Returns true if stored, false if rejected due to generation mismatch. + pub fn store_decoded_with_generation( + &mut self, + path: PathBuf, + handle: widget::image::Handle, + display_size: Option<(u32, u32)>, + generation: u64, + ) -> bool { + // Check if this decode is still current (not superseded by a newer one) + if let Some(¤t_gen) = self.decode_generations.get(&path) { + if generation != current_gen { + log::info!( + "Discarding outdated decode for {} (generation {} != current {})", + path.display(), + generation, + current_gen + ); + return false; + } + } + + log::info!( + "Storing decoded image for {} (generation {})", + path.display(), + generation + ); + self.decoded_images.insert(path.clone(), handle); + if let Some(size) = display_size { + self.decoded_display_sizes.insert(path.clone(), size); + } self.decoding_images.remove(&path); + true } pub fn store_error(&mut self, path: PathBuf, error: String) { @@ -320,28 +443,115 @@ impl LargeImageManager { self.decoded_images.is_empty() } - /// Attempt to decode a large image, checking memory availability first. - /// Returns true if decode was initiated, false if skipped due to insufficient memory. - pub fn try_decode(&mut self, path: &PathBuf) -> bool { - self.clear_error(path); - - // Check if already decoded or decoding - if self.get_decoded(path).is_some() || self.is_decoding(path) { + /// Check if an image should be re-decoded due to display size increase. + /// Returns true only if the display size has INCREASED by more than 20% in either dimension. + /// Does NOT re-decode for smaller sizes (GPU can efficiently downscale). + pub fn needs_redecode_for_size( + &self, + path: &Path, + new_display_size: Option<(u32, u32)>, + ) -> bool { + let Some(new_size) = new_display_size else { return false; + }; + + let Some(&old_size) = self.decoded_display_sizes.get(path) else { + return false; + }; + + const REDECODE_THRESHOLD: f32 = 0.2; + + let width_increase = (new_size.0 as f32 / old_size.0 as f32) - 1.0; + let height_increase = (new_size.1 as f32 / old_size.1 as f32) - 1.0; + + let needs_redecode = + width_increase > REDECODE_THRESHOLD || height_increase > REDECODE_THRESHOLD; + + if needs_redecode { + log::info!( + "Display size increased significantly for {}: {}x{} -> {}x{} (increase: {:.1}% width, {:.1}% height) - re-decoding at higher resolution", + path.display(), + old_size.0, + old_size.1, + new_size.0, + new_size.1, + width_increase * 100.0, + height_increase * 100.0 + ); + } else if width_increase < -REDECODE_THRESHOLD || height_increase < -REDECODE_THRESHOLD { + log::debug!( + "Display size decreased for {}: {}x{} -> {}x{} (decrease: {:.1}% width, {:.1}% height) - keeping existing higher resolution", + path.display(), + old_size.0, + old_size.1, + new_size.0, + new_size.1, + width_increase * 100.0, + height_increase * 100.0 + ); + } + + needs_redecode + } + + /// Attempt to decode a large image, checking memory availability first. + /// Returns (should_decode, target_dimensions, generation) tuple. + pub fn try_decode( + &mut self, + path: &PathBuf, + display_dimensions: Option<(u32, u32)>, + ) -> (bool, Option<(u32, u32)>, u64) { + self.clear_error(path); + let is_currently_decoding = self.is_decoding(path); + let needs_redecode = self.needs_redecode_for_size(path, display_dimensions); + + if is_currently_decoding && !needs_redecode { + // Get current generation for the ongoing decode + let generation = self.decode_generations.get(path).copied().unwrap_or(0); + return (false, None, generation); + } + + if self.get_decoded(path).is_some() && !needs_redecode && !is_currently_decoding { + let generation = self.decode_generations.get(path).copied().unwrap_or(0); + return (false, None, generation); } let Some((width, height)) = get_image_dimensions(path) else { self.store_error(path.clone(), "Failed to read image dimensions".to_string()); - return false; + return (false, None, 0); }; - if !self.ensure_memory_available(path, width, height) { - return false; + let target_dimensions = if let Some((display_w, display_h)) = display_dimensions { + calculate_target_dimensions(width, height, display_w, display_h) + } else { + None + }; + + // Check memory for target size (if resizing) or full size + let (check_w, check_h) = target_dimensions.unwrap_or((width, height)); + if !self.ensure_memory_available(path, check_w, check_h) { + return (false, None, 0); + } + + // Increment generation counter (cancels any previous decode) + let generation = self + .decode_generations + .entry(path.clone()) + .and_modify(|g| *g += 1) + .or_insert(1); + let generation = *generation; + + if is_currently_decoding { + log::info!( + "Cancelling previous decode for {} and starting new one (generation {})", + path.display(), + generation + ); } // Mark as decoding self.decoding_images.insert(path.clone()); - true + (true, target_dimensions, generation) } /// Check if sufficient memory is available, clearing cache if needed. diff --git a/src/tab.rs b/src/tab.rs index 421d6c7..c26bb0d 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -1624,7 +1624,7 @@ pub enum Message { HighlightDeactivate(usize), HighlightActivate(usize), DirectorySize(PathBuf, DirSize), - ImageDecoded(PathBuf, u32, u32, Vec), + ImageDecoded(PathBuf, u32, u32, Vec, Option<(u32, u32)>, u64), // path, width, height, pixels, display_size, generation } #[derive(Copy, Clone, Debug, Eq, PartialEq)] @@ -2986,16 +2986,35 @@ impl Tab { // Clone path to avoid borrow checker issues let path = path.to_path_buf(); - // Try to decode the image using LargeImageManager - if self.large_image_manager.try_decode(&path) { + // Get display size for adaptive resolution + let display_dimensions = self + .size_opt + .get() + .map(|size| (size.width as u32, size.height as u32)); + + // Try to decode the image using LargeImageManager with adaptive resolution + let (should_decode, target_dimensions, generation) = self + .large_image_manager + .try_decode(&path, display_dimensions); + if should_decode { vec![Command::Iced( - cosmic::iced::Task::perform(decode_large_image(path), |result| { - result - .map(|(path, width, height, pixels)| { - Message::ImageDecoded(path, width, height, pixels) - }) - .unwrap_or_else(|| Message::AutoScroll(None)) - }) + cosmic::iced::Task::perform( + decode_large_image(path, target_dimensions), + move |result| { + result + .map(|(path, width, height, pixels)| { + Message::ImageDecoded( + path, + width, + height, + pixels, + display_dimensions, + generation, + ) + }) + .unwrap_or_else(|| Message::AutoScroll(None)) + }, + ) .into(), )] } else { @@ -3955,12 +3974,17 @@ impl Tab { } } } - Message::ImageDecoded(path, width, height, pixels) => { + Message::ImageDecoded(path, width, height, pixels, display_size, generation) => { // Create handle from pre-decoded RGBA data (fast!) let handle = widget::image::Handle::from_rgba(width, height, pixels); - // Store decoded image handle and remove from decoding set - self.large_image_manager.store_decoded(path, handle); + // Store decoded image handle if generation still matches (not superseded) + self.large_image_manager.store_decoded_with_generation( + path, + handle, + display_size, + generation, + ); } Message::ToggleSort(heading_option) => { if !matches!(self.location, Location::Search(..)) { @@ -4324,15 +4348,19 @@ impl Tab { if let Some(error_msg) = self.large_image_manager.get_error(path) { error_msg_opt = Some(error_msg.clone()); handle.clone() + } else if self.large_image_manager.is_decoding(path) { + // Currently decoding (initial or re-decode) --> show cached/thumbnail with loading indicator + is_loading = true; + // Use decoded handle if available (re-decode), otherwise thumbnail (initial decode) + self.large_image_manager + .get_decoded(path) + .cloned() + .unwrap_or_else(|| handle.clone()) } else if let Some(decoded_handle) = self.large_image_manager.get_decoded(path) { - // Full resolution ready --> use it + // Decoded and not currently decoding --> use it decoded_handle.clone() - } else if self.large_image_manager.is_decoding(path) { - // Currently decoding --> show thumbnail with loading indicator - is_loading = true; - handle.clone() } else if let Some((w, h)) = original_dims { // Check if image needs tiling if should_use_tiling(*w, *h) { @@ -4361,7 +4389,7 @@ impl Tab { } else if is_loading { widget::column() .push(widget::image(image_handle)) - .push(widget::text("Loading full resolution...").size(14)) + .push(widget::text("Loading higher resolution...").size(14)) .padding(space_xs) .align_x(cosmic::iced::Alignment::Center) .into()