From dd03d5117b1232c80fbd27dba65f34bacec062de Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 6 Apr 2026 18:18:19 +0300 Subject: [PATCH 1/2] lychee-widgets: add `ImageCanvas` widget for pan/zoom rendering Signed-off-by: NotAShelf Change-Id: I266d89773f0d0f9279e53f2a78d981606a6a6964 --- crates/lychee-widgets/Cargo.toml | 8 + crates/lychee-widgets/src/canvas.rs | 331 ++++++++++++++++++++++++++++ crates/lychee-widgets/src/lib.rs | 3 + 3 files changed, 342 insertions(+) create mode 100644 crates/lychee-widgets/Cargo.toml create mode 100644 crates/lychee-widgets/src/canvas.rs create mode 100644 crates/lychee-widgets/src/lib.rs diff --git a/crates/lychee-widgets/Cargo.toml b/crates/lychee-widgets/Cargo.toml new file mode 100644 index 0000000..7a8a801 --- /dev/null +++ b/crates/lychee-widgets/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "lychee-widgets" +version.workspace = true +edition.workspace = true + +[dependencies] +iced.workspace = true +iced_graphics.workspace = true \ No newline at end of file diff --git a/crates/lychee-widgets/src/canvas.rs b/crates/lychee-widgets/src/canvas.rs new file mode 100644 index 0000000..5ee4172 --- /dev/null +++ b/crates/lychee-widgets/src/canvas.rs @@ -0,0 +1,331 @@ +use iced::mouse::{self, Cursor, Interaction}; +use iced::widget::canvas::{self, Geometry}; +use iced::widget::canvas::{Cache, Canvas}; +use iced::{Element, Length, Point, Rectangle, Renderer, Size, Theme, Vector}; +use iced_graphics::geometry::Image as GraphicsImage; + +use iced::Event; +use iced::widget::image::Handle; + +/// Message types emitted by [`ImageCanvas`]. +/// +/// These are used to communicate user interactions to the application. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Message { + /// Pan by the given delta (dx, dy) in screen pixels. + Pan(f32, f32), + /// Wheel zoom request with cursor and viewport center for zoom-around-cursor. + Zoom { + factor: f32, + cursor: Point, + viewport_center: Point, + }, + /// Zoom in. + ZoomIn, + /// Zoom out. + ZoomOut, +} + +/// Interaction state for the canvas. +/// +/// Tracks the current drag state for panning and visual zoom during drag. +#[derive(Debug, Clone)] +pub struct CanvasInteraction { + /// Current visual pan offset during interaction. + pan_offset: Point, + /// Current visual zoom scale during interaction. + zoom_scale: f32, + /// Whether we're currently dragging. + is_dragging: bool, + /// Cursor position when drag started. + drag_start: Point, + /// Pan offset when drag started. + initial_pan: Point, +} + +impl Default for CanvasInteraction { + fn default() -> Self { + Self { + pan_offset: Point::ORIGIN, + zoom_scale: 1.0, + is_dragging: false, + drag_start: Point::ORIGIN, + initial_pan: Point::ORIGIN, + } + } +} + +/// A pan/zoom capable canvas widget for image viewing. +/// +/// This widget renders an image and provides visual pan and zoom capabilities +/// through mouse interaction: +/// +/// - **Mouse drag**: Pan the view +/// - **Scroll wheel**: Zoom in/out (publishes message for App to handle) +/// +/// The widget is designed to be integrated with an App that manages the +/// authoritative state. Mouse events during drag update visual state for +/// smoothness, but publish messages to sync with App state when interaction ends. +#[derive(Debug)] +pub struct ImageCanvas { + /// The image data handle. + handle: Handle, + /// Image dimensions in pixels. + width: u32, + height: u32, + /// Authoritative zoom scale from App. + zoom_scale: f32, + /// Authoritative pan offset from App. + pan_offset: Point, + /// Minimum zoom scale. + min_zoom: f32, + /// Maximum zoom scale. + max_zoom: f32, + /// Zoom step multiplier per scroll tick. + zoom_step: f32, + /// Geometry cache for rendering. + cache: Cache, +} + +impl ImageCanvas { + /// Create a new [`ImageCanvas`] from an image handle and dimensions. + /// + /// # Arguments + /// * `handle` - The image handle + /// * `width` - Image width in pixels + /// * `height` - Image height in pixels + /// * `zoom_scale` - Initial zoom scale (1.0 = 100%) + /// * `pan_offset` - Initial pan offset + /// * `zoom_step` - Zoom multiplier per scroll tick (e.g., 1.1 for 10% steps) + pub fn new( + handle: Handle, + width: u32, + height: u32, + zoom_scale: f32, + pan_offset: Point, + zoom_step: f32, + ) -> Self { + Self { + handle, + width, + height, + zoom_scale, + pan_offset, + min_zoom: 0.1, // 10% + max_zoom: 10.0, // 1000% + zoom_step, + cache: Cache::default(), + } + } + + /// Apply a zoom factor to the given pan offset, returning adjusted values. + /// + /// Used by App to calculate new pan when zoom changes. + pub fn adjust_pan_for_zoom(pan: Point, old_scale: f32, new_scale: f32, cursor: Point) -> Point { + if (new_scale - old_scale).abs() < f32::EPSILON { + return pan; + } + + let scale_delta = new_scale / old_scale; + Point::new( + cursor.x - (cursor.x - pan.x) * scale_delta, + cursor.y - (cursor.y - pan.y) * scale_delta, + ) + } + + /// Clamp pan offset based on zoom level and viewport size. + /// + /// Returns the clamped pan offset that keeps the image reasonably visible. + pub fn clamp_pan(pan: Point, zoom_scale: f32, viewport: Size) -> Point { + // At scale 1.0, no panning needed (image fills viewport) + // At scale > 1.0, allow panning to see offscreen parts + // At scale < 1.0, limit to keep image centered + let max_pan_x = if zoom_scale > 1.0 { + (zoom_scale - 1.0) * viewport.width / (2.0 * zoom_scale) + } else { + (1.0 - zoom_scale) * viewport.width / 2.0 + }; + let max_pan_y = if zoom_scale > 1.0 { + (zoom_scale - 1.0) * viewport.height / (2.0 * zoom_scale) + } else { + (1.0 - zoom_scale) * viewport.height / 2.0 + }; + + Point::new( + pan.x.clamp(-max_pan_x, max_pan_x), + pan.y.clamp(-max_pan_y, max_pan_y), + ) + } + + /// Convert to an Iced element for embedding in UI. + pub fn into_element(self) -> Element<'static, Message> { + Canvas::new(self) + .width(Length::Fill) + .height(Length::Fill) + .into() + } +} + +impl canvas::Program for ImageCanvas { + type State = CanvasInteraction; + + fn update( + &self, + interaction: &mut CanvasInteraction, + event: &Event, + bounds: Rectangle, + cursor: Cursor, + ) -> Option> { + let cursor_position = cursor.position_in(bounds)?; + + match event { + Event::Mouse(mouse_event) => match mouse_event { + mouse::Event::ButtonPressed(mouse::Button::Left) => { + // Start panning - initialize interaction state from App state + interaction.pan_offset = self.pan_offset; + interaction.zoom_scale = self.zoom_scale; + interaction.is_dragging = true; + interaction.drag_start = cursor_position; + interaction.initial_pan = self.pan_offset; + Some(canvas::Action::capture()) + } + mouse::Event::ButtonReleased(mouse::Button::Left) => { + if interaction.is_dragging { + interaction.is_dragging = false; + // Publish final pan to sync with App + let dx = interaction.pan_offset.x - self.pan_offset.x; + let dy = interaction.pan_offset.y - self.pan_offset.y; + if dx.abs() > 0.1 || dy.abs() > 0.1 { + return Some(canvas::Action::publish(Message::Pan(dx, dy))); + } + } + None + } + mouse::Event::CursorMoved { .. } => { + if interaction.is_dragging { + // Calculate delta from drag start position + let dx = cursor_position.x - interaction.drag_start.x; + let dy = cursor_position.y - interaction.drag_start.y; + + // Update visual pan offset from initial pan + interaction.pan_offset = Point::new( + interaction.initial_pan.x + dx / interaction.zoom_scale, + interaction.initial_pan.y + dy / interaction.zoom_scale, + ); + + return Some(canvas::Action::request_redraw()); + } + None + } + mouse::Event::WheelScrolled { delta } => { + // Publish zoom message for App to handle + // This ensures App state stays in sync + let factor = match delta { + mouse::ScrollDelta::Lines { y, .. } + | mouse::ScrollDelta::Pixels { y, .. } => { + if *y > 0.0 { + self.zoom_step + } else if *y < 0.0 { + 1.0 / self.zoom_step + } else { + 1.0 + } + } + }; + + if (factor - 1.0).abs() > f32::EPSILON { + // Clamp to valid range + let new_scale = + (self.zoom_scale * factor).clamp(self.min_zoom, self.max_zoom); + if (new_scale - self.zoom_scale).abs() > f32::EPSILON { + // Publish actual factor that produced the clamped value + let clamped_factor = new_scale / self.zoom_scale; + // Calculate viewport center from bounds + let viewport_center = + Point::new(bounds.width / 2.0, bounds.height / 2.0); + // Pass cursor position and viewport center for zoom-around-cursor + return Some(canvas::Action::publish(Message::Zoom { + factor: clamped_factor, + cursor: cursor_position, + viewport_center, + })); + } + } + None + } + _ => None, + }, + _ => None, + } + } + + fn draw( + &self, + interaction: &CanvasInteraction, + renderer: &Renderer, + _theme: &Theme, + bounds: Rectangle, + _cursor: Cursor, + ) -> Vec { + // Use interaction state if dragging, otherwise use App state + let pan_offset = if interaction.is_dragging { + interaction.pan_offset + } else { + self.pan_offset + }; + + let zoom_scale = if interaction.is_dragging { + interaction.zoom_scale + } else { + self.zoom_scale + }; + + let center = Point::new(bounds.width / 2.0, bounds.height / 2.0); + + // Draw the image using the cache + let geometry = self.cache.draw(renderer, bounds.size(), |frame| { + frame.with_save(|frame| { + // Move to center of viewport + frame.translate(Vector::new(center.x, center.y)); + + // Apply zoom (around center) + frame.scale(zoom_scale); + + // Apply pan (already in zoomed coordinates) + frame.translate(Vector::new(pan_offset.x, pan_offset.y)); + + // Center image at origin + frame.translate(Vector::new( + -(self.width as f32) / 2.0, + -(self.height as f32) / 2.0, + )); + + // Draw the image + // Note: We draw at origin since we already translated to center + let image = GraphicsImage::new(&self.handle); + frame.draw_image( + Rectangle::new( + Point::ORIGIN, + Size::new(self.width as f32, self.height as f32), + ), + image, + ); + }); + }); + + vec![geometry] + } + + fn mouse_interaction( + &self, + interaction: &CanvasInteraction, + _bounds: Rectangle, + _cursor: Cursor, + ) -> Interaction { + if interaction.is_dragging { + Interaction::Grabbing + } else { + Interaction::Grab + } + } +} diff --git a/crates/lychee-widgets/src/lib.rs b/crates/lychee-widgets/src/lib.rs new file mode 100644 index 0000000..3be61af --- /dev/null +++ b/crates/lychee-widgets/src/lib.rs @@ -0,0 +1,3 @@ +mod canvas; + +pub use canvas::{ImageCanvas, Message}; From add40c39f8627e952d8d1ab466a1463e44fb69bf Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 19 Apr 2026 23:09:48 +0300 Subject: [PATCH 2/2] 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();