various: init ImageCanvas widget; improve pan/zoom functionality #1
7 changed files with 524 additions and 78 deletions
69
Cargo.lock
generated
69
Cargo.lock
generated
|
|
@ -1135,6 +1135,12 @@ dependencies = [
|
||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "float_next_after"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
|
|
@ -1603,6 +1609,7 @@ dependencies = [
|
||||||
"image",
|
"image",
|
||||||
"kamadak-exif",
|
"kamadak-exif",
|
||||||
"log",
|
"log",
|
||||||
|
"lyon_path",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"rustc-hash 2.1.2",
|
"rustc-hash 2.1.2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
|
@ -1677,6 +1684,7 @@ dependencies = [
|
||||||
"iced_debug",
|
"iced_debug",
|
||||||
"iced_graphics",
|
"iced_graphics",
|
||||||
"log",
|
"log",
|
||||||
|
"lyon",
|
||||||
"rustc-hash 2.1.2",
|
"rustc-hash 2.1.2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
|
|
@ -2080,6 +2088,7 @@ dependencies = [
|
||||||
"image",
|
"image",
|
||||||
"lychee-cli",
|
"lychee-cli",
|
||||||
"lychee-img",
|
"lychee-img",
|
||||||
|
"lychee-widgets",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2089,6 +2098,66 @@ dependencies = [
|
||||||
"image",
|
"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]]
|
[[package]]
|
||||||
name = "malloc_buf"
|
name = "malloc_buf"
|
||||||
version = "0.0.6"
|
version = "0.0.6"
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,14 @@ edition = "2024"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
clap = { version = "4.6.0", features = ["derive"] }
|
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"
|
image = "0.25.10"
|
||||||
|
|
||||||
lychee-cli = { path = "./crates/lychee-cli" }
|
lychee-cli = { path = "./crates/lychee-cli" }
|
||||||
lychee-img = { path = "./crates/lychee-img" }
|
lychee-img = { path = "./crates/lychee-img" }
|
||||||
lychee-core = { path = "./crates/lychee-core" }
|
lychee-core = { path = "./crates/lychee-core" }
|
||||||
|
lychee-widgets = { path = "./crates/lychee-widgets" }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = true
|
lto = true
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,4 @@ iced.workspace = true
|
||||||
image.workspace = true
|
image.workspace = true
|
||||||
lychee-cli.workspace = true
|
lychee-cli.workspace = true
|
||||||
lychee-img.workspace = true
|
lychee-img.workspace = true
|
||||||
|
lychee-widgets.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,23 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use iced::event;
|
use iced::event;
|
||||||
|
use iced::Point;
|
||||||
use iced::{
|
use iced::{
|
||||||
Alignment, Element, Length, Subscription, Task, keyboard,
|
keyboard,
|
||||||
time::{self, milliseconds},
|
time::{self, milliseconds},
|
||||||
widget::{column, container, row},
|
widget::{column, row},
|
||||||
window,
|
window, Element, Length, Subscription, Task,
|
||||||
};
|
};
|
||||||
use image::{GenericImageView, imageops::FilterType};
|
use image::GenericImageView;
|
||||||
use lychee_cli::{Args, Clap};
|
use lychee_cli::{Args, Clap};
|
||||||
use lychee_img::collect_paths;
|
use lychee_img::collect_paths;
|
||||||
|
use lychee_widgets::ImageCanvas;
|
||||||
|
|
||||||
use crate::keybindings::handle_key_event;
|
use crate::keybindings::handle_key_event;
|
||||||
|
|
||||||
|
type ImageCacheKey = (usize, u16, bool, bool);
|
||||||
|
type ImageCacheValue = (iced::widget::image::Handle, u32, u32);
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Message {
|
pub enum Message {
|
||||||
Next,
|
Next,
|
||||||
|
|
@ -25,6 +30,11 @@ pub enum Message {
|
||||||
FitWindow,
|
FitWindow,
|
||||||
ResetView,
|
ResetView,
|
||||||
Pan(i32, i32),
|
Pan(i32, i32),
|
||||||
|
CanvasWheelZoom {
|
||||||
|
factor: f32,
|
||||||
|
cursor: Point,
|
||||||
|
viewport_center: Point,
|
||||||
|
},
|
||||||
RotateCW,
|
RotateCW,
|
||||||
RotateCCW,
|
RotateCCW,
|
||||||
FlipHorizontal,
|
FlipHorizontal,
|
||||||
|
|
@ -42,12 +52,14 @@ pub struct App {
|
||||||
window_id: window::Id,
|
window_id: window::Id,
|
||||||
paths: Vec<String>,
|
paths: Vec<String>,
|
||||||
current: usize,
|
current: usize,
|
||||||
// Cache key: (index, rotation_degrees, flip_h, flip_v, zoom_percent)
|
// Cache key: (index, rotation_degrees, flip_h, flip_v)
|
||||||
image_cache: HashMap<(usize, u16, bool, bool, u32), iced::widget::image::Handle>,
|
// Cache value: (image_handle, width, height)
|
||||||
|
// NOTE: zoom is visual only (canvas transform), not in cache key
|
||||||
|
image_cache: HashMap<ImageCacheKey, ImageCacheValue>,
|
||||||
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)
|
// Zoom state
|
||||||
scale: f32,
|
scale: f32,
|
||||||
min_scale: f32,
|
min_scale: f32,
|
||||||
max_scale: f32,
|
max_scale: f32,
|
||||||
|
|
@ -107,28 +119,41 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn view(&self) -> Element<'_, Message> {
|
pub fn view(&self) -> Element<'_, Message> {
|
||||||
let image_content: Element<'_, Message> = match self.image_cache.get(&(
|
let image_content: Element<'_, Message> =
|
||||||
self.current,
|
match self
|
||||||
self.rotation,
|
.image_cache
|
||||||
self.flip_h,
|
.get(&(self.current, self.rotation, self.flip_h, self.flip_v))
|
||||||
self.flip_v,
|
{
|
||||||
self.zoom_percent,
|
Some((handle, width, height)) => {
|
||||||
)) {
|
// Create ImageCanvas with pan/zoom support
|
||||||
Some(handle) => {
|
// zoom_step = 1 + scale_step (e.g., 1.0 + 0.1 = 1.1 for 10% steps)
|
||||||
// Image displayed at actual pixel dimensions (Shrink)
|
let zoom_step = 1.0 + self.scale_step;
|
||||||
let img: iced::widget::Image<iced::widget::image::Handle> =
|
let canvas = ImageCanvas::new(
|
||||||
iced::widget::Image::new(handle.clone());
|
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
|
canvas.into_element().map(|msg| match msg {
|
||||||
container(img)
|
lychee_widgets::Message::Pan(dx, dy) => Message::Pan(dx as i32, dy as i32),
|
||||||
.width(Length::Fill)
|
lychee_widgets::Message::Zoom {
|
||||||
.height(Length::Fill)
|
factor,
|
||||||
.align_x(Alignment::Center)
|
cursor,
|
||||||
.align_y(Alignment::Center)
|
viewport_center,
|
||||||
.into()
|
} => Message::CanvasWheelZoom {
|
||||||
}
|
factor,
|
||||||
None => iced::widget::text("Loading...").into(),
|
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
|
// Main layout: image on top, statusbar at bottom
|
||||||
let statusbar: Element<'_, Message> = self.render_statusbar();
|
let statusbar: Element<'_, Message> = self.render_statusbar();
|
||||||
|
|
@ -183,25 +208,18 @@ impl App {
|
||||||
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;
|
||||||
let cache_key = (
|
let cache_key = (idx, self.rotation, self.flip_h, self.flip_v);
|
||||||
idx,
|
|
||||||
self.rotation,
|
|
||||||
self.flip_h,
|
|
||||||
self.flip_v,
|
|
||||||
self.zoom_percent,
|
|
||||||
);
|
|
||||||
if idx < self.paths.len() && !self.image_cache.contains_key(&cache_key) {
|
if idx < self.paths.len() && !self.image_cache.contains_key(&cache_key) {
|
||||||
// Extract path to avoid borrow conflict
|
// Extract path to avoid borrow conflict
|
||||||
let path = self.paths[idx].clone();
|
let path = self.paths[idx].clone();
|
||||||
let zoom = self.zoom_percent;
|
if let Some(handle_data) = self.load_image(&path) {
|
||||||
if let Some(handle) = self.load_image(&path, zoom) {
|
self.image_cache.insert(cache_key, handle_data);
|
||||||
self.image_cache.insert(cache_key, handle);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_image(&mut self, path: &str, zoom_percent: u32) -> Option<iced::widget::image::Handle> {
|
fn load_image(&mut self, path: &str) -> Option<(iced::widget::image::Handle, u32, u32)> {
|
||||||
let mut img = ::image::open(path).ok()?;
|
let mut img = ::image::open(path).ok()?;
|
||||||
|
|
||||||
// Apply rotation
|
// Apply rotation
|
||||||
|
|
@ -224,53 +242,33 @@ impl App {
|
||||||
let (orig_width, orig_height) = img.dimensions();
|
let (orig_width, orig_height) = img.dimensions();
|
||||||
self.current_image_size = (orig_width, orig_height);
|
self.current_image_size = (orig_width, orig_height);
|
||||||
|
|
||||||
// Apply zoom by resizing
|
// Load at native resolution - zoom is purely visual via canvas transform
|
||||||
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))
|
let handle = iced::widget::image::Handle::from_rgba(width, height, raw);
|
||||||
|
Some((handle, width, height))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn invalidate_cache(&mut self) {
|
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] {
|
for offset in [0isize, -1, 1] {
|
||||||
let idx = (self.current as isize + offset) as usize;
|
let idx = (self.current as isize + offset) as usize;
|
||||||
let key = (
|
let key = (idx, self.rotation, self.flip_h, self.flip_v);
|
||||||
idx,
|
|
||||||
self.rotation,
|
|
||||||
self.flip_h,
|
|
||||||
self.flip_v,
|
|
||||||
self.zoom_percent,
|
|
||||||
);
|
|
||||||
self.image_cache.remove(&key);
|
self.image_cache.remove(&key);
|
||||||
}
|
}
|
||||||
self.preload_adjacent();
|
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) {
|
fn ensure_current_loaded(&mut self) {
|
||||||
let key = (
|
let key = (self.current, self.rotation, self.flip_h, self.flip_v);
|
||||||
self.current,
|
|
||||||
self.rotation,
|
|
||||||
self.flip_h,
|
|
||||||
self.flip_v,
|
|
||||||
self.zoom_percent,
|
|
||||||
);
|
|
||||||
if !self.image_cache.contains_key(&key) {
|
if !self.image_cache.contains_key(&key) {
|
||||||
// Extract path to avoid borrow conflict
|
// Extract path to avoid borrow conflict
|
||||||
let path = self.paths[self.current].clone();
|
let path = self.paths[self.current].clone();
|
||||||
let zoom = self.zoom_percent;
|
if let Some(handle_data) = self.load_image(&path) {
|
||||||
if let Some(handle) = self.load_image(&path, zoom) {
|
self.image_cache.insert(key, handle_data);
|
||||||
self.image_cache.insert(key, handle);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -310,19 +308,21 @@ impl App {
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::ZoomIn => {
|
Message::ZoomIn => {
|
||||||
|
// Zoom is visual only via canvas transform - no cache invalidation
|
||||||
let new_percent =
|
let new_percent =
|
||||||
(self.zoom_percent as f32 * (1.0 + self.scale_step)).round() as u32;
|
(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.zoom_percent = new_percent.min((self.max_scale * 100.0) as u32);
|
||||||
self.scale = self.zoom_percent as f32 / 100.0;
|
self.scale = self.zoom_percent as f32 / 100.0;
|
||||||
self.invalidate_cache();
|
// No cache invalidation - image data unchanged
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::ZoomOut => {
|
Message::ZoomOut => {
|
||||||
|
// Zoom is visual only via canvas transform - no cache invalidation
|
||||||
let new_percent =
|
let new_percent =
|
||||||
(self.zoom_percent as f32 * (1.0 / (1.0 + self.scale_step))).round() as u32;
|
(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.zoom_percent = new_percent.max((self.min_scale * 100.0) as u32);
|
||||||
self.scale = self.zoom_percent as f32 / 100.0;
|
self.scale = self.zoom_percent as f32 / 100.0;
|
||||||
self.invalidate_cache();
|
// No cache invalidation - image data unchanged
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::ActualSize | Message::FitWindow | Message::ResetView => {
|
Message::ActualSize | Message::FitWindow | Message::ResetView => {
|
||||||
|
|
@ -356,14 +356,46 @@ impl App {
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::Pan(dx, dy) => {
|
Message::Pan(dx, dy) => {
|
||||||
// XXX: pan_offset is tracked but visual panning requires a custom
|
// Update visual pan offset from canvas drag
|
||||||
// canvas widget or Iced's scrollable with exposed State. This will
|
// Canvas sends raw screen pixel delta; keyboard sends 50 units per press
|
||||||
// be implemented in a future version, because I don't really have
|
// Use raw values directly for natural mouse feel
|
||||||
// the energy to do so right now.
|
|
||||||
self.pan_offset.0 += dx as f32;
|
self.pan_offset.0 += dx as f32;
|
||||||
self.pan_offset.1 += dy as f32;
|
self.pan_offset.1 += dy as f32;
|
||||||
Task::none()
|
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 => {
|
Message::RotateCW => {
|
||||||
self.rotation = (self.rotation + 90) % 360;
|
self.rotation = (self.rotation + 90) % 360;
|
||||||
self.invalidate_cache();
|
self.invalidate_cache();
|
||||||
|
|
|
||||||
8
crates/lychee-widgets/Cargo.toml
Normal file
8
crates/lychee-widgets/Cargo.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
[package]
|
||||||
|
name = "lychee-widgets"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
iced.workspace = true
|
||||||
|
iced_graphics.workspace = true
|
||||||
331
crates/lychee-widgets/src/canvas.rs
Normal file
331
crates/lychee-widgets/src/canvas.rs
Normal file
|
|
@ -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<Message> for ImageCanvas {
|
||||||
|
type State = CanvasInteraction;
|
||||||
|
|
||||||
|
fn update(
|
||||||
|
&self,
|
||||||
|
interaction: &mut CanvasInteraction,
|
||||||
|
event: &Event,
|
||||||
|
bounds: Rectangle,
|
||||||
|
cursor: Cursor,
|
||||||
|
) -> Option<canvas::Action<Message>> {
|
||||||
|
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<Geometry> {
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
crates/lychee-widgets/src/lib.rs
Normal file
3
crates/lychee-widgets/src/lib.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
mod canvas;
|
||||||
|
|
||||||
|
pub use canvas::{ImageCanvas, Message};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue