Step 3 complete: Replace custom image_viewer (559 lines) with standard widgets (~120 lines)
This commit is contained in:
parent
387afdf4f2
commit
5d729c7495
5 changed files with 96 additions and 634 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue