feat(svg-editor): Implement SVG shape loading and editing functionality

- Add SVG shape loading from disk, allowing users to customize shapes by placing SVG files in the designated directory.
- Introduce a new `ShapeCatalog` to manage SVG shapes, prioritizing user-defined shapes over built-in templates.
- Implement an SVG editor with node-based editing capabilities, enabling users to manipulate SVG paths directly.
- Create a new `SvgEditable` structure to represent editable SVG shapes, supporting operations like adding, removing, and moving nodes.
- Enhance the GUI with an SVG editor panel for visual editing of SVG shapes, including controls for node manipulation and saving changes.
- Ensure compatibility with existing shape tools and maintain a seamless user experience across the application.
This commit is contained in:
2026-07-19 13:50:17 +03:00
parent 5632c916ee
commit 6e0d179be9
20 changed files with 1723 additions and 25 deletions
@@ -176,6 +176,9 @@ pub struct HcieApp {
/// Used to keep documents in the center area and prevent swapping with utility panels.
pub canonical_doc_node: Option<egui_dock::NodePath>,
/// SVG editor modal state. `Some` when the editor is open.
pub svg_editor_state: Option<panels::svg_editor_panel::SvgEditorState>,
pub paste_counter: u64,
/// Hash of last clipboard image to detect new clipboard content.
pub clipboard_hash: u64,
@@ -631,6 +634,7 @@ impl HcieApp {
app_icon: None,
pending_paste_data: None,
hide_panels: false,
svg_editor_state: None,
};
if std::env::var("FLOAT_TEST_PANELS").is_ok() {
app.panels_to_float.push(dock::HciePane::Brushes);
@@ -1178,6 +1182,41 @@ impl HcieApp {
}
ctx.request_repaint();
}
AppEvent::OpenSvgEditor {
layer_id,
shape_idx,
} => {
if let Some(doc) = self.documents.get(self.active_doc) {
if let Some(shapes) = doc.engine.active_vector_shapes() {
if let Some(shape) = shapes.get(shape_idx) {
if let hcie_engine_api::VectorShape::SvgShape { svg, .. } = shape {
match hcie_engine_api::SvgEditable::from_svg(svg) {
Ok(editable) => {
self.svg_editor_state = Some(
panels::svg_editor_panel::SvgEditorState {
layer_id,
shape_idx,
editable,
selected_poly: 0,
selected_node: None,
drag_node: None,
rendered_preview: None,
},
);
}
Err(e) => {
self.status_message = Some((
format!("SVG Editor: {}", e),
std::time::Instant::now()
+ std::time::Duration::from_secs(3),
));
}
}
}
}
}
}
}
AppEvent::ToolTextStart { x, y } => {
if self.state.text_edit.active {
let (layer_id, draft_text) = (
@@ -4812,6 +4851,11 @@ impl eframe::App for HcieApp {
}
self.ui_main_assembly(&ctx);
// SVG Editor modal (overlays the main UI)
if self.svg_editor_state.is_some() {
panels::svg_editor_panel::show_svg_editor(self, &ctx);
}
// Dev-only: handle screenshot events triggered by F12
if self.screenshot_requested {
let mut captured = None;
@@ -1,3 +1,4 @@
mod panels;
pub use panels::*;
pub mod color;
pub mod svg_editor_panel;
@@ -1733,6 +1733,23 @@ pub fn show_properties(
}
changed |= ui.add(PlainSlider::new("Hardness", &mut state.tool_configs.vector.hardness, 0.0..=1.0, state.settings.theme)).changed();
// Edit SVG button for CustomShape (visible in Properties panel without switching to VectorSelect)
if matches!(state.active_tool, Tool::CustomShape(_)) {
if let Some(shape_idx) = state.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(shape_idx) {
if matches!(shape, hcie_engine_api::VectorShape::SvgShape { .. }) {
ui.add_space(4.0);
if ui.button("Edit SVG").on_hover_text("Open SVG node editor").clicked() {
event_bus.push(crate::event_bus::AppEvent::OpenSvgEditor { layer_id, shape_idx });
}
}
}
}
}
}
}
Tool::Text => {
crate::app::tools::text_editor::draw_text_properties(doc, state, event_bus, ui);
@@ -1748,8 +1765,20 @@ pub fn show_properties(
let layer_id = doc.engine.active_layer_id();
ui.label(RichText::new(format!("Shape #{}", shape_idx + 1)).strong().color(colors.accent));
ui.add_space(4.0);
if let Some(shapes) = doc.engine.active_vector_shapes() {
if let Some(shape) = shapes.get(shape_idx) {
if let Some(shapes) = doc.engine.active_vector_shapes() {
if let Some(shape) = shapes.get(shape_idx) {
// ── Edit SVG button for SvgShape variants ──
if matches!(shape, hcie_engine_api::VectorShape::SvgShape { .. }) {
ui.horizontal(|ui| {
if ui.button("Edit SVG").on_hover_text("Open SVG node editor").clicked() {
event_bus.push(crate::event_bus::AppEvent::OpenSvgEditor {
layer_id,
shape_idx,
});
}
});
ui.add_space(4.0);
}
let stroke = shape.stroke();
let mut new_stroke = stroke;
changed |= ui.add(PlainSlider::new("Stroke", &mut new_stroke, 0.0..=100.0, state.settings.theme).suffix("px")).changed();
@@ -0,0 +1,434 @@
use crate::app::{HcieApp, ThemeColors};
use eframe::egui;
use egui::Vec2;
use hcie_engine_api::SvgEditable;
// ── SvgEditorState ────────────────────────────────────────────────────────────
/// Runtime state for the SVG editor panel.
pub struct SvgEditorState {
pub layer_id: u64,
pub shape_idx: usize,
pub editable: SvgEditable,
pub selected_poly: usize,
pub selected_node: Option<usize>,
pub drag_node: Option<(usize, usize)>, // (poly_idx, node_idx)
pub rendered_preview: Option<egui::TextureHandle>,
}
// ── Coordinate helpers ────────────────────────────────────────────────────────
/// Map an SVG viewBox coordinate to a pixel position within the canvas rect.
fn svg_to_screen(
sx: f32,
sy: f32,
vb: (f32, f32, f32, f32),
canvas_rect: egui::Rect,
) -> egui::Pos2 {
let (vbx, vby, vbw, vbh) = vb;
let margin = 16.0;
let draw_w = canvas_rect.width() - margin * 2.0;
let draw_h = canvas_rect.height() - margin * 2.0;
let scale = (draw_w / vbw).min(draw_h / vbh).max(1.0);
let ox = canvas_rect.left() + margin + (draw_w - vbw * scale) * 0.5;
let oy = canvas_rect.top() + margin + (draw_h - vbh * scale) * 0.5;
egui::pos2(ox + (sx - vbx) * scale, oy + (sy - vby) * scale)
}
/// Map a pixel position back to SVG viewBox coordinates.
fn screen_to_svg(
p: egui::Pos2,
vb: (f32, f32, f32, f32),
canvas_rect: egui::Rect,
) -> (f32, f32) {
let (vbx, vby, vbw, vbh) = vb;
let margin = 16.0;
let draw_w = canvas_rect.width() - margin * 2.0;
let draw_h = canvas_rect.height() - margin * 2.0;
let scale = (draw_w / vbw).min(draw_h / vbh).max(1.0);
let ox = canvas_rect.left() + margin + (draw_w - vbw * scale) * 0.5;
let oy = canvas_rect.top() + margin + (draw_h - vbh * scale) * 0.5;
((p.x - ox) / scale + vbx, (p.y - oy) / scale + vby)
}
// ── Render ────────────────────────────────────────────────────────────────────
pub fn show_svg_editor(app: &mut HcieApp, ctx: &egui::Context) {
let colors = ThemeColors::get(app.state.settings.theme);
// Extract state pointer — safe because we don't re-borrow app inside the window closure.
let state_ptr: *mut SvgEditorState = match &mut app.svg_editor_state {
Some(s) => s,
None => return,
};
let mut action: SvgEditorAction = SvgEditorAction::None;
let window = egui::Window::new("SVG Editor")
.id(egui::Id::new("svg_editor_window"))
.default_size(ctx.used_size() * 0.6)
.min_size([400.0, 300.0])
.collapsible(false)
.resizable(true)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]);
window.show(ctx, |ui| {
// SAFETY: We do not access `app` through any other path inside this closure.
let state = unsafe { &mut *state_ptr };
let available = ui.available_size();
let canvas_height = (available.y - 120.0).max(200.0);
let vb = state.editable.view_box;
// ── Canvas area ─────────────────────────────────────────────────
let (canvas_rect, canvas_response) = ui.allocate_exact_size(
Vec2::new(ui.available_width(), canvas_height),
egui::Sense::click_and_drag(),
);
let painter = ui.painter_at(canvas_rect);
// Draw background
painter.rect_filled(canvas_rect, 0.0, colors.bg_dark_depth);
// Draw filled polygon (preview)
draw_polygons(&painter, &state.editable, vb, canvas_rect, colors);
// Draw nodes
draw_nodes(&painter, &state.editable, vb, canvas_rect, colors, state);
// Draw edge midpoints
draw_midpoints(&painter, &state.editable, vb, canvas_rect, colors, state);
// ── Mouse handling ─────────────────────────────────────────────
if canvas_response.hovered() {
let pointer_pos = ui.input(|i| i.pointer.interact_pos());
if let Some(pos) = pointer_pos {
if canvas_response.clicked() {
// Try to select a node
let hit = hit_test_node(&state.editable, vb, canvas_rect, pos);
if let Some((pi, ni)) = hit {
state.selected_poly = pi;
state.selected_node = Some(ni);
} else {
// Try to add a node on midpoint click
let mid_hit = hit_test_midpoint(&state.editable, vb, canvas_rect, pos);
if let Some((pi, ei)) = mid_hit {
state.editable.add_node(pi, ei);
state.selected_poly = pi;
state.selected_node = Some((ei + 1) % state.editable.polygons[pi].len());
} else {
state.selected_node = None;
}
}
}
// Drag existing node
if canvas_response.dragged_by(egui::PointerButton::Primary) {
if let Some((pi, ni)) = state.drag_node {
let (sx, sy) = screen_to_svg(pos, vb, canvas_rect);
state.editable.move_node(pi, ni, sx, sy);
state.rendered_preview = None;
}
}
}
}
// Track drag start/stop
if canvas_response.drag_started_by(egui::PointerButton::Primary) {
if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
let hit = hit_test_node(&state.editable, vb, canvas_rect, pos);
if let Some((pi, ni)) = hit {
state.selected_poly = pi;
state.selected_node = Some(ni);
state.drag_node = Some((pi, ni));
}
}
}
if state.drag_node.is_some() && !canvas_response.dragged_by(egui::PointerButton::Primary) {
// Drag ended this frame
state.drag_node = None;
}
ui.add_space(8.0);
// ── Polygon selector and controls ──────────────────────────────
ui.horizontal(|ui| {
ui.label("Polygon:");
let poly_count = state.editable.polygons.len();
let mut poly_sel = state.selected_poly;
egui::ComboBox::from_id_salt("svg_poly_sel")
.width(120.0)
.selected_text(format!("#{} ({} nodes)", poly_sel + 1, {
if poly_sel < state.editable.polygons.len() {
state.editable.polygons[poly_sel].len()
} else {
0
}
}))
.show_ui(ui, |ui| {
for i in 0..poly_count {
let label =
format!("#{} ({} nodes)", i + 1, state.editable.polygons[i].len());
ui.selectable_value(&mut poly_sel, i, label);
}
});
state.selected_poly = poly_sel.min(poly_count.max(1) - 1);
ui.separator();
if ui.button(" Add Node").on_hover_text("Add node after selected")
.clicked()
{
if let Some(ni) = state.selected_node {
state.editable.add_node(state.selected_poly, ni);
state.selected_node = Some(ni + 1);
state.rendered_preview = None;
}
}
if ui.button("✕ Remove").on_hover_text("Remove selected node")
.clicked()
{
if let Some(ni) = state.selected_node {
state.editable.remove_node(state.selected_poly, ni);
state.selected_node = None;
state.rendered_preview = None;
}
}
});
ui.add_space(8.0);
// ── Node list ──────────────────────────────────────────────────
if state.selected_poly < state.editable.polygons.len() {
let poly = &state.editable.polygons[state.selected_poly];
egui::ScrollArea::vertical()
.max_height(80.0)
.show(ui, |ui| {
for (i, node) in poly.iter().enumerate() {
let is_sel = state.selected_node == Some(i);
let label = format!("#{}: ({:.1}, {:.1})", i, node.x, node.y);
if ui.selectable_label(is_sel, label).clicked() {
state.selected_node = Some(i);
}
}
});
}
ui.add_space(8.0);
// ── Close / Save / Cancel ──────────────────────────────────────
ui.horizontal(|ui| {
if ui.button("Save").clicked() {
action = SvgEditorAction::Save;
}
if ui.button("Cancel").clicked() {
action = SvgEditorAction::Close;
}
});
});
match action {
SvgEditorAction::Save => {
let action_layer_id;
let action_shape_idx;
let new_svg;
{
let state = unsafe { &mut *state_ptr };
new_svg = state.editable.to_svg();
action_layer_id = state.layer_id;
action_shape_idx = state.shape_idx;
}
if let Some(doc) = app.documents.get_mut(app.active_doc) {
doc.engine
.set_vector_shape_svg(action_layer_id, action_shape_idx, &new_svg);
app.event_bus.push(crate::event_bus::AppEvent::RenderRequested);
}
app.svg_editor_state = None;
}
SvgEditorAction::Close => {
app.svg_editor_state = None;
}
SvgEditorAction::None => {}
}
}
#[derive(PartialEq)]
enum SvgEditorAction {
None,
Save,
Close,
}
// ── Drawing helpers ───────────────────────────────────────────────────────────
fn draw_polygons(
painter: &egui::Painter,
editable: &SvgEditable,
vb: (f32, f32, f32, f32),
canvas_rect: egui::Rect,
colors: ThemeColors,
) {
let fill_color = egui::Color32::from_rgba_premultiplied(
colors.accent.r(),
colors.accent.g(),
colors.accent.b(),
60,
);
let stroke = egui::Stroke::new(1.5, colors.text_primary);
for poly in &editable.polygons {
if poly.len() < 2 {
continue;
}
let points: Vec<egui::Pos2> = poly
.iter()
.map(|n| svg_to_screen(n.x, n.y, vb, canvas_rect))
.collect();
// Fill
if poly.len() >= 3 {
painter.add(egui::Shape::convex_polygon(
points.clone(),
fill_color,
stroke,
));
} else {
// Line
painter.add(egui::Shape::line_segment(
[points[0], points[1]],
stroke,
));
}
// Outline edges
for i in 0..poly.len() {
let a = points[i];
let b = points[(i + 1) % poly.len()];
painter.add(egui::Shape::line_segment([a, b], stroke));
}
}
}
fn draw_nodes(
painter: &egui::Painter,
editable: &SvgEditable,
vb: (f32, f32, f32, f32),
canvas_rect: egui::Rect,
colors: ThemeColors,
state: &SvgEditorState,
) {
let node_radius = 5.0;
let fill = egui::Color32::WHITE;
let selected_fill = colors.accent;
for (pi, poly) in editable.polygons.iter().enumerate() {
for (ni, node) in poly.iter().enumerate() {
let pos = svg_to_screen(node.x, node.y, vb, canvas_rect);
let is_sel = pi == state.selected_poly && state.selected_node == Some(ni);
let color = if is_sel { selected_fill } else { fill };
painter.circle(pos, node_radius, color, egui::Stroke::new(1.5, colors.text_primary));
}
}
}
fn draw_midpoints(
painter: &egui::Painter,
editable: &SvgEditable,
vb: (f32, f32, f32, f32),
canvas_rect: egui::Rect,
colors: ThemeColors,
_state: &SvgEditorState,
) {
let _ = _state;
let mid_radius = 3.0;
let fill = egui::Color32::from_gray(180);
for (_pi, poly) in editable.polygons.iter().enumerate() {
if poly.len() < 2 {
continue;
}
for ei in 0..poly.len() {
let a = svg_to_screen(poly[ei].x, poly[ei].y, vb, canvas_rect);
let b = svg_to_screen(poly[(ei + 1) % poly.len()].x, poly[(ei + 1) % poly.len()].y, vb, canvas_rect);
let mid = egui::pos2((a.x + b.x) * 0.5, (a.y + b.y) * 0.5);
let is_hovered = {
let pointer_pos = painter.ctx().input(|i| i.pointer.interact_pos());
pointer_pos.map_or(false, |p| p.distance(mid) < 8.0)
};
let color = if is_hovered { colors.accent } else { fill };
painter.circle(mid, mid_radius, color, egui::Stroke::new(1.0, colors.text_secondary));
}
}
}
// ── Hit testing ───────────────────────────────────────────────────────────────
fn hit_test_node(
editable: &SvgEditable,
vb: (f32, f32, f32, f32),
canvas_rect: egui::Rect,
pos: egui::Pos2,
) -> Option<(usize, usize)> {
let threshold = 8.0;
let mut best: Option<(usize, usize, f32)> = None;
for (pi, poly) in editable.polygons.iter().enumerate() {
for (ni, node) in poly.iter().enumerate() {
let sp = svg_to_screen(node.x, node.y, vb, canvas_rect);
let dist = pos.distance(sp);
if dist < threshold {
let better = best.as_ref().map_or(true, |(_, _, d)| dist < *d);
if better {
best = Some((pi, ni, dist));
}
}
}
}
best.map(|(pi, ni, _)| (pi, ni))
}
fn hit_test_midpoint(
editable: &SvgEditable,
vb: (f32, f32, f32, f32),
canvas_rect: egui::Rect,
pos: egui::Pos2,
) -> Option<(usize, usize)> {
let threshold = 8.0;
let mut best: Option<(usize, usize, f32)> = None;
for (pi, poly) in editable.polygons.iter().enumerate() {
if poly.len() < 2 {
continue;
}
for ei in 0..poly.len() {
let a = svg_to_screen(poly[ei].x, poly[ei].y, vb, canvas_rect);
let b = svg_to_screen(
poly[(ei + 1) % poly.len()].x,
poly[(ei + 1) % poly.len()].y,
vb,
canvas_rect,
);
let mid = egui::pos2((a.x + b.x) * 0.5, (a.y + b.y) * 0.5);
let dist = pos.distance(mid);
if dist < threshold {
let better = best.as_ref().map_or(true, |(_, _, d)| dist < *d);
if better {
best = Some((pi, ei, dist));
}
}
}
}
best.map(|(pi, ei, _)| (pi, ei))
}
// ── Save ──────────────────────────────────────────────────────────────────────
fn save_svg(app: &mut HcieApp) {
let state = match &app.svg_editor_state {
Some(s) => s,
None => return,
};
let new_svg = state.editable.to_svg();
let layer_id = state.layer_id;
let shape_idx = state.shape_idx;
if let Some(doc) = app.documents.get_mut(app.active_doc) {
doc.engine
.set_vector_shape_svg(layer_id, shape_idx, &new_svg);
app.event_bus.push(crate::event_bus::AppEvent::RenderRequested);
}
}
@@ -272,6 +272,28 @@ pub fn show_geometry(
.size(11.0),
);
});
// ── Edit SVG button for SvgShape variants ────────────────────
{
let svg_shapes = doc.engine.active_vector_shapes();
if let Some(shapes) = svg_shapes {
if shape_idx < shapes.len() {
if matches!(&shapes[shape_idx], VectorShape::SvgShape { .. }) {
let layer_id = doc.engine.active_layer_id();
ui.horizontal(|ui| {
ui.add_space(8.0);
if ui.button("Edit SVG").on_hover_text("Open SVG node editor").clicked() {
event_bus.push(AppEvent::OpenSvgEditor {
layer_id,
shape_idx,
});
}
});
ui.add_space(2.0);
}
}
}
}
ui.add_space(4.0);
let shapes = match doc.engine.active_vector_shapes() {
@@ -58,6 +58,7 @@ fn tool_icon_source(tool: Tool) -> Option<egui::ImageSource<'static>> {
Tool::VectorCrescent => Some(icon!("vector_crescent.svg")),
Tool::VectorBolt => Some(icon!("vector_bolt.svg")),
Tool::VectorArrow4 => Some(icon!("vector_arrow4.svg")),
Tool::CustomShape(_) => None, // loaded dynamically via shape catalog
}
}
@@ -245,9 +246,12 @@ pub fn show_tool_panel_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
});
// Render active popup (if any) on top of everything
// (custom shape popup is rendered inside render_custom_shape_slot)
if let Some(slot_idx) = app.state.active_popup_slot {
if let Some(anchor) = app.state.popup_anchor_pos {
show_slot_popup(app, ui.ctx(), slot_idx, anchor);
if slot_idx < TOOL_SLOTS.len() {
if let Some(anchor) = app.state.popup_anchor_pos {
show_slot_popup(app, ui.ctx(), slot_idx, anchor);
}
}
}
}
@@ -449,6 +453,9 @@ fn render_tool_slots_single_column(app: &mut HcieApp, ui: &mut egui::Ui) {
let _ = resp.on_hover_text(last_used_tool.label());
}
// ── Custom shape slot ──
render_custom_shape_slot(app, ui);
}
fn render_tool_slots_two_column(app: &mut HcieApp, ui: &mut egui::Ui) {
@@ -498,6 +505,26 @@ fn render_tool_slots_two_column(app: &mut HcieApp, ui: &mut egui::Ui) {
}
ui.add_space(row_height);
}
// ── Custom shape slot (full width in two-column mode) ──
let engine = app.documents.get(app.active_doc).map(|d| &d.engine);
let catalog = match engine {
Some(e) => &e.shape_catalog,
None => return,
};
if !catalog.is_empty() {
let top = ui.min_rect().bottom();
// Use one column width for the slot
let x = ui.min_rect().left();
let slot_rect = egui::Rect::from_min_size(
egui::pos2(x, top),
egui::vec2(SLOT_W, TOOL_BTN_SIZE),
);
let _ = ui.allocate_ui_at_rect(slot_rect, |ui| {
ui.set_width(SLOT_W);
render_custom_shape_slot(app, ui);
});
}
}
fn handle_slot_response(
@@ -570,3 +597,205 @@ fn handle_slot_response(
let _ = resp.on_hover_text(last_used_tool.label());
}
/// Render a single slot for custom SVG shapes with a popup listing all entries.
fn render_custom_shape_slot(app: &mut HcieApp, ui: &mut egui::Ui) {
let engine = app
.documents
.get(app.active_doc)
.map(|d| &d.engine);
let catalog = match engine {
Some(e) => &e.shape_catalog,
None => return,
};
if catalog.is_empty() {
return;
}
let theme = app.state.settings.theme;
let colors = ThemeColors::get(theme);
// Determine the currently selected custom shape index
let current_idx = match app.state.active_tool {
Tool::CustomShape(i) => Some(i),
_ => None,
};
// Button label from the catalog
let _entry = current_idx.and_then(|i| catalog.get(i));
let _label = _entry.map(|e| e.label.as_str()).unwrap_or("Custom Shape");
let icon_str = _entry.map(|_| "").unwrap_or("");
let slot_idx = usize::MAX; // custom slot sentinel
let is_active = current_idx.is_some();
let has_submenu = catalog.len() > 1;
let icon = tool_icon_source(app.state.active_tool);
let fallback = if icon.is_none() {
Some(icon_str.to_string())
} else {
None
};
let layout_id = format!("toolbox.custom_shape_slot");
let resp = ui.add(
ToolSlotButton::new(icon, fallback, is_active, has_submenu, theme)
.layout_id(layout_id.clone()),
);
let resp = crate::app::shell::gui_layout::track(&layout_id, ui, &resp);
let resp_rect = resp.rect;
let resp_clicked = resp.clicked();
let resp_secondary = resp.secondary_clicked();
let pointer_down_on_this = resp.is_pointer_button_down_on();
if has_submenu {
let pressing_this = app
.state
.slot_press_start
.as_ref()
.map(|(idx, _)| *idx == slot_idx)
.unwrap_or(false);
if pointer_down_on_this && !pressing_this && app.state.active_popup_slot.is_none() {
app.state.slot_press_start = Some((slot_idx, Instant::now()));
}
if !pointer_down_on_this && pressing_this {
app.state.slot_press_start = None;
}
if let Some((idx, start)) = app.state.slot_press_start {
if idx == slot_idx
&& start.elapsed().as_millis() >= LONG_PRESS_MS
&& app.state.active_popup_slot.is_none()
{
let anchor = resp_rect.right_center();
app.state.active_popup_slot = Some(99); // custom slot popup marker
app.state.popup_anchor_pos = Some(anchor);
app.state.slot_press_start = None;
app.state.popup_opened_via_drag = true;
}
}
if resp_clicked && app.state.active_popup_slot.is_none() {
// Activate currently selected or first custom shape
let idx = current_idx.unwrap_or(0);
app.state.active_tool = Tool::CustomShape(idx);
app.event_bus.push(AppEvent::ToolChanged(app.state.active_tool));
}
if resp_secondary && app.state.active_popup_slot.is_none() {
let anchor = resp_rect.right_center();
app.state.active_popup_slot = Some(99);
app.state.popup_anchor_pos = Some(anchor);
app.state.popup_opened_via_drag = false;
}
} else {
if resp_clicked {
app.state.active_tool = Tool::CustomShape(0);
app.event_bus.push(AppEvent::ToolChanged(app.state.active_tool));
}
}
let _ = resp.on_hover_text(_label);
// ── Render custom shapes popup if active ──
if app.state.active_popup_slot == Some(99) {
if let Some(anchor) = app.state.popup_anchor_pos {
let popup_count = catalog.len() as f32;
let popup_h = (popup_count * POPUP_ROW_H + 8.0).min(400.0);
let screen = ui.ctx().content_rect();
let popup_pos = egui::pos2(
(anchor.x + 4.0)
.min(screen.max.x - POPUP_W - 4.0)
.max(screen.min.x + 4.0),
(anchor.y - popup_h / 2.0)
.min(screen.max.y - popup_h - 4.0)
.max(screen.min.y + 4.0),
);
let popup_id = egui::Id::new("custom_shape_popup");
let popup_response = egui::Area::new(popup_id)
.order(egui::Order::Foreground)
.fixed_pos(popup_pos)
.show(ui.ctx(), |ui| {
egui::Frame::NONE
.fill(colors.bg_elevated)
.stroke(egui::Stroke::new(1.0, colors.border_high))
.corner_radius(egui::CornerRadius::same(6))
.inner_margin(egui::Margin::same(4))
.show(ui, |ui| {
ui.set_min_width(POPUP_W);
egui::ScrollArea::vertical()
.max_height(popup_h)
.scroll_bar_visibility(
egui::scroll_area::ScrollBarVisibility::AlwaysHidden,
)
.show(ui, |ui| {
ui.spacing_mut().item_spacing = egui::vec2(0.0, 0.0);
let mut clicked_idx: Option<u32> = None;
for entry in catalog.all() {
let active = current_idx == Some(entry.index);
let r = ui.allocate_response(
egui::vec2(POPUP_W - 8.0, POPUP_ROW_H),
egui::Sense::click(),
);
let rect = r.rect;
if ui.is_rect_visible(rect) {
let fill = if active {
colors.accent.gamma_multiply(0.25)
} else if r.hovered() {
colors.bg_hover
} else {
egui::Color32::TRANSPARENT
};
if fill != egui::Color32::TRANSPARENT {
ui.painter().rect_filled(
rect,
egui::CornerRadius::same(4),
fill,
);
}
let tint = if active {
colors.accent
} else {
colors.text_primary
};
ui.painter().text(
egui::pos2(rect.left() + 6.0, rect.center().y),
egui::Align2::LEFT_CENTER,
&entry.label,
egui::FontId::proportional(12.0),
tint,
);
}
if r.clicked() {
clicked_idx = Some(entry.index);
}
}
if let Some(idx) = clicked_idx {
app.state.active_tool = Tool::CustomShape(idx);
app.event_bus.push(AppEvent::ToolChanged(
app.state.active_tool,
));
app.state.popup_close_request = true;
}
});
});
})
.response;
let popup_rect = popup_response.rect;
let pointer_pressed = ui.ctx().input(|i| i.pointer.any_pressed());
let pointer_in_popup = ui.ctx().input(|i| {
i.pointer
.hover_pos()
.map_or(false, |p| popup_rect.contains(p))
});
if pointer_pressed && !pointer_in_popup && !app.state.popup_close_request {
app.state.popup_close_request = true;
}
}
}
}
@@ -1301,8 +1301,14 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
x2 as f32,
y2 as f32,
&self.state,
&self.doc.engine.shape_catalog,
);
self.doc.engine.add_vector_shape(shape);
// Auto-select the newly created shape
if let Some(shapes) = self.doc.engine.active_vector_shapes() {
self.state.selected_vector_shape = Some(shapes.len() - 1);
self.state.vector_shapes_snapshot = Some(shapes.clone());
}
self.event_bus.push(AppEvent::RenderRequested);
self.event_bus.push(AppEvent::DrawingFinished);
}
@@ -2166,6 +2172,7 @@ fn create_shape_from_drag(
x2: f32,
y2: f32,
state: &ToolState,
catalog: &hcie_engine_api::ShapeCatalog,
) -> hcie_engine_api::VectorShape {
use hcie_engine_api::VectorShape;
let color = state.primary_color;
@@ -2334,6 +2341,26 @@ fn create_shape_from_drag(
_ => unreachable!(),
}
},
Tool::CustomShape(idx) => {
if let Some(entry) = catalog.get(idx) {
VectorShape::SvgShape {
x1, y1, x2, y2,
kind: entry.kind.clone(),
svg: entry.svg.clone(),
stroke, color, fill_color, fill,
angle: 0.0, opacity, hardness,
}
} else {
// Fallback: empty SVG rectangle
VectorShape::SvgShape {
x1, y1, x2, y2,
kind: "custom".to_string(),
svg: r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><path d="M 0,0 L 1,0 L 1,1 L 0,1 Z"/></svg>"#.to_string(),
stroke, color, fill_color, fill,
angle: 0.0, opacity, hardness,
}
}
},
_ => VectorShape::Line {
x1,
y1,
@@ -67,6 +67,10 @@ pub enum AppEvent {
// Vector
VectorShapeDeleted(u64, usize),
OpenSvgEditor {
layer_id: u64,
shape_idx: usize,
},
// Clipboard
ClipboardCopy,