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:
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user