diff --git a/Cargo.lock b/Cargo.lock index f97b369..e07e45d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1427,7 +1427,7 @@ dependencies = [ [[package]] name = "cosmic-config" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "atomicwrites", "cosmic-config-derive", @@ -1448,7 +1448,7 @@ dependencies = [ [[package]] name = "cosmic-config-derive" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "quote", "syn 2.0.108", @@ -1528,15 +1528,15 @@ dependencies = [ [[package]] name = "cosmic-freedesktop-icons" -version = "0.3.0" -source = "git+https://github.com/pop-os/freedesktop-icons#8a05c322c482ff3c69cf34bacfee98907ac45307" +version = "0.4.0" +source = "git+https://github.com/pop-os/freedesktop-icons#689c60d428f46dc59316eafa22297e196afa4b15" dependencies = [ - "dirs 5.0.1", + "dirs 6.0.0", "ini_core", "memmap2 0.9.9", "thiserror 2.0.17", "tracing", - "xdg 2.5.2", + "xdg 3.0.0", ] [[package]] @@ -1588,7 +1588,7 @@ dependencies = [ [[package]] name = "cosmic-text" version = "0.15.0" -source = "git+https://github.com/pop-os/cosmic-text.git#8a7bc790e5283f9aa569c29f8142a7a1b14e2b80" +source = "git+https://github.com/pop-os/cosmic-text.git#9339446cfa9b7f0110094a97764dccc09cfa98a2" dependencies = [ "bitflags 2.10.0", "fontdb 0.23.0", @@ -1611,7 +1611,7 @@ dependencies = [ [[package]] name = "cosmic-theme" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "almost", "cosmic-config", @@ -3147,7 +3147,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.56.0", ] [[package]] @@ -3162,7 +3162,7 @@ dependencies = [ [[package]] name = "iced" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "dnd", "iced_accessibility", @@ -3180,7 +3180,7 @@ dependencies = [ [[package]] name = "iced_accessibility" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "accesskit", "accesskit_winit", @@ -3189,7 +3189,7 @@ dependencies = [ [[package]] name = "iced_core" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "bitflags 2.10.0", "bytes", @@ -3213,7 +3213,7 @@ dependencies = [ [[package]] name = "iced_futures" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "futures", "iced_core", @@ -3239,7 +3239,7 @@ dependencies = [ [[package]] name = "iced_graphics" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "bitflags 2.10.0", "bytemuck", @@ -3261,7 +3261,7 @@ dependencies = [ [[package]] name = "iced_renderer" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "iced_graphics", "iced_tiny_skia", @@ -3273,7 +3273,7 @@ dependencies = [ [[package]] name = "iced_runtime" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "bytes", "cosmic-client-toolkit", @@ -3288,7 +3288,7 @@ dependencies = [ [[package]] name = "iced_tiny_skia" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "bytemuck", "cosmic-text", @@ -3304,7 +3304,7 @@ dependencies = [ [[package]] name = "iced_wgpu" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "as-raw-xcb-connection", "bitflags 2.10.0", @@ -3335,7 +3335,7 @@ dependencies = [ [[package]] name = "iced_widget" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "cosmic-client-toolkit", "dnd", @@ -3354,7 +3354,7 @@ dependencies = [ [[package]] name = "iced_winit" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "cosmic-client-toolkit", "dnd", @@ -4423,7 +4423,7 @@ checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "libcosmic" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic.git#16d095b2cdf3696718b1da87a83d8679fbee01a0" dependencies = [ "apply", "ashpd 0.12.0", @@ -8288,20 +8288,7 @@ dependencies = [ "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-strings", ] [[package]] @@ -8421,15 +8408,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link 0.2.1", -] - [[package]] name = "windows-strings" version = "0.4.2" @@ -8439,15 +8417,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link 0.2.1", -] - [[package]] name = "windows-sys" version = "0.45.0" diff --git a/src/large_image.rs b/src/large_image.rs new file mode 100644 index 0000000..6a2cf6b --- /dev/null +++ b/src/large_image.rs @@ -0,0 +1,599 @@ +use cosmic::widget; +use image::ImageReader; +use std::{ + collections::{HashMap, HashSet}, + path::{Path, PathBuf}, +}; + +/// Bytes per pixel in RGBA format (Red, Green, Blue, Alpha = 4 bytes) +pub const RGBA_BYTES_PER_PIXEL: u64 = 4; + +/// System memory reserve in MB to maintain for system stability (prevents thrashing) +/// Note: RAM checking is currently only available on Linux via procfs. +/// On Windows and macOS, only GPU buffer limits are enforced. +const SYSTEM_MEMORY_RESERVE_MB: u64 = 500; + +/// Maximum memory allocation for gallery image decoding in MB. +/// Gallery mode uses the full memory budget since only one image decodes at a time. +/// This matches the ThumbCfg max_mem_mb budget for consistency. +const GALLERY_MEMORY_LIMIT_MB: u64 = 2000; + +/// Threshold for considering an image "large" requiring GPU tiling +/// Atlas fragment/tile size in pixels. Large images are split into fragments of this size. +/// Must match the atlas SIZE constant in libcosmic/iced/wgpu/src/image/atlas.rs +pub const ATLAS_FRAGMENT_SIZE: u32 = 4096; + +/// Conversion factor: 1 MB = 1024 * 1024 bytes (binary megabyte, used for RAM calculations) +pub const MB_TO_BYTES: u64 = 1024 * 1024; + +/// Conversion factor: 1 MB = 1000 * 1000 bytes (decimal megabyte, used by image crate) +/// 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 { + let Some(bytes_needed) = calculate_image_memory(width, height) else { + // Overflow in calculation means it definitely exceeds any reasonable limit + return true; + }; + + let max_bytes = memory_limit_mb * MB_TO_BYTES; + bytes_needed > max_bytes +} + +/// Check if an image should use GPU tiling for display. +/// Images larger than the atlas fragment size need to be split into tiles for GPU upload. +pub fn should_use_tiling(width: u32, height: u32) -> bool { + width > ATLAS_FRAGMENT_SIZE || height > ATLAS_FRAGMENT_SIZE +} + +/// Determine if an image should use the dedicated worker for thumbnail generation. +/// Returns (use_dedicated_worker, effective_max_mb, effective_jobs). +/// +/// Large images that exceed per-worker memory budget get routed to a dedicated worker +/// with full memory budget. Smaller images use the normal parallel worker pool. +pub fn should_use_dedicated_worker( + width: u32, + height: u32, + total_budget_mb: u64, + parallel_workers: usize, +) -> (bool, u64, usize) { + if width == 0 || height == 0 { + log::warn!( + "Invalid image dimensions {}x{}, using normal queue", + width, + height + ); + return (false, total_budget_mb, parallel_workers); + } + + let Some(bytes_needed) = calculate_image_memory(width, height) else { + log::warn!( + "Image dimensions {}x{} overflow memory calculation, using normal queue", + width, + height + ); + return (false, total_budget_mb, parallel_workers); + }; + + let mb_needed = bytes_needed / MB_TO_BYTES; + let per_worker_budget_mb = total_budget_mb / parallel_workers as u64; + + if mb_needed > per_worker_budget_mb { + log::info!( + "Large image {}x{} needs {}MB (exceeds per-worker {}MB), using dedicated worker", + width, + height, + mb_needed, + per_worker_budget_mb + ); + // Use dedicated worker with full budget + (true, total_budget_mb, 1) + } else { + log::debug!( + "Normal image {}x{} needs {}MB (within per-worker {}MB), using parallel workers", + width, + height, + mb_needed, + per_worker_budget_mb + ); + // Use parallel worker pool with shared budget + (false, total_budget_mb, parallel_workers) + } +} + +/// Get the dimensions of an image without fully decoding it +pub fn get_image_dimensions(path: &Path) -> Option<(u32, u32)> { + match ImageReader::open(path) { + Ok(reader) => match reader.into_dimensions() { + Ok((width, height)) => { + log::debug!( + "Image dimensions: {}x{} for {}", + width, + height, + path.display() + ); + Some((width, height)) + } + Err(e) => { + log::warn!("Failed to get dimensions for {}: {}", path.display(), e); + None + } + }, + Err(e) => { + log::warn!("Failed to open image reader for {}: {}", path.display(), e); + None + } + } +} + +/// Calculate the memory required to decode an image in bytes. +/// Returns None if the calculation overflows. +fn calculate_image_memory(width: u32, height: u32) -> Option { + let pixels = (width as u64).checked_mul(height as u64)?; + pixels.checked_mul(RGBA_BYTES_PER_PIXEL) +} + +/// Check if there's sufficient system RAM to decode an image (Linux only). +/// Returns: (has_memory, error_message) +#[cfg(target_os = "linux")] +fn check_ram_available(width: u32, height: u32) -> (bool, Option) { + use procfs::Current; + + let Some(bytes_needed) = calculate_image_memory(width, height) else { + let error_msg = format!( + "Image dimensions too large: {}x{} causes overflow in memory calculation", + width, height + ); + log::error!("{}", error_msg); + return (false, Some(error_msg)); + }; + + let mb_needed = bytes_needed / MB_TO_BYTES; + + match procfs::Meminfo::current() { + Ok(meminfo) => { + // MemAvailable includes reclaimable cache and is the best estimate of + // actually available memory for new allocations + let available_kb = meminfo.mem_available.unwrap_or(0); + let available_bytes = available_kb * 1024; + + // Maintain system reserve to prevent thrashing and OOM killer + let min_reserve_bytes = SYSTEM_MEMORY_RESERVE_MB * MB_TO_BYTES; + let usable_bytes = available_bytes.saturating_sub(min_reserve_bytes); + + if bytes_needed > usable_bytes { + let available_mb = available_bytes / MB_TO_BYTES; + let error_msg = format!( + "Insufficient memory: need {}MB, available {}MB. Try closing other applications.", + mb_needed, available_mb + ); + log::warn!("{}", error_msg); + return (false, Some(error_msg)); + } + + (true, None) + } + Err(e) => { + log::warn!("Failed to read /proc/meminfo: {}. Skipping RAM check.", e); + // Graceful fallback: assume RAM is available + (true, None) + } + } +} + +#[cfg(not(target_os = "linux"))] +fn check_ram_available(_width: u32, _height: u32) -> (bool, Option) { + // RAM checking not implemented for this platform + (true, None) +} + +pub fn check_memory_available(width: u32, height: u32) -> (bool, Option) { + if width == 0 || height == 0 { + let error_msg = format!( + "Invalid image dimensions: {}x{} (zero dimension)", + width, height + ); + log::error!("{}", error_msg); + return (false, Some(error_msg)); + } + + // Check system RAM availability + check_ram_available(width, height) +} + +/// Decode a large image asynchronously in a blocking thread pool. +/// +/// 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, + 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()); + + // Use ImageReader with explicit memory limits to avoid "Memory limit exceeded" errors + // Gallery mode uses the full memory budget since only one image decodes at a time + match image::ImageReader::open(&path) { + Ok(reader) => { + match reader.with_guessed_format() { + Ok(mut reader) => { + // Note: image crate uses decimal MB (1000^2), not binary MB (1024^2) + let mut limits = image::Limits::default(); + limits.max_alloc = Some(GALLERY_MEMORY_LIMIT_MB * DECIMAL_MB_TO_BYTES); + reader.limits(limits); + + match reader.decode() { + Ok(img) => { + let rgba = img.into_rgba8(); + let orig_width = rgba.width(); + let orig_height = rgba.height(); + + // 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) => { + log::warn!("Failed to decode {}: {}", path.display(), e); + None + } + } + } + Err(e) => { + log::warn!("Failed to guess format for {}: {}", path.display(), e); + None + } + } + } + Err(e) => { + log::warn!("Failed to open {}: {}", path.display(), e); + None + } + } + }) + .await + .ok() + .flatten() +} + +/// Manages state and operations for large image decoding in gallery mode +#[derive(Debug, Default)] +pub struct LargeImageManager { + /// Paths of images currently being decoded + 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 { + pub fn new() -> Self { + Self::default() + } + + pub fn is_decoding(&self, path: &Path) -> bool { + self.decoding_images.contains(path) + } + + pub fn get_decoded(&self, path: &Path) -> Option<&widget::image::Handle> { + self.decoded_images.get(path) + } + + pub fn get_error(&self, path: &Path) -> Option<&String> { + self.decode_errors.get(path) + } + + /// 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) { + self.decode_errors.insert(path.clone(), error); + self.decoding_images.remove(&path); + } + + pub fn clear_error(&mut self, path: &Path) { + self.decode_errors.remove(path); + } + + pub fn clear_cache(&mut self) { + log::info!( + "Clearing {} cached images from large image manager", + self.decoded_images.len() + ); + self.decoded_images.clear(); + } + + pub fn cache_size(&self) -> usize { + self.decoded_images.len() + } + + pub fn cache_is_empty(&self) -> bool { + self.decoded_images.is_empty() + } + + /// 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, None, 0); + }; + + 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, target_dimensions, generation) + } + + /// Check if sufficient memory is available, clearing cache if needed. + /// Returns true if memory is available, false otherwise. + fn ensure_memory_available(&mut self, path: &PathBuf, width: u32, height: u32) -> bool { + let (has_memory, error_opt) = check_memory_available(width, height); + + if has_memory { + return true; + } + + if self.cache_is_empty() { + if let Some(error_msg) = error_opt { + self.store_error(path.clone(), error_msg); + log::warn!( + "Cannot load {}: insufficient memory and cache is empty", + path.display() + ); + } + return false; + } + + log::info!( + "Insufficient memory, clearing {} cached images", + self.cache_size() + ); + self.clear_cache(); + + let (has_memory_after_clear, error_opt_after) = check_memory_available(width, height); + + if has_memory_after_clear { + log::info!("Memory available after cache clear, proceeding with decode"); + return true; + } + + if let Some(error_msg) = error_opt_after { + self.store_error(path.clone(), error_msg); + log::warn!( + "Cannot load {}: insufficient memory even after cache clear", + path.display() + ); + } + false + } +} diff --git a/src/lib.rs b/src/lib.rs index 6ffbe2e..e2bd675 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ use config::Config; pub mod config; pub mod dialog; mod key_bind; +pub(crate) mod large_image; mod localize; mod menu; mod mime_app; diff --git a/src/tab.rs b/src/tab.rs index 9f6d2da..6de2436 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -79,6 +79,10 @@ use crate::{ config::{DesktopConfig, ICON_SCALE_MAX, ICON_SIZE_GRID, IconSizes, TabConfig, ThumbCfg}, dialog::DialogKind, fl, + large_image::{ + LargeImageManager, decode_large_image, exceeds_memory_limit, should_use_dedicated_worker, + should_use_tiling, + }, localize::{LANGUAGE_SORTER, LOCALE}, menu, mime_app, mime_icon::{mime_for_path, mime_icon}, @@ -98,8 +102,10 @@ const MAX_SEARCH_RESULTS: usize = 200; //TODO: configurable thumbnail size? const THUMBNAIL_SIZE: u32 = (ICON_SIZE_GRID as u32) * (ICON_SCALE_MAX as u32); +// Thumbnail generation semaphore - limits parallel thumbnail workers +// Uses 4 workers for balanced throughput and memory usage pub static THUMB_SEMAPHORE: LazyLock = - LazyLock::new(|| tokio::sync::Semaphore::const_new(num_cpus::get())); + LazyLock::new(|| tokio::sync::Semaphore::const_new(num_cpus::get().min(4))); pub(crate) static SORT_OPTION_FALLBACK: LazyLock> = LazyLock::new(|| { @@ -1640,6 +1646,7 @@ pub enum Message { HighlightDeactivate(usize), HighlightActivate(usize), DirectorySize(PathBuf, DirSize), + ImageDecoded(PathBuf, u32, u32, Vec, Option<(u32, u32)>, u64), // path, width, height, pixels, display_size, generation } #[derive(Copy, Clone, Debug, Eq, PartialEq)] @@ -1766,10 +1773,17 @@ impl ItemThumbnail { ThumbnailCacher::new(path, ThumbnailSize::from_pixel_size(thumbnail_size)); match thumbnail_cacher.as_ref() { Ok(cache) => match cache.get_cached_thumbnail() { - CachedThumbnail::Valid((path, size)) => { + CachedThumbnail::Valid((thumbnail_path, size)) => { + // Check original image dimensions even when loading cached thumbnail + // This prevents trying to load huge images in preview mode + let original_dims = match image::image_dimensions(path) { + Ok((width, height)) => Some((width, height)), + Err(_) => size.map(|s| (s.pixel_size(), s.pixel_size())), + }; + return Self::Image( - widget::image::Handle::from_path(path), - size.map(|s| (s.pixel_size(), s.pixel_size())), + widget::image::Handle::from_path(thumbnail_path), + original_dims, ); } CachedThumbnail::Failed => { @@ -1809,6 +1823,45 @@ impl ItemThumbnail { let mut tried_supported_file = false; // First try built-in image thumbnailer if mime.type_() == mime::IMAGE && check_size("image", max_size_mb * 1000 * 1000) { + // Check if image dimensions would exceed available memory budget + // The GPU tiling system can handle large images, but we still need to decode them first + let dimensions_ok = match image::image_dimensions(path) { + Ok((width, height)) => { + if exceeds_memory_limit(width, height, max_mem) { + log::warn!( + "skipping thumbnail generation for {}: {}x{} image would exceed {}MB memory budget", + path.display(), + width, + height, + max_mem + ); + false + } else { + if should_use_tiling(width, height) { + log::info!( + "Large image {}x{} detected, will use GPU tiling for display", + width, + height + ); + } + true + } + } + Err(err) => { + log::debug!( + "failed to read dimensions for {}: {}, will try decoding", + path.display(), + err + ); + true // If we can't read dimensions, try anyway + } + }; + + if !dimensions_ok { + // Skip this image entirely since it is too large to safely decode + return Self::NotImage; + } + tried_supported_file = true; let dyn_img: Option = match mime.subtype().as_str() { "jxl" => match File::open(path) { @@ -1862,10 +1915,15 @@ impl ItemThumbnail { }; if let Some(dyn_img) = dyn_img { + let (img_width, img_height) = (dyn_img.width(), dyn_img.height()); + if let Ok(cacher) = thumbnail_cacher.as_ref() { match cacher.update_with_image(dyn_img) { - Ok(path) => { - return Self::Image(widget::image::Handle::from_path(path), None); + Ok(thumb_path) => { + return Self::Image( + widget::image::Handle::from_path(thumb_path), + Some((img_width, img_height)), + ); } Err(err) => { log::warn!("cacher failed to decode {}: {}", path.display(), err); @@ -1882,7 +1940,7 @@ impl ItemThumbnail { thumbnail.height(), thumbnail.into_raw(), ), - Some((dyn_img.width(), dyn_img.height())), + Some((img_width, img_height)), ); } } @@ -2095,12 +2153,9 @@ impl Item { .unwrap_or(&ItemThumbnail::NotImage) { ItemThumbnail::NotImage => icon, - ItemThumbnail::Image(handle, _) => { - if let Some(path) = self.path_opt() { - if self.mime.type_() == mime::IMAGE { - return widget::image(widget::image::Handle::from_path(path)).into(); - } - } + ItemThumbnail::Image(handle, _original_dims) => { + // Preview pane: ALWAYS show thumbnail for instant, responsive UI + // Full resolution loading happens in gallery mode widget::image(handle.clone()).into() } ItemThumbnail::Svg(handle) => widget::svg(handle.clone()).into(), @@ -2501,6 +2556,7 @@ pub struct Tab { time_formatter: DateTimeFormatter, watch_drag: bool, window_id: Option, + large_image_manager: LargeImageManager, } async fn calculate_dir_size(path: &Path, controller: Controller) -> Result { @@ -2621,6 +2677,7 @@ impl Tab { time_formatter: time_formatter(config.military_time), watch_drag: true, window_id, + large_image_manager: LargeImageManager::new(), } } @@ -2914,6 +2971,77 @@ impl Tab { last } + fn trigger_async_decode(&mut self) -> Vec { + // Only trigger decode in gallery mode for the currently selected image + if !self.gallery { + return Vec::new(); + } + + let Some(index) = self.select_focus else { + return Vec::new(); + }; + + let Some(items) = &self.items_opt else { + return Vec::new(); + }; + + let Some(item) = items.get(index) else { + return Vec::new(); + }; + + let Some(ItemThumbnail::Image(_, original_dims)) = &item.thumbnail_opt else { + return Vec::new(); + }; + + if let Some((w, h)) = original_dims { + if !should_use_tiling(*w, *h) { + return Vec::new(); + } + } + + let Some(path) = item.path_opt() else { + return Vec::new(); + }; + + // Clone path to avoid borrow checker issues + let path = path.to_path_buf(); + + // 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, 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 { + Vec::new() + } + } + pub fn change_location(&mut self, location: &Location, history_i_opt: Option) { self.location = location.normalize(); self.location_ancestors = self.location.ancestors(); @@ -3325,6 +3453,10 @@ impl Tab { } Message::Gallery(gallery) => { self.gallery = gallery; + + if gallery { + commands.extend(self.trigger_async_decode()); + } } Message::GalleryPrevious | Message::GalleryNext => { let mut pos_opt = None; @@ -3352,6 +3484,8 @@ impl Tab { if let Some((row, col)) = pos_opt { // Should mod_shift be available? self.select_position(row, col, mod_shift); + + commands.extend(self.trigger_async_decode()); } if let Some(offset) = self.select_focus_scroll() { commands.push(Command::Iced( @@ -3367,6 +3501,10 @@ impl Tab { for (_, item) in &indices { if item.selected && item.can_gallery() { self.gallery = !self.gallery; + + if self.gallery { + commands.extend(self.trigger_async_decode()); + } break; } } @@ -3853,6 +3991,18 @@ impl Tab { } } } + 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 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(..)) { let heading_sort = if self.sort_name == heading_option { @@ -4207,26 +4357,66 @@ impl Tab { .unwrap_or(&ItemThumbnail::NotImage) { ItemThumbnail::NotImage => {} - ItemThumbnail::Image(handle, _) => { - if let Some(path) = item.path_opt() { - element_opt = Some( - widget::container( - //TODO: use widget::image::viewer, when its zoom can be reset - widget::image(widget::image::Handle::from_path(path)), - ) - .center(Length::Fill) - .into(), - ); + ItemThumbnail::Image(handle, original_dims) => { + // Determine which image to show based on async decode state + let mut is_loading = false; + let mut error_msg_opt = None; + let image_handle = if let Some(path) = item.path_opt() { + 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) + { + // Decoded and not currently decoding --> use it + decoded_handle.clone() + } else if let Some((w, h)) = original_dims { + // Check if image needs tiling + if should_use_tiling(*w, *h) { + // Large image --> show thumbnail only + handle.clone() + } else { + // Normal-sized image --> load full resolution directly + widget::image::Handle::from_path(path) + } + } else { + // No dimensions available --> show thumbnail + handle.clone() + } } else { - element_opt = Some( - widget::container( - //TODO: use widget::image::viewer, when its zoom can be reset - widget::image(handle.clone()), - ) - .center(Length::Fill) - .into(), - ); - } + handle.clone() + }; + + let content: cosmic::Element<'_, Message> = + if let Some(error_msg) = error_msg_opt { + widget::column() + .push(widget::image(image_handle)) + .push(widget::text(format!("⚠ {}", error_msg)).size(13)) + .padding(space_xs) + .align_x(cosmic::iced::Alignment::Center) + .into() + } else if is_loading { + widget::column() + .push(widget::image(image_handle)) + .push(widget::text("Loading higher resolution...").size(14)) + .padding(space_xs) + .align_x(cosmic::iced::Alignment::Center) + .into() + } else { + //TODO: use widget::image::viewer, when its zoom can be reset + widget::image(image_handle).into() + }; + + element_opt = + Some(widget::container(content).center(Length::Fill).into()); } ItemThumbnail::Svg(handle) => { element_opt = Some( @@ -5661,13 +5851,30 @@ impl Tab { let max_jobs = jobs; let max_mb = u64::from(self.thumb_config.max_mem_mb.get()); let max_size = u64::from(self.thumb_config.max_size_mb.get()); + + // Determine effective memory budget based on image size + let (effective_max_mb, effective_jobs) = if mime.type_() == mime::IMAGE { + match image::image_dimensions(&path) { + Ok((width, height)) => { + let (_use_dedicated, eff_mb, eff_jobs) = + should_use_dedicated_worker(width, height, max_mb, max_jobs); + (eff_mb, eff_jobs) + } + Err(_) => (max_mb, max_jobs), + } + } else { + (max_mb, max_jobs) + }; + subscriptions.push(Subscription::run_with_id( ("thumbnail", path.clone()), stream::channel(1, move |mut output| async move { let message = { let path = path.clone(); + // Acquire semaphore permit _ = THUMB_SEMAPHORE.acquire().await; + tokio::task::spawn_blocking(move || { let start = Instant::now(); let thumbnail = ItemThumbnail::new( @@ -5675,8 +5882,8 @@ impl Tab { metadata, mime, THUMBNAIL_SIZE, - max_mb, - max_jobs, + effective_max_mb, + effective_jobs, max_size, ); log::debug!(