Merge pull request #1377 from FreddyFunk/bugfix/large-image-preview
improve support and performance with very large images for thumbnail generation, preview tab and gallery view
This commit is contained in:
commit
28c241bc1b
4 changed files with 863 additions and 87 deletions
75
Cargo.lock
generated
75
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
599
src/large_image.rs
Normal file
599
src/large_image.rs
Normal file
|
|
@ -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<u64> {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
// RAM checking not implemented for this platform
|
||||
(true, None)
|
||||
}
|
||||
|
||||
pub fn check_memory_available(width: u32, height: u32) -> (bool, Option<String>) {
|
||||
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<u8>)> {
|
||||
// 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<PathBuf>,
|
||||
/// Cache of decoded image handles
|
||||
decoded_images: HashMap<PathBuf, widget::image::Handle>,
|
||||
/// Display dimensions used for each decoded image (for resize detection)
|
||||
decoded_display_sizes: HashMap<PathBuf, (u32, u32)>,
|
||||
/// Errors encountered during decoding
|
||||
decode_errors: HashMap<PathBuf, String>,
|
||||
/// 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<PathBuf, u64>,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
275
src/tab.rs
275
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<tokio::sync::Semaphore> =
|
||||
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<FxHashMap<String, (HeadingOptions, bool)>> =
|
||||
LazyLock::new(|| {
|
||||
|
|
@ -1640,6 +1646,7 @@ pub enum Message {
|
|||
HighlightDeactivate(usize),
|
||||
HighlightActivate(usize),
|
||||
DirectorySize(PathBuf, DirSize),
|
||||
ImageDecoded(PathBuf, u32, u32, Vec<u8>, 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<image::DynamicImage> = 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<fieldsets::T>,
|
||||
watch_drag: bool,
|
||||
window_id: Option<window::Id>,
|
||||
large_image_manager: LargeImageManager,
|
||||
}
|
||||
|
||||
async fn calculate_dir_size(path: &Path, controller: Controller) -> Result<u64, OperationError> {
|
||||
|
|
@ -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<Command> {
|
||||
// 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<usize>) {
|
||||
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!(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue