//! 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>, /// The handle that iced renders (after rotation applied). pub handle: Option>, /// 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, 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() } }