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),
VectorDrawEnd,
// ── Crop tool ──────────────────────────────────────
CropDragStart(f32, f32),
CropDragMove(f32, f32),
CropDragEnd,
CropConfirm,
CropCancel,
// ── AI Chat ─────────────────────────────────────────
AiChatInput(String),
AiChatSend,
@@ -882,6 +889,14 @@ impl HcieIcedApp {
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)
});
}
// 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 => {
@@ -968,6 +992,11 @@ impl HcieIcedApp {
if matches!(self.tool_state.active_tool, Tool::VectorRect | Tool::VectorCircle | Tool::VectorLine) {
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 } => {
@@ -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 ───────────────────────────────────
Message::AiChatInput(_text) => {
// Handled by the chat panel directly
@@ -2450,7 +2501,12 @@ impl HcieIcedApp {
}
}
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;
} else if 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 => {
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) => {
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,
}
@@ -24,7 +24,7 @@ pub mod shader_canvas;
// pub mod render;
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::{Frame, Path, Stroke};
use iced::widget::Shader;
@@ -57,6 +57,8 @@ struct OverlayProgram {
selection_transform: Option<SelectionTransform>,
/// Current transform handle being hovered/dragged.
active_handle: TransformHandle,
/// Crop tool state (for drawing crop overlay).
crop_state: Option<CropState>,
}
/// 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_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(
Point::new(hx - half, hy - half),
Size::new(handle_size, handle_size),
@@ -218,6 +220,98 @@ impl OverlayProgram {
..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 {
@@ -237,7 +331,8 @@ impl canvas::Program<Message> for OverlayProgram {
// ── Selection rectangle or transform handles ───────────────────────
let has_overlays = self.selection_rect.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 {
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());
}
@@ -425,6 +527,7 @@ pub fn view<'a>(
vector_draw: doc.vector_draw,
selection_transform: doc.selection_transform.clone(),
active_handle: TransformHandle::None, // TODO: track hover state
crop_state: Some(doc.crop_state.clone()),
};
let overlay_canvas = canvas(overlay_program)
@@ -4,7 +4,7 @@
//! through confirmation or cancellation.
/// State for the crop tool.
#[derive(Clone, Default)]
#[derive(Clone, Debug, Default)]
pub struct CropState {
/// Crop rectangle in canvas coordinates (x, y, width, height)
pub rect: Option<(f32, f32, f32, f32)>,