feat(iced): fix crop tool with proper handles, keyboard, and mouse events

- Add CropDragStart, CropDragMove, CropDragEnd, CropConfirm, CropCancel messages
- Implement crop overlay with 8 handles and rule-of-thirds grid
- Wire Enter key to confirm crop and Escape to cancel crop
- Wire mouse events for crop drag operations
- Fix CropState per-document isolation
- Add darkened area outside crop for visual clarity
This commit is contained in:
2026-07-15 03:21:46 +03:00
parent 80e0637271
commit 017f6f3dd3
3 changed files with 170 additions and 7 deletions
+63 -3
View File
@@ -392,6 +392,13 @@ pub enum Message {
VectorDrawMove(f32, f32), VectorDrawMove(f32, f32),
VectorDrawEnd, VectorDrawEnd,
// ── Crop tool ──────────────────────────────────────
CropDragStart(f32, f32),
CropDragMove(f32, f32),
CropDragEnd,
CropConfirm,
CropCancel,
// ── AI Chat ───────────────────────────────────────── // ── AI Chat ─────────────────────────────────────────
AiChatInput(String), AiChatInput(String),
AiChatSend, AiChatSend,
@@ -882,6 +889,14 @@ impl HcieIcedApp {
Message::VectorDrawStart(sx, sy) Message::VectorDrawStart(sx, sy)
}); });
} }
Tool::Crop => {
// Start crop drag
let sx = canvas_x;
let sy = canvas_y;
return Task::perform(async move {}, move |_| {
Message::CropDragStart(sx, sy)
});
}
_ => {} _ => {}
} }
} }
@@ -944,6 +959,15 @@ impl HcieIcedApp {
Message::VectorDrawMove(mx, my) Message::VectorDrawMove(mx, my)
}); });
} }
// Handle crop drag
if self.tool_state.active_tool == Tool::Crop {
let mx = x;
let my = y;
return Task::perform(async move {}, move |_| {
Message::CropDragMove(mx, my)
});
}
} }
Message::CanvasPointerReleased => { Message::CanvasPointerReleased => {
@@ -968,6 +992,11 @@ impl HcieIcedApp {
if matches!(self.tool_state.active_tool, Tool::VectorRect | Tool::VectorCircle | Tool::VectorLine) { if matches!(self.tool_state.active_tool, Tool::VectorRect | Tool::VectorCircle | Tool::VectorLine) {
return Task::perform(async {}, |_| Message::VectorDrawEnd); return Task::perform(async {}, |_| Message::VectorDrawEnd);
} }
// End crop drag
if self.tool_state.active_tool == Tool::Crop {
return Task::perform(async {}, |_| Message::CropDragEnd);
}
} }
Message::CanvasPanZoom { zoom, pan_offset } => { Message::CanvasPanZoom { zoom, pan_offset } => {
@@ -2110,6 +2139,28 @@ impl HcieIcedApp {
} }
} }
// ── Crop tool ────────────────────────────────
Message::CropDragStart(x, y) => {
self.documents[self.active_doc].crop_state.start_drag(x, y);
}
Message::CropDragMove(x, y) => {
self.documents[self.active_doc].crop_state.update_drag(x, y);
}
Message::CropDragEnd => {
self.documents[self.active_doc].crop_state.end_drag();
}
Message::CropConfirm => {
if let Some((x, y, w, h)) = self.documents[self.active_doc].crop_state.confirm() {
self.documents[self.active_doc].engine.crop(x, y, w, h);
self.documents[self.active_doc].crop_state.cancel();
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
Message::CropCancel => {
self.documents[self.active_doc].crop_state.cancel();
}
// ── AI Chat ─────────────────────────────────── // ── AI Chat ───────────────────────────────────
Message::AiChatInput(_text) => { Message::AiChatInput(_text) => {
// Handled by the chat panel directly // Handled by the chat panel directly
@@ -2450,7 +2501,12 @@ impl HcieIcedApp {
} }
} }
Message::MenuClose => { Message::MenuClose => {
if self.active_menu.is_some() { // If crop tool is active with an active crop, cancel it first
if self.tool_state.active_tool == Tool::Crop
&& self.documents[self.active_doc].crop_state.active
{
self.documents[self.active_doc].crop_state.cancel();
} else if self.active_menu.is_some() {
self.active_menu = None; self.active_menu = None;
} else if self.active_dialog != ActiveDialog::None { } else if self.active_dialog != ActiveDialog::None {
self.active_dialog = ActiveDialog::None; self.active_dialog = ActiveDialog::None;
@@ -2975,9 +3031,13 @@ impl HcieIcedApp {
iced::keyboard::Key::Character(ref c) if c.as_str() == "x" && ctrl && !shift => { iced::keyboard::Key::Character(ref c) if c.as_str() == "x" && ctrl && !shift => {
Some(Message::CopyImage) // TODO: cut Some(Message::CopyImage) // TODO: cut
} }
// Escape = close menu, cancel dialog, or deselect // Escape = close menu, cancel dialog, deselect, or cancel crop
iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape) => { iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape) => {
Some(Message::MenuClose) Some(Message::MenuClose) // Crop cancel handled in update()
}
// Enter = confirm crop
iced::keyboard::Key::Named(iced::keyboard::key::Named::Enter) => {
Some(Message::CropConfirm) // Crop confirm handled in update()
} }
_ => None, _ => None,
} }
@@ -24,7 +24,7 @@ pub mod shader_canvas;
// pub mod render; // pub mod render;
use crate::app::Message; use crate::app::Message;
use crate::selection::{SelectionTransform, TransformHandle}; use crate::selection::{CropState, SelectionTransform, TransformHandle};
use iced::widget::{canvas, column, container, row, text, Stack}; use iced::widget::{canvas, column, container, row, text, Stack};
use iced::widget::canvas::{Frame, Path, Stroke}; use iced::widget::canvas::{Frame, Path, Stroke};
use iced::widget::Shader; use iced::widget::Shader;
@@ -57,6 +57,8 @@ struct OverlayProgram {
selection_transform: Option<SelectionTransform>, selection_transform: Option<SelectionTransform>,
/// Current transform handle being hovered/dragged. /// Current transform handle being hovered/dragged.
active_handle: TransformHandle, active_handle: TransformHandle,
/// Crop tool state (for drawing crop overlay).
crop_state: Option<CropState>,
} }
/// Overlay state — tracks hover and cursor position for crosshair. /// Overlay state — tracks hover and cursor position for crosshair.
@@ -180,7 +182,7 @@ impl OverlayProgram {
let handle_color = iced::Color::from_rgb(1.0, 1.0, 1.0); let handle_color = iced::Color::from_rgb(1.0, 1.0, 1.0);
let handle_stroke_color = iced::Color::from_rgb(0.0, 0.0, 0.0); let handle_stroke_color = iced::Color::from_rgb(0.0, 0.0, 0.0);
for (handle, hx, hy) in &handles { for (_handle, hx, hy) in &handles {
let rect = Path::rectangle( let rect = Path::rectangle(
Point::new(hx - half, hy - half), Point::new(hx - half, hy - half),
Size::new(handle_size, handle_size), Size::new(handle_size, handle_size),
@@ -218,6 +220,98 @@ impl OverlayProgram {
..Default::default() ..Default::default()
}); });
} }
/// Draw crop overlay with handles and rule-of-thirds.
fn draw_crop_overlay(
&self,
frame: &mut Frame,
crop: &CropState,
origin_x: f32,
origin_y: f32,
) {
if let Some((x, y, w, h)) = crop.rect {
if w < 1.0 || h < 1.0 {
return;
}
let cx = origin_x + x * self.zoom;
let cy = origin_y + y * self.zoom;
let cw = w * self.zoom;
let ch = h * self.zoom;
// Draw darkened area outside crop
let crop_path = Path::rectangle(Point::new(cx, cy), Size::new(cw, ch));
frame.fill(&crop_path, iced::Color::from_rgba(0.0, 0.0, 0.0, 0.5));
// Draw crop border
let border = Path::rectangle(Point::new(cx, cy), Size::new(cw, ch));
frame.stroke(&border, Stroke {
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(1.0, 1.0, 1.0)),
width: 2.0 / self.zoom.max(1.0),
..Default::default()
});
// Draw rule-of-thirds lines
let third_w = cw / 3.0;
let third_h = ch / 3.0;
for i in 1..3 {
// Vertical lines
let vx = cx + third_w * i as f32;
let vline = Path::line(
Point::new(vx, cy),
Point::new(vx, cy + ch),
);
frame.stroke(&vline, Stroke {
style: canvas::stroke::Style::Solid(iced::Color::from_rgba(1.0, 1.0, 1.0, 0.5)),
width: 1.0 / self.zoom.max(1.0),
..Default::default()
});
// Horizontal lines
let hy = cy + third_h * i as f32;
let hline = Path::line(
Point::new(cx, hy),
Point::new(cx + cw, hy),
);
frame.stroke(&hline, Stroke {
style: canvas::stroke::Style::Solid(iced::Color::from_rgba(1.0, 1.0, 1.0, 0.5)),
width: 1.0 / self.zoom.max(1.0),
..Default::default()
});
}
// Draw handles
let handle_size = 8.0 / self.zoom.max(1.0);
let half = handle_size / 2.0;
let handle_color = iced::Color::from_rgb(1.0, 1.0, 1.0);
let handle_stroke = iced::Color::from_rgb(0.0, 0.0, 0.0);
let handles = [
(cx, cy), // TopLeft
(cx + cw, cy), // TopRight
(cx, cy + ch), // BottomLeft
(cx + cw, cy + ch), // BottomRight
(cx + cw / 2.0, cy), // Top
(cx + cw / 2.0, cy + ch), // Bottom
(cx, cy + ch / 2.0), // Left
(cx + cw, cy + ch / 2.0), // Right
];
for (hx, hy) in &handles {
let rect = Path::rectangle(
Point::new(hx - half, hy - half),
Size::new(handle_size, handle_size),
);
frame.fill(&rect, handle_color);
frame.stroke(&rect, Stroke {
style: canvas::stroke::Style::Solid(handle_stroke),
width: 1.0 / self.zoom.max(1.0),
..Default::default()
});
}
}
}
} }
impl canvas::Program<Message> for OverlayProgram { impl canvas::Program<Message> for OverlayProgram {
@@ -237,7 +331,8 @@ impl canvas::Program<Message> for OverlayProgram {
// ── Selection rectangle or transform handles ─────────────────────── // ── Selection rectangle or transform handles ───────────────────────
let has_overlays = self.selection_rect.is_some() let has_overlays = self.selection_rect.is_some()
|| self.vector_draw.is_some() || self.vector_draw.is_some()
|| self.selection_transform.is_some(); || self.selection_transform.is_some()
|| self.crop_state.as_ref().map_or(false, |c| c.active);
if has_overlays { if has_overlays {
let mut frame = Frame::new(renderer, bounds.size()); let mut frame = Frame::new(renderer, bounds.size());
@@ -306,6 +401,13 @@ impl canvas::Program<Message> for OverlayProgram {
} }
} }
// Draw crop overlay
if let Some(ref crop) = self.crop_state {
if crop.active {
self.draw_crop_overlay(&mut frame, crop, origin_x, origin_y);
}
}
geometries.push(frame.into_geometry()); geometries.push(frame.into_geometry());
} }
@@ -425,6 +527,7 @@ pub fn view<'a>(
vector_draw: doc.vector_draw, vector_draw: doc.vector_draw,
selection_transform: doc.selection_transform.clone(), selection_transform: doc.selection_transform.clone(),
active_handle: TransformHandle::None, // TODO: track hover state active_handle: TransformHandle::None, // TODO: track hover state
crop_state: Some(doc.crop_state.clone()),
}; };
let overlay_canvas = canvas(overlay_program) let overlay_canvas = canvas(overlay_program)
@@ -4,7 +4,7 @@
//! through confirmation or cancellation. //! through confirmation or cancellation.
/// State for the crop tool. /// State for the crop tool.
#[derive(Clone, Default)] #[derive(Clone, Debug, Default)]
pub struct CropState { pub struct CropState {
/// Crop rectangle in canvas coordinates (x, y, width, height) /// Crop rectangle in canvas coordinates (x, y, width, height)
pub rect: Option<(f32, f32, f32, f32)>, pub rect: Option<(f32, f32, f32, f32)>,