From add40c39f8627e952d8d1ab466a1463e44fb69bf Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 19 Apr 2026 23:09:48 +0300 Subject: [PATCH] lychee-core: use `ImageCanvas` for visual pan/zoom; fix panning delta calculation Signed-off-by: NotAShelf Change-Id: I8ac7479803ab70149d6ffadf897ef33d6a6a6964 --- Cargo.lock | 69 +++++++++++++ Cargo.toml | 4 +- crates/lychee-core/Cargo.toml | 1 + crates/lychee-core/src/app.rs | 184 ++++++++++++++++++++-------------- 4 files changed, 181 insertions(+), 77 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e568d3e..7393ec1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1135,6 +1135,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + [[package]] name = "foldhash" version = "0.1.5" @@ -1603,6 +1609,7 @@ dependencies = [ "image", "kamadak-exif", "log", + "lyon_path", "raw-window-handle", "rustc-hash 2.1.2", "thiserror 2.0.18", @@ -1677,6 +1684,7 @@ dependencies = [ "iced_debug", "iced_graphics", "log", + "lyon", "rustc-hash 2.1.2", "thiserror 2.0.18", "wgpu", @@ -2080,6 +2088,7 @@ dependencies = [ "image", "lychee-cli", "lychee-img", + "lychee-widgets", ] [[package]] @@ -2089,6 +2098,66 @@ dependencies = [ "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]] name = "malloc_buf" version = "0.0.6" diff --git a/Cargo.toml b/Cargo.toml index 33a2cfe..90fdec7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,12 +8,14 @@ edition = "2024" [workspace.dependencies] 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" lychee-cli = { path = "./crates/lychee-cli" } lychee-img = { path = "./crates/lychee-img" } lychee-core = { path = "./crates/lychee-core" } +lychee-widgets = { path = "./crates/lychee-widgets" } [profile.release] lto = true diff --git a/crates/lychee-core/Cargo.toml b/crates/lychee-core/Cargo.toml index 3cd5d29..0081fd1 100644 --- a/crates/lychee-core/Cargo.toml +++ b/crates/lychee-core/Cargo.toml @@ -9,3 +9,4 @@ iced.workspace = true image.workspace = true lychee-cli.workspace = true lychee-img.workspace = true +lychee-widgets.workspace = true diff --git a/crates/lychee-core/src/app.rs b/crates/lychee-core/src/app.rs index e818751..72e586c 100644 --- a/crates/lychee-core/src/app.rs +++ b/crates/lychee-core/src/app.rs @@ -1,18 +1,23 @@ use std::collections::HashMap; use iced::event; +use iced::Point; use iced::{ - Alignment, Element, Length, Subscription, Task, keyboard, + keyboard, time::{self, milliseconds}, - widget::{column, container, row}, - window, + widget::{column, row}, + window, Element, Length, Subscription, Task, }; -use image::{GenericImageView, imageops::FilterType}; +use image::GenericImageView; use lychee_cli::{Args, Clap}; use lychee_img::collect_paths; +use lychee_widgets::ImageCanvas; use crate::keybindings::handle_key_event; +type ImageCacheKey = (usize, u16, bool, bool); +type ImageCacheValue = (iced::widget::image::Handle, u32, u32); + #[derive(Debug, Clone)] pub enum Message { Next, @@ -25,6 +30,11 @@ pub enum Message { FitWindow, ResetView, Pan(i32, i32), + CanvasWheelZoom { + factor: f32, + cursor: Point, + viewport_center: Point, + }, RotateCW, RotateCCW, FlipHorizontal, @@ -42,12 +52,14 @@ pub struct App { window_id: window::Id, paths: Vec, current: usize, - // Cache key: (index, rotation_degrees, flip_h, flip_v, zoom_percent) - image_cache: HashMap<(usize, u16, bool, bool, u32), iced::widget::image::Handle>, + // Cache key: (index, rotation_degrees, flip_h, flip_v) + // Cache value: (image_handle, width, height) + // NOTE: zoom is visual only (canvas transform), not in cache key + image_cache: HashMap, fullscreen: bool, slideshow_active: bool, slideshow_interval: u64, - // Zoom state (scale is display value, zoom_percent is cache key) + // Zoom state scale: f32, min_scale: f32, max_scale: f32, @@ -107,28 +119,41 @@ impl App { } pub fn view(&self) -> Element<'_, Message> { - let image_content: Element<'_, Message> = match self.image_cache.get(&( - self.current, - self.rotation, - self.flip_h, - self.flip_v, - self.zoom_percent, - )) { - Some(handle) => { - // Image displayed at actual pixel dimensions (Shrink) - let img: iced::widget::Image = - iced::widget::Image::new(handle.clone()); + let image_content: Element<'_, Message> = + match self + .image_cache + .get(&(self.current, self.rotation, self.flip_h, self.flip_v)) + { + Some((handle, width, height)) => { + // Create ImageCanvas with pan/zoom support + // zoom_step = 1 + scale_step (e.g., 1.0 + 0.1 = 1.1 for 10% steps) + let zoom_step = 1.0 + self.scale_step; + let canvas = ImageCanvas::new( + 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 - container(img) - .width(Length::Fill) - .height(Length::Fill) - .align_x(Alignment::Center) - .align_y(Alignment::Center) - .into() - } - None => iced::widget::text("Loading...").into(), - }; + canvas.into_element().map(|msg| match msg { + lychee_widgets::Message::Pan(dx, dy) => Message::Pan(dx as i32, dy as i32), + lychee_widgets::Message::Zoom { + factor, + cursor, + viewport_center, + } => Message::CanvasWheelZoom { + factor, + 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 let statusbar: Element<'_, Message> = self.render_statusbar(); @@ -183,25 +208,18 @@ impl App { fn preload_adjacent(&mut self) { for offset in [0isize, -1, 1] { let idx = (self.current as isize + offset) as usize; - let cache_key = ( - idx, - self.rotation, - self.flip_h, - self.flip_v, - self.zoom_percent, - ); + let cache_key = (idx, self.rotation, self.flip_h, self.flip_v); if idx < self.paths.len() && !self.image_cache.contains_key(&cache_key) { // Extract path to avoid borrow conflict let path = self.paths[idx].clone(); - let zoom = self.zoom_percent; - if let Some(handle) = self.load_image(&path, zoom) { - self.image_cache.insert(cache_key, handle); + if let Some(handle_data) = self.load_image(&path) { + self.image_cache.insert(cache_key, handle_data); } } } } - fn load_image(&mut self, path: &str, zoom_percent: u32) -> Option { + fn load_image(&mut self, path: &str) -> Option<(iced::widget::image::Handle, u32, u32)> { let mut img = ::image::open(path).ok()?; // Apply rotation @@ -224,53 +242,33 @@ impl App { let (orig_width, orig_height) = img.dimensions(); self.current_image_size = (orig_width, orig_height); - // Apply zoom by resizing - 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 - }; - + // Load at native resolution - zoom is purely visual via canvas transform let (width, height) = img.dimensions(); 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) { - // 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] { let idx = (self.current as isize + offset) as usize; - let key = ( - idx, - self.rotation, - self.flip_h, - self.flip_v, - self.zoom_percent, - ); + let key = (idx, self.rotation, self.flip_h, self.flip_v); self.image_cache.remove(&key); } 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) { - let key = ( - self.current, - self.rotation, - self.flip_h, - self.flip_v, - self.zoom_percent, - ); + let key = (self.current, self.rotation, self.flip_h, self.flip_v); if !self.image_cache.contains_key(&key) { // Extract path to avoid borrow conflict let path = self.paths[self.current].clone(); - let zoom = self.zoom_percent; - if let Some(handle) = self.load_image(&path, zoom) { - self.image_cache.insert(key, handle); + if let Some(handle_data) = self.load_image(&path) { + self.image_cache.insert(key, handle_data); } } } @@ -310,19 +308,21 @@ impl App { Task::none() } Message::ZoomIn => { + // Zoom is visual only via canvas transform - no cache invalidation let new_percent = (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.scale = self.zoom_percent as f32 / 100.0; - self.invalidate_cache(); + // No cache invalidation - image data unchanged Task::none() } Message::ZoomOut => { + // Zoom is visual only via canvas transform - no cache invalidation let new_percent = (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.scale = self.zoom_percent as f32 / 100.0; - self.invalidate_cache(); + // No cache invalidation - image data unchanged Task::none() } Message::ActualSize | Message::FitWindow | Message::ResetView => { @@ -356,14 +356,46 @@ impl App { Task::none() } Message::Pan(dx, dy) => { - // XXX: pan_offset is tracked but visual panning requires a custom - // canvas widget or Iced's scrollable with exposed State. This will - // be implemented in a future version, because I don't really have - // the energy to do so right now. + // Update visual pan offset from canvas drag + // Canvas sends raw screen pixel delta; keyboard sends 50 units per press + // Use raw values directly for natural mouse feel self.pan_offset.0 += dx as f32; self.pan_offset.1 += dy as f32; 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 => { self.rotation = (self.rotation + 90) % 360; self.invalidate_cache();