migrate : glm and gemini pro . no proper achievements

This commit is contained in:
2026-07-15 19:44:02 +03:00
parent 70084faf0e
commit eb01ec98e1
11 changed files with 1944 additions and 353 deletions
File diff suppressed because it is too large Load Diff
@@ -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).
+198 -31
View File
@@ -142,6 +142,11 @@ pub struct HcieIcedApp {
/// Window ID for window control operations (drag, minimize, maximize, close). /// Window ID for window control operations (drag, minimize, maximize, close).
pub window_id: Option<iced::window::Id>, pub window_id: Option<iced::window::Id>,
/// New Image dialog state. /// 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_name: String,
pub dialog_new_width: u32, pub dialog_new_width: u32,
pub dialog_new_height: u32, pub dialog_new_height: u32,
@@ -246,6 +251,8 @@ pub struct HcieIcedApp {
pub show_dock_profile_dialog: bool, pub show_dock_profile_dialog: bool,
/// Current UI language for internationalization. /// Current UI language for internationalization.
pub language: i18n::Language, 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 /// Dirty flag set whenever fg/bg/recent colors change, so colors can be
/// persisted to disk (egui persists these under the `hcie_colors` key). /// persisted to disk (egui persists these under the `hcie_colors` key).
pub colors_dirty: bool, pub colors_dirty: bool,
@@ -325,6 +332,10 @@ pub struct IcedDocument {
pub vision_rect: Option<(f32, f32, f32, f32)>, pub vision_rect: Option<(f32, f32, f32, f32)>,
/// Draft text state for the on-canvas text tool. /// Draft text state for the on-canvas text tool.
pub text_draft: Option<TextDraft>, pub text_draft: Option<TextDraft>,
/// 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<Arc<Vec<(u32, u32, u32, u32)>>>,
} }
/// In-progress text layer being authored with the Text tool. /// In-progress text layer being authored with the Text tool.
@@ -387,6 +398,16 @@ pub struct ToolState {
pub last_update_instant: std::time::Instant, pub last_update_instant: std::time::Instant,
/// Imported brush presets from ABR/KPP files. /// Imported brush presets from ABR/KPP files.
pub imported_brushes: Vec<hcie_engine_api::BrushPreset>, pub imported_brushes: Vec<hcie_engine_api::BrushPreset>,
/// 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 { impl Default for ToolState {
@@ -404,6 +425,11 @@ impl Default for ToolState {
last_composite_refresh: std::time::Instant::now(), last_composite_refresh: std::time::Instant::now(),
last_update_instant: std::time::Instant::now(), last_update_instant: std::time::Instant::now(),
imported_brushes: Vec::new(), 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 }, CanvasPointerPressed { x: f32, y: f32 },
CanvasPointerMoved { x: f32, y: f32 }, CanvasPointerMoved { x: f32, y: f32 },
CanvasPointerReleased, CanvasPointerReleased,
CanvasPointerRightClicked { x: f32, y: f32, screen_x: f32, screen_y: f32 },
CanvasContextMenuClose,
CanvasPanZoom { zoom: f32, pan_offset: Vector }, CanvasPanZoom { zoom: f32, pan_offset: Vector },
CanvasZoomSet(f32), CanvasZoomSet(f32),
CanvasZoomRelative(f32), CanvasZoomRelative(f32),
CanvasSize((f32, f32)), CanvasSize((f32, f32)),
CanvasCursorPos { x: u32, y: u32 }, CanvasCursorPos { x: u32, y: u32 },
ModifiersChanged(iced::keyboard::Modifiers),
// ── Engine operations ─────────────────────────────── // ── Engine operations ───────────────────────────────
CompositeRefresh, CompositeRefresh,
@@ -945,6 +974,8 @@ impl HcieIcedApp {
gradient_drag: None, gradient_drag: None,
vision_rect: None, vision_rect: None,
text_draft: None, text_draft: None,
selection_edge_cache: Default::default(),
marching_ants_edges: None,
}; };
// Load saved settings // Load saved settings
@@ -962,6 +993,12 @@ impl HcieIcedApp {
active_tool_slot: 0, active_tool_slot: 0,
sub_tools_open: false, sub_tools_open: false,
slot_last_used: vec![0; 20], 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(), settings: settings.clone(),
window_id: None, window_id: None,
dialog_new_name: "Untitled".to_string(), dialog_new_name: "Untitled".to_string(),
@@ -1190,6 +1227,18 @@ impl HcieIcedApp {
doc.engine.clear_dirty_flags(); 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) // Always refresh cached panel data (cheap operation)
doc.cached_layers = doc.engine.layer_infos(); doc.cached_layers = doc.engine.layer_infos();
let history_len = doc.engine.history_len(); 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)); self.canvas_cursor_pos = Some((canvas_x as u32, canvas_y as u32));
let tool = self.tool_state.active_tool; 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 { match tool {
Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray => { Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray => {
// Apply brush parameters before starting stroke // Apply brush parameters before starting stroke
@@ -1546,39 +1636,67 @@ impl HcieIcedApp {
self.canvas_cursor_pos = Some((x as u32, y as u32)); self.canvas_cursor_pos = Some((x as u32, y as u32));
if self.tool_state.is_drawing { if self.tool_state.is_drawing {
let layer_id = self.documents[self.active_doc].engine.active_layer_id(); let is_ctrl = self.modifiers.control() || self.modifiers.logo();
if is_ctrl {
let spacing: f32 = match self.tool_state.active_tool { // Pick color
Tool::Spray => 2.0_f32, let cx = x as u32;
_ => 1.0_f32, let cy = y as u32;
}.max(1.0); let w = self.documents[self.active_doc].engine.canvas_width();
let h = self.documents[self.active_doc].engine.canvas_height();
if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos { if cx < w && cy < h {
let dx = x - last_x; let pixels = &self.documents[self.active_doc].composite_raw;
let dy = y - last_y; let idx = ((cy * w + cx) * 4) as usize;
let dist = (dx * dx + dy * dy).sqrt(); if idx + 3 < pixels.len() {
let color = [pixels[idx], pixels[idx + 1], pixels[idx + 2], pixels[idx + 3]];
// Use constant pressure for mouse input self.fg_color = color;
// Real tablet pressure comes from tablet_state self.documents[self.active_doc].engine.set_color(color);
let pressure = self.tablet_state.lock().map(|s| s.current_pressure()).unwrap_or(1.0); }
}
if dist >= spacing { } else if self.tool_state.is_resizing_brush {
self.documents[self.active_doc].engine.stroke_to(layer_id, x, y, pressure); if let Some((start_x, _)) = self.tool_state.brush_resize_start_pos {
self.tool_state.last_stroke_pos = Some((x, y)); let dx = x - start_x;
let new_size = (self.tool_state.brush_resize_start_size + dx).clamp(1.0, 500.0);
// Throttle composite refresh to ~60 FPS during strokes. match self.tool_state.active_tool {
let now = std::time::Instant::now(); Tool::Pen => self.settings.tool_settings.pen_size = new_size,
let elapsed = now.duration_since(self.tool_state.last_composite_refresh); Tool::Brush => self.settings.tool_settings.brush_size = new_size,
if elapsed.as_secs_f32() >= 0.016 { Tool::Eraser => self.settings.tool_settings.eraser_size = new_size,
self.tool_state.last_composite_refresh = now; Tool::Spray => self.settings.tool_settings.spray_radius = new_size,
// 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();
} }
} }
} else { } else {
self.documents[self.active_doc].engine.stroke_to(layer_id, x, y, 1.0); let layer_id = self.documents[self.active_doc].engine.active_layer_id();
self.tool_state.last_stroke_pos = Some((x, y));
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 => { Message::CanvasPointerReleased => {
self.tool_state.is_resizing_brush = false;
// Lasso release finalizes a freehand selection path. // Lasso release finalizes a freehand selection path.
if self.tool_state.active_tool == Tool::Lasso && self.tool_state.is_drawing { if self.tool_state.active_tool == Tool::Lasso && self.tool_state.is_drawing {
self.tool_state.is_drawing = false; 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 } => { Message::CanvasPanZoom { zoom, pan_offset } => {
let doc = &mut self.documents[self.active_doc]; let doc = &mut self.documents[self.active_doc];
doc.zoom = zoom; doc.zoom = zoom;
@@ -2789,6 +2918,8 @@ impl HcieIcedApp {
gradient_drag: None, gradient_drag: None,
vision_rect: None, vision_rect: None,
text_draft: None, text_draft: None,
selection_edge_cache: Default::default(),
marching_ants_edges: None,
}); });
self.active_doc = self.documents.len() - 1; self.active_doc = self.documents.len() - 1;
self.active_dialog = ActiveDialog::None; self.active_dialog = ActiveDialog::None;
@@ -3943,6 +4074,8 @@ impl HcieIcedApp {
gradient_drag: None, gradient_drag: None,
vision_rect: None, vision_rect: None,
text_draft: None, text_draft: None,
selection_edge_cache: Default::default(),
marching_ants_edges: None,
}); });
} }
@@ -4532,6 +4665,9 @@ impl HcieIcedApp {
return self.update(Message::ViewerEnter); return self.update(Message::ViewerEnter);
} }
} }
Message::ModifiersChanged(modifiers) => {
self.modifiers = modifiers;
}
} }
// Persist colors to disk when they have changed (debounced via the // 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_dialog = self.active_dialog != ActiveDialog::None;
let has_menu = self.active_menu.is_some(); let has_menu = self.active_menu.is_some();
let has_dock_profile_dialog = self.show_dock_profile_dialog; 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); let mut stack = iced::widget::Stack::new().push(content);
if has_dialog { if has_dialog {
stack = stack.push(dialog_overlay); 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()) { if let Some(menu_overlay) = panels::menus::dropdown_overlay(self.active_menu, &self.recent_files, self.theme_state.colors()) {
stack = stack.push(menu_overlay); 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(); let _elapsed = view_start.elapsed();
// Performance log measuring Elm view reconstruction time. // Performance log measuring Elm view reconstruction time.
// useful to track widget layout allocation and view model reconstruction times. // 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, _ => None,
} }
}); });
@@ -5012,3 +5160,22 @@ impl HcieIcedApp {
iced::Subscription::batch(vec![keyboard, mouse, marching_ants]) 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<u64> {
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
}
@@ -0,0 +1,62 @@
use std::sync::Arc;
#[derive(Clone, Default)]
pub struct SelectionEdgeCache {
mask_ptr: usize,
mask_len: usize,
edges: Arc<Vec<(u32, u32, u32, u32)>>,
}
impl SelectionEdgeCache {
pub fn get(&self, mask: &[u8]) -> Option<Arc<Vec<(u32, u32, u32, u32)>>> {
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<Vec<(u32, u32, u32, u32)>> {
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
}
@@ -20,6 +20,7 @@
//! - The checkerboard is procedural (in the fragment shader) — no CPU geometry //! - The checkerboard is procedural (in the fragment shader) — no CPU geometry
pub mod shader_canvas; pub mod shader_canvas;
pub mod edge_cache;
// pub mod viewport; // pub mod viewport;
// pub mod render; // pub mod render;
@@ -69,6 +70,8 @@ struct OverlayProgram {
active_tool: hcie_engine_api::Tool, active_tool: hcie_engine_api::Tool,
/// Gradient drag preview endpoints in canvas-space. /// Gradient drag preview endpoints in canvas-space.
gradient_drag: Option<((f32, f32), (f32, f32))>, gradient_drag: Option<((f32, f32), (f32, f32))>,
/// Extracted edges for marching ants rendering.
marching_ants_edges: Option<std::sync::Arc<Vec<(u32, u32, u32, u32)>>>,
} }
/// Overlay state — tracks hover and cursor position for crosshair. /// Overlay state — tracks hover and cursor position for crosshair.
@@ -391,32 +394,74 @@ impl canvas::Program<Message> for OverlayProgram {
let total_cycle = dash_length + gap_length; let total_cycle = dash_length + gap_length;
let animated_offset = (self.marching_ants_offset * total_cycle) as usize; 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 // 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); 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)), if let Some(edges) = &self.marching_ants_edges {
width: 2.0 / self.zoom.max(1.0), // Irregular selection bounds (marching ants along extracted edges)
line_dash: canvas::LineDash { for &(x1, y1, x2, y2) in edges.iter() {
segments: &[dash_length, gap_length], let s1 = Point::new(origin_x + x1 as f32 * self.zoom, origin_y + y1 as f32 * self.zoom);
offset: white_offset, let s2 = Point::new(origin_x + x2 as f32 * self.zoom, origin_y + y2 as f32 * self.zoom);
},
..Default::default() // 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, pressure,
active_tool: tool_state.active_tool, active_tool: tool_state.active_tool,
gradient_drag: doc.gradient_drag, gradient_drag: doc.gradient_drag,
marching_ants_edges: doc.marching_ants_edges.clone(),
}; };
let overlay_canvas = canvas(overlay_program) let overlay_canvas = canvas(overlay_program)
@@ -899,6 +899,23 @@ impl shader::Program<Message> for CanvasShaderProgram {
state.pan_start = Some(pos); state.pan_start = Some(pos);
return (iced::event::Status::Captured, None); 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,
}));
}
}
}
} }
} }
@@ -14,6 +14,7 @@
use crate::app::Message; use crate::app::Message;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use hcie_engine_api::Tool;
use iced::widget::{column, container, horizontal_rule, mouse_area, row, text}; use iced::widget::{column, container, horizontal_rule, mouse_area, row, text};
use iced::{Element, Length}; use iced::{Element, Length};
@@ -502,3 +503,131 @@ pub fn dropdown_overlay(
Some(overlay.into()) 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()
}
@@ -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 crate::script::parser::ScriptAction;
use hcie_engine_api::Engine; use hcie_engine_api::Engine;
use hcie_engine_api::{BlendMode, BrushStyle, BrushTip}; 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] { fn hex_to_rgba(hex: &str) -> [u8; 4] {
let hex = hex.trim_start_matches('#'); let hex = hex.trim_start_matches('#');
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0); 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] [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 { fn parse_blend_mode(s: &str) -> BlendMode {
match s.to_lowercase().as_str() { match s.to_lowercase().as_str() {
"normal" => BlendMode::Normal, "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 { fn parse_brush_style(s: &str) -> BrushStyle {
match s.to_lowercase().as_str() { match s.to_lowercase().as_str() {
"default" | "round" => BrushStyle::Round, "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<u64> { fn find_layer_id(engine: &mut Engine, name_or_index: &str) -> Option<u64> {
if let Ok(idx) = name_or_index.parse::<usize>() { if let Ok(idx) = name_or_index.parse::<usize>() {
let ids = engine.get_all_layer_ids(); let ids = engine.get_all_layer_ids();
@@ -157,16 +95,6 @@ fn find_layer_id(engine: &mut Engine, name_or_index: &str) -> Option<u64> {
None 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]) { pub fn execute_actions(engine: &mut Engine, actions: &[ScriptAction]) {
for action in actions { for action in actions {
match action { match action {
@@ -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 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;
@@ -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; use crate::script::types::ScriptParserState;
// ── Expression Evaluator ────────────────────────────────────────────────── // ── Expression Evaluator ──────────────────────────────────────────────────
/// Token type for the arithmetic expression tokenizer.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum Token { enum Token {
/// Numeric literal.
Num(f32), Num(f32),
/// Variable reference (e.g. `$i`, `$count`).
Var(String), Var(String),
/// Addition operator.
Plus, Plus,
/// Subtraction operator.
Minus, Minus,
/// Multiplication operator.
Mul, Mul,
/// Division operator.
Div, Div,
/// Left parenthesis.
LParen, LParen,
/// Right parenthesis.
RParen, 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<Token>` on success, or an error message describing the invalid character.
fn tokenize(s: &str) -> Result<Vec<Token>, String> { fn tokenize(s: &str) -> Result<Vec<Token>, String> {
let mut tokens = Vec::new(); let mut tokens = Vec::new();
let chars: Vec<char> = s.chars().collect(); let chars: Vec<char> = s.chars().collect();
@@ -124,10 +83,6 @@ fn tokenize(s: &str) -> Result<Vec<Token>, String> {
Ok(tokens) 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<String, f32>) -> Result<(f32, usize), String> { fn parse_expression(tokens: &[Token], pos: usize, vars: &std::collections::HashMap<String, f32>) -> Result<(f32, usize), String> {
let (mut left, mut pos) = parse_term(tokens, pos, vars)?; let (mut left, mut pos) = parse_term(tokens, pos, vars)?;
while pos < tokens.len() { while pos < tokens.len() {
@@ -148,10 +103,6 @@ fn parse_expression(tokens: &[Token], pos: usize, vars: &std::collections::HashM
Ok((left, pos)) 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<String, f32>) -> Result<(f32, usize), String> { fn parse_term(tokens: &[Token], pos: usize, vars: &std::collections::HashMap<String, f32>) -> Result<(f32, usize), String> {
let (mut left, mut pos) = parse_factor(tokens, pos, vars)?; let (mut left, mut pos) = parse_factor(tokens, pos, vars)?;
while pos < tokens.len() { while pos < tokens.len() {
@@ -175,10 +126,6 @@ fn parse_term(tokens: &[Token], pos: usize, vars: &std::collections::HashMap<Str
Ok((left, pos)) Ok((left, pos))
} }
/// Parses a factor — the highest-precedence atom in the expression grammar.
///
/// Supports numeric literals, variable references (resolved from `vars`),
/// unary negation, and parenthesized sub-expressions.
fn parse_factor(tokens: &[Token], pos: usize, vars: &std::collections::HashMap<String, f32>) -> Result<(f32, usize), String> { fn parse_factor(tokens: &[Token], pos: usize, vars: &std::collections::HashMap<String, f32>) -> Result<(f32, usize), String> {
if pos >= tokens.len() { if pos >= tokens.len() {
return Err("unexpected end of expression".to_string()); return Err("unexpected end of expression".to_string());
@@ -210,21 +157,6 @@ fn parse_factor(tokens: &[Token], pos: usize, vars: &std::collections::HashMap<S
} }
} }
/// Evaluates an arithmetic expression string using the given variable map.
///
/// Handles three fast paths before falling through to the full tokenizer:
/// 1. Pure numeric literal — parsed directly.
/// 2. Bare variable reference (`$name`) — looked up in `vars`.
/// 3. Full expression — tokenized and parsed via recursive descent.
///
/// # Arguments
///
/// * `expr` — The expression string (e.g. `"3 + $i * 2"`).
/// * `vars` — Map of variable names to their current values.
///
/// # Returns
///
/// The evaluated `f32` result, or an error message.
fn eval_expr(expr: &str, vars: &std::collections::HashMap<String, f32>) -> Result<f32, String> { fn eval_expr(expr: &str, vars: &std::collections::HashMap<String, f32>) -> Result<f32, String> {
let expr = expr.trim(); let expr = expr.trim();
if expr.is_empty() { if expr.is_empty() {
@@ -254,26 +186,14 @@ fn eval_expr(expr: &str, vars: &std::collections::HashMap<String, f32>) -> Resul
// ── Parsed Action Types ────────────────────────────────────────────────── // ── 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)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum ScriptAction { pub enum ScriptAction {
/// Create a new layer with the given name and type.
CreateLayer { name: String, layer_type: String }, CreateLayer { name: String, layer_type: String },
/// Delete a layer identified by name or index.
DeleteLayer { name_or_index: String }, DeleteLayer { name_or_index: String },
/// Select (make active) a layer by name or index.
SelectLayer { name_or_index: String }, SelectLayer { name_or_index: String },
/// Rename a layer identified by name or index.
RenameLayer { name_or_index: String, new_name: String }, 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 }, 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 }, SetLayerBlendMode { name_or_index: String, blend_mode: String },
/// Paint a stroke through the given points with specified brush parameters.
PaintStroke { PaintStroke {
points: Vec<[f32; 2]>, points: Vec<[f32; 2]>,
color: String, color: String,
@@ -283,7 +203,6 @@ pub enum ScriptAction {
spacing: f32, spacing: f32,
hardness: f32, hardness: f32,
}, },
/// Update brush settings (any field that is `Some` is applied).
SetBrush { SetBrush {
style: Option<String>, style: Option<String>,
size: Option<f32>, size: Option<f32>,
@@ -291,9 +210,7 @@ pub enum ScriptAction {
spacing: Option<f32>, spacing: Option<f32>,
hardness: Option<f32>, hardness: Option<f32>,
}, },
/// Set the foreground color from a hex string.
SetForegroundColor { hex: String }, SetForegroundColor { hex: String },
/// Fill a rectangle on the raster layer with a solid color.
FillRasterRect { FillRasterRect {
x1: f32, x1: f32,
y1: f32, y1: f32,
@@ -301,7 +218,6 @@ pub enum ScriptAction {
y2: f32, y2: f32,
color: String, color: String,
}, },
/// Add a vector path (free-form or closed shape).
AddVectorPath { AddVectorPath {
points: Vec<[f32; 2]>, points: Vec<[f32; 2]>,
closed: bool, closed: bool,
@@ -309,31 +225,24 @@ pub enum ScriptAction {
stroke_color: String, stroke_color: String,
stroke_width: f32, stroke_width: f32,
}, },
/// Script completion marker.
Finish { summary: String }, Finish { summary: String },
} }
// ── Helper functions ──────────────────────────────────────────────────── // ── Helper functions ────────────────────────────────────────────────────
/// Converts an absolute pixel coordinate to normalized `[0, 1]` range.
fn to_norm(coord: f32, canvas_dim: f32) -> f32 { fn to_norm(coord: f32, canvas_dim: f32) -> f32 {
(coord / canvas_dim).clamp(0.0, 1.0) (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<String, f32>, canvas: f32) -> Result<f32, String> { fn parse_coord(s: &str, vars: &std::collections::HashMap<String, f32>, canvas: f32) -> Result<f32, String> {
let val = eval_expr(s, vars)?; let val = eval_expr(s, vars)?;
Ok(to_norm(val, canvas)) 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<String, f32>) -> Result<f32, String> { fn parse_coord_abs(s: &str, vars: &std::collections::HashMap<String, f32>) -> Result<f32, String> {
eval_expr(s, vars) 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> { fn hex_to_rgba_components(hex: &str) -> Result<(f32, f32, f32), String> {
let hex = hex.trim_start_matches('#'); let hex = hex.trim_start_matches('#');
if hex.len() != 6 && hex.len() != 8 { 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)) 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<String, String> { fn interpolate_hex(hex1: &str, hex2: &str, t: f32) -> Result<String, String> {
let (r1, g1, b1) = hex_to_rgba_components(hex1)?; let (r1, g1, b1) = hex_to_rgba_components(hex1)?;
let (r2, g2, b2) = hex_to_rgba_components(hex2)?; let (r2, g2, b2) = hex_to_rgba_components(hex2)?;
@@ -365,16 +263,6 @@ fn interpolate_hex(hex1: &str, hex2: &str, t: f32) -> Result<String, String> {
Ok(format!("#{:02X}{:02X}{:02X}", r, g, b)) 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]> { 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); let mut pts = Vec::with_capacity(steps + 1);
for i in 0..=steps { 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 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]> { fn pseudo_scatter(count: usize, bx: f32, by: f32, bw: f32, bh: f32) -> Vec<[f32; 2]> {
let mut pts = Vec::with_capacity(count); let mut pts = Vec::with_capacity(count);
for i in 0..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 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) { fn wiggle_point(px: f32, py: f32, amp: f32, seed: u64) -> (f32, f32) {
if amp <= 0.0 { if amp <= 0.0 {
return (px, py); return (px, py);
@@ -436,10 +302,6 @@ fn wiggle_point(px: f32, py: f32, amp: f32, seed: u64) -> (f32, f32) {
(px + dx, py + dy) (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) { fn apply_wiggle(pts: &mut [[f32; 2]], wiggle: f32) {
if wiggle <= 0.0 { if wiggle <= 0.0 {
return; 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<ScriptAction> { fn build_strokes(mut points: Vec<[f32; 2]>, state: &ScriptParserState) -> Vec<ScriptAction> {
if points.len() < 2 { if points.len() < 2 {
if points.len() == 1 { if points.len() == 1 {
@@ -510,7 +363,6 @@ fn build_strokes(mut points: Vec<[f32; 2]>, state: &ScriptParserState) -> Vec<Sc
actions actions
} }
/// Strips leading `[` and trailing `]` from a string (for bracket-enclosed arguments).
fn strip_brackets(s: &str) -> &str { fn strip_brackets(s: &str) -> &str {
let s = s.strip_prefix('[').unwrap_or(s); let s = s.strip_prefix('[').unwrap_or(s);
s.strip_suffix(']').unwrap_or(s) s.strip_suffix(']').unwrap_or(s)
@@ -518,20 +370,6 @@ fn strip_brackets(s: &str) -> &str {
// ── Line Parser ────────────────────────────────────────────────────────── // ── 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<ScriptAction>` on success, or a formatted error string with line number.
fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -> Result<Vec<ScriptAction>, String> { fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -> Result<Vec<ScriptAction>, String> {
let err = |msg: &str| format!("Line {}: {}", line_num, msg); let err = |msg: &str| format!("Line {}: {}", line_num, msg);
let parts: Vec<&str> = line.split_whitespace().collect(); 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 ────────────────────────────────────────────────────────── // ── 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<ScriptAction>` on success, or a formatted error string with line number.
pub fn parse_dsl_script(script_text: &str) -> Result<Vec<ScriptAction>, String> { pub fn parse_dsl_script(script_text: &str) -> Result<Vec<ScriptAction>, String> {
let mut actions = Vec::new(); let mut actions = Vec::new();
let mut state = ScriptParserState::default(); let mut state = ScriptParserState::default();
@@ -1168,30 +993,12 @@ pub fn parse_dsl_script(script_text: &str) -> Result<Vec<ScriptAction>, String>
Ok(actions) 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<usize> { pub fn parse_error_line(err: &str) -> Option<usize> {
let rest = err.strip_prefix("Line ")?; let rest = err.strip_prefix("Line ")?;
let colon = rest.find(':')?; let colon = rest.find(':')?;
rest[..colon].trim().parse().ok() 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> { pub fn suggest_fix(err: &str) -> Option<&'static str> {
if err.contains("gradient expects") { if err.contains("gradient expects") {
Some("gradient x1,y1 x2,y2 #hex1 #hex2 N — e.g. gradient 0,0 800,600 #87CEEB #FFD700 30") 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 pub const DEFAULT_SCRIPT: &str = r#"# Mountain Lake Landscape — 800×600
# Back-to-front drawing # Back-to-front drawing
@@ -1,29 +1,5 @@
use serde::{Deserialize, Serialize}; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScriptParserState { pub struct ScriptParserState {
pub color: String, pub color: String,