diff --git a/Cargo.lock b/Cargo.lock index 7393ec1..e568d3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1135,12 +1135,6 @@ 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" @@ -1609,7 +1603,6 @@ dependencies = [ "image", "kamadak-exif", "log", - "lyon_path", "raw-window-handle", "rustc-hash 2.1.2", "thiserror 2.0.18", @@ -1684,7 +1677,6 @@ dependencies = [ "iced_debug", "iced_graphics", "log", - "lyon", "rustc-hash 2.1.2", "thiserror 2.0.18", "wgpu", @@ -2088,7 +2080,6 @@ dependencies = [ "image", "lychee-cli", "lychee-img", - "lychee-widgets", ] [[package]] @@ -2098,66 +2089,6 @@ 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 90fdec7..33a2cfe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,14 +8,12 @@ edition = "2024" [workspace.dependencies] clap = { version = "4.6.0", features = ["derive"] } -iced = { version = "0.14.0", features = ["image", "wayland", "smol", "canvas"] } -iced_graphics = "0.14.0" +iced = { version = "0.14.0", features = ["image", "wayland", "smol"] } 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 0081fd1..3cd5d29 100644 --- a/crates/lychee-core/Cargo.toml +++ b/crates/lychee-core/Cargo.toml @@ -9,4 +9,3 @@ 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 72e586c..e818751 100644 --- a/crates/lychee-core/src/app.rs +++ b/crates/lychee-core/src/app.rs @@ -1,23 +1,18 @@ use std::collections::HashMap; use iced::event; -use iced::Point; use iced::{ - keyboard, + Alignment, Element, Length, Subscription, Task, keyboard, time::{self, milliseconds}, - widget::{column, row}, - window, Element, Length, Subscription, Task, + widget::{column, container, row}, + window, }; -use image::GenericImageView; +use image::{GenericImageView, imageops::FilterType}; 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, @@ -30,11 +25,6 @@ pub enum Message { FitWindow, ResetView, Pan(i32, i32), - CanvasWheelZoom { - factor: f32, - cursor: Point, - viewport_center: Point, - }, RotateCW, RotateCCW, FlipHorizontal, @@ -52,14 +42,12 @@ pub struct App { window_id: window::Id, paths: Vec, current: usize, - // 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, + // Cache key: (index, rotation_degrees, flip_h, flip_v, zoom_percent) + image_cache: HashMap<(usize, u16, bool, bool, u32), iced::widget::image::Handle>, fullscreen: bool, slideshow_active: bool, slideshow_interval: u64, - // Zoom state + // Zoom state (scale is display value, zoom_percent is cache key) scale: f32, min_scale: f32, max_scale: f32, @@ -119,41 +107,28 @@ 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)) - { - 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, - ); + 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()); - 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(), - }; + // 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(), + }; // Main layout: image on top, statusbar at bottom let statusbar: Element<'_, Message> = self.render_statusbar(); @@ -208,18 +183,25 @@ 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); + let cache_key = ( + idx, + self.rotation, + self.flip_h, + self.flip_v, + self.zoom_percent, + ); if idx < self.paths.len() && !self.image_cache.contains_key(&cache_key) { // Extract path to avoid borrow conflict let path = self.paths[idx].clone(); - if let Some(handle_data) = self.load_image(&path) { - self.image_cache.insert(cache_key, handle_data); + let zoom = self.zoom_percent; + if let Some(handle) = self.load_image(&path, zoom) { + self.image_cache.insert(cache_key, handle); } } } } - fn load_image(&mut self, path: &str) -> Option<(iced::widget::image::Handle, u32, u32)> { + fn load_image(&mut self, path: &str, zoom_percent: u32) -> Option { let mut img = ::image::open(path).ok()?; // Apply rotation @@ -242,33 +224,53 @@ impl App { let (orig_width, orig_height) = img.dimensions(); self.current_image_size = (orig_width, orig_height); - // Load at native resolution - zoom is purely visual via canvas transform + // 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 + }; + let (width, height) = img.dimensions(); let raw = img.to_rgba8().into_raw(); - let handle = iced::widget::image::Handle::from_rgba(width, height, raw); - Some((handle, width, height)) + Some(iced::widget::image::Handle::from_rgba(width, height, raw)) } fn invalidate_cache(&mut self) { - // Invalidate current and adjacent images when transform changes. Zoom is actually - // visual only, so it does not invalidate cache. + // Invalidate current and adjacent images at current zoom level 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); + let key = ( + idx, + self.rotation, + self.flip_h, + self.flip_v, + self.zoom_percent, + ); 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); + let key = ( + self.current, + self.rotation, + self.flip_h, + self.flip_v, + self.zoom_percent, + ); if !self.image_cache.contains_key(&key) { // Extract path to avoid borrow conflict let path = self.paths[self.current].clone(); - if let Some(handle_data) = self.load_image(&path) { - self.image_cache.insert(key, handle_data); + let zoom = self.zoom_percent; + if let Some(handle) = self.load_image(&path, zoom) { + self.image_cache.insert(key, handle); } } } @@ -308,21 +310,19 @@ 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; - // No cache invalidation - image data unchanged + self.invalidate_cache(); 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; - // No cache invalidation - image data unchanged + self.invalidate_cache(); Task::none() } Message::ActualSize | Message::FitWindow | Message::ResetView => { @@ -356,46 +356,14 @@ impl App { Task::none() } Message::Pan(dx, dy) => { - // 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 + // 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. 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(); diff --git a/crates/lychee-widgets/Cargo.toml b/crates/lychee-widgets/Cargo.toml deleted file mode 100644 index 7a8a801..0000000 --- a/crates/lychee-widgets/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[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 deleted file mode 100644 index 5ee4172..0000000 --- a/crates/lychee-widgets/src/canvas.rs +++ /dev/null @@ -1,331 +0,0 @@ -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 deleted file mode 100644 index 3be61af..0000000 --- a/crates/lychee-widgets/src/lib.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod canvas; - -pub use canvas::{ImageCanvas, Message};