From 977684d5872cf4dbf2541dab25ba398851276f85 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 6 Apr 2026 13:36:44 +0300 Subject: [PATCH] lychee-core: add image zoom, rotate, and flip transforms Signed-off-by: NotAShelf Change-Id: I5c48a422d7ff0c4fa0ff8ae0c34a514e6a6a6964 --- crates/lychee-core/src/app.rs | 301 +++++++++++++++++++++++--- crates/lychee-core/src/keybindings.rs | 29 ++- 2 files changed, 294 insertions(+), 36 deletions(-) diff --git a/crates/lychee-core/src/app.rs b/crates/lychee-core/src/app.rs index 9aafdd7..e818751 100644 --- a/crates/lychee-core/src/app.rs +++ b/crates/lychee-core/src/app.rs @@ -1,12 +1,13 @@ use std::collections::HashMap; +use iced::event; use iced::{ - ContentFit, Element, Subscription, Task, keyboard, + Alignment, Element, Length, Subscription, Task, keyboard, time::{self, milliseconds}, - widget::image::viewer, + widget::{column, container, row}, window, }; -use image::GenericImageView; +use image::{GenericImageView, imageops::FilterType}; use lychee_cli::{Args, Clap}; use lychee_img::collect_paths; @@ -22,11 +23,18 @@ pub enum Message { ZoomOut, ActualSize, FitWindow, - Reset, + ResetView, + Pan(i32, i32), + RotateCW, + RotateCCW, + FlipHorizontal, + FlipVertical, + ResetTransform, ToggleFullscreen, ToggleSlideshow, SlideshowTick, AdjustSlideshowDelay(i64), + WindowResized(u32, u32), Close, } @@ -34,11 +42,26 @@ pub struct App { window_id: window::Id, paths: Vec, current: usize, - image_cache: HashMap, - fit_to_window: bool, + // 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 (scale is display value, zoom_percent is cache key) + scale: f32, + min_scale: f32, + max_scale: f32, + scale_step: f32, + zoom_percent: u32, + pan_offset: (f32, f32), + // Transform state + rotation: u16, + flip_h: bool, + flip_v: bool, + // Window dimensions for fit-to-window calculation + window_size: (u32, u32), + // Original image dimensions for fit-to-window calculation + current_image_size: (u32, u32), } impl App { @@ -47,17 +70,33 @@ impl App { let paths = collect_paths(args.paths); let window_id = window::Id::unique(); - let app = Self { + let mut app = Self { window_id, paths, current: 0, image_cache: HashMap::new(), - fit_to_window: true, fullscreen: args.fullscreen, slideshow_active: false, slideshow_interval: args.slideshow, + // Zoom state + scale: 1.0, + min_scale: 0.1, + max_scale: 10.0, + scale_step: 0.1, + zoom_percent: 100, + pan_offset: (0.0, 0.0), + // Transform state + rotation: 0, + flip_h: false, + flip_v: false, + // Window and image dimensions + window_size: (800, 600), // Default, will be updated by resize events + current_image_size: (0, 0), }; + // Preload initial images into cache + app.preload_adjacent(); + let task = if args.fullscreen { window::maximize(window_id, true) } else { @@ -68,20 +107,46 @@ impl App { } pub fn view(&self) -> Element<'_, Message> { - match self.image_cache.get(&self.current) { + 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) => { - let v = viewer(handle.clone()) - .content_fit(if self.fit_to_window { - ContentFit::Contain - } else { - ContentFit::Cover - }) - .padding(20); - v.into() - } + // Image displayed at actual pixel dimensions (Shrink) + let img: iced::widget::Image = + iced::widget::Image::new(handle.clone()); - None => iced::widget::text("No image loaded").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(); + + column![image_content, statusbar].into() + } + + fn render_statusbar(&self) -> Element<'_, Message> { + let zoom_text = format!("{:.*}%", 0, self.scale * 100.0); + let index_text = format!("{}/{}", self.current + 1, self.paths.len()); + + row![ + iced::widget::text(zoom_text).size(14), + iced::widget::text(index_text).size(14), + ] + .padding(8) + .width(Length::Fill) + .into() } pub fn subscription(&self) -> Subscription { @@ -101,30 +166,120 @@ impl App { let close = window::close_events().map(|_| Message::Close); let slideshow = time::every(milliseconds(self.slideshow_interval * 1000)) .map(|_| Message::SlideshowTick); + let resize = event::listen().filter_map(|event| { + if let iced::Event::Window(window::Event::Resized(size)) = event { + Some(Message::WindowResized( + size.width as u32, + size.height as u32, + )) + } else { + None + } + }); - Subscription::batch(vec![hotkey, close, slideshow]) + Subscription::batch(vec![hotkey, close, slideshow, resize]) } fn preload_adjacent(&mut self) { for offset in [0isize, -1, 1] { let idx = (self.current as isize + offset) as usize; - if idx < self.paths.len() - && !self.image_cache.contains_key(&idx) - && let Some(handle) = self.load_image(&self.paths[idx]) - { - self.image_cache.insert(idx, handle); + 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(); + let zoom = self.zoom_percent; + if let Some(handle) = self.load_image(&path, zoom) { + self.image_cache.insert(cache_key, handle); + } } } } - fn load_image(&self, path: &str) -> Option { - let img = ::image::open(path).ok()?; + fn load_image(&mut self, path: &str, zoom_percent: u32) -> Option { + let mut img = ::image::open(path).ok()?; + + // Apply rotation + match self.rotation { + 90 => img = img.rotate90(), + 180 => img = img.rotate180(), + 270 => img = img.rotate270(), + _ => {} + } + + // Apply flips + if self.flip_h { + img = img.fliph(); + } + if self.flip_v { + img = img.flipv(); + } + + // Track original image dimensions for fit-to-window calculation + 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 + }; + let (width, height) = img.dimensions(); let raw = img.to_rgba8().into_raw(); Some(iced::widget::image::Handle::from_rgba(width, height, raw)) } + fn invalidate_cache(&mut self) { + // 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, + 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. + fn ensure_current_loaded(&mut self) { + 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(); + let zoom = self.zoom_percent; + if let Some(handle) = self.load_image(&path, zoom) { + self.image_cache.insert(key, handle); + } + } + } + pub fn update(&mut self, message: Message) -> Task { + // Ensure current image is loaded; this handles edge cases where + // cache miss occurred and preload hasn't triggered yet + self.ensure_current_loaded(); + match message { Message::Next => { if self.current < self.paths.len() - 1 { @@ -154,11 +309,91 @@ impl App { } Task::none() } - Message::ZoomIn - | Message::ZoomOut - | Message::ActualSize - | Message::FitWindow - | Message::Reset => Task::none(), + Message::ZoomIn => { + 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(); + Task::none() + } + Message::ZoomOut => { + 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(); + Task::none() + } + Message::ActualSize | Message::FitWindow | Message::ResetView => { + let fit_zoom = if self.current_image_size.0 > 0 && self.current_image_size.1 > 0 { + // Calculate zoom to fit image within window, preserving aspect ratio + let window_ratio = (self.window_size.0 as f32) / (self.window_size.1 as f32); + let image_ratio = + (self.current_image_size.0 as f32) / (self.current_image_size.1 as f32); + + if window_ratio > image_ratio { + // Window is wider than image, fit by height + ((self.window_size.1 as f32) / (self.current_image_size.1 as f32) * 100.0) + as u32 + } else { + // Window is taller than image, fit by width + ((self.window_size.0 as f32) / (self.current_image_size.0 as f32) * 100.0) + as u32 + } + } else { + 100 // default to 100% if no image dimensions available + }; + + self.zoom_percent = fit_zoom.min((self.max_scale * 100.0) as u32); + self.scale = self.zoom_percent as f32 / 100.0; + self.pan_offset = (0.0, 0.0); + self.invalidate_cache(); + Task::none() + } + Message::WindowResized(width, height) => { + self.window_size = (width, height); + 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. + self.pan_offset.0 += dx as f32; + self.pan_offset.1 += dy as f32; + Task::none() + } + Message::RotateCW => { + self.rotation = (self.rotation + 90) % 360; + self.invalidate_cache(); + Task::none() + } + Message::RotateCCW => { + self.rotation = (self.rotation + 270) % 360; + self.invalidate_cache(); + Task::none() + } + Message::FlipHorizontal => { + self.flip_h = !self.flip_h; + self.invalidate_cache(); + Task::none() + } + Message::FlipVertical => { + self.flip_v = !self.flip_v; + self.invalidate_cache(); + Task::none() + } + Message::ResetTransform => { + self.scale = 1.0; + self.zoom_percent = 100; + self.pan_offset = (0.0, 0.0); + self.rotation = 0; + self.flip_h = false; + self.flip_v = false; + self.invalidate_cache(); + Task::none() + } Message::ToggleFullscreen => { self.fullscreen = !self.fullscreen; window::maximize(self.window_id, self.fullscreen) diff --git a/crates/lychee-core/src/keybindings.rs b/crates/lychee-core/src/keybindings.rs index caeb936..a187dc5 100644 --- a/crates/lychee-core/src/keybindings.rs +++ b/crates/lychee-core/src/keybindings.rs @@ -5,9 +5,31 @@ use crate::Message; pub fn handle_key_event( key: &keyboard::Key, _ctrl: bool, - _shift: bool, + shift: bool, empty_mods: bool, ) -> Option { + // Handle shift-modified keys first + if shift && empty_mods { + if let keyboard::Key::Character(c) = key { + match c.as_str() { + "R" => return Some(Message::RotateCCW), + "F" => return Some(Message::FlipVertical), + "0" => return Some(Message::ResetTransform), + _ => {} + } + } + if let keyboard::Key::Named(n) = key { + match n { + keyboard::key::Named::ArrowRight => return Some(Message::Pan(50, 0)), + keyboard::key::Named::ArrowLeft => return Some(Message::Pan(-50, 0)), + keyboard::key::Named::ArrowUp => return Some(Message::Pan(0, -50)), + keyboard::key::Named::ArrowDown => return Some(Message::Pan(0, 50)), + _ => {} + } + } + } + + // Block keys with other modifiers (ctrl, alt, meta) if !empty_mods { return None; } @@ -15,12 +37,12 @@ pub fn handle_key_event( match key { keyboard::Key::Character(c) => match c.as_str() { "q" => Some(Message::Close), - "f" => Some(Message::ToggleFullscreen), + "f" => Some(Message::FlipHorizontal), "+" | "=" => Some(Message::ZoomIn), "-" => Some(Message::ZoomOut), "1" => Some(Message::ActualSize), "0" => Some(Message::FitWindow), - "r" => Some(Message::Reset), + "r" => Some(Message::RotateCW), " " => Some(Message::ToggleSlideshow), "t" => Some(Message::AdjustSlideshowDelay(1)), "T" => Some(Message::AdjustSlideshowDelay(-1)), @@ -40,6 +62,7 @@ pub fn handle_key_event( keyboard::key::Named::ArrowUp => Some(Message::ZoomIn), keyboard::key::Named::ArrowDown => Some(Message::ZoomOut), keyboard::key::Named::Escape => Some(Message::Close), + keyboard::key::Named::F11 => Some(Message::ToggleFullscreen), _ => None, }, _ => None,