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