From eb01ec98e1bd21a4dd7928f82ee8d86153365078 Mon Sep 17 00:00:00 2001 From: Halit Can Date: Wed, 15 Jul 2026 19:44:02 +0300 Subject: [PATCH] migrate : glm and gemini pro . no proper achievements --- .../2025-07-15-iced-full-gui-migration.md | 1300 +++++++++++++++++ .../2025-07-15-iced-migration-handoff.md | 160 ++ hcie-iced-app/crates/hcie-iced-gui/src/app.rs | 229 ++- .../hcie-iced-gui/src/canvas/edge_cache.rs | 62 + .../crates/hcie-iced-gui/src/canvas/mod.rs | 94 +- .../hcie-iced-gui/src/canvas/shader_canvas.rs | 17 + .../crates/hcie-iced-gui/src/panels/menus.rs | 129 ++ .../hcie-iced-gui/src/script/executor.rs | 72 - .../crates/hcie-iced-gui/src/script/mod.rs | 12 +- .../crates/hcie-iced-gui/src/script/parser.rs | 198 --- .../crates/hcie-iced-gui/src/script/types.rs | 24 - 11 files changed, 1944 insertions(+), 353 deletions(-) create mode 100644 docs/compose/plans/2025-07-15-iced-full-gui-migration.md create mode 100644 docs/compose/plans/2025-07-15-iced-migration-handoff.md create mode 100644 hcie-iced-app/crates/hcie-iced-gui/src/canvas/edge_cache.rs diff --git a/docs/compose/plans/2025-07-15-iced-full-gui-migration.md b/docs/compose/plans/2025-07-15-iced-full-gui-migration.md new file mode 100644 index 0000000..9d189ca --- /dev/null +++ b/docs/compose/plans/2025-07-15-iced-full-gui-migration.md @@ -0,0 +1,1300 @@ +# ICED Full GUI Migration Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Migrate all GUI functions and elements from egui to ICED, bringing the ICED GUI to feature parity with the egui GUI. + +**Architecture:** The ICED GUI already has solid infrastructure (~12,000 lines, 52 source files) including GPU-accelerated canvas, dock system, basic panels, and selection/transform systems. The migration focuses on filling the gaps: filter parameter panels, AI chat with streaming, full script editor with parser/executor, AI script generator, viewer mode, and auxiliary features. + +**Tech Stack:** Rust, ICED 0.13 (with tokio, image, svg, canvas, wgpu, advanced features), hcie-engine-api, serde_json, reqwest, tokio, arboard, rfd + +## Global Constraints + +- All GUI code lives in `hcie-iced-app/crates/` — never modify engine crates directly +- Engine interaction only through `hcie_engine-api` public API +- ICED 0.13 with features: `["tokio", "image", "svg", "canvas", "wgpu", "advanced"]` +- Follow existing ICED patterns: `update()`/`view()`/`subscription()` Elm architecture +- Use `ThemeColors` from `iced-panel-adapter` for consistent styling +- Preserve all GPU-accelerated canvas performance optimizations in `shader_canvas.rs` +- No comments unless the WHY is non-obvious + +--- + +## Phase 1: Filter Parameter Panels (Critical — Core Feature) + +### Task 1: Create Filter Parameter Panel Module + +**Covers:** Filter parameter UI for all 30+ filters + +**Files:** +- Create: `hcie-iced-app/crates/hcie-iced-gui/src/panels/filter_params.rs` +- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/panels/mod.rs` — add `pub mod filter_params;` +- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` — add `FilterParamChanged` message handling + +**Interfaces:** +- Consumes: `FilterType`, `ThemeColors`, `serde_json::Value` +- Produces: `Element<'static, Message>` for each filter's parameter panel + +- [ ] **Step 1: Create filter_params.rs with parameter panels for all filters** + +```rust +//! Filter parameter panels — dynamic parameter UIs for each filter type. +//! +//! Each filter gets a dedicated parameter panel with appropriate controls +//! (sliders, color pickers, dropdowns) matching the egui implementation. + +use crate::app::Message; +use crate::panels::styles; +use crate::theme::ThemeColors; +use hcie_engine_api::FilterType; +use iced::widget::{button, column, container, horizontal_rule, pick_list, row, scrollable, slider, text, text_input}; +use iced::{Element, Length}; +use serde_json::{json, Value}; + +/// Build the parameter panel for the selected filter. +pub fn view( + filter: FilterType, + params: &Value, + colors: ThemeColors, +) -> Element<'static, Message> { + let panel: Element<'static, Message> = match filter { + // ── Blur filters ────────────────────────────────── + FilterType::BoxBlur => blur_panel("box_blur", "Radius", params, colors), + FilterType::GaussianBlur => blur_panel("gaussian_blur", "Radius", params, colors), + FilterType::MotionBlur => motion_blur_panel(params, colors), + FilterType::GaussianBlurGamma => blur_panel("gaussian_blur_gamma", "Radius", params, colors), + FilterType::BoxBlurGamma => blur_panel("box_blur_gamma", "Radius", params, colors), + FilterType::MedianFilter => blur_panel("median_filter", "Radius", params, colors), + + // ── Sharpen ─────────────────────────────────────── + FilterType::UnsharpMask => unsharp_panel(params, colors), + + // ── Pixelate ────────────────────────────────────── + FilterType::Mosaic => pixel_size_panel("mosaic", params, colors), + FilterType::Crystallize => pixel_size_panel("crystallize", "Cell Size", params, colors), + + // ── Distort ─────────────────────────────────────── + FilterType::Pinch => amount_panel("pinch", "Strength", params, colors), + FilterType::Twirl => amount_panel("twirl", "Angle", params, colors), + + // ── Stylize ─────────────────────────────────────── + FilterType::OilPaint => oil_paint_panel(params, colors), + + // ── Color & Light ───────────────────────────────── + FilterType::Levels => levels_panel(params, colors), + FilterType::Vibrance => amount_panel("vibrance", "Amount", params, colors), + FilterType::Exposure => amount_panel("exposure", "Stops", params, colors), + FilterType::Posterize => levels_panel_custom("posterize", "Levels", 2, 255, params, colors), + FilterType::Threshold => levels_panel_custom("threshold", "Threshold", 0, 255, params, colors), + FilterType::ChannelMixer => channel_mixer_panel(params, colors), + FilterType::BlackAndWhite => black_and_white_panel(params, colors), + FilterType::SelectiveColor => selective_color_panel(params, colors), + FilterType::GammaCorrection => amount_panel("gamma_correction", "Gamma", params, colors), + FilterType::ExtractChannel => extract_channel_panel(params, colors), + FilterType::GradientMap => gradient_map_panel(params, colors), + FilterType::Dehaze => amount_panel("dehaze", "Strength", params, colors), + + // ── Noise & Pattern ─────────────────────────────── + FilterType::NoisePattern => noise_pattern_panel(params, colors), + }; + + container(panel) + .width(Length::Fill) + .padding(8) + .style(move |_theme| styles::panel_background(colors)) + .into() +} + +// ── Helper functions for building parameter panels ──────── + +fn param_slider( + label: &'static str, + filter_id: &'static str, + param_key: &'static str, + min: f32, + max: f32, + step: f32, + current: f32, + colors: ThemeColors, +) -> Element<'static, Message> { + let value_label = text(format!("{:.1}", current)) + .size(10) + .width(Length::Fixed(40.0)); + let sl = slider(min..=max, current, move |v| { + Message::FilterParamChanged( + filter_id.to_string(), + json!({ param_key: v }), + ) + }) + .step(step) + .width(Length::Fill); + + row![text(label).size(10).width(Length::Fixed(80.0)), sl, value_label] + .spacing(4) + .align_y(iced::Alignment::Center) + .into() +} + +fn blur_panel(filter_id: &'static str, label: &'static str, params: &Value, colors: ThemeColors) -> Element<'static, Message> { + let radius = params["radius"].as_f64().unwrap_or(5.0) as f32; + column![ + param_slider(label, filter_id, "radius", 1.0, 50.0, 0.5, radius, colors), + ] + .spacing(4) + .into() +} + +fn motion_blur_panel(params: &Value, colors: ThemeColors) -> Element<'static, Message> { + let radius = params["radius"].as_f64().unwrap_or(5.0) as f32; + let angle = params["angle"].as_f64().unwrap_or(0.0) as f32; + column![ + param_slider("Radius", "motion_blur", "radius", 1.0, 50.0, 0.5, radius, colors), + param_slider("Angle", "motion_blur", "angle", 0.0, 360.0, 1.0, angle, colors), + ] + .spacing(4) + .into() +} + +fn unsharp_panel(params: &Value, colors: ThemeColors) -> Element<'static, Message> { + let radius = params["radius"].as_f64().unwrap_or(3.0) as f32; + let amount = params["amount"].as_f64().unwrap_or(1.0) as f32; + let threshold = params["threshold"].as_f64().unwrap_or(0.0) as f32; + column![ + param_slider("Radius", "unsharp_mask", "radius", 1.0, 20.0, 0.5, radius, colors), + param_slider("Amount", "unsharp_mask", "amount", 0.1, 5.0, 0.1, amount, colors), + param_slider("Threshold", "unsharp_mask", "threshold", 0.0, 255.0, 1.0, threshold, colors), + ] + .spacing(4) + .into() +} + +fn pixel_size_panel(filter_id: &'static str, params: &Value, colors: ThemeColors) -> Element<'static, Message> { + let size = params["size"].as_f64().unwrap_or(10.0) as f32; + param_slider("Cell Size", filter_id, "size", 2.0, 100.0, 1.0, size, colors) +} + +fn amount_panel(filter_id: &'static str, label: &'static str, params: &Value, colors: ThemeColors) -> Element<'static, Message> { + let amount = params["amount"].as_f64().unwrap_or(0.5) as f32; + param_slider(label, filter_id, "amount", -2.0, 2.0, 0.01, amount, colors) +} + +fn oil_paint_panel(params: &Value, colors: ThemeColors) -> Element<'static, Message> { + let size = params["size"].as_f64().unwrap_or(4.0) as f32; + let dynamic = params["dynamic"].as_f64().unwrap_or(1.0) as f32; + column![ + param_slider("Brush Size", "oil_paint", "size", 1.0, 20.0, 1.0, size, colors), + param_slider("Dynamic", "oil_paint", "dynamic", 0.0, 2.0, 0.1, dynamic, colors), + ] + .spacing(4) + .into() +} + +fn levels_panel(params: &Value, colors: ThemeColors) -> Element<'static, Message> { + let input_low = params["input_low"].as_f64().unwrap_or(0.0) as f32; + let input_high = params["input_high"].as_f64().unwrap_or(255.0) as f32; + let output_low = params["output_low"].as_f64().unwrap_or(0.0) as f32; + let output_high = params["output_high"].as_f64().unwrap_or(255.0) as f32; + let gamma = params["gamma"].as_f64().unwrap_or(1.0) as f32; + column![ + param_slider("Input Low", "levels", "input_low", 0.0, 255.0, 1.0, input_low, colors), + param_slider("Input High", "levels", "input_high", 0.0, 255.0, 1.0, input_high, colors), + param_slider("Gamma", "levels", "gamma", 0.1, 5.0, 0.1, gamma, colors), + param_slider("Output Low", "levels", "output_low", 0.0, 255.0, 1.0, output_low, colors), + param_slider("Output High", "levels", "output_high", 0.0, 255.0, 1.0, output_high, colors), + ] + .spacing(4) + .into() +} + +fn levels_panel_custom(filter_id: &'static str, label: &'static str, min: f32, max: f32, params: &Value, colors: ThemeColors) -> Element<'static, Message> { + let value = params["levels"].as_f64().unwrap_or(128.0) as f32; + param_slider(label, filter_id, "levels", min, max, 1.0, value, colors) +} + +fn channel_mixer_panel(params: &Value, colors: ThemeColors) -> Element<'static, Message> { + let rr = params["red_red"].as_f64().unwrap_or(100.0) as f32; + let rg = params["red_green"].as_f64().unwrap_or(0.0) as f32; + let rb = params["red_blue"].as_f64().unwrap_or(0.0) as f32; + let gr = params["green_red"].as_f64().unwrap_or(0.0) as f32; + let gg = params["green_green"].as_f64().unwrap_or(100.0) as f32; + let gb = params["green_blue"].as_f64().unwrap_or(0.0) as f32; + let br = params["blue_red"].as_f64().unwrap_or(0.0) as f32; + let bg = params["blue_green"].as_f64().unwrap_or(0.0) as f32; + let bb = params["blue_blue"].as_f64().unwrap_or(100.0) as f32; + column![ + text("Red Channel").size(11), + param_slider("Red", "channel_mixer", "red_red", -200.0, 200.0, 1.0, rr, colors), + param_slider("Green", "channel_mixer", "red_green", -200.0, 200.0, 1.0, rg, colors), + param_slider("Blue", "channel_mixer", "red_blue", -200.0, 200.0, 1.0, rb, colors), + text("Green Channel").size(11), + param_slider("Red", "channel_mixer", "green_red", -200.0, 200.0, 1.0, gr, colors), + param_slider("Green", "channel_mixer", "green_green", -200.0, 200.0, 1.0, gg, colors), + param_slider("Blue", "channel_mixer", "green_blue", -200.0, 200.0, 1.0, gb, colors), + text("Blue Channel").size(11), + param_slider("Red", "channel_mixer", "blue_red", -200.0, 200.0, 1.0, br, colors), + param_slider("Green", "channel_mixer", "blue_green", -200.0, 200.0, 1.0, bg, colors), + param_slider("Blue", "channel_mixer", "blue_blue", -200.0, 200.0, 1.0, bb, colors), + ] + .spacing(4) + .into() +} + +fn black_and_white_panel(params: &Value, colors: ThemeColors) -> Element<'static, Message> { + let red = params["red"].as_f64().unwrap_or(40.0) as f32; + let yellow = params["yellow"].as_f64().unwrap_or(60.0) as f32; + let green = params["green"].as_f64().unwrap_or(40.0) as f32; + let cyan = params["cyan"].as_f64().unwrap_or(60.0) as f32; + let blue = params["blue"].as_f64().unwrap_or(20.0) as f32; + let magenta = params["magenta"].as_f64().unwrap_or(80.0) as f32; + column![ + param_slider("Red", "black_and_white", "red", 0.0, 200.0, 1.0, red, colors), + param_slider("Yellow", "black_and_white", "yellow", 0.0, 200.0, 1.0, yellow, colors), + param_slider("Green", "black_and_white", "green", 0.0, 200.0, 1.0, green, colors), + param_slider("Cyan", "black_and_white", "cyan", 0.0, 200.0, 1.0, cyan, colors), + param_slider("Blue", "black_and_white", "blue", 0.0, 200.0, 1.0, blue, colors), + param_slider("Magenta", "black_and_white", "magenta", 0.0, 200.0, 1.0, magenta, colors), + ] + .spacing(4) + .into() +} + +fn selective_color_panel(params: &Value, colors: ThemeColors) -> Element<'static, Message> { + let _ = params; + column![ + text("Selective Color").size(11), + text("Select a color range to adjust:").size(10), + text("Cyan, Magenta, Yellow, Black").size(10), + ] + .spacing(4) + .into() +} + +fn extract_channel_panel(params: &Value, colors: ThemeColors) -> Element<'static, Message> { + let _ = params; + column![ + text("Extract Channel").size(11), + text("Channel: Red").size(10), + ] + .spacing(4) + .into() +} + +fn gradient_map_panel(params: &Value, colors: ThemeColors) -> Element<'static, Message> { + let _ = params; + column![ + text("Gradient Map").size(11), + text("Maps grayscale values to a gradient").size(10), + ] + .spacing(4) + .into() +} + +fn noise_pattern_panel(params: &Value, colors: ThemeColors) -> Element<'static, Message> { + let scale = params["scale"].as_f64().unwrap_or(50.0) as f32; + let opacity = params["opacity"].as_f64().unwrap_or(0.5) as f32; + column![ + param_slider("Scale", "noise_pattern", "scale", 1.0, 200.0, 1.0, scale, colors), + param_slider("Opacity", "noise_pattern", "opacity", 0.0, 1.0, 0.01, opacity, colors), + ] + .spacing(4) + .into() +} +``` + +- [ ] **Step 2: Register the module in panels/mod.rs** + +Add to `hcie-iced-app/crates/hcie-iced-gui/src/panels/mod.rs`: +```rust +pub mod filter_params; +``` + +- [ ] **Step 3: Integrate filter params into filters panel view** + +Modify `hcie-iced-app/crates/hcie-iced-gui/src/panels/filters.rs` to show parameter panel when a filter is selected: + +Replace the `view` function with: +```rust +pub fn view(selected_filter: Option, params: &serde_json::Value, colors: ThemeColors) -> Element<'static, Message> { + let mut filter_list = column![].spacing(2); + + for category in FILTER_CATEGORIES { + let cat_name = text(category.name).size(12); + let mut cat_filters = column![].spacing(1).padding(iced::Padding::ZERO.left(12)); + + for entry in category.filters { + let is_selected = selected_filter == Some(entry.filter_type); + let filter_text = text(entry.name).size(11); + let filter_text = if is_selected { + filter_text.style(move |_theme: &iced::Theme| iced::widget::text::Style { + color: Some(colors.accent), + }) + } else { + filter_text + }; + + let item_style = if is_selected { + styles::active_item_bg(colors) + } else { + styles::inactive_item_bg(colors) + }; + + let item = container(filter_text) + .width(Length::Fill) + .padding([3, 6]) + .style(move |_theme| item_style.clone()); + + let filter_type = entry.filter_type; + let clickable = iced::widget::mouse_area(item) + .on_press(Message::FilterSelect(filter_type)); + + cat_filters = cat_filters.push(clickable); + } + + filter_list = filter_list.push(cat_name); + filter_list = filter_list.push(cat_filters); + } + + let apply_btn = button(text("Apply").size(11)) + .on_press(Message::FilterApply) + .padding([6, 12]); + + let reset_btn = button(text("Reset").size(11)) + .on_press(Message::FilterReset) + .padding([6, 12]); + + let buttons = row![apply_btn, reset_btn].spacing(8); + + // Show parameter panel when a filter is selected + let param_panel = if let Some(filter) = selected_filter { + super::filter_params::view(filter, params, colors) + } else { + container(text("Select a filter").size(10).style(move |_theme: &iced::Theme| iced::widget::text::Style { + color: Some(colors.text_secondary), + })) + .padding(8) + .into() + }; + + let panel = column![ + scrollable(filter_list).height(Length::Fill), + horizontal_rule(1), + param_panel, + horizontal_rule(1), + buttons, + ] + .spacing(0) + .padding(4); + + container(panel) + .width(Length::Fill) + .height(Length::Fill) + .style(move |_theme| styles::panel_background(colors)) + .into() +} +``` + +- [ ] **Step 4: Update app.rs to pass filter params to filters view** + +Find where `panels::filters::view()` is called in app.rs and add the `params` argument. + +- [ ] **Step 5: Test compilation** + +Run: `cargo build -p hcie-iced-gui` +Expected: Compiles without errors + +- [ ] **Step 6: Commit** + +```bash +git add hcie-iced-app/crates/hcie-iced-gui/src/panels/filter_params.rs hcie-iced-app/crates/hcie-iced-gui/src/panels/mod.rs hcie-iced-app/crates/hcie-iced-gui/src/panels/filters.rs hcie-iced-app/crates/hcie-iced-gui/src/app.rs +git commit -m "feat(iced): add filter parameter panels for all 30+ filters" +``` + +--- + +### Task 2: Add Filter Instant Preview + +**Covers:** Live preview of filters before applying + +**Files:** +- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` — add preview begin/restore/apply logic +- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/panels/filters.rs` — add preview checkbox + +**Interfaces:** +- Consumes: `engine.filter_preview_begin()`, `engine.filter_preview_restore()`, `engine.apply_filter_preview()` +- Produces: Updated composite texture with preview + +- [ ] **Step 1: Add preview state to app.rs** + +In `HcieIcedApp` struct, ensure `filter_preview_active: bool` exists (already present). + +- [ ] **Step 2: Add preview message variants** + +Add to Message enum: +```rust +FilterPreviewToggle(bool), +FilterPreviewApply, +FilterPreviewRestore, +``` + +- [ ] **Step 3: Handle preview messages in update()** + +In the `update()` function, add handling for the new messages that call the engine's preview methods. + +- [ ] **Step 4: Add preview checkbox to filters panel** + +In `filters.rs`, add a checkbox before Apply/Reset buttons: +```rust +let preview_check = iced::widget::checkbox("Preview", filter_preview_active) + .on_toggle(|v| Message::FilterPreviewToggle(v)); +``` + +- [ ] **Step 5: Test compilation and verify** + +Run: `cargo build -p hcie-iced-gui` +Expected: Compiles without errors + +- [ ] **Step 6: Commit** + +```bash +git add hcie-iced-app/crates/hcie-iced-gui/src/app.rs hcie-iced-app/crates/hcie-iced-gui/src/panels/filters.rs +git commit -m "feat(iced): add filter instant preview" +``` + +--- + +## Phase 2: Full AI Chat Panel with Streaming + +### Task 3: Implement AI Chat with Streaming SSE + +**Covers:** Complete AI chat panel with streaming responses, tool execution, and DSL parsing + +**Files:** +- Create: `hcie-iced-app/crates/hcie-iced-gui/src/ai_chat.rs` — AI chat state and logic +- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/panels/ai_chat_panel.rs` — full implementation +- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` — add AI chat messages +- Modify: `hcie-iced-app/crates/hcie-iced-gui/Cargo.toml` — add `reqwest` and `tokio` dependencies + +**Interfaces:** +- Consumes: `hcie_engine_api::Engine`, `reqwest`, `tokio` +- Produces: Streaming AI responses, tool call execution + +- [ ] **Step 1: Create ai_chat.rs with chat state and streaming logic** + +```rust +//! AI Chat state and streaming logic. +//! +//! Handles LLM communication, streaming SSE responses, +//! tool call parsing, and DSL script execution. + +use hcie_engine_api::Engine; +use std::sync::{Arc, Mutex}; + +/// AI chat message. +#[derive(Debug, Clone)] +pub struct ChatMessage { + pub role: String, + pub content: String, + pub is_reasoning: bool, +} + +/// AI chat configuration. +#[derive(Debug, Clone)] +pub struct AiChatConfig { + pub provider: String, + pub base_url: String, + pub model: String, + pub max_turns: u32, + pub reasoning_enabled: bool, + pub canvas_context: bool, +} + +impl Default for AiChatConfig { + fn default() -> Self { + Self { + provider: "ollama".to_string(), + base_url: "http://localhost:11434".to_string(), + model: "llama3".to_string(), + max_turns: 10, + reasoning_enabled: false, + canvas_context: true, + } + } +} + +/// AI chat state. +pub struct AiChatState { + pub messages: Vec, + pub input: String, + pub config: AiChatConfig, + pub is_streaming: bool, + pub current_response: String, +} + +impl AiChatState { + pub fn new() -> Self { + Self { + messages: Vec::new(), + input: String::new(), + config: AiChatConfig::default(), + is_streaming: false, + current_response: String::new(), + } + } + + pub fn clear(&mut self) { + self.messages.clear(); + self.current_response.clear(); + self.is_streaming = false; + } +} + +/// System prompt for the AI assistant. +pub const SYSTEM_PROMPT: &str = r##"You are an expert digital artist AI assistant integrated inside HCIE-Rust, a pixel-grade digital painting and image editing application. +You can talk to the user AND draw or edit their canvas. +You have access to a set of canvas tools that you can call. If the user asks you to paint or edit something, always call the appropriate tools using a JSON code block. + +## Tool Calling Mode +To call tools, output a markdown code block starting with ```json containing either a single tool object or an array of tool objects. + +### Supported JSON Tools: +- create_layer(name: string): Creates a new layer and selects it. +- select_layer(name: string): Selects an existing layer by name. +- draw_rect(x1, y1, x2, y2): Draws a filled rectangle. +- draw_ellipse(cx, cy, rx, ry): Draws a filled ellipse. +- draw_line(x0, y0, x1, y1): Draws a line. +- add_text(text, font, size, x, y, color_hex): Renders vector text. +- apply_filter(filter_id, params): Applies an image filter. +- draw_stroke(points): Paints a brush stroke. + +## DSL Mode +You can also output a procedural drawing script in a ```dsl code block. +Available DSL commands: +- layer — Create and select a layer. +- color — Set current color. +- size — Set brush size. +- rect x1,y1 x2,y2 — Draw filled rectangle. +- circle cx,cy — Draw filled circle. +- line x0,y0 x1,y1 — Draw straight line. +- text "content" font size x y [hex] — Draw text. +"##; + +/// Execute a parsed JSON tool call on the engine. +pub fn execute_tool_call(engine: &mut Engine, name: &str, args: &serde_json::Value) -> Result { + match name { + "create_layer" | "add_layer" => { + let layer_name = args["name"].as_str().unwrap_or("Layer"); + let id = engine.add_layer(layer_name); + engine.set_active_layer(id); + Ok(format!("Created layer '{}' (ID: {})", layer_name, id)) + } + "select_layer" => { + let layer_name = args["name"].as_str().unwrap_or(""); + if let Some(layer) = engine.layer_infos().iter().find(|l| l.name.eq_ignore_ascii_case(layer_name)) { + engine.set_active_layer(layer.id); + Ok(format!("Selected layer '{}' (ID: {})", layer_name, layer.id)) + } else { + let id = engine.add_layer(layer_name); + engine.set_active_layer(id); + Ok(format!("Layer '{}' not found. Created and selected it.", layer_name)) + } + } + "draw_rect" => { + let x1 = args["x1"].as_f64().unwrap_or(0.0) as f32; + let y1 = args["y1"].as_f64().unwrap_or(0.0) as f32; + let x2 = args["x2"].as_f64().unwrap_or(0.0) as f32; + let y2 = args["y2"].as_f64().unwrap_or(0.0) as f32; + engine.draw_rect(x1, y1, x2, y2); + Ok(format!("Drew rectangle [{}, {}, {}, {}]", x1, y1, x2, y2)) + } + "draw_ellipse" => { + let cx = args["cx"].as_f64().unwrap_or(0.0) as f32; + let cy = args["cy"].as_f64().unwrap_or(0.0) as f32; + let rx = args["rx"].as_f64().unwrap_or(0.0) as f32; + let ry = args["ry"].as_f64().unwrap_or(0.0) as f32; + engine.draw_ellipse(cx, cy, rx, ry); + Ok(format!("Drew ellipse center ({}, {}) radius ({}, {})", cx, cy, rx, ry)) + } + "draw_line" => { + let x0 = args["x0"].as_f64().unwrap_or(0.0) as f32; + let y0 = args["y0"].as_f64().unwrap_or(0.0) as f32; + let x1 = args["x1"].as_f64().unwrap_or(0.0) as f32; + let y1 = args["y1"].as_f64().unwrap_or(0.0) as f32; + engine.draw_line(x0, y0, x1, y1); + Ok(format!("Drew line from ({}, {}) to ({}, {})", x0, y0, x1, y1)) + } + "add_text" => { + let text = args["text"].as_str().unwrap_or(""); + let font = args["font"].as_str().unwrap_or("default"); + let size = args["size"].as_f64().unwrap_or(24.0) as f32; + let x = args["x"].as_f64().unwrap_or(100.0) as f32; + let y = args["y"].as_f64().unwrap_or(100.0) as f32; + let color_hex = args["color_hex"].as_str().unwrap_or("#000000"); + let color = parse_hex_color(color_hex).unwrap_or([0, 0, 0, 255]); + engine.add_text(text, font, size, x, y, color, 0.0, hcie_engine_api::TextAlignment::Left, hcie_engine_api::TextOrientation::Horizontal, &[]); + Ok(format!("Added text '{}' at ({}, {})", text, x, y)) + } + "apply_filter" => { + let filter_id = args["filter_id"].as_str().unwrap_or(""); + let params = args["params"].clone(); + engine.apply_filter(filter_id, params); + Ok(format!("Applied filter '{}'", filter_id)) + } + "draw_stroke" => { + let mut points = Vec::new(); + if let Some(pts_arr) = args["points"].as_array() { + for pt in pts_arr { + let x = pt["x"].as_f64().unwrap_or(0.0) as f32; + let y = pt["y"].as_f64().unwrap_or(0.0) as f32; + let p = pt["pressure"].as_f64().unwrap_or(1.0) as f32; + points.push((x, y, p)); + } + } + engine.draw_stroke(&points); + Ok(format!("Drew brush stroke with {} points", points.len())) + } + _ => Err(format!("Unknown tool: {}", name)) + } +} + +/// Parse hex color string to RGBA. +fn parse_hex_color(hex: &str) -> Option<[u8; 4]> { + let hex = hex.trim_start_matches('#'); + if hex.len() < 6 { return None; } + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + let a = if hex.len() >= 8 { + u8::from_str_radix(&hex[6..8], 16).unwrap_or(255) + } else { + 255 + }; + Some([r, g, b, a]) +} + +/// Parse DSL script and execute on engine. +pub fn execute_dsl_script(engine: &mut Engine, script: &str) -> Result, String> { + let mut results = Vec::new(); + for line in script.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { continue; } + + let parts: Vec<&str> = line.splitn(2, ' ').collect(); + let cmd = parts[0]; + let args = parts.get(1).unwrap_or(&""); + + match cmd { + "layer" => { + let id = engine.add_layer(args); + results.push(format!("Created layer '{}' (ID: {})", args, id)); + } + "color" => { + if let Some(color) = parse_hex_color(args) { + engine.set_color(color); + results.push(format!("Set color to {}", args)); + } + } + "size" => { + if let Ok(size) = args.parse::() { + let mut tip = hcie_engine_api::BrushTip::default(); + tip.size = size; + engine.set_brush_tip(tip); + results.push(format!("Set brush size to {}", size)); + } + } + "rect" => { + let coords: Vec = args.split(|c: char| c == ',' || c == ' ') + .filter_map(|s| s.parse().ok()) + .collect(); + if coords.len() >= 4 { + engine.draw_rect(coords[0], coords[1], coords[2], coords[3]); + results.push(format!("Drew rect [{}, {}, {}, {}]", coords[0], coords[1], coords[2], coords[3])); + } + } + "line" => { + let coords: Vec = args.split(|c: char| c == ',' || c == ' ') + .filter_map(|s| s.parse().ok()) + .collect(); + if coords.len() >= 4 { + engine.draw_line(coords[0], coords[1], coords[2], coords[3]); + results.push(format!("Drew line [{}, {}, {}, {}]", coords[0], coords[1], coords[2], coords[3])); + } + } + "circle" => { + let coords: Vec = args.split(|c: char| c == ',' || c == ' ') + .filter_map(|s| s.parse().ok()) + .collect(); + if coords.len() >= 3 { + engine.draw_ellipse(coords[0], coords[1], coords[2], coords[2]); + results.push(format!("Drew circle at ({}, {}) r={}", coords[0], coords[1], coords[2])); + } + } + _ => { + results.push(format!("Unknown command: {}", cmd)); + } + } + } + Ok(results) +} +``` + +- [ ] **Step 2: Update ai_chat_panel.rs with full implementation** + +Replace the entire `ai_chat_panel.rs` with a full implementation that includes: +- Provider selector (Ollama, Claude, OpenAI) +- URL and model inputs +- Message list with chat bubbles +- Streaming response display +- Input field with send/clear buttons +- Reasoning toggle +- Canvas context toggle + +- [ ] **Step 3: Add AI chat message variants to app.rs** + +Add to Message enum: +```rust +AiChatSend, +AiChatClear, +AiChatInput(String), +AiChatProviderChanged(String), +AiChatUrlChanged(String), +AiChatModelChanged(String), +AiChatReasoningToggled(bool), +AiChatCanvasContextToggled(bool), +AiChatStreamChunk(String), +AiChatStreamFinished(Result), +``` + +- [ ] **Step 4: Handle AI chat messages in update()** + +Implement the update logic for AI chat, including spawning async tasks for streaming. + +- [ ] **Step 5: Add reqwest and tokio dependencies** + +In `hcie-iced-app/crates/hcie-iced-gui/Cargo.toml`, add: +```toml +reqwest = { version = "0.12", features = ["json", "stream"] } +tokio = { version = "1", features = ["full"] } +futures-util = "0.3" +``` + +- [ ] **Step 6: Test compilation** + +Run: `cargo build -p hcie-iced-gui` +Expected: Compiles without errors + +- [ ] **Step 7: Commit** + +```bash +git add hcie-iced-app/crates/hcie-iced-gui/src/ai_chat.rs hcie-iced-app/crates/hcie-iced-gui/src/panels/ai_chat_panel.rs hcie-iced-app/crates/hcie-iced-gui/src/app.rs hcie-iced-app/crates/hcie-iced-gui/Cargo.toml +git commit -m "feat(iced): implement AI chat with streaming SSE and tool execution" +``` + +--- + +## Phase 3: Full Script Editor with Parser/Executor + +### Task 4: Port DSL Parser to ICED + +**Covers:** Complete DSL tokenizer and recursive-descent parser + +**Files:** +- Create: `hcie-iced-app/crates/hcie-iced-gui/src/script/parser.rs` +- Create: `hcie-iced-app/crates/hcie-iced-gui/src/script/executor.rs` +- Create: `hcie-iced-app/crates/hcie-iced-gui/src/script/mod.rs` +- Create: `hcie-iced-app/crates/hcie-iced-gui/src/script/types.rs` + +**Interfaces:** +- Consumes: `hcie_engine_api::Engine`, `serde_json::Value` +- Produces: `ScriptAction` list, execution results + +- [ ] **Step 1: Create script/types.rs** + +```rust +//! Script parser state for tracking brush/color/layer state during parsing. + +#[derive(Debug, Clone, Default)] +pub struct ScriptParserState { + pub current_color: String, + pub brush_style: Option, + pub brush_size: f32, + pub brush_opacity: f32, + pub brush_hardness: f32, + pub brush_spacing: f32, + pub blend_mode: String, + pub variables: std::collections::HashMap, + pub scatter: Option, + pub flow: f32, + pub pressure: f32, + pub wiggle: f32, +} +``` + +- [ ] **Step 2: Create script/parser.rs** + +Port the full parser from `egui-panel-script/src/parser.rs` (1143 lines) to ICED. This includes: +- Expression tokenizer +- Recursive-descent parser +- All ScriptAction variants +- Variable interpolation +- Loop/repeat support +- Error reporting with line numbers + +- [ ] **Step 3: Create script/executor.rs** + +Port the executor from `egui-panel-script/src/executor.rs` (234 lines) to ICED. + +- [ ] **Step 4: Create script/mod.rs** + +```rust +pub mod executor; +pub mod parser; +pub mod types; + +pub use executor::execute_actions; +pub use parser::parse_dsl_script; +pub use parser::parse_error_line; +pub use parser::suggest_fix; +pub use parser::DEFAULT_SCRIPT; +``` + +- [ ] **Step 5: Test compilation** + +Run: `cargo build -p hcie-iced-gui` +Expected: Compiles without errors + +- [ ] **Step 6: Commit** + +```bash +git add hcie-iced-app/crates/hcie-iced-gui/src/script/ +git commit -m "feat(iced): port full DSL parser and executor to ICED" +``` + +--- + +### Task 5: Implement Full Script Editor Panel + +**Covers:** Script editor with line numbers, error highlighting, and command reference + +**Files:** +- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/panels/script_panel.rs` +- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` — add script state and messages + +**Interfaces:** +- Consumes: `script::parse_dsl_script`, `script::execute_actions`, `ThemeColors` +- Produces: Full script editor UI with error display + +- [ ] **Step 1: Add script state to app.rs** + +In `HcieIcedApp` struct, add: +```rust +pub script_text: String, +pub script_error: Option, +pub script_error_line: Option, +pub script_is_running: bool, +``` + +- [ ] **Step 2: Add script message variants** + +```rust +ScriptTextChanged(String), +ScriptRun, +ScriptClear, +``` + +- [ ] **Step 3: Implement full script_panel.rs** + +Replace the current placeholder with a full implementation including: +- Monospace text editor with line numbers +- Error highlighting (red line numbers for error lines) +- Run/Clear buttons with status indicator +- Collapsible command reference +- Error display with line context and suggestions + +- [ ] **Step 4: Handle script messages in update()** + +- [ ] **Step 5: Test compilation** + +Run: `cargo build -p hcie-iced-gui` +Expected: Compiles without errors + +- [ ] **Step 6: Commit** + +```bash +git add hcie-iced-app/crates/hcie-iced-gui/src/panels/script_panel.rs hcie-iced-app/crates/hcie-iced-gui/src/app.rs +git commit -m "feat(iced): implement full script editor with line numbers and error highlighting" +``` + +--- + +## Phase 4: AI Script Generator + +### Task 6: Implement AI Script Generator with Working LLM Calls + +**Covers:** AI script generation with provider selection and API calls + +**Files:** +- Create: `hcie-iced-app/crates/hcie-iced-gui/src/ai_script.rs` +- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/panels/ai_script_panel.rs` +- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` — add AI script state and messages + +**Interfaces:** +- Consumes: `reqwest`, `tokio`, `script::parse_dsl_script`, `script::execute_actions` +- Produces: Generated DSL scripts, execution results + +- [ ] **Step 1: Create ai_script.rs** + +Port the full AI script generator from `egui-panel-ai-script/src/lib.rs` (373 lines) to ICED, including: +- Provider selection (Ollama, Claude, OpenAI) +- API calls for script generation +- System prompt for HCIE DSL +- Script cleaning and validation +- Run/Copy functionality + +- [ ] **Step 2: Add AI script state to app.rs** + +```rust +pub ai_script_prompt: String, +pub ai_script_output: String, +pub ai_script_error: Option, +pub ai_script_is_running: bool, +pub ai_script_provider: String, +pub ai_script_model: String, +pub ai_script_url: String, +``` + +- [ ] **Step 3: Add AI script message variants** + +```rust +AiScriptPromptChanged(String), +AiScriptProviderChanged(String), +AiScriptModelChanged(String), +AiScriptUrlChanged(String), +AiScriptGenerate, +AiScriptRun, +AiScriptCopy, +AiScriptClear, +AiScriptResult(Result), +``` + +- [ ] **Step 4: Implement full ai_script_panel.rs** + +- [ ] **Step 5: Test compilation** + +Run: `cargo build -p hcie-iced-gui` +Expected: Compiles without errors + +- [ ] **Step 6: Commit** + +```bash +git add hcie-iced-app/crates/hcie-iced-gui/src/ai_script.rs hcie-iced-app/crates/hcie-iced-gui/src/panels/ai_script_panel.rs hcie-iced-app/crates/hcie-iced-gui/src/app.rs +git commit -m "feat(iced): implement AI script generator with working LLM calls" +``` + +--- + +## Phase 5: Viewer Mode + +### Task 7: Implement FastStone-like Image Viewer + +**Covers:** Directory browser, thumbnail grid, preview panel, keyboard navigation + +**Files:** +- Create: `hcie-iced-app/crates/hcie-iced-gui/src/viewer/mod.rs` +- Create: `hcie-iced-app/crates/hcie-iced-gui/src/viewer/directory_tree.rs` +- Create: `hcie-iced-app/crates/hcie-iced-gui/src/viewer/thumbnail_grid.rs` +- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` — add viewer state and mode + +**Interfaces:** +- Consumes: `std::path::PathBuf`, `image` crate +- Produces: Viewer UI with directory navigation and image preview + +- [ ] **Step 1: Create viewer/mod.rs** + +```rust +pub mod directory_tree; +pub mod thumbnail_grid; + +use crate::app::Message; +use crate::theme::ThemeColors; +use iced::widget::{column, container, row, scrollable, text}; +use iced::{Element, Length}; + +/// Viewer mode state. +pub struct ViewerState { + pub current_dir: std::path::PathBuf, + pub selected_file: Option, + pub thumbnails: Vec, + pub is_fullscreen: bool, +} + +pub struct ThumbnailEntry { + pub path: std::path::PathBuf, + pub name: String, + pub is_directory: bool, + pub thumbnail: Option>, +} + +impl ViewerState { + pub fn new() -> Self { + Self { + current_dir: dirs::home_dir().unwrap_or_default(), + selected_file: None, + thumbnails: Vec::new(), + is_fullscreen: false, + } + } +} + +pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> { + let dir_tree = directory_tree::view(&state.current_dir, colors); + let thumbnails = thumbnail_grid::view(&state.thumbnails, colors); + + row![ + container(dir_tree).width(Length::Fixed(200.0)), + container(thumbnails).width(Length::Fill), + ] + .spacing(4) + .into() +} +``` + +- [ ] **Step 2: Create directory_tree.rs** + +Implement directory navigation with tree view. + +- [ ] **Step 3: Create thumbnail_grid.rs** + +Implement thumbnail grid with lazy loading. + +- [ ] **Step 4: Add viewer state to app.rs and viewer mode toggle** + +- [ ] **Step 5: Test compilation** + +Run: `cargo build -p hcie-iced-gui` +Expected: Compiles without errors + +- [ ] **Step 6: Commit** + +```bash +git add hcie-iced-app/crates/hcie-iced-gui/src/viewer/ +git commit -m "feat(iced): implement FastStone-like image viewer" +``` + +--- + +## Phase 6: Auxiliary Features + +### Task 8: Add Dock Profile Management + +**Covers:** Save/load/rename/delete dock profiles + +**Files:** +- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` — add dock profile messages +- Create: `hcie-iced-app/crates/hcie-iced-gui/src/dialogs/dock_profile.rs` + +**Interfaces:** +- Consumes: `serde_json`, `std::fs` +- Produces: Dock profile CRUD operations + +- [ ] **Step 1: Create dock_profile.rs dialog** + +- [ ] **Step 2: Add dock profile message variants** + +- [ ] **Step 3: Handle dock profile messages in update()** + +- [ ] **Step 4: Test compilation** + +- [ ] **Step 5: Commit** + +```bash +git add hcie-iced-app/crates/hcie-iced-gui/src/dialogs/dock_profile.rs hcie-iced-app/crates/hcie-iced-gui/src/app.rs +git commit -m "feat(iced): add dock profile management" +``` + +--- + +### Task 9: Add ABR Brush Import + +**Covers:** Parse Photoshop .abr files and import brush presets + +**Files:** +- Create: `hcie-iced-app/crates/hcie-iced-gui/src/brush_import.rs` +- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs` — add import button + +**Interfaces:** +- Consumes: `std::fs::File`, `std::io::Read` +- Produces: `Vec` + +- [ ] **Step 1: Port ABR parser from egui** + +Port `brush_import.rs` (631 lines) from egui to ICED. + +- [ ] **Step 2: Add import button to brushes panel** + +- [ ] **Step 3: Test compilation** + +- [ ] **Step 4: Commit** + +```bash +git add hcie-iced-app/crates/hcie-iced-gui/src/brush_import.rs hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs +git commit -m "feat(iced): add ABR brush import" +``` + +--- + +### Task 10: Add Pressure Indicator HUD + +**Covers:** HUD showing current pen pressure during strokes + +**Files:** +- Create: `hcie-iced-app/crates/hcie-iced-gui/src/panels/pressure_indicator.rs` +- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs` — render pressure indicator + +**Interfaces:** +- Consumes: `tablet_state: Arc>` +- Produces: Pressure indicator overlay + +- [ ] **Step 1: Create pressure_indicator.rs** + +- [ ] **Step 2: Integrate into canvas view** + +- [ ] **Step 3: Test compilation** + +- [ ] **Step 4: Commit** + +```bash +git add hcie-iced-app/crates/hcie-iced-gui/src/panels/pressure_indicator.rs hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs +git commit -m "feat(iced): add pressure indicator HUD" +``` + +--- + +### Task 11: Add Internationalization Support + +**Covers:** English/Turkish translation keys + +**Files:** +- Create: `hcie-iced-app/crates/hcie-iced-gui/src/i18n.rs` +- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` — add language state + +**Interfaces:** +- Consumes: `std::collections::HashMap` +- Produces: Translated UI strings + +- [ ] **Step 1: Create i18n.rs with translation keys** + +Port from egui's `i18n.rs` (76 lines). + +- [ ] **Step 2: Add language state to app.rs** + +- [ ] **Step 3: Test compilation** + +- [ ] **Step 4: Commit** + +```bash +git add hcie-iced-app/crates/hcie-iced-gui/src/i18n.rs hcie-iced-app/crates/hcie-iced-gui/src/app.rs +git commit -m "feat(iced): add internationalization support" +``` + +--- + +## Phase 7: Polish and Integration + +### Task 12: Add Missing Dialogs + +**Covers:** Image Size, Canvas Size, About dialogs + +**Files:** +- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/dialogs/` — add missing dialogs + +- [ ] **Step 1: Add Image Size dialog** + +- [ ] **Step 2: Add Canvas Size dialog** + +- [ ] **Step 3: Add About dialog** + +- [ ] **Step 4: Test compilation** + +- [ ] **Step 5: Commit** + +```bash +git add hcie-iced-app/crates/hcie-iced-gui/src/dialogs/ +git commit -m "feat(iced): add missing dialogs (Image Size, Canvas Size, About)" +``` + +--- + +### Task 13: Add Keyboard Shortcuts + +**Covers:** All keyboard shortcuts from egui + +**Files:** +- Modify: `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` — add keyboard handling in subscription() + +- [ ] **Step 1: Add keyboard shortcut handling** + +Port all keyboard shortcuts from egui's `menus.rs` (903 lines) to ICED's subscription system. + +- [ ] **Step 2: Test compilation** + +- [ ] **Step 3: Commit** + +```bash +git add hcie-iced-app/crates/hcie-iced-gui/src/app.rs +git commit -m "feat(iced): add all keyboard shortcuts from egui" +``` + +--- + +### Task 14: Final Integration Test + +**Covers:** End-to-end testing of all migrated features + +**Files:** +- Modify: `hcie-iced-app/crates/hcie-iced-gui/tests/` — add integration tests + +- [ ] **Step 1: Run full build** + +Run: `cargo build -p hcie-iced-gui` +Expected: Compiles without errors + +- [ ] **Step 2: Run existing tests** + +Run: `cargo test -p hcie-iced-gui` +Expected: All tests pass + +- [ ] **Step 3: Manual verification** + +Launch the app and verify: +- Filter parameter panels work for all filters +- AI chat with streaming works +- Script editor with parser/executor works +- AI script generator works +- Viewer mode works +- All dialogs work +- Keyboard shortcuts work + +- [ ] **Step 4: Final commit** + +```bash +git add -A +git commit -m "feat(iced): complete GUI migration from egui to ICED" +``` + +--- + +## Summary + +| Phase | Tasks | Description | +|-------|-------|-------------| +| 1 | 1-2 | Filter parameter panels + instant preview | +| 2 | 3 | AI chat with streaming SSE | +| 3 | 4-5 | Full script editor with parser/executor | +| 4 | 6 | AI script generator | +| 5 | 7 | Viewer mode | +| 6 | 8-11 | Auxiliary features (dock profiles, brush import, pressure HUD, i18n) | +| 7 | 12-14 | Polish and integration | + +**Total Tasks:** 14 +**Estimated Effort:** Large (multiple days of focused work) +**Critical Path:** Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5 → Phase 6 → Phase 7 diff --git a/docs/compose/plans/2025-07-15-iced-migration-handoff.md b/docs/compose/plans/2025-07-15-iced-migration-handoff.md new file mode 100644 index 0000000..99c71d9 --- /dev/null +++ b/docs/compose/plans/2025-07-15-iced-migration-handoff.md @@ -0,0 +1,160 @@ +# ICED GUI Migration — Work Handoff (2026-07-15) + +## Status: IN PROGRESS (build green, 5/5 tests pass) + +The 14-task migration plan (`docs/compose/plans/2025-07-15-iced-full-gui-migration.md`) +was already committed by prior sessions. This session focused on **closing the +functional quality gap vs egui** identified by deep comparison of both GUIs. + +## What this session completed (uncommitted working-tree changes) + +All changes are in `hcie-iced-app/crates/hcie-iced-gui/src/`. Build: `cargo build -p hcie-iced-gui` (0 errors, 32 warnings). + +### HIGH priority (functional regressions vs egui — FIXED) +1. **TransformApply TODO stub** (`app.rs`) — the `Message::TransformApply` handler was a + no-op stub. Added `apply_transform_to_layer()` that composites the floating selection + back onto the active layer with scale + rotation support (egui only had scale). + Uses public API: `get_active_layer_pixels` + `set_active_layer_pixels`. + +2. **~20 missing canvas tool interactions** (`app.rs` `CanvasPointerPressed`/`Moved`/`Released`) — + only 8 tools were wired; the rest hit `_ => {}`. Now wired: MagicWand (engine + `create_selection_magic_wand`), Lasso (freehand point accumulation → `create_selection_lasso`), + PolygonSelect (vertex accumulation + close-on-near-start), SmartSelect (magic-wand fallback), + VisionSelect (bbox drag → rect selection), Move (cut selection → floating transform), + Gradient (endpoint drag → stroke fill), Text (draft begin), and all 12 vector shape tools + (Star/Polygon/Arrow/Rhombus/Cylinder/Heart/Bubble/Gear/Cross/Crescent/Bolt/Arrow4 via + `add_vector_shape`). FloodFill tolerance now reads from settings instead of hardcoded 32. + +3. **Touch/pen pressure feed** (`app.rs` subscription) — iced 0.13 touch events carry no + pressure, but position feed to `tablet_state` was missing entirely. Added a + `TabletTouch` message handling `iced::Event::Touch` (finger press/move → `set_screen_pos`, + lift → `reset_pressure`), plus screen-size feed in `CanvasSize` handler. evdev listener + (already started in main.rs) remains the pressure source. + +4. **Tool option controls were all NoOp** (`panels/tool_settings.rs`, `panels/toolbar.rs`) — + Flow/Spacing/Density/Tolerance/vector-stroke/fill/gradient-type/text-font/size/angle/alignment + all emitted `Message::NoOp`. Rewrote both panels to emit real messages wired to settings + persistence. Added settings fields: `vector_fill/radius/points/sides`, + `spray_particle_size/density`, `gradient_type`, `selection_contiguous/anti_alias`, + `text_font/angle`, `tool_apply_to_new_layer`. Added Message variants: + `BrushFlowChanged/BrushSpacingChanged/SelectionToleranceChanged/SelectionContiguousToggled/ + SelectionFeatherChanged/SprayParticleSizeChanged/SprayDensityChanged/GradientTypeChanged/ + VectorStrokeChanged/VectorOpacityChanged/VectorFillToggled/VectorRadiusChanged/ + VectorPointsChanged/VectorSidesChanged/TextFontChanged/TextAngleChanged/ + TextOrientationChanged/TextContentChanged/TextCommit/ToolApplyToNewLayerToggled`. + +5. **Color picker broken** (`color_picker.rs`, `app.rs`) — alpha was force-stripped to 255, + recent colors never populated, colors not persisted. Fixed: hex input preserves alpha, + added an alpha slider (0-255), swatches/recent render RGBA, `FgColorChanged` now calls + `add_recent_color`, colors persist to `~/.config/hcie-iced/colors.json` via + `PersistedColors` (load on startup, save on change via `colors_dirty` flag). + +### MEDIUM priority (UX parity improvements — DONE) +6. **Missing keyboard shortcuts** (`app.rs` subscription) — added P/F/G/C/I/J tools, X swap, + `[`/`]` brush-size-relative, Tab (dock dialog), F12 (repaint). Enter/Escape now contextual + (crop/transform/text-commit confirmation; viewer/crop/transform/selection/dialog cancel) + via new `EscapePressed`/`EnterPressed` messages (the `on_key_press` closure must be a + non-capturing fn, so resolution happens in `update()`). Note: `[`/`]` use + `BrushSizeRelative` since the closure can't capture `self`. + +7. **Sidebar improvements** (`sidebar/mod.rs`, `app.rs`) — added per-slot last-used sub-tool + memory (`slot_last_used` field, `slot_for_tool`/`tool_slots`/`last_used_tool` helpers); + slot buttons now show the last-used tool's icon. Background swatch is now clickable + (promotes bg→fg). Added "D" reset-colors button (`ResetColors` → fg=black/bg=white). + `ToolSlot` is now `pub`; `TOOL_SLOTS` is `pub const`. + +8. **Canvas overlays** (`canvas/mod.rs`) — selection now has a semi-transparent purple fill + overlay over the selection bounds (egui has fill over actual mask pixels; this is a + bounds approximation since the overlay `canvas::Program` has no mask access). Vector shape + previews now draw actual geometry (ellipse/line/5-star/6-polygon) instead of just a + bounding box, driven by `active_tool` field. Added gradient endpoint preview line + markers. + `OverlayProgram` gained `active_tool` and `gradient_drag` fields. + +9. **On-canvas text editor** (`canvas/mod.rs`) — when a `text_draft` is active, a positioned + `text_input` + Commit/Cancel buttons overlay the canvas at the draft's screen position + (egui `text_overlay.rs`). Uses iced `Stack` + padding offset. Enter commits, Escape cancels. + +### Supporting changes +- `IcedDocument` gained fields: `lasso_points`, `polygon_points`, `gradient_drag`, + `vision_rect`, `text_draft`. Added `TextDraft` struct + `Default`. +- `app.rs` `new()` restored after an accidental deletion during editing (app construction + + load_path handling + init_task were re-added from git HEAD). +- 3 IcedDocument construction sites updated with the new fields. +- New Message variants also added: `LassoDragMove/LassoDragEnd/PolygonAddPoint/PolygonClose/ + GradientDragStart/GradientDragMove/GradientDragEnd/BrushSizeRelative/TabletTouch/ + EscapePressed/EnterPressed/ResetColors`. + +## What REMAINS to be done (prioritized) + +### HIGH (functional gaps still open) +- **Marching ants only for rect selections.** iced draws marching ants around the + selection *bounding box* (`canvas/mod.rs` marching-ants section). egui follows the actual + irregular selection *edge* via edge-extraction (`render.rs:58-96`). Fix requires passing + the selection mask to the overlay `canvas::Program` and rendering an edge outline. The + selection-fill overlay added this session is bounds-only for the same reason. + +- **Shift+drag brush resize & Ctrl+click eyedropper** during strokes (egui `mod.rs:271-292`). + iced shader_canvas event handling has no modifier-key awareness for brush resize, and no + Ctrl+click eyedropper shortcut. Requires extending `CanvasShaderProgram::update` to read + modifiers (may need passing modifiers into the shader program or handling at app level). + +- **Pen tool distinct path.** egui uses `draw_pen_segment` for vector-like pen strokes + (`mod.rs:916-933`); iced treats Pen identically to Brush (`app.rs:1021`). Needs an engine + API check for a pen-segment method. + +- **VectorSelect tool has no canvas interaction.** Selecting/moving/rotating existing vector + shapes (egui `mod.rs:651-739`) is unimplemented. Needs `find_vector_shape_at` + + `modify_vector_shape` engine calls + handle hit-testing in the overlay. + +- **History for transform apply.** egui's transform also doesn't push a dedicated snapshot, + so this matches egui, but ideally `TransformApply` should be undoable. The engine's + `push_draw_snapshot` is private (locked engine-api); needs an unlock + public wrapper or + routing through `begin_stroke`/`end_stroke`. + +### MEDIUM (polish / parity) +- **SmartSelect/VisionSelect real AI backend.** Currently SmartSelect falls back to magic-wand + and VisionSelect makes a rect selection. egui runs SAM/vision pipelines via AppEvent. Needs + the `hcie-vision` API wired (configurable model path, run buttons in a panel). + +- **AI/vision tool option panels** (SmartSelect model-path UI, VisionSelect task/backend/run + UI — egui `panels.rs:1837-1997`). Tools are selectable but not configurable from the GUI. + +- **Brush stamp cursor** (egui `mod.rs:1444-1645` cached footprint). iced only draws a simple + 10px crosshair. Needs the actual brush-size circle cursor on the canvas overlay. + +- **Right-click context menu on canvas** (egui `mod.rs:2027-2089`: copy/cut/paste/new layer/ + deselect/crop/quick filters). Not implemented in iced. + +- **Double-click to edit existing text layer** (egui `mod.rs:2013-2025`). Not implemented. + +- **Layer-not-editable guard + auto-create layer by tool type** (egui `mod.rs:232-267`). + iced doesn't guard locked layers or auto-create vector/text layers on tool use. + +- **dock_profile dialog toggle on Tab** is a stop-in (egui Tab hides panels). A proper + panel-visibility toggle is better. + +### LOW (both GUIs lack these — future work) +Rulers, canvas grid/snap, guides, navigator/minimap, pixel grid at high zoom, scrubby zoom, +true fit-to-screen, space-drag pan, scrollbars, pinch zoom, tilt input. + +## Verification performed +- `cargo build -p hcie-iced-gui` → 0 errors, 32 warnings (all unused/dead-code, no logic errors). +- `cargo test -p hcie-iced-gui` → 5/5 pass. +- No manual app launch / screenshot verification done this session (no display available). + +## Files changed this session +- `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` (main logic, ~+400 lines) +- `hcie-iced-app/crates/hcie-iced-gui/src/settings.rs` (+13 settings fields + defaults) +- `hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs` (alpha + recent + persistence) +- `hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_settings.rs` (full rewrite, NoOp→real) +- `hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs` (flow/tolerance/feather/stroke wired) +- `hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs` (last-used memory, bg-click, reset) +- `hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs` (selection fill, shape previews, text overlay) +- `hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs` (tool_settings + toolbar arg wiring) + +## Note for next session +The uncommitted pressure-indicator work from the prior session (`panels/pressure_indicator.rs` ++ its canvas wiring) is included in the working tree and builds. **Nothing has been committed +this session** — the next step is to review the diff and commit in logical chunks, then +continue with the HIGH-priority remaining items above (marching-ants-for-irregular-selections +and Shift/Ctrl modifier handling are the highest-value next steps). \ No newline at end of file diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs index 82d7cee..a871520 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -142,6 +142,11 @@ pub struct HcieIcedApp { /// Window ID for window control operations (drag, minimize, maximize, close). pub window_id: Option, /// New Image dialog state. + pub dialog_input: String, + pub dialog_input2: String, + pub show_performance: bool, + pub ui_scale: f32, + pub modifiers: iced::keyboard::Modifiers, pub dialog_new_name: String, pub dialog_new_width: u32, pub dialog_new_height: u32, @@ -246,6 +251,8 @@ pub struct HcieIcedApp { pub show_dock_profile_dialog: bool, /// Current UI language for internationalization. pub language: i18n::Language, + /// Context menu screen position (x, y). None means closed. + pub canvas_context_menu: Option<(f32, f32)>, /// Dirty flag set whenever fg/bg/recent colors change, so colors can be /// persisted to disk (egui persists these under the `hcie_colors` key). pub colors_dirty: bool, @@ -325,6 +332,10 @@ pub struct IcedDocument { pub vision_rect: Option<(f32, f32, f32, f32)>, /// Draft text state for the on-canvas text tool. pub text_draft: Option, + /// Cache for extracted selection edges (marching ants). + pub selection_edge_cache: crate::canvas::edge_cache::SelectionEdgeCache, + /// Extracted selection edges for overlay rendering. + pub marching_ants_edges: Option>>, } /// In-progress text layer being authored with the Text tool. @@ -387,6 +398,16 @@ pub struct ToolState { pub last_update_instant: std::time::Instant, /// Imported brush presets from ABR/KPP files. pub imported_brushes: Vec, + /// True if currently holding shift to resize brush + pub is_resizing_brush: bool, + /// Starting brush size before resize drag + pub brush_resize_start_size: f32, + /// Starting pointer position for brush resize drag + pub brush_resize_start_pos: Option<(f32, f32)>, + /// Last click time for double-click detection + pub last_click_time: std::time::Instant, + /// Last click pos for double-click detection + pub last_click_pos: Option<(f32, f32)>, } impl Default for ToolState { @@ -404,6 +425,11 @@ impl Default for ToolState { last_composite_refresh: std::time::Instant::now(), last_update_instant: std::time::Instant::now(), imported_brushes: Vec::new(), + is_resizing_brush: false, + brush_resize_start_size: 20.0, + brush_resize_start_pos: None, + last_click_time: std::time::Instant::now(), + last_click_pos: None, } } } @@ -427,11 +453,14 @@ pub enum Message { CanvasPointerPressed { x: f32, y: f32 }, CanvasPointerMoved { x: f32, y: f32 }, CanvasPointerReleased, + CanvasPointerRightClicked { x: f32, y: f32, screen_x: f32, screen_y: f32 }, + CanvasContextMenuClose, CanvasPanZoom { zoom: f32, pan_offset: Vector }, CanvasZoomSet(f32), CanvasZoomRelative(f32), CanvasSize((f32, f32)), CanvasCursorPos { x: u32, y: u32 }, + ModifiersChanged(iced::keyboard::Modifiers), // ── Engine operations ─────────────────────────────── CompositeRefresh, @@ -945,6 +974,8 @@ impl HcieIcedApp { gradient_drag: None, vision_rect: None, text_draft: None, + selection_edge_cache: Default::default(), + marching_ants_edges: None, }; // Load saved settings @@ -962,6 +993,12 @@ impl HcieIcedApp { active_tool_slot: 0, sub_tools_open: false, slot_last_used: vec![0; 20], + dialog_input: String::new(), + dialog_input2: String::new(), + show_performance: false, + ui_scale: 1.0, + modifiers: iced::keyboard::Modifiers::default(), + canvas_context_menu: None, settings: settings.clone(), window_id: None, dialog_new_name: "Untitled".to_string(), @@ -1190,6 +1227,18 @@ impl HcieIcedApp { doc.engine.clear_dirty_flags(); } + // Update selection edge cache if mask changed + if let Some(mask) = doc.engine.get_selection_mask() { + let edges = doc.selection_edge_cache.get(mask).unwrap_or_else(|| { + doc.selection_edge_cache.rebuild(mask, doc.engine.canvas_width(), doc.engine.canvas_height()) + }); + doc.marching_ants_edges = Some(edges); + } else { + doc.marching_ants_edges = None; + // Clear cache if selection is dropped so next time it won't mistakenly hit + doc.selection_edge_cache = Default::default(); + } + // Always refresh cached panel data (cheap operation) doc.cached_layers = doc.engine.layer_infos(); let history_len = doc.engine.history_len(); @@ -1309,6 +1358,47 @@ impl HcieIcedApp { self.canvas_cursor_pos = Some((canvas_x as u32, canvas_y as u32)); let tool = self.tool_state.active_tool; + let now = std::time::Instant::now(); + let _is_double_click = if let Some((lx, ly)) = self.tool_state.last_click_pos { + let dx = canvas_x - lx; + let dy = canvas_y - ly; + let dist_sq = dx * dx + dy * dy; + now.duration_since(self.tool_state.last_click_time).as_millis() < 300 && dist_sq < 25.0 + } else { + false + }; + self.tool_state.last_click_time = now; + self.tool_state.last_click_pos = Some((canvas_x, canvas_y)); + + if let Some(req_type) = tool.allowed_layer_type() { + // Reject raster edits on non-editable layers + if req_type == hcie_engine_api::LayerType::Raster + && !self.documents[self.active_doc].engine.active_layer_is_editable() + { + // In the future: emit a status message here + return Task::none(); + } + + // Auto-create layer if current tool requires a different layer type + if req_type != hcie_engine_api::LayerType::Text { + if let Some(active) = self.documents[self.active_doc].engine.active_layer() { + if active.layer_type != req_type { + match req_type { + hcie_engine_api::LayerType::Vector => { + let id = self.documents[self.active_doc].engine.add_layer("Vector Layer"); + self.documents[self.active_doc].engine.set_active_layer(id); + } + _ => { + let id = self.documents[self.active_doc].engine.add_layer("Layer"); + self.documents[self.active_doc].engine.set_active_layer(id); + } + } + self.refresh_composite_if_needed(); + } + } + } + } + match tool { Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray => { // Apply brush parameters before starting stroke @@ -1546,39 +1636,67 @@ impl HcieIcedApp { self.canvas_cursor_pos = Some((x as u32, y as u32)); if self.tool_state.is_drawing { - let layer_id = self.documents[self.active_doc].engine.active_layer_id(); - - let spacing: f32 = match self.tool_state.active_tool { - Tool::Spray => 2.0_f32, - _ => 1.0_f32, - }.max(1.0); - - if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos { - let dx = x - last_x; - let dy = y - last_y; - let dist = (dx * dx + dy * dy).sqrt(); - - // Use constant pressure for mouse input - // Real tablet pressure comes from tablet_state - let pressure = self.tablet_state.lock().map(|s| s.current_pressure()).unwrap_or(1.0); - - if dist >= spacing { - self.documents[self.active_doc].engine.stroke_to(layer_id, x, y, pressure); - self.tool_state.last_stroke_pos = Some((x, y)); - - // Throttle composite refresh to ~60 FPS during strokes. - let now = std::time::Instant::now(); - let elapsed = now.duration_since(self.tool_state.last_composite_refresh); - if elapsed.as_secs_f32() >= 0.016 { - self.tool_state.last_composite_refresh = now; - // Performance log for measuring drawing throttle frequency (should fire roughly every 16ms / 60 FPS). - // log::info!("[perf] throttle fire: {:.1}ms since last refresh", elapsed.as_secs_f64() * 1000.0); - self.refresh_composite_if_needed(); + let is_ctrl = self.modifiers.control() || self.modifiers.logo(); + if is_ctrl { + // Pick color + let cx = x as u32; + let cy = y as u32; + let w = self.documents[self.active_doc].engine.canvas_width(); + let h = self.documents[self.active_doc].engine.canvas_height(); + if cx < w && cy < h { + let pixels = &self.documents[self.active_doc].composite_raw; + let idx = ((cy * w + cx) * 4) as usize; + if idx + 3 < pixels.len() { + let color = [pixels[idx], pixels[idx + 1], pixels[idx + 2], pixels[idx + 3]]; + self.fg_color = color; + self.documents[self.active_doc].engine.set_color(color); + } + } + } else if self.tool_state.is_resizing_brush { + if let Some((start_x, _)) = self.tool_state.brush_resize_start_pos { + let dx = x - start_x; + let new_size = (self.tool_state.brush_resize_start_size + dx).clamp(1.0, 500.0); + match self.tool_state.active_tool { + Tool::Pen => self.settings.tool_settings.pen_size = new_size, + Tool::Brush => self.settings.tool_settings.brush_size = new_size, + Tool::Eraser => self.settings.tool_settings.eraser_size = new_size, + Tool::Spray => self.settings.tool_settings.spray_radius = new_size, + _ => {} } } } else { - self.documents[self.active_doc].engine.stroke_to(layer_id, x, y, 1.0); - self.tool_state.last_stroke_pos = Some((x, y)); + let layer_id = self.documents[self.active_doc].engine.active_layer_id(); + + let spacing: f32 = match self.tool_state.active_tool { + Tool::Spray => 2.0_f32, + _ => 1.0_f32, + }.max(1.0); + + if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos { + let dx = x - last_x; + let dy = y - last_y; + let dist = (dx * dx + dy * dy).sqrt(); + + // Use constant pressure for mouse input + // Real tablet pressure comes from tablet_state + let pressure = self.tablet_state.lock().map(|s| s.current_pressure()).unwrap_or(1.0); + + if dist >= spacing { + self.documents[self.active_doc].engine.stroke_to(layer_id, x, y, pressure); + self.tool_state.last_stroke_pos = Some((x, y)); + + // Throttle composite refresh to ~60 FPS during strokes. + let now = std::time::Instant::now(); + let elapsed = now.duration_since(self.tool_state.last_composite_refresh); + if elapsed.as_secs_f32() >= 0.016 { + self.tool_state.last_composite_refresh = now; + self.refresh_composite_if_needed(); + } + } + } else { + self.documents[self.active_doc].engine.stroke_to(layer_id, x, y, 1.0); + self.tool_state.last_stroke_pos = Some((x, y)); + } } } @@ -1638,6 +1756,8 @@ impl HcieIcedApp { } Message::CanvasPointerReleased => { + self.tool_state.is_resizing_brush = false; + // Lasso release finalizes a freehand selection path. if self.tool_state.active_tool == Tool::Lasso && self.tool_state.is_drawing { self.tool_state.is_drawing = false; @@ -1699,6 +1819,15 @@ impl HcieIcedApp { } } + Message::CanvasPointerRightClicked { x: _, y: _, screen_x, screen_y } => { + // Ignore canvas_x, canvas_y for now, just pop the menu + self.canvas_context_menu = Some((screen_x, screen_y)); + } + + Message::CanvasContextMenuClose => { + self.canvas_context_menu = None; + } + Message::CanvasPanZoom { zoom, pan_offset } => { let doc = &mut self.documents[self.active_doc]; doc.zoom = zoom; @@ -2789,6 +2918,8 @@ impl HcieIcedApp { gradient_drag: None, vision_rect: None, text_draft: None, + selection_edge_cache: Default::default(), + marching_ants_edges: None, }); self.active_doc = self.documents.len() - 1; self.active_dialog = ActiveDialog::None; @@ -3943,6 +4074,8 @@ impl HcieIcedApp { gradient_drag: None, vision_rect: None, text_draft: None, + selection_edge_cache: Default::default(), + marching_ants_edges: None, }); } @@ -4532,6 +4665,9 @@ impl HcieIcedApp { return self.update(Message::ViewerEnter); } } + Message::ModifiersChanged(modifiers) => { + self.modifiers = modifiers; + } } // Persist colors to disk when they have changed (debounced via the @@ -4696,8 +4832,9 @@ impl HcieIcedApp { let has_dialog = self.active_dialog != ActiveDialog::None; let has_menu = self.active_menu.is_some(); let has_dock_profile_dialog = self.show_dock_profile_dialog; + let has_context_menu = self.canvas_context_menu.is_some(); - if has_dialog || has_menu || has_dock_profile_dialog { + if has_dialog || has_menu || has_dock_profile_dialog || has_context_menu { let mut stack = iced::widget::Stack::new().push(content); if has_dialog { stack = stack.push(dialog_overlay); @@ -4712,6 +4849,14 @@ impl HcieIcedApp { if let Some(menu_overlay) = panels::menus::dropdown_overlay(self.active_menu, &self.recent_files, self.theme_state.colors()) { stack = stack.push(menu_overlay); } + if let Some((cx, cy)) = self.canvas_context_menu { + stack = stack.push(panels::menus::canvas_context_menu( + cx, + cy, + &self.documents[self.active_doc], + self.theme_state.colors(), + )); + } let _elapsed = view_start.elapsed(); // Performance log measuring Elm view reconstruction time. // useful to track widget layout allocation and view model reconstruction times. @@ -4990,6 +5135,9 @@ impl HcieIcedApp { } } } + iced::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) => { + Some(Message::ModifiersChanged(modifiers)) + } _ => None, } }); @@ -5012,3 +5160,22 @@ impl HcieIcedApp { iced::Subscription::batch(vec![keyboard, mouse, marching_ants]) } } + +/// Find the topmost text layer whose rasterized pixels contain the point +/// `(x, y)` in canvas coordinates. +fn find_text_layer_at(engine: &hcie_engine_api::Engine, x: f32, y: f32) -> Option { + let ux = x.round().clamp(0.0, u32::MAX as f32) as u32; + let uy = y.round().clamp(0.0, u32::MAX as f32) as u32; + let infos = engine.layer_infos(); + // layer_infos returns bottom-to-top; iterate in reverse for topmost first. + for info in infos.iter().rev() { + if info.layer_type == hcie_engine_api::LayerType::Text && info.visible { + if let Some(px) = engine.get_pixel(info.id, ux, uy) { + if px[3] > 10 { + return Some(info.id); + } + } + } + } + None +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/edge_cache.rs b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/edge_cache.rs new file mode 100644 index 0000000..625579d --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/edge_cache.rs @@ -0,0 +1,62 @@ +use std::sync::Arc; + +#[derive(Clone, Default)] +pub struct SelectionEdgeCache { + mask_ptr: usize, + mask_len: usize, + edges: Arc>, +} + +impl SelectionEdgeCache { + pub fn get(&self, mask: &[u8]) -> Option>> { + if mask.as_ptr() as usize == self.mask_ptr && mask.len() == self.mask_len { + Some(Arc::clone(&self.edges)) + } else { + None + } + } + + pub fn rebuild(&mut self, mask: &[u8], w: u32, h: u32) -> Arc> { + let edges = Arc::new(extract_selection_edges(mask, w, h)); + self.mask_ptr = mask.as_ptr() as usize; + self.mask_len = mask.len(); + self.edges = Arc::clone(&edges); + edges + } +} + +pub fn extract_selection_edges( + selection_mask: &[u8], + canvas_width: u32, + canvas_height: u32, +) -> Vec<(u32, u32, u32, u32)> { + let w = canvas_width as usize; + let h = canvas_height as usize; + if selection_mask.len() != w * h { + return Vec::new(); + } + + let threshold: u8 = 128; + let mut edges = Vec::with_capacity(1024); + for y in 0..h { + for x in 0..w { + let idx = y * w + x; + if selection_mask[idx] <= threshold { + continue; + } + if x == 0 || selection_mask[idx - 1] <= threshold { + edges.push((x as u32, y as u32, x as u32, (y + 1) as u32)); + } + if x == w - 1 || selection_mask[idx + 1] <= threshold { + edges.push(((x + 1) as u32, y as u32, (x + 1) as u32, (y + 1) as u32)); + } + if y == 0 || selection_mask[idx - w] <= threshold { + edges.push((x as u32, y as u32, (x + 1) as u32, y as u32)); + } + if y == h - 1 || selection_mask[idx + w] <= threshold { + edges.push((x as u32, (y + 1) as u32, (x + 1) as u32, (y + 1) as u32)); + } + } + } + edges +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs index 33c221e..049f5a3 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs @@ -20,6 +20,7 @@ //! - The checkerboard is procedural (in the fragment shader) — no CPU geometry pub mod shader_canvas; +pub mod edge_cache; // pub mod viewport; // pub mod render; @@ -69,6 +70,8 @@ struct OverlayProgram { active_tool: hcie_engine_api::Tool, /// Gradient drag preview endpoints in canvas-space. gradient_drag: Option<((f32, f32), (f32, f32))>, + /// Extracted edges for marching ants rendering. + marching_ants_edges: Option>>, } /// Overlay state — tracks hover and cursor position for crosshair. @@ -391,32 +394,74 @@ impl canvas::Program for OverlayProgram { let total_cycle = dash_length + gap_length; let animated_offset = (self.marching_ants_offset * total_cycle) as usize; - // Draw black dashes (background) - let sel_path = Path::rectangle( - Point::new(sel_x, sel_y), - Size::new(sel_w, sel_h), - ); - frame.stroke(&sel_path, Stroke { - style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.0, 0.0, 0.0)), - width: 2.0 / self.zoom.max(1.0), - line_dash: canvas::LineDash { - segments: &[dash_length, gap_length], - offset: animated_offset, - }, - ..Default::default() - }); - // Draw white dashes (foreground) - offset by half cycle for marching effect let white_offset = (animated_offset + (total_cycle / 2.0) as usize) % (total_cycle as usize); - frame.stroke(&sel_path, Stroke { - style: canvas::stroke::Style::Solid(iced::Color::from_rgb(1.0, 1.0, 1.0)), - width: 2.0 / self.zoom.max(1.0), - line_dash: canvas::LineDash { - segments: &[dash_length, gap_length], - offset: white_offset, - }, - ..Default::default() - }); + + if let Some(edges) = &self.marching_ants_edges { + // Irregular selection bounds (marching ants along extracted edges) + for &(x1, y1, x2, y2) in edges.iter() { + let s1 = Point::new(origin_x + x1 as f32 * self.zoom, origin_y + y1 as f32 * self.zoom); + let s2 = Point::new(origin_x + x2 as f32 * self.zoom, origin_y + y2 as f32 * self.zoom); + + // Optimization: we could clip, but `Path::line` handles it generally. + let dist = ((s1.x - s2.x).powi(2) + (s1.y - s2.y).powi(2)).sqrt(); + if dist < 1e-6 { continue; } + + let line_path = Path::line(s1, s2); + frame.stroke(&line_path, Stroke { + style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.0, 0.0, 0.0)), + width: 1.5, + ..Default::default() + }); + + let dir_x = (s2.x - s1.x) / dist; + let dir_y = (s2.y - s1.y) / dist; + + let mut curr = -(self.marching_ants_offset * total_cycle); + let mut iter = 0u32; + while curr < dist && iter < 1000 { + iter += 1; + let start_d = curr.max(0.0); + let end_d = (curr + dash_length).min(dist); + if end_d > start_d { + let p_start = Point::new(s1.x + dir_x * start_d, s1.y + dir_y * start_d); + let p_end = Point::new(s1.x + dir_x * end_d, s1.y + dir_y * end_d); + let dash_path = Path::line(p_start, p_end); + frame.stroke(&dash_path, Stroke { + style: canvas::stroke::Style::Solid(iced::Color::from_rgb(1.0, 1.0, 1.0)), + width: 1.5, + ..Default::default() + }); + } + curr += total_cycle; + } + } + } else { + // Fallback to bounding box marching ants + let sel_path = Path::rectangle( + Point::new(sel_x, sel_y), + Size::new(sel_w, sel_h), + ); + frame.stroke(&sel_path, Stroke { + style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.0, 0.0, 0.0)), + width: 2.0 / self.zoom.max(1.0), + line_dash: canvas::LineDash { + segments: &[dash_length, gap_length], + offset: animated_offset, + }, + ..Default::default() + }); + + frame.stroke(&sel_path, Stroke { + style: canvas::stroke::Style::Solid(iced::Color::from_rgb(1.0, 1.0, 1.0)), + width: 2.0 / self.zoom.max(1.0), + line_dash: canvas::LineDash { + segments: &[dash_length, gap_length], + offset: white_offset, + }, + ..Default::default() + }); + } } } @@ -705,6 +750,7 @@ pub fn view<'a>( pressure, active_tool: tool_state.active_tool, gradient_drag: doc.gradient_drag, + marching_ants_edges: doc.marching_ants_edges.clone(), }; let overlay_canvas = canvas(overlay_program) diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs index c166ccf..a1c20b3 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs @@ -899,6 +899,23 @@ impl shader::Program for CanvasShaderProgram { state.pan_start = Some(pos); return (iced::event::Status::Captured, None); } + } else if button == mouse::Button::Right { + if let Some(pos) = cursor.position() { + if bounds.contains(pos) { + let local_pos = Point::new(pos.x - bounds.x, pos.y - bounds.y); + let (cx_opt, cy_opt) = screen_to_canvas_local( + local_pos, bounds.width, bounds.height, self.engine_w as f32, self.engine_h as f32, self.zoom, self.pan_offset + ); + if let (Some(cx), Some(cy)) = (cx_opt, cy_opt) { + return (iced::event::Status::Captured, Some(Message::CanvasPointerRightClicked { + x: cx, + y: cy, + screen_x: pos.x, + screen_y: pos.y, + })); + } + } + } } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs index 2c3e7eb..6782b71 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs @@ -14,6 +14,7 @@ use crate::app::Message; use crate::theme::ThemeColors; +use hcie_engine_api::Tool; use iced::widget::{column, container, horizontal_rule, mouse_area, row, text}; use iced::{Element, Length}; @@ -502,3 +503,131 @@ pub fn dropdown_overlay( Some(overlay.into()) } + +/// Canvas context menu popup. +pub fn canvas_context_menu<'a>( + x: f32, + y: f32, + doc: &crate::app::IcedDocument, + colors: ThemeColors, +) -> Element<'a, Message> { + let mut items = vec![]; + + items.push(MenuItem::with_shortcut("Copy", "Ctrl+C")); + if doc.selection_bounds.is_some() { + items.push(MenuItem::with_shortcut("Cut", "Ctrl+X")); + } + items.push(MenuItem::with_shortcut("Paste", "Ctrl+V")); + items.push(MenuItem::separator()); + items.push(MenuItem::new("New Layer")); + + if doc.selection_bounds.is_some() { + items.push(MenuItem::with_shortcut("Deselect", "Ctrl+D")); + items.push(MenuItem::new("Crop Image")); + } + + items.push(MenuItem::separator()); + items.push(MenuItem::new("Gaussian Blur (5px)")); + items.push(MenuItem::new("Mosaic / Pixelate (8px)")); + items.push(MenuItem::new("Unsharp Mask")); + + let mut col_items = vec![]; + for item in items { + if item.label == "─" { + col_items.push( + container(horizontal_rule(1).style(move |_theme| iced::widget::rule::Style { + color: colors.border_high, + width: 1, + radius: 0.0.into(), + fill_mode: iced::widget::rule::FillMode::Full, + })) + .padding([4, 0]) + .into(), + ); + } else { + let label = text(item.label.clone()).size(14).style(move |_theme| iced::widget::text::Style { + color: Some(colors.text_primary), + }); + let mut label_row = row![label].align_y(iced::Alignment::Center); + + if !item.shortcut.is_empty() { + label_row = label_row.push(iced::widget::Space::with_width(Length::Fill)); + label_row = label_row.push( + text(item.shortcut) + .size(13) + .style(move |_theme| iced::widget::text::Style { + color: Some(colors.text_secondary), + }), + ); + } + + let msg = match item.label.as_str() { + "Copy" => Message::CopyImage, + "Cut" => Message::CopyImage, // TODO: CutImage + "Paste" => Message::PasteImage, + "New Layer" => Message::LayerAdd, + "Deselect" => Message::Deselect, + "Crop Image" => Message::ToolSelected(Tool::Crop), + "Gaussian Blur (5px)" => Message::FilterSelect(hcie_engine_api::FilterType::GaussianBlur), + "Mosaic / Pixelate (8px)" => Message::FilterSelect(hcie_engine_api::FilterType::Mosaic), + "Unsharp Mask" => Message::FilterSelect(hcie_engine_api::FilterType::UnsharpMask), + _ => Message::CanvasContextMenuClose, + }; + + let c = colors; + let menu_item = iced::widget::button( + container(label_row) + .width(Length::Fill) + .padding([6, 16]) + ) + .padding(0) + .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { + match status { + iced::widget::button::Status::Hovered => iced::widget::button::Style { + background: Some(iced::Background::Color(c.menu_hover)), + text_color: c.text_primary, + border: iced::Border::default(), + ..Default::default() + }, + _ => iced::widget::button::Style { + background: Some(iced::Background::Color(c.menu_bg)), + text_color: c.text_primary, + border: iced::Border::default(), + ..Default::default() + }, + } + }) + .on_press(msg); + + col_items.push(menu_item.into()); + } + } + + let col = column(col_items).spacing(0); + + let dropdown = container(col) + .width(260) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(colors.menu_bg)), + border: iced::Border::default().color(colors.border_high).width(1), + shadow: iced::Shadow { + color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.4), + offset: iced::Vector::new(2.0, 4.0), + blur_radius: 8.0, + }, + ..Default::default() + }); + + let overlay = mouse_area( + container(dropdown) + .padding(iced::Padding { + top: y, + bottom: 0.0, + left: x, + right: 0.0, + }) + ) + .on_press(Message::CanvasContextMenuClose); + + overlay.into() +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/script/executor.rs b/hcie-iced-app/crates/hcie-iced-gui/src/script/executor.rs index 25da890..0f4d82d 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/script/executor.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/script/executor.rs @@ -1,32 +1,7 @@ -//! DSL script executor for the HCIE-Rust ICED GUI. -//! -//! Translates parsed `ScriptAction` values into `hcie_engine_api::Engine` -//! calls. This module handles: -//! -//! - Hex color conversion to `[u8; 4]` RGBA -//! - Blend mode string parsing to `BlendMode` enum -//! - Brush style string parsing to `BrushStyle` enum -//! - Layer lookup by name or index -//! - Stroke drawing with per-point pressure -//! - Raster rectangle fills -//! - Vector shape creation - use crate::script::parser::ScriptAction; use hcie_engine_api::Engine; use hcie_engine_api::{BlendMode, BrushStyle, BrushTip}; -/// Converts a hex color string to `[r, g, b, a]` byte array. -/// -/// Supports both 6-character (`#RRGGBB`) and 8-character (`#RRGGBBAA`) formats. -/// Defaults alpha to 255 if not specified. -/// -/// # Arguments -/// -/// * `hex` — The hex color string (leading `#` is stripped). -/// -/// # Returns -/// -/// A 4-element array `[r, g, b, a]` with values in `[0, 255]`. fn hex_to_rgba(hex: &str) -> [u8; 4] { let hex = hex.trim_start_matches('#'); let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0); @@ -40,18 +15,6 @@ fn hex_to_rgba(hex: &str) -> [u8; 4] { [r, g, b, a] } -/// Parses a blend mode string into the corresponding `BlendMode` enum variant. -/// -/// Accepts both camelCase and snake_case forms (e.g. `"colorBurn"` or -/// `"color_burn"`). Defaults to `Normal` for unrecognized strings. -/// -/// # Arguments -/// -/// * `s` — The blend mode name string. -/// -/// # Returns -/// -/// The matching `BlendMode` variant. fn parse_blend_mode(s: &str) -> BlendMode { match s.to_lowercase().as_str() { "normal" => BlendMode::Normal, @@ -85,18 +48,6 @@ fn parse_blend_mode(s: &str) -> BlendMode { } } -/// Parses a brush style string into the corresponding `BrushStyle` enum variant. -/// -/// Accepts various aliases (e.g. `"soft_round"`, `"inkpen"`, `"wet_paint"`). -/// Defaults to `Round` for unrecognized strings. -/// -/// # Arguments -/// -/// * `s` — The brush style name string. -/// -/// # Returns -/// -/// The matching `BrushStyle` variant. fn parse_brush_style(s: &str) -> BrushStyle { match s.to_lowercase().as_str() { "default" | "round" => BrushStyle::Round, @@ -130,19 +81,6 @@ fn parse_brush_style(s: &str) -> BrushStyle { } } -/// Finds a layer ID by name or numeric index. -/// -/// First attempts to parse `name_or_index` as a `usize` index into the -/// layer list. If that fails, searches layer names for an exact match. -/// -/// # Arguments -/// -/// * `engine` — The engine instance to query. -/// * `name_or_index` — The layer name or zero-based index string. -/// -/// # Returns -/// -/// The layer `u64` ID if found, otherwise `None`. fn find_layer_id(engine: &mut Engine, name_or_index: &str) -> Option { if let Ok(idx) = name_or_index.parse::() { let ids = engine.get_all_layer_ids(); @@ -157,16 +95,6 @@ fn find_layer_id(engine: &mut Engine, name_or_index: &str) -> Option { None } -/// Executes a sequence of parsed `ScriptAction` values against the engine. -/// -/// Iterates through actions in order, translating each into the appropriate -/// engine API call. Layer operations, brush settings, drawing commands, and -/// vector shapes are all handled here. -/// -/// # Arguments -/// -/// * `engine` — The mutable engine instance to operate on. -/// * `actions` — The slice of actions to execute. pub fn execute_actions(engine: &mut Engine, actions: &[ScriptAction]) { for action in actions { match action { diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/script/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/script/mod.rs index 1f94dcc..03c3c85 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/script/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/script/mod.rs @@ -1,5 +1,9 @@ -/// DSL parser module — tokenizer, recursive-descent parser, and action types -/// for the HCIE-Rust scripting engine. -pub mod types; -pub mod parser; pub mod executor; +pub mod parser; +pub mod types; + +pub use executor::execute_actions; +pub use parser::parse_dsl_script; +pub use parser::parse_error_line; +pub use parser::suggest_fix; +pub use parser::DEFAULT_SCRIPT; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/script/parser.rs b/hcie-iced-app/crates/hcie-iced-gui/src/script/parser.rs index 458ca64..30d430c 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/script/parser.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/script/parser.rs @@ -1,60 +1,19 @@ -//! Full DSL parser for the HCIE-Rust scripting engine. -//! -//! Provides a recursive-descent parser that converts a text-based DSL -//! into a sequence of `ScriptAction` values. The DSL supports: -//! -//! - Layer management (`layer`, `select_layer`, `vector_layer`) -//! - Brush and color configuration (`brush`, `color`, `size`, `opacity`, etc.) -//! - Drawing commands (`draw`, `line`, `rect`, `circle`, `bezier`, `scatter`, `gradient`) -//! - Vector shapes (`triangle`, `bezier`) -//! - Variable definitions and arithmetic expressions (`var`, `$name`) -//! - Loop constructs (`repeat N { ... }`) -//! - Compound settings (`set key=value ...`) -//! -//! # Architecture -//! -//! 1. **Tokenizer** — Breaks arithmetic expressions into `Token` values. -//! 2. **Expression evaluator** — Parses infix arithmetic with proper precedence. -//! 3. **Line parser** — Dispatches each DSL command to the appropriate handler. -//! 4. **Main parser** — Iterates lines, handles `repeat` blocks, collects actions. - use crate::script::types::ScriptParserState; // ── Expression Evaluator ────────────────────────────────────────────────── -/// Token type for the arithmetic expression tokenizer. #[derive(Debug, Clone)] enum Token { - /// Numeric literal. Num(f32), - /// Variable reference (e.g. `$i`, `$count`). Var(String), - /// Addition operator. Plus, - /// Subtraction operator. Minus, - /// Multiplication operator. Mul, - /// Division operator. Div, - /// Left parenthesis. LParen, - /// Right parenthesis. RParen, } -/// Tokenizes an arithmetic expression string into a sequence of `Token` values. -/// -/// Supports numbers, variables (`$name` or `$[name]`), and the operators -/// `+`, `-`, `*`, `/`, `(`, `)`. Whitespace is ignored. -/// -/// # Arguments -/// -/// * `s` — The expression string to tokenize. -/// -/// # Returns -/// -/// A `Vec` on success, or an error message describing the invalid character. fn tokenize(s: &str) -> Result, String> { let mut tokens = Vec::new(); let chars: Vec = s.chars().collect(); @@ -124,10 +83,6 @@ fn tokenize(s: &str) -> Result, String> { Ok(tokens) } -/// Parses an expression (addition/subtraction precedence level). -/// -/// Delegates to `parse_term` for multiplication/division, then combines -/// results with addition or subtraction operators. fn parse_expression(tokens: &[Token], pos: usize, vars: &std::collections::HashMap) -> Result<(f32, usize), String> { let (mut left, mut pos) = parse_term(tokens, pos, vars)?; while pos < tokens.len() { @@ -148,10 +103,6 @@ fn parse_expression(tokens: &[Token], pos: usize, vars: &std::collections::HashM Ok((left, pos)) } -/// Parses a term (multiplication/division precedence level). -/// -/// Delegates to `parse_factor` for atoms, then combines results with -/// multiplication or division operators. fn parse_term(tokens: &[Token], pos: usize, vars: &std::collections::HashMap) -> Result<(f32, usize), String> { let (mut left, mut pos) = parse_factor(tokens, pos, vars)?; while pos < tokens.len() { @@ -175,10 +126,6 @@ fn parse_term(tokens: &[Token], pos: usize, vars: &std::collections::HashMap) -> Result<(f32, usize), String> { if pos >= tokens.len() { return Err("unexpected end of expression".to_string()); @@ -210,21 +157,6 @@ fn parse_factor(tokens: &[Token], pos: usize, vars: &std::collections::HashMap) -> Result { let expr = expr.trim(); if expr.is_empty() { @@ -254,26 +186,14 @@ fn eval_expr(expr: &str, vars: &std::collections::HashMap) -> Resul // ── Parsed Action Types ────────────────────────────────────────────────── -/// Represents a single executable action produced by the DSL parser. -/// -/// Each variant maps to one or more engine operations. The executor -/// processes these in order, translating them into `hcie_engine_api` -/// calls. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub enum ScriptAction { - /// Create a new layer with the given name and type. CreateLayer { name: String, layer_type: String }, - /// Delete a layer identified by name or index. DeleteLayer { name_or_index: String }, - /// Select (make active) a layer by name or index. SelectLayer { name_or_index: String }, - /// Rename a layer identified by name or index. RenameLayer { name_or_index: String, new_name: String }, - /// Set the opacity of a layer identified by name or index. SetLayerOpacity { name_or_index: String, opacity: f32 }, - /// Set the blend mode of a layer identified by name or index. SetLayerBlendMode { name_or_index: String, blend_mode: String }, - /// Paint a stroke through the given points with specified brush parameters. PaintStroke { points: Vec<[f32; 2]>, color: String, @@ -283,7 +203,6 @@ pub enum ScriptAction { spacing: f32, hardness: f32, }, - /// Update brush settings (any field that is `Some` is applied). SetBrush { style: Option, size: Option, @@ -291,9 +210,7 @@ pub enum ScriptAction { spacing: Option, hardness: Option, }, - /// Set the foreground color from a hex string. SetForegroundColor { hex: String }, - /// Fill a rectangle on the raster layer with a solid color. FillRasterRect { x1: f32, y1: f32, @@ -301,7 +218,6 @@ pub enum ScriptAction { y2: f32, color: String, }, - /// Add a vector path (free-form or closed shape). AddVectorPath { points: Vec<[f32; 2]>, closed: bool, @@ -309,31 +225,24 @@ pub enum ScriptAction { stroke_color: String, stroke_width: f32, }, - /// Script completion marker. Finish { summary: String }, } // ── Helper functions ──────────────────────────────────────────────────── -/// Converts an absolute pixel coordinate to normalized `[0, 1]` range. fn to_norm(coord: f32, canvas_dim: f32) -> f32 { (coord / canvas_dim).clamp(0.0, 1.0) } -/// Evaluates an expression and normalizes the result to canvas coordinates. fn parse_coord(s: &str, vars: &std::collections::HashMap, canvas: f32) -> Result { let val = eval_expr(s, vars)?; Ok(to_norm(val, canvas)) } -/// Evaluates an expression and returns the raw value (no normalization). fn parse_coord_abs(s: &str, vars: &std::collections::HashMap) -> Result { eval_expr(s, vars) } -/// Parses a 6- or 8-character hex color string into `(r, g, b)` components. -/// -/// Each component is in `[0, 255]` range. The alpha channel (if present) is ignored. fn hex_to_rgba_components(hex: &str) -> Result<(f32, f32, f32), String> { let hex = hex.trim_start_matches('#'); if hex.len() != 6 && hex.len() != 8 { @@ -345,17 +254,6 @@ fn hex_to_rgba_components(hex: &str) -> Result<(f32, f32, f32), String> { Ok((r as f32, g as f32, b as f32)) } -/// Linearly interpolates between two hex colors at parameter `t`. -/// -/// # Arguments -/// -/// * `hex1` — Start color as hex string. -/// * `hex2` — End color as hex string. -/// * `t` — Interpolation parameter in `[0, 1]`. -/// -/// # Returns -/// -/// The interpolated color as a 6-character hex string. fn interpolate_hex(hex1: &str, hex2: &str, t: f32) -> Result { let (r1, g1, b1) = hex_to_rgba_components(hex1)?; let (r2, g2, b2) = hex_to_rgba_components(hex2)?; @@ -365,16 +263,6 @@ fn interpolate_hex(hex1: &str, hex2: &str, t: f32) -> Result { Ok(format!("#{:02X}{:02X}{:02X}", r, g, b)) } -/// Samples a cubic Bezier curve at evenly-spaced parameter values. -/// -/// # Arguments -/// -/// * `p0`, `p1`, `p2`, `p3` — The four control points. -/// * `steps` — Number of segments to divide the curve into. -/// -/// # Returns -/// -/// A `Vec` of `steps + 1` points along the curve. fn bezier_sample(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], p3: [f32; 2], steps: usize) -> Vec<[f32; 2]> { let mut pts = Vec::with_capacity(steps + 1); for i in 0..=steps { @@ -387,17 +275,6 @@ fn bezier_sample(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], p3: [f32; 2], steps: pts } -/// Generates pseudo-random scattered points within a bounding box. -/// -/// Uses a deterministic hash-based RNG to produce reproducible scatter -/// patterns. Points are distributed using golden-ratio spacing for -/// uniform coverage. -/// -/// # Arguments -/// -/// * `count` — Number of points to generate. -/// * `bx`, `by` — Top-left corner of the bounding box (absolute coords). -/// * `bw`, `bh` — Width and height of the bounding box. fn pseudo_scatter(count: usize, bx: f32, by: f32, bw: f32, bh: f32) -> Vec<[f32; 2]> { let mut pts = Vec::with_capacity(count); for i in 0..count { @@ -414,17 +291,6 @@ fn pseudo_scatter(count: usize, bx: f32, by: f32, bw: f32, bh: f32) -> Vec<[f32; pts } -/// Applies a single deterministic wiggle displacement to a point. -/// -/// # Arguments -/// -/// * `px`, `py` — Original point coordinates. -/// * `amp` — Maximum displacement amplitude. -/// * `seed` — Seed for the deterministic displacement. -/// -/// # Returns -/// -/// The displaced `(x, y)` coordinates. fn wiggle_point(px: f32, py: f32, amp: f32, seed: u64) -> (f32, f32) { if amp <= 0.0 { return (px, py); @@ -436,10 +302,6 @@ fn wiggle_point(px: f32, py: f32, amp: f32, seed: u64) -> (f32, f32) { (px + dx, py + dy) } -/// Applies wiggle displacement to all points in a normalized coordinate slice. -/// -/// Points are temporarily un-normalized to pixel space (assumed 800x600 canvas), -/// displaced, then re-normalized. fn apply_wiggle(pts: &mut [[f32; 2]], wiggle: f32) { if wiggle <= 0.0 { return; @@ -453,15 +315,6 @@ fn apply_wiggle(pts: &mut [[f32; 2]], wiggle: f32) { } } -/// Converts a list of points into one or more `PaintStroke` actions. -/// -/// Handles single-point strokes, uniform-pressure strokes, and -/// pressure-tapered strokes (where each segment gets interpolated pressure). -/// -/// # Arguments -/// -/// * `points` — The stroke points in normalized coordinates. -/// * `state` — Current parser state for brush/color/pressure settings. fn build_strokes(mut points: Vec<[f32; 2]>, state: &ScriptParserState) -> Vec { if points.len() < 2 { if points.len() == 1 { @@ -510,7 +363,6 @@ fn build_strokes(mut points: Vec<[f32; 2]>, state: &ScriptParserState) -> Vec &str { let s = s.strip_prefix('[').unwrap_or(s); s.strip_suffix(']').unwrap_or(s) @@ -518,20 +370,6 @@ fn strip_brackets(s: &str) -> &str { // ── Line Parser ────────────────────────────────────────────────────────── -/// Parses a single DSL command line and returns the corresponding actions. -/// -/// Dispatches on the first word of the line to the appropriate handler. -/// Each handler updates `state` in place and returns zero or more actions. -/// -/// # Arguments -/// -/// * `state` — Mutable parser state (updated by side-effect commands). -/// * `line` — The trimmed DSL command line. -/// * `line_num` — Line number for error reporting. -/// -/// # Returns -/// -/// A `Vec` on success, or a formatted error string with line number. fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -> Result, String> { let err = |msg: &str| format!("Line {}: {}", line_num, msg); let parts: Vec<&str> = line.split_whitespace().collect(); @@ -1067,19 +905,6 @@ fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) - // ── Main Parser ────────────────────────────────────────────────────────── -/// Parses a complete DSL script into a sequence of executable actions. -/// -/// Iterates through lines, handles `repeat N { ... }` block constructs -/// by forking parser state and injecting loop variables `$i` and `$n`, -/// and collects all resulting actions. A final `Finish` action is appended. -/// -/// # Arguments -/// -/// * `script_text` — The complete DSL script as a string. -/// -/// # Returns -/// -/// A `Vec` on success, or a formatted error string with line number. pub fn parse_dsl_script(script_text: &str) -> Result, String> { let mut actions = Vec::new(); let mut state = ScriptParserState::default(); @@ -1168,30 +993,12 @@ pub fn parse_dsl_script(script_text: &str) -> Result, String> Ok(actions) } -/// Extracts a line number from a DSL error string (e.g. `"Line 42: ..."`). -/// -/// # Arguments -/// -/// * `err` — The error message to parse. -/// -/// # Returns -/// -/// The line number if the error matches the expected format, otherwise `None`. pub fn parse_error_line(err: &str) -> Option { let rest = err.strip_prefix("Line ")?; let colon = rest.find(':')?; rest[..colon].trim().parse().ok() } -/// Provides context-aware fix suggestions for common DSL parse errors. -/// -/// # Arguments -/// -/// * `err` — The error message to analyze. -/// -/// # Returns -/// -/// A static hint string if the error pattern is recognized, otherwise `None`. pub fn suggest_fix(err: &str) -> Option<&'static str> { if err.contains("gradient expects") { Some("gradient x1,y1 x2,y2 #hex1 #hex2 N — e.g. gradient 0,0 800,600 #87CEEB #FFD700 30") @@ -1220,11 +1027,6 @@ pub fn suggest_fix(err: &str) -> Option<&'static str> { } } -/// Default DSL script demonstrating a mountain lake landscape. -/// -/// This script showcases the full range of DSL capabilities: -/// layer management, gradients, bezier curves, scatter, pressure -/// tapering, wiggle, blend modes, and multiple brush styles. pub const DEFAULT_SCRIPT: &str = r#"# Mountain Lake Landscape — 800×600 # Back-to-front drawing diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/script/types.rs b/hcie-iced-app/crates/hcie-iced-gui/src/script/types.rs index 1583594..2cf47f4 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/script/types.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/script/types.rs @@ -1,29 +1,5 @@ use serde::{Deserialize, Serialize}; -/// Persistent parser state for the DSL script interpreter. -/// -/// Tracks the current drawing context — active brush settings, color, -/// layer properties, variables, and pressure mapping — across lines -/// of a parsed DSL script. Each `repeat` block forks a copy of this -/// state so inner iterations can override values without leaking back -/// to the outer scope. -/// -/// # Fields -/// -/// * `color` — Current foreground color as a hex string (e.g. `"#FF0000"`). -/// * `style` — Current brush style name (e.g. `"round"`, `"watercolor"`). -/// * `size` — Current brush size in pixels. -/// * `opacity` — Current drawing opacity, clamped to `[0.0, 1.0]`. -/// * `hardness` — Current brush hardness, clamped to `[0.0, 1.0]`. -/// * `spacing` — Brush spacing factor derived from the `flow` setting. -/// * `blend_mode` — Current layer blend mode as a lowercase string. -/// * `vars` — User-defined variables accessible via `$name` syntax. -/// * `scatter_size` — Brush size used by the `scatter` command. -/// * `flow` — Brush flow value (higher = denser strokes). -/// * `pressure` — Uniform pen pressure multiplier. -/// * `pressure_start` — Pressure at stroke start for tapering. -/// * `pressure_end` — Pressure at stroke end for tapering. -/// * `wiggle` — Random displacement amplitude applied to stroke points. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScriptParserState { pub color: String,