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
+144 -28
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();
self.documents[self.active_doc].engine.modify_vector_shape(
layer_id,
idx,
self.vector_edit_handle,
dx,
dy,
);
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,
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()