diff --git a/src/ui/message.rs b/src/ui/message.rs index a191dde..18826e3 100644 --- a/src/ui/message.rs +++ b/src/ui/message.rs @@ -62,6 +62,7 @@ pub enum AppMessage { max_x: f32, max_y: f32, }, + CropDragEnd, // Panels. diff --git a/src/ui/views/canvas.rs b/src/ui/views/canvas.rs index 7f7aab8..3f9cdf5 100644 --- a/src/ui/views/canvas.rs +++ b/src/ui/views/canvas.rs @@ -5,10 +5,11 @@ use cosmic::iced::widget::image::FilterMethod; use cosmic::iced::{ContentFit, Length}; +use cosmic::iced_widget::stack; use cosmic::widget::{container, text}; use cosmic::Element; -use crate::ui::widgets::Viewer; +use crate::ui::widgets::{crop_overlay, Viewer}; use crate::ui::model::{ToolMode, ViewMode}; use crate::ui::{AppMessage, AppModel}; use crate::application::DocumentManager; @@ -47,11 +48,16 @@ pub fn view<'a>( .scale_step(config.scale_step - 1.0) .disable_pan(model.tool_mode == ToolMode::Crop); - // TODO: Re-add simple crop overlay (not as complex dialog) - container(img_viewer) - .width(Length::Fill) - .height(Length::Fill) - .into() + // Overlay crop UI when in crop mode + if model.tool_mode == ToolMode::Crop { + let overlay = crop_overlay(&model.crop_selection, config.crop_show_grid); + stack![img_viewer, overlay].into() + } else { + container(img_viewer) + .width(Length::Fill) + .height(Length::Fill) + .into() + } } else { container(text(fl!("no-document"))) .width(Length::Fill) diff --git a/src/ui/widgets/crop_overlay.rs b/src/ui/widgets/crop_overlay.rs new file mode 100644 index 0000000..8233be3 --- /dev/null +++ b/src/ui/widgets/crop_overlay.rs @@ -0,0 +1,395 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/ui/widgets/crop_overlay.rs +// +// Simple crop overlay (just draws UI, no complex logic). + +use cosmic::{ + Element, Renderer, + iced::{ + Color, Length, Point, Rectangle, Size, + advanced::{ + Clipboard, Layout, Shell, Widget, + layout::{Limits, Node}, + renderer::{Quad, Renderer as QuadRenderer}, + widget::Tree, + }, + event::{Event, Status}, + mouse::{self, Button, Cursor}, + }, +}; + +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; + +/// Simple crop overlay widget. +/// +/// Works in SCREEN coordinates - receives canvas bounds and selection in pixels. +/// Much simpler than trying to coordinate with image viewer transformations! +pub struct CropOverlay { + selection: CropSelection, + show_grid: bool, +} + +impl CropOverlay { + pub fn new(selection: &CropSelection, show_grid: bool) -> Self { + Self { + selection: selection.clone(), + show_grid, + } + } + + /// Hit test for handles. + fn hit_test_handle(&self, point: Point) -> DragHandle { + let Some((x, y, w, h)) = self.selection.region else { + return DragHandle::None; + }; + + // 8 handle positions + let handles = [ + (Point::new(x, y), DragHandle::TopLeft), + (Point::new(x + w, y), DragHandle::TopRight), + (Point::new(x, y + h), DragHandle::BottomLeft), + (Point::new(x + w, y + h), DragHandle::BottomRight), + (Point::new(x + w / 2.0, y), DragHandle::Top), + (Point::new(x + w / 2.0, y + h), DragHandle::Bottom), + (Point::new(x, y + h / 2.0), DragHandle::Left), + (Point::new(x + w, y + h / 2.0), DragHandle::Right), + ]; + + // Test handles + for (pos, handle) in handles { + if point_in_handle(point, pos) { + return handle; + } + } + + // Test if inside selection (move) + if point.x >= x && point.x <= x + w && point.y >= y && point.y <= y + h { + return DragHandle::Move; + } + + DragHandle::None + } + + /// Draw darkened overlay. + fn draw_overlay(&self, renderer: &mut Renderer, bounds: Rectangle) { + let Some((x, y, w, h)) = self.selection.region else { + // No selection - darken all + draw_quad(renderer, bounds, OVERLAY_COLOR); + return; + }; + + // Clamp selection to bounds + let x = x.max(bounds.x); + let y = y.max(bounds.y); + let right = (x + w).min(bounds.x + bounds.width); + let bottom = (y + h).min(bounds.y + bounds.height); + let w = right - x; + let h = bottom - y; + + // Draw 4 overlay rectangles around selection + // Top + if y > bounds.y { + draw_quad( + renderer, + Rectangle::new( + Point::new(bounds.x, bounds.y), + Size::new(bounds.width, y - bounds.y), + ), + OVERLAY_COLOR, + ); + } + + // Bottom + if bottom < bounds.y + bounds.height { + draw_quad( + renderer, + Rectangle::new( + Point::new(bounds.x, bottom), + Size::new(bounds.width, bounds.y + bounds.height - bottom), + ), + OVERLAY_COLOR, + ); + } + + // Left + if x > bounds.x { + draw_quad( + renderer, + Rectangle::new(Point::new(bounds.x, y), Size::new(x - bounds.x, h)), + OVERLAY_COLOR, + ); + } + + // Right + if right < bounds.x + bounds.width { + draw_quad( + renderer, + Rectangle::new( + Point::new(right, y), + Size::new(bounds.x + bounds.width - right, h), + ), + OVERLAY_COLOR, + ); + } + } + + /// Draw border. + fn draw_border(&self, renderer: &mut Renderer, _bounds: Rectangle) { + let Some((x, y, w, h)) = self.selection.region else { + return; + }; + + // Top + draw_quad( + renderer, + Rectangle::new(Point::new(x, y), Size::new(w, BORDER_WIDTH)), + BORDER_COLOR, + ); + + // Bottom + draw_quad( + renderer, + Rectangle::new( + Point::new(x, y + h - BORDER_WIDTH), + Size::new(w, BORDER_WIDTH), + ), + BORDER_COLOR, + ); + + // Left + draw_quad( + renderer, + Rectangle::new(Point::new(x, y), Size::new(BORDER_WIDTH, h)), + BORDER_COLOR, + ); + + // Right + draw_quad( + renderer, + Rectangle::new( + Point::new(x + w - BORDER_WIDTH, y), + Size::new(BORDER_WIDTH, h), + ), + BORDER_COLOR, + ); + } + + /// Draw handles. + fn draw_handles(&self, renderer: &mut Renderer, _bounds: Rectangle) { + let Some((x, y, w, h)) = self.selection.region else { + return; + }; + + let half = HANDLE_SIZE / 2.0; + + // 8 handle positions + let handles = [ + Point::new(x, y), + Point::new(x + w, y), + Point::new(x, y + h), + Point::new(x + w, y + h), + Point::new(x + w / 2.0, y), + Point::new(x + w / 2.0, y + h), + Point::new(x, y + h / 2.0), + Point::new(x + w, y + h / 2.0), + ]; + + 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, + ); + } + } + + /// Draw grid (rule of thirds). + fn draw_grid(&self, renderer: &mut Renderer, _bounds: Rectangle) { + if !self.show_grid { + return; + } + + let Some((x, y, w, h)) = self.selection.region else { + return; + }; + + if w <= 10.0 || h <= 10.0 { + return; + } + + let grid_color = Color::from_rgba(1.0, 1.0, 1.0, 0.3); + let third_w = w / 3.0; + let third_h = h / 3.0; + + // 2 vertical lines + for i in 1..3 { + let line_x = x + third_w * i as f32; + draw_quad( + renderer, + Rectangle::new(Point::new(line_x, y), Size::new(1.0, h)), + grid_color, + ); + } + + // 2 horizontal lines + for i in 1..3 { + let line_y = y + third_h * i as f32; + draw_quad( + renderer, + Rectangle::new(Point::new(x, line_y), Size::new(w, 1.0)), + grid_color, + ); + } + } +} + +impl Widget for CropOverlay { + fn size(&self) -> Size { + Size::new(Length::Fill, Length::Fill) + } + + fn layout(&self, _tree: &mut Tree, _renderer: &Renderer, limits: &Limits) -> Node { + Node::new(limits.max()) + } + + fn draw( + &self, + _tree: &Tree, + renderer: &mut Renderer, + _theme: &cosmic::Theme, + _style: &cosmic::iced::advanced::renderer::Style, + layout: Layout<'_>, + _cursor: Cursor, + _viewport: &Rectangle, + ) { + let bounds = layout.bounds(); + + self.draw_overlay(renderer, bounds); + self.draw_border(renderer, bounds); + self.draw_handles(renderer, bounds); + self.draw_grid(renderer, bounds); + } + + 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(); + + match event { + Event::Mouse(mouse::Event::ButtonPressed(Button::Left)) => { + if let Some(pos) = cursor.position_in(bounds) { + let handle = self.hit_test_handle(pos); + + shell.publish(AppMessage::CropDragStart { + x: pos.x, + y: pos.y, + handle, + }); + return Status::Captured; + } + } + Event::Mouse(mouse::Event::CursorMoved { .. }) => { + if self.selection.is_dragging { + if let Some(pos) = cursor.position_in(bounds) { + shell.publish(AppMessage::CropDragMove { + x: pos.x, + y: pos.y, + max_x: bounds.width, + max_y: bounds.height, + }); + 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(); + + if let Some(pos) = cursor.position_in(bounds) { + let handle = self.hit_test_handle(pos); + return match handle { + DragHandle::TopLeft | DragHandle::BottomRight => { + mouse::Interaction::ResizingDiagonallyDown + } + DragHandle::TopRight | DragHandle::BottomLeft => { + mouse::Interaction::ResizingDiagonallyUp + } + DragHandle::Top | DragHandle::Bottom => mouse::Interaction::ResizingVertically, + DragHandle::Left | DragHandle::Right => mouse::Interaction::ResizingHorizontally, + DragHandle::Move => mouse::Interaction::Grabbing, + DragHandle::None => mouse::Interaction::Crosshair, + }; + } + + mouse::Interaction::None + } +} + +impl<'a> From for Element<'a, AppMessage> { + fn from(widget: CropOverlay) -> 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. +pub fn crop_overlay<'a>(selection: &CropSelection, show_grid: bool) -> Element<'a, AppMessage> { + CropOverlay::new(selection, show_grid).into() +} diff --git a/src/ui/widgets/mod.rs b/src/ui/widgets/mod.rs index 740b892..fd7021c 100644 --- a/src/ui/widgets/mod.rs +++ b/src/ui/widgets/mod.rs @@ -4,7 +4,9 @@ // Custom widgets module. pub mod crop_types; +pub mod crop_overlay; pub mod image_viewer; pub use crop_types::{CropRegion, CropSelection, DragHandle}; +pub use crop_overlay::crop_overlay; pub use image_viewer::Viewer;