feat(svg-editor): implement interactive SVG node editor with parsing and editing capabilities

feat(debug-logger): add DebugSignalLogger plugin for event logging and inspection

feat(plugins): introduce built-in plugins module and integrate debug logger

feat(plugin-integration): create integration layer for mapping Iced Messages to CoreEvents

feat(plugin-registry): establish PluginRegistry for managing plugins and event dispatching
This commit is contained in:
2026-07-20 01:43:22 +03:00
parent 6e0d179be9
commit 2fb47520b3
46 changed files with 4093 additions and 314 deletions
@@ -34,3 +34,4 @@ reqwest = { version = "0.12", features = ["json", "stream"] }
tokio = { version = "1", features = ["full"] }
futures-util = "0.3"
zip = { workspace = true }
usvg = { workspace = true }
@@ -140,6 +140,38 @@ impl Default for AiChatConfig {
}
}
/// ComfyUI connection status.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ComfyStatus {
Unknown,
Running,
Error,
}
impl Default for ComfyStatus {
fn default() -> Self {
Self::Unknown
}
}
/// State for the ComfyUI pipeline viewer tab.
#[derive(Debug, Clone)]
pub struct ComfyUiState {
/// ComfyUI server URL.
pub url: String,
/// Last known connection status.
pub status: ComfyStatus,
}
impl Default for ComfyUiState {
fn default() -> Self {
Self {
url: "http://127.0.0.1:8188".to_string(),
status: ComfyStatus::Unknown,
}
}
}
/// Complete AI chat state including messages, input buffer, and streaming state.
#[derive(Debug)]
pub struct AiChatState {
File diff suppressed because it is too large Load Diff
+31 -1
View File
@@ -19,6 +19,8 @@ pub struct ScreenshotRequest {
pub auto_hide_panel: Option<String>,
/// Whether the post-close welcome surface is activated before capture.
pub welcome: bool,
/// Whether a deterministic SVG editor fixture is opened before capture.
pub svg_editor: bool,
/// PNG output path supplied by the caller.
pub output: PathBuf,
}
@@ -73,6 +75,7 @@ where
floating_panel: None,
auto_hide_panel: None,
welcome: false,
svg_editor: false,
output: PathBuf::from(output),
});
index += 2;
@@ -87,6 +90,7 @@ where
floating_panel: Some(panel.to_owned()),
auto_hide_panel: None,
welcome: false,
svg_editor: false,
output: PathBuf::from(output),
});
index += 3;
@@ -101,6 +105,7 @@ where
floating_panel: None,
auto_hide_panel: None,
welcome: false,
svg_editor: false,
output: PathBuf::from(output),
});
index += 3;
@@ -114,6 +119,7 @@ where
floating_panel: None,
auto_hide_panel: None,
welcome: true,
svg_editor: false,
output: PathBuf::from(output),
});
index += 2;
@@ -128,11 +134,26 @@ where
floating_panel: None,
auto_hide_panel: Some(panel.to_owned()),
welcome: false,
svg_editor: false,
output: PathBuf::from(output),
});
index += 3;
continue;
}
"--screenshot-svg-editor" => {
ensure_no_screenshot(&parsed)?;
let output = required_operand(&tokens, index + 1, "--screenshot-svg-editor")?;
parsed.screenshot = Some(ScreenshotRequest {
panel: None,
floating_panel: None,
auto_hide_panel: None,
welcome: false,
svg_editor: true,
output: PathBuf::from(output),
});
index += 2;
continue;
}
_ if token.starts_with('-') => {
return Err(format!("unknown argument: {token}"));
}
@@ -173,7 +194,7 @@ fn ensure_no_screenshot(parsed: &CliArgs) -> Result<(), String> {
/// Returns the command-line help shown by the binary.
pub fn help_text() -> &'static str {
"Usage: hcie-iced [OPTIONS] [FILE]\n\nHCIE - Iced Edition\n\nOptions:\n -h, --help Print this help message\n --screenshot OUTPUT Capture the full viewport as PNG\n --screenshot-panel PANEL OUTPUT\n Capture a named panel as PNG\n --screenshot-welcome OUTPUT Capture the post-close welcome surface\n\nArguments:\n FILE Image file to open on startup"
"Usage: hcie-iced [OPTIONS] [FILE]\n\nHCIE - Iced Edition\n\nOptions:\n -h, --help Print this help message\n --screenshot OUTPUT Capture the full viewport as PNG\n --screenshot-panel PANEL OUTPUT\n Capture a named panel as PNG\n --screenshot-welcome OUTPUT Capture the post-close welcome surface\n --screenshot-svg-editor OUTPUT\n Capture the SVG node editor fixture\n\nArguments:\n FILE Image file to open on startup"
}
#[cfg(test)]
@@ -192,6 +213,7 @@ mod tests {
floating_panel: None,
auto_hide_panel: None,
welcome: false,
svg_editor: false,
output: PathBuf::from("full.png"),
})
);
@@ -203,6 +225,14 @@ mod tests {
assert_eq!(args.screenshot.unwrap().panel.as_deref(), Some("Geometry"));
}
#[test]
fn parses_svg_editor_screenshot() {
let args = parse_args(["--screenshot-svg-editor", "svg-editor.png"]).unwrap();
let screenshot = args.screenshot.expect("screenshot request");
assert!(screenshot.svg_editor);
assert_eq!(screenshot.output, PathBuf::from("svg-editor.png"));
}
#[test]
fn rejects_duplicate_positional_files() {
assert!(parse_args(["one.png", "two.png"]).is_err());
@@ -591,6 +591,14 @@ impl canvas::Program<Message> for ColorWheel {
return (canvas::event::Status::Captured, Some(self.message(color)));
}
}
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => {
if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) {
return (
canvas::event::Status::Captured,
Some(Message::BgColorChanged(color)),
);
}
}
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => {
if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) {
return (canvas::event::Status::Captured, Some(self.message(color)));
@@ -1,4 +1,4 @@
//! Confirm dialog — save/discard/cancel confirmation.
//! Confirm dialog — save/discard/cancel confirmation and selection operation parameter dialog.
use crate::app::Message;
use crate::theme::ThemeColors;
@@ -28,18 +28,37 @@ pub fn close_confirm_view(doc_name: &str, colors: ThemeColors) -> Element<'stati
super::modal(dialog, colors)
}
/// Build a selection operation dialog (grow/shrink/feather).
/// Return the slider range and unit suffix for a selection operation.
fn selection_op_range(op: &str) -> (f32, f32, &'static str) {
match op {
"Erode" => (1.0, 50.0, "px"),
"Fade" => (0.0, 100.0, "radius"),
"Feather" => (0.0, 250.0, "px"),
"Border" => (1.0, 200.0, "px"),
_ => (1.0, 100.0, "px"),
}
}
/// Build a selection operation dialog (grow/shrink/feather/erode/fade).
pub fn selection_op_view(
op_name: &str,
value: f32,
min: f32,
max: f32,
_min: f32,
_max: f32,
colors: ThemeColors,
) -> Element<'static, Message> {
let op_owned = op_name.to_string();
let value_slider = plain_slider("Value", value, min..=max, 1.0, "", 0, |v| {
Message::SelectionOpValue(v)
});
let (range_min, range_max, unit) = selection_op_range(op_name);
let step = if unit == "radius" { 0.5 } else { 1.0 };
let value_slider = plain_slider(
&format!("Value ({})", unit),
value,
range_min..=range_max,
step,
"",
0,
|v| Message::SelectionOpValue(v),
);
let apply_btn = super::primary_btn("Apply", Message::SelectionOpApply, colors);
let cancel_btn = super::secondary_btn("Cancel", Message::DialogClose, colors);
@@ -0,0 +1,36 @@
//! Canvas Expand dialog — shown when pasted content exceeds the canvas size.
//!
//! Offers to expand the canvas to fit the pasted content, or cancel.
use crate::app::Message;
use crate::dialogs::{modal, primary_btn, secondary_btn};
use crate::theme::ThemeColors;
use iced::widget::{column, row, text, Space};
use iced::Element;
/// Build the canvas expand dialog view.
pub fn view(paste_w: u32, paste_h: u32, canvas_w: u32, canvas_h: u32, colors: ThemeColors) -> Element<'static, Message> {
let body = column![
text("Pasted Image Exceeds Canvas")
.size(16),
Space::with_height(12),
text(format!(
"Pasted image ({}x{}) is larger than the canvas ({}x{}). \
Expand the document to fit the pasted content?",
paste_w, paste_h, canvas_w, canvas_h
))
.size(12),
Space::with_height(16),
row![
primary_btn("Expand", Message::DialogCloseSave, colors),
Space::with_width(8),
secondary_btn("Cancel", Message::DialogClose, colors),
]
.spacing(0),
]
.spacing(0)
.padding(24)
.max_width(420);
modal(body, colors)
}
@@ -11,7 +11,9 @@ pub mod canvas_size;
pub mod confirm;
pub mod dock_profile;
pub mod image_size;
pub mod expand_canvas;
pub mod new_image;
pub mod save_error;
/// Wraps dialog content in an opaque themed card over a translucent full-screen scrim.
///
@@ -0,0 +1,64 @@
//! Save Error dialog — shown when a file save/export operation fails.
//!
//! Displays the error, the target path, and offers a suggested alternative path.
//! The user can retry, save to the suggested path, or close.
use crate::app::Message;
use crate::dialogs::{modal, primary_btn, secondary_btn};
use crate::theme::ThemeColors;
use iced::widget::{column, container, row, text, Space};
use iced::{Element, Length};
use std::path::PathBuf;
/// State for the save error dialog.
#[derive(Debug, Clone)]
pub struct SaveErrorState {
pub operation: String,
pub path: PathBuf,
pub error: String,
pub suggestion: PathBuf,
}
/// Build the save error dialog view.
pub fn view(state: &SaveErrorState, colors: ThemeColors) -> Element<'static, Message> {
let body = column![
text(format!("{} — Failed", state.operation))
.size(15),
Space::with_height(12),
text("Target path:")
.size(11),
container(
text(state.path.to_string_lossy().to_string())
.size(11)
)
.padding(4)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.08))),
border: iced::Border::default().rounded(2),
..Default::default()
})
.width(Length::Fill),
Space::with_height(8),
text(format!("Error: {}", state.error))
.size(11),
Space::with_height(4),
text("Suggested action:")
.size(11),
text(state.suggestion.to_string_lossy().to_string())
.size(11),
Space::with_height(12),
row![
primary_btn("Save to Suggested Path", Message::DialogCloseSave, colors),
Space::with_width(8),
secondary_btn("Try Again", Message::DialogCloseDiscard, colors),
Space::with_width(8),
secondary_btn("Close", Message::DialogClose, colors),
]
.spacing(0),
]
.spacing(0)
.padding(24)
.max_width(420);
modal(body, colors)
}
@@ -79,10 +79,14 @@ pub fn panel_size_policy(panel: PaneType) -> PaneSizePolicy {
width: axis(160.0, 300.0, f32::INFINITY, 35),
height: axis(120.0, 300.0, f32::INFINITY, 35),
},
PaneType::Brushes | PaneType::Filters => PaneSizePolicy {
PaneType::Brushes => PaneSizePolicy {
width: axis(160.0, 300.0, f32::INFINITY, 30),
height: axis(120.0, 280.0, f32::INFINITY, 30),
},
PaneType::Filters => PaneSizePolicy {
width: axis(240.0, 400.0, f32::INFINITY, 30),
height: axis(140.0, 320.0, f32::INFINITY, 30),
},
PaneType::Properties | PaneType::Geometry | PaneType::ToolSettings => PaneSizePolicy {
width: axis(160.0, 280.0, f32::INFINITY, 25),
height: axis(120.0, 280.0, f32::INFINITY, 25),
@@ -169,6 +169,8 @@ pub fn panel_body<'a>(
doc.engine.active_layer_id(),
&doc.engine,
colors,
&doc.layer_thumbnails,
doc.thumb_gen,
)
}
PaneType::History => {
@@ -250,7 +252,7 @@ pub fn panel_body<'a>(
app.script_is_running,
colors,
),
PaneType::AiChat => crate::panels::ai_chat_panel::view(&app.ai_chat_state, colors),
PaneType::AiChat => crate::panels::ai_chat_panel::view(app, colors),
PaneType::AiScript => crate::panels::ai_script_panel::view(app),
PaneType::LayerDetails => {
let doc = &app.documents[app.active_doc];
@@ -261,16 +263,16 @@ pub fn panel_body<'a>(
)
}
PaneType::Geometry => {
let shapes = app.documents[app.active_doc]
.engine
.active_vector_shapes()
.unwrap_or_default();
let doc = &app.documents[app.active_doc];
let shapes = doc.engine.active_vector_shapes().unwrap_or_default();
let active_id = doc.engine.active_layer_id();
crate::panels::geometry::view(
shapes,
colors,
app.documents[app.active_doc].selected_vector_shape,
doc.selected_vector_shape,
app.bool_shape_a,
app.bool_shape_b,
active_id,
)
}
PaneType::ToolSettings => {
@@ -14,6 +14,7 @@ mod dock;
mod i18n;
mod io;
mod panels;
mod plugins;
mod raster;
mod screenshot;
mod script;
@@ -1,18 +1,152 @@
//! AI Chat panel — AI assistant with streaming SSE responses, tool execution, and DSL scripts.
//! AI Assistant panel with two sub-tabs: ComfyUI pipeline viewer and AI Chat.
//!
//! Full implementation of the AI chat interface including provider selection,
//! streaming response display, chat bubbles, reasoning toggle, and canvas context toggle.
//! Uses ThemeColors for consistent styling.
//! Mirrors the egui AI Assistant panel which has both ComfyUI and AI Chat tabs.
//! The active tab is controlled by `app.ai_assistant_tab` (0 = ComfyUI, 1 = AI Chat).
//! Uses ThemeColors for consistent styling across both tabs.
use crate::ai_chat::{AiChatState, ChatMessage};
use crate::ai_chat::{AiChatState, ChatMessage, ComfyStatus, ComfyUiState};
use crate::app::Message;
use crate::panels::styles;
use crate::theme::ThemeColors;
use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, text_input};
use iced::{Element, Length};
use crate::app::HcieIcedApp;
/// Build the AI chat panel with full streaming UI.
pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Message> {
/// Build the AI Assistant panel with tab switcher and sub-tab content.
pub fn view(app: &HcieIcedApp, colors: ThemeColors) -> Element<'static, Message> {
let tab = app.ai_assistant_tab;
// ── Tab bar ──
let comfy_tab_btn = tab_button("ComfyUI", tab == 0, 0, colors);
let chat_tab_btn = tab_button("AI Chat", tab == 1, 1, colors);
let tab_bar = row![comfy_tab_btn, chat_tab_btn]
.spacing(0)
.align_y(iced::Alignment::Center);
// ── Content area ──
let content: Element<'static, Message> = match tab {
0 => comfy_tab_content(&app.comfy_state, colors),
_ => chat_tab_content(&app.ai_chat_state, colors),
};
let panel = column![
tab_bar,
horizontal_rule(1),
content,
]
.spacing(0)
.padding(8);
container(panel)
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| styles::panel_background(colors))
.into()
}
/// A tab button that highlights when active.
fn tab_button(
label: &str,
active: bool,
tab_index: usize,
colors: ThemeColors,
) -> Element<'static, Message> {
let label_owned = label.to_string();
let bg = if active { colors.accent } else { colors.bg_active };
let fg = if active { iced::Color::WHITE } else { colors.text_secondary };
button(text(label_owned).size(11))
.on_press(Message::AiAssistantTabChanged(tab_index))
.padding([4, 12])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
let (background, text_color) = match status {
iced::widget::button::Status::Active => (bg, fg),
iced::widget::button::Status::Hovered => {
(colors.accent_hover, iced::Color::WHITE)
}
iced::widget::button::Status::Pressed => {
(colors.accent, iced::Color::WHITE)
}
iced::widget::button::Status::Disabled => {
(colors.bg_app, colors.text_disabled)
}
};
iced::widget::button::Style {
background: Some(iced::Background::Color(background)),
text_color,
border: iced::Border::default().rounded(4),
..Default::default()
}
})
.into()
}
/// ComfyUI tab content — placeholder pipeline viewer.
fn comfy_tab_content(state: &ComfyUiState, colors: ThemeColors) -> Element<'static, Message> {
let status_label = match state.status {
ComfyStatus::Unknown => "Unknown",
ComfyStatus::Running => "Running",
ComfyStatus::Error => "Error",
};
let status_color = match state.status {
ComfyStatus::Unknown => colors.text_disabled,
ComfyStatus::Running => colors.accent_green,
ComfyStatus::Error => iced::Color::from_rgb(0.95, 0.3, 0.3),
};
let url_input = text_input("http://127.0.0.1:8188", &state.url)
.on_input(|s| Message::ComfyUiUrlChanged(s))
.size(10)
.width(Length::Fill);
let check_btn = button(text("Check Status").size(10))
.on_press(Message::ComfyUiCheckStatus)
.padding([4, 10]);
let status_row = row![
text("Status:").size(10).color(colors.text_secondary),
text(status_label).size(10).color(status_color),
]
.spacing(6)
.align_y(iced::Alignment::Center);
column![
text("ComfyUI Pipeline").size(13).color(colors.text_primary),
text("").size(4),
text("Connect to a running ComfyUI instance to run")
.size(10)
.color(colors.text_disabled),
text("stable diffusion pipelines and AI image generation.")
.size(10)
.color(colors.text_disabled),
text("").size(8),
row![
text("Server URL:").size(10).color(colors.text_secondary),
url_input,
check_btn,
]
.spacing(4)
.align_y(iced::Alignment::Center),
status_row,
text("").size(8),
container(
text("Pipeline playback and node graph editing will be added in a future update.")
.size(10)
.color(colors.text_disabled),
)
.padding(8)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_hover)),
border: iced::Border::default().rounded(6),
..Default::default()
}),
]
.spacing(4)
.into()
}
/// AI Chat tab content — streaming SSE chat interface.
fn chat_tab_content(state: &AiChatState, colors: ThemeColors) -> Element<'static, Message> {
// ── Provider selector row ──
let provider_label = text("Provider:").size(10).color(colors.text_secondary);
let ollama_btn = make_provider_btn(
@@ -90,11 +224,14 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
.spacing(4)
.align_y(iced::Alignment::Center);
let hint = text("Model discovery is unavailable; enter the exact provider model ID.")
.size(9)
.color(colors.text_disabled);
// ── Message list ──
let mut msg_list = column![].spacing(6);
if state.messages.is_empty() && !state.is_streaming {
// Welcome message
let welcome = container(
column![
text("AI Creative Assistant").size(13).color(colors.accent),
@@ -122,13 +259,11 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
msg_list = msg_list.push(welcome);
} else {
// Render chat history
for msg in &state.messages {
let bubble = render_message_bubble(msg, colors);
msg_list = msg_list.push(bubble);
}
// Render streaming reasoning if enabled
if state.config.reasoning_enabled && !state.current_reasoning.is_empty() {
let reasoning_bubble = container(
column![
@@ -156,7 +291,6 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
msg_list = msg_list.push(reasoning_bubble);
}
// Render streaming response if in progress
if state.is_streaming && !state.current_response.is_empty() {
let streaming_bubble = container(
text(state.current_response.clone())
@@ -178,7 +312,6 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
msg_list = msg_list.push(streaming_bubble);
}
// Streaming indicator
if state.is_streaming {
let indicator = row![
text("...").size(10).color(colors.text_disabled),
@@ -219,14 +352,10 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
.spacing(4)
.align_y(iced::Alignment::Center);
// ── Assemble panel ──
let panel = column![
text("AI Assistant").size(13).color(colors.text_primary),
column![
provider_row,
config_row,
text("Model discovery is unavailable; enter the exact provider model ID.")
.size(9)
.color(colors.text_disabled),
hint,
toggles_row,
horizontal_rule(1),
scrollable(msg_list).height(Length::Fill),
@@ -234,16 +363,9 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
input_row,
]
.spacing(4)
.padding(8);
container(panel)
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| styles::panel_background(colors))
.into()
.into()
}
/// Build an action button whose disabled, hover, pressed, and focus-border states are explicit.
fn action_button(
label: &'static str,
message: Option<Message>,
@@ -279,7 +401,6 @@ fn action_button(
.into()
}
/// Render a single chat message as a styled bubble.
fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'static, Message> {
if msg.is_reasoning {
return container(
@@ -302,7 +423,6 @@ fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'sta
}
match msg.role.as_str() {
"user" => {
// User bubble — right-aligned, blue tint
let label = text("You").size(9).color(colors.accent);
let content = text(msg.content.clone())
.size(11)
@@ -321,7 +441,6 @@ fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'sta
row![iced::widget::Space::with_width(Length::Fill), bubble].into()
}
"tool" => {
// Tool result — monospace pill
let content = text(msg.content.clone()).size(10).color(colors.accent);
container(content)
.padding([4, 8])
@@ -333,7 +452,6 @@ fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'sta
.into()
}
_ => {
// Assistant bubble — left-aligned, slate tint
let label = text("AI").size(9).color(colors.accent_green);
let content = text(msg.content.clone())
.size(11)
@@ -354,7 +472,6 @@ fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'sta
}
}
/// Create a provider selection button with active state highlighting.
fn make_provider_btn(
label: &str,
is_active: bool,
@@ -12,30 +12,36 @@ use crate::panels::typography::{BODY, SECTION};
use crate::theme::ThemeColors;
use hcie_engine_api::{LineCap, VectorBooleanOp, VectorShape};
use iced::widget::{
button, checkbox, column, container, pick_list, row, scrollable, slider, text, Space,
button, checkbox, column, container, pick_list, row, scrollable, slider, text, text_input,
Space,
};
use iced::{Element, Length};
/// Human-readable label for a shape at a given index.
fn shape_label(shapes: &[VectorShape], i: usize) -> String {
match &shapes[i] {
VectorShape::Line { .. } => format!("Line #{}", i + 1),
VectorShape::Rect { .. } => format!("Rect #{}", i + 1),
VectorShape::Circle { .. } => format!("Circle #{}", i + 1),
VectorShape::Arrow { .. } => format!("Arrow #{}", i + 1),
VectorShape::Star { .. } => format!("Star #{}", i + 1),
VectorShape::Polygon { .. } => format!("Polygon #{}", i + 1),
VectorShape::Rhombus { .. } => format!("Rhombus #{}", i + 1),
VectorShape::Cylinder { .. } => format!("Cylinder #{}", i + 1),
VectorShape::Heart { .. } => format!("Heart #{}", i + 1),
VectorShape::Bubble { .. } => format!("Bubble #{}", i + 1),
VectorShape::Gear { .. } => format!("Gear #{}", i + 1),
VectorShape::Cross { .. } => format!("Cross #{}", i + 1),
VectorShape::Crescent { .. } => format!("Crescent #{}", i + 1),
VectorShape::Bolt { .. } => format!("Bolt #{}", i + 1),
VectorShape::Arrow4 { .. } => format!("4-way Arrow #{}", i + 1),
VectorShape::SvgShape { kind, .. } => format!("{} #{}", kind, i + 1),
VectorShape::FreePath { pts, .. } => format!("Path ({} pts) #{}", pts.len(), i + 1),
let name = shapes[i].name();
if name.is_empty() {
match &shapes[i] {
VectorShape::Line { .. } => format!("Line #{}", i + 1),
VectorShape::Rect { .. } => format!("Rect #{}", i + 1),
VectorShape::Circle { .. } => format!("Circle #{}", i + 1),
VectorShape::Arrow { .. } => format!("Arrow #{}", i + 1),
VectorShape::Star { .. } => format!("Star #{}", i + 1),
VectorShape::Polygon { .. } => format!("Polygon #{}", i + 1),
VectorShape::Rhombus { .. } => format!("Rhombus #{}", i + 1),
VectorShape::Cylinder { .. } => format!("Cylinder #{}", i + 1),
VectorShape::Heart { .. } => format!("Heart #{}", i + 1),
VectorShape::Bubble { .. } => format!("Bubble #{}", i + 1),
VectorShape::Gear { .. } => format!("Gear #{}", i + 1),
VectorShape::Cross { .. } => format!("Cross #{}", i + 1),
VectorShape::Crescent { .. } => format!("Crescent #{}", i + 1),
VectorShape::Bolt { .. } => format!("Bolt #{}", i + 1),
VectorShape::Arrow4 { .. } => format!("4-way Arrow #{}", i + 1),
VectorShape::SvgShape { kind, .. } => format!("{} #{}", kind, i + 1),
VectorShape::FreePath { pts, .. } => format!("Path ({} pts) #{}", pts.len(), i + 1),
}
} else {
name
}
}
@@ -80,6 +86,7 @@ pub fn view(
selected_shape: Option<usize>,
bool_shape_a: Option<usize>,
bool_shape_b: Option<usize>,
layer_id: u64,
) -> Element<'static, Message> {
let has_shapes = !shapes.is_empty();
let shape_count = shapes.len();
@@ -90,25 +97,46 @@ pub fn view(
content = content.push(section_header("SHAPES".to_string(), colors));
if has_shapes {
// Delete button in header row (right-aligned)
// Delete and reorder buttons in header row (right-aligned)
if selected_shape.is_some() {
let sel = selected_shape.unwrap();
// The list is shown front-to-back: the last rendered shape (top of Z)
// appears at the top of the list, and the first rendered shape (back)
// appears at the bottom. Therefore:
// ▲ (MoveUp) moves the selected shape toward the front (higher index).
// ▼ (MoveDown) moves it toward the back (lower index).
let can_up = sel + 1 < shape_count;
let can_down = sel > 0;
let mut header = row![Space::with_width(Length::Fill)];
if can_up {
header = header.push(
button(text("").size(BODY))
.on_press(Message::VectorShapeMoveUp)
.padding([2, 6]),
);
}
if can_down {
header = header.push(
button(text("").size(BODY))
.on_press(Message::VectorShapeMoveDown)
.padding([2, 6]),
);
}
header = header.push(
button(text("X").size(BODY))
.on_press(Message::VectorShapeDelete)
.padding([2, 6]),
);
content = content.push(
container(
row![
Space::with_width(Length::Fill),
button(text("X").size(BODY))
.on_press(Message::VectorShapeDelete)
.padding([2, 6]),
]
.width(Length::Fill),
)
.padding([2, 0]),
container(header.spacing(4).width(Length::Fill)).padding([2, 0]),
);
}
// Shape list
// Shape list — rendered front-to-back so the topmost (last index)
// shape appears at the top of the list.
let mut shape_list = column![].spacing(1);
for i in 0..shape_count {
for display_i in 0..shape_count {
let i = shape_count - 1 - display_i;
let label = shape_label(&shapes, i);
let is_selected = selected_shape == Some(i);
let c = colors;
@@ -261,6 +289,41 @@ pub fn view(
content = content.push(section_header(format!("GEOMETRY [#{}]", idx + 1), colors));
content = content.push(Space::with_height(4));
// Shape name (editable)
let shape_name = shapes[idx].name();
content = content.push(
row![
text("Name").size(BODY).width(42),
text_input("Shape name", &shape_name)
.on_input(move |v| Message::VectorShapeRename(idx, v))
.size(BODY)
.width(Length::Fill),
]
.spacing(4)
.align_y(iced::Alignment::Center),
);
content = content.push(Space::with_height(4));
// "Edit SVG" button — only for SvgShape variants (EGUI parity)
if matches!(&shapes[idx], VectorShape::SvgShape { .. }) {
let c = colors;
content = content.push(
button(text("Edit SVG").size(BODY))
.on_press(Message::SvgEditorOpen {
layer_id,
shape_idx: idx,
})
.padding([4, 8])
.style(move |_theme, _status| iced::widget::button::Style {
background: Some(iced::Background::Color(c.accent)),
text_color: iced::Color::WHITE,
border: iced::Border::default().rounded(3),
..Default::default()
}),
);
content = content.push(Space::with_height(4));
}
let (x1, y1, x2, y2) = shapes[idx].bounds();
let w = (x2 - x1).abs();
let h = (y2 - y1).abs();
@@ -6,10 +6,16 @@
//! 2. Scrollable layer list — each row shows indent, collapse arrow, visibility, thumbnail,
//! name, FX icon, and type badge. Layers with effects show expandable effect sub-items.
//! 3. Bottom toolbar — add layer, add group, flatten, delete, move up/down
//!
//! Z-order convention:
//! The engine renders layers in array order: the first layer is at the back, the
//! last layer is at the front. The UI list mirrors this order, so the topmost
//! (front-most) layer appears at the bottom of the list. Therefore "Move Up" in
//! the toolbar moves the layer toward the end of the array / front of the canvas,
//! and "Move Down" moves it toward the start / back.
use crate::app::Message;
use crate::panels::styles;
use crate::panels::thumbnails;
use crate::panels::typography::BODY;
use crate::theme::ThemeColors;
use crate::widgets::plain_slider::plain_slider;
@@ -80,6 +86,9 @@ struct LayerEntry<'a> {
}
/// Build a flat rendering order from the layer list, respecting group hierarchy.
/// Layers are returned in visual order: the top/front-most layer first and the
/// bottom/back-most layer last. This matches Photoshop-style panels where the
/// layer you see on top of the canvas appears at the top of the list.
fn build_tree<'a>(layers: &'a [LayerInfo]) -> Vec<LayerEntry<'a>> {
let mut children_of: std::collections::HashMap<Option<u64>, Vec<&LayerInfo>> =
std::collections::HashMap::new();
@@ -95,6 +104,8 @@ fn build_tree<'a>(layers: &'a [LayerInfo]) -> Vec<LayerEntry<'a>> {
result: &mut Vec<LayerEntry<'a>>,
) {
if let Some(children) = children_of.get(&parent_id) {
// children is already in reverse-engine order (front-most first); walk
// it as-is so the list renders front-to-back.
for info in children {
let is_group = info.layer_type == LayerType::Group;
result.push(LayerEntry {
@@ -123,10 +134,11 @@ fn flat_btn_style(colors: ThemeColors) -> iced::widget::button::Style {
}
}
/// Build the thumbnail element for a layer row.
/// Build the thumbnail element for a layer row from the cache or as a fallback icon.
fn layer_thumbnail<'a>(
entry: &LayerEntry<'a>,
engine: &hcie_engine_api::Engine,
thumb_cache: &std::collections::HashMap<u64, (u32, u32, Vec<u8>)>,
_thumb_gen: u64,
colors: ThemeColors,
) -> Element<'a, Message> {
if entry.is_group {
@@ -137,15 +149,12 @@ fn layer_thumbnail<'a>(
.into();
}
let info = entry.info;
let thumb = engine
.get_layer_pixels(info.id)
.filter(|px| !px.is_empty() && info.width > 0 && info.height > 0)
.map(|px| thumbnails::generate_thumbnail(&px, info.width, info.height, 48));
if let Some(pixels) = thumb {
let handle = iced::widget::image::Handle::from_rgba(48, 36, pixels);
let img = iced::widget::Image::new(handle).width(48).height(36);
if let Some((tw, th, pixels)) = thumb_cache.get(&entry.info.id) {
let (tw32, th32) = (*tw as u32, *th as u32);
let handle = iced::widget::image::Handle::from_rgba(tw32, th32, pixels.clone());
let img = iced::widget::Image::new(handle)
.width(Length::Fixed(*tw as f32))
.height(Length::Fixed(*th as f32));
return container(img)
.width(50)
.height(38)
@@ -159,7 +168,7 @@ fn layer_thumbnail<'a>(
}
// Fallback: type-based icon
let (icon_char, icon_color) = match info.layer_type {
let (icon_char, icon_color) = match entry.info.layer_type {
LayerType::Vector => ("V", iced::Color::from_rgb(0.4, 0.8, 0.4)),
LayerType::Text => ("T", iced::Color::from_rgb(0.4, 0.6, 1.0)),
LayerType::Mask => ("M", iced::Color::from_rgb(0.8, 0.4, 0.8)),
@@ -191,6 +200,8 @@ pub fn view<'a>(
active_layer_id: u64,
engine: &hcie_engine_api::Engine,
colors: ThemeColors,
thumb_cache: &'a std::collections::HashMap<u64, (u32, u32, Vec<u8>)>,
thumb_gen: u64,
) -> Element<'a, Message> {
let entries = build_tree(layers);
@@ -315,7 +326,7 @@ pub fn view<'a>(
.style(move |_theme, _status| flat_btn_style(colors));
// Thumbnail
let thumb_elem = layer_thumbnail(entry, engine, colors);
let thumb_elem = layer_thumbnail(entry, thumb_cache, thumb_gen, colors);
// Layer name
let c = if is_active {
@@ -16,6 +16,7 @@ use crate::app::Message;
use crate::dock::state::{DockState, PaneType};
use crate::panels::typography;
use crate::theme::ThemeColors;
use hcie_engine_api::Tool;
use iced::widget::{column, container, horizontal_rule, mouse_area, row, text, Space};
use iced::{Element, Length};
@@ -26,25 +27,47 @@ use iced::{Element, Length};
/// carry their path so reordering the recent list cannot change the selected file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MenuCommand {
// File
NewDocument,
OpenFile,
Save,
SaveAs,
OpenRecent(String),
ClearRecent,
ImportFile,
ImportSvg,
ImportBrushes,
Export,
// Edit
Undo,
Redo,
Copy,
Cut,
ClearPixels,
Paste,
PasteSpecial,
Fill,
Stroke,
FreeTransform,
Preferences,
// Image
CanvasSize,
ImageSize,
Rotate90CW,
Rotate90CCW,
Rotate180,
FlipHorizontal,
FlipVertical,
ImageCrop,
InvertNegative,
// Layer
AddLayer,
DeleteLayer,
LayerStyles,
MergeDown,
Flatten,
AlignLayer(String),
// Select
SelectAll,
Deselect,
InvertSelection,
@@ -57,16 +80,37 @@ pub enum MenuCommand {
SaveSelection,
LoadSelection,
ToggleQuickMask,
ErodeBorder,
FadeBorder,
// Filter (distort, noise, pixelate, render, restore, stylize, texture)
FilterDistort(String),
FilterNoise(String),
FilterPixelate(String),
FilterRender(String),
FilterRestore(String),
FilterStylize(String),
FilterTexture(String),
// View
ZoomIn,
ZoomOut,
FitArea,
ActualPixels,
ResetPan,
ViewerToggle,
// Window
TogglePane(PaneType),
OpenDocuments,
ResetLayout,
// Tool
SelectTool(Tool),
// Theme
SetTheme(crate::theme::ThemePreset),
// Misc
SelectCropTool,
GaussianBlur,
Mosaic,
UnsharpMask,
TogglePane(PaneType),
SetTheme(crate::theme::ThemePreset),
About,
}
/// Describes one rendered menu row and its optional semantic operation.
@@ -242,12 +286,17 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::new("Open & Place..."),
MenuItem::submenu("Open More"),
MenuItem::separator(),
MenuItem::command("Import...", MenuCommand::ImportFile),
MenuItem::command("Import SVG...", MenuCommand::ImportSvg),
MenuItem::command("Import Brushes...", MenuCommand::ImportBrushes),
MenuItem::separator(),
MenuItem::new("Share"),
MenuItem::separator(),
MenuItem::command_with_shortcut("Save", "Ctrl+S", MenuCommand::Save),
MenuItem::command("Save as PSD", MenuCommand::SaveAs),
MenuItem::submenu("Save More"),
MenuItem::submenu("Export as"),
MenuItem::command("Export...", MenuCommand::Export),
MenuItem::with_shortcut("Print...", "Ctrl+P"),
MenuItem::separator(),
MenuItem::new("Export Layers..."),
@@ -292,13 +341,15 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::command_with_shortcut("Paste", "Ctrl+V", MenuCommand::Paste),
MenuItem::command("Clear", MenuCommand::ClearPixels),
MenuItem::separator(),
MenuItem::with_shortcut("Fill...", "Shift+F5"),
MenuItem::new("Stroke..."),
MenuItem::command_with_shortcut("Paste Special", "Shift+Ctrl+V", MenuCommand::PasteSpecial),
MenuItem::separator(),
MenuItem::command_with_shortcut("Fill...", "Shift+F5", MenuCommand::Fill),
MenuItem::command("Stroke...", MenuCommand::Stroke),
MenuItem::separator(),
MenuItem::new("Content-Aware Scale"),
MenuItem::new("Puppet Warp"),
MenuItem::new("Perspective Warp"),
MenuItem::with_shortcut("Free Transform", "Alt+Ctrl+T"),
MenuItem::command_with_shortcut("Free Transform", "Alt+Ctrl+T", MenuCommand::FreeTransform),
MenuItem::submenu("Transform"),
MenuItem::new("Auto-Align"),
MenuItem::new("Auto-Blend"),
@@ -308,10 +359,40 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::separator(),
MenuItem::submenu("Define New"),
MenuItem::new("Preset Manager..."),
MenuItem::with_shortcut("Preferences...", "Ctrl+K"),
MenuItem::command_with_shortcut("Preferences...", "Ctrl+K", MenuCommand::Preferences),
MenuItem::new("Local Storage..."),
],
},
// ── Tools menu (Photopea-style) ──
MenuDef {
label: "Tools".to_string(),
items: vec![
MenuItem::command_with_shortcut("Eyedropper", "I", MenuCommand::SelectTool(Tool::Eyedropper)),
MenuItem::command_with_shortcut("Pencil / Pen", "P", MenuCommand::SelectTool(Tool::Pen)),
MenuItem::command_with_shortcut("Art Brush", "B", MenuCommand::SelectTool(Tool::Brush)),
MenuItem::command_with_shortcut("Eraser", "E", MenuCommand::SelectTool(Tool::Eraser)),
MenuItem::command("Spray", MenuCommand::SelectTool(Tool::Spray)),
MenuItem::command_with_shortcut("Flood Fill", "F", MenuCommand::SelectTool(Tool::FloodFill)),
MenuItem::command_with_shortcut("Magic Wand", "W", MenuCommand::SelectTool(Tool::MagicWand)),
MenuItem::command_with_shortcut("Rect Selection", "M", MenuCommand::SelectTool(Tool::Select)),
MenuItem::command_with_shortcut("Lasso", "L", MenuCommand::SelectTool(Tool::Lasso)),
MenuItem::command("Polygon Select", MenuCommand::SelectTool(Tool::PolygonSelect)),
MenuItem::command_with_shortcut("Move", "V", MenuCommand::SelectTool(Tool::Move)),
MenuItem::command_with_shortcut("Crop", "C", MenuCommand::SelectTool(Tool::Crop)),
MenuItem::command_with_shortcut("Text", "T", MenuCommand::SelectTool(Tool::Text)),
MenuItem::command_with_shortcut("Gradient", "G", MenuCommand::SelectTool(Tool::Gradient)),
MenuItem::separator(),
MenuItem::disabled("Retouch"),
MenuItem::command("Red-eye Removal", MenuCommand::SelectTool(Tool::RedEyeRemoval)),
MenuItem::command("Spot Removal Patch", MenuCommand::SelectTool(Tool::SpotRemoval)),
MenuItem::command("Smart Patch", MenuCommand::SelectTool(Tool::SmartPatch)),
MenuItem::separator(),
MenuItem::disabled("AI"),
MenuItem::command("AI Object Removal", MenuCommand::SelectTool(Tool::AiObjectRemoval)),
MenuItem::command("Smart Select", MenuCommand::SelectTool(Tool::SmartSelect)),
MenuItem::command("Vision Select", MenuCommand::SelectTool(Tool::VisionSelect)),
],
},
// ── Image menu (Photopea-style) ──
MenuDef {
label: "Image".to_string(),
@@ -337,10 +418,16 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuCommand::ImageSize,
),
MenuItem::submenu("Transform"),
MenuItem::new("Crop"),
MenuItem::command("Rotate 90° CW", MenuCommand::Rotate90CW),
MenuItem::command("Rotate 90° CCW", MenuCommand::Rotate90CCW),
MenuItem::command("Rotate 180°", MenuCommand::Rotate180),
MenuItem::command("Flip Horizontal", MenuCommand::FlipHorizontal),
MenuItem::command("Flip Vertical", MenuCommand::FlipVertical),
MenuItem::command("Crop", MenuCommand::ImageCrop),
MenuItem::with_shortcut("Trim...", "Ctrl+."),
MenuItem::new("Reveal All"),
MenuItem::separator(),
MenuItem::command("Invert / Negative", MenuCommand::InvertNegative),
MenuItem::new("Duplicate"),
MenuItem::new("Apply Image..."),
MenuItem::separator(),
@@ -372,6 +459,12 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::separator(),
MenuItem::with_shortcut("Group Layers", "Ctrl+G"),
MenuItem::submenu("Arrange"),
MenuItem::command("Align Left", MenuCommand::AlignLayer("left".to_string())),
MenuItem::command("Align Center", MenuCommand::AlignLayer("center".to_string())),
MenuItem::command("Align Right", MenuCommand::AlignLayer("right".to_string())),
MenuItem::command("Align Top", MenuCommand::AlignLayer("top".to_string())),
MenuItem::command("Align Middle", MenuCommand::AlignLayer("middle".to_string())),
MenuItem::command("Align Bottom", MenuCommand::AlignLayer("bottom".to_string())),
MenuItem::submenu("Combine Shapes"),
MenuItem::submenu("Animation"),
MenuItem::separator(),
@@ -416,6 +509,8 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
),
MenuItem::command("Border...", MenuCommand::BorderSelection),
MenuItem::command("Smooth...", MenuCommand::SmoothSelection),
MenuItem::command("Erode Border", MenuCommand::ErodeBorder),
MenuItem::command("Fade Border", MenuCommand::FadeBorder),
MenuItem::separator(),
MenuItem::command(
"New Selection",
@@ -459,17 +554,92 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::new("Liquify..."),
MenuItem::new("Vanishing Point..."),
MenuItem::separator(),
MenuItem::submenu("3D"),
MenuItem::submenu("Blur"),
MenuItem::submenu("Blur Gallery"),
MenuItem::submenu("Distort"),
MenuItem::submenu("Noise"),
MenuItem::submenu("Pixelate"),
MenuItem::submenu("Render"),
MenuItem::submenu("Sharpen"),
MenuItem::submenu("Stylize"),
MenuItem::submenu("Other"),
MenuItem::submenu("Fourier"),
// ── Blur ──
MenuItem::disabled("Blur"),
MenuItem::command("Box Blur", MenuCommand::FilterDistort("box_blur".into())),
MenuItem::command("Gaussian Blur", MenuCommand::FilterDistort("gaussian_blur".into())),
MenuItem::command("Motion Blur", MenuCommand::FilterDistort("motion_blur".into())),
MenuItem::command("Unsharp Mask", MenuCommand::FilterRestore("unsharp_mask".into())),
MenuItem::separator(),
// ── Distort ──
MenuItem::disabled("Distort"),
MenuItem::command("Pinch / Punch", MenuCommand::FilterDistort("pinch".into())),
MenuItem::command("Twirl", MenuCommand::FilterDistort("twirl".into())),
MenuItem::separator(),
// ── Noise ──
MenuItem::disabled("Noise"),
MenuItem::command("Add Noise", MenuCommand::FilterNoise("add_noise".into())),
MenuItem::command("Median Filter", MenuCommand::FilterRestore("median".into())),
MenuItem::command("Dehaze", MenuCommand::FilterRestore("dehaze".into())),
MenuItem::command("Noise Pattern", MenuCommand::FilterNoise("noise_pattern".into())),
MenuItem::separator(),
// ── Pixelate ──
MenuItem::disabled("Pixelate"),
MenuItem::command("Crystallize", MenuCommand::FilterPixelate("crystallize".into())),
MenuItem::command("Mosaic", MenuCommand::FilterPixelate("mosaic".into())),
MenuItem::separator(),
// ── Render ──
MenuItem::disabled("Render"),
MenuItem::command("Clouds", MenuCommand::FilterRender("clouds".into())),
MenuItem::separator(),
// ── Procedural Textures (flat-grouped; ICED menus are single-level) ──
MenuItem::disabled("Procedural Textures"),
MenuItem::disabled("Natural"),
MenuItem::command("Clouds Texture", MenuCommand::FilterTexture("texture_clouds".into())),
MenuItem::command("Grass", MenuCommand::FilterTexture("texture_grass".into())),
MenuItem::command("Sand", MenuCommand::FilterTexture("texture_sand".into())),
MenuItem::command("Dirt", MenuCommand::FilterTexture("texture_dirt".into())),
MenuItem::command("Water", MenuCommand::FilterTexture("texture_water".into())),
MenuItem::command("Rain", MenuCommand::FilterTexture("texture_rain".into())),
MenuItem::command("Stone", MenuCommand::FilterTexture("texture_stone".into())),
MenuItem::separator(),
MenuItem::disabled("Architecture"),
MenuItem::command("Bricks", MenuCommand::FilterTexture("texture_bricks".into())),
MenuItem::separator(),
MenuItem::disabled("Wood"),
MenuItem::command("Oak", MenuCommand::FilterTexture("texture_wood_oak".into())),
MenuItem::command("Pine", MenuCommand::FilterTexture("texture_wood_pine".into())),
MenuItem::command("Dark", MenuCommand::FilterTexture("texture_wood_dark".into())),
MenuItem::separator(),
MenuItem::disabled("Metal"),
MenuItem::command("Steel", MenuCommand::FilterTexture("texture_metal_steel".into())),
MenuItem::command("Gold", MenuCommand::FilterTexture("texture_metal_gold".into())),
MenuItem::command("Copper", MenuCommand::FilterTexture("texture_metal_copper".into())),
MenuItem::separator(),
MenuItem::disabled("Material"),
MenuItem::command("Linen", MenuCommand::FilterTexture("texture_linen".into())),
MenuItem::command("Glass", MenuCommand::FilterTexture("texture_glass".into())),
MenuItem::separator(),
MenuItem::disabled("Effect"),
MenuItem::command("Sky Replacement", MenuCommand::FilterTexture("sky_replacement".into())),
MenuItem::separator(),
// ── Sharpen / Restore ──
MenuItem::disabled("Sharpen / Restore"),
MenuItem::command("Sharpen", MenuCommand::FilterRestore("sharpen".into())),
MenuItem::command("Smart Patch", MenuCommand::FilterRestore("smart_patch".into())),
MenuItem::separator(),
// ── Stylize ──
MenuItem::disabled("Stylize"),
MenuItem::command("Emboss", MenuCommand::FilterStylize("emboss".into())),
MenuItem::command("Find Edges", MenuCommand::FilterStylize("find_edges".into())),
MenuItem::command("Oil Paint", MenuCommand::FilterStylize("oil_paint".into())),
MenuItem::command("Posterize", MenuCommand::FilterStylize("posterize".into())),
MenuItem::command("Threshold", MenuCommand::FilterStylize("threshold".into())),
MenuItem::separator(),
// ── Color / Light ──
MenuItem::disabled("Color / Light"),
MenuItem::command("Levels", MenuCommand::FilterRender("levels".into())),
MenuItem::command("Vibrance", MenuCommand::FilterRender("vibrance".into())),
MenuItem::command("Exposure", MenuCommand::FilterRender("exposure".into())),
MenuItem::command("Gamma Correction", MenuCommand::FilterRender("gamma".into())),
MenuItem::command("Black & White", MenuCommand::FilterStylize("bw".into())),
MenuItem::command("Gradient Map", MenuCommand::FilterRender("gradient_map".into())),
MenuItem::command("Channel Mixer", MenuCommand::FilterRender("channel_mixer".into())),
MenuItem::command("Selective Color", MenuCommand::FilterRender("selective_color".into())),
MenuItem::separator(),
// ── Fourier ──
MenuItem::disabled("Fourier"),
MenuItem::command("FFT Filter", MenuCommand::FilterRender("fft".into())),
],
},
// ── View menu (Photopea-style) ──
@@ -484,6 +654,7 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
"Ctrl+1",
MenuCommand::ActualPixels,
),
MenuItem::command("Reset Pan", MenuCommand::ResetPan),
MenuItem::new("Pattern Preview"),
MenuItem::separator(),
MenuItem::submenu("Mode"),
@@ -501,6 +672,8 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::new("Guides from Layer"),
MenuItem::separator(),
MenuItem::new("Clear Slices"),
MenuItem::separator(),
MenuItem::command("Viewer Mode", MenuCommand::ViewerToggle),
],
},
// ── Window menu (Photopea-style) ──
@@ -552,6 +725,9 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
"Theme: ProLight (Nord)",
MenuCommand::SetTheme(crate::theme::ThemePreset::ProLight),
),
MenuItem::separator(),
MenuItem::command("Open Documents", MenuCommand::OpenDocuments),
MenuItem::command("Reset Layout", MenuCommand::ResetLayout),
],
},
// ── More menu (Photopea-style) ──
@@ -581,6 +757,8 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::new("Swatches"),
MenuItem::new("Tool Presets"),
MenuItem::new("Vector Info"),
MenuItem::separator(),
MenuItem::command("About", MenuCommand::About),
],
},
]
@@ -616,7 +794,7 @@ pub fn dropdown_overlay(
let enabled = item.enabled;
// Check if this is a Window menu item that should show a checkmark
let is_window_panel = menu_idx == 7 && item_idx <= 10;
let is_window_panel = menu_idx == 8 && item_idx <= 10;
let is_checked = if is_window_panel {
let pane_type = match item_idx {
0 => Some(PaneType::Layers),
@@ -932,8 +1110,12 @@ mod tests {
let expected = vec![
"File/New...",
"File/Open...",
"File/Import...",
"File/Import SVG...",
"File/Import Brushes...",
"File/Save",
"File/Save as PSD",
"File/Export...",
"Edit/Undo / Redo",
"Edit/Step Forward",
"Edit/Step Backward",
@@ -941,11 +1123,49 @@ mod tests {
"Edit/Copy",
"Edit/Paste",
"Edit/Clear",
"Edit/Paste Special",
"Edit/Fill...",
"Edit/Stroke...",
"Edit/Free Transform",
"Edit/Preferences...",
"Tools/Eyedropper",
"Tools/Pencil / Pen",
"Tools/Art Brush",
"Tools/Eraser",
"Tools/Spray",
"Tools/Flood Fill",
"Tools/Magic Wand",
"Tools/Rect Selection",
"Tools/Lasso",
"Tools/Polygon Select",
"Tools/Move",
"Tools/Crop",
"Tools/Text",
"Tools/Gradient",
"Tools/Red-eye Removal",
"Tools/Spot Removal Patch",
"Tools/Smart Patch",
"Tools/AI Object Removal",
"Tools/Smart Select",
"Tools/Vision Select",
"Image/Canvas Size...",
"Image/Image Size...",
"Image/Rotate 90° CW",
"Image/Rotate 90° CCW",
"Image/Rotate 180°",
"Image/Flip Horizontal",
"Image/Flip Vertical",
"Image/Crop",
"Image/Invert / Negative",
"Layer/New",
"Layer/Delete",
"Layer/Layer Style",
"Layer/Align Left",
"Layer/Align Center",
"Layer/Align Right",
"Layer/Align Top",
"Layer/Align Middle",
"Layer/Align Bottom",
"Layer/Merge Down",
"Layer/Flatten Image",
"Select/All",
@@ -956,6 +1176,8 @@ mod tests {
"Select/Feather...",
"Select/Border...",
"Select/Smooth...",
"Select/Erode Border",
"Select/Fade Border",
"Select/New Selection",
"Select/Add to Selection",
"Select/Subtract from Selection",
@@ -963,10 +1185,58 @@ mod tests {
"Select/Quick Mask Mode",
"Select/Load Selection",
"Select/Save Selection",
"Filter/Box Blur",
"Filter/Gaussian Blur",
"Filter/Motion Blur",
"Filter/Unsharp Mask",
"Filter/Pinch / Punch",
"Filter/Twirl",
"Filter/Add Noise",
"Filter/Median Filter",
"Filter/Dehaze",
"Filter/Noise Pattern",
"Filter/Crystallize",
"Filter/Mosaic",
"Filter/Clouds",
"Filter/Clouds Texture",
"Filter/Grass",
"Filter/Sand",
"Filter/Dirt",
"Filter/Water",
"Filter/Rain",
"Filter/Stone",
"Filter/Bricks",
"Filter/Oak",
"Filter/Pine",
"Filter/Dark",
"Filter/Steel",
"Filter/Gold",
"Filter/Copper",
"Filter/Linen",
"Filter/Glass",
"Filter/Sky Replacement",
"Filter/Sharpen",
"Filter/Smart Patch",
"Filter/Emboss",
"Filter/Find Edges",
"Filter/Oil Paint",
"Filter/Posterize",
"Filter/Threshold",
"Filter/Levels",
"Filter/Vibrance",
"Filter/Exposure",
"Filter/Gamma Correction",
"Filter/Black & White",
"Filter/Gradient Map",
"Filter/Channel Mixer",
"Filter/Selective Color",
"Filter/FFT Filter",
"View/Zoom In",
"View/Zoom Out",
"View/Fit The Area",
"View/Pixel to Pixel",
"View/Reset Pan",
"View/Viewer Mode",
"Window/Layers",
"Window/History",
"Window/Brushes & Tips",
@@ -984,6 +1254,9 @@ mod tests {
"Window/Theme: Amoled (Dracula)",
"Window/Theme: PhotoshopLight (Solarized)",
"Window/Theme: ProLight (Nord)",
"Window/Open Documents",
"Window/Reset Layout",
"More/About",
];
assert_eq!(actual, expected);
@@ -16,6 +16,7 @@ pub mod properties;
pub mod script_panel;
pub mod status_bar;
pub mod styles;
pub mod svg_editor;
pub mod text_editor;
pub mod thumbnails;
pub mod title_bar;
@@ -17,6 +17,10 @@ use iced::{Element, Length};
/// Human-readable label for a shape at a given index.
fn shape_label(shapes: &[VectorShape], i: usize) -> String {
let name = shapes[i].name();
if !name.is_empty() {
return name;
}
match &shapes[i] {
VectorShape::Line { .. } => format!("Line #{}", i + 1),
VectorShape::Rect { .. } => format!("Rect #{}", i + 1),
@@ -132,7 +136,13 @@ pub fn view<'a>(
}
Tool::Select => selection_properties(selection_rect, colors),
Tool::VectorSelect => {
vector_select_properties(selected_vector_shape, vector_shapes, vector_angle, colors)
vector_select_properties(
selected_vector_shape,
vector_shapes,
vector_angle,
active_layer_id,
colors,
)
}
Tool::VectorRect
| Tool::VectorCircle
@@ -251,7 +261,8 @@ fn vector_select_properties<'a>(
selected: Option<usize>,
shapes: &[VectorShape],
vector_angle: f32,
_colors: ThemeColors,
active_layer_id: u64,
colors: ThemeColors,
) -> Element<'a, Message> {
match selected.and_then(|idx| shapes.get(idx).map(|s| (idx, s))) {
Some((idx, shape)) => {
@@ -283,10 +294,32 @@ fn vector_select_properties<'a>(
VectorShape::FreePath { .. } => "Free Path".into(),
};
// "Edit SVG" button — only for SvgShape variants (EGUI parity)
let edit_svg_btn: Element<'a, Message> =
if matches!(shape, VectorShape::SvgShape { .. }) {
let c = colors;
button(text("Edit SVG").size(BODY))
.on_press(Message::SvgEditorOpen {
layer_id: active_layer_id,
shape_idx: idx,
})
.padding([4, 8])
.style(move |_theme, _status| iced::widget::button::Style {
background: Some(iced::Background::Color(c.accent)),
text_color: iced::Color::WHITE,
border: iced::Border::default().rounded(3),
..Default::default()
})
.into()
} else {
text("").into()
};
column![
text("Shape Properties").size(SECTION),
row![text("Shape").size(BODY), text(label).size(BODY)].spacing(4),
row![text("Type").size(BODY), text(shape_type).size(BODY)].spacing(4),
edit_svg_btn,
plain_slider(
"Stroke",
stroke,
@@ -0,0 +1,878 @@
//! Interactive SVG node editor for simple polygon/path SVG shapes.
//!
//! The editor renders the parsed `SvgEditable` model on an iced Canvas. Nodes
//! can be selected, dragged, inserted at edge midpoints, removed, or edited by
//! numeric coordinates. Each interaction emits an application message so the
//! authoritative editor state remains in `HcieIcedApp` and the main canvas can
//! receive a live SVG preview.
use crate::app::Message;
use crate::theme::ThemeColors;
use iced::mouse;
use iced::widget::canvas::{self, Canvas, Frame, Path, Program, Stroke};
use iced::widget::{button, checkbox, column, container, row, scrollable, text, text_input, Space};
use iced::{Color, Element, Length, Point, Rectangle, Size, Vector};
use usvg::tiny_skia_path::PathSegment;
/// Parse an SVG into the engine API's editable polygon model.
///
/// **Purpose:** Keeps native line nodes intact when possible and provides a visual-editing fallback
/// for curves and arcs, which `SvgEditable::from_svg` intentionally rejects. **Logic & Workflow:**
/// First uses the public engine parser; on failure, `usvg` normalizes the document and curved
/// segments are sampled into line nodes. **Arguments:** `svg` is the complete SVG source.
/// **Returns:** An editable model or a descriptive parse error. **Side Effects / Dependencies:**
/// Parses XML through `usvg`; it performs no file or engine mutation.
pub fn parse_editable(svg: &str) -> Result<hcie_engine_api::SvgEditable, String> {
hcie_engine_api::SvgEditable::from_svg(svg).or_else(|_| flatten_svg(svg))
}
/// Flatten all rendered SVG paths into editable polygons.
///
/// **Arguments:** `svg` is complete SVG source. **Returns:** A polygon model preserving the source
/// view box. **Logic & Workflow:** Traverses normalized `usvg` paths and delegates curve sampling to
/// `flatten_group`. **Side Effects / Dependencies:** Uses `usvg` parsing only.
fn flatten_svg(svg: &str) -> Result<hcie_engine_api::SvgEditable, String> {
let tree = usvg::Tree::from_data(svg.as_bytes(), &usvg::Options::default())
.map_err(|error| error.to_string())?;
let mut polygons = Vec::new();
flatten_group(tree.root(), &mut polygons);
if polygons.is_empty() {
return Err("SVG does not contain an editable path".to_string());
}
Ok(hcie_engine_api::SvgEditable {
view_box: parse_view_box(svg).unwrap_or((0.0, 0.0, 1.0, 1.0)),
polygons,
})
}
/// Recursively collect line and sampled curve nodes from a normalized SVG group.
///
/// **Arguments:** `group` is the current `usvg` group and `output` receives editable paths.
/// **Returns:** Nothing. **Logic & Workflow:** Move/line endpoints are retained; quadratic and cubic
/// segments are sampled at a screen-independent density; close commands flush one polygon.
/// **Side Effects / Dependencies:** Appends to `output`.
fn flatten_group(group: &usvg::Group, output: &mut Vec<Vec<hcie_engine_api::SvgNode>>) {
for child in group.children() {
match child {
usvg::Node::Path(path) => {
let mut current = Vec::new();
let mut cursor = Point::ORIGIN;
for segment in path.data().segments() {
match segment {
PathSegment::MoveTo(point) => {
flush_path(&mut current, output);
cursor = Point::new(point.x, point.y);
current.push(hcie_engine_api::SvgNode {
x: point.x,
y: point.y,
});
}
PathSegment::LineTo(point) => {
cursor = Point::new(point.x, point.y);
current.push(hcie_engine_api::SvgNode {
x: point.x,
y: point.y,
});
}
PathSegment::QuadTo(control, point) => {
let start = cursor;
for step in 1..=12 {
let t = step as f32 / 12.0;
let inverse = 1.0 - t;
current.push(hcie_engine_api::SvgNode {
x: inverse * inverse * start.x
+ 2.0 * inverse * t * control.x
+ t * t * point.x,
y: inverse * inverse * start.y
+ 2.0 * inverse * t * control.y
+ t * t * point.y,
});
}
cursor = Point::new(point.x, point.y);
}
PathSegment::CubicTo(first, second, point) => {
let start = cursor;
for step in 1..=16 {
let t = step as f32 / 16.0;
let inverse = 1.0 - t;
current.push(hcie_engine_api::SvgNode {
x: inverse.powi(3) * start.x
+ 3.0 * inverse * inverse * t * first.x
+ 3.0 * inverse * t * t * second.x
+ t.powi(3) * point.x,
y: inverse.powi(3) * start.y
+ 3.0 * inverse * inverse * t * first.y
+ 3.0 * inverse * t * t * second.y
+ t.powi(3) * point.y,
});
}
cursor = Point::new(point.x, point.y);
}
PathSegment::Close => flush_path(&mut current, output),
}
}
flush_path(&mut current, output);
}
usvg::Node::Group(child_group) => flatten_group(child_group, output),
_ => {}
}
}
}
/// Move a completed subpath into the editable output when it can form a visible polygon.
///
/// **Arguments:** `current` is the active node buffer and `output` receives valid paths.
/// **Returns:** Nothing. **Side Effects:** Clears `current` and may append to `output`.
fn flush_path(
current: &mut Vec<hcie_engine_api::SvgNode>,
output: &mut Vec<Vec<hcie_engine_api::SvgNode>>,
) {
if current.len() > 2 {
output.push(std::mem::take(current));
} else {
current.clear();
}
}
/// Extract a case-insensitive SVG `viewBox` attribute.
///
/// **Arguments:** `svg` is raw XML. **Returns:** `(min_x, min_y, width, height)` when valid.
/// **Side Effects:** None.
fn parse_view_box(svg: &str) -> Option<(f32, f32, f32, f32)> {
let lower = svg.to_ascii_lowercase();
let start = lower.find("viewbox")?;
let suffix = &svg[start + "viewbox".len()..];
let equals = suffix.find('=')?;
let value = suffix[equals + 1..].trim_start();
let quote = value.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
let value = &value[quote.len_utf8()..];
let end = value.find(quote)?;
let numbers: Vec<f32> = value[..end]
.split(|character: char| character.is_ascii_whitespace() || character == ',')
.filter(|part| !part.is_empty())
.filter_map(|part| part.parse().ok())
.collect();
(numbers.len() == 4).then(|| (numbers[0], numbers[1], numbers[2], numbers[3]))
}
/// Runtime state for the SVG editor dialog.
pub struct SvgEditorState {
/// Engine layer containing the edited vector shape.
pub layer_id: u64,
/// Index of the SVG shape within the vector layer.
pub shape_idx: usize,
/// SVG text captured before the editing session began.
pub original_svg: String,
/// Mutable polygon/node model used by the visual editor.
pub editable: hcie_engine_api::SvgEditable,
/// Currently selected polygon.
pub selected_poly: usize,
/// Currently selected node in `selected_poly`.
pub selected_node: Option<usize>,
/// View zoom factor.
pub zoom: f32,
/// View pan offset in dialog-canvas pixels.
pub pan: Vector,
/// Editable X-coordinate text, retained while the user types.
pub x_input: String,
/// Editable Y-coordinate text, retained while the user types.
pub y_input: String,
/// Whether moved nodes are quantized to `grid_size` SVG units.
pub snap_to_grid: bool,
/// Grid interval in SVG view-box units.
pub grid_size: f32,
/// Editable grid interval text, retained while the user types.
pub grid_input: String,
}
impl SvgEditorState {
/// Select a node and synchronize the persistent coordinate inputs.
///
/// **Arguments:** `polygon` and `node` identify an existing model point.
/// **Returns:** Nothing. **Side Effects:** Updates selection and text-entry state.
pub fn select_node(&mut self, polygon: usize, node: usize) {
let Some(value) = self
.editable
.polygons
.get(polygon)
.and_then(|path| path.get(node))
.copied()
else {
return;
};
self.selected_poly = polygon;
self.selected_node = Some(node);
self.x_input = format_coordinate(value.x);
self.y_input = format_coordinate(value.y);
}
/// Quantize a coordinate when grid snapping is enabled.
///
/// **Arguments:** `value` is an SVG view-box coordinate. **Returns:** The original or nearest
/// grid-aligned coordinate. **Side Effects:** None.
pub fn snapped(&self, value: f32) -> f32 {
if self.snap_to_grid && self.grid_size.is_finite() && self.grid_size > f32::EPSILON {
(value / self.grid_size).round() * self.grid_size
} else {
value
}
}
/// Refresh coordinate inputs from the currently selected model node.
///
/// **Arguments/Returns:** None. **Side Effects:** Replaces X/Y text with model coordinates.
pub fn refresh_coordinate_inputs(&mut self) {
let Some(node) = self.selected_node.and_then(|node| {
self.editable
.polygons
.get(self.selected_poly)
.and_then(|path| path.get(node))
}) else {
self.x_input.clear();
self.y_input.clear();
return;
};
self.x_input = format_coordinate(node.x);
self.y_input = format_coordinate(node.y);
}
}
/// Format SVG coordinates compactly while retaining useful sub-pixel precision.
///
/// **Arguments:** `value` is an SVG coordinate. **Returns:** A trimmed decimal string.
/// **Side Effects:** None.
fn format_coordinate(value: f32) -> String {
let formatted = format!("{value:.4}");
formatted
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
}
/// Ephemeral pointer state owned by the Canvas program.
#[derive(Debug, Default, Clone, Copy)]
struct InteractionState {
dragging: Option<(usize, usize)>,
panning: bool,
last_pointer: Option<Point>,
}
/// Canvas program that draws the SVG model and translates pointer gestures to
/// SVG coordinates.
#[derive(Debug, Clone)]
struct SvgCanvasProgram {
editable: hcie_engine_api::SvgEditable,
selected_poly: usize,
selected_node: Option<usize>,
zoom: f32,
pan: Vector,
grid_size: f32,
colors: ThemeColors,
}
impl SvgCanvasProgram {
/// Compute the uniform viewBox-to-screen transform.
fn transform(&self, bounds: Rectangle) -> (f32, f32, f32) {
let (_, _, view_w, view_h) = self.editable.view_box;
let margin = 24.0;
let scale = ((bounds.width - margin * 2.0) / view_w.max(1.0))
.min((bounds.height - margin * 2.0) / view_h.max(1.0))
.max(0.01)
* self.zoom;
let draw_w = view_w * scale;
let draw_h = view_h * scale;
let ox = (bounds.width - draw_w) * 0.5 + self.pan.x;
let oy = (bounds.height - draw_h) * 0.5 + self.pan.y;
(ox, oy, scale)
}
/// Convert an SVG coordinate into a local canvas point.
fn svg_to_screen(&self, bounds: Rectangle, x: f32, y: f32) -> Point {
let (vbx, vby, _, _) = self.editable.view_box;
let (ox, oy, scale) = self.transform(bounds);
Point::new(ox + (x - vbx) * scale, oy + (y - vby) * scale)
}
/// Convert a local canvas point into an SVG coordinate.
fn screen_to_svg(&self, bounds: Rectangle, point: Point) -> (f32, f32) {
let (vbx, vby, _, _) = self.editable.view_box;
let (ox, oy, scale) = self.transform(bounds);
((point.x - ox) / scale + vbx, (point.y - oy) / scale + vby)
}
/// Find the nearest node in screen space.
fn hit_node(&self, bounds: Rectangle, point: Point) -> Option<(usize, usize)> {
let mut best = None;
for (polygon, nodes) in self.editable.polygons.iter().enumerate() {
for (node, value) in nodes.iter().enumerate() {
let distance = self.svg_to_screen(bounds, value.x, value.y).distance(point);
if distance <= 10.0
&& best
.as_ref()
.is_none_or(|(_, _, best_distance)| distance < *best_distance)
{
best = Some((polygon, node, distance));
}
}
}
best.map(|(polygon, node, _)| (polygon, node))
}
/// Find an edge midpoint in screen space for insertion.
fn hit_midpoint(&self, bounds: Rectangle, point: Point) -> Option<(usize, usize)> {
let mut best = None;
for (polygon, nodes) in self.editable.polygons.iter().enumerate() {
if nodes.len() < 2 {
continue;
}
for edge in 0..nodes.len() {
let a = self.svg_to_screen(bounds, nodes[edge].x, nodes[edge].y);
let b = self.svg_to_screen(
bounds,
nodes[(edge + 1) % nodes.len()].x,
nodes[(edge + 1) % nodes.len()].y,
);
let midpoint = Point::new((a.x + b.x) * 0.5, (a.y + b.y) * 0.5);
let distance = midpoint.distance(point);
if distance <= 9.0
&& best
.as_ref()
.is_none_or(|(_, _, best_distance)| distance < *best_distance)
{
best = Some((polygon, edge, distance));
}
}
}
best.map(|(polygon, edge, _)| (polygon, edge))
}
}
impl Program<Message> for SvgCanvasProgram {
type State = InteractionState;
fn draw(
&self,
_state: &Self::State,
renderer: &iced::Renderer,
_theme: &iced::Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let mut frame = Frame::new(renderer, bounds.size());
frame.fill(
&Path::rectangle(Point::ORIGIN, bounds.size()),
self.colors.bg_app,
);
let (_, _, view_w, view_h) = self.editable.view_box;
let (ox, oy, scale) = self.transform(bounds);
let grid_step = (self.grid_size.max(0.0001) * scale).max(8.0);
let grid_color = Color::from_rgba(1.0, 1.0, 1.0, 0.06);
let mut x = ox.rem_euclid(grid_step);
while x < bounds.width {
frame.stroke(
&Path::line(Point::new(x, 0.0), Point::new(x, bounds.height)),
Stroke::default().with_color(grid_color).with_width(1.0),
);
x += grid_step;
}
let mut y = oy.rem_euclid(grid_step);
while y < bounds.height {
frame.stroke(
&Path::line(Point::new(0.0, y), Point::new(bounds.width, y)),
Stroke::default().with_color(grid_color).with_width(1.0),
);
y += grid_step;
}
let view_box_rect = Path::rectangle(
Point::new(ox, oy),
Size::new(view_w * scale, view_h * scale),
);
frame.stroke(
&view_box_rect,
Stroke::default()
.with_color(self.colors.border_high)
.with_width(1.0),
);
for (polygon_idx, polygon) in self.editable.polygons.iter().enumerate() {
if polygon.is_empty() {
continue;
}
let path = Path::new(|builder| {
let first = self.svg_to_screen(bounds, polygon[0].x, polygon[0].y);
builder.move_to(first);
for node in polygon.iter().skip(1) {
builder.line_to(self.svg_to_screen(bounds, node.x, node.y));
}
if polygon.len() > 2 {
builder.close();
}
});
let fill = if polygon_idx == self.selected_poly {
Color::from_rgba(
self.colors.accent.r,
self.colors.accent.g,
self.colors.accent.b,
0.24,
)
} else {
Color::from_rgba(
self.colors.text_secondary.r,
self.colors.text_secondary.g,
self.colors.text_secondary.b,
0.10,
)
};
frame.fill(&path, fill);
frame.stroke(
&path,
Stroke::default()
.with_color(if polygon_idx == self.selected_poly {
self.colors.accent
} else {
self.colors.text_secondary
})
.with_width(1.5),
);
for edge in 0..polygon.len() {
if polygon.len() < 2 {
break;
}
let a = self.svg_to_screen(bounds, polygon[edge].x, polygon[edge].y);
let b = self.svg_to_screen(
bounds,
polygon[(edge + 1) % polygon.len()].x,
polygon[(edge + 1) % polygon.len()].y,
);
let midpoint = Point::new((a.x + b.x) * 0.5, (a.y + b.y) * 0.5);
frame.fill(
&Path::circle(midpoint, 3.5),
Color::from_rgb(0.55, 0.55, 0.55),
);
}
for (node_idx, node) in polygon.iter().enumerate() {
let point = self.svg_to_screen(bounds, node.x, node.y);
let selected =
polygon_idx == self.selected_poly && self.selected_node == Some(node_idx);
frame.fill(
&Path::circle(point, if selected { 6.0 } else { 5.0 }),
if selected {
self.colors.accent
} else {
Color::WHITE
},
);
frame.stroke(
&Path::circle(point, if selected { 6.0 } else { 5.0 }),
Stroke::default().with_color(Color::BLACK).with_width(1.5),
);
}
}
vec![frame.into_geometry()]
}
fn update(
&self,
state: &mut Self::State,
event: canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> (canvas::event::Status, Option<Message>) {
let position = cursor.position_in(bounds);
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
let Some(position) = position else {
return (canvas::event::Status::Ignored, None);
};
if let Some((polygon, node)) = self.hit_node(bounds, position) {
state.dragging = Some((polygon, node));
return (
canvas::event::Status::Captured,
Some(Message::SvgEditorSelectNode { polygon, node }),
);
}
if let Some((polygon, edge)) = self.hit_midpoint(bounds, position) {
return (
canvas::event::Status::Captured,
Some(Message::SvgEditorCanvasAddNode { polygon, edge }),
);
}
(
canvas::event::Status::Captured,
Some(Message::SvgEditorClearSelection),
)
}
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)) => {
state.panning = true;
state.last_pointer = position;
(canvas::event::Status::Captured, None)
}
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
let Some(position) = position else {
return (canvas::event::Status::Ignored, None);
};
if let Some((polygon, node)) = state.dragging {
let (x, y) = self.screen_to_svg(bounds, position);
return (
canvas::event::Status::Captured,
Some(Message::SvgEditorMoveNode {
polygon,
node,
x,
y,
}),
);
}
if state.panning {
let previous = state.last_pointer.replace(position).unwrap_or(position);
return (
canvas::event::Status::Captured,
Some(Message::SvgEditorPan {
dx: position.x - previous.x,
dy: position.y - previous.y,
}),
);
}
(canvas::event::Status::Ignored, None)
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
state.dragging = None;
(
canvas::event::Status::Captured,
Some(Message::SvgEditorCanvasReleased),
)
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)) => {
state.panning = false;
state.last_pointer = None;
(canvas::event::Status::Captured, None)
}
canvas::Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
let amount = match delta {
mouse::ScrollDelta::Lines { y, .. } => y,
mouse::ScrollDelta::Pixels { y, .. } => y / 100.0,
};
if amount.abs() > f32::EPSILON {
let factor = if amount > 0.0 { 1.1 } else { 1.0 / 1.1 };
return (
canvas::event::Status::Captured,
Some(Message::SvgEditorZoom(factor)),
);
}
(canvas::event::Status::Ignored, None)
}
_ => (canvas::event::Status::Ignored, None),
}
}
/// Show precise interaction cursors over nodes, insertion handles, and the canvas surface.
///
/// **Arguments:** Runtime state, canvas bounds, and cursor location. **Returns:** The cursor
/// icon representing the available interaction. **Side Effects:** None.
fn mouse_interaction(
&self,
state: &Self::State,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> mouse::Interaction {
if state.dragging.is_some() {
return mouse::Interaction::Grabbing;
}
if state.panning {
return mouse::Interaction::Grabbing;
}
let Some(position) = cursor.position_in(bounds) else {
return mouse::Interaction::default();
};
if self.hit_node(bounds, position).is_some() {
mouse::Interaction::Grab
} else if self.hit_midpoint(bounds, position).is_some() {
mouse::Interaction::Crosshair
} else {
mouse::Interaction::default()
}
}
}
/// Build the visual SVG editor dialog.
pub fn view(state: &SvgEditorState, colors: ThemeColors) -> Element<'static, Message> {
let poly_count = state.editable.polygons.len();
if poly_count == 0 {
return container(text("No polygons to edit").size(12))
.padding(24)
.into();
}
let selected_poly = state.selected_poly.min(poly_count - 1);
let selected_node = state
.selected_node
.filter(|idx| *idx < state.editable.polygons[selected_poly].len());
let selected_coords = selected_node.and_then(|idx| {
state.editable.polygons[selected_poly]
.get(idx)
.map(|node| (node.x, node.y))
});
let canvas = Canvas::new(SvgCanvasProgram {
editable: state.editable.clone(),
selected_poly,
selected_node,
zoom: state.zoom,
pan: state.pan,
grid_size: state.grid_size,
colors,
})
.width(Length::Fill)
.height(Length::Fill);
let mut polygons = column![text("ELEMENTS").size(10)].spacing(3);
for index in 0..poly_count {
let label = format!(
"Path {} · {} nodes",
index + 1,
state.editable.polygons[index].len()
);
polygons = polygons.push(
button(text(label).size(10))
.on_press(Message::SvgEditorSelectPoly(index))
.width(Length::Fill)
.padding([3, 6])
.style(move |_theme, _status| iced::widget::button::Style {
background: Some(iced::Background::Color(if index == selected_poly {
colors.accent
} else {
colors.bg_hover
})),
text_color: if index == selected_poly {
Color::WHITE
} else {
colors.text_primary
},
border: iced::Border::default().rounded(3),
..Default::default()
}),
);
}
polygons = polygons.push(Space::with_height(8));
polygons = polygons.push(text("NODES").size(10));
for (index, node) in state.editable.polygons[selected_poly].iter().enumerate() {
let selected = selected_node == Some(index);
let label = format!(
"{:02} {:>8} {:>8}",
index + 1,
format_coordinate(node.x),
format_coordinate(node.y)
);
polygons = polygons.push(
button(text(label).size(10))
.on_press(Message::SvgEditorSelectNode {
polygon: selected_poly,
node: index,
})
.width(Length::Fill)
.padding([3, 6])
.style(move |_theme, status| iced::widget::button::Style {
background: Some(iced::Background::Color(if selected {
colors.accent
} else if matches!(status, iced::widget::button::Status::Hovered) {
colors.bg_hover
} else {
colors.bg_panel
})),
text_color: if selected {
Color::WHITE
} else {
colors.text_primary
},
border: iced::Border::default().rounded(3),
..Default::default()
}),
);
}
let path_list = container(scrollable(polygons).height(Length::Fill))
.width(220)
.height(Length::Fill)
.padding(8)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_app)),
border: iced::Border::default().color(colors.border_high).width(1),
..Default::default()
});
let coordinates: Element<'static, Message> = if selected_coords.is_some() {
row![
text("NODE").size(10),
text(format!("#{}", selected_node.unwrap_or(0) + 1)).size(10),
text("X").size(10),
text_input("X", &state.x_input)
.on_input(Message::SvgEditorXChanged)
.width(96),
text("Y").size(10),
text_input("Y", &state.y_input)
.on_input(Message::SvgEditorYChanged)
.width(96),
button(text("Delete node").size(10))
.on_press(Message::SvgEditorRemoveNode)
.padding([4, 8]),
]
.spacing(4)
.align_y(iced::Alignment::Center)
.into()
} else {
row![text("Select and drag a white node, or click a gray edge handle to insert.").size(10)]
.into()
};
let controls = row![
button(text("Insert after").size(10))
.on_press(Message::SvgEditorAddNode)
.padding([4, 8]),
button(text("Fit").size(10))
.on_press(Message::SvgEditorFit)
.padding([4, 8]),
button(text("-").size(10))
.on_press(Message::SvgEditorZoom(1.0 / 1.2))
.padding([4, 8]),
button(text("+").size(10))
.on_press(Message::SvgEditorZoom(1.2))
.padding([4, 8]),
text(format!("{:.0}%", state.zoom * 100.0)).size(10),
checkbox("Snap", state.snap_to_grid).on_toggle(Message::SvgEditorSnapChanged),
text("Grid").size(10),
text_input("Grid", &state.grid_input)
.on_input(Message::SvgEditorGridChanged)
.width(64),
Space::with_width(Length::Fill),
button(text("Save").size(11))
.on_press(Message::SvgEditorSave)
.padding([6, 14]),
button(text("Cancel").size(11))
.on_press(Message::SvgEditorCancel)
.padding([6, 14]),
]
.spacing(4)
.align_y(iced::Alignment::Center);
let content = column![
row![
column![
text("SVG Node Editor").size(16),
text("Direct path editing with live document preview").size(10),
],
Space::with_width(Length::Fill),
text("Wheel: zoom · Middle-drag: pan").size(10),
]
.align_y(iced::Alignment::Center),
row![
path_list,
container(canvas)
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
border: iced::Border::default().color(colors.border_high).width(1),
..Default::default()
})
]
.spacing(8)
.height(Length::Fill),
coordinates,
controls,
]
.spacing(8)
.padding(12)
.width(920)
.height(680);
let dialog = container(content).style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_panel)),
border: iced::Border::default()
.rounded(6)
.color(colors.border_high)
.width(1),
shadow: iced::Shadow {
color: Color::from_rgba(0.0, 0.0, 0.0, 0.45),
offset: Vector::new(0.0, 5.0),
blur_radius: 16.0,
},
..Default::default()
});
container(dialog)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.padding(20)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(Color::from_rgba(
0.0, 0.0, 0.0, 0.58,
))),
..Default::default()
})
.into()
}
#[cfg(test)]
mod tests {
use super::{parse_editable, SvgEditorState};
use iced::Vector;
/// Build a deterministic state for selection and snapping tests.
fn state() -> SvgEditorState {
let editable = parse_editable(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><path d="M 0,0 L 10,0 L 10,10 Z"/></svg>"#,
)
.expect("triangle should parse");
SvgEditorState {
layer_id: 1,
shape_idx: 0,
original_svg: String::new(),
editable,
selected_poly: 0,
selected_node: None,
zoom: 1.0,
pan: Vector::ZERO,
x_input: String::new(),
y_input: String::new(),
snap_to_grid: true,
grid_size: 0.5,
grid_input: "0.5".to_string(),
}
}
#[test]
fn straight_paths_retain_author_nodes() {
let editable = state().editable;
assert_eq!(editable.view_box, (0.0, 0.0, 10.0, 10.0));
assert_eq!(editable.polygons.len(), 1);
assert_eq!(editable.polygons[0].len(), 3);
}
#[test]
fn curved_paths_fall_back_to_dense_visual_nodes() {
let editable = parse_editable(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M 0,50 C 20,0 80,0 100,50 L 100,100 L 0,100 Z"/></svg>"#,
)
.expect("cubic path should flatten");
assert_eq!(editable.polygons.len(), 1);
assert!(editable.polygons[0].len() >= 19);
}
#[test]
fn selection_updates_numeric_inputs_and_snap_is_deterministic() {
let mut state = state();
state.select_node(0, 1);
assert_eq!(state.selected_node, Some(1));
assert_eq!(state.x_input, "10");
assert_eq!(state.y_input, "0");
assert_eq!(state.snapped(2.74), 2.5);
state.snap_to_grid = false;
assert_eq!(state.snapped(2.74), 2.74);
}
}
@@ -1,42 +1,35 @@
//! Title bar — Photopea-style unified bar with menus, doc info, and window controls.
//! Title bar — Photopea-style unified bar with menus, search, panel toggle, doc info, and window controls.
//!
//! **Purpose:** Combines menu bar and title bar into a single 24px bar matching Photopea's layout.
//! Layout: [File Edit Image Layer Select Filter View Window More] [doc info] [search] [─ □ ×]
//! **Purpose:** Combines menu bar and title bar into a single bar.
//! Layout: [icon] [File Edit Image ... More] [search] [≡] [HCIE v0.1 — doc name] [─ □ ×]
//!
//! **Design reference:** Photopea image editor (93 screenshots analyzed).
//! - Menu items: 12px font, tight padding
//! - Active menu: light background with dark text
//! - Window controls: standard minimize/maximize/close
//! **Design reference:** EGUI title bar + Photopea image editor.
//! - Search box for tool/action search (EGUI parity)
//! - Panel list toggle button (EGUI parity)
//! - Double-click drag area to toggle maximize (EGUI parity)
//! - App icon + version string centered
use crate::app::Message;
use crate::panels::typography;
use crate::theme::ThemeColors;
use iced::widget::{button, container, mouse_area, row, text};
use iced::widget::{button, container, mouse_area, row, text, text_input};
use iced::{Element, Length};
/// Photopea-style menu labels matching the reference screenshots.
pub(crate) const MENU_LABELS: &[&str] = &[
"File", "Edit", "Image", "Layer", "Select", "Filter", "View", "Window", "More",
"File", "Edit", "Tools", "Image", "Layer", "Select", "Filter", "View", "Window", "More",
];
/// Horizontal title-menu metrics shared by button layout and dropdown placement.
///
/// **Purpose:** Keeps menu buttons and dropdown anchors in one deterministic layout model.
/// **Logic & Workflow:** Widths include the measured 12px medium label advance plus the
/// title button's 20px horizontal padding. [`menu_anchor_x`] sums these exact rendered widths.
/// **Side Effects / Dependencies:** Values depend on the title bar font and padding below.
pub(crate) const MENU_BUTTON_WIDTHS: &[f32] =
&[46.0, 46.0, 56.0, 60.0, 64.0, 55.0, 51.0, 71.0, 54.0];
&[46.0, 46.0, 56.0, 56.0, 60.0, 64.0, 55.0, 51.0, 71.0, 54.0];
pub(crate) const MENU_LEFT_INSET: f32 = 4.0;
/// Unified menu/title bar height shared with dropdown placement.
pub(crate) const MENU_BAR_HEIGHT: f32 = 28.0;
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Returns the clamped left edge for a menu dropdown.
///
/// **Arguments:** `menu_index` selects a title button, `viewport_width` is the available width,
/// and `dropdown_width` is the popup width. **Returns:** A viewport-safe X coordinate.
/// **Logic & Workflow:** Sums the same explicit widths assigned to preceding buttons, then clamps
/// the result so the dropdown remains visible on narrow windows. **Side Effects:** None.
pub(crate) fn menu_anchor_x(menu_index: usize, viewport_width: f32, dropdown_width: f32) -> f32 {
let natural = MENU_LEFT_INSET
+ MENU_BUTTON_WIDTHS
@@ -46,10 +39,11 @@ pub(crate) fn menu_anchor_x(menu_index: usize, viewport_width: f32, dropdown_wid
natural.min((viewport_width - dropdown_width).max(0.0))
}
/// Build the Photopea-style unified title/menu bar element.
/// Build the unified title/menu bar.
///
/// Layout: [File Edit Image ... More] [doc info] [─ □ ×]
/// The left portion (before window controls) is draggable.
/// Layout: [icon] [File Edit ... More] [search] [≡] [HCIE v0.1 — doc] [─ □ ×]
/// The area between the rightmost menu button and the window controls is draggable
/// and double-clicking it toggles maximize.
pub fn view<'a>(
doc_name: &'a str,
modified: bool,
@@ -58,8 +52,27 @@ pub fn view<'a>(
_canvas_h: u32,
colors: ThemeColors,
active_menu: Option<usize>,
title_search: &'a str,
show_panel_list: bool,
) -> Element<'a, Message> {
// ── Menu items — readable 12px medium labels with balanced padding ──
// ── App icon ──
let icon = text("H")
.size(13)
.font(iced::Font {
weight: iced::font::Weight::Bold,
..iced::Font::default()
})
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(colors.accent),
});
let icon_container = container(icon)
.width(28)
.height(MENU_BAR_HEIGHT)
.center_y(Length::Shrink)
.center_x(Length::Shrink);
// ── Menu items ──
let mut menu_items = row![].spacing(0);
for (idx, &label) in MENU_LABELS.iter().enumerate() {
let is_open = active_menu == Some(idx);
@@ -78,7 +91,6 @@ pub fn view<'a>(
.style(
move |_theme: &iced::Theme, status: iced::widget::button::Status| {
if is_open {
// Active menu: light background with dark text (Photopea style)
iced::widget::button::Style {
background: Some(iced::Background::Color(c.menu_bg)),
text_color: c.menu_text,
@@ -108,18 +120,56 @@ pub fn view<'a>(
menu_items = menu_items.push(btn);
}
// ── Document info — Photopea style: "New Project.psd *" ──
let doc_info = text(if modified {
format!("{} *", doc_name)
} else {
doc_name.to_string()
})
.size(11)
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(colors.text_secondary),
});
// ── Search box ──
let search_input = text_input("Search…", title_search)
.on_input(|s| Message::TitleSearchChanged(s))
.size(10)
.width(130.0)
.padding([2, 6]);
// ── Window controls — Photopea style: ─ □ × ──
// ── Panel list toggle (hamburger) ──
let panel_list_btn = {
let c = colors;
let label = if show_panel_list { "" } else { "" };
button(text(label).size(14))
.on_press(Message::PanelListToggle)
.padding([2, 8])
.style(
move |_theme: &iced::Theme, status: iced::widget::button::Status| {
let (bg, tc) = if show_panel_list {
(c.menu_bg, c.menu_text)
} else {
match status {
iced::widget::button::Status::Hovered => (c.bg_hover, c.text_primary),
_ => {
(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0), c.text_secondary)
}
}
};
iced::widget::button::Style {
background: Some(iced::Background::Color(bg)),
text_color: tc,
border: iced::Border::default(),
..Default::default()
}
},
)
};
// ── Document info — centered: "HCIE v{version} — New Project.psd *" ──
let title_str = format!(
"HCIE v{}{}{}",
APP_VERSION,
doc_name,
if modified { " *" } else { "" }
);
let doc_info = text(title_str)
.size(11)
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(colors.text_secondary),
});
// ── Window controls ──
let c = colors;
let minimize_btn = button(text("").size(12))
@@ -190,30 +240,35 @@ pub fn view<'a>(
let window_controls = row![minimize_btn, maximize_btn, close_btn].spacing(0);
// Only the empty title region is draggable. Interactive menus are siblings,
// preventing their presses from also initiating a native window drag.
let draggable_left = mouse_area(
container(row![
iced::widget::Space::with_width(Length::Fill),
doc_info
])
.width(Length::Fill)
.height(MENU_BAR_HEIGHT)
.align_y(iced::Alignment::Center)
.padding([0, 4]),
// ── Draggable center area ──
// Fills remaining space between the left cluster and window controls.
let draggable_center = mouse_area(
container(doc_info)
.width(Length::Fill)
.height(MENU_BAR_HEIGHT)
.center_y(Length::Shrink),
)
.on_press(Message::WindowDrag)
.interaction(iced::mouse::Interaction::Grab);
// ── Full bar ──
let bar = row![
// ── Left cluster: icon, menus, search, panel toggle ──
let left_cluster = row![
icon_container,
container(menu_items).padding(iced::Padding {
left: MENU_LEFT_INSET,
left: 0.0,
right: 0.0,
top: 0.0,
bottom: 0.0,
}),
draggable_left,
search_input,
panel_list_btn,
]
.align_y(iced::Alignment::Center);
// ── Full bar ──
let bar = row![
left_cluster,
draggable_center,
window_controls,
]
.align_y(iced::Alignment::Center);
@@ -233,13 +288,14 @@ pub fn view<'a>(
mod tests {
use super::menu_anchor_x;
/// Verifies natural anchors and narrow-viewport clamping use rendered button metrics.
#[test]
fn menu_anchors_are_deterministic_across_viewport_widths() {
assert_eq!(menu_anchor_x(0, 1280.0, 260.0), 4.0);
// Index 3 = Image: File(46) + Edit(46) + Tools(56) = 148 + 4 = 152
assert_eq!(menu_anchor_x(3, 1280.0, 260.0), 152.0);
assert_eq!(menu_anchor_x(8, 1280.0, 260.0), 453.0);
assert_eq!(menu_anchor_x(8, 500.0, 260.0), 240.0);
assert_eq!(menu_anchor_x(8, 180.0, 260.0), 0.0);
// Index 9 = More: sum 9 prior widths + 4 = (46+46+56+56+60+64+55+51+71)=505+4=509
assert_eq!(menu_anchor_x(9, 1280.0, 260.0), 509.0);
assert_eq!(menu_anchor_x(9, 500.0, 260.0), 240.0);
assert_eq!(menu_anchor_x(9, 180.0, 260.0), 0.0);
}
}
@@ -0,0 +1,68 @@
//! DebugSignalLogger — a built-in plugin that records every CoreEvent.
//!
//! Logs events with timestamps and can expose them for inspection.
//! Ported from the EGUI DebugSignalLogger, adapted for the Iced architecture.
use crate::app::{HcieIcedApp, ToolState};
use crate::plugins::{CoreEvent, HciePlugin, PluginAction};
use std::time::{SystemTime, UNIX_EPOCH};
pub struct DebugSignalLogger {
name: &'static str,
version: &'static str,
events: Vec<(u128, String)>,
max_events: usize,
}
impl DebugSignalLogger {
pub fn new() -> Self {
Self {
name: "debug-signal-logger",
version: "1.0.0",
events: Vec::new(),
max_events: 200,
}
}
pub fn get_events(&self) -> &[(u128, String)] {
&self.events
}
}
impl HciePlugin for DebugSignalLogger {
fn name(&self) -> &'static str {
self.name
}
fn version(&self) -> &'static str {
self.version
}
fn initialize(&mut self) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis();
self.events.push((now, "Plugin initialized".to_string()));
}
fn on_event(&mut self, event: &CoreEvent, _state: &mut ToolState) -> Vec<PluginAction> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis();
let event_str = format!("{:?}", event);
self.events.push((now, event_str));
if self.events.len() > self.max_events {
self.events.remove(0);
}
Vec::new()
}
fn ui(&mut self, _app: &HcieIcedApp) -> Vec<PluginAction> {
Vec::new()
}
}
@@ -0,0 +1,3 @@
//! Built-in plugins that ship with HCIE by default.
pub mod debug_logger;
@@ -0,0 +1,53 @@
//! Plugin Integration — maps Iced Message events to CoreEvent and dispatches
//! them to the PluginRegistry. Ported from the EGUI PluginHost pattern.
//!
//! **Purpose:** Converts key Iced `Message` variants into `CoreEvent` values
//! and dispatches them to the registry. This is called at the top of `update()`
//! BEFORE the main match block, so the message is still available as a reference.
//!
//! **Logic & Workflow:**
//! 1. `try_dispatch()` is called at the start of `HcieIcedApp::update()`.
//! 2. It converts the incoming `Message` to an optional `CoreEvent`.
//! 3. If a conversion exists, it dispatches to all registered plugins.
//! 4. The returned `Vec<PluginAction>` can be processed for action->message mapping.
//!
//! **Arguments & Returns:**
//! * `registry`: Mutable reference to the PluginRegistry.
//! * `message`: The incoming Iced Message (reference, unconsumed).
//! * `state`: Mutable reference to ToolState for plugin access.
//! * Returns the `Vec<PluginAction>` from all plugins (can be ignored with `let _`).
use crate::app::{Message, ToolState};
use crate::plugins::registry::PluginRegistry;
use crate::plugins::{CoreEvent, PluginAction};
/// Dispatch a Message to the PluginRegistry if it maps to a CoreEvent.
/// Should be called at the top of `update()` before the main match block.
pub fn try_dispatch(
registry: &mut PluginRegistry,
message: &Message,
state: &mut ToolState,
) -> Vec<PluginAction> {
if let Some(core_event) = message_to_core_event(message) {
registry.dispatch(&core_event, state)
} else {
Vec::new()
}
}
/// Convert an Iced Message to an optional CoreEvent.
/// Only messages that have meaningful plugin-side side effects are included.
fn message_to_core_event(message: &Message) -> Option<CoreEvent> {
match message {
Message::ToolSelected(tool) => Some(CoreEvent::ToolChanged(*tool)),
Message::LayerAdd => Some(CoreEvent::LayerAdded(0)),
Message::LayerDelete(id) => Some(CoreEvent::LayerDeleted(*id as usize)),
Message::LayerSelect(id) => Some(CoreEvent::LayerSelected(*id as usize)),
Message::Undo => Some(CoreEvent::Undo),
Message::Redo => Some(CoreEvent::Redo),
Message::CompositeRefresh => Some(CoreEvent::RenderRequested),
Message::CanvasPointerPressed { .. } => Some(CoreEvent::DrawingStarted),
Message::CanvasPointerReleased => Some(CoreEvent::DrawingFinished),
_ => None,
}
}
@@ -0,0 +1,74 @@
//! Plugin system for HCIE Iced GUI.
//!
//! Port of the EGUI plugin architecture to the Iced Elm-architecture.
//! `CoreEvent` and `PluginAction` are kept identical to the EGUI version.
//! The `HciePlugin::ui()` method is retained as a no-op since Iced uses
//! declarative views instead of immediate-mode UI contributions.
pub mod built_in;
pub mod integration;
pub mod registry;
use crate::app::{HcieIcedApp, ToolState};
use hcie_engine_api::Tool;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Core events dispatched to every registered plugin.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CoreEvent {
ToolChanged(Tool),
LayerAdded(usize),
LayerDeleted(usize),
LayerSelected(usize),
LayerModified(usize),
Undo,
Redo,
RenderRequested,
DrawingStarted,
DrawingFinished,
DocSwitched(usize),
DocCreated(usize),
DocClosed(usize),
FileOpened(PathBuf),
FileSaved(PathBuf),
SettingsChanged,
ThemeChanged,
FrameTick,
PluginBroadcast(String, serde_json::Value),
ExecuteAiActions(serde_json::Value),
}
/// Actions a plugin can request in response to an event or UI interaction.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PluginAction {
SetActiveTool(Tool),
AddLayer,
DeleteLayer(usize),
SelectLayer(usize),
Undo,
Redo,
Render,
SaveAs(PathBuf),
Broadcast(String, serde_json::Value),
Custom {
target_plugin: String,
key: String,
payload: serde_json::Value,
},
AiActions(serde_json::Value),
Batch(Vec<PluginAction>),
}
/// Trait that all plugins must implement.
pub trait HciePlugin: Send + Sync {
fn name(&self) -> &'static str;
fn version(&self) -> &'static str;
fn initialize(&mut self) {}
fn on_event(&mut self, event: &CoreEvent, state: &mut ToolState) -> Vec<PluginAction>;
/// Optional UI contribution. In the Iced architecture this is a no-op
/// placeholder; plugins that need UI should expose their own view functions.
fn ui(&mut self, _app: &HcieIcedApp) -> Vec<PluginAction> {
Vec::new()
}
}
@@ -0,0 +1,82 @@
//! PluginRegistry — the runtime manager for all HciePlugin instances.
//!
//! Provides registration, event dispatch, action collection, and plugin enumeration.
//! Ported from the EGUI plugin system, adapted for the Iced architecture.
use crate::app::ToolState;
use crate::plugins::{CoreEvent, HciePlugin, PluginAction};
use std::collections::HashMap;
/// Central registry that owns and manages all loaded plugins.
pub struct PluginRegistry {
plugins: Vec<Box<dyn HciePlugin>>,
/// Optional per-plugin debug log (name -> last few events)
debug_logs: HashMap<String, Vec<String>>,
max_log_entries: usize,
}
impl PluginRegistry {
pub fn new() -> Self {
Self {
plugins: Vec::new(),
debug_logs: HashMap::new(),
max_log_entries: 50,
}
}
/// Register a new plugin instance. The plugin is initialized immediately.
pub fn register(&mut self, mut plugin: Box<dyn HciePlugin>) {
plugin.initialize();
let name = plugin.name().to_string();
self.debug_logs.insert(name.clone(), Vec::new());
self.plugins.push(plugin);
}
/// Dispatch a CoreEvent to every registered plugin.
/// Collects all PluginActions returned and returns them as a flat list.
pub fn dispatch(&mut self, event: &CoreEvent, state: &mut ToolState) -> Vec<PluginAction> {
let mut all_actions = Vec::new();
for plugin in &mut self.plugins {
let name = plugin.name().to_string();
let actions = plugin.on_event(event, state);
if let Some(log) = self.debug_logs.get_mut(&name) {
let event_str = format!("{:?}", event);
log.push(event_str);
if log.len() > self.max_log_entries {
log.remove(0);
}
}
all_actions.extend(actions);
}
all_actions
}
/// Returns the list of currently loaded plugin names.
pub fn plugin_names(&self) -> Vec<String> {
self.plugins.iter().map(|p| p.name().to_string()).collect()
}
/// Returns a reference to the debug log of a specific plugin.
pub fn get_debug_log(&self, plugin_name: &str) -> Option<&[String]> {
self.debug_logs.get(plugin_name).map(|v| v.as_slice())
}
/// Number of registered plugins.
pub fn len(&self) -> usize {
self.plugins.len()
}
pub fn is_empty(&self) -> bool {
self.plugins.is_empty()
}
}
impl Default for PluginRegistry {
fn default() -> Self {
Self::new()
}
}
@@ -215,6 +215,7 @@ pub fn execute_actions(engine: &mut Engine, actions: &[ScriptAction]) {
255,
];
engine.add_vector_shape(hcie_engine_api::VectorShape::FreePath {
name: String::new(),
pts: path_points,
closed: *closed,
stroke: *stroke_width,
@@ -40,6 +40,9 @@ pub struct AppSettings {
/// Selected application color preset.
#[serde(default)]
pub theme_preset: crate::theme::ThemePreset,
/// Last directory used by the image viewer (FastStone-like).
#[serde(default)]
pub viewer_last_dir: Option<String>,
}
/// Tool-specific settings that persist between sessions.
@@ -168,6 +171,7 @@ impl Default for AppSettings {
window: WindowSettings::default(),
dock_profiles: Vec::new(),
theme_preset: crate::theme::ThemePreset::default(),
viewer_last_dir: None,
}
}
}
@@ -347,6 +347,7 @@ mod tests {
/// Creates a stable rectangle fixture for transform invariant tests.
fn rect() -> VectorShape {
VectorShape::Rect {
name: String::new(),
x1: 10.0,
y1: 20.0,
x2: 110.0,
@@ -132,7 +132,7 @@ impl ThemeColors {
accent_hover: Color::from_rgb(0.940, 0.640, 0.260),
accent_green: Color::from_rgb(0.298, 0.686, 0.314),
danger: Color::from_rgb(0.780, 0.220, 0.267),
border_low: Color::from_rgb(0.110, 0.106, 0.086),
border_low: Color::from_rgb(0.165, 0.169, 0.141),
border_high: Color::from_rgb(0.243, 0.251, 0.212),
text_primary: Color::from_rgb(0.973, 0.973, 0.941),
text_secondary: Color::from_rgb(0.580, 0.588, 0.533),
@@ -153,7 +153,7 @@ impl ThemeColors {
accent_hover: Color::from_rgb(1.000, 0.530, 0.820),
accent_green: Color::from_rgb(0.298, 0.686, 0.314),
danger: Color::from_rgb(0.753, 0.220, 0.294),
border_low: Color::from_rgb(0.118, 0.110, 0.165),
border_low: Color::from_rgb(0.173, 0.180, 0.227),
border_high: Color::from_rgb(0.267, 0.275, 0.345),
text_primary: Color::from_rgb(0.973, 0.973, 0.949),
text_secondary: Color::from_rgb(0.588, 0.596, 0.667),