From a9284bc22b60afeb554cfd7ee83e7eb577102b59 Mon Sep 17 00:00:00 2001 From: wfx Date: Wed, 4 Feb 2026 16:29:09 +0100 Subject: [PATCH] REVERT: Back to working viewer - Keep what works (Mouse Zoom/Pan), Remove over-engineering --- src/ui/message.rs | 9 + src/ui/sync.rs | 2 +- src/ui/update.rs | 31 +- src/ui/views/canvas.rs | 133 +++----- src/ui/views/crop_dialog.rs | 87 ------ src/ui/views/image_viewer.rs | 559 ++++++++++++++++++++++++++++++++++ src/ui/views/mod.rs | 12 +- src/ui/widgets/crop_widget.rs | 444 --------------------------- src/ui/widgets/mod.rs | 2 - 9 files changed, 637 insertions(+), 642 deletions(-) delete mode 100644 src/ui/views/crop_dialog.rs create mode 100644 src/ui/views/image_viewer.rs delete mode 100644 src/ui/widgets/crop_widget.rs diff --git a/src/ui/message.rs b/src/ui/message.rs index 30429e1..a191dde 100644 --- a/src/ui/message.rs +++ b/src/ui/message.rs @@ -28,6 +28,13 @@ pub enum AppMessage { ZoomOut, ZoomReset, ZoomFit, + ViewerStateChanged { + scale: f32, + offset_x: f32, + offset_y: f32, + canvas_size: cosmic::iced::Size, + image_size: cosmic::iced::Size, + }, // Pan control. PanLeft, @@ -52,6 +59,8 @@ pub enum AppMessage { CropDragMove { x: f32, y: f32, + max_x: f32, + max_y: f32, }, CropDragEnd, diff --git a/src/ui/sync.rs b/src/ui/sync.rs index 81790a3..70672e1 100644 --- a/src/ui/sync.rs +++ b/src/ui/sync.rs @@ -52,7 +52,7 @@ pub fn sync_model_from_manager(model: &mut AppModel, manager: &mut DocumentManag pub fn sync_render_data(model: &mut AppModel, manager: &mut DocumentManager) { if let Some(doc) = manager.current_document_mut() { // Re-render at current scale to get updated image handle - if let Ok(render_output) = doc.render(1.0) { + if let Ok(render_output) = doc.render(model.scale as f64) { model.current_image_handle = Some(render_output.handle); } diff --git a/src/ui/update.rs b/src/ui/update.rs index d6762d6..8cdb9fd 100644 --- a/src/ui/update.rs +++ b/src/ui/update.rs @@ -126,6 +126,31 @@ pub fn update(app: &mut NoctuaApp, msg: &AppMessage) -> UpdateResult { app.model.reset_pan(); } + AppMessage::ViewerStateChanged { + scale, + offset_x, + offset_y, + canvas_size, + image_size, + } => { + // Detect scale changes (zoom vs just pan) + let old_scale = app.model.scale; + + // Update model from viewer state + app.model.scale = *scale; + app.model.pan_x = *offset_x; + app.model.pan_y = *offset_y; + app.model.canvas_size = *canvas_size; + app.model.image_size = *image_size; + + // If scale changed, user zoomed -> switch to Custom mode + // (Fit mode is only maintained when explicitly set via ZoomFit button) + if old_scale != *scale { + app.model.view_mode = ViewMode::Custom; + } + } + + // ---- Pan control --------------------------------------------------------- AppMessage::PanLeft => { app.model.pan_x -= app.config.pan_step; } @@ -223,11 +248,9 @@ pub fn update(app: &mut NoctuaApp, msg: &AppMessage) -> UpdateResult { } } } - AppMessage::CropDragMove { x, y } => { + AppMessage::CropDragMove { x, y, max_x, max_y } => { if app.model.tool_mode == ToolMode::Crop { - if let Some((img_w, img_h)) = app.model.current_dimensions { - app.model.crop_selection.update_drag(*x, *y, img_w as f32, img_h as f32); - } + app.model.crop_selection.update_drag(*x, *y, *max_x, *max_y); } } AppMessage::CropDragEnd => { diff --git a/src/ui/views/canvas.rs b/src/ui/views/canvas.rs index 7f1b4ce..97ee16f 100644 --- a/src/ui/views/canvas.rs +++ b/src/ui/views/canvas.rs @@ -1,117 +1,62 @@ // SPDX-License-Identifier: GPL-3.0-or-later // src/ui/views/canvas.rs // -// Canvas view using standard widgets (no custom viewer needed). +// Render the center canvas area with the current document. -use cosmic::iced::widget::scrollable::{Direction, Scrollbar}; -use cosmic::widget::Id; +use cosmic::iced::widget::image::FilterMethod; use cosmic::iced::{ContentFit, Length}; -use cosmic::widget::{container, image, scrollable, text}; -use cosmic::{Element, widget::responsive}; +use cosmic::widget::{container, text}; +use cosmic::Element; -use crate::ui::model::ViewMode; +use super::image_viewer::Viewer; +use crate::ui::model::{ToolMode, ViewMode}; use crate::ui::{AppMessage, AppModel}; use crate::application::DocumentManager; use crate::config::AppConfig; use crate::fl; /// Render the center canvas area with the current document. -/// -/// Uses standard cosmic widgets: -/// - `image()` for display -/// - `responsive()` for size calculation based on available space -/// - `scrollable()` for panning when image is larger than viewport -/// -/// The Domain renders images at scale=1.0, and UI scales them for display. -/// This allows smooth zooming without re-rendering from Domain. pub fn view<'a>( model: &'a AppModel, _manager: &'a DocumentManager, - _config: &'a AppConfig, + config: &'a AppConfig, ) -> Element<'a, AppMessage> { - // Check if we have an image to display - let Some(handle) = &model.current_image_handle else { - return container(text(fl!("no-document"))) - .width(Length::Fill) - .height(Length::Fill) - .center(Length::Fill) - .into(); - }; - - // Get image dimensions (from cached data) - let (img_width, img_height) = model.current_dimensions - .map(|(w, h)| (w as f32, h as f32)) - .unwrap_or((800.0, 600.0)); // Fallback if dimensions not available - - // Clone values for move closure in responsive() - let handle_clone = handle.clone(); - let scale = model.scale; - let view_mode = model.view_mode; - - // Use responsive() to calculate sizes based on available viewport space - // This ensures proper scaling regardless of window size - container(responsive(move |size| { - let available_width = size.width; - let available_height = size.height; - - // Calculate effective zoom based on view mode - let effective_zoom = match view_mode { - ViewMode::Fit => { - // Calculate zoom to fit image in viewport (maintain aspect ratio) - let zoom_x = available_width / img_width; - let zoom_y = available_height / img_height; - zoom_x.min(zoom_y).min(1.0) // Don't zoom in beyond 100% - } - ViewMode::ActualSize => 1.0, - ViewMode::Custom => scale, + if let Some(handle) = &model.current_image_handle { + let content_fit = match model.view_mode { + ViewMode::Fit => ContentFit::Contain, + ViewMode::ActualSize | ViewMode::Custom => ContentFit::None, }; - // Calculate scaled dimensions for display - let scaled_width = img_width * effective_zoom; - let scaled_height = img_height * effective_zoom; + let img_viewer = Viewer::new(handle) + .with_state(model.scale, model.pan_x, model.pan_y) + .on_state_change(|scale, offset_x, offset_y, canvas_size, image_size| { + AppMessage::ViewerStateChanged { + scale, + offset_x, + offset_y, + canvas_size, + image_size, + } + }) + .width(Length::Fill) + .height(Length::Fill) + .content_fit(content_fit) + .filter_method(FilterMethod::Nearest) + .min_scale(config.min_scale) + .max_scale(config.max_scale) + .scale_step(config.scale_step - 1.0) + .disable_pan(model.tool_mode == ToolMode::Crop); - // Create image widget with calculated size - // ContentFit::Fill ensures the image fills the specified dimensions - let image_widget = image(handle_clone.clone()) - .content_fit(ContentFit::Fill) - .width(Length::Fixed(scaled_width)) - .height(Length::Fixed(scaled_height)); - - // If image is larger than viewport, wrap in scrollable for panning - if scaled_width > available_width || scaled_height > available_height { - // Calculate padding to center the image when not scrolled - let pad_x = ((available_width - scaled_width) / 2.0).max(0.0); - let pad_y = ((available_height - scaled_height) / 2.0).max(0.0); - - // Scrollable provides automatic panning via scrollbars/mouse drag - container( - scrollable( - container(image_widget) - .width(Length::Shrink) - .height(Length::Shrink) - .padding([pad_y, pad_x]), - ) - .id(Id::new("canvas-scroll")) - .direction(Direction::Both { - vertical: Scrollbar::default(), - horizontal: Scrollbar::default(), - }) - .width(Length::Fill) - .height(Length::Fill), - ) + // TODO: Re-add simple crop overlay (not as complex dialog) + container(img_viewer) .width(Length::Fill) .height(Length::Fill) .into() - } else { - // Image fits in viewport - just center it - container(image_widget) - .width(Length::Fill) - .height(Length::Fill) - .center(Length::Fill) - .into() - } - })) - .width(Length::Fill) - .height(Length::Fill) - .into() + } else { + container(text(fl!("no-document"))) + .width(Length::Fill) + .height(Length::Fill) + .center(Length::Fill) + .into() + } } diff --git a/src/ui/views/crop_dialog.rs b/src/ui/views/crop_dialog.rs deleted file mode 100644 index 67ea8c3..0000000 --- a/src/ui/views/crop_dialog.rs +++ /dev/null @@ -1,87 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/ui/views/crop_dialog.rs -// -// Crop dialog view with CropWidget. - -use cosmic::widget::{button, column, container, horizontal_space, icon, row}; -use cosmic::iced::Length; -use cosmic::{Element, theme}; - -use crate::ui::widgets::crop_widget; -use crate::ui::{AppMessage, AppModel}; -use crate::fl; - -/// Render crop dialog as modal overlay. -/// -/// Shows the crop widget with header (title + close) and footer (apply/cancel buttons). -pub fn view<'a>(model: &'a AppModel) -> Option> { - // Only show if in crop mode and have an image - if model.tool_mode != crate::ui::model::ToolMode::Crop { - return None; - } - - let Some(handle) = &model.current_image_handle else { - return None; - }; - - let (img_width, img_height) = model.current_dimensions.unwrap_or((800, 600)); - - let spacing = theme::active().cosmic().spacing; - - // Header with title and close button - let close_btn = button::icon(icon::from_name("window-close-symbolic")) - .on_press(AppMessage::CancelCrop) - .padding(spacing.space_xs); - - let header = row() - .push( - container(cosmic::widget::text("Crop Image")) - .padding(spacing.space_xs) - ) - .push(horizontal_space()) - .push(close_btn) - .width(Length::Fill) - .padding(spacing.space_xs); - - // Crop widget (self-contained, handles all crop UI) - let crop = crop_widget( - handle.clone(), - img_width, - img_height, - &model.crop_selection, - ); - - // Footer with action buttons - let cancel_btn = button::standard("Cancel") - .on_press(AppMessage::CancelCrop); - - let apply_btn = if model.crop_selection.has_selection() { - button::suggested("Apply") - .on_press(AppMessage::ApplyCrop) - } else { - button::suggested("Apply") - }; - - let footer = row() - .push(horizontal_space()) - .push(cancel_btn) - .push(apply_btn) - .spacing(spacing.space_xs) - .padding(spacing.space_xs); - - // Full layout - let content = column() - .push(header) - .push(crop) - .push(footer) - .width(Length::Fill) - .height(Length::Fill); - - Some( - container(content) - .width(Length::Fill) - .height(Length::Fill) - .center(Length::Fill) - .into() - ) -} diff --git a/src/ui/views/image_viewer.rs b/src/ui/views/image_viewer.rs new file mode 100644 index 0000000..01a30d9 --- /dev/null +++ b/src/ui/views/image_viewer.rs @@ -0,0 +1,559 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/app/view/image_viewer.rs +// +// Zoom and pan image viewer widget with external state control. +// Forked from cosmic::iced to support external state control. + +use cosmic::iced::advanced::image as img_renderer; +use cosmic::iced::advanced::layout; +use cosmic::iced::advanced::renderer; +use cosmic::iced::advanced::widget::tree::{self, Tree}; +use cosmic::iced::advanced::widget::Widget; +use cosmic::iced::advanced::{Clipboard, Layout, Shell}; +use cosmic::iced::event::{self, Event}; +use cosmic::iced::mouse; +use cosmic::iced::widget::image::FilterMethod; +use cosmic::iced::{ContentFit, Element, Length, Pixels, Point, Radians, Rectangle, Size, Vector}; + +/// Tolerance for scale comparisons in widget state synchronization. +const SCALE_EPSILON: f32 = 0.0001; + +/// Tolerance for offset comparisons in widget state synchronization. +const OFFSET_EPSILON: f32 = 0.01; + +/// Callback type for notifying viewer state changes (scale, `offset_x`, `offset_y`, `canvas_size`, `image_size`). +type StateChangeCallback = Box Message>; + +/// 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 (scale, offset) + external_state: Option<(f32, Vector)>, + /// Optional callback to notify state changes + on_state_change: Option>, + /// Disable pan interaction (for crop mode) + disable_pan: bool, +} + +impl Viewer { + /// Creates a new [`Viewer`] with the given handle. + 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, + disable_pan: false, + } + } + + /// 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, Size, Size) -> Message, + { + self.on_state_change = Some(Box::new(f)); + self + } + + /// Disable pan interaction (useful when overlaying crop tools). + pub fn disable_pan(mut self, disable: bool) -> Self { + self.disable_pan = disable; + self + } + + /// Sets the [`FilterMethod`] of the [`Viewer`]. + pub fn filter_method(mut self, filter_method: 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 at creation + 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) { + // Sync external state into internal state when user is not dragging + if let Some((ext_scale, ext_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 significantly from current state + let scale_changed = (state.scale - ext_scale).abs() > SCALE_EPSILON; + let offset_changed = (state.current_offset.x - ext_offset.x).abs() > OFFSET_EPSILON + || (state.current_offset.y - ext_offset.y).abs() > OFFSET_EPSILON; + + if scale_changed || offset_changed { + state.scale = ext_scale; + state.current_offset = ext_offset; + state.starting_offset = ext_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 { + let image_size = renderer.measure_image(&self.handle); + let image_size = Size::new(image_size.width as f32, image_size.height as f32); + + let raw_size = limits.resolve(self.width, self.height, image_size); + let full_size = self.content_fit.fit(image_size, raw_size); + + 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 scale_factor = state.scale / previous_scale; + + // Cursor position relative to the image center (not bounds center) + // The image is centered in bounds, so bounds.center() is correct + let cursor_to_center = cursor_position - bounds.center(); + + // Transform offset so the point under cursor stays stationary + // Formula: new_offset = old_offset * scale_factor + cursor_to_center * (scale_factor - 1) + let new_offset = Vector::new( + state.current_offset.x * scale_factor + + cursor_to_center.x * (scale_factor - 1.0), + state.current_offset.y * scale_factor + + cursor_to_center.y * (scale_factor - 1.0), + ); + + // Clamp offset to valid range + let scaled_size = scaled_image_size( + renderer, + &self.handle, + state, + bounds.size(), + self.content_fit, + ); + + state.current_offset = + clamp_offset(new_offset, bounds.size(), scaled_size); + + // Notify state change + if let Some(ref on_change) = self.on_state_change { + let image_size = renderer.measure_image(&self.handle); + let image_size = + Size::new(image_size.width as f32, image_size.height as f32); + shell.publish(on_change( + state.scale, + state.current_offset.x, + state.current_offset.y, + bounds.size(), + image_size, + )); + } + } + } + } + + event::Status::Captured + } + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + if self.disable_pan { + return event::Status::Ignored; + } + + 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)) => { + if self.disable_pan { + return event::Status::Ignored; + } + + let state = tree.state.downcast_mut::(); + + if state.cursor_grabbed_at.is_some() { + state.cursor_grabbed_at = None; + + // Notify final state after drag ends + if let Some(ref on_change) = self.on_state_change { + let image_size = renderer.measure_image(&self.handle); + let image_size = + Size::new(image_size.width as f32, image_size.height as f32); + shell.publish(on_change( + state.scale, + state.current_offset.x, + state.current_offset.y, + bounds.size(), + image_size, + )); + } + + event::Status::Captured + } else { + event::Status::Ignored + } + } + Event::Mouse(mouse::Event::CursorMoved { position }) => { + if self.disable_pan { + return event::Status::Ignored; + } + + 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 delta = position - origin; + + // Pan: subtract delta from starting offset + let new_offset = Vector::new( + state.starting_offset.x - delta.x, + state.starting_offset.y - delta.y, + ); + + state.current_offset = clamp_offset(new_offset, bounds.size(), scaled_size); + + // Notify state change during pan + if let Some(ref on_change) = self.on_state_change { + let image_size = renderer.measure_image(&self.handle); + let image_size = + Size::new(image_size.width as f32, image_size.height as f32); + shell.publish(on_change( + state.scale, + state.current_offset.x, + state.current_offset.y, + bounds.size(), + image_size, + )); + } + + 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 scaled_size = scaled_image_size( + renderer, + &self.handle, + state, + bounds.size(), + self.content_fit, + ); + + // Calculate translation to center the image and apply offset + let translation = { + // How much space is left after placing the scaled image + let diff_w = bounds.width - scaled_size.width; + let diff_h = bounds.height - scaled_size.height; + + // Base position: center the image in the viewport + // For images smaller than viewport: center them (diff > 0) + // For images larger than viewport: they extend beyond bounds (diff < 0) + let center_offset = Vector::new(diff_w / 2.0, diff_h / 2.0); + + // Apply pan offset (offset moves the "camera", so subtract it) + // Positive offset = looking at right/bottom part = image moves left/up + center_offset - state.current_offset + }; + + let drawing_bounds = Rectangle::new(bounds.position(), scaled_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 if the cursor is currently grabbed by the [`Viewer`]. + pub fn is_cursor_grabbed(&self) -> bool { + self.cursor_grabbed_at.is_some() + } +} + +/// Clamps the offset to keep the image within reasonable bounds. +/// +/// The offset represents how far the viewport's center is displaced from the image's center. +/// - offset (0, 0) = image centered +/// - positive offset = viewing right/bottom part of image +/// - negative offset = viewing left/top part of image +fn clamp_offset(offset: Vector, viewport_size: Size, image_size: Size) -> Vector { + // Maximum allowed offset in each direction + // When image is larger than viewport, allow panning up to image edge + // When image is smaller than viewport, no panning needed (clamp to 0) + let max_offset_x = ((image_size.width - viewport_size.width) / 2.0).max(0.0); + let max_offset_y = ((image_size.height - viewport_size.height) / 2.0).max(0.0); + + Vector::new( + offset.x.clamp(-max_offset_x, max_offset_x), + offset.y.clamp(-max_offset_y, max_offset_y), + ) +} + +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 scaled size of the image given current state. +/// Calculate the scaled image size after applying content fit and zoom. +/// +/// This is the canonical implementation used by the viewer widget. +/// A simplified version exists in `document::utils::scaled_image_size`. +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); + + let adjusted_fit = match content_fit { + ContentFit::None => image_size, + _ => content_fit.fit(image_size, bounds), + }; + + Size::new( + adjusted_fit.width * state.scale, + adjusted_fit.height * state.scale, + ) +} diff --git a/src/ui/views/mod.rs b/src/ui/views/mod.rs index 1ab11b5..32bea11 100644 --- a/src/ui/views/mod.rs +++ b/src/ui/views/mod.rs @@ -4,10 +4,10 @@ // View module exports. pub mod canvas; -pub mod crop_dialog; pub mod footer; pub mod format_panel; pub mod header; +pub mod image_viewer; pub mod pages_panel; pub mod panels; @@ -26,15 +26,7 @@ pub fn view<'a>( manager: &'a DocumentManager, config: &'a AppConfig, ) -> Element<'a, AppMessage> { - let canvas = canvas::view(model, manager, config); - - // Overlay crop dialog if in crop mode - if let Some(crop_dialog) = crop_dialog::view(model) { - // Use stack to overlay dialog on top of canvas - cosmic::iced_widget::stack![canvas, crop_dialog].into() - } else { - canvas - } + canvas::view(model, manager, config) } /// Navigation bar content (left panel). diff --git a/src/ui/widgets/crop_widget.rs b/src/ui/widgets/crop_widget.rs deleted file mode 100644 index 2528d08..0000000 --- a/src/ui/widgets/crop_widget.rs +++ /dev/null @@ -1,444 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/ui/widgets/crop_widget.rs -// -// Self-contained crop widget (based on Cupola, adapted for Noctua). - -use cosmic::{ - Element, Renderer, - iced::{ - Color, Length, Point, Rectangle, Size, - advanced::{ - Clipboard, Layout, Shell, Widget, - image::Renderer as ImageRenderer, - layout::{Limits, Node}, - renderer::{Quad, Renderer as QuadRenderer}, - widget::Tree, - }, - event::{Event, Status}, - mouse::{self, Button, Cursor}, - }, - widget::image::Handle, -}; - -use crate::ui::widgets::{CropSelection, DragHandle}; -use crate::ui::AppMessage; - -// Visual constants -const HANDLE_SIZE: f32 = 12.0; -const HANDLE_HIT_SIZE: f32 = 24.0; -const OVERLAY_COLOR: Color = Color::from_rgba(0.0, 0.0, 0.0, 0.5); -const HANDLE_COLOR: Color = Color::WHITE; -const BORDER_COLOR: Color = Color::WHITE; -const BORDER_WIDTH: f32 = 2.0; - -/// Self-contained crop widget that renders image and crop UI together. -/// -/// All coordinates are handled internally - no transformation needed! -/// This is much simpler than the old overlay approach. -pub struct CropWidget { - handle: Handle, - img_width: u32, - img_height: u32, - selection: CropSelection, -} - -impl CropWidget { - pub fn new(handle: Handle, img_width: u32, img_height: u32, selection: &CropSelection) -> Self { - Self { - handle, - img_width, - img_height, - selection: selection.clone(), - } - } - - /// Calculate image rectangle within bounds (centered, scaled to fit). - fn calculate_image_rect(&self, bounds: Rectangle) -> (Rectangle, f32) { - let scale_x = bounds.width / self.img_width as f32; - let scale_y = bounds.height / self.img_height as f32; - let scale = scale_x.min(scale_y).min(1.0); // Don't upscale - - let img_w = self.img_width as f32 * scale; - let img_h = self.img_height as f32 * scale; - - let img_x = bounds.x + (bounds.width - img_w) / 2.0; - let img_y = bounds.y + (bounds.height - img_h) / 2.0; - - ( - Rectangle::new(Point::new(img_x, img_y), Size::new(img_w, img_h)), - scale, - ) - } - - /// Convert screen coordinates to image coordinates. - fn screen_to_image(&self, screen_point: Point, img_rect: Rectangle, scale: f32) -> (f32, f32) { - let rel_x = (screen_point.x - img_rect.x) / scale; - let rel_y = (screen_point.y - img_rect.y) / scale; - (rel_x, rel_y) - } - - /// Convert image coordinates to screen coordinates. - fn image_to_screen(&self, img_x: f32, img_y: f32, img_rect: Rectangle, scale: f32) -> Point { - Point::new( - img_rect.x + img_x * scale, - img_rect.y + img_y * scale, - ) - } - - /// Hit-test to find which handle (if any) is under the cursor. - fn hit_test_handle(&self, screen_point: Point, img_rect: Rectangle, scale: f32) -> DragHandle { - let Some((x, y, w, h)) = self.selection.region else { - return DragHandle::None; - }; - - // Convert handle positions to screen coordinates - let handles = [ - (self.image_to_screen(x, y, img_rect, scale), DragHandle::TopLeft), - (self.image_to_screen(x + w, y, img_rect, scale), DragHandle::TopRight), - (self.image_to_screen(x, y + h, img_rect, scale), DragHandle::BottomLeft), - (self.image_to_screen(x + w, y + h, img_rect, scale), DragHandle::BottomRight), - (self.image_to_screen(x + w / 2.0, y, img_rect, scale), DragHandle::Top), - (self.image_to_screen(x + w / 2.0, y + h, img_rect, scale), DragHandle::Bottom), - (self.image_to_screen(x, y + h / 2.0, img_rect, scale), DragHandle::Left), - (self.image_to_screen(x + w, y + h / 2.0, img_rect, scale), DragHandle::Right), - ]; - - // Check handles - for (pos, handle) in handles { - if point_in_handle(screen_point, pos) { - return handle; - } - } - - // Check if inside selection (move) - let sel_rect = Rectangle::new( - self.image_to_screen(x, y, img_rect, scale), - Size::new(w * scale, h * scale), - ); - if sel_rect.contains(screen_point) { - return DragHandle::Move; - } - - DragHandle::None - } - - /// Draw the darkened overlay outside the selection. - fn draw_overlay(&self, renderer: &mut Renderer, bounds: Rectangle, img_rect: Rectangle, scale: f32) { - let Some((x, y, w, h)) = self.selection.region else { - // No selection - darken entire image - draw_quad(renderer, img_rect, OVERLAY_COLOR); - return; - }; - - // Convert selection to screen coordinates - let sel_screen = Rectangle::new( - self.image_to_screen(x, y, img_rect, scale), - Size::new(w * scale, h * scale), - ); - - // Draw 4 overlay rectangles around the selection - // Top - if sel_screen.y > img_rect.y { - draw_quad( - renderer, - Rectangle::new( - Point::new(img_rect.x, img_rect.y), - Size::new(img_rect.width, sel_screen.y - img_rect.y), - ), - OVERLAY_COLOR, - ); - } - - // Bottom - let sel_bottom = sel_screen.y + sel_screen.height; - let img_bottom = img_rect.y + img_rect.height; - if sel_bottom < img_bottom { - draw_quad( - renderer, - Rectangle::new( - Point::new(img_rect.x, sel_bottom), - Size::new(img_rect.width, img_bottom - sel_bottom), - ), - OVERLAY_COLOR, - ); - } - - // Left - if sel_screen.x > img_rect.x { - draw_quad( - renderer, - Rectangle::new( - Point::new(img_rect.x, sel_screen.y), - Size::new(sel_screen.x - img_rect.x, sel_screen.height), - ), - OVERLAY_COLOR, - ); - } - - // Right - let sel_right = sel_screen.x + sel_screen.width; - let img_right = img_rect.x + img_rect.width; - if sel_right < img_right { - draw_quad( - renderer, - Rectangle::new( - Point::new(sel_right, sel_screen.y), - Size::new(img_right - sel_right, sel_screen.height), - ), - OVERLAY_COLOR, - ); - } - } - - /// Draw selection border. - fn draw_border(&self, renderer: &mut Renderer, img_rect: Rectangle, scale: f32) { - let Some((x, y, w, h)) = self.selection.region else { - return; - }; - - let sel_screen = Rectangle::new( - self.image_to_screen(x, y, img_rect, scale), - Size::new(w * scale, h * scale), - ); - - // Draw 4 border lines - let sx = sel_screen.x; - let sy = sel_screen.y; - let sw = sel_screen.width; - let sh = sel_screen.height; - - // Top - draw_quad( - renderer, - Rectangle::new(Point::new(sx, sy), Size::new(sw, BORDER_WIDTH)), - BORDER_COLOR, - ); - - // Bottom - draw_quad( - renderer, - Rectangle::new( - Point::new(sx, sy + sh - BORDER_WIDTH), - Size::new(sw, BORDER_WIDTH), - ), - BORDER_COLOR, - ); - - // Left - draw_quad( - renderer, - Rectangle::new(Point::new(sx, sy), Size::new(BORDER_WIDTH, sh)), - BORDER_COLOR, - ); - - // Right - draw_quad( - renderer, - Rectangle::new( - Point::new(sx + sw - BORDER_WIDTH, sy), - Size::new(BORDER_WIDTH, sh), - ), - BORDER_COLOR, - ); - } - - /// Draw resize handles. - fn draw_handles(&self, renderer: &mut Renderer, img_rect: Rectangle, scale: f32) { - let Some((x, y, w, h)) = self.selection.region else { - return; - }; - - let half = HANDLE_SIZE / 2.0; - - // 8 handle positions - let handles = [ - self.image_to_screen(x, y, img_rect, scale), - self.image_to_screen(x + w, y, img_rect, scale), - self.image_to_screen(x, y + h, img_rect, scale), - self.image_to_screen(x + w, y + h, img_rect, scale), - self.image_to_screen(x + w / 2.0, y, img_rect, scale), - self.image_to_screen(x + w / 2.0, y + h, img_rect, scale), - self.image_to_screen(x, y + h / 2.0, img_rect, scale), - self.image_to_screen(x + w, y + h / 2.0, img_rect, scale), - ]; - - for pos in handles { - draw_quad( - renderer, - Rectangle::new( - Point::new(pos.x - half, pos.y - half), - Size::new(HANDLE_SIZE, HANDLE_SIZE), - ), - HANDLE_COLOR, - ); - } - } -} - -impl Widget for CropWidget { - fn size(&self) -> Size { - Size::new(Length::Fill, Length::Fill) - } - - fn layout(&self, _tree: &mut Tree, _renderer: &Renderer, limits: &Limits) -> Node { - Node::new(limits.max()) - } - - fn draw( - &self, - _tree: &Tree, - renderer: &mut Renderer, - _theme: &cosmic::Theme, - _style: &cosmic::iced::advanced::renderer::Style, - layout: Layout<'_>, - _cursor: Cursor, - _viewport: &Rectangle, - ) { - let bounds = layout.bounds(); - let (img_rect, scale) = self.calculate_image_rect(bounds); - - // Draw image - renderer.draw_image( - self.handle.clone(), - cosmic::iced::widget::image::FilterMethod::Linear, - img_rect, - cosmic::iced::Radians(0.0), - 1.0, - [0.0; 4], - ); - - // Draw crop UI - self.draw_overlay(renderer, bounds, img_rect, scale); - self.draw_border(renderer, img_rect, scale); - self.draw_handles(renderer, img_rect, scale); - } - - fn on_event( - &mut self, - _tree: &mut Tree, - event: Event, - layout: Layout<'_>, - cursor: Cursor, - _renderer: &Renderer, - _clipboard: &mut dyn Clipboard, - shell: &mut Shell<'_, AppMessage>, - _viewport: &Rectangle, - ) -> Status { - let bounds = layout.bounds(); - let (img_rect, scale) = self.calculate_image_rect(bounds); - - match event { - Event::Mouse(mouse::Event::ButtonPressed(Button::Left)) => { - if let Some(screen_pos) = cursor.position_in(bounds) { - // Only handle clicks inside image area - if !img_rect.contains(screen_pos) { - return Status::Ignored; - } - - let handle = self.hit_test_handle(screen_pos, img_rect, scale); - let (img_x, img_y) = self.screen_to_image(screen_pos, img_rect, scale); - - shell.publish(AppMessage::CropDragStart { - x: img_x, - y: img_y, - handle, - }); - return Status::Captured; - } - } - Event::Mouse(mouse::Event::CursorMoved { .. }) => { - if self.selection.is_dragging { - if let Some(screen_pos) = cursor.position_in(bounds) { - let (img_x, img_y) = self.screen_to_image(screen_pos, img_rect, scale); - shell.publish(AppMessage::CropDragMove { - x: img_x, - y: img_y, - }); - return Status::Captured; - } - } - } - Event::Mouse(mouse::Event::ButtonReleased(Button::Left)) => { - if self.selection.is_dragging { - shell.publish(AppMessage::CropDragEnd); - return Status::Captured; - } - } - _ => {} - } - - Status::Ignored - } - - fn mouse_interaction( - &self, - _tree: &Tree, - layout: Layout<'_>, - cursor: Cursor, - _viewport: &Rectangle, - _renderer: &Renderer, - ) -> mouse::Interaction { - let bounds = layout.bounds(); - let (img_rect, scale) = self.calculate_image_rect(bounds); - - if let Some(screen_pos) = cursor.position_in(bounds) { - if img_rect.contains(screen_pos) { - let handle = self.hit_test_handle(screen_pos, img_rect, scale); - return match handle { - DragHandle::TopLeft | DragHandle::BottomRight => { - mouse::Interaction::ResizingDiagonallyDown - } - DragHandle::TopRight | DragHandle::BottomLeft => { - mouse::Interaction::ResizingDiagonallyUp - } - DragHandle::Top | DragHandle::Bottom => { - mouse::Interaction::ResizingVertically - } - DragHandle::Left | DragHandle::Right => { - mouse::Interaction::ResizingHorizontally - } - DragHandle::Move => mouse::Interaction::Grabbing, - DragHandle::None => mouse::Interaction::Crosshair, - }; - } - } - - mouse::Interaction::None - } -} - -impl<'a> From for Element<'a, AppMessage> { - fn from(widget: CropWidget) -> Self { - Element::new(widget) - } -} - -/// Helper: Check if point is within handle hit area. -fn point_in_handle(point: Point, handle_center: Point) -> bool { - let half = HANDLE_HIT_SIZE / 2.0; - point.x >= handle_center.x - half - && point.x <= handle_center.x + half - && point.y >= handle_center.y - half - && point.y <= handle_center.y + half -} - -/// Helper: Draw a filled quad. -fn draw_quad(renderer: &mut Renderer, bounds: Rectangle, color: Color) { - renderer.fill_quad( - Quad { - bounds, - ..Quad::default() - }, - color, - ); -} - -/// Public constructor function (convenience). -pub fn crop_widget<'a>( - handle: Handle, - img_width: u32, - img_height: u32, - selection: &CropSelection, -) -> Element<'a, AppMessage> { - CropWidget::new(handle, img_width, img_height, selection).into() -} diff --git a/src/ui/widgets/mod.rs b/src/ui/widgets/mod.rs index 02e237e..acb6c3c 100644 --- a/src/ui/widgets/mod.rs +++ b/src/ui/widgets/mod.rs @@ -4,7 +4,5 @@ // Custom widgets module. pub mod crop_types; -pub mod crop_widget; pub use crop_types::{CropRegion, CropSelection, DragHandle}; -pub use crop_widget::crop_widget;