lychee-core: use ImageCanvas for visual pan/zoom; fix panning delta calculation

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I8ac7479803ab70149d6ffadf897ef33d6a6a6964
This commit is contained in:
raf 2026-04-19 23:09:48 +03:00
commit add40c39f8
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
4 changed files with 182 additions and 78 deletions

69
Cargo.lock generated
View file

@ -1135,6 +1135,12 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "float_next_after"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.1.5" version = "0.1.5"
@ -1603,6 +1609,7 @@ dependencies = [
"image", "image",
"kamadak-exif", "kamadak-exif",
"log", "log",
"lyon_path",
"raw-window-handle", "raw-window-handle",
"rustc-hash 2.1.2", "rustc-hash 2.1.2",
"thiserror 2.0.18", "thiserror 2.0.18",
@ -1677,6 +1684,7 @@ dependencies = [
"iced_debug", "iced_debug",
"iced_graphics", "iced_graphics",
"log", "log",
"lyon",
"rustc-hash 2.1.2", "rustc-hash 2.1.2",
"thiserror 2.0.18", "thiserror 2.0.18",
"wgpu", "wgpu",
@ -2080,6 +2088,7 @@ dependencies = [
"image", "image",
"lychee-cli", "lychee-cli",
"lychee-img", "lychee-img",
"lychee-widgets",
] ]
[[package]] [[package]]
@ -2089,6 +2098,66 @@ dependencies = [
"image", "image",
] ]
[[package]]
name = "lychee-widgets"
version = "1.0.0"
dependencies = [
"iced",
"iced_graphics",
]
[[package]]
name = "lyon"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd0578bdecb7d6d88987b8b2b1e3a4e2f81df9d0ece1078623324a567904e7b7"
dependencies = [
"lyon_algorithms",
"lyon_tessellation",
]
[[package]]
name = "lyon_algorithms"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9815fac08e6fd96733a11dce4f9d15a3f338e96a2e2311ee21e1b738efc2bc0f"
dependencies = [
"lyon_path",
"num-traits",
]
[[package]]
name = "lyon_geom"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92"
dependencies = [
"arrayvec",
"euclid",
"num-traits",
]
[[package]]
name = "lyon_path"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e"
dependencies = [
"lyon_geom",
"num-traits",
]
[[package]]
name = "lyon_tessellation"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e43b7e44161571868f5c931d12583592c223c5583eef86b08aa02b7048a3552"
dependencies = [
"float_next_after",
"lyon_path",
"num-traits",
]
[[package]] [[package]]
name = "malloc_buf" name = "malloc_buf"
version = "0.0.6" version = "0.0.6"

View file

@ -8,12 +8,14 @@ edition = "2024"
[workspace.dependencies] [workspace.dependencies]
clap = { version = "4.6.0", features = ["derive"] } clap = { version = "4.6.0", features = ["derive"] }
iced = { version = "0.14.0", features = ["image", "wayland", "smol"] } iced = { version = "0.14.0", features = ["image", "wayland", "smol", "canvas"] }
iced_graphics = "0.14.0"
image = "0.25.10" image = "0.25.10"
lychee-cli = { path = "./crates/lychee-cli" } lychee-cli = { path = "./crates/lychee-cli" }
lychee-img = { path = "./crates/lychee-img" } lychee-img = { path = "./crates/lychee-img" }
lychee-core = { path = "./crates/lychee-core" } lychee-core = { path = "./crates/lychee-core" }
lychee-widgets = { path = "./crates/lychee-widgets" }
[profile.release] [profile.release]
lto = true lto = true

View file

@ -9,3 +9,4 @@ iced.workspace = true
image.workspace = true image.workspace = true
lychee-cli.workspace = true lychee-cli.workspace = true
lychee-img.workspace = true lychee-img.workspace = true
lychee-widgets.workspace = true

View file

@ -1,18 +1,23 @@
use std::collections::HashMap; use std::collections::HashMap;
use iced::event; use iced::event;
use iced::Point;
use iced::{ use iced::{
Alignment, Element, Length, Subscription, Task, keyboard, keyboard,
time::{self, milliseconds}, time::{self, milliseconds},
widget::{column, container, row}, widget::{column, row},
window, window, Element, Length, Subscription, Task,
}; };
use image::{GenericImageView, imageops::FilterType}; use image::GenericImageView;
use lychee_cli::{Args, Clap}; use lychee_cli::{Args, Clap};
use lychee_img::collect_paths; use lychee_img::collect_paths;
use lychee_widgets::ImageCanvas;
use crate::keybindings::handle_key_event; use crate::keybindings::handle_key_event;
type ImageCacheKey = (usize, u16, bool, bool);
type ImageCacheValue = (iced::widget::image::Handle, u32, u32);
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Message { pub enum Message {
Next, Next,
@ -25,6 +30,11 @@ pub enum Message {
FitWindow, FitWindow,
ResetView, ResetView,
Pan(i32, i32), Pan(i32, i32),
CanvasWheelZoom {
factor: f32,
cursor: Point,
viewport_center: Point,
},
RotateCW, RotateCW,
RotateCCW, RotateCCW,
FlipHorizontal, FlipHorizontal,
@ -42,12 +52,14 @@ pub struct App {
window_id: window::Id, window_id: window::Id,
paths: Vec<String>, paths: Vec<String>,
current: usize, current: usize,
// Cache key: (index, rotation_degrees, flip_h, flip_v, zoom_percent) // Cache key: (index, rotation_degrees, flip_h, flip_v)
image_cache: HashMap<(usize, u16, bool, bool, u32), iced::widget::image::Handle>, // Cache value: (image_handle, width, height)
// NOTE: zoom is visual only (canvas transform), not in cache key
image_cache: HashMap<ImageCacheKey, ImageCacheValue>,
fullscreen: bool, fullscreen: bool,
slideshow_active: bool, slideshow_active: bool,
slideshow_interval: u64, slideshow_interval: u64,
// Zoom state (scale is display value, zoom_percent is cache key) // Zoom state
scale: f32, scale: f32,
min_scale: f32, min_scale: f32,
max_scale: f32, max_scale: f32,
@ -107,28 +119,41 @@ impl App {
} }
pub fn view(&self) -> Element<'_, Message> { pub fn view(&self) -> Element<'_, Message> {
let image_content: Element<'_, Message> = match self.image_cache.get(&( let image_content: Element<'_, Message> =
self.current, match self
self.rotation, .image_cache
self.flip_h, .get(&(self.current, self.rotation, self.flip_h, self.flip_v))
self.flip_v, {
self.zoom_percent, Some((handle, width, height)) => {
)) { // Create ImageCanvas with pan/zoom support
Some(handle) => { // zoom_step = 1 + scale_step (e.g., 1.0 + 0.1 = 1.1 for 10% steps)
// Image displayed at actual pixel dimensions (Shrink) let zoom_step = 1.0 + self.scale_step;
let img: iced::widget::Image<iced::widget::image::Handle> = let canvas = ImageCanvas::new(
iced::widget::Image::new(handle.clone()); handle.clone(),
*width,
*height,
self.scale,
Point::new(self.pan_offset.0, self.pan_offset.1),
zoom_step,
);
// Center the image in a Fill-sized container canvas.into_element().map(|msg| match msg {
container(img) lychee_widgets::Message::Pan(dx, dy) => Message::Pan(dx as i32, dy as i32),
.width(Length::Fill) lychee_widgets::Message::Zoom {
.height(Length::Fill) factor,
.align_x(Alignment::Center) cursor,
.align_y(Alignment::Center) viewport_center,
.into() } => Message::CanvasWheelZoom {
} factor,
None => iced::widget::text("Loading...").into(), cursor,
}; viewport_center,
},
lychee_widgets::Message::ZoomIn => Message::ZoomIn,
lychee_widgets::Message::ZoomOut => Message::ZoomOut,
})
}
None => iced::widget::text("Loading...").into(),
};
// Main layout: image on top, statusbar at bottom // Main layout: image on top, statusbar at bottom
let statusbar: Element<'_, Message> = self.render_statusbar(); let statusbar: Element<'_, Message> = self.render_statusbar();
@ -183,25 +208,18 @@ impl App {
fn preload_adjacent(&mut self) { fn preload_adjacent(&mut self) {
for offset in [0isize, -1, 1] { for offset in [0isize, -1, 1] {
let idx = (self.current as isize + offset) as usize; let idx = (self.current as isize + offset) as usize;
let cache_key = ( let cache_key = (idx, self.rotation, self.flip_h, self.flip_v);
idx,
self.rotation,
self.flip_h,
self.flip_v,
self.zoom_percent,
);
if idx < self.paths.len() && !self.image_cache.contains_key(&cache_key) { if idx < self.paths.len() && !self.image_cache.contains_key(&cache_key) {
// Extract path to avoid borrow conflict // Extract path to avoid borrow conflict
let path = self.paths[idx].clone(); let path = self.paths[idx].clone();
let zoom = self.zoom_percent; if let Some(handle_data) = self.load_image(&path) {
if let Some(handle) = self.load_image(&path, zoom) { self.image_cache.insert(cache_key, handle_data);
self.image_cache.insert(cache_key, handle);
} }
} }
} }
} }
fn load_image(&mut self, path: &str, zoom_percent: u32) -> Option<iced::widget::image::Handle> { fn load_image(&mut self, path: &str) -> Option<(iced::widget::image::Handle, u32, u32)> {
let mut img = ::image::open(path).ok()?; let mut img = ::image::open(path).ok()?;
// Apply rotation // Apply rotation
@ -224,53 +242,33 @@ impl App {
let (orig_width, orig_height) = img.dimensions(); let (orig_width, orig_height) = img.dimensions();
self.current_image_size = (orig_width, orig_height); self.current_image_size = (orig_width, orig_height);
// Apply zoom by resizing // Load at native resolution - zoom is purely visual via canvas transform
let new_width = ((orig_width as f32) * (zoom_percent as f32) / 100.0).max(1.0) as u32;
let new_height = ((orig_height as f32) * (zoom_percent as f32) / 100.0).max(1.0) as u32;
// Only resize if not 100%
let img = if zoom_percent != 100 {
img.resize(new_width, new_height, FilterType::Lanczos3)
} else {
img
};
let (width, height) = img.dimensions(); let (width, height) = img.dimensions();
let raw = img.to_rgba8().into_raw(); let raw = img.to_rgba8().into_raw();
Some(iced::widget::image::Handle::from_rgba(width, height, raw)) let handle = iced::widget::image::Handle::from_rgba(width, height, raw);
Some((handle, width, height))
} }
fn invalidate_cache(&mut self) { fn invalidate_cache(&mut self) {
// Invalidate current and adjacent images at current zoom level // Invalidate current and adjacent images when transform changes. Zoom is actually
// visual only, so it does not invalidate cache.
for offset in [0isize, -1, 1] { for offset in [0isize, -1, 1] {
let idx = (self.current as isize + offset) as usize; let idx = (self.current as isize + offset) as usize;
let key = ( let key = (idx, self.rotation, self.flip_h, self.flip_v);
idx,
self.rotation,
self.flip_h,
self.flip_v,
self.zoom_percent,
);
self.image_cache.remove(&key); self.image_cache.remove(&key);
} }
self.preload_adjacent(); self.preload_adjacent();
} }
/// Ensure the current image is loaded into cache We'll load it on-demand if missing. /// Ensure the current image is loaded into cache.
/// We'll load it on-demand if missing.
fn ensure_current_loaded(&mut self) { fn ensure_current_loaded(&mut self) {
let key = ( let key = (self.current, self.rotation, self.flip_h, self.flip_v);
self.current,
self.rotation,
self.flip_h,
self.flip_v,
self.zoom_percent,
);
if !self.image_cache.contains_key(&key) { if !self.image_cache.contains_key(&key) {
// Extract path to avoid borrow conflict // Extract path to avoid borrow conflict
let path = self.paths[self.current].clone(); let path = self.paths[self.current].clone();
let zoom = self.zoom_percent; if let Some(handle_data) = self.load_image(&path) {
if let Some(handle) = self.load_image(&path, zoom) { self.image_cache.insert(key, handle_data);
self.image_cache.insert(key, handle);
} }
} }
} }
@ -310,19 +308,21 @@ impl App {
Task::none() Task::none()
} }
Message::ZoomIn => { Message::ZoomIn => {
// Zoom is visual only via canvas transform - no cache invalidation
let new_percent = let new_percent =
(self.zoom_percent as f32 * (1.0 + self.scale_step)).round() as u32; (self.zoom_percent as f32 * (1.0 + self.scale_step)).round() as u32;
self.zoom_percent = new_percent.min((self.max_scale * 100.0) as u32); self.zoom_percent = new_percent.min((self.max_scale * 100.0) as u32);
self.scale = self.zoom_percent as f32 / 100.0; self.scale = self.zoom_percent as f32 / 100.0;
self.invalidate_cache(); // No cache invalidation - image data unchanged
Task::none() Task::none()
} }
Message::ZoomOut => { Message::ZoomOut => {
// Zoom is visual only via canvas transform - no cache invalidation
let new_percent = let new_percent =
(self.zoom_percent as f32 * (1.0 / (1.0 + self.scale_step))).round() as u32; (self.zoom_percent as f32 * (1.0 / (1.0 + self.scale_step))).round() as u32;
self.zoom_percent = new_percent.max((self.min_scale * 100.0) as u32); self.zoom_percent = new_percent.max((self.min_scale * 100.0) as u32);
self.scale = self.zoom_percent as f32 / 100.0; self.scale = self.zoom_percent as f32 / 100.0;
self.invalidate_cache(); // No cache invalidation - image data unchanged
Task::none() Task::none()
} }
Message::ActualSize | Message::FitWindow | Message::ResetView => { Message::ActualSize | Message::FitWindow | Message::ResetView => {
@ -356,14 +356,46 @@ impl App {
Task::none() Task::none()
} }
Message::Pan(dx, dy) => { Message::Pan(dx, dy) => {
// XXX: pan_offset is tracked but visual panning requires a custom // Update visual pan offset from canvas drag
// canvas widget or Iced's scrollable with exposed State. This will // Canvas sends raw screen pixel delta; keyboard sends 50 units per press
// be implemented in a future version, because I don't really have // Use raw values directly for natural mouse feel
// the energy to do so right now.
self.pan_offset.0 += dx as f32; self.pan_offset.0 += dx as f32;
self.pan_offset.1 += dy as f32; self.pan_offset.1 += dy as f32;
Task::none() Task::none()
} }
Message::CanvasWheelZoom {
factor,
cursor,
viewport_center,
} => {
// Handle wheel zoom from canvas - zoom is visual only. Zoom focuses
// around cursor position
let old_scale = self.scale;
let new_scale = (self.scale * factor).clamp(self.min_scale, self.max_scale);
// Use viewport center from canvas. NOT window center, because canvas may not fill window
// and instead go piss in my cereal. Fuck. FUCK.
if (new_scale - old_scale).abs() > f32::EPSILON {
// Calculate cursor offset from viewport center
let cursor_offset =
Point::new(cursor.x - viewport_center.x, cursor.y - viewport_center.y);
// Calculate pan adjustment for zoom-around-cursor
// Formula: pan_delta = cursor_offset * (1 - 1/factor) / old_scale
// This keeps the point under cursor stationary during zoom
let pan_delta = (
cursor_offset.x * (1.0 - 1.0 / factor) / old_scale,
cursor_offset.y * (1.0 - 1.0 / factor) / old_scale,
);
self.pan_offset.0 += pan_delta.0;
self.pan_offset.1 += pan_delta.1;
self.scale = new_scale;
self.zoom_percent = (new_scale * 100.0).round() as u32;
}
Task::none()
}
Message::RotateCW => { Message::RotateCW => {
self.rotation = (self.rotation + 90) % 360; self.rotation = (self.rotation + 90) % 360;
self.invalidate_cache(); self.invalidate_cache();