diff --git a/src/app/message.rs b/src/app/message.rs index 0154b11..443243c 100644 --- a/src/app/message.rs +++ b/src/app/message.rs @@ -37,6 +37,8 @@ pub enum AppMessage { ZoomReset, /// Fit document to window. ZoomFit, + /// Update zoom and pan from viewer (mouse interaction). + ViewerStateChanged { scale: f32, offset_x: f32, offset_y: f32 }, // === Pan === /// Pan image left. diff --git a/src/app/update.rs b/src/app/update.rs index 8f259f9..8ec2b56 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -37,6 +37,12 @@ pub fn update(model: &mut AppModel, msg: AppMessage) { model.view_mode = ViewMode::Fit; model.reset_pan(); } + AppMessage::ViewerStateChanged { scale, offset_x, offset_y } => { + // Update model state from viewer (mouse interaction) + model.view_mode = ViewMode::Custom(scale); + model.pan_x = offset_x; + model.pan_y = offset_y; + } // ===== Pan control (Ctrl + arrow keys) =========================================== AppMessage::PanLeft => { diff --git a/src/app/view/canvas.rs b/src/app/view/canvas.rs index e49107c..39f4e32 100644 --- a/src/app/view/canvas.rs +++ b/src/app/view/canvas.rs @@ -1,12 +1,13 @@ // SPDX-License-Identifier: GPL-3.0-or-later // src/app/view/canvas.rs // -// Renders the center canvas area with the current document. +// Render the center canvas area with the current document. -use cosmic::iced::{Alignment, Length}; -use cosmic::widget::{container, image, text, Column, Row}; +use cosmic::iced::{ContentFit, Length}; +use cosmic::widget::{container, text}; use cosmic::Element; +use super::image_viewer::Viewer; use crate::app::model::ViewMode; use crate::app::{AppMessage, AppModel}; use crate::fl; @@ -16,51 +17,40 @@ pub fn view(model: &AppModel) -> Element<'_, AppMessage> { if let Some(doc) = &model.document { let handle = doc.handle(); - let img_widget = match &model.view_mode { - ViewMode::Fit => { - // Fit mode: image scales to fill container while preserving aspect ratio. - image::Image::new(handle) - .width(Length::Fill) - .height(Length::Fill) - } - ViewMode::ActualSize => { - // 1:1 pixel size. - let (native_w, native_h) = doc.dimensions(); - image::Image::new(handle) - .width(Length::Fixed(native_w as f32)) - .height(Length::Fixed(native_h as f32)) - } - ViewMode::Custom(zoom) => { - // Custom zoom factor applied to native size. - let (native_w, native_h) = doc.dimensions(); - let scaled_w = (native_w as f32 * zoom).round(); - let scaled_h = (native_h as f32 * zoom).round(); - image::Image::new(handle) - .width(Length::Fixed(scaled_w)) - .height(Length::Fixed(scaled_h)) - } + // Determine zoom scale and content fit based on view mode + let (scale, content_fit) = match model.view_mode { + ViewMode::Fit => (1.0, ContentFit::Contain), + ViewMode::ActualSize => (1.0, ContentFit::None), + ViewMode::Custom(z) => (z, ContentFit::None), }; - // Center the image both horizontally and vertically. - Column::new() + // Use our forked viewer with external state control + let img_viewer = Viewer::new(handle) + .with_state(scale, model.pan_x, model.pan_y) + .on_state_change(|scale, offset_x, offset_y| { + AppMessage::ViewerStateChanged { + scale, + offset_x, + offset_y, + } + }) + .width(Length::Fill) + .height(Length::Fill) + .content_fit(content_fit) + .min_scale(0.1) + .max_scale(20.0) + .scale_step(0.1); + + container(img_viewer) .width(Length::Fill) .height(Length::Fill) - .align_x(Alignment::Center) - .push( - Row::new() - .push(img_widget) - .width(Length::Fill) - .height(Length::Fill) - .align_y(Alignment::Center), - ) .into() } else { - // No document loaded placeholder. + // Placeholder when no document is loaded container(text(fl!("no-document"))) - .center_x(Length::Fill) - .center_y(Length::Fill) .width(Length::Fill) .height(Length::Fill) + .center(Length::Fill) .into() } } diff --git a/src/app/view/image_viewer.rs b/src/app/view/image_viewer.rs new file mode 100644 index 0000000..1a69519 --- /dev/null +++ b/src/app/view/image_viewer.rs @@ -0,0 +1,526 @@ +//! Zoom and pan on an image. +//! Forked from cosmic::iced to support external state control. + +use cosmic::iced::advanced::widget::{self, tree::{self, Tree}, Widget}; +use cosmic::iced::advanced::{ + Clipboard, Layout, Shell, + layout, renderer, image as img_renderer, +}; +use cosmic::iced::event::{self, Event}; +use cosmic::iced::mouse; +use cosmic::iced::widget::image::{self, FilterMethod, Handle}; +use cosmic::iced::{ + ContentFit, Element, Length, Pixels, Point, + Radians, Rectangle, Size, Vector, +}; + +/// A frame that displays an image with the ability to zoom in/out and pan. +#[allow(missing_debug_implementations)] +pub struct Viewer { + padding: f32, + width: Length, + height: Length, + min_scale: f32, + max_scale: f32, + scale_step: f32, + handle: Handle, + filter_method: FilterMethod, + content_fit: ContentFit, + /// Optional external state to override internal state + external_state: Option<(f32, Vector)>, // (scale, offset) + /// Optional callback to notify state changes + on_state_change: Option Message>>, +} + +impl Viewer { + /// Creates a new [`Viewer`] with the given [`State`]. + pub fn new>(handle: T) -> Self { + Viewer { + handle: handle.into(), + padding: 0.0, + width: Length::Shrink, + height: Length::Shrink, + min_scale: 0.25, + max_scale: 10.0, + scale_step: 0.10, + filter_method: FilterMethod::default(), + content_fit: ContentFit::default(), + external_state: None, + on_state_change: None, + } + } + + /// Set external state to control zoom and pan from outside. + /// This allows keyboard/button controls to override the internal state. + pub fn with_state(mut self, scale: f32, offset_x: f32, offset_y: f32) -> Self { + self.external_state = Some((scale, Vector::new(offset_x, offset_y))); + self + } + + /// Set a callback to be notified when the state changes (for mouse interaction). + pub fn on_state_change(mut self, f: F) -> Self + where + F: 'static + Fn(f32, f32, f32) -> Message, + { + self.on_state_change = Some(Box::new(f)); + self + } + + /// Sets the [`FilterMethod`] of the [`Viewer`]. + pub fn filter_method(mut self, filter_method: image::FilterMethod) -> Self { + self.filter_method = filter_method; + self + } + + /// Sets the [`ContentFit`] of the [`Viewer`]. + pub fn content_fit(mut self, content_fit: ContentFit) -> Self { + self.content_fit = content_fit; + self + } + + /// Sets the padding of the [`Viewer`]. + pub fn padding(mut self, padding: impl Into) -> Self { + self.padding = padding.into().0; + self + } + + /// Sets the width of the [`Viewer`]. + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + /// Sets the height of the [`Viewer`]. + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } + + /// Sets the max scale applied to the image of the [`Viewer`]. + /// + /// Default is `10.0` + pub fn max_scale(mut self, max_scale: f32) -> Self { + self.max_scale = max_scale; + self + } + + /// Sets the min scale applied to the image of the [`Viewer`]. + /// + /// Default is `0.25` + pub fn min_scale(mut self, min_scale: f32) -> Self { + self.min_scale = min_scale; + self + } + + /// Sets the percentage the image of the [`Viewer`] will be scaled by + /// when zoomed in / out. + /// + /// Default is `0.10` + pub fn scale_step(mut self, scale_step: f32) -> Self { + self.scale_step = scale_step; + self + } +} + +impl Widget + for Viewer +where + Renderer: img_renderer::Renderer, + Handle: Clone, + Message: Clone, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + let mut state = State::new(); + // Apply external state if provided + if let Some((scale, offset)) = self.external_state { + state.scale = scale; + state.current_offset = offset; + state.starting_offset = offset; + } + tree::State::new(state) + } + + fn diff(&mut self, tree: &mut Tree) { + // Only update state if external state changed and user is not interacting + if let Some((scale, offset)) = self.external_state { + let state = tree.state.downcast_mut::(); + + // Only apply external state if user is not currently dragging + if !state.is_cursor_grabbed() { + // Check if external state differs from current state + let scale_changed = (state.scale - scale).abs() > 0.001; + let offset_changed = (state.current_offset.x - offset.x).abs() > 0.1 + || (state.current_offset.y - offset.y).abs() > 0.1; + + if scale_changed || offset_changed { + state.scale = scale; + state.current_offset = offset; + state.starting_offset = offset; + } + } + } + } + + fn size(&self) -> Size { + Size { + width: self.width, + height: self.height, + } + } + + fn layout( + &self, + _tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + // The raw w/h of the underlying image + let image_size = renderer.measure_image(&self.handle); + let image_size = + Size::new(image_size.width as f32, image_size.height as f32); + + // The size to be available to the widget prior to `Shrink`ing + let raw_size = limits.resolve(self.width, self.height, image_size); + + // The uncropped size of the image when fit to the bounds above + let full_size = self.content_fit.fit(image_size, raw_size); + + // Shrink the widget to fit the resized image, if requested + let final_size = Size { + width: match self.width { + Length::Shrink => f32::min(raw_size.width, full_size.width), + _ => raw_size.width, + }, + height: match self.height { + Length::Shrink => f32::min(raw_size.height, full_size.height), + _ => raw_size.height, + }, + }; + + layout::Node::new(final_size) + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + _viewport: &Rectangle, + ) -> event::Status { + let bounds = layout.bounds(); + + match event { + Event::Mouse(mouse::Event::WheelScrolled { delta }) => { + let Some(cursor_position) = cursor.position_over(bounds) else { + return event::Status::Ignored; + }; + + match delta { + mouse::ScrollDelta::Lines { y, .. } + | mouse::ScrollDelta::Pixels { y, .. } => { + let state = tree.state.downcast_mut::(); + let previous_scale = state.scale; + + if y < 0.0 && previous_scale > self.min_scale + || y > 0.0 && previous_scale < self.max_scale + { + state.scale = (if y > 0.0 { + state.scale * (1.0 + self.scale_step) + } else { + state.scale / (1.0 + self.scale_step) + }) + .clamp(self.min_scale, self.max_scale); + + let scaled_size = scaled_image_size( + renderer, + &self.handle, + state, + bounds.size(), + self.content_fit, + ); + + let factor = state.scale / previous_scale - 1.0; + + let cursor_to_center = + cursor_position - bounds.center(); + + let adjustment = cursor_to_center * factor + + state.current_offset * factor; + + state.current_offset = Vector::new( + if scaled_size.width > bounds.width { + state.current_offset.x + adjustment.x + } else { + 0.0 + }, + if scaled_size.height > bounds.height { + state.current_offset.y + adjustment.y + } else { + 0.0 + }, + ); + + // Notify state change + if let Some(ref on_change) = self.on_state_change { + shell.publish(on_change( + state.scale, + state.current_offset.x, + state.current_offset.y, + )); + } + } + } + } + + event::Status::Captured + } + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + let Some(cursor_position) = cursor.position_over(bounds) else { + return event::Status::Ignored; + }; + + let state = tree.state.downcast_mut::(); + + state.cursor_grabbed_at = Some(cursor_position); + state.starting_offset = state.current_offset; + + event::Status::Captured + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + let state = tree.state.downcast_mut::(); + + if state.cursor_grabbed_at.is_some() { + state.cursor_grabbed_at = None; + + event::Status::Captured + } else { + event::Status::Ignored + } + } + Event::Mouse(mouse::Event::CursorMoved { position }) => { + let state = tree.state.downcast_mut::(); + + if let Some(origin) = state.cursor_grabbed_at { + let scaled_size = scaled_image_size( + renderer, + &self.handle, + state, + bounds.size(), + self.content_fit, + ); + let hidden_width = (scaled_size.width - bounds.width / 2.0) + .max(0.0) + .round(); + + let hidden_height = (scaled_size.height + - bounds.height / 2.0) + .max(0.0) + .round(); + + let delta = position - origin; + + let x = if bounds.width < scaled_size.width { + (state.starting_offset.x - delta.x) + .clamp(-hidden_width, hidden_width) + } else { + 0.0 + }; + + let y = if bounds.height < scaled_size.height { + (state.starting_offset.y - delta.y) + .clamp(-hidden_height, hidden_height) + } else { + 0.0 + }; + + state.current_offset = Vector::new(x, y); + + // Notify state change on pan + if let Some(ref on_change) = self.on_state_change { + shell.publish(on_change( + state.scale, + state.current_offset.x, + state.current_offset.y, + )); + } + + event::Status::Captured + } else { + event::Status::Ignored + } + } + _ => event::Status::Ignored, + } + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + let state = tree.state.downcast_ref::(); + let bounds = layout.bounds(); + let is_mouse_over = cursor.is_over(bounds); + + if state.is_cursor_grabbed() { + mouse::Interaction::Grabbing + } else if is_mouse_over { + mouse::Interaction::Grab + } else { + mouse::Interaction::None + } + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + _theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + _cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + let state = tree.state.downcast_ref::(); + let bounds = layout.bounds(); + + let final_size = scaled_image_size( + renderer, + &self.handle, + state, + bounds.size(), + self.content_fit, + ); + + let translation = { + let diff_w = bounds.width - final_size.width; + let diff_h = bounds.height - final_size.height; + + let image_top_left = match self.content_fit { + ContentFit::None => { + Vector::new(diff_w.max(0.0) / 2.0, diff_h.max(0.0) / 2.0) + } + _ => Vector::new(diff_w / 2.0, diff_h / 2.0), + }; + + image_top_left - state.offset(bounds, final_size) + }; + + let drawing_bounds = Rectangle::new(bounds.position(), final_size); + + let render = |renderer: &mut Renderer| { + renderer.with_translation(translation, |renderer| { + renderer.draw_image( + self.handle.clone(), + self.filter_method, + drawing_bounds, + Radians(0.0), + 1.0, + [0.0; 4], + ); + }); + }; + + renderer.with_layer(bounds, render); + } +} + +/// The local state of a [`Viewer`]. +#[derive(Debug, Clone, Copy)] +pub struct State { + scale: f32, + starting_offset: Vector, + current_offset: Vector, + cursor_grabbed_at: Option, +} + +impl Default for State { + fn default() -> Self { + Self { + scale: 1.0, + starting_offset: Vector::default(), + current_offset: Vector::default(), + cursor_grabbed_at: None, + } + } +} + +impl State { + /// Creates a new [`State`]. + pub fn new() -> Self { + State::default() + } + + /// Returns the current offset of the [`State`], given the bounds + /// of the [`Viewer`] and its image. + fn offset(&self, bounds: Rectangle, image_size: Size) -> Vector { + let hidden_width = + (image_size.width - bounds.width / 2.0).max(0.0).round(); + + let hidden_height = + (image_size.height - bounds.height / 2.0).max(0.0).round(); + + Vector::new( + self.current_offset.x.clamp(-hidden_width, hidden_width), + self.current_offset.y.clamp(-hidden_height, hidden_height), + ) + } + + /// Returns if the cursor is currently grabbed by the [`Viewer`]. + pub fn is_cursor_grabbed(&self) -> bool { + self.cursor_grabbed_at.is_some() + } +} + +impl<'a, Message, Theme, Renderer, Handle> From> + for Element<'a, Message, Theme, Renderer> +where + Renderer: 'a + img_renderer::Renderer, + Message: 'a + Clone, + Handle: Clone + 'a, +{ + fn from(viewer: Viewer) -> Element<'a, Message, Theme, Renderer> { + Element::new(viewer) + } +} + +/// Returns the bounds of the underlying image, given the bounds of +/// the [`Viewer`]. Scaling will be applied and original aspect ratio +/// will be respected. +pub fn scaled_image_size( + renderer: &Renderer, + handle: &::Handle, + state: &State, + bounds: Size, + content_fit: ContentFit, +) -> Size +where + Renderer: img_renderer::Renderer, +{ + let Size { width, height } = renderer.measure_image(handle); + let image_size = Size::new(width as f32, height as f32); + + // For ContentFit::None, use the raw image size directly with scale + // to ensure pixel-perfect rendering at scale 1.0 + let adjusted_fit = if matches!(content_fit, ContentFit::None) { + image_size + } else { + content_fit.fit(image_size, bounds) + }; + + Size::new( + adjusted_fit.width * state.scale, + adjusted_fit.height * state.scale, + ) +} diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index d2cdd5b..2c7f35d 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -6,6 +6,7 @@ mod canvas; pub mod footer; pub mod header; +mod image_viewer; pub mod panels; use cosmic::Element;