246 lines
8.0 KiB
Rust
246 lines
8.0 KiB
Rust
//! The central image display — fit-to-window, zoom, pan, and rotation.
|
|
//!
|
|
//! Rendering strategy:
|
|
//! - `FitToWindow`: image widget is `Length::Fill` + `ContentFit::Contain`.
|
|
//! iced handles all centering and scaling automatically. No manual math.
|
|
//! - `ActualSize` / `CustomZoom`: image is `Length::Fixed(w)` + `Length::Fixed(h)`,
|
|
//! wrapped in a `scrollable` so overflow is handled with scrollbars/drag.
|
|
//! Scroll wheel is consumed by the scrollable (pan) instead of navigating.
|
|
//!
|
|
//! Rotation is applied at decode time by pre-rotating the RGBA buffer. This
|
|
//! works around iced 0.13's lack of native image rotation.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use iced::widget::{container, image, mouse_area, scrollable, text};
|
|
use iced::{Element, Length, Size};
|
|
|
|
use super::theme;
|
|
use crate::codec::{DecodedImage, rotate_rgba};
|
|
|
|
/// The zoom mode the image view is currently in.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub enum ZoomMode {
|
|
FitToWindow,
|
|
ActualSize,
|
|
Custom(f32),
|
|
}
|
|
|
|
impl ZoomMode {
|
|
pub fn factor(&self, viewport: Size, image_size: Size) -> f32 {
|
|
match self {
|
|
ZoomMode::FitToWindow => {
|
|
if image_size.width <= 0.0 || image_size.height <= 0.0 {
|
|
return 1.0;
|
|
}
|
|
let sx = viewport.width / image_size.width;
|
|
let sy = viewport.height / image_size.height;
|
|
sx.min(sy).min(1.0)
|
|
}
|
|
ZoomMode::ActualSize => 1.0,
|
|
ZoomMode::Custom(f) => *f,
|
|
}
|
|
}
|
|
|
|
pub fn pct(&self) -> u32 {
|
|
match self {
|
|
ZoomMode::FitToWindow => 0,
|
|
ZoomMode::ActualSize => 100,
|
|
ZoomMode::Custom(f) => (f * 100.0).round() as u32,
|
|
}
|
|
}
|
|
|
|
pub fn is_fit(&self) -> bool {
|
|
matches!(self, ZoomMode::FitToWindow)
|
|
}
|
|
}
|
|
|
|
/// Messages emitted by the image view.
|
|
#[derive(Debug, Clone)]
|
|
pub enum ImageMessage {
|
|
/// User right-clicked (position comes from cursor subscription).
|
|
RightClicked,
|
|
}
|
|
|
|
/// State for the image view.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ImageView {
|
|
/// The ORIGINAL decoded image (before rotation).
|
|
pub original: Option<Arc<DecodedImage>>,
|
|
/// The handle that iced renders (after rotation applied).
|
|
pub handle: Option<Arc<image::Handle>>,
|
|
/// Current zoom mode.
|
|
pub zoom: ZoomMode,
|
|
/// Rotation in degrees (0, 90, 180, 270).
|
|
pub rotation: i32,
|
|
}
|
|
|
|
impl Default for ImageView {
|
|
fn default() -> Self {
|
|
Self {
|
|
original: None,
|
|
handle: None,
|
|
zoom: ZoomMode::FitToWindow,
|
|
rotation: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ImageView {
|
|
/// Set the current image. Resets zoom to the given default mode and pan.
|
|
/// Keeps rotation.
|
|
pub fn set_image(&mut self, img: Arc<DecodedImage>, default_zoom: ZoomMode) {
|
|
self.original = Some(img);
|
|
self.zoom = default_zoom;
|
|
self.rebuild_handle();
|
|
}
|
|
|
|
pub fn clear(&mut self) {
|
|
self.original = None;
|
|
self.handle = None;
|
|
self.zoom = ZoomMode::FitToWindow;
|
|
}
|
|
|
|
/// Rebuild the iced handle from the original image + current rotation.
|
|
fn rebuild_handle(&mut self) {
|
|
let Some(img) = &self.original else {
|
|
return;
|
|
};
|
|
let (pixels, w, h) =
|
|
rotate_rgba(&img.pixels, img.width, img.height, self.rotation);
|
|
self.handle = Some(Arc::new(image::Handle::from_rgba(w, h, pixels)));
|
|
}
|
|
|
|
pub fn zoom_in(&mut self) {
|
|
self.zoom = match self.zoom {
|
|
ZoomMode::FitToWindow => ZoomMode::Custom(1.1),
|
|
ZoomMode::ActualSize => ZoomMode::Custom(1.1),
|
|
ZoomMode::Custom(f) => ZoomMode::Custom((f * 1.1).min(16.0)),
|
|
};
|
|
}
|
|
|
|
pub fn zoom_out(&mut self) {
|
|
self.zoom = match self.zoom {
|
|
ZoomMode::FitToWindow => ZoomMode::Custom(0.9),
|
|
ZoomMode::ActualSize => ZoomMode::Custom(0.9),
|
|
ZoomMode::Custom(f) => ZoomMode::Custom((f * 0.9).max(0.05)),
|
|
};
|
|
}
|
|
|
|
pub fn fit_to_window(&mut self) {
|
|
self.zoom = ZoomMode::FitToWindow;
|
|
}
|
|
|
|
pub fn actual_size(&mut self) {
|
|
self.zoom = ZoomMode::ActualSize;
|
|
}
|
|
|
|
pub fn rotate_cw(&mut self) {
|
|
self.rotation = (self.rotation + 90) % 360;
|
|
self.rebuild_handle();
|
|
}
|
|
|
|
pub fn rotate_ccw(&mut self) {
|
|
self.rotation = (self.rotation + 270) % 360;
|
|
self.rebuild_handle();
|
|
}
|
|
|
|
/// The displayed image dimensions (after rotation).
|
|
pub fn displayed_dimensions(&self) -> Option<(u32, u32)> {
|
|
let img = self.original.as_ref()?;
|
|
if self.rotation % 180 == 0 {
|
|
Some((img.width, img.height))
|
|
} else {
|
|
Some((img.height, img.width))
|
|
}
|
|
}
|
|
|
|
/// Render the image view.
|
|
/// `viewport` is the available size for the image area (already minus chrome).
|
|
pub fn view(&self, _viewport: Size) -> Element<'_, ImageMessage> {
|
|
// Guard: no image loaded → render the empty-state placeholder.
|
|
let (handle, img) = match (&self.handle, &self.original) {
|
|
(Some(h), Some(i)) => (h, i),
|
|
_ => return self.view_empty(),
|
|
};
|
|
|
|
let (iw, ih) = if self.rotation % 180 == 0 {
|
|
(img.width as f32, img.height as f32)
|
|
} else {
|
|
(img.height as f32, img.width as f32)
|
|
};
|
|
|
|
let image_element: Element<'_, ImageMessage> = match self.zoom {
|
|
ZoomMode::FitToWindow => {
|
|
container(
|
|
image(handle.as_ref().clone())
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.content_fit(iced::ContentFit::Contain),
|
|
)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.into()
|
|
}
|
|
ZoomMode::ActualSize | ZoomMode::Custom(_) => {
|
|
let factor = self.zoom.factor(
|
|
Size::new(f32::MAX, f32::MAX),
|
|
Size::new(iw, ih),
|
|
);
|
|
let dw = iw * factor;
|
|
let dh = ih * factor;
|
|
|
|
// The image widget is the direct child of the scrollable
|
|
// (no intermediate Fill container, which collapses inside
|
|
// scrollable and hides the image). ContentFit::Contain scales
|
|
// the image to fill the Fixed box while preserving aspect
|
|
// ratio — since dw/dh matches iw/ih, the image fills exactly.
|
|
scrollable(
|
|
image(handle.as_ref().clone())
|
|
.width(Length::Fixed(dw))
|
|
.height(Length::Fixed(dh))
|
|
.content_fit(iced::ContentFit::Contain),
|
|
)
|
|
.direction(scrollable::Direction::Both {
|
|
horizontal: scrollable::Scrollbar::new().width(6).scroller_width(6),
|
|
vertical: scrollable::Scrollbar::new().width(6).scroller_width(6),
|
|
})
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.into()
|
|
}
|
|
};
|
|
|
|
// Wrap in mouse_area to capture right-clicks for context menu.
|
|
let with_mouse = mouse_area(image_element)
|
|
.on_right_press(ImageMessage::RightClicked);
|
|
|
|
container(with_mouse)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.style(|_| container::Style {
|
|
background: Some(theme::BG_IMAGE_AREA.into()),
|
|
..Default::default()
|
|
})
|
|
.into()
|
|
}
|
|
|
|
/// Empty-state placeholder shown before any folder is opened.
|
|
fn view_empty(&self) -> Element<'_, ImageMessage> {
|
|
container(
|
|
text("No image — press O to open a folder")
|
|
.color(theme::TEXT_SECONDARY)
|
|
.size(16),
|
|
)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.align_x(iced::Alignment::Center)
|
|
.align_y(iced::Alignment::Center)
|
|
.style(|_| container::Style {
|
|
background: Some(theme::BG_IMAGE_AREA.into()),
|
|
..Default::default()
|
|
})
|
|
.into()
|
|
}
|
|
}
|