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:
@@ -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)>,
|
||||
|
||||
Reference in New Issue
Block a user