REVERT: Back to working viewer - Keep what works (Mouse Zoom/Pan), Remove over-engineering
This commit is contained in:
parent
49bff3dd89
commit
a9284bc22b
9 changed files with 637 additions and 642 deletions
|
|
@ -28,6 +28,13 @@ pub enum AppMessage {
|
||||||
ZoomOut,
|
ZoomOut,
|
||||||
ZoomReset,
|
ZoomReset,
|
||||||
ZoomFit,
|
ZoomFit,
|
||||||
|
ViewerStateChanged {
|
||||||
|
scale: f32,
|
||||||
|
offset_x: f32,
|
||||||
|
offset_y: f32,
|
||||||
|
canvas_size: cosmic::iced::Size,
|
||||||
|
image_size: cosmic::iced::Size,
|
||||||
|
},
|
||||||
|
|
||||||
// Pan control.
|
// Pan control.
|
||||||
PanLeft,
|
PanLeft,
|
||||||
|
|
@ -52,6 +59,8 @@ pub enum AppMessage {
|
||||||
CropDragMove {
|
CropDragMove {
|
||||||
x: f32,
|
x: f32,
|
||||||
y: f32,
|
y: f32,
|
||||||
|
max_x: f32,
|
||||||
|
max_y: f32,
|
||||||
},
|
},
|
||||||
CropDragEnd,
|
CropDragEnd,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
pub fn sync_render_data(model: &mut AppModel, manager: &mut DocumentManager) {
|
||||||
if let Some(doc) = manager.current_document_mut() {
|
if let Some(doc) = manager.current_document_mut() {
|
||||||
// Re-render at current scale to get updated image handle
|
// 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);
|
model.current_image_handle = Some(render_output.handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,31 @@ pub fn update(app: &mut NoctuaApp, msg: &AppMessage) -> UpdateResult {
|
||||||
app.model.reset_pan();
|
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 => {
|
AppMessage::PanLeft => {
|
||||||
app.model.pan_x -= app.config.pan_step;
|
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 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, *max_x, *max_y);
|
||||||
app.model.crop_selection.update_drag(*x, *y, img_w as f32, img_h as f32);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AppMessage::CropDragEnd => {
|
AppMessage::CropDragEnd => {
|
||||||
|
|
|
||||||
|
|
@ -1,117 +1,62 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
// src/ui/views/canvas.rs
|
// 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::iced::widget::image::FilterMethod;
|
||||||
use cosmic::widget::Id;
|
|
||||||
use cosmic::iced::{ContentFit, Length};
|
use cosmic::iced::{ContentFit, Length};
|
||||||
use cosmic::widget::{container, image, scrollable, text};
|
use cosmic::widget::{container, text};
|
||||||
use cosmic::{Element, widget::responsive};
|
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::ui::{AppMessage, AppModel};
|
||||||
use crate::application::DocumentManager;
|
use crate::application::DocumentManager;
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
use crate::fl;
|
use crate::fl;
|
||||||
|
|
||||||
/// Render the center canvas area with the current document.
|
/// 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>(
|
pub fn view<'a>(
|
||||||
model: &'a AppModel,
|
model: &'a AppModel,
|
||||||
_manager: &'a DocumentManager,
|
_manager: &'a DocumentManager,
|
||||||
_config: &'a AppConfig,
|
config: &'a AppConfig,
|
||||||
) -> Element<'a, AppMessage> {
|
) -> Element<'a, AppMessage> {
|
||||||
// Check if we have an image to display
|
if let Some(handle) = &model.current_image_handle {
|
||||||
let Some(handle) = &model.current_image_handle else {
|
let content_fit = match model.view_mode {
|
||||||
return container(text(fl!("no-document")))
|
ViewMode::Fit => ContentFit::Contain,
|
||||||
.width(Length::Fill)
|
ViewMode::ActualSize | ViewMode::Custom => ContentFit::None,
|
||||||
.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 img_viewer = Viewer::new(handle)
|
||||||
let scaled_width = img_width * effective_zoom;
|
.with_state(model.scale, model.pan_x, model.pan_y)
|
||||||
let scaled_height = img_height * effective_zoom;
|
.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
|
// TODO: Re-add simple crop overlay (not as complex dialog)
|
||||||
// ContentFit::Fill ensures the image fills the specified dimensions
|
container(img_viewer)
|
||||||
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)
|
.width(Length::Fill)
|
||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
// Image fits in viewport - just center it
|
container(text(fl!("no-document")))
|
||||||
container(image_widget)
|
.width(Length::Fill)
|
||||||
.width(Length::Fill)
|
.height(Length::Fill)
|
||||||
.height(Length::Fill)
|
.center(Length::Fill)
|
||||||
.center(Length::Fill)
|
.into()
|
||||||
.into()
|
}
|
||||||
}
|
|
||||||
}))
|
|
||||||
.width(Length::Fill)
|
|
||||||
.height(Length::Fill)
|
|
||||||
.into()
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<Element<'a, AppMessage>> {
|
|
||||||
// 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()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
559
src/ui/views/image_viewer.rs
Normal file
559
src/ui/views/image_viewer.rs
Normal file
|
|
@ -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<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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -4,10 +4,10 @@
|
||||||
// View module exports.
|
// View module exports.
|
||||||
|
|
||||||
pub mod canvas;
|
pub mod canvas;
|
||||||
pub mod crop_dialog;
|
|
||||||
pub mod footer;
|
pub mod footer;
|
||||||
pub mod format_panel;
|
pub mod format_panel;
|
||||||
pub mod header;
|
pub mod header;
|
||||||
|
pub mod image_viewer;
|
||||||
pub mod pages_panel;
|
pub mod pages_panel;
|
||||||
pub mod panels;
|
pub mod panels;
|
||||||
|
|
||||||
|
|
@ -26,15 +26,7 @@ pub fn view<'a>(
|
||||||
manager: &'a DocumentManager,
|
manager: &'a DocumentManager,
|
||||||
config: &'a AppConfig,
|
config: &'a AppConfig,
|
||||||
) -> Element<'a, AppMessage> {
|
) -> Element<'a, AppMessage> {
|
||||||
let canvas = canvas::view(model, manager, config);
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigation bar content (left panel).
|
/// Navigation bar content (left panel).
|
||||||
|
|
|
||||||
|
|
@ -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<AppMessage, cosmic::Theme, Renderer> for CropWidget {
|
|
||||||
fn size(&self) -> Size<Length> {
|
|
||||||
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<CropWidget> 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()
|
|
||||||
}
|
|
||||||
|
|
@ -4,7 +4,5 @@
|
||||||
// Custom widgets module.
|
// Custom widgets module.
|
||||||
|
|
||||||
pub mod crop_types;
|
pub mod crop_types;
|
||||||
pub mod crop_widget;
|
|
||||||
|
|
||||||
pub use crop_types::{CropRegion, CropSelection, DragHandle};
|
pub use crop_types::{CropRegion, CropSelection, DragHandle};
|
||||||
pub use crop_widget::crop_widget;
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue