From 49bff3dd89a6b11d4785503099a898ad23c150dd Mon Sep 17 00:00:00 2001 From: wfx Date: Wed, 4 Feb 2026 16:20:08 +0100 Subject: [PATCH] Step 4 complete: Add CropWidget (444 lines) and crop dialog - Crop feature restored! --- src/ui/message.rs | 2 - src/ui/update.rs | 6 +- src/ui/views/crop_dialog.rs | 87 +++++++ src/ui/views/mod.rs | 11 +- src/ui/widgets/crop_widget.rs | 444 ++++++++++++++++++++++++++++++++++ src/ui/widgets/mod.rs | 3 +- 6 files changed, 547 insertions(+), 6 deletions(-) create mode 100644 src/ui/views/crop_dialog.rs create mode 100644 src/ui/widgets/crop_widget.rs diff --git a/src/ui/message.rs b/src/ui/message.rs index cc36714..30429e1 100644 --- a/src/ui/message.rs +++ b/src/ui/message.rs @@ -52,8 +52,6 @@ pub enum AppMessage { CropDragMove { x: f32, y: f32, - max_x: f32, - max_y: f32, }, CropDragEnd, diff --git a/src/ui/update.rs b/src/ui/update.rs index 7a86641..d6762d6 100644 --- a/src/ui/update.rs +++ b/src/ui/update.rs @@ -223,9 +223,11 @@ pub fn update(app: &mut NoctuaApp, msg: &AppMessage) -> UpdateResult { } } } - AppMessage::CropDragMove { x, y, max_x, max_y } => { + AppMessage::CropDragMove { x, y } => { if app.model.tool_mode == ToolMode::Crop { - app.model.crop_selection.update_drag(*x, *y, *max_x, *max_y); + if let Some((img_w, img_h)) = app.model.current_dimensions { + app.model.crop_selection.update_drag(*x, *y, img_w as f32, img_h as f32); + } } } AppMessage::CropDragEnd => { diff --git a/src/ui/views/crop_dialog.rs b/src/ui/views/crop_dialog.rs new file mode 100644 index 0000000..67ea8c3 --- /dev/null +++ b/src/ui/views/crop_dialog.rs @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/ui/views/crop_dialog.rs +// +// Crop dialog view with CropWidget. + +use cosmic::widget::{button, column, container, horizontal_space, icon, row}; +use cosmic::iced::Length; +use cosmic::{Element, theme}; + +use crate::ui::widgets::crop_widget; +use crate::ui::{AppMessage, AppModel}; +use crate::fl; + +/// Render crop dialog as modal overlay. +/// +/// Shows the crop widget with header (title + close) and footer (apply/cancel buttons). +pub fn view<'a>(model: &'a AppModel) -> Option> { + // Only show if in crop mode and have an image + if model.tool_mode != crate::ui::model::ToolMode::Crop { + return None; + } + + let Some(handle) = &model.current_image_handle else { + return None; + }; + + let (img_width, img_height) = model.current_dimensions.unwrap_or((800, 600)); + + let spacing = theme::active().cosmic().spacing; + + // Header with title and close button + let close_btn = button::icon(icon::from_name("window-close-symbolic")) + .on_press(AppMessage::CancelCrop) + .padding(spacing.space_xs); + + let header = row() + .push( + container(cosmic::widget::text("Crop Image")) + .padding(spacing.space_xs) + ) + .push(horizontal_space()) + .push(close_btn) + .width(Length::Fill) + .padding(spacing.space_xs); + + // Crop widget (self-contained, handles all crop UI) + let crop = crop_widget( + handle.clone(), + img_width, + img_height, + &model.crop_selection, + ); + + // Footer with action buttons + let cancel_btn = button::standard("Cancel") + .on_press(AppMessage::CancelCrop); + + let apply_btn = if model.crop_selection.has_selection() { + button::suggested("Apply") + .on_press(AppMessage::ApplyCrop) + } else { + button::suggested("Apply") + }; + + let footer = row() + .push(horizontal_space()) + .push(cancel_btn) + .push(apply_btn) + .spacing(spacing.space_xs) + .padding(spacing.space_xs); + + // Full layout + let content = column() + .push(header) + .push(crop) + .push(footer) + .width(Length::Fill) + .height(Length::Fill); + + Some( + container(content) + .width(Length::Fill) + .height(Length::Fill) + .center(Length::Fill) + .into() + ) +} diff --git a/src/ui/views/mod.rs b/src/ui/views/mod.rs index 0fd30a5..1ab11b5 100644 --- a/src/ui/views/mod.rs +++ b/src/ui/views/mod.rs @@ -4,6 +4,7 @@ // View module exports. pub mod canvas; +pub mod crop_dialog; pub mod footer; pub mod format_panel; pub mod header; @@ -25,7 +26,15 @@ pub fn view<'a>( manager: &'a DocumentManager, config: &'a AppConfig, ) -> Element<'a, AppMessage> { - canvas::view(model, manager, config) + let canvas = canvas::view(model, manager, config); + + // Overlay crop dialog if in crop mode + if let Some(crop_dialog) = crop_dialog::view(model) { + // Use stack to overlay dialog on top of canvas + cosmic::iced_widget::stack![canvas, crop_dialog].into() + } else { + canvas + } } /// Navigation bar content (left panel). diff --git a/src/ui/widgets/crop_widget.rs b/src/ui/widgets/crop_widget.rs new file mode 100644 index 0000000..2528d08 --- /dev/null +++ b/src/ui/widgets/crop_widget.rs @@ -0,0 +1,444 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/ui/widgets/crop_widget.rs +// +// Self-contained crop widget (based on Cupola, adapted for Noctua). + +use cosmic::{ + Element, Renderer, + iced::{ + Color, Length, Point, Rectangle, Size, + advanced::{ + Clipboard, Layout, Shell, Widget, + image::Renderer as ImageRenderer, + layout::{Limits, Node}, + renderer::{Quad, Renderer as QuadRenderer}, + widget::Tree, + }, + event::{Event, Status}, + mouse::{self, Button, Cursor}, + }, + widget::image::Handle, +}; + +use crate::ui::widgets::{CropSelection, DragHandle}; +use crate::ui::AppMessage; + +// Visual constants +const HANDLE_SIZE: f32 = 12.0; +const HANDLE_HIT_SIZE: f32 = 24.0; +const OVERLAY_COLOR: Color = Color::from_rgba(0.0, 0.0, 0.0, 0.5); +const HANDLE_COLOR: Color = Color::WHITE; +const BORDER_COLOR: Color = Color::WHITE; +const BORDER_WIDTH: f32 = 2.0; + +/// Self-contained crop widget that renders image and crop UI together. +/// +/// All coordinates are handled internally - no transformation needed! +/// This is much simpler than the old overlay approach. +pub struct CropWidget { + handle: Handle, + img_width: u32, + img_height: u32, + selection: CropSelection, +} + +impl CropWidget { + pub fn new(handle: Handle, img_width: u32, img_height: u32, selection: &CropSelection) -> Self { + Self { + handle, + img_width, + img_height, + selection: selection.clone(), + } + } + + /// Calculate image rectangle within bounds (centered, scaled to fit). + fn calculate_image_rect(&self, bounds: Rectangle) -> (Rectangle, f32) { + let scale_x = bounds.width / self.img_width as f32; + let scale_y = bounds.height / self.img_height as f32; + let scale = scale_x.min(scale_y).min(1.0); // Don't upscale + + let img_w = self.img_width as f32 * scale; + let img_h = self.img_height as f32 * scale; + + let img_x = bounds.x + (bounds.width - img_w) / 2.0; + let img_y = bounds.y + (bounds.height - img_h) / 2.0; + + ( + Rectangle::new(Point::new(img_x, img_y), Size::new(img_w, img_h)), + scale, + ) + } + + /// Convert screen coordinates to image coordinates. + fn screen_to_image(&self, screen_point: Point, img_rect: Rectangle, scale: f32) -> (f32, f32) { + let rel_x = (screen_point.x - img_rect.x) / scale; + let rel_y = (screen_point.y - img_rect.y) / scale; + (rel_x, rel_y) + } + + /// Convert image coordinates to screen coordinates. + fn image_to_screen(&self, img_x: f32, img_y: f32, img_rect: Rectangle, scale: f32) -> Point { + Point::new( + img_rect.x + img_x * scale, + img_rect.y + img_y * scale, + ) + } + + /// Hit-test to find which handle (if any) is under the cursor. + fn hit_test_handle(&self, screen_point: Point, img_rect: Rectangle, scale: f32) -> DragHandle { + let Some((x, y, w, h)) = self.selection.region else { + return DragHandle::None; + }; + + // Convert handle positions to screen coordinates + let handles = [ + (self.image_to_screen(x, y, img_rect, scale), DragHandle::TopLeft), + (self.image_to_screen(x + w, y, img_rect, scale), DragHandle::TopRight), + (self.image_to_screen(x, y + h, img_rect, scale), DragHandle::BottomLeft), + (self.image_to_screen(x + w, y + h, img_rect, scale), DragHandle::BottomRight), + (self.image_to_screen(x + w / 2.0, y, img_rect, scale), DragHandle::Top), + (self.image_to_screen(x + w / 2.0, y + h, img_rect, scale), DragHandle::Bottom), + (self.image_to_screen(x, y + h / 2.0, img_rect, scale), DragHandle::Left), + (self.image_to_screen(x + w, y + h / 2.0, img_rect, scale), DragHandle::Right), + ]; + + // Check handles + for (pos, handle) in handles { + if point_in_handle(screen_point, pos) { + return handle; + } + } + + // Check if inside selection (move) + let sel_rect = Rectangle::new( + self.image_to_screen(x, y, img_rect, scale), + Size::new(w * scale, h * scale), + ); + if sel_rect.contains(screen_point) { + return DragHandle::Move; + } + + DragHandle::None + } + + /// Draw the darkened overlay outside the selection. + fn draw_overlay(&self, renderer: &mut Renderer, bounds: Rectangle, img_rect: Rectangle, scale: f32) { + let Some((x, y, w, h)) = self.selection.region else { + // No selection - darken entire image + draw_quad(renderer, img_rect, OVERLAY_COLOR); + return; + }; + + // Convert selection to screen coordinates + let sel_screen = Rectangle::new( + self.image_to_screen(x, y, img_rect, scale), + Size::new(w * scale, h * scale), + ); + + // Draw 4 overlay rectangles around the selection + // Top + if sel_screen.y > img_rect.y { + draw_quad( + renderer, + Rectangle::new( + Point::new(img_rect.x, img_rect.y), + Size::new(img_rect.width, sel_screen.y - img_rect.y), + ), + OVERLAY_COLOR, + ); + } + + // Bottom + let sel_bottom = sel_screen.y + sel_screen.height; + let img_bottom = img_rect.y + img_rect.height; + if sel_bottom < img_bottom { + draw_quad( + renderer, + Rectangle::new( + Point::new(img_rect.x, sel_bottom), + Size::new(img_rect.width, img_bottom - sel_bottom), + ), + OVERLAY_COLOR, + ); + } + + // Left + if sel_screen.x > img_rect.x { + draw_quad( + renderer, + Rectangle::new( + Point::new(img_rect.x, sel_screen.y), + Size::new(sel_screen.x - img_rect.x, sel_screen.height), + ), + OVERLAY_COLOR, + ); + } + + // Right + let sel_right = sel_screen.x + sel_screen.width; + let img_right = img_rect.x + img_rect.width; + if sel_right < img_right { + draw_quad( + renderer, + Rectangle::new( + Point::new(sel_right, sel_screen.y), + Size::new(img_right - sel_right, sel_screen.height), + ), + OVERLAY_COLOR, + ); + } + } + + /// Draw selection border. + fn draw_border(&self, renderer: &mut Renderer, img_rect: Rectangle, scale: f32) { + let Some((x, y, w, h)) = self.selection.region else { + return; + }; + + let sel_screen = Rectangle::new( + self.image_to_screen(x, y, img_rect, scale), + Size::new(w * scale, h * scale), + ); + + // Draw 4 border lines + let sx = sel_screen.x; + let sy = sel_screen.y; + let sw = sel_screen.width; + let sh = sel_screen.height; + + // Top + draw_quad( + renderer, + Rectangle::new(Point::new(sx, sy), Size::new(sw, BORDER_WIDTH)), + BORDER_COLOR, + ); + + // Bottom + draw_quad( + renderer, + Rectangle::new( + Point::new(sx, sy + sh - BORDER_WIDTH), + Size::new(sw, BORDER_WIDTH), + ), + BORDER_COLOR, + ); + + // Left + draw_quad( + renderer, + Rectangle::new(Point::new(sx, sy), Size::new(BORDER_WIDTH, sh)), + BORDER_COLOR, + ); + + // Right + draw_quad( + renderer, + Rectangle::new( + Point::new(sx + sw - BORDER_WIDTH, sy), + Size::new(BORDER_WIDTH, sh), + ), + BORDER_COLOR, + ); + } + + /// Draw resize handles. + fn draw_handles(&self, renderer: &mut Renderer, img_rect: Rectangle, scale: f32) { + let Some((x, y, w, h)) = self.selection.region else { + return; + }; + + let half = HANDLE_SIZE / 2.0; + + // 8 handle positions + let handles = [ + self.image_to_screen(x, y, img_rect, scale), + self.image_to_screen(x + w, y, img_rect, scale), + self.image_to_screen(x, y + h, img_rect, scale), + self.image_to_screen(x + w, y + h, img_rect, scale), + self.image_to_screen(x + w / 2.0, y, img_rect, scale), + self.image_to_screen(x + w / 2.0, y + h, img_rect, scale), + self.image_to_screen(x, y + h / 2.0, img_rect, scale), + self.image_to_screen(x + w, y + h / 2.0, img_rect, scale), + ]; + + for pos in handles { + draw_quad( + renderer, + Rectangle::new( + Point::new(pos.x - half, pos.y - half), + Size::new(HANDLE_SIZE, HANDLE_SIZE), + ), + HANDLE_COLOR, + ); + } + } +} + +impl Widget for CropWidget { + fn size(&self) -> Size { + Size::new(Length::Fill, Length::Fill) + } + + fn layout(&self, _tree: &mut Tree, _renderer: &Renderer, limits: &Limits) -> Node { + Node::new(limits.max()) + } + + fn draw( + &self, + _tree: &Tree, + renderer: &mut Renderer, + _theme: &cosmic::Theme, + _style: &cosmic::iced::advanced::renderer::Style, + layout: Layout<'_>, + _cursor: Cursor, + _viewport: &Rectangle, + ) { + let bounds = layout.bounds(); + let (img_rect, scale) = self.calculate_image_rect(bounds); + + // Draw image + renderer.draw_image( + self.handle.clone(), + cosmic::iced::widget::image::FilterMethod::Linear, + img_rect, + cosmic::iced::Radians(0.0), + 1.0, + [0.0; 4], + ); + + // Draw crop UI + self.draw_overlay(renderer, bounds, img_rect, scale); + self.draw_border(renderer, img_rect, scale); + self.draw_handles(renderer, img_rect, scale); + } + + fn on_event( + &mut self, + _tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: Cursor, + _renderer: &Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, AppMessage>, + _viewport: &Rectangle, + ) -> Status { + let bounds = layout.bounds(); + let (img_rect, scale) = self.calculate_image_rect(bounds); + + match event { + Event::Mouse(mouse::Event::ButtonPressed(Button::Left)) => { + if let Some(screen_pos) = cursor.position_in(bounds) { + // Only handle clicks inside image area + if !img_rect.contains(screen_pos) { + return Status::Ignored; + } + + let handle = self.hit_test_handle(screen_pos, img_rect, scale); + let (img_x, img_y) = self.screen_to_image(screen_pos, img_rect, scale); + + shell.publish(AppMessage::CropDragStart { + x: img_x, + y: img_y, + handle, + }); + return Status::Captured; + } + } + Event::Mouse(mouse::Event::CursorMoved { .. }) => { + if self.selection.is_dragging { + if let Some(screen_pos) = cursor.position_in(bounds) { + let (img_x, img_y) = self.screen_to_image(screen_pos, img_rect, scale); + shell.publish(AppMessage::CropDragMove { + x: img_x, + y: img_y, + }); + return Status::Captured; + } + } + } + Event::Mouse(mouse::Event::ButtonReleased(Button::Left)) => { + if self.selection.is_dragging { + shell.publish(AppMessage::CropDragEnd); + return Status::Captured; + } + } + _ => {} + } + + Status::Ignored + } + + fn mouse_interaction( + &self, + _tree: &Tree, + layout: Layout<'_>, + cursor: Cursor, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + let bounds = layout.bounds(); + let (img_rect, scale) = self.calculate_image_rect(bounds); + + if let Some(screen_pos) = cursor.position_in(bounds) { + if img_rect.contains(screen_pos) { + let handle = self.hit_test_handle(screen_pos, img_rect, scale); + return match handle { + DragHandle::TopLeft | DragHandle::BottomRight => { + mouse::Interaction::ResizingDiagonallyDown + } + DragHandle::TopRight | DragHandle::BottomLeft => { + mouse::Interaction::ResizingDiagonallyUp + } + DragHandle::Top | DragHandle::Bottom => { + mouse::Interaction::ResizingVertically + } + DragHandle::Left | DragHandle::Right => { + mouse::Interaction::ResizingHorizontally + } + DragHandle::Move => mouse::Interaction::Grabbing, + DragHandle::None => mouse::Interaction::Crosshair, + }; + } + } + + mouse::Interaction::None + } +} + +impl<'a> From for Element<'a, AppMessage> { + fn from(widget: CropWidget) -> Self { + Element::new(widget) + } +} + +/// Helper: Check if point is within handle hit area. +fn point_in_handle(point: Point, handle_center: Point) -> bool { + let half = HANDLE_HIT_SIZE / 2.0; + point.x >= handle_center.x - half + && point.x <= handle_center.x + half + && point.y >= handle_center.y - half + && point.y <= handle_center.y + half +} + +/// Helper: Draw a filled quad. +fn draw_quad(renderer: &mut Renderer, bounds: Rectangle, color: Color) { + renderer.fill_quad( + Quad { + bounds, + ..Quad::default() + }, + color, + ); +} + +/// Public constructor function (convenience). +pub fn crop_widget<'a>( + handle: Handle, + img_width: u32, + img_height: u32, + selection: &CropSelection, +) -> Element<'a, AppMessage> { + CropWidget::new(handle, img_width, img_height, selection).into() +} diff --git a/src/ui/widgets/mod.rs b/src/ui/widgets/mod.rs index f73ca8c..02e237e 100644 --- a/src/ui/widgets/mod.rs +++ b/src/ui/widgets/mod.rs @@ -4,6 +4,7 @@ // Custom widgets module. pub mod crop_types; -// pub mod crop_widget; // TODO: Implement next +pub mod crop_widget; pub use crop_types::{CropRegion, CropSelection, DragHandle}; +pub use crop_widget::crop_widget;