feat(iced): implement VectorEditHandle rendering and drag/resize/rotate

This commit is contained in:
2026-07-16 05:05:21 +03:00
parent f375048eac
commit 76da953a17
3 changed files with 366 additions and 29 deletions
+138 -22
View File
@@ -256,12 +256,8 @@ pub struct HcieIcedApp {
/// Dirty flag set whenever fg/bg/recent colors change, so colors can be
/// persisted to disk (egui persists these under the `hcie_colors` key).
pub colors_dirty: bool,
/// Index of the currently selected vector shape (None = no selection).
pub selected_vector_shape: Option<usize>,
/// Snapshot of vector shapes before editing (for undo/Cancel).
pub vector_shapes_snapshot: Option<Vec<hcie_engine_api::VectorShape>>,
/// Which transform handle is being dragged on the selected vector shape.
pub vector_edit_handle: hcie_engine_api::VectorEditHandle,
/// Last drag position during vector shape editing (canvas coordinates).
pub vector_drag_last: Option<(f32, f32)>,
}
@@ -344,6 +340,10 @@ pub struct IcedDocument {
pub selection_edge_cache: crate::canvas::edge_cache::SelectionEdgeCache,
/// Extracted selection edges for overlay rendering.
pub marching_ants_edges: Option<Arc<Vec<(u32, u32, u32, u32)>>>,
/// Index of the currently selected vector shape (None = no selection).
pub selected_vector_shape: Option<usize>,
/// Which vector edit handle is active (hovered or being dragged).
pub vector_edit_handle: hcie_engine_api::VectorEditHandle,
}
/// In-progress text layer being authored with the Text tool.
@@ -992,6 +992,8 @@ impl HcieIcedApp {
text_draft: None,
selection_edge_cache: Default::default(),
marching_ants_edges: None,
selected_vector_shape: None,
vector_edit_handle: hcie_engine_api::VectorEditHandle::None,
};
// Load saved settings
@@ -1078,9 +1080,7 @@ impl HcieIcedApp {
show_dock_profile_dialog: false,
language: i18n::Language::default(),
colors_dirty: false,
selected_vector_shape: None,
vector_shapes_snapshot: None,
vector_edit_handle: hcie_engine_api::VectorEditHandle::None,
vector_drag_last: None,
};
@@ -1191,6 +1191,86 @@ impl HcieIcedApp {
}
}
/// Hit-test which vector edit handle is at the given canvas-space position.
///
/// Checks rotation handle first (circle area), then resize handles (square
/// hit-box), then the move handle (inside bounding box). Returns the
/// matching `VectorEditHandle` variant. Coordinates are in canvas-space.
fn vector_hit_test_handle(&self, cx: f32, cy: f32) -> hcie_engine_api::VectorEditHandle {
use hcie_engine_api::VectorEditHandle as VEH;
let doc = &self.documents[self.active_doc];
let Some(idx) = doc.selected_vector_shape else {
return VEH::None;
};
let layer_id = doc.engine.active_layer_id();
let shapes = match doc.engine.active_vector_shapes() {
Some(s) => s,
None => return VEH::None,
};
let shape = match shapes.get(idx) {
Some(s) => s,
None => return VEH::None,
};
let (x1, y1, x2, y2) = shape.normalized_bounds();
let angle = doc.engine.vector_shape_angle(layer_id, idx);
let w = (x2 - x1).abs();
let h = (y2 - y1).abs();
if w < 0.5 || h < 0.5 { return VEH::None; }
let bcx = (x1 + x2) / 2.0;
let bcy = (y1 + y2) / 2.0;
let cos_a = angle.cos();
let sin_a = angle.sin();
// Inverse-rotate a canvas-space point around the bounding-box center.
let unrotate = |px: f32, py: f32| -> (f32, f32) {
let dx = px - bcx;
let dy = py - bcy;
(bcx + dx * cos_a + dy * sin_a, bcy - dx * sin_a + dy * cos_a)
};
let (lx, ly) = unrotate(cx, cy);
// Check rotation handle first (outside the box).
let rotate_offset = 24.0;
let rot_circ_x = bcx;
let rot_circ_y = y1 - rotate_offset;
let rot_radius = 6.0;
let drx = lx - rot_circ_x;
let dry = ly - rot_circ_y;
if drx * drx + dry * dry <= rot_radius * rot_radius {
return VEH::Rotate;
}
// Check 8 resize handles.
let handle_half = 7.0;
let handle_points: [(VEH, f32, f32); 8] = [
(VEH::TopLeft, x1, y1),
(VEH::TopRight, x2, y1),
(VEH::BottomLeft, x1, y2),
(VEH::BottomRight, x2, y2),
(VEH::Top, bcx, y1),
(VEH::Bottom, bcx, y2),
(VEH::Left, x1, bcy),
(VEH::Right, x2, bcy),
];
for &(handle, hx, hy) in &handle_points {
if (lx - hx).abs() <= handle_half && (ly - hy).abs() <= handle_half {
return handle;
}
}
// Check move handle (inside bounding box).
if lx >= x1 && lx <= x2 && ly >= y1 && ly <= y2 {
return VEH::Move;
}
VEH::None
}
/// Add a file to the recent files list (most recent first, max 20) and persist to disk.
@@ -2962,6 +3042,8 @@ impl HcieIcedApp {
text_draft: None,
selection_edge_cache: Default::default(),
marching_ants_edges: None,
selected_vector_shape: None,
vector_edit_handle: hcie_engine_api::VectorEditHandle::None,
});
self.active_doc = self.documents.len() - 1;
self.active_dialog = ActiveDialog::None;
@@ -3356,55 +3438,87 @@ impl HcieIcedApp {
// ── Vector select / edit ─────────────────────
Message::VectorSelectClick { x, y } => {
use hcie_engine_api::VectorEditHandle as VEH;
// If a shape is already selected, hit-test its edit handles first.
if self.documents[self.active_doc].selected_vector_shape.is_some() {
let handle = self.vector_hit_test_handle(x, y);
if handle != VEH::None && handle != VEH::Move {
// Clicked on a resize/rotate handle — start drag with that handle.
self.documents[self.active_doc].vector_edit_handle = handle;
self.vector_drag_last = Some((x, y));
return Task::none();
}
}
// Hit-test vector shapes under cursor; select the topmost one.
if let Some(idx) = self.documents[self.active_doc].engine.find_vector_shape_at(x, y) {
// Save snapshot before editing (for undo).
let snapshot = self.documents[self.active_doc].engine.active_vector_shapes();
self.vector_shapes_snapshot = snapshot;
self.selected_vector_shape = Some(idx);
// Start a drag from this position (move on drag, deselect on release without drag).
let doc = &mut self.documents[self.active_doc];
doc.selected_vector_shape = Some(idx);
doc.vector_draw = None; // clear any preview
doc.vector_edit_handle = VEH::Move;
self.vector_drag_last = Some((x, y));
self.vector_edit_handle = hcie_engine_api::VectorEditHandle::Move;
} else {
// No shape under cursor — deselect.
self.selected_vector_shape = None;
let doc = &mut self.documents[self.active_doc];
doc.selected_vector_shape = None;
doc.vector_edit_handle = VEH::None;
self.vector_shapes_snapshot = None;
self.vector_edit_handle = hcie_engine_api::VectorEditHandle::None;
self.vector_drag_last = None;
}
}
Message::VectorSelectDragStart { x, y } => {
if self.selected_vector_shape.is_some() {
if self.documents[self.active_doc].selected_vector_shape.is_some() {
// Hit-test vector edit handles to determine drag mode.
let handle = self.vector_hit_test_handle(x, y);
self.documents[self.active_doc].vector_edit_handle = handle;
self.vector_drag_last = Some((x, y));
}
}
Message::VectorSelectDragMove { x, y } => {
if let (Some(idx), Some((last_x, last_y))) = (self.selected_vector_shape, self.vector_drag_last) {
let idx = self.documents[self.active_doc].selected_vector_shape;
if let (Some(idx), Some((last_x, last_y))) = (idx, self.vector_drag_last) {
let dx = x - last_x;
let dy = y - last_y;
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
let handle = self.documents[self.active_doc].vector_edit_handle;
match handle {
hcie_engine_api::VectorEditHandle::Rotate => {
// Compute rotation delta from mouse movement.
if let Some((cx, cy)) = self.documents[self.active_doc].engine.get_vector_shape_center(layer_id, idx) {
let angle_start = (last_y - cy).atan2(last_x - cx);
let angle_end = (y - cy).atan2(x - cx);
let delta = angle_end - angle_start;
let current = self.documents[self.active_doc].engine.vector_shape_angle(layer_id, idx);
self.documents[self.active_doc].engine.set_vector_shape_angle(layer_id, idx, current + delta);
}
}
_ => {
self.documents[self.active_doc].engine.modify_vector_shape(
layer_id,
idx,
self.vector_edit_handle,
handle,
dx,
dy,
);
}
}
self.vector_drag_last = Some((x, y));
self.refresh_composite_if_needed();
}
}
Message::VectorSelectDragEnd => {
if self.vector_drag_last.is_some() {
// Commit the move: if shapes changed, push history snapshot.
// Commit the move/resize/rotate: if shapes changed, push history snapshot.
self.vector_drag_last = None;
self.vector_edit_handle = hcie_engine_api::VectorEditHandle::None;
self.documents[self.active_doc].vector_edit_handle = hcie_engine_api::VectorEditHandle::None;
}
}
Message::VectorShapeDelete => {
if let Some(idx) = self.selected_vector_shape {
if let Some(idx) = self.documents[self.active_doc].selected_vector_shape {
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
// Remove the shape from the layer's shape list.
if let Some(shapes) = self.documents[self.active_doc].engine.get_layer_shapes_direct(layer_id) {
@@ -3412,7 +3526,7 @@ impl HcieIcedApp {
shapes.remove(idx);
}
}
self.selected_vector_shape = None;
self.documents[self.active_doc].selected_vector_shape = None;
self.vector_shapes_snapshot = None;
self.documents[self.active_doc].engine.mark_composite_dirty();
self.refresh_composite_if_needed();
@@ -3420,7 +3534,7 @@ impl HcieIcedApp {
}
}
Message::VectorShapeSelect(idx) => {
self.selected_vector_shape = idx;
self.documents[self.active_doc].selected_vector_shape = idx;
if idx.is_some() {
let snapshot = self.documents[self.active_doc].engine.active_vector_shapes();
self.vector_shapes_snapshot = snapshot;
@@ -4193,6 +4307,8 @@ impl HcieIcedApp {
text_draft: None,
selection_edge_cache: Default::default(),
marching_ants_edges: None,
selected_vector_shape: None,
vector_edit_handle: hcie_engine_api::VectorEditHandle::None,
});
}
@@ -4760,10 +4876,10 @@ impl HcieIcedApp {
return self.update(Message::CropCancel);
} else if self.documents[self.active_doc].selection_transform.is_some() {
return self.update(Message::TransformCancel);
} else if self.selected_vector_shape.is_some() {
self.selected_vector_shape = None;
} else if self.documents[self.active_doc].selected_vector_shape.is_some() {
self.documents[self.active_doc].selected_vector_shape = None;
self.vector_shapes_snapshot = None;
self.vector_edit_handle = hcie_engine_api::VectorEditHandle::None;
self.documents[self.active_doc].vector_edit_handle = hcie_engine_api::VectorEditHandle::None;
self.vector_drag_last = None;
} else if self.documents[self.active_doc].selection_bounds.is_some()
|| self.documents[self.active_doc].selection_rect.is_some()
@@ -72,6 +72,12 @@ struct OverlayProgram {
gradient_drag: Option<((f32, f32), (f32, f32))>,
/// Extracted edges for marching ants rendering.
marching_ants_edges: Option<std::sync::Arc<Vec<(u32, u32, u32, u32)>>>,
/// Normalized bounds of the selected vector shape in canvas-space (x1, y1, x2, y2).
selected_vector_bounds: Option<(f32, f32, f32, f32)>,
/// Rotation angle (radians) of the selected vector shape.
selected_vector_angle: f32,
/// Which vector edit handle is active (hovered or being dragged).
vector_edit_handle: hcie_engine_api::VectorEditHandle,
}
/// Overlay state — tracks hover and cursor position for crosshair.
@@ -325,6 +331,195 @@ impl OverlayProgram {
}
}
}
/// Draw transform handles for selected vector shapes.
///
/// Renders a blue selection outline, 8 white resize handles at corners/edges,
/// and a rotation handle (line + blue circle) above the top-center. All
/// geometry is rotated by `angle` around the bounding-box center.
fn draw_vector_edit_handles(
&self,
frame: &mut Frame,
x1: f32, y1: f32, x2: f32, y2: f32,
angle: f32,
origin_x: f32,
origin_y: f32,
) {
let cx = (x1 + x2) / 2.0;
let cy = (y1 + y2) / 2.0;
let w = (x2 - x1).abs();
let h = (y2 - y1).abs();
if w < 0.5 || h < 0.5 { return; }
let cos_a = angle.cos();
let sin_a = angle.sin();
// Helper: rotate a canvas-space point around (cx, cy) by angle.
let rotate = |px: f32, py: f32| -> (f32, f32) {
let dx = px - cx;
let dy = py - cy;
(cx + dx * cos_a - dy * sin_a, cy + dx * sin_a + dy * cos_a)
};
let line_width = 1.5 / self.zoom.max(1.0);
// ── Blue selection outline ─────────────────────────────────────────
let outline_color = iced::Color::from_rgb(0.2, 0.5, 1.0);
let corners: [(f32, f32); 4] = [(x1, y1), (x2, y1), (x2, y2), (x1, y2)];
let rotated_corners: Vec<(f32, f32)> = corners.iter().map(|&(px, py)| rotate(px, py)).collect();
let outline = Path::new(|b| {
for (i, &(px, py)) in rotated_corners.iter().enumerate() {
let sx = origin_x + px * self.zoom;
let sy = origin_y + py * self.zoom;
if i == 0 { b.move_to(Point::new(sx, sy)); }
else { b.line_to(Point::new(sx, sy)); }
}
b.close();
});
frame.stroke(&outline, Stroke {
style: canvas::stroke::Style::Solid(outline_color),
width: line_width,
..Default::default()
});
// ── 8 resize handles (white squares with black stroke) ─────────────
let handle_size = 8.0 / self.zoom.max(1.0);
let half = handle_size / 2.0;
let white = iced::Color::from_rgb(1.0, 1.0, 1.0);
let black = iced::Color::from_rgb(0.0, 0.0, 0.0);
let handle_points: [(f32, f32); 8] = [
(x1, y1), // TopLeft
(x2, y1), // TopRight
(x1, y2), // BottomLeft
(x2, y2), // BottomRight
(cx, y1), // Top
(cx, y2), // Bottom
(x1, cy), // Left
(x2, cy), // Right
];
for &(px, py) in &handle_points {
let (rpx, rpy) = rotate(px, py);
let sx = origin_x + rpx * self.zoom;
let sy = origin_y + rpy * self.zoom;
let rect = Path::rectangle(
Point::new(sx - half, sy - half),
Size::new(handle_size, handle_size),
);
frame.fill(&rect, white);
frame.stroke(&rect, Stroke {
style: canvas::stroke::Style::Solid(black),
width: line_width,
..Default::default()
});
}
// ── Rotation handle (line from top-center upward + blue circle) ────
let rotate_offset = 24.0 / self.zoom.max(1.0);
let (rot_line_start_x, rot_line_start_y) = rotate(cx, y1);
let (rot_circ_x, rot_circ_y) = rotate(cx, y1 - rotate_offset);
let rot_circ_radius = 4.0 / self.zoom.max(1.0);
// Line from top-center to rotation circle
let rot_line = Path::line(
Point::new(origin_x + rot_line_start_x * self.zoom, origin_y + rot_line_start_y * self.zoom),
Point::new(origin_x + rot_circ_x * self.zoom, origin_y + rot_circ_y * self.zoom),
);
frame.stroke(&rot_line, Stroke {
style: canvas::stroke::Style::Solid(black),
width: line_width,
..Default::default()
});
// Blue rotation circle
let rot_circle = Path::circle(
Point::new(origin_x + rot_circ_x * self.zoom, origin_y + rot_circ_y * self.zoom),
rot_circ_radius,
);
let blue = iced::Color::from_rgb(0.2, 0.5, 1.0);
frame.fill(&rot_circle, blue);
frame.stroke(&rot_circle, Stroke {
style: canvas::stroke::Style::Solid(black),
width: line_width,
..Default::default()
});
}
/// Hit-test which vector edit handle is at the given viewport position.
///
/// Checks rotation handle first (circle area), then resize handles (square
/// hit-box), then the move handle (expanded bounding box). Returns the
/// matching `VectorEditHandle` variant.
pub fn hit_test_vector_handle(
&self,
viewport_x: f32,
viewport_y: f32,
) -> hcie_engine_api::VectorEditHandle {
use hcie_engine_api::VectorEditHandle as VEH;
let Some((x1, y1, x2, y2)) = self.selected_vector_bounds else {
return VEH::None;
};
let (origin_x, origin_y, _, _) = self.canvas_origin(iced::Size::new(0.0, 0.0));
// Convert viewport to canvas coordinates
let canvas_x = (viewport_x - origin_x) / self.zoom;
let canvas_y = (viewport_y - origin_y) / self.zoom;
let cx = (x1 + x2) / 2.0;
let cy = (y1 + y2) / 2.0;
let angle = self.selected_vector_angle;
let cos_a = angle.cos();
let sin_a = angle.sin();
// Inverse-rotate a canvas-space point around (cx, cy) by -angle.
let unrotate = |px: f32, py: f32| -> (f32, f32) {
let dx = px - cx;
let dy = py - cy;
(cx + dx * cos_a + dy * sin_a, cy - dx * sin_a + dy * cos_a)
};
let (lx, ly) = unrotate(canvas_x, canvas_y);
// ── Check rotation handle first (outside the box) ──────────────────
let rotate_offset = 24.0 / self.zoom.max(1.0);
let rot_circ_x = cx;
let rot_circ_y = y1 - rotate_offset;
let rot_radius = 6.0 / self.zoom.max(1.0);
let drx = lx - rot_circ_x;
let dry = ly - rot_circ_y;
if drx * drx + dry * dry <= rot_radius * rot_radius {
return VEH::Rotate;
}
// ── Check 8 resize handles ────────────────────────────────────────
let handle_half = 7.0 / self.zoom.max(1.0);
let handle_points: [(VEH, f32, f32); 8] = [
(VEH::TopLeft, x1, y1),
(VEH::TopRight, x2, y1),
(VEH::BottomLeft, x1, y2),
(VEH::BottomRight, x2, y2),
(VEH::Top, cx, y1),
(VEH::Bottom, cx, y2),
(VEH::Left, x1, cy),
(VEH::Right, x2, cy),
];
for &(handle, hx, hy) in &handle_points {
if (lx - hx).abs() <= handle_half && (ly - hy).abs() <= handle_half {
return handle;
}
}
// ── Check move handle (inside bounding box) ────────────────────────
if lx >= x1 && lx <= x2 && ly >= y1 && ly <= y2 {
return VEH::Move;
}
VEH::None
}
}
impl canvas::Program<Message> for OverlayProgram {
@@ -568,6 +763,11 @@ impl canvas::Program<Message> for OverlayProgram {
}
}
// Draw vector edit handles for selected shape (blue outline, resize + rotation)
if let Some((x1, y1, x2, y2)) = self.selected_vector_bounds {
self.draw_vector_edit_handles(&mut frame, x1, y1, x2, y2, self.selected_vector_angle, origin_x, origin_y);
}
// Draw gradient endpoint preview line.
if let Some(((gx0, gy0), (gx1, gy1))) = self.gradient_drag {
let p0 = Point::new(origin_x + gx0 * self.zoom, origin_y + gy0 * self.zoom);
@@ -735,6 +935,24 @@ pub fn view<'a>(
.height(Length::Fill);
// ── Overlay canvas (selection, vector preview, crosshair, pressure HUD) ──────
// Compute selected vector shape bounds for handle rendering.
let (sel_vec_bounds, sel_vec_angle) = if let Some(idx) = doc.selected_vector_shape {
let layer_id = doc.engine.active_layer_id();
if let Some(shapes) = doc.engine.active_vector_shapes() {
if let Some(shape) = shapes.get(idx) {
let (x1, y1, x2, y2) = shape.normalized_bounds();
let angle = doc.engine.vector_shape_angle(layer_id, idx);
(Some((x1, y1, x2, y2)), angle)
} else {
(None, 0.0)
}
} else {
(None, 0.0)
}
} else {
(None, 0.0)
};
let overlay_program = OverlayProgram {
engine_w,
engine_h,
@@ -751,6 +969,9 @@ pub fn view<'a>(
active_tool: tool_state.active_tool,
gradient_drag: doc.gradient_drag,
marching_ants_edges: doc.marching_ants_edges.clone(),
selected_vector_bounds: sel_vec_bounds,
selected_vector_angle: sel_vec_angle,
vector_edit_handle: doc.vector_edit_handle,
};
let overlay_canvas = canvas(overlay_program)
@@ -104,7 +104,7 @@ pub fn dock_view<'a>(
}
PaneType::Geometry => {
let shapes = app.documents[app.active_doc].engine.active_vector_shapes().unwrap_or_default();
crate::panels::geometry::view(shapes, colors, app.selected_vector_shape)
crate::panels::geometry::view(shapes, colors, app.documents[app.active_doc].selected_vector_shape)
}
PaneType::ToolSettings => {
let fg = app.fg_color;