lychee-core: add image zoom, rotate, and flip transforms
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I5c48a422d7ff0c4fa0ff8ae0c34a514e6a6a6964
This commit is contained in:
parent
0fc6b177cc
commit
977684d587
2 changed files with 293 additions and 35 deletions
|
|
@ -1,12 +1,13 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use iced::event;
|
||||||
use iced::{
|
use iced::{
|
||||||
ContentFit, Element, Subscription, Task, keyboard,
|
Alignment, Element, Length, Subscription, Task, keyboard,
|
||||||
time::{self, milliseconds},
|
time::{self, milliseconds},
|
||||||
widget::image::viewer,
|
widget::{column, container, row},
|
||||||
window,
|
window,
|
||||||
};
|
};
|
||||||
use image::GenericImageView;
|
use image::{GenericImageView, imageops::FilterType};
|
||||||
use lychee_cli::{Args, Clap};
|
use lychee_cli::{Args, Clap};
|
||||||
use lychee_img::collect_paths;
|
use lychee_img::collect_paths;
|
||||||
|
|
||||||
|
|
@ -22,11 +23,18 @@ pub enum Message {
|
||||||
ZoomOut,
|
ZoomOut,
|
||||||
ActualSize,
|
ActualSize,
|
||||||
FitWindow,
|
FitWindow,
|
||||||
Reset,
|
ResetView,
|
||||||
|
Pan(i32, i32),
|
||||||
|
RotateCW,
|
||||||
|
RotateCCW,
|
||||||
|
FlipHorizontal,
|
||||||
|
FlipVertical,
|
||||||
|
ResetTransform,
|
||||||
ToggleFullscreen,
|
ToggleFullscreen,
|
||||||
ToggleSlideshow,
|
ToggleSlideshow,
|
||||||
SlideshowTick,
|
SlideshowTick,
|
||||||
AdjustSlideshowDelay(i64),
|
AdjustSlideshowDelay(i64),
|
||||||
|
WindowResized(u32, u32),
|
||||||
Close,
|
Close,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,11 +42,26 @@ pub struct App {
|
||||||
window_id: window::Id,
|
window_id: window::Id,
|
||||||
paths: Vec<String>,
|
paths: Vec<String>,
|
||||||
current: usize,
|
current: usize,
|
||||||
image_cache: HashMap<usize, iced::widget::image::Handle>,
|
// Cache key: (index, rotation_degrees, flip_h, flip_v, zoom_percent)
|
||||||
fit_to_window: bool,
|
image_cache: HashMap<(usize, u16, bool, bool, u32), iced::widget::image::Handle>,
|
||||||
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)
|
||||||
|
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 {
|
impl App {
|
||||||
|
|
@ -47,17 +70,33 @@ impl App {
|
||||||
let paths = collect_paths(args.paths);
|
let paths = collect_paths(args.paths);
|
||||||
let window_id = window::Id::unique();
|
let window_id = window::Id::unique();
|
||||||
|
|
||||||
let app = Self {
|
let mut app = Self {
|
||||||
window_id,
|
window_id,
|
||||||
paths,
|
paths,
|
||||||
current: 0,
|
current: 0,
|
||||||
image_cache: HashMap::new(),
|
image_cache: HashMap::new(),
|
||||||
fit_to_window: true,
|
|
||||||
fullscreen: args.fullscreen,
|
fullscreen: args.fullscreen,
|
||||||
slideshow_active: false,
|
slideshow_active: false,
|
||||||
slideshow_interval: args.slideshow,
|
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 {
|
let task = if args.fullscreen {
|
||||||
window::maximize(window_id, true)
|
window::maximize(window_id, true)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -68,20 +107,46 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn view(&self) -> Element<'_, Message> {
|
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) => {
|
Some(handle) => {
|
||||||
let v = viewer(handle.clone())
|
// Image displayed at actual pixel dimensions (Shrink)
|
||||||
.content_fit(if self.fit_to_window {
|
let img: iced::widget::Image<iced::widget::image::Handle> =
|
||||||
ContentFit::Contain
|
iced::widget::Image::new(handle.clone());
|
||||||
} else {
|
|
||||||
ContentFit::Cover
|
|
||||||
})
|
|
||||||
.padding(20);
|
|
||||||
v.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
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<Message> {
|
pub fn subscription(&self) -> Subscription<Message> {
|
||||||
|
|
@ -101,30 +166,120 @@ impl App {
|
||||||
let close = window::close_events().map(|_| Message::Close);
|
let close = window::close_events().map(|_| Message::Close);
|
||||||
let slideshow = time::every(milliseconds(self.slideshow_interval * 1000))
|
let slideshow = time::every(milliseconds(self.slideshow_interval * 1000))
|
||||||
.map(|_| Message::SlideshowTick);
|
.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) {
|
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;
|
||||||
if idx < self.paths.len()
|
let cache_key = (
|
||||||
&& !self.image_cache.contains_key(&idx)
|
idx,
|
||||||
&& let Some(handle) = self.load_image(&self.paths[idx])
|
self.rotation,
|
||||||
{
|
self.flip_h,
|
||||||
self.image_cache.insert(idx, handle);
|
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<iced::widget::image::Handle> {
|
fn load_image(&mut self, path: &str, zoom_percent: u32) -> Option<iced::widget::image::Handle> {
|
||||||
let img = ::image::open(path).ok()?;
|
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 (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))
|
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<Message> {
|
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||||
|
// 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 {
|
match message {
|
||||||
Message::Next => {
|
Message::Next => {
|
||||||
if self.current < self.paths.len() - 1 {
|
if self.current < self.paths.len() - 1 {
|
||||||
|
|
@ -154,11 +309,91 @@ impl App {
|
||||||
}
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::ZoomIn
|
Message::ZoomIn => {
|
||||||
| Message::ZoomOut
|
let new_percent =
|
||||||
| Message::ActualSize
|
(self.zoom_percent as f32 * (1.0 + self.scale_step)).round() as u32;
|
||||||
| Message::FitWindow
|
self.zoom_percent = new_percent.min((self.max_scale * 100.0) as u32);
|
||||||
| Message::Reset => Task::none(),
|
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 => {
|
Message::ToggleFullscreen => {
|
||||||
self.fullscreen = !self.fullscreen;
|
self.fullscreen = !self.fullscreen;
|
||||||
window::maximize(self.window_id, self.fullscreen)
|
window::maximize(self.window_id, self.fullscreen)
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,31 @@ use crate::Message;
|
||||||
pub fn handle_key_event(
|
pub fn handle_key_event(
|
||||||
key: &keyboard::Key,
|
key: &keyboard::Key,
|
||||||
_ctrl: bool,
|
_ctrl: bool,
|
||||||
_shift: bool,
|
shift: bool,
|
||||||
empty_mods: bool,
|
empty_mods: bool,
|
||||||
) -> Option<Message> {
|
) -> Option<Message> {
|
||||||
|
// 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 {
|
if !empty_mods {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
@ -15,12 +37,12 @@ pub fn handle_key_event(
|
||||||
match key {
|
match key {
|
||||||
keyboard::Key::Character(c) => match c.as_str() {
|
keyboard::Key::Character(c) => match c.as_str() {
|
||||||
"q" => Some(Message::Close),
|
"q" => Some(Message::Close),
|
||||||
"f" => Some(Message::ToggleFullscreen),
|
"f" => Some(Message::FlipHorizontal),
|
||||||
"+" | "=" => Some(Message::ZoomIn),
|
"+" | "=" => Some(Message::ZoomIn),
|
||||||
"-" => Some(Message::ZoomOut),
|
"-" => Some(Message::ZoomOut),
|
||||||
"1" => Some(Message::ActualSize),
|
"1" => Some(Message::ActualSize),
|
||||||
"0" => Some(Message::FitWindow),
|
"0" => Some(Message::FitWindow),
|
||||||
"r" => Some(Message::Reset),
|
"r" => Some(Message::RotateCW),
|
||||||
" " => Some(Message::ToggleSlideshow),
|
" " => Some(Message::ToggleSlideshow),
|
||||||
"t" => Some(Message::AdjustSlideshowDelay(1)),
|
"t" => Some(Message::AdjustSlideshowDelay(1)),
|
||||||
"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::ArrowUp => Some(Message::ZoomIn),
|
||||||
keyboard::key::Named::ArrowDown => Some(Message::ZoomOut),
|
keyboard::key::Named::ArrowDown => Some(Message::ZoomOut),
|
||||||
keyboard::key::Named::Escape => Some(Message::Close),
|
keyboard::key::Named::Escape => Some(Message::Close),
|
||||||
|
keyboard::key::Named::F11 => Some(Message::ToggleFullscreen),
|
||||||
_ => None,
|
_ => None,
|
||||||
},
|
},
|
||||||
_ => None,
|
_ => None,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue