feat(iced): implement VectorSelect click-to-select with shape hit-testing

This commit is contained in:
2026-07-16 04:51:30 +03:00
parent eb01ec98e1
commit f375048eac
3 changed files with 168 additions and 10 deletions
+123 -1
View File
@@ -256,6 +256,14 @@ 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)>,
}
/// A recently opened file entry.
@@ -613,6 +621,14 @@ pub enum Message {
VectorDrawMove(f32, f32),
VectorDrawEnd,
// ── Vector select / edit ────────────────────────────
VectorSelectClick { x: f32, y: f32 },
VectorSelectDragStart { x: f32, y: f32 },
VectorSelectDragMove { x: f32, y: f32 },
VectorSelectDragEnd,
VectorShapeDelete,
VectorShapeSelect(Option<usize>),
// ── Lasso / polygon selection ───────────────────────
LassoDragMove(f32, f32),
LassoDragEnd,
@@ -1062,6 +1078,10 @@ 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,
};
// Apply saved settings to tool state
@@ -1627,6 +1647,14 @@ impl HcieIcedApp {
Message::CropDragStart(sx, sy)
});
}
Tool::VectorSelect => {
// Click to select vector shapes under cursor.
let cx = canvas_x;
let cy = canvas_y;
return Task::perform(async move {}, move |_| {
Message::VectorSelectClick { x: cx, y: cy }
});
}
_ => {}
}
}
@@ -1753,6 +1781,15 @@ impl HcieIcedApp {
Message::CropDragMove(mx, my)
});
}
// Handle vector select drag
if self.tool_state.active_tool == Tool::VectorSelect && self.vector_drag_last.is_some() {
let mx = x;
let my = y;
return Task::perform(async move {}, move |_| {
Message::VectorSelectDragMove { x: mx, y: my }
});
}
}
Message::CanvasPointerReleased => {
@@ -1817,6 +1854,11 @@ impl HcieIcedApp {
if self.tool_state.active_tool == Tool::Crop {
return Task::perform(async {}, |_| Message::CropDragEnd);
}
// End vector select drag
if self.tool_state.active_tool == Tool::VectorSelect && self.vector_drag_last.is_some() {
return Task::perform(async {}, |_| Message::VectorSelectDragEnd);
}
}
Message::CanvasPointerRightClicked { x: _, y: _, screen_x, screen_y } => {
@@ -3312,6 +3354,81 @@ impl HcieIcedApp {
}
}
// ── Vector select / edit ─────────────────────
Message::VectorSelectClick { x, y } => {
// 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.vector_draw = None; // clear any preview
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;
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() {
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 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,
);
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.
self.vector_drag_last = None;
self.vector_edit_handle = hcie_engine_api::VectorEditHandle::None;
}
}
Message::VectorShapeDelete => {
if let Some(idx) = self.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) {
if idx < shapes.len() {
shapes.remove(idx);
}
}
self.selected_vector_shape = None;
self.vector_shapes_snapshot = None;
self.documents[self.active_doc].engine.mark_composite_dirty();
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
Message::VectorShapeSelect(idx) => {
self.selected_vector_shape = idx;
if idx.is_some() {
let snapshot = self.documents[self.active_doc].engine.active_vector_shapes();
self.vector_shapes_snapshot = snapshot;
} else {
self.vector_shapes_snapshot = None;
}
}
// ── Lasso / polygon selection ─────────────────
Message::LassoDragMove(x, y) => {
let cx = x.round().max(0.0) as u32;
@@ -4636,13 +4753,18 @@ impl HcieIcedApp {
// ── Contextual key actions ───────────────────────
Message::EscapePressed => {
// Mirrors egui's Escape handling. Priority: viewer > crop >
// transform > selection > dialog > viewer fallback.
// transform > vector selection > selection > dialog > viewer fallback.
if self.viewer_active {
return self.update(Message::ViewerToggle);
} else if self.documents[self.active_doc].crop_state.active {
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;
self.vector_shapes_snapshot = None;
self.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()
{
@@ -103,7 +103,8 @@ pub fn dock_view<'a>(
crate::panels::layer_details::view(&doc.cached_layers, doc.engine.active_layer_id(), colors)
}
PaneType::Geometry => {
crate::panels::geometry::view(&[], colors)
let shapes = app.documents[app.active_doc].engine.active_vector_shapes().unwrap_or_default();
crate::panels::geometry::view(shapes, colors, app.selected_vector_shape)
}
PaneType::ToolSettings => {
let fg = app.fg_color;
@@ -5,12 +5,12 @@ use crate::app::Message;
use crate::panels::styles;
use crate::theme::ThemeColors;
use hcie_engine_api::VectorShape;
use iced::widget::{column, container, horizontal_rule, scrollable, text};
use iced::widget::{column, horizontal_rule, scrollable, text};
use iced::{Element, Length};
/// Build the geometry panel for vector shapes.
/// Panel title is in the dock tab — no duplicate inside the panel.
pub fn view<'a>(shapes: &'a [VectorShape], colors: ThemeColors) -> Element<'a, Message> {
pub fn view(shapes: Vec<VectorShape>, colors: ThemeColors, selected_shape: Option<usize>) -> Element<'static, Message> {
let panel = if shapes.is_empty() {
column![
text("No vector shapes").size(11),
@@ -18,33 +18,68 @@ pub fn view<'a>(shapes: &'a [VectorShape], colors: ThemeColors) -> Element<'a, M
]
} else {
let mut shape_list = column![].spacing(2);
let shape_count = shapes.len();
for (i, shape) in shapes.iter().enumerate() {
let (shape_name, shape_info) = match shape {
for (i, shape) in shapes.into_iter().enumerate() {
let (shape_name, shape_info) = match &shape {
VectorShape::Line { x1, y1, x2, y2, stroke, .. } => ("Line", format!("({:.0},{:.0})→({:.0},{:.0}) w:{:.1}", x1, y1, x2, y2, stroke)),
VectorShape::Rect { x1, y1, x2, y2, fill, stroke, .. } => ("Rect", format!("{}×{} fill:{} stroke:{:.1}", (x2-x1).abs() as u32, (y2-y1).abs() as u32, fill, stroke)),
VectorShape::Circle { x1, y1, x2, y2, fill, stroke, .. } => ("Circle", format!("r:{:.0} fill:{} stroke:{:.1}", ((x2-x1).abs() + (y2-y1).abs()) / 4.0, fill, stroke)),
_ => ("Shape", "Custom".to_string()),
};
let item = container(
let is_selected = selected_shape == Some(i);
let c = colors;
let item = iced::widget::button(
column![text(format!("{}. {}", i + 1, shape_name)).size(11), text(shape_info).size(9)].spacing(2)
)
.on_press(Message::VectorShapeSelect(Some(i)))
.width(Length::Fill)
.padding([4, 6])
.style(move |_theme| styles::inactive_item_bg(colors));
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
match status {
iced::widget::button::Status::Active => {
if is_selected {
iced::widget::button::Style {
background: Some(iced::Background::Color(c.accent)),
text_color: iced::Color::WHITE,
..Default::default()
}
} else {
iced::widget::button::Style {
background: Some(iced::Background::Color(c.bg_hover)),
text_color: c.text_primary,
..Default::default()
}
}
}
iced::widget::button::Status::Hovered => {
iced::widget::button::Style {
background: Some(iced::Background::Color(if is_selected { c.accent } else { c.bg_active })),
text_color: iced::Color::WHITE,
..Default::default()
}
}
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(c.bg_hover)),
text_color: c.text_primary,
..Default::default()
},
}
});
shape_list = shape_list.push(item);
}
column![
text(format!("{} shapes", shapes.len())).size(10),
text(format!("{} shapes", shape_count)).size(10),
horizontal_rule(1),
scrollable(shape_list).height(Length::Fill),
]
};
container(panel.spacing(0).padding(4))
iced::widget::container(panel.spacing(0).padding(4))
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| styles::panel_background(colors))