Step 3 complete: Replace custom image_viewer (559 lines) with standard widgets (~120 lines)

This commit is contained in:
wfx 2026-02-04 16:10:14 +01:00
parent 387afdf4f2
commit 5d729c7495
5 changed files with 96 additions and 634 deletions

View file

@ -28,13 +28,6 @@ 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,

View file

@ -126,31 +126,6 @@ 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;
}

View file

@ -1,63 +1,117 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/ui/views/canvas.rs
//
// Render the center canvas area with the current document.
// Canvas view using standard widgets (no custom viewer needed).
use cosmic::iced::widget::image::FilterMethod;
use cosmic::iced::widget::scrollable::{Direction, Scrollbar};
use cosmic::widget::Id;
use cosmic::iced::{ContentFit, Length};
use cosmic::widget::{container, text};
use cosmic::Element;
use cosmic::widget::{container, image, scrollable, text};
use cosmic::{Element, widget::responsive};
use super::image_viewer::Viewer;
use crate::ui::model::{ToolMode, ViewMode};
use crate::ui::model::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> {
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,
};
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);
// TODO: Add crop dialog when ToolMode::Crop
// For now, just show the viewer
container(img_viewer)
.width(Length::Fill)
.height(Length::Fill)
.into()
} else {
container(text(fl!("no-document")))
// 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,
};
// Calculate scaled dimensions for display
let scaled_width = img_width * effective_zoom;
let scaled_height = img_height * effective_zoom;
// 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),
)
.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()
}

View file

@ -1,559 +0,0 @@
// 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<Message> = Box<dyn Fn(f32, f32, f32, Size, Size) -> Message>;
/// A frame that displays an image with the ability to zoom in/out and pan.
#[allow(missing_debug_implementations)]
pub struct Viewer<Handle, Message> {
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<StateChangeCallback<Message>>,
/// Disable pan interaction (for crop mode)
disable_pan: bool,
}
impl<Handle, Message> Viewer<Handle, Message> {
/// Creates a new [`Viewer`] with the given handle.
pub fn new<T: Into<Handle>>(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<F>(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<Pixels>) -> Self {
self.padding = padding.into().0;
self
}
/// Sets the width of the [`Viewer`].
pub fn width(mut self, width: impl Into<Length>) -> Self {
self.width = width.into();
self
}
/// Sets the height of the [`Viewer`].
pub fn height(mut self, height: impl Into<Length>) -> 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<Message, Theme, Renderer, Handle> Widget<Message, Theme, Renderer> for Viewer<Handle, Message>
where
Renderer: img_renderer::Renderer<Handle = Handle>,
Handle: Clone,
Message: Clone,
{
fn tag(&self) -> tree::Tag {
tree::Tag::of::<State>()
}
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::<State>();
// 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<Length> {
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::<State>();
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>();
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::<State>();
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::<State>();
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::<State>();
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::<State>();
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<Point>,
}
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<Viewer<Handle, Message>>
for Element<'a, Message, Theme, Renderer>
where
Renderer: 'a + img_renderer::Renderer<Handle = Handle>,
Message: 'a + Clone,
Handle: Clone + 'a,
{
fn from(viewer: Viewer<Handle, Message>) -> 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: &Renderer,
handle: &<Renderer as img_renderer::Renderer>::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,
)
}

View file

@ -7,7 +7,6 @@ pub mod canvas;
pub mod footer;
pub mod format_panel;
pub mod header;
pub mod image_viewer;
pub mod pages_panel;
pub mod panels;