marten/src/ui/image_view.rs

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()
}
}