migrate : glm and gemini pro . no proper achievements
This commit is contained in:
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).
|
||||||
@@ -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,6 +1636,35 @@ 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 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 {
|
||||||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||||||
|
|
||||||
let spacing: f32 = match self.tool_state.active_tool {
|
let spacing: f32 = match self.tool_state.active_tool {
|
||||||
@@ -1571,8 +1690,6 @@ impl HcieIcedApp {
|
|||||||
let elapsed = now.duration_since(self.tool_state.last_composite_refresh);
|
let elapsed = now.duration_since(self.tool_state.last_composite_refresh);
|
||||||
if elapsed.as_secs_f32() >= 0.016 {
|
if elapsed.as_secs_f32() >= 0.016 {
|
||||||
self.tool_state.last_composite_refresh = now;
|
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();
|
self.refresh_composite_if_needed();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1581,6 +1698,7 @@ impl HcieIcedApp {
|
|||||||
self.tool_state.last_stroke_pos = Some((x, y));
|
self.tool_state.last_stroke_pos = Some((x, y));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle selection drag
|
// Handle selection drag
|
||||||
if self.tool_state.active_tool == Tool::Select {
|
if self.tool_state.active_tool == Tool::Select {
|
||||||
@@ -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,7 +394,50 @@ 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)
|
// 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);
|
||||||
|
|
||||||
|
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(
|
let sel_path = Path::rectangle(
|
||||||
Point::new(sel_x, sel_y),
|
Point::new(sel_x, sel_y),
|
||||||
Size::new(sel_w, sel_h),
|
Size::new(sel_w, sel_h),
|
||||||
@@ -406,8 +452,6 @@ impl canvas::Program<Message> for OverlayProgram {
|
|||||||
..Default::default()
|
..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 {
|
frame.stroke(&sel_path, Stroke {
|
||||||
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(1.0, 1.0, 1.0)),
|
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(1.0, 1.0, 1.0)),
|
||||||
width: 2.0 / self.zoom.max(1.0),
|
width: 2.0 / self.zoom.max(1.0),
|
||||||
@@ -419,6 +463,7 @@ impl canvas::Program<Message> for OverlayProgram {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Draw vector shape preview — shape geometry matches the active
|
// Draw vector shape preview — shape geometry matches the active
|
||||||
// tool so the user sees what will be drawn (egui draws the actual
|
// tool so the user sees what will be drawn (egui draws the actual
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user