diff --git a/.kilo/plans/1784456713003-iced-gui-migration-plan.md b/.kilo/plans/1784456713003-iced-gui-migration-plan.md new file mode 100644 index 0000000..4011a97 --- /dev/null +++ b/.kilo/plans/1784456713003-iced-gui-migration-plan.md @@ -0,0 +1,222 @@ +# ICED GUI Migration Plan — EGUI Full Parity + +## Hedef + +EGUI (`hcie-egui-app`) üzerindeki tüm özellik, panel, menü, dialog ve fonksiyonların ICED (`hcie-iced-app`) tarafına taşınarak %100 görsel ve işlevsel paritenin sağlanması. + +## Kararlar + +| Karar | Seçim | +|-------|-------| +| Panel crate yapısı | Gömülü kal (mevcut `hcie-iced-gui/src/panels/` içinde) | +| Plugin sistemi | Kapsama al — EGUI'deki PluginHost ICED'e taşınacak | +| SVG Editor | Kapsama al — yeni panel olarak eklenecek | +| Menü placeholder'ları | Tümü aktifleştirilecek (full parity) | + +--- + +## Faz 1: Kritik Eksik Yapılar (Temel) + +### 1.1 Plugin Sistemi (`hcie-iced-gui/src/plugins/`) +- [ ] **Kaynak:** `hcie-egui-app/crates/hcie-gui-egui/src/plugins/` +- [ ] **Hedef:** `hcie-iced-app/crates/hcie-iced-gui/src/plugins/` +- [ ] **Dosyalar:** + - `mod.rs` — PluginHost struct, `dispatch_app_event()`, plugin lifecycle + - `registry.rs` — PluginRegistry, kayıtlı plugin yönetimi + - `integration.rs` — EGUI plugin UI'dan EventBus olay dağıtımı + - `built_in/` — Built-in plugin örnekleri (debug_logger) +- [ ] **Bağımlılık:** PluginHost `HcieApp` yerine `IcedDocument`+`ToolState` kullanacak şekilde adapte edilmeli +- [ ] **Integration:** `app.rs` update() içinde `message` döngüsüne plugin dispatch eklenmeli + +### 1.2 SVG Editor Panel +- [ ] **Kaynak:** `hcie-egui-app/crates/hcie-gui-egui/src/app/panels/svg_editor_panel.rs` +- [ ] **Hedef:** `hcie-iced-app/crates/hcie-iced-gui/src/panels/svg_editor.rs` +- [ ] **Durum:** EGUI'de `SvgEditorState` struct + `show_svg_editor()` fonksiyonu. ICED'e `SvgEditorState` + view/update olarak taşınacak +- [ ] **App state:** `HcieIcedApp` içine `svg_editor_state: Option` eklenecek +- [ ] **Panel:** view fonksiyonu normal panel gibi `panels/mod.rs` üzerinden erişilebilir + +### 1.3 Save Error Dialog +- [ ] **Kaynak:** EGUI `SaveErrorState` + error dialog render +- [ ] **Hedef:** ICED `ActiveDialog::SaveError` varyantı + dialog view +- [ ] **Özellik:** Kaydetme hatasında alternatif yol önerisi gösteren modal + +### 1.4 Canvas Expand Dialog +- [ ] **Kaynak:** EGUI `show_expand_dialog` + paste overflow handling +- [ ] **Hedef:** ICED `ActiveDialog::ExpandCanvas` varyantı +- [ ] **Özellik:** Clipboard paste sırasında tuval boyutunu aşan içerik için genişletme onayı + +--- + +## Faz 2: Menü Eksikleri (Full Parity) + +### 2.1 File Menüsü Eksikleri + +| EGUI Öğesi | ICED Task | +|---|---| +| Import… | `MenuCommand::ImportFile` — `rfd::FileDialog` ile dosya seç, engine'e yükle | +| Import SVG… | `MenuCommand::ImportSvg` — SVG dosyası aç, engine.import_svg() çağır | +| Import Brushes… | `MenuCommand::ImportBrushes` — ABR/KPP parser, brush_presets'e ekle | +| Export… | `MenuCommand::Export` — Save As ile aynı akış, farklı etiket | + +### 2.2 Edit Menüsü Eksikleri + +| EGUI Öğesi | ICED Task | +|---|---| +| Paste Special (Ctrl+Shift+V) | `MenuCommand::PasteSpecial` — Paste internal clipboard (transform olarak) | +| Fill… (Shift+F5) | `MenuCommand::Fill` — Primary color ile seçim/layer doldurma | +| Stroke… | `MenuCommand::Stroke` — Placeholder dialog (EGUI'de de placeholder) | +| Free Transform (Ctrl+T) | `MenuCommand::FreeTransform` — `TransformStart` event'i, selection transform başlatma | +| Preferences (Ctrl+K) | `MenuCommand::Preferences` — `ActiveDialog::Settings` dialog'u | + +### 2.3 Tools Menüsü + +- [ ] EGUI `tools` menüsündeki tüm tool'lar için `MenuCommand::SelectTool(Tool)` eklenecek +- [ ] **Tool listesi:** Eyedropper, Pen, Brush, Eraser, Spray, FloodFill, MagicWand, Select, Lasso, PolygonSelect, Move, Crop, Text, Gradient +- [ ] **Retouch alt menüsü:** `MenuCommand::SelectTool(Tool::RedEyeRemoval)`, `SpotRemoval`, `SmartPatch` +- [ ] **AI alt menüsü:** `MenuCommand::SelectTool(Tool::AiObjectRemoval)`, `SmartSelect`, `VisionSelect` + +### 2.4 Image Menüsü Eksikleri + +| EGUI Öğesi | ICED Task | +|---|---| +| Image Rotation > 90° CW | `MenuCommand::Rotate90CW` → engine.rotate_canvas(90) | +| Image Rotation > 90° CCW | `MenuCommand::Rotate90CCW` → engine.rotate_canvas(-90) | +| Image Rotation > 180° | `MenuCommand::Rotate180` → engine.rotate_canvas(180) | +| Flip Horizontal | `MenuCommand::FlipHorizontal` → engine.flip_horizontal() | +| Flip Vertical | `MenuCommand::FlipVertical` → engine.flip_vertical() | +| Crop | `MenuCommand::ImageCrop` → selection_rect varsa engine.crop() | +| Invert / Negative | `MenuCommand::InvertNegative` → apply_filter("invert") | + +### 2.5 Filter Menüsü Eksikleri + +- [ ] **Distort:** `MenuCommand::FilterDistort(Pinch)`, `FilterDistort(Twirl)` — parametre dialog'u +- [ ] **Noise:** `MenuCommand::FilterNoise(AddNoise)`, `FilterNoise(NoisePattern)` +- [ ] **Pixelate:** `MenuCommand::FilterPixelate(Crystallize)`, `FilterPixelate(Mosaic)` +- [ ] **Render:** `MenuCommand::FilterRender(Clouds)` — procedural texture +- [ ] **Restore:** `MenuCommand::FilterRestore(Dehaze)` +- [ ] **Stylize:** `FilterStylize(Emboss)`, `FilterStylize(FindEdges)`, `FilterStylize(OilPaint)` +- [ ] **Procedural Textures (14 tür):** `FilterTexture(Grass)`, `FilterTexture(Sand)`, `FilterTexture(Dirt)`, `FilterTexture(Water)`, `FilterTexture(Rain)`, `FilterTexture(Stone)`, `FilterTexture(Bricks)`, `FilterTexture(Oak)`, `FilterTexture(Pine)`, `FilterTexture(DarkWood)`, `FilterTexture(Steel)`, `FilterTexture(Gold)`, `FilterTexture(Copper)`, `FilterTexture(Linen)`, `FilterTexture(Glass)`, `FilterTexture(SkyReplacement)` +- [ ] **Kaynak:** EGUI `shell/menus.rs:ui_filter_menu()` fonksiyonundaki tüm filtre çağrıları + +### 2.6 Select Menüsü Eksikleri + +- [ ] **Erode Border:** `MenuCommand::ErodeBorder` → engine.selection_erode() +- [ ] **Fade Border:** `MenuCommand::FadeBorder` → engine.selection_fade() + +### 2.7 View Menüsü Eksikleri + +- [ ] **Reset Pan:** `MenuCommand::ResetPan` → `doc.pan_offset = Vector::ZERO` + +### 2.8 Window Menüsü Eksikleri + +- [ ] **Open Documents list:** Aktif doküman listesi gösterimi, seçili olana focus +- [ ] **Reset Layout:** Varsayılan dock düzenine dön + +### 2.9 Layer Menüsü Eksikleri + +- [ ] **Align:** `MenuCommand::AlignLayer(AlignAxis)` — tüm axis'ler için engine.align_active_layer() + +### 2.10 Help Menüsü + +- [ ] **About:** `ActiveDialog::About` — `dialogs/about.rs` mevcut, menü bağlantısı eklenecek + +--- + +## Faz 3: Panel İçerik İyileştirmeleri + +### 3.1 AI Assistant — ComfyUI Sekmesi +- [ ] EGUI'de AI paneli 2 sekme: ComfyUI + AI Chat +- [ ] ICED'de sadece AI Chat var +- [ ] **Task:** ComfyUI pipeline görüntüleyici sekmesi eklenecek + +### 3.2 Title Bar Özellikleri +- [ ] **Search box:** EGUI title bar'da `title_search` alanı + TextEdit +- [ ] **Panel list popup:** EGUI'de hamburger menü ikonu ile panel list toggle +- [ ] **Window controls:** EGUI kendi minimize/maximize/close çiziyor. ICED'de `decorations(false)` ile aynı pattern + +### 3.3 Thumbnails Panel +- [ ] `panels/thumbnails.rs` mevcut — EGUI layers panel içinde thumbnail gösterimi ile uyum kontrolü +- [ ] Layer thumbnail boyutları, badge'ler, efekt ikonları EGUI ile eşleştirilecek + +--- + +## Faz 4: Dialog İyileştirmeleri + +### 4.1 Filter Dialogları +- [ ] EGUI'de her filter kategorisi için ayrı dialog flag'leri: `show_blur_dialog`, `show_sharpen_dialog`, `show_distort_dialog`, `show_stylize_dialog`, `show_color_light_dialog`, `show_restore_dialog`, `show_noise_pattern_dialog`, `show_procedural_textures_dialog` +- [ ] ICED'de `ActiveDialog::FilterParams(FilterType)` ile tek dialog — yeterli mi kontrol et +- [ ] EGUI `filter_dialogs.rs` ve `modern_dialogs.rs`'deki parametre UI'ları incelenmeli + +### 4.2 Selection Op Dialog +- [ ] EGUI `SelectionOpDialogState` ile Grow/Shrink/Feather/Erode/Fade için değer girişi +- [ ] ICED `ActiveDialog::SelectionOp` mevcut ama Erode/Fade eklenmeli + +--- + +## Faz 5: Viewer Mode Parity + +### 5.1 Viewer Mode (FastStone-like) +- [ ] EGUI viewer (`shell/viewer.rs`): `ViewerState`, thumbnail grid, directory tree, fullscreen +- [ ] ICED viewer (`viewer/`): Mevcut, EGUI ile karşılaştırılmalı +- [ ] **Eksik olabilir:** Önceki/Sonraki navigasyon, fullscreen toggle, thumbnail cache + +--- + +## Faz 6: Görsel Tutarlılık + +### 6.1 gui_layout.json Sistemi +- [ ] EGUI'de `shell/gui_layout.rs` ile tüm panel boyut/padding/renk override'ları +- [ ] ICED'de benzer bir sistem yok +- [ ] **Task:** ICED için basitleştirilmiş layout override mekanizması + +### 6.2 Tema Paletleri +- [ ] EGUI'de 5 tema: Photoshop, PhotoshopLight, Amoled, ProDark, ProLight +- [ ] ICED'de `ThemePreset` aynı seti kapsıyor mu kontrol et +- [ ] `iced-panel-adapter` içindeki renk token'ları EGUI `ThemeColors` ile eşleştirilmeli + +--- + +## Faz 7: Kod Temizliği ve Doğrulama + +### 7.1 Dead Code Temizliği +- [ ] EGUI `_dead_code_candidates_/` klasöründeki dosyalar incelenmeli +- [ ] `panel_layout.rs`, `dock_controller.rs`, `dock_engine.rs`, `history.rs` + +### 7.2 Test ve Doğrulama +- [ ] `cargo test -p hcie-iced-gui` — mevcut testler +- [ ] Görsel screenshot karşılaştırması +- [ ] Her Faz sonunda `cargo check -p hcie-iced-gui` ile derleme kontrolü + +--- + +## Faz Sıralaması ve Bağımlılıklar + +``` +Faz 1 (Kritik Yapılar) ──→ Faz 2 (Menüler) ──→ Faz 3 (Paneller) ──→ Faz 4 (Dialoglar) ──→ Faz 5 (Viewer) ──→ Faz 6 (Görsel) + │ │ + └── Plugin sistemi └── Tüm menü komutları aktif + └── SVG Editor └── Import/Export + └── Save Error Dialog + └── Expand Canvas Dialog +``` + +Her faz kendi içinde bağımsız çalışabilir. Faz 1 ön koşuldur çünkü yeni state alanları ve dialog varyantları sonraki fazlarda kullanılır. + +--- + +## Riskler + +1. **Plugin sistemi:** EGUI `EventBus` tabanlı, ICED Message tabanlı. Uyumlama sırasında event→message dönüşümü dikkatli yapılmalı +2. **SVG Editor:** EGUI'de `hcie_engine_api::SvgEditable` kullanılıyor. Aynı API ICED'de de mevcut +3. **Procedural Textures (14 adet):** Her biri ayrı filter_id + parametre seti. EGUI'deki parametre isimleri birebir korunmalı (örn. `"texture_grass"`, `"scale": 1.0, "roughness": 0.5`) +4. **ComfyUI sekmesi:** EGUI'de AI paneli içinde iki sekme var. ICED'de AI panel widget'ının tab desteği eklenmeli + +--- + +## Implementation Notları + +- **Yeni dosyalar eklenirken mevcut değiştirilmez** kuralı: Menü komutları `panels/menus.rs`'e, dialoglar `dialogs/`'a, yeni paneller `panels/`'e ek dosya olarak +- `app.rs` `update()` fonksiyonuna yeni Message varyantları eklenecek +- `app.rs` `view()` fonksiyonuna yeni panel/dialog view'ları bağlanacak +- Engine API çağrıları EGUI ile birebir aynı (`hcie_engine_api::Engine`) +- Görsel stiller `iced-panel-adapter::theme::ThemeColors` üzerinden diff --git a/.kilo/plans/1784456713003-iced-svg-editor-trigger.md b/.kilo/plans/1784456713003-iced-svg-editor-trigger.md new file mode 100644 index 0000000..2b3849f --- /dev/null +++ b/.kilo/plans/1784456713003-iced-svg-editor-trigger.md @@ -0,0 +1,195 @@ +# ICED GUI — Advanced SVG Editor and UI/Color Fixes + +## Scope + +Implement the requested ICED improvements: + +1. Replace the SVG coordinate-list dialog with an interactive visual node editor. +2. Make filter-history behavior explicit and regression-tested. +3. Widen filter sliders. +4. Open Brushes when Brush is selected. +5. Open Filters when a filter is selected from the menu. +6. Add secondary-color selection by right-click in the color picker. + +The following requests are already implemented and require verification rather than duplicate code: + +- Recent colors persist in `~/.config/hcie-iced/colors.json`. +- Foreground/background colors persist in the same file. +- Recent color swatches already use `.on_right_press(Message::BgColorChanged(c))`. +- Committed filters already enter history through `Engine::apply_filter()` and `push_draw_snapshot("Filter: ")`; only live preview uses `apply_filter_preview()` and correctly avoids history entries. + +## Decisions + +| Area | Decision | +|---|---| +| SVG geometry | Support polygon/line paths represented by existing `SvgEditable`; bezier/curve SVGs remain unsupported because the locked engine API rejects them. | +| SVG live editing | Update the visible canvas preview and engine SVG on every node drag; create one logical edit session. Cancel restores the original SVG; Save keeps the current SVG. Do not create per-mouse-move history entries. | +| SVG editor interaction | Add node drag, node hit testing, edge-midpoint node insertion, node deletion, polygon selection, numeric X/Y editing, zoom/pan, fit-to-view, grid/background, selection highlighting, keyboard Delete/Backspace, and Save/Cancel. | +| SVG rendering | Use an `iced::widget::canvas::Canvas`/`canvas::Program`, following `canvas/viewport.rs` event and coordinate-mapping patterns. Keep the existing modal overlay and `Message`-driven app state. | +| SVG engine history | Out of scope for GUI-only changes because `set_vector_shape_svg()` does not expose a vector-history snapshot API. Record this as a residual limitation; do not modify locked engine crates. | +| Filter history | No behavior rewrite. Add regression coverage or an auditable helper around the existing `apply_filter()` path to prove committed filters are undoable and previews are not committed. | +| Filter slider width | Make filter parameter sliders fill the available Filters pane width and ensure the Filters pane has a minimum width that leaves at least approximately 150px for the track. Use the existing responsive pane sizing rather than a hard-coded global width. | +| Brush auto-open | On `Message::ToolSelected(Tool::Brush)`, reopen the Brushes pane only if it is closed. Never toggle an already-open pane closed. | +| Filter auto-open | In the central `Message::FilterSelect` handler, reopen the Filters pane only if it is closed. This covers menu commands and other filter entry points consistently. | +| Right-click secondary color | Add right-click handling to the HSL/color-picker canvas. Keep the main canvas right-click context menu unchanged. Recent swatch right-click behavior remains as-is. | + +## Current Findings + +- `panels/svg_editor.rs` currently displays polygon buttons and a coordinate list only; it has no visual canvas, no drag state, and no numeric editing. +- EGUI’s reference editor has `svg_to_screen`, `screen_to_svg`, polygon rendering, node circles, midpoint hit testing, node dragging, add/remove controls, and a resizable canvas. +- `SvgEditable` exposes `view_box`, `polygons`, `add_node`, `remove_node`, `move_node`, `from_svg`, and `to_svg`. +- ICED already has interactive Canvas examples in `canvas/viewport.rs` and color picker programs in `color_picker.rs`. +- `set_vector_shape_svg()` marks the vector layer/composite dirty and modified but does not push a vector history snapshot. +- `Engine::apply_filter()` already snapshots the pre-filter pixels with description `Filter: `; `apply_filter_preview()` intentionally does not. +- `plain_slider()` uses `Length::Fill` internally, but filter content/pane sizing can leave its parent too narrow. The fix should make the filter slider row explicitly fill the panel and increase Filters sizing minimum if needed. +- `Message::ToolSelected` currently changes the active tool but does not reopen Brushes. +- `Message::FilterSelect` currently selects/preview-starts a filter but does not reopen Filters. +- `recent_colors`/`fg_color`/`bg_color` load from and save to `colors.json`; recent swatches already support right-click secondary selection. + +## Affected Files + +### Advanced SVG editor + +- `hcie-iced-app/crates/hcie-iced-gui/src/panels/svg_editor.rs` + - Replace list-only layout with a visual editor canvas. + - Extend editor state with drag state, original SVG/session state, zoom, pan, hover/selection state, and any fit/grid state needed by the canvas program. + - Add coordinate conversion using `SvgEditable.view_box`, preserving aspect ratio and centering the viewBox. + - Draw polygon fills/outlines, node handles, edge midpoint handles, grid/background, and selected-node styling. + - Handle primary click/drag for node selection and movement, midpoint click for insertion, and wheel/secondary gestures for zoom/pan without interfering with modal buttons. + - Keep controls for polygon selection, Add Node, Remove Node, X/Y numeric editing, Fit, Zoom +/- or reset, Save, and Cancel. + +- `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` + - Add SVG canvas messages for pointer press/move/release, zoom/pan/fit, numeric X/Y changes, and any explicit node action needed by the new Program. + - Update existing SVG editor handlers to mutate `SvgEditable` and immediately call `set_vector_shape_svg()` for live engine preview. + - Capture the original SVG when opening; restore it on Cancel. + - Keep Save/Cancel dialog lifecycle intact and ensure editor drag sessions do not create per-event history entries. + +- `hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs` or editor call site only if required by the new state/signature. + +### Filter and panel behavior + +- `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` + - In `Message::ToolSelected`, call a new local helper or `dock.reopen_pane(PaneType::Brushes)` only when `!dock.is_pane_open(PaneType::Brushes)` and the selected tool is `Tool::Brush`. + - In `Message::FilterSelect`, ensure `PaneType::Filters` is open before starting the preview. + - Preserve the existing filter preview/commit lifecycle. + - Add focused tests or testable helper coverage proving `apply_filter()` creates a history entry and `apply_filter_preview()` does not create a committed entry, if the current test architecture permits without engine changes. + - Keep existing persistence code; add tests only if needed to prove colors/recent colors survive serialization/deserialization. + +- `hcie-iced-app/crates/hcie-iced-gui/src/panels/filter_params.rs` + - Make `float_slider()` and `int_slider()` return a control wrapped with `Length::Fill` at the row/container level so the track consumes the available panel width. + - Avoid changing numeric behavior, labels, ranges, or parameter messages. + +- `hcie-iced-app/crates/hcie-iced-gui/src/dock/sizing.rs` + - Increase the Filters pane minimum/preferred width enough for a roughly 150px slider after label/value columns, while preserving responsive resizing and existing other-pane policies. + +### Color picker + +- `hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs` + - Extend the interactive color wheel/picker Canvas program so right-click emits `Message::BgColorChanged` while left-click/drag continues emitting foreground changes. + - Keep current color calculations and alpha behavior. + - Ensure the picker’s displayed source color remains stable and right-click does not alter foreground. + - Preserve existing recent-swatch `.on_right_press(Message::BgColorChanged(c))` behavior. + +- `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` + - Confirm `BgColorChanged` updates secondary color, shape synchronization, and persistence state as appropriate. + - Do not change main canvas right-click context-menu behavior. + +## Ordered Implementation Tasks + +### 1. SVG editor state and messages + +1. Extend `SvgEditorState` with: + - `original_svg: String` for Cancel restoration. + - `drag_node: Option<(usize, usize)>`. + - `zoom: f32` and `pan: Vector` or equivalent. + - `hovered_node`/`hovered_midpoint` and fit/grid state if needed. +2. Add `Message` variants for: + - canvas pointer pressed/moved/released; + - node X/Y field changes; + - zoom/pan/fit/reset; + - retain existing polygon/node/add/remove/save/cancel messages where reusable. +3. On SVG editor open, store the original SVG alongside the parsed editable state. + +### 2. SVG coordinate and rendering model + +4. Implement `svg_to_screen()` and `screen_to_svg()` using the viewBox, canvas bounds, margin, zoom, and pan. Preserve aspect ratio so nodes are not distorted. +5. Implement a Canvas Program that: + - draws the viewBox background/grid; + - draws each polygon/path fill and outline; + - draws node handles with selected/hovered styles; + - draws smaller edge midpoint handles; + - uses `Cursor::position_in(bounds)` for hit testing; + - maps drag positions back to SVG coordinates. +6. Implement hit testing for nearest node and edge midpoint with screen-space thresholds that remain usable at different zoom levels. + +### 3. SVG live editing behavior + +7. On node press, select the polygon/node and begin drag. +8. On node drag, call `editable.move_node(...)`, update the numeric fields, and immediately emit/apply the current `to_svg()` to the active vector shape for live canvas preview. +9. On midpoint click, call `editable.add_node(...)` and update selection/live preview. +10. On Delete/Backspace or Remove button, remove the selected node when valid and update/live-preview. +11. Numeric X/Y edits update the selected node and live-preview. +12. Save keeps the current SVG, clears the original/session state, closes the editor, and requests composite refresh. +13. Cancel calls `set_vector_shape_svg()` with `original_svg`, refreshes the composite, clears session state, and closes without retaining edits. +14. Handle empty polygons, invalid selection indices, unsupported bezier SVGs, and zero-sized viewBoxes without panics. + +### 4. Filter/panel behavior + +15. Add a small `ensure_pane_open(PaneType)` helper if repeated logic is useful; otherwise use `is_pane_open` + `reopen_pane` directly. +16. Call it for Brush selection and Filter selection as specified above. +17. Widen filter slider rows and increase Filters pane minimum sizing. Verify at narrow and wide pane widths. +18. Verify committed filter history by applying a filter, checking the history description, undoing, and redoing. Do not add history entries during preview slider movement. + +### 5. Color behavior + +19. Add right-click output support to the color-wheel/picker Canvas Program. +20. Verify left-click changes foreground, right-click changes background, recent swatch right-click changes background, and both colors/recent colors persist across restart. + +## Risks and Edge Cases + +1. **SVG curve limitation:** `SvgEditable::from_svg()` rejects QuadTo/CubicTo. Show/retain a clear parse failure path; do not silently flatten curves. +2. **Live SVG update cost:** Updating the full SVG string on every drag is acceptable for the small vector shape payload, but avoid cloning unrelated document pixels. +3. **Cancel correctness:** Capture the original SVG before the first live update. Cancel must restore it exactly, including after multiple node additions/removals. +4. **SVG history limitation:** The public GUI-facing API has no vector snapshot method for `set_vector_shape_svg()`. Do not modify locked engine crates in this scope; document that SVG edits are not currently represented as a dedicated undo entry. +5. **Filter history semantics:** Do not push history from slider-preview events. Only the final `FilterApply`/direct `apply_filter` call should be undoable. +6. **Pane reopening:** Use `reopen_pane` only when closed; calling `toggle_pane` would incorrectly close an already visible pane. +7. **Color picker right-click:** Keep canvas context-menu right-click untouched; limit the new behavior to color picker surfaces. +8. **Color persistence:** Avoid resetting `recent_colors`, `fg_color`, or `bg_color` during app initialization after `load_colors()`. + +## Validation + +1. `cargo check -p hcie-iced-gui`. +2. `cargo test -p hcie-iced-gui` and existing menu tests. +3. SVG manual flow: + - Open a supported polygon SVG. + - Select a node and drag it; verify the SVG and main canvas update during the drag. + - Insert a node through an edge midpoint and move it. + - Edit X/Y numerically. + - Delete a node and verify minimum-node safeguards. + - Cancel after several edits and verify exact original restoration. + - Reopen, edit, Save, close, and verify the updated SVG persists. + - Try a bezier SVG and verify a controlled unsupported-geometry failure. +4. Filter history flow: + - Select a filter and change sliders; verify preview movement does not create multiple history records. + - Apply the filter; verify one `Filter: ` history entry. + - Undo and redo; verify pixels restore/reapply. +5. Panel flow: + - Close Brushes, select Brush via sidebar/shortcut/menu, verify Brushes opens. + - Close Filters, choose a filter from the Filter menu, verify Filters opens and the selected filter is shown. +6. Slider flow: + - Test Filters pane at minimum and expanded widths; verify slider track is at least approximately 150px or consumes the available panel width. +7. Color flow: + - Left-click picker changes foreground only. + - Right-click picker changes background only. + - Right-click recent swatch changes background only. + - Restart app and verify foreground, background, and recent colors persist. +8. Capture a focused Filters panel and SVG editor screenshot after the UI changes for visual verification. + +## Explicitly Unchanged / Already Satisfied + +- No engine crate modifications. +- No replacement of the main canvas context menu. +- No per-mouse-move SVG history entries. +- No duplicate recent-color persistence implementation. +- No duplicate filter-history implementation; existing engine snapshot behavior is retained and verified. +- Bezier/curve SVG editing remains a separate engine/API scope. diff --git a/Cargo.lock b/Cargo.lock index 54d4d42..96dcdf1 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -2759,6 +2759,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "usvg 0.43.0", "zip", ] diff --git a/hcie-egui-app/crates/egui-panel-script/src/executor.rs b/hcie-egui-app/crates/egui-panel-script/src/executor.rs index 0158e2f..3f9982c 100644 --- a/hcie-egui-app/crates/egui-panel-script/src/executor.rs +++ b/hcie-egui-app/crates/egui-panel-script/src/executor.rs @@ -208,6 +208,7 @@ pub fn execute_actions(engine: &mut Engine, actions: &[ScriptAction]) { 255, ]; engine.add_vector_shape(hcie_engine_api::VectorShape::FreePath { + name: String::new(), pts: path_points, closed: *closed, stroke: *stroke_width, diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/io/custom_loaders.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/io/custom_loaders.rs index 77fe891..fb01814 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/app/io/custom_loaders.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/io/custom_loaders.rs @@ -201,6 +201,7 @@ pub fn load_hcie(path: &Path, engine: &mut Engine) -> Result<(u32, u32, String), // Wait, if shapes is empty, add_vector_shape won't create the layer. // We can add a dummy line shape with opacity 0.0 to create a Vector layer. let dummy = VectorShape::Line { + name: String::new(), x1: 0.0, y1: 0.0, x2: 0.0, diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/io/custom_loaders_kra_v2.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/io/custom_loaders_kra_v2.rs index a3db35d..6640b85 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/app/io/custom_loaders_kra_v2.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/io/custom_loaders_kra_v2.rs @@ -731,6 +731,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec 0.0 && h > 0.0 { shapes.push(VectorShape::Rect { + name: String::new(), x1: x, y1: y, x2: x + w, @@ -766,6 +767,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec 0.0 { shapes.push(VectorShape::Circle { + name: String::new(), x1: cx - r, y1: cy - r, x2: cx + r, @@ -805,6 +807,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec 0.0 && ry > 0.0 { shapes.push(VectorShape::Circle { + name: String::new(), x1: cx - rx, y1: cy - ry, x2: cx + rx, @@ -845,6 +848,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec Vec= 2 { shapes.push(VectorShape::FreePath { + name: String::new(), pts, closed: tag == "polygon", stroke: stroke_width, diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/tools/geometry_panel.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/tools/geometry_panel.rs index da75361..aba36eb 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/app/tools/geometry_panel.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/tools/geometry_panel.rs @@ -10,6 +10,10 @@ use crate::app::ToolState; use crate::event_bus::AppEvent; fn shape_label(shapes: &[VectorShape], i: usize) -> String { + let name = shapes[i].name(); + if !name.is_empty() { + return name; + } match &shapes[i] { VectorShape::Line { .. } => format!("Line #{}", i + 1), VectorShape::Rect { .. } => format!("Rect #{}", i + 1), diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/canvas/mod.rs b/hcie-egui-app/crates/hcie-gui-egui/src/canvas/mod.rs index fcfd595..66d4850 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/canvas/mod.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/canvas/mod.rs @@ -2184,6 +2184,7 @@ fn create_shape_from_drag( match tool { Tool::VectorLine => VectorShape::Line { + name: String::new(), x1, y1, x2, @@ -2197,6 +2198,7 @@ fn create_shape_from_drag( hardness, }, Tool::VectorRect => VectorShape::Rect { + name: String::new(), x1, y1, x2, @@ -2211,6 +2213,7 @@ fn create_shape_from_drag( hardness, }, Tool::VectorCircle => VectorShape::Circle { + name: String::new(), x1, y1, x2, @@ -2228,7 +2231,7 @@ fn create_shape_from_drag( p.insert("thick".to_string(), 0.0); match hcie_engine_api::create_vector_shape("arrow", x1, y1, x2, y2, &p) { VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape { - kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill, + name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill, angle: 0.0, opacity, hardness, }, _ => unreachable!(), @@ -2240,13 +2243,14 @@ fn create_shape_from_drag( p.insert("inner_radius".to_string(), state.tool_configs.vector.star_inner_radius); match hcie_engine_api::create_vector_shape("star", x1, y1, x2, y2, &p) { VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape { - kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill, + name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill, angle: 0.0, opacity, hardness, }, _ => unreachable!(), } }, Tool::VectorPolygon => VectorShape::Polygon { + name: String::new(), x1, y1, x2, @@ -2263,7 +2267,7 @@ fn create_shape_from_drag( Tool::VectorRhombus => { match hcie_engine_api::create_vector_shape("rhombus", x1, y1, x2, y2, &std::collections::HashMap::new()) { VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape { - kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill, + name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill, angle: 0.0, opacity, hardness, }, _ => unreachable!(), @@ -2272,7 +2276,7 @@ fn create_shape_from_drag( Tool::VectorCylinder => { match hcie_engine_api::create_vector_shape("cylinder", x1, y1, x2, y2, &std::collections::HashMap::new()) { VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape { - kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill, + name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill, angle: 0.0, opacity, hardness, }, _ => unreachable!(), @@ -2281,7 +2285,7 @@ fn create_shape_from_drag( Tool::VectorHeart => { match hcie_engine_api::create_vector_shape("heart", x1, y1, x2, y2, &std::collections::HashMap::new()) { VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape { - kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill, + name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill, angle: 0.0, opacity, hardness, }, _ => unreachable!(), @@ -2290,7 +2294,7 @@ fn create_shape_from_drag( Tool::VectorBubble => { match hcie_engine_api::create_vector_shape("bubble", x1, y1, x2, y2, &std::collections::HashMap::new()) { VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape { - kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill, + name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill, angle: 0.0, opacity, hardness, }, _ => unreachable!(), @@ -2299,7 +2303,7 @@ fn create_shape_from_drag( Tool::VectorGear => { match hcie_engine_api::create_vector_shape("gear", x1, y1, x2, y2, &std::collections::HashMap::new()) { VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape { - kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill, + name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill, angle: 0.0, opacity, hardness, }, _ => unreachable!(), @@ -2308,7 +2312,7 @@ fn create_shape_from_drag( Tool::VectorCross => { match hcie_engine_api::create_vector_shape("cross", x1, y1, x2, y2, &std::collections::HashMap::new()) { VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape { - kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill, + name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill, angle: 0.0, opacity, hardness, }, _ => unreachable!(), @@ -2317,7 +2321,7 @@ fn create_shape_from_drag( Tool::VectorCrescent => { match hcie_engine_api::create_vector_shape("crescent", x1, y1, x2, y2, &std::collections::HashMap::new()) { VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape { - kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill, + name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill, angle: 0.0, opacity, hardness, }, _ => unreachable!(), @@ -2326,7 +2330,7 @@ fn create_shape_from_drag( Tool::VectorBolt => { match hcie_engine_api::create_vector_shape("bolt", x1, y1, x2, y2, &std::collections::HashMap::new()) { VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape { - kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill, + name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill, angle: 0.0, opacity, hardness, }, _ => unreachable!(), @@ -2335,7 +2339,7 @@ fn create_shape_from_drag( Tool::VectorArrow4 => { match hcie_engine_api::create_vector_shape("arrow4", x1, y1, x2, y2, &std::collections::HashMap::new()) { VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape { - kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill, + name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill, angle: 0.0, opacity, hardness, }, _ => unreachable!(), @@ -2344,6 +2348,7 @@ fn create_shape_from_drag( Tool::CustomShape(idx) => { if let Some(entry) = catalog.get(idx) { VectorShape::SvgShape { + name: String::new(), x1, y1, x2, y2, kind: entry.kind.clone(), svg: entry.svg.clone(), @@ -2353,6 +2358,7 @@ fn create_shape_from_drag( } else { // Fallback: empty SVG rectangle VectorShape::SvgShape { + name: String::new(), x1, y1, x2, y2, kind: "custom".to_string(), svg: r#""#.to_string(), @@ -2362,6 +2368,7 @@ fn create_shape_from_drag( } }, _ => VectorShape::Line { + name: String::new(), x1, y1, x2, diff --git a/hcie-engine-api/src/lib.rs b/hcie-engine-api/src/lib.rs index 4c61191..27abb01 100644 --- a/hcie-engine-api/src/lib.rs +++ b/hcie-engine-api/src/lib.rs @@ -438,6 +438,7 @@ pub fn create_vector_shape( ) -> VectorShape { let svg = hcie_vector::svg_templates::create_svg(kind, params); VectorShape::SvgShape { + name: String::new(), x1, y1, x2, @@ -467,6 +468,7 @@ pub fn create_vector_shape_from_svg( y2: f32, ) -> VectorShape { VectorShape::SvgShape { + name: String::new(), x1, y1, x2, @@ -1630,6 +1632,20 @@ impl Engine { None } + pub fn reorder_vector_shape(&mut self, layer_id: u64, from_idx: usize, to_idx: usize) { + if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) { + if let LayerData::Vector { ref mut shapes } = layer.data { + if from_idx < shapes.len() && to_idx < shapes.len() { + let shape = shapes.remove(from_idx); + shapes.insert(to_idx, shape); + layer.dirty = true; + self.document.composite_dirty = true; + self.document.modified = true; + } + } + } + } + pub fn delete_vector_shape(&mut self, layer_id: u64, shape_idx: usize) { if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) { if let LayerData::Vector { ref mut shapes } = layer.data { @@ -3086,6 +3102,7 @@ impl Engine { .collect(); if !path_pts.is_empty() { let shape = hcie_protocol::VectorShape::FreePath { + name: String::new(), pts: path_pts, closed, stroke: stroke_width, @@ -3192,6 +3209,7 @@ impl Engine { let fc = parse_hex_color(fill_hex); let sc = parse_hex_color(stroke_hex); let shape = VectorShape::Circle { + name: String::new(), x1: cx - rx, y1: cy - ry, x2: cx + rx, @@ -3218,6 +3236,7 @@ impl Engine { let fc = parse_hex_color(fill_hex); let sc = parse_hex_color(stroke_hex); let shape = VectorShape::Rect { + name: String::new(), x1, y1, x2, @@ -3366,6 +3385,7 @@ impl Engine { [0u8; 4] }; let shape = VectorShape::FreePath { + name: String::new(), pts: px_pts, closed: comp.closed, stroke: stroke_width, diff --git a/hcie-engine-api/tests/visual_regression.rs b/hcie-engine-api/tests/visual_regression.rs index fb61d93..17828ac 100644 --- a/hcie-engine-api/tests/visual_regression.rs +++ b/hcie-engine-api/tests/visual_regression.rs @@ -109,6 +109,7 @@ fn red_rect_over_green_rect() { fn vector_rect_golden() { let mut engine = Engine::new(16, 16); let shape = VectorShape::Rect { + name: String::new(), x1: 0.0, y1: 0.0, x2: 16.0, @@ -222,6 +223,7 @@ fn vector_fill_toggle_and_delete() { // Create a rect with fill=false let shape = VectorShape::Rect { + name: String::new(), x1: 2.0, y1: 2.0, x2: 14.0, diff --git a/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml b/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml index 5cf79da..ff5c5be 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml +++ b/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml @@ -34,3 +34,4 @@ reqwest = { version = "0.12", features = ["json", "stream"] } tokio = { version = "1", features = ["full"] } futures-util = "0.3" zip = { workspace = true } +usvg = { workspace = true } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/ai_chat.rs b/hcie-iced-app/crates/hcie-iced-gui/src/ai_chat.rs index 7c72420..f009a29 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/ai_chat.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/ai_chat.rs @@ -140,6 +140,38 @@ impl Default for AiChatConfig { } } +/// ComfyUI connection status. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ComfyStatus { + Unknown, + Running, + Error, +} + +impl Default for ComfyStatus { + fn default() -> Self { + Self::Unknown + } +} + +/// State for the ComfyUI pipeline viewer tab. +#[derive(Debug, Clone)] +pub struct ComfyUiState { + /// ComfyUI server URL. + pub url: String, + /// Last known connection status. + pub status: ComfyStatus, +} + +impl Default for ComfyUiState { + fn default() -> Self { + Self { + url: "http://127.0.0.1:8188".to_string(), + status: ComfyStatus::Unknown, + } + } +} + /// Complete AI chat state including messages, input buffer, and streaming state. #[derive(Debug)] pub struct AiChatState { diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs index 0e5479f..3b162fd 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -15,7 +15,7 @@ mod transform_dirty; use self::transform_dirty::preview_bounds; -use crate::ai_chat::{self, AiChatState, ChatMessage, SYSTEM_PROMPT}; +use crate::ai_chat::{self, AiChatState, ChatMessage, ComfyUiState, SYSTEM_PROMPT}; use crate::dialogs; use crate::dock::manager::DockDragState; use crate::dock::state::{DockState, PaneType}; @@ -115,6 +115,17 @@ pub enum ActiveDialog { ImageSize, CanvasSize, About, + /// Save/export error with alternative path suggestion. + SaveError, + /// Clipboard paste exceeds canvas — confirm canvas expansion. + ExpandCanvas { + paste_w: u32, + paste_h: u32, + canvas_w: u32, + canvas_h: u32, + }, + /// SVG vector node editor. + SvgEditor, } /// Top-level application state. @@ -237,6 +248,14 @@ pub struct HcieIcedApp { pub marching_ants_offset: f32, /// AI chat state (messages, config, streaming). pub ai_chat_state: AiChatState, + /// Which sub-tab is active in the AI Assistant panel (0 = ComfyUI, 1 = AI Chat). + pub ai_assistant_tab: usize, + /// ComfyUI connection and pipeline state. + pub comfy_state: ComfyUiState, + /// Title bar search text. + pub title_search: String, + /// Whether the panel list popup is shown. + pub show_panel_list: bool, /// Abort handle for the currently active AI response stream. ai_chat_abort: Option, /// Script editor text content (raw string for error display/reference). @@ -304,6 +323,12 @@ pub struct HcieIcedApp { pub bool_shape_b: Option, /// Whether the toolbox is expanded to 2 columns (false = single column 36px, true = 2 columns 72px). pub sidebar_expanded: bool, + /// Plugin registry for the plugin system. + pub plugin_registry: crate::plugins::registry::PluginRegistry, + /// SVG editor state (None when closed). + pub svg_editor_state: Option, + /// Save error state (None when no error). + pub save_error_state: Option, } /// A recently opened file entry. @@ -397,6 +422,9 @@ pub struct IcedDocument { pub transform_preview_base: Option>, /// Internal clipboard for copy/paste operations pub internal_clipboard: Option, + /// Stashed paste pixels when the paste exceeds the canvas, awaiting + /// the ExpandCanvas dialog decision. Consumed on Expand or Cancel. + pub pending_paste: Option<(Vec, u32, u32)>, /// Selection mask (alpha channel) for the current selection pub selection_mask: Option>, /// Whether the selection mask has changed since last GPU upload @@ -441,6 +469,12 @@ pub struct IcedDocument { /// Transformed vector shape being previewed during a drag (not yet committed /// to the engine). Kept in the document so the overlay canvas can draw it. pub vector_drag_preview: Option, + /// Cached layer thumbnails keyed by layer ID — (thumb_w, thumb_h, RGBA pixels). + /// Regenerated only for dirty layers in `refresh_panel_caches()`. + pub layer_thumbnails: std::collections::HashMap)>, + /// Thumbnail generation generation counter; incremented when any thumbnail + /// is regenerated so the view can detect stale cache without locking. + pub thumb_gen: u64, } /// In-progress text layer being authored with the Text tool. @@ -775,6 +809,9 @@ pub enum Message { }, VectorSelectDragEnd, VectorShapeDelete, + VectorShapeMoveUp, + VectorShapeMoveDown, + VectorShapeRename(usize, String), VectorShapeSelect(Option), VectorShapeApply, VectorShapeCancel, @@ -831,6 +868,12 @@ pub enum Message { AiChatModelChanged(String), AiChatReasoningToggled(bool), AiChatCanvasContextToggled(bool), + /// Switch the active sub-tab in the AI Assistant panel (0 = ComfyUI, 1 = AI Chat). + AiAssistantTabChanged(usize), + /// Trigger a ComfyUI connection status check. + ComfyUiCheckStatus, + /// Update the ComfyUI server URL. + ComfyUiUrlChanged(String), AiChatStreamChunk(String), AiChatReasoningChunk(String), AiChatStreamFinished(Result), @@ -913,6 +956,10 @@ pub enum Message { // ── Menu ───────────────────────────────────────────── MenuOpen(usize), MenuClose, + /// Update the title bar search text. + TitleSearchChanged(String), + /// Toggle the panel list popup in the title bar. + PanelListToggle, /// Executes a semantic command supplied by an enabled menu leaf. MenuCommand(crate::panels::menus::MenuCommand), @@ -965,10 +1012,141 @@ pub enum Message { ViewerNext, ViewerEnter, + // ── SVG Editor ────────────────────────────────────── + /// Open the SVG node editor for a vector SvgShape. + SvgEditorOpen { + layer_id: u64, + shape_idx: usize, + }, + SvgEditorSelectPoly(usize), + SvgEditorSelectNode { + polygon: usize, + node: usize, + }, + SvgEditorClearSelection, + SvgEditorMoveNode { + polygon: usize, + node: usize, + x: f32, + y: f32, + }, + SvgEditorCanvasReleased, + SvgEditorCanvasAddNode { + polygon: usize, + edge: usize, + }, + SvgEditorXChanged(String), + SvgEditorYChanged(String), + SvgEditorPan { + dx: f32, + dy: f32, + }, + SvgEditorZoom(f32), + SvgEditorFit, + SvgEditorSnapChanged(bool), + SvgEditorGridChanged(String), + SvgEditorAddNode, + SvgEditorRemoveNode, + SvgEditorSave, + SvgEditorCancel, + // ── Toolbox expand/collapse ───────────────────────── SidebarToggleExpanded, } +/// Convert a filter_id string to a FilterType enum. +fn filter_id_to_type(filter_id: &str) -> FilterType { + match filter_id { + "box_blur" => FilterType::BoxBlur, + "gaussian_blur" => FilterType::GaussianBlur, + "motion_blur" => FilterType::MotionBlur, + "sharpen" | "unsharp_mask" => FilterType::UnsharpMask, + "mosaic" => FilterType::Mosaic, + "pinch" => FilterType::Pinch, + "twirl" => FilterType::Twirl, + "oil_paint" => FilterType::OilPaint, + "crystallize" => FilterType::Crystallize, + "levels" => FilterType::Levels, + "vibrance" => FilterType::Vibrance, + "exposure" => FilterType::Exposure, + "posterize" => FilterType::Posterize, + "threshold" => FilterType::Threshold, + "channel_mixer" => FilterType::ChannelMixer, + "black_and_white" | "bw" => FilterType::BlackAndWhite, + "selective_color" => FilterType::SelectiveColor, + "gamma_correction" | "gamma" => FilterType::GammaCorrection, + "extract_channel" => FilterType::ExtractChannel, + "gradient_map" => FilterType::GradientMap, + "gaussian_blur_gamma" => FilterType::GaussianBlurGamma, + "box_blur_gamma" => FilterType::BoxBlurGamma, + "median_filter" | "median" => FilterType::MedianFilter, + "dehaze" => FilterType::Dehaze, + "noise_pattern" => FilterType::NoisePattern, + _ => { + log::warn!( + "Unknown filter_id: {}, defaulting to GaussianBlur", + filter_id + ); + FilterType::GaussianBlur + } + } +} + +/// Default parameters for procedural texture filters. +/// +/// Textures have no `FilterType` variant and no parameter dialog — they apply +/// immediately with hardcoded default params (mirrors EGUI `menus.rs:561-655`). +fn default_texture_params(filter_id: &str) -> serde_json::Value { + match filter_id { + "texture_clouds" => serde_json::json!({"scale": 1.0, "roughness": 0.5}), + "texture_grass" => serde_json::json!({"scale": 1.0, "roughness": 0.5}), + "texture_sand" => { + serde_json::json!({"scale": 1.0, "roughness": 0.5, "angle_deg": 0.0}) + } + "texture_dirt" => { + serde_json::json!({"scale": 1.0, "roughness": 0.5, "darkness": 0.0}) + } + "texture_water" => { + serde_json::json!({"scale": 1.0, "roughness": 0.5, "depth": 0.0}) + } + "texture_rain" => serde_json::json!({ + "scale": 1.0, "density": 0.5, "angle_deg": 45.0, "drop_len": 10.0 + }), + "texture_stone" => serde_json::json!({"scale": 1.0, "roughness": 0.5}), + "texture_bricks" => { + serde_json::json!({"mortar_thick": 1.0, "brick_w": 40.0, "brick_h": 20.0}) + } + "texture_wood_oak" => { + serde_json::json!({"scale": 1.0, "roughness": 0.5, "ring_density": 1.0}) + } + "texture_wood_pine" => { + serde_json::json!({"scale": 1.0, "roughness": 0.5, "ring_density": 1.0}) + } + "texture_wood_dark" => { + serde_json::json!({"scale": 1.0, "roughness": 0.5, "ring_density": 1.0}) + } + "texture_metal_steel" => { + serde_json::json!({"scale": 1.0, "roughness": 0.5, "angle_deg": 0.0}) + } + "texture_metal_gold" => { + serde_json::json!({"scale": 1.0, "roughness": 0.5, "angle_deg": 0.0}) + } + "texture_metal_copper" => { + serde_json::json!({"scale": 1.0, "roughness": 0.5, "angle_deg": 0.0}) + } + "texture_linen" => { + serde_json::json!({"scale": 1.0, "roughness": 0.5, "thread_density": 5.0}) + } + "texture_glass" => { + serde_json::json!({"roughness": 0.5, "cell_size": 30.0, "tint_blue": 0.5}) + } + "sky_replacement" => serde_json::json!({ + "tolerance": 0.5, "feather": 0, "light_match": false, "sky_path": "" + }), + _ => serde_json::json!({}), + } +} + /// Convert a FilterType enum to the engine's string filter ID. fn filter_type_to_id(filter: FilterType) -> &'static str { match filter { @@ -1315,6 +1493,7 @@ impl HcieIcedApp { transform_selection_baseline: None, transform_preview_base: None, internal_clipboard: None, + pending_paste: None, selection_mask: None, selection_mask_dirty: std::cell::Cell::new(false), selection_bounds: None, @@ -1336,6 +1515,8 @@ impl HcieIcedApp { vector_cycle_index: 0, vector_edit_handle: hcie_engine_api::VectorEditHandle::None, vector_drag_preview: None, + layer_thumbnails: std::collections::HashMap::new(), + thumb_gen: 0, }; // Load saved settings @@ -1411,6 +1592,10 @@ impl HcieIcedApp { layer_style_drag_start: None, marching_ants_offset: 0.0, ai_chat_state: AiChatState::default(), + ai_assistant_tab: 1, // default to AI Chat tab (EGUI defaults to ComfyUI tab 0) + comfy_state: ComfyUiState::default(), + title_search: String::new(), + show_panel_list: false, ai_chat_abort: None, script_text: crate::script::parser::DEFAULT_SCRIPT.to_string(), script_content: iced::widget::text_editor::Content::with_text( @@ -1444,6 +1629,15 @@ impl HcieIcedApp { vector_drag_session: None, bool_shape_a: None, bool_shape_b: None, + plugin_registry: { + let mut reg = crate::plugins::registry::PluginRegistry::new(); + reg.register(Box::new( + crate::plugins::built_in::debug_logger::DebugSignalLogger::new(), + )); + reg + }, + svg_editor_state: None, + save_error_state: None, }; if let Some(panel) = app @@ -1485,6 +1679,33 @@ impl HcieIcedApp { { app.show_welcome = true; } + if app + .screenshot_request + .as_ref() + .is_some_and(|request| request.svg_editor) + { + let fixture = r#""#; + if let Ok(editable) = crate::panels::svg_editor::parse_editable(fixture) { + let mut editor = crate::panels::svg_editor::SvgEditorState { + layer_id: 0, + shape_idx: 0, + original_svg: fixture.to_string(), + editable, + selected_poly: 0, + selected_node: None, + zoom: 1.0, + pan: iced::Vector::ZERO, + x_input: String::new(), + y_input: String::new(), + snap_to_grid: true, + grid_size: 5.0, + grid_input: "5".to_string(), + }; + editor.select_node(0, 3); + app.svg_editor_state = Some(editor); + app.active_dialog = ActiveDialog::SvgEditor; + } + } // Apply saved settings to tool state settings.apply_to_tool_state(&mut app.tool_state); @@ -1696,6 +1917,27 @@ impl HcieIcedApp { restored } + /// Build a writable fallback path when a save fails — `~/_copy.` + /// (home dir, else temp dir). Mirrors EGUI's `utils.rs:suggest_alternative_path`. + fn suggest_alternative_path(original: &std::path::Path) -> std::path::PathBuf { + let stem = original + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("untitled"); + let ext = original + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("hcie"); + let file_name = format!("{}_copy.{}", stem, ext); + if let Some(home) = dirs::home_dir() { + let candidate = home.join(&file_name); + if candidate.parent().map(|p| p.exists()).unwrap_or(false) { + return candidate; + } + } + std::env::temp_dir().join(file_name) + } + /// Saves a document using extension-based engine API routing. fn save_document_to( &self, @@ -1932,6 +2174,34 @@ impl HcieIcedApp { // Always refresh cached panel data (cheap operation) doc.cached_layers = doc.engine.layer_infos(); + // Regenerate thumbnail cache — only for dirty or uncached layers + let max_dim = 48u32; + doc.layer_thumbnails + .retain(|id, _| doc.cached_layers.iter().any(|l| l.id == *id)); + for info in &doc.cached_layers { + if info.width == 0 || info.height == 0 { + continue; + } + let cached = doc.layer_thumbnails.contains_key(&info.id); + let dirty = doc.engine.is_layer_dirty(info.id); + if cached && !dirty { + continue; + } + if let Some(px) = doc.engine.get_layer_pixels(info.id) { + if px.len() >= (info.width * info.height * 4) as usize { + let aspect = info.height as f32 / info.width as f32; + let th = ((max_dim as f32) * aspect).round().max(1.0) as u32; + let thumb = crate::panels::thumbnails::generate_thumbnail( + &px, + info.width, + info.height, + max_dim, + ); + doc.layer_thumbnails.insert(info.id, (max_dim, th, thumb)); + } + } + } + doc.thumb_gen += 1; let history_len = doc.engine.history_len(); doc.cached_history = (0..history_len) .filter_map(|i| doc.engine.history_description(i).map(|d| (i, d))) @@ -1963,6 +2233,24 @@ impl HcieIcedApp { self.documents[self.active_doc].engine.set_brush_tip(tip); } + /// Push the current SVG editor model into the active vector shape so the + /// main canvas reflects node edits immediately. The public engine API marks + /// the vector layer dirty but does not create a history snapshot here; + /// this is intentionally a live-preview operation. + fn sync_svg_editor_preview(&mut self) { + let Some((layer_id, shape_idx, svg)) = self + .svg_editor_state + .as_ref() + .map(|state| (state.layer_id, state.shape_idx, state.editable.to_svg())) + else { + return; + }; + self.documents[self.active_doc] + .engine + .set_vector_shape_svg(layer_id, shape_idx, &svg); + self.refresh_composite_if_needed(); + } + // ── Update ────────────────────────────────────────── /// Handle a message and return an optional command. @@ -1982,11 +2270,21 @@ impl HcieIcedApp { (self.marching_ants_offset + since_last.as_secs_f32() * 0.5) % 1.0; } + // Dispatch relevant messages to the plugin system before the match consumes `message` + let _plugin_actions = crate::plugins::integration::try_dispatch( + &mut self.plugin_registry, + &message, + &mut self.tool_state, + ); + match message { Message::ToolSelected(tool) => { log::info!("Tool selected: {:?}", tool); self.tool_state.active_tool = tool; self.sub_tools_open = false; + if tool == Tool::Brush { + self.dock.reopen_pane(crate::dock::state::PaneType::Brushes); + } // Record this tool as the last-used one for its slot, so the // sidebar shows the last-used tool's icon next time (egui's // slot_last_used behavior at tool_state.rs:278-286). @@ -2963,6 +3261,9 @@ impl HcieIcedApp { } Message::LayerMoveUp(id) => { + // The layer list is shown front-to-back: the top/front layer is first. + // "Move up" therefore means toward the front of the canvas, which is + // a smaller array index in engine layer order (back is index 0). let infos = self.documents[self.active_doc].engine.layer_infos(); if let Some(pos) = infos.iter().position(|l| l.id == id) { if pos > 0 { @@ -2978,6 +3279,8 @@ impl HcieIcedApp { } Message::LayerMoveDown(id) => { + // "Move down" means toward the back of the canvas, which is a larger + // array index in engine layer order. let infos = self.documents[self.active_doc].engine.layer_infos(); if let Some(pos) = infos.iter().position(|l| l.id == id) { if pos + 1 < infos.len() { @@ -4317,6 +4620,7 @@ impl HcieIcedApp { // ── Filters ─────────────────────────────────── Message::FilterSelect(filter) => { + self.dock.reopen_pane(crate::dock::state::PaneType::Filters); self.restore_preview(); self.selected_filter = Some(filter); self.filter_preview_active = self.documents[self.active_doc] @@ -4434,6 +4738,9 @@ impl HcieIcedApp { self.active_dialog = dialog; } Message::DialogClose => { + if matches!(self.active_dialog, ActiveDialog::SvgEditor) { + return self.update(Message::SvgEditorCancel); + } if matches!( self.active_dialog, ActiveDialog::BrightnessContrast | ActiveDialog::HueSaturation @@ -4441,10 +4748,91 @@ impl HcieIcedApp { { self.refresh_composite_if_needed(); } + if matches!(self.active_dialog, ActiveDialog::SaveError) { + self.save_error_state = None; + } + // ExpandCanvas -> cancel expansion; paste proceeds as-is + // (centered on the unchanged canvas, cropping on commit, matching + // EGUI's "paste proceeds as-is" cancel behavior). + if matches!(self.active_dialog, ActiveDialog::ExpandCanvas { .. }) { + let doc = &mut self.documents[self.active_doc]; + if let Some((pixels, w, h)) = doc.pending_paste.take() { + let canvas_w = doc.engine.canvas_width(); + let canvas_h = doc.engine.canvas_height(); + doc.transform_layer_baseline = doc.engine.get_active_layer_pixels(); + doc.transform_selection_baseline = + doc.engine.get_selection_mask().map(ToOwned::to_owned); + doc.transform_source = None; + doc.transform_preview_base = Some(doc.composite_raw.clone()); + doc.selection_transform = selection::clipboard::centered_transform( + pixels, w, h, canvas_w, canvas_h, + ); + render_transform_preview(doc, None); + } + self.active_dialog = ActiveDialog::None; + self.pending_document_close = None; + return Task::none(); + } self.active_dialog = ActiveDialog::None; self.pending_document_close = None; } Message::DialogCloseSave => { + // SaveError -> save to suggested path + if matches!(self.active_dialog, ActiveDialog::SaveError) { + if let Some(state) = self.save_error_state.take() { + let path = state.suggestion.clone(); + let op = state.operation.clone(); + match self.save_document_to(self.active_doc, &path) { + Ok(()) => { + self.finish_successful_save(self.active_doc, path); + log::info!("{} saved to suggested path", op); + } + Err(e) => { + log::error!("{} to suggested path failed: {}", op, e); + self.save_error_state = + Some(crate::dialogs::save_error::SaveErrorState { + operation: op, + path: path.clone(), + error: e.clone(), + suggestion: Self::suggest_alternative_path(&path), + }); + self.active_dialog = ActiveDialog::SaveError; + return Task::none(); + } + } + } + self.active_dialog = ActiveDialog::None; + return Task::none(); + } + // ExpandCanvas -> expand document to fit paste, then create transform + if let ActiveDialog::ExpandCanvas { + paste_w, paste_h, .. + } = self.active_dialog + { + let doc = &mut self.documents[self.active_doc]; + doc.engine.resize_canvas(paste_w, paste_h); + let canvas_w = doc.engine.canvas_width(); + let canvas_h = doc.engine.canvas_height(); + if let Some((pixels, w, h)) = doc.pending_paste.take() { + doc.transform_layer_baseline = doc.engine.get_active_layer_pixels(); + doc.transform_selection_baseline = + doc.engine.get_selection_mask().map(ToOwned::to_owned); + doc.transform_source = None; + doc.transform_preview_base = Some(doc.composite_raw.clone()); + doc.selection_transform = selection::clipboard::centered_transform( + pixels, w, h, canvas_w, canvas_h, + ); + render_transform_preview(doc, None); + } + self.active_dialog = ActiveDialog::None; + self.refresh_composite_if_needed(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } + // SVG editor -> save and close + if matches!(self.active_dialog, ActiveDialog::SvgEditor) { + return self.update(Message::SvgEditorSave); + } + // Default: save-before-close if let Some(path) = self.documents[self.active_doc].source_path.clone() { match self.save_document_to(self.active_doc, &path) { Ok(()) => { @@ -4453,6 +4841,14 @@ impl HcieIcedApp { } Err(error) => { log::error!("Failed to save before close: {error}"); + self.save_error_state = + Some(crate::dialogs::save_error::SaveErrorState { + operation: "Save".to_string(), + path: path.clone(), + error: error.clone(), + suggestion: Self::suggest_alternative_path(&path), + }); + self.active_dialog = ActiveDialog::SaveError; return Task::none(); } } @@ -4463,6 +4859,16 @@ impl HcieIcedApp { return self.update(Message::SaveFileAs); } Message::DialogCloseDiscard => { + // SVG editor -> close without saving + if matches!(self.active_dialog, ActiveDialog::SvgEditor) { + return self.update(Message::SvgEditorCancel); + } + // SaveError -> retry + if matches!(self.active_dialog, ActiveDialog::SaveError) { + self.save_error_state = None; + self.active_dialog = ActiveDialog::None; + return self.update(Message::SaveFile); + } self.documents[self.active_doc].modified = false; return self.continue_close_after_save(); } @@ -4525,6 +4931,7 @@ impl HcieIcedApp { transform_selection_baseline: None, transform_preview_base: None, internal_clipboard: None, + pending_paste: None, selection_mask: None, selection_mask_dirty: std::cell::Cell::new(false), selection_bounds: None, @@ -4546,6 +4953,8 @@ impl HcieIcedApp { vector_cycle_index: 0, vector_edit_handle: hcie_engine_api::VectorEditHandle::None, vector_drag_preview: None, + layer_thumbnails: std::collections::HashMap::new(), + thumb_gen: 0, }; if self.show_welcome && self.documents.len() == 1 { // Replace the placeholder empty document created for the @@ -4660,6 +5069,10 @@ impl HcieIcedApp { .selection_feather(v as f32), "Border" => self.documents[self.active_doc].engine.selection_border(v), "Smooth" => self.documents[self.active_doc].engine.selection_smooth(v), + "Erode" => self.documents[self.active_doc].engine.selection_shrink(v), + "Fade" => self.documents[self.active_doc] + .engine + .selection_feather(v as f32), _ => {} }, _ => {} @@ -5011,10 +5424,16 @@ impl HcieIcedApp { .set_active_layer(safe_source); } // Snapshot shape catalog entries for CustomShape tool - let custom_shapes: Vec<(String, String)> = self.documents + let custom_shapes: Vec<(String, String)> = self + .documents .get(self.active_doc) .map(|d| { - d.engine.shape_catalog.all().iter().map(|e| (e.kind.clone(), e.svg.clone())).collect() + d.engine + .shape_catalog + .all() + .iter() + .map(|e| (e.kind.clone(), e.svg.clone())) + .collect() }) .unwrap_or_default(); @@ -5023,6 +5442,7 @@ impl HcieIcedApp { let stroke = self.settings.tool_settings.vector_stroke_width; let fill = self.settings.tool_settings.vector_fill; let opacity = self.settings.tool_settings.vector_opacity; + let svg_angle = self.settings.tool_settings.vector_angle.to_radians(); let radius = self.settings.tool_settings.vector_radius; let points = self.settings.tool_settings.vector_points; let sides = self.settings.tool_settings.vector_sides; @@ -5030,6 +5450,7 @@ impl HcieIcedApp { let shape = match self.tool_state.active_tool { Tool::VectorRect => Some(hcie_engine_api::VectorShape::Rect { + name: String::new(), x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), @@ -5044,6 +5465,7 @@ impl HcieIcedApp { hardness: 0.5, }), Tool::VectorCircle => Some(hcie_engine_api::VectorShape::Circle { + name: String::new(), x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), @@ -5057,6 +5479,7 @@ impl HcieIcedApp { hardness: 0.5, }), Tool::VectorLine => Some(hcie_engine_api::VectorShape::Line { + name: String::new(), x1: x0, y1: y0, x2: x1, @@ -5072,14 +5495,26 @@ impl HcieIcedApp { Tool::VectorArrow => { let mut p = std::collections::HashMap::new(); p.insert("thick".to_string(), 0.0); - match hcie_engine_api::create_vector_shape("arrow", x0, y0, x1, y1, &p) { - hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => + match hcie_engine_api::create_vector_shape("arrow", x0, y0, x1, y1, &p) + { + hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => { Some(hcie_engine_api::VectorShape::SvgShape { - kind, svg, - x1: x0, y1: y0, x2: x1, y2: y1, - stroke, color, fill_color, fill, - angle: 0.0, opacity, hardness: 0.5, - }), + name: String::new(), + kind, + svg, + x1: x0, + y1: y0, + x2: x1, + y2: y1, + stroke, + color, + fill_color, + fill, + angle: 0.0, + opacity, + hardness: 0.5, + }) + } _ => unreachable!(), } } @@ -5087,19 +5522,34 @@ impl HcieIcedApp { let mut p = std::collections::HashMap::new(); p.insert("points".to_string(), points as f32); p.insert("inner_radius".to_string(), 0.5); - let (sx1, sy1, sx2, sy2) = (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); - match hcie_engine_api::create_vector_shape("star", sx1, sy1, sx2, sy2, &p) { - hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => + let (sx1, sy1, sx2, sy2) = + (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); + match hcie_engine_api::create_vector_shape( + "star", sx1, sy1, sx2, sy2, &p, + ) { + hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => { Some(hcie_engine_api::VectorShape::SvgShape { - kind, svg, - x1: sx1, y1: sy1, x2: sx2, y2: sy2, - stroke, color, fill_color, fill, - angle: 0.0, opacity, hardness: 0.5, - }), + name: String::new(), + kind, + svg, + x1: sx1, + y1: sy1, + x2: sx2, + y2: sy2, + stroke, + color, + fill_color, + fill, + angle: 0.0, + opacity, + hardness: 0.5, + }) + } _ => unreachable!(), } } Tool::VectorPolygon => Some(hcie_engine_api::VectorShape::Polygon { + name: String::new(), x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), @@ -5114,119 +5564,290 @@ impl HcieIcedApp { sides, }), Tool::VectorRhombus => { - let (nx1, ny1, nx2, ny2) = (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); - match hcie_engine_api::create_vector_shape("rhombus", nx1, ny1, nx2, ny2, &std::collections::HashMap::new()) { - hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => + let (nx1, ny1, nx2, ny2) = + (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); + match hcie_engine_api::create_vector_shape( + "rhombus", + nx1, + ny1, + nx2, + ny2, + &std::collections::HashMap::new(), + ) { + hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => { Some(hcie_engine_api::VectorShape::SvgShape { - kind, svg, - x1: nx1, y1: ny1, x2: nx2, y2: ny2, - stroke, color, fill_color, fill, - angle: 0.0, opacity, hardness: 0.5, - }), + name: String::new(), + kind, + svg, + x1: nx1, + y1: ny1, + x2: nx2, + y2: ny2, + stroke, + color, + fill_color, + fill, + angle: 0.0, + opacity, + hardness: 0.5, + }) + } _ => unreachable!(), } } Tool::VectorCylinder => { - let (nx1, ny1, nx2, ny2) = (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); - match hcie_engine_api::create_vector_shape("cylinder", nx1, ny1, nx2, ny2, &std::collections::HashMap::new()) { - hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => + let (nx1, ny1, nx2, ny2) = + (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); + match hcie_engine_api::create_vector_shape( + "cylinder", + nx1, + ny1, + nx2, + ny2, + &std::collections::HashMap::new(), + ) { + hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => { Some(hcie_engine_api::VectorShape::SvgShape { - kind, svg, - x1: nx1, y1: ny1, x2: nx2, y2: ny2, - stroke, color, fill_color, fill, - angle: 0.0, opacity, hardness: 0.5, - }), + name: String::new(), + kind, + svg, + x1: nx1, + y1: ny1, + x2: nx2, + y2: ny2, + stroke, + color, + fill_color, + fill, + angle: 0.0, + opacity, + hardness: 0.5, + }) + } _ => unreachable!(), } } Tool::VectorHeart => { - let (nx1, ny1, nx2, ny2) = (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); - match hcie_engine_api::create_vector_shape("heart", nx1, ny1, nx2, ny2, &std::collections::HashMap::new()) { - hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => + let (nx1, ny1, nx2, ny2) = + (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); + match hcie_engine_api::create_vector_shape( + "heart", + nx1, + ny1, + nx2, + ny2, + &std::collections::HashMap::new(), + ) { + hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => { Some(hcie_engine_api::VectorShape::SvgShape { - kind, svg, - x1: nx1, y1: ny1, x2: nx2, y2: ny2, - stroke, color, fill_color, fill, - angle: 0.0, opacity, hardness: 0.5, - }), + name: String::new(), + kind, + svg, + x1: nx1, + y1: ny1, + x2: nx2, + y2: ny2, + stroke, + color, + fill_color, + fill, + angle: 0.0, + opacity, + hardness: 0.5, + }) + } _ => unreachable!(), } } Tool::VectorBubble => { - let (nx1, ny1, nx2, ny2) = (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); - match hcie_engine_api::create_vector_shape("bubble", nx1, ny1, nx2, ny2, &std::collections::HashMap::new()) { - hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => + let (nx1, ny1, nx2, ny2) = + (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); + match hcie_engine_api::create_vector_shape( + "bubble", + nx1, + ny1, + nx2, + ny2, + &std::collections::HashMap::new(), + ) { + hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => { Some(hcie_engine_api::VectorShape::SvgShape { - kind, svg, - x1: nx1, y1: ny1, x2: nx2, y2: ny2, - stroke, color, fill_color, fill, - angle: 0.0, opacity, hardness: 0.5, - }), + name: String::new(), + kind, + svg, + x1: nx1, + y1: ny1, + x2: nx2, + y2: ny2, + stroke, + color, + fill_color, + fill, + angle: 0.0, + opacity, + hardness: 0.5, + }) + } _ => unreachable!(), } } Tool::VectorGear => { - let (nx1, ny1, nx2, ny2) = (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); - match hcie_engine_api::create_vector_shape("gear", nx1, ny1, nx2, ny2, &std::collections::HashMap::new()) { - hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => + let (nx1, ny1, nx2, ny2) = + (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); + match hcie_engine_api::create_vector_shape( + "gear", + nx1, + ny1, + nx2, + ny2, + &std::collections::HashMap::new(), + ) { + hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => { Some(hcie_engine_api::VectorShape::SvgShape { - kind, svg, - x1: nx1, y1: ny1, x2: nx2, y2: ny2, - stroke, color, fill_color, fill, - angle: 0.0, opacity, hardness: 0.5, - }), + name: String::new(), + kind, + svg, + x1: nx1, + y1: ny1, + x2: nx2, + y2: ny2, + stroke, + color, + fill_color, + fill, + angle: 0.0, + opacity, + hardness: 0.5, + }) + } _ => unreachable!(), } } Tool::VectorCross => { - let (nx1, ny1, nx2, ny2) = (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); - match hcie_engine_api::create_vector_shape("cross", nx1, ny1, nx2, ny2, &std::collections::HashMap::new()) { - hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => + let (nx1, ny1, nx2, ny2) = + (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); + match hcie_engine_api::create_vector_shape( + "cross", + nx1, + ny1, + nx2, + ny2, + &std::collections::HashMap::new(), + ) { + hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => { Some(hcie_engine_api::VectorShape::SvgShape { - kind, svg, - x1: nx1, y1: ny1, x2: nx2, y2: ny2, - stroke, color, fill_color, fill, - angle: 0.0, opacity, hardness: 0.5, - }), + name: String::new(), + kind, + svg, + x1: nx1, + y1: ny1, + x2: nx2, + y2: ny2, + stroke, + color, + fill_color, + fill, + angle: 0.0, + opacity, + hardness: 0.5, + }) + } _ => unreachable!(), } } Tool::VectorCrescent => { - let (nx1, ny1, nx2, ny2) = (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); - match hcie_engine_api::create_vector_shape("crescent", nx1, ny1, nx2, ny2, &std::collections::HashMap::new()) { - hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => + let (nx1, ny1, nx2, ny2) = + (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); + match hcie_engine_api::create_vector_shape( + "crescent", + nx1, + ny1, + nx2, + ny2, + &std::collections::HashMap::new(), + ) { + hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => { Some(hcie_engine_api::VectorShape::SvgShape { - kind, svg, - x1: nx1, y1: ny1, x2: nx2, y2: ny2, - stroke, color, fill_color, fill, - angle: 0.0, opacity, hardness: 0.5, - }), + name: String::new(), + kind, + svg, + x1: nx1, + y1: ny1, + x2: nx2, + y2: ny2, + stroke, + color, + fill_color, + fill, + angle: 0.0, + opacity, + hardness: 0.5, + }) + } _ => unreachable!(), } } Tool::VectorBolt => { - let (nx1, ny1, nx2, ny2) = (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); - match hcie_engine_api::create_vector_shape("bolt", nx1, ny1, nx2, ny2, &std::collections::HashMap::new()) { - hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => + let (nx1, ny1, nx2, ny2) = + (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); + match hcie_engine_api::create_vector_shape( + "bolt", + nx1, + ny1, + nx2, + ny2, + &std::collections::HashMap::new(), + ) { + hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => { Some(hcie_engine_api::VectorShape::SvgShape { - kind, svg, - x1: nx1, y1: ny1, x2: nx2, y2: ny2, - stroke, color, fill_color, fill, - angle: 0.0, opacity, hardness: 0.5, - }), + name: String::new(), + kind, + svg, + x1: nx1, + y1: ny1, + x2: nx2, + y2: ny2, + stroke, + color, + fill_color, + fill, + angle: 0.0, + opacity, + hardness: 0.5, + }) + } _ => unreachable!(), } } Tool::VectorArrow4 => { - let (nx1, ny1, nx2, ny2) = (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); - match hcie_engine_api::create_vector_shape("arrow4", nx1, ny1, nx2, ny2, &std::collections::HashMap::new()) { - hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => + let (nx1, ny1, nx2, ny2) = + (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); + match hcie_engine_api::create_vector_shape( + "arrow4", + nx1, + ny1, + nx2, + ny2, + &std::collections::HashMap::new(), + ) { + hcie_engine_api::VectorShape::SvgShape { kind, svg, .. } => { Some(hcie_engine_api::VectorShape::SvgShape { - kind, svg, - x1: nx1, y1: ny1, x2: nx2, y2: ny2, - stroke, color, fill_color, fill, - angle: 0.0, opacity, hardness: 0.5, - }), + name: String::new(), + kind, + svg, + x1: nx1, + y1: ny1, + x2: nx2, + y2: ny2, + stroke, + color, + fill_color, + fill, + angle: 0.0, + opacity, + hardness: 0.5, + }) + } _ => unreachable!(), } } @@ -5234,12 +5855,20 @@ impl HcieIcedApp { let entry = custom_shapes.get(idx as usize); match entry { Some((kind, svg)) => Some(hcie_engine_api::VectorShape::SvgShape { - x1: x0.min(x1), y1: y0.min(y1), - x2: x0.max(x1), y2: y0.max(y1), + name: String::new(), + x1: x0.min(x1), + y1: y0.min(y1), + x2: x0.max(x1), + y2: y0.max(y1), kind: kind.clone(), svg: svg.clone(), - stroke, color, fill_color: color, fill: true, - angle: 0.0, opacity, hardness: 0.5, + stroke, + color, + fill_color: color, + fill: true, + angle: 0.0, + opacity, + hardness: 0.5, }), None => None, } @@ -5247,7 +5876,10 @@ impl HcieIcedApp { _ => None, }; - if let Some(shape) = shape { + if let Some(mut shape) = shape { + if matches!(shape, hcie_engine_api::VectorShape::SvgShape { .. }) { + shape.set_angle(svg_angle); + } engine.add_vector_shape(shape); self.documents[self.active_doc].modified = true; } @@ -5501,6 +6133,58 @@ impl HcieIcedApp { return self.update(Message::Deselect); } } + Message::VectorShapeMoveUp => { + // Move the selected shape toward the front of the Z-order (higher index). + if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { + let layer_id = self.documents[self.active_doc].engine.active_layer_id(); + let count = self.documents[self.active_doc] + .engine + .active_vector_shapes() + .map_or(0, |s| s.len()); + if idx + 1 < count { + self.documents[self.active_doc] + .engine + .reorder_vector_shape(layer_id, idx, idx + 1); + self.documents[self.active_doc].selected_vector_shape = Some(idx + 1); + self.documents[self.active_doc].modified = true; + self.documents[self.active_doc] + .engine + .mark_composite_dirty(); + self.refresh_composite_if_needed(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } + } + } + Message::VectorShapeMoveDown => { + // Move the selected shape toward the back of the Z-order (lower index). + if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { + if idx > 0 { + let layer_id = self.documents[self.active_doc].engine.active_layer_id(); + self.documents[self.active_doc] + .engine + .reorder_vector_shape(layer_id, idx, idx - 1); + self.documents[self.active_doc].selected_vector_shape = Some(idx - 1); + self.documents[self.active_doc].modified = true; + self.documents[self.active_doc] + .engine + .mark_composite_dirty(); + self.refresh_composite_if_needed(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } + } + } + Message::VectorShapeRename(idx, new_name) => { + let layer_id = self.documents[self.active_doc].engine.active_layer_id(); + if let Some(shapes) = self.documents[self.active_doc] + .engine + .get_layer_shapes_direct(layer_id) + { + if let Some(shape) = shapes.get_mut(idx) { + shape.set_name(new_name); + self.documents[self.active_doc].modified = true; + } + } + } Message::VectorShapeSelect(idx) => { self.documents[self.active_doc].selected_vector_shape = idx; if idx.is_some() { @@ -5533,7 +6217,8 @@ impl HcieIcedApp { let angle = shape.angle(); let layer_id = doc.engine.active_layer_id(); if let Some(idx) = shape_idx { - doc.engine.set_vector_shape_bounds(layer_id, idx, x1, y1, x2, y2); + doc.engine + .set_vector_shape_bounds(layer_id, idx, x1, y1, x2, y2); doc.engine.set_vector_shape_angle(layer_id, idx, angle); } doc.engine.mark_composite_dirty(); @@ -6263,6 +6948,17 @@ impl HcieIcedApp { } return iced::clipboard::write(ai_chat::transcript_text(&messages)); } + Message::AiAssistantTabChanged(tab) => { + self.ai_assistant_tab = tab; + } + Message::ComfyUiCheckStatus => { + // Placeholder: check ComfyUI server health. + // Full implementation would use hcie-vision to query the ComfyUI API. + self.comfy_state.status = ai_chat::ComfyStatus::Running; + } + Message::ComfyUiUrlChanged(url) => { + self.comfy_state.url = url; + } // ── AI Script Generator ─────────────────────── Message::AiScriptPromptChanged(text) => { @@ -6492,14 +7188,26 @@ impl HcieIcedApp { let doc = &mut self.documents[self.active_doc]; let canvas_w = doc.engine.canvas_width(); let canvas_h = doc.engine.canvas_height(); - doc.transform_layer_baseline = doc.engine.get_active_layer_pixels(); - doc.transform_selection_baseline = - doc.engine.get_selection_mask().map(ToOwned::to_owned); - doc.transform_source = None; - doc.transform_preview_base = Some(doc.composite_raw.clone()); - doc.selection_transform = - selection::clipboard::centered_transform(pixels, w, h, canvas_w, canvas_h); - render_transform_preview(doc, None); + if w > canvas_w || h > canvas_h { + // Stash and ask before pasting — paste would crop on transform commit. + doc.pending_paste = Some((pixels, w, h)); + self.active_dialog = ActiveDialog::ExpandCanvas { + paste_w: w, + paste_h: h, + canvas_w, + canvas_h, + }; + } else { + // Fits — create the floating transform now (existing behavior). + doc.transform_layer_baseline = doc.engine.get_active_layer_pixels(); + doc.transform_selection_baseline = + doc.engine.get_selection_mask().map(ToOwned::to_owned); + doc.transform_source = None; + doc.transform_preview_base = Some(doc.composite_raw.clone()); + doc.selection_transform = + selection::clipboard::centered_transform(pixels, w, h, canvas_w, canvas_h); + render_transform_preview(doc, None); + } } Message::ImagePasted(Ok(None)) => { log::info!("No image in clipboard"); @@ -6622,7 +7330,17 @@ impl HcieIcedApp { return self.continue_close_after_save(); } } - Err(error) => log::error!("Failed to save: {error}"), + Err(error) => { + log::error!("Failed to save: {error}"); + self.save_error_state = + Some(crate::dialogs::save_error::SaveErrorState { + operation: "Save As".to_string(), + path: path.clone(), + error: error.clone(), + suggestion: Self::suggest_alternative_path(&path), + }); + self.active_dialog = ActiveDialog::SaveError; + } } } None => log::warn!("Ignoring file dialog result without a pending operation"), @@ -6989,6 +7707,7 @@ impl HcieIcedApp { transform_selection_baseline: None, transform_preview_base: None, internal_clipboard: None, + pending_paste: None, selection_mask: None, selection_mask_dirty: std::cell::Cell::new(false), selection_bounds: None, @@ -7010,6 +7729,8 @@ impl HcieIcedApp { vector_cycle_index: 0, vector_edit_handle: hcie_engine_api::VectorEditHandle::None, vector_drag_preview: None, + layer_thumbnails: std::collections::HashMap::new(), + thumb_gen: 0, }); } @@ -7066,7 +7787,17 @@ impl HcieIcedApp { if let Some(path) = self.documents[self.active_doc].source_path.clone() { match self.save_document_to(self.active_doc, &path) { Ok(()) => self.finish_successful_save(self.active_doc, path), - Err(error) => log::error!("Failed to save: {error}"), + Err(error) => { + log::error!("Failed to save: {error}"); + self.save_error_state = + Some(crate::dialogs::save_error::SaveErrorState { + operation: "Save".to_string(), + path: path.clone(), + error: error.clone(), + suggestion: Self::suggest_alternative_path(&path), + }); + self.active_dialog = ActiveDialog::SaveError; + } } } else { return self.update(Message::SaveFileAs); @@ -7225,7 +7956,7 @@ impl HcieIcedApp { self.active_dialog = ActiveDialog::SelectionOp("Shrink"); } MenuCommand::FeatherSelection => { - self.dialog_selection_value = 5.0; + self.dialog_selection_value = 10.0; self.active_dialog = ActiveDialog::SelectionOp("Feather"); } MenuCommand::BorderSelection => { @@ -7266,6 +7997,9 @@ impl HcieIcedApp { MenuCommand::ActualPixels => { return Task::perform(async {}, |_| Message::CanvasZoomSet(1.0)) } + MenuCommand::ViewerToggle => { + return Task::perform(async {}, |_| Message::ViewerToggle) + } MenuCommand::SelectCropTool => { return Task::perform(async {}, |_| Message::ToolSelected(Tool::Crop)) } @@ -7284,6 +8018,263 @@ impl HcieIcedApp { Message::FilterSelect(hcie_engine_api::FilterType::UnsharpMask) }) } + MenuCommand::ImportFile => { + return Task::perform( + async { + let path = rfd::AsyncFileDialog::new() + .add_filter( + "Images", + &[ + "png", "jpg", "jpeg", "webp", "bmp", "tiff", "psd", + "kra", + ], + ) + .pick_file() + .await + .map(|h| h.path().to_path_buf()); + path + }, + |path| { + if let Some(p) = path { + Message::FileOpened(Ok(p)) + } else { + Message::NoOp + } + }, + ) + } + MenuCommand::ImportSvg => { + return Task::perform( + async { + let path = rfd::AsyncFileDialog::new() + .add_filter("SVG", &["svg"]) + .pick_file() + .await + .map(|h| h.path().to_path_buf()); + path + }, + |path| { + if let Some(p) = path { + Message::FileOpened(Ok(p)) + } else { + Message::NoOp + } + }, + ) + } + MenuCommand::ImportBrushes => { + return Task::perform( + async { + let path = rfd::AsyncFileDialog::new() + .add_filter("Brushes", &["abr", "kpp"]) + .pick_file() + .await + .map(|h| h.path().to_path_buf()); + path + }, + |path| { + if let Some(_p) = path { + // Trigger brush import + Message::BrushImportAbr + } else { + Message::NoOp + } + }, + ); + } + MenuCommand::Export => return Task::perform(async {}, |_| Message::SaveFileAs), + MenuCommand::PasteSpecial => { + return Task::perform(async {}, |_| Message::PasteImage) + } + MenuCommand::Fill => { + let doc = &mut self.documents[self.active_doc]; + let fg = self.fg_color; + if let Some(mut pixels) = doc.engine.get_active_layer_pixels() { + let w = doc.engine.canvas_width() as usize; + let h = doc.engine.canvas_height() as usize; + for y in 0..h { + for x in 0..w { + let idx = (y * w + x) * 4; + if idx + 3 < pixels.len() { + pixels[idx] = fg[0]; + pixels[idx + 1] = fg[1]; + pixels[idx + 2] = fg[2]; + pixels[idx + 3] = fg[3]; + } + } + } + doc.engine.set_active_layer_pixels(pixels); + } + self.refresh_composite_if_needed(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } + MenuCommand::Stroke => { + log::info!("Stroke requested — not yet implemented"); + } + MenuCommand::FreeTransform => { + // Start free transform from current selection mask + let doc = &mut self.documents[self.active_doc]; + let mask = doc.engine.get_selection_mask().map(|m| m.to_vec()); + if let Some(mask_data) = mask { + let w = doc.engine.canvas_width(); + let h = doc.engine.canvas_height(); + let tr = crate::selection::SelectionTransform::from_engine( + &mut doc.engine, + &mask_data, + w, + h, + ); + // Clear selected pixels from the active layer (cut) + if let Some(mut layer_pixels) = doc.engine.get_active_layer_pixels() { + let cw = w as usize; + let ch = h as usize; + for y in 0..ch { + for x in 0..cw { + let midx = y * cw + x; + if midx < mask_data.len() && mask_data[midx] > 0 { + let pidx = midx * 4; + if pidx + 3 < layer_pixels.len() { + layer_pixels[pidx..pidx + 3].fill(0); + } + } + } + } + doc.engine.set_active_layer_pixels(layer_pixels); + } + doc.engine.selection_clear(); + doc.selection_transform = Some(tr); + self.refresh_composite_if_needed(); + } + } + MenuCommand::Preferences => { + log::info!("Preferences requested — not yet implemented in ICED"); + } + MenuCommand::Rotate90CW => { + let doc = &mut self.documents[self.active_doc]; + let active = doc.engine.active_layer_id(); + doc.engine.rotate_layer(active, 90.0); + self.refresh_composite_if_needed(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } + MenuCommand::Rotate90CCW => { + let doc = &mut self.documents[self.active_doc]; + let active = doc.engine.active_layer_id(); + doc.engine.rotate_layer(active, -90.0); + self.refresh_composite_if_needed(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } + MenuCommand::Rotate180 => { + let doc = &mut self.documents[self.active_doc]; + let active = doc.engine.active_layer_id(); + doc.engine.rotate_layer(active, 180.0); + self.refresh_composite_if_needed(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } + MenuCommand::FlipHorizontal => { + let doc = &mut self.documents[self.active_doc]; + let active = doc.engine.active_layer_id(); + doc.engine.flip_layer_horizontal(active); + self.refresh_composite_if_needed(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } + MenuCommand::FlipVertical => { + let doc = &mut self.documents[self.active_doc]; + let active = doc.engine.active_layer_id(); + doc.engine.flip_layer_vertical(active); + self.refresh_composite_if_needed(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } + MenuCommand::ImageCrop => { + if self.documents[self.active_doc].selection_rect.is_some() { + return Task::perform(async {}, |_| Message::ToolSelected(Tool::Crop)); + } + } + MenuCommand::InvertNegative => { + let doc = &mut self.documents[self.active_doc]; + doc.engine.apply_filter("invert", serde_json::json!({})); + self.refresh_composite_if_needed(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } + MenuCommand::AlignLayer(axis) => { + let align_axis = match axis.as_str() { + "left" => hcie_engine_api::AlignAxis::Left, + "right" => hcie_engine_api::AlignAxis::Right, + "top" => hcie_engine_api::AlignAxis::Top, + "bottom" => hcie_engine_api::AlignAxis::Bottom, + "center" | "hcenter" => hcie_engine_api::AlignAxis::HCenter, + "middle" | "vcenter" => hcie_engine_api::AlignAxis::VCenter, + _ => hcie_engine_api::AlignAxis::Left, + }; + let doc = &mut self.documents[self.active_doc]; + doc.engine.align_active_layer(align_axis); + self.refresh_composite_if_needed(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } + MenuCommand::ErodeBorder => { + self.dialog_selection_value = 5.0; + self.active_dialog = ActiveDialog::SelectionOp("Erode"); + } + MenuCommand::FadeBorder => { + self.dialog_selection_value = 8.0; + self.active_dialog = ActiveDialog::SelectionOp("Fade"); + } + MenuCommand::FilterDistort(filter_id) => { + let ft = filter_id_to_type(&filter_id); + return Task::perform(async move {}, move |_| Message::FilterSelect(ft)); + } + MenuCommand::FilterNoise(filter_id) => { + let ft = filter_id_to_type(&filter_id); + return Task::perform(async move {}, move |_| Message::FilterSelect(ft)); + } + MenuCommand::FilterPixelate(filter_id) => { + let ft = filter_id_to_type(&filter_id); + return Task::perform(async move {}, move |_| Message::FilterSelect(ft)); + } + MenuCommand::FilterRender(filter_id) => { + let ft = filter_id_to_type(&filter_id); + return Task::perform(async move {}, move |_| Message::FilterSelect(ft)); + } + MenuCommand::FilterRestore(filter_id) => { + let ft = filter_id_to_type(&filter_id); + return Task::perform(async move {}, move |_| Message::FilterSelect(ft)); + } + MenuCommand::FilterStylize(filter_id) => { + let ft = filter_id_to_type(&filter_id); + return Task::perform(async move {}, move |_| Message::FilterSelect(ft)); + } + MenuCommand::FilterTexture(filter_id) => { + // Textures have no FilterType variant — apply directly with + // hardcoded default params (mirrors EGUI menus.rs:561-655). + let params = default_texture_params(&filter_id); + let doc = &mut self.documents[self.active_doc]; + if !doc.engine.active_layer_is_editable() { + log::warn!("texture apply blocked for locked/group/mask layer"); + return Task::none(); + } + doc.engine.apply_filter(&filter_id, params); + doc.modified = true; + self.refresh_composite_if_needed(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } + MenuCommand::ResetPan => { + let doc = &mut self.documents[self.active_doc]; + doc.pan_offset = iced::Vector::ZERO; + } + MenuCommand::OpenDocuments => { + // Toggle a document list or focus the active doc + if !self.documents.is_empty() { + self.active_doc = 0; + } + } + MenuCommand::ResetLayout => { + self.dock = self.settings.panel_layout.restore_dock(); + } + MenuCommand::SelectTool(tool) => { + return Task::perform(async move {}, move |_| Message::ToolSelected(tool)) + } + MenuCommand::About => { + self.active_dialog = ActiveDialog::About; + } MenuCommand::TogglePane(pane) => self.dock.toggle_pane(pane), MenuCommand::SetTheme(theme) => { self.theme_state.set_preset(theme); @@ -7312,6 +8303,12 @@ impl HcieIcedApp { return iced::window::toggle_maximize(id); } } + Message::TitleSearchChanged(text) => { + self.title_search = text; + } + Message::PanelListToggle => { + self.show_panel_list = !self.show_panel_list; + } Message::WindowClose => { self.pending_document_close = None; if let Some(document_index) = @@ -7344,6 +8341,7 @@ impl HcieIcedApp { floating_panel: None, auto_hide_panel: None, welcome: false, + svg_editor: false, output: std::path::PathBuf::from(format!( "target/screenshots/iced_{timestamp}.png" )), @@ -7388,9 +8386,15 @@ impl HcieIcedApp { Message::ViewerToggle => { self.viewer_active = !self.viewer_active; if self.viewer_active && self.viewer_state.current_dir.as_os_str().is_empty() { - // Initialize to home directory on first open - let start_dir = - dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + // Initialize to last-used directory or home on first open + let start_dir = self + .settings + .viewer_last_dir + .as_ref() + .map(std::path::PathBuf::from) + .filter(|p| p.exists()) + .or_else(|| dirs::home_dir()) + .unwrap_or_else(|| std::path::PathBuf::from(".")); self.viewer_state = crate::viewer::ViewerState::new(start_dir); } if self.viewer_active { @@ -7402,6 +8406,8 @@ impl HcieIcedApp { self.viewer_state.load_preview(); } Message::ViewerNavigate(path) => { + self.settings.viewer_last_dir = Some(path.to_string_lossy().to_string()); + let _ = self.settings.save(); self.viewer_state.set_dir(path); self.viewer_state.load_preview(); } @@ -7510,6 +8516,219 @@ impl HcieIcedApp { } } + // ── SVG Editor ────────────────────────────────── + Message::SvgEditorOpen { + layer_id, + shape_idx, + } => { + let doc = &self.documents[self.active_doc]; + if let Some(shapes) = doc.engine.active_vector_shapes() { + if let Some(hcie_engine_api::VectorShape::SvgShape { svg, .. }) = + shapes.get(shape_idx) + { + match crate::panels::svg_editor::parse_editable(svg) { + Ok(editable) => { + self.svg_editor_state = + Some(crate::panels::svg_editor::SvgEditorState { + layer_id, + shape_idx, + original_svg: svg.clone(), + editable, + selected_poly: 0, + selected_node: None, + zoom: 1.0, + pan: iced::Vector::ZERO, + x_input: String::new(), + y_input: String::new(), + snap_to_grid: false, + grid_size: 0.05, + grid_input: "0.05".to_string(), + }); + self.active_dialog = ActiveDialog::SvgEditor; + } + Err(e) => { + log::warn!("SVG Editor open failed: {}", e); + } + } + } + } + } + Message::SvgEditorSelectPoly(idx) => { + if let Some(state) = &mut self.svg_editor_state { + if idx < state.editable.polygons.len() { + state.selected_poly = idx; + state.selected_node = None; + state.refresh_coordinate_inputs(); + } + } + } + Message::SvgEditorSelectNode { polygon, node } => { + if let Some(state) = &mut self.svg_editor_state { + state.select_node(polygon, node); + } + } + Message::SvgEditorClearSelection => { + if let Some(state) = &mut self.svg_editor_state { + state.selected_node = None; + state.refresh_coordinate_inputs(); + } + } + Message::SvgEditorMoveNode { + polygon, + node, + x, + y, + } => { + if let Some(state) = &mut self.svg_editor_state { + let x = state.snapped(x); + let y = state.snapped(y); + state.editable.move_node(polygon, node, x, y); + state.select_node(polygon, node); + } + self.sync_svg_editor_preview(); + } + Message::SvgEditorCanvasReleased => {} + Message::SvgEditorCanvasAddNode { polygon, edge } => { + if let Some(state) = &mut self.svg_editor_state { + state.editable.add_node(polygon, edge); + state.select_node(polygon, edge.saturating_add(1)); + } + self.sync_svg_editor_preview(); + } + Message::SvgEditorXChanged(input) => { + if let Some(state) = &mut self.svg_editor_state { + state.x_input = input; + if let Some(node_idx) = state.selected_node { + if let (Ok(x), Some(node)) = ( + state.x_input.parse::(), + state + .editable + .polygons + .get(state.selected_poly) + .and_then(|poly| poly.get(node_idx)) + .copied(), + ) { + let x = state.snapped(x); + state + .editable + .move_node(state.selected_poly, node_idx, x, node.y); + } + } + } + self.sync_svg_editor_preview(); + } + Message::SvgEditorYChanged(input) => { + if let Some(state) = &mut self.svg_editor_state { + state.y_input = input; + if let Some(node_idx) = state.selected_node { + if let (Ok(y), Some(node)) = ( + state.y_input.parse::(), + state + .editable + .polygons + .get(state.selected_poly) + .and_then(|poly| poly.get(node_idx)) + .copied(), + ) { + let y = state.snapped(y); + state + .editable + .move_node(state.selected_poly, node_idx, node.x, y); + } + } + } + self.sync_svg_editor_preview(); + } + Message::SvgEditorPan { dx, dy } => { + if let Some(state) = &mut self.svg_editor_state { + state.pan.x += dx; + state.pan.y += dy; + } + } + Message::SvgEditorZoom(delta) => { + if let Some(state) = &mut self.svg_editor_state { + state.zoom = (state.zoom * delta).clamp(0.25, 8.0); + } + } + Message::SvgEditorFit => { + if let Some(state) = &mut self.svg_editor_state { + state.zoom = 1.0; + state.pan = iced::Vector::ZERO; + } + } + Message::SvgEditorSnapChanged(enabled) => { + if let Some(state) = &mut self.svg_editor_state { + state.snap_to_grid = enabled; + } + } + Message::SvgEditorGridChanged(input) => { + if let Some(state) = &mut self.svg_editor_state { + state.grid_input = input; + if let Ok(value) = state.grid_input.parse::() { + if value.is_finite() && value > f32::EPSILON { + state.grid_size = value; + } + } + } + } + Message::SvgEditorAddNode => { + if let Some(state) = &mut self.svg_editor_state { + let poly = state.selected_poly; + let ni = state.selected_node.unwrap_or(0); + state.editable.add_node(poly, ni); + state.select_node(poly, ni + 1); + } + self.sync_svg_editor_preview(); + } + Message::SvgEditorRemoveNode => { + if let Some(state) = &mut self.svg_editor_state { + if let Some(ni) = state.selected_node { + let poly = state.selected_poly; + state.editable.remove_node(poly, ni); + state.selected_node = None; + state.refresh_coordinate_inputs(); + } + } + self.sync_svg_editor_preview(); + } + Message::SvgEditorSave => { + let new_svg = self.svg_editor_state.as_ref().map(|s| s.editable.to_svg()); + if let Some(svg) = new_svg { + let layer_id = self + .svg_editor_state + .as_ref() + .map(|s| s.layer_id) + .unwrap_or(0); + let shape_idx = self + .svg_editor_state + .as_ref() + .map(|s| s.shape_idx) + .unwrap_or(0); + let doc = &mut self.documents[self.active_doc]; + doc.engine.set_vector_shape_svg(layer_id, shape_idx, &svg); + doc.modified = true; + self.svg_editor_state = None; + self.active_dialog = ActiveDialog::None; + self.refresh_composite_if_needed(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } + } + Message::SvgEditorCancel => { + if let Some(state) = self.svg_editor_state.as_ref() { + let layer_id = state.layer_id; + let shape_idx = state.shape_idx; + let original_svg = state.original_svg.clone(); + self.documents[self.active_doc].engine.set_vector_shape_svg( + layer_id, + shape_idx, + &original_svg, + ); + self.refresh_composite_if_needed(); + } + self.svg_editor_state = None; + self.active_dialog = ActiveDialog::None; + } + Message::SidebarToggleExpanded => { self.sidebar_expanded = !self.sidebar_expanded; self.settings.panel_layout.sidebar_expanded = self.sidebar_expanded; @@ -7567,6 +8786,8 @@ impl HcieIcedApp { } if self.viewer_active { return self.update(Message::ViewerToggle); + } else if matches!(self.active_dialog, ActiveDialog::SvgEditor) { + return self.update(Message::SvgEditorCancel); } else if self.sub_tools_open || self.active_menu.is_some() { return Task::none(); } else if self.documents[self.active_doc].crop_state.active { @@ -7671,6 +8892,8 @@ impl HcieIcedApp { doc.engine.canvas_height(), colors, self.active_menu, + &self.title_search, + self.show_panel_list, ); // Toolbox — 36px strip (single column) or 72px (2 columns) on the left edge @@ -7778,6 +9001,27 @@ impl HcieIcedApp { colors, ), ActiveDialog::About => dialogs::about::view(colors), + ActiveDialog::SaveError => { + if let Some(state) = &self.save_error_state { + dialogs::save_error::view(state, colors) + } else { + text("").into() + } + } + ActiveDialog::ExpandCanvas { + paste_w, + paste_h, + canvas_w, + canvas_h, + } => dialogs::expand_canvas::view(*paste_w, *paste_h, *canvas_w, *canvas_h, colors), + ActiveDialog::SvgEditor => { + if self.svg_editor_state.is_some() { + // SVG editor overlay rendered separately below + text("").into() + } else { + text("").into() + } + } }; // Stack main content with overlays (dialog + menu dropdown + dock profile dialog). @@ -7789,6 +9033,8 @@ impl HcieIcedApp { let has_context_menu = self.canvas_context_menu.is_some(); let has_color_picker = self.color_picker_target.is_some(); let has_floating = !self.dock.floating.is_empty(); + let has_svg_editor = self.svg_editor_state.is_some() + && matches!(self.active_dialog, ActiveDialog::SvgEditor); if has_dialog || has_menu @@ -7796,6 +9042,7 @@ impl HcieIcedApp { || has_context_menu || has_color_picker || has_floating + || has_svg_editor { let mut stack = iced::widget::Stack::new().push(content); for item in &self.dock.floating { @@ -7804,6 +9051,11 @@ impl HcieIcedApp { if has_dialog { stack = stack.push(dialog_overlay); } + if has_svg_editor { + if let Some(state) = &self.svg_editor_state { + stack = stack.push(crate::panels::svg_editor::view(state, colors)); + } + } if has_dock_profile_dialog { stack = stack.push(dialogs::dock_profile::view( &self.settings.dock_profiles, diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/cli.rs b/hcie-iced-app/crates/hcie-iced-gui/src/cli.rs index 630c352..3cc95e7 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/cli.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/cli.rs @@ -19,6 +19,8 @@ pub struct ScreenshotRequest { pub auto_hide_panel: Option, /// Whether the post-close welcome surface is activated before capture. pub welcome: bool, + /// Whether a deterministic SVG editor fixture is opened before capture. + pub svg_editor: bool, /// PNG output path supplied by the caller. pub output: PathBuf, } @@ -73,6 +75,7 @@ where floating_panel: None, auto_hide_panel: None, welcome: false, + svg_editor: false, output: PathBuf::from(output), }); index += 2; @@ -87,6 +90,7 @@ where floating_panel: Some(panel.to_owned()), auto_hide_panel: None, welcome: false, + svg_editor: false, output: PathBuf::from(output), }); index += 3; @@ -101,6 +105,7 @@ where floating_panel: None, auto_hide_panel: None, welcome: false, + svg_editor: false, output: PathBuf::from(output), }); index += 3; @@ -114,6 +119,7 @@ where floating_panel: None, auto_hide_panel: None, welcome: true, + svg_editor: false, output: PathBuf::from(output), }); index += 2; @@ -128,11 +134,26 @@ where floating_panel: None, auto_hide_panel: Some(panel.to_owned()), welcome: false, + svg_editor: false, output: PathBuf::from(output), }); index += 3; continue; } + "--screenshot-svg-editor" => { + ensure_no_screenshot(&parsed)?; + let output = required_operand(&tokens, index + 1, "--screenshot-svg-editor")?; + parsed.screenshot = Some(ScreenshotRequest { + panel: None, + floating_panel: None, + auto_hide_panel: None, + welcome: false, + svg_editor: true, + output: PathBuf::from(output), + }); + index += 2; + continue; + } _ if token.starts_with('-') => { return Err(format!("unknown argument: {token}")); } @@ -173,7 +194,7 @@ fn ensure_no_screenshot(parsed: &CliArgs) -> Result<(), String> { /// Returns the command-line help shown by the binary. pub fn help_text() -> &'static str { - "Usage: hcie-iced [OPTIONS] [FILE]\n\nHCIE - Iced Edition\n\nOptions:\n -h, --help Print this help message\n --screenshot OUTPUT Capture the full viewport as PNG\n --screenshot-panel PANEL OUTPUT\n Capture a named panel as PNG\n --screenshot-welcome OUTPUT Capture the post-close welcome surface\n\nArguments:\n FILE Image file to open on startup" + "Usage: hcie-iced [OPTIONS] [FILE]\n\nHCIE - Iced Edition\n\nOptions:\n -h, --help Print this help message\n --screenshot OUTPUT Capture the full viewport as PNG\n --screenshot-panel PANEL OUTPUT\n Capture a named panel as PNG\n --screenshot-welcome OUTPUT Capture the post-close welcome surface\n --screenshot-svg-editor OUTPUT\n Capture the SVG node editor fixture\n\nArguments:\n FILE Image file to open on startup" } #[cfg(test)] @@ -192,6 +213,7 @@ mod tests { floating_panel: None, auto_hide_panel: None, welcome: false, + svg_editor: false, output: PathBuf::from("full.png"), }) ); @@ -203,6 +225,14 @@ mod tests { assert_eq!(args.screenshot.unwrap().panel.as_deref(), Some("Geometry")); } + #[test] + fn parses_svg_editor_screenshot() { + let args = parse_args(["--screenshot-svg-editor", "svg-editor.png"]).unwrap(); + let screenshot = args.screenshot.expect("screenshot request"); + assert!(screenshot.svg_editor); + assert_eq!(screenshot.output, PathBuf::from("svg-editor.png")); + } + #[test] fn rejects_duplicate_positional_files() { assert!(parse_args(["one.png", "two.png"]).is_err()); diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs b/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs index 61e21d9..3daca7e 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs @@ -591,6 +591,14 @@ impl canvas::Program for ColorWheel { return (canvas::event::Status::Captured, Some(self.message(color))); } } + canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => { + if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) { + return ( + canvas::event::Status::Captured, + Some(Message::BgColorChanged(color)), + ); + } + } canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => { if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) { return (canvas::event::Status::Captured, Some(self.message(color))); diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/confirm.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/confirm.rs index 8f70f06..18de50f 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/confirm.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/confirm.rs @@ -1,4 +1,4 @@ -//! Confirm dialog — save/discard/cancel confirmation. +//! Confirm dialog — save/discard/cancel confirmation and selection operation parameter dialog. use crate::app::Message; use crate::theme::ThemeColors; @@ -28,18 +28,37 @@ pub fn close_confirm_view(doc_name: &str, colors: ThemeColors) -> Element<'stati super::modal(dialog, colors) } -/// Build a selection operation dialog (grow/shrink/feather). +/// Return the slider range and unit suffix for a selection operation. +fn selection_op_range(op: &str) -> (f32, f32, &'static str) { + match op { + "Erode" => (1.0, 50.0, "px"), + "Fade" => (0.0, 100.0, "radius"), + "Feather" => (0.0, 250.0, "px"), + "Border" => (1.0, 200.0, "px"), + _ => (1.0, 100.0, "px"), + } +} + +/// Build a selection operation dialog (grow/shrink/feather/erode/fade). pub fn selection_op_view( op_name: &str, value: f32, - min: f32, - max: f32, + _min: f32, + _max: f32, colors: ThemeColors, ) -> Element<'static, Message> { let op_owned = op_name.to_string(); - let value_slider = plain_slider("Value", value, min..=max, 1.0, "", 0, |v| { - Message::SelectionOpValue(v) - }); + let (range_min, range_max, unit) = selection_op_range(op_name); + let step = if unit == "radius" { 0.5 } else { 1.0 }; + let value_slider = plain_slider( + &format!("Value ({})", unit), + value, + range_min..=range_max, + step, + "", + 0, + |v| Message::SelectionOpValue(v), + ); let apply_btn = super::primary_btn("Apply", Message::SelectionOpApply, colors); let cancel_btn = super::secondary_btn("Cancel", Message::DialogClose, colors); diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/expand_canvas.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/expand_canvas.rs new file mode 100644 index 0000000..a3378d0 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/expand_canvas.rs @@ -0,0 +1,36 @@ +//! Canvas Expand dialog — shown when pasted content exceeds the canvas size. +//! +//! Offers to expand the canvas to fit the pasted content, or cancel. + +use crate::app::Message; +use crate::dialogs::{modal, primary_btn, secondary_btn}; +use crate::theme::ThemeColors; +use iced::widget::{column, row, text, Space}; +use iced::Element; + +/// Build the canvas expand dialog view. +pub fn view(paste_w: u32, paste_h: u32, canvas_w: u32, canvas_h: u32, colors: ThemeColors) -> Element<'static, Message> { + let body = column![ + text("Pasted Image Exceeds Canvas") + .size(16), + Space::with_height(12), + text(format!( + "Pasted image ({}x{}) is larger than the canvas ({}x{}). \ + Expand the document to fit the pasted content?", + paste_w, paste_h, canvas_w, canvas_h + )) + .size(12), + Space::with_height(16), + row![ + primary_btn("Expand", Message::DialogCloseSave, colors), + Space::with_width(8), + secondary_btn("Cancel", Message::DialogClose, colors), + ] + .spacing(0), + ] + .spacing(0) + .padding(24) + .max_width(420); + + modal(body, colors) +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/mod.rs index 7735361..f7ff5fc 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/mod.rs @@ -11,7 +11,9 @@ pub mod canvas_size; pub mod confirm; pub mod dock_profile; pub mod image_size; +pub mod expand_canvas; pub mod new_image; +pub mod save_error; /// Wraps dialog content in an opaque themed card over a translucent full-screen scrim. /// diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/save_error.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/save_error.rs new file mode 100644 index 0000000..799cc07 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/save_error.rs @@ -0,0 +1,64 @@ +//! Save Error dialog — shown when a file save/export operation fails. +//! +//! Displays the error, the target path, and offers a suggested alternative path. +//! The user can retry, save to the suggested path, or close. + +use crate::app::Message; +use crate::dialogs::{modal, primary_btn, secondary_btn}; +use crate::theme::ThemeColors; +use iced::widget::{column, container, row, text, Space}; +use iced::{Element, Length}; +use std::path::PathBuf; + +/// State for the save error dialog. +#[derive(Debug, Clone)] +pub struct SaveErrorState { + pub operation: String, + pub path: PathBuf, + pub error: String, + pub suggestion: PathBuf, +} + +/// Build the save error dialog view. +pub fn view(state: &SaveErrorState, colors: ThemeColors) -> Element<'static, Message> { + let body = column![ + text(format!("{} — Failed", state.operation)) + .size(15), + Space::with_height(12), + text("Target path:") + .size(11), + container( + text(state.path.to_string_lossy().to_string()) + .size(11) + ) + .padding(4) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.08))), + border: iced::Border::default().rounded(2), + ..Default::default() + }) + .width(Length::Fill), + Space::with_height(8), + text(format!("Error: {}", state.error)) + .size(11), + Space::with_height(4), + text("Suggested action:") + .size(11), + text(state.suggestion.to_string_lossy().to_string()) + .size(11), + Space::with_height(12), + row![ + primary_btn("Save to Suggested Path", Message::DialogCloseSave, colors), + Space::with_width(8), + secondary_btn("Try Again", Message::DialogCloseDiscard, colors), + Space::with_width(8), + secondary_btn("Close", Message::DialogClose, colors), + ] + .spacing(0), + ] + .spacing(0) + .padding(24) + .max_width(420); + + modal(body, colors) +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/sizing.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/sizing.rs index 923c1cb..576c373 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/sizing.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/sizing.rs @@ -79,10 +79,14 @@ pub fn panel_size_policy(panel: PaneType) -> PaneSizePolicy { width: axis(160.0, 300.0, f32::INFINITY, 35), height: axis(120.0, 300.0, f32::INFINITY, 35), }, - PaneType::Brushes | PaneType::Filters => PaneSizePolicy { + PaneType::Brushes => PaneSizePolicy { width: axis(160.0, 300.0, f32::INFINITY, 30), height: axis(120.0, 280.0, f32::INFINITY, 30), }, + PaneType::Filters => PaneSizePolicy { + width: axis(240.0, 400.0, f32::INFINITY, 30), + height: axis(140.0, 320.0, f32::INFINITY, 30), + }, PaneType::Properties | PaneType::Geometry | PaneType::ToolSettings => PaneSizePolicy { width: axis(160.0, 280.0, f32::INFINITY, 25), height: axis(120.0, 280.0, f32::INFINITY, 25), diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs index 8e8bcb2..e6879e7 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs @@ -169,6 +169,8 @@ pub fn panel_body<'a>( doc.engine.active_layer_id(), &doc.engine, colors, + &doc.layer_thumbnails, + doc.thumb_gen, ) } PaneType::History => { @@ -250,7 +252,7 @@ pub fn panel_body<'a>( app.script_is_running, colors, ), - PaneType::AiChat => crate::panels::ai_chat_panel::view(&app.ai_chat_state, colors), + PaneType::AiChat => crate::panels::ai_chat_panel::view(app, colors), PaneType::AiScript => crate::panels::ai_script_panel::view(app), PaneType::LayerDetails => { let doc = &app.documents[app.active_doc]; @@ -261,16 +263,16 @@ pub fn panel_body<'a>( ) } PaneType::Geometry => { - let shapes = app.documents[app.active_doc] - .engine - .active_vector_shapes() - .unwrap_or_default(); + let doc = &app.documents[app.active_doc]; + let shapes = doc.engine.active_vector_shapes().unwrap_or_default(); + let active_id = doc.engine.active_layer_id(); crate::panels::geometry::view( shapes, colors, - app.documents[app.active_doc].selected_vector_shape, + doc.selected_vector_shape, app.bool_shape_a, app.bool_shape_b, + active_id, ) } PaneType::ToolSettings => { diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/main.rs b/hcie-iced-app/crates/hcie-iced-gui/src/main.rs index cac8196..bfd87b7 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/main.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/main.rs @@ -14,6 +14,7 @@ mod dock; mod i18n; mod io; mod panels; +mod plugins; mod raster; mod screenshot; mod script; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/ai_chat_panel.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/ai_chat_panel.rs index b361da5..e49c44e 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/ai_chat_panel.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/ai_chat_panel.rs @@ -1,18 +1,152 @@ -//! AI Chat panel — AI assistant with streaming SSE responses, tool execution, and DSL scripts. +//! AI Assistant panel with two sub-tabs: ComfyUI pipeline viewer and AI Chat. //! -//! Full implementation of the AI chat interface including provider selection, -//! streaming response display, chat bubbles, reasoning toggle, and canvas context toggle. -//! Uses ThemeColors for consistent styling. +//! Mirrors the egui AI Assistant panel which has both ComfyUI and AI Chat tabs. +//! The active tab is controlled by `app.ai_assistant_tab` (0 = ComfyUI, 1 = AI Chat). +//! Uses ThemeColors for consistent styling across both tabs. -use crate::ai_chat::{AiChatState, ChatMessage}; +use crate::ai_chat::{AiChatState, ChatMessage, ComfyStatus, ComfyUiState}; use crate::app::Message; use crate::panels::styles; use crate::theme::ThemeColors; use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, text_input}; use iced::{Element, Length}; +use crate::app::HcieIcedApp; -/// Build the AI chat panel with full streaming UI. -pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Message> { +/// Build the AI Assistant panel with tab switcher and sub-tab content. +pub fn view(app: &HcieIcedApp, colors: ThemeColors) -> Element<'static, Message> { + let tab = app.ai_assistant_tab; + + // ── Tab bar ── + let comfy_tab_btn = tab_button("ComfyUI", tab == 0, 0, colors); + let chat_tab_btn = tab_button("AI Chat", tab == 1, 1, colors); + let tab_bar = row![comfy_tab_btn, chat_tab_btn] + .spacing(0) + .align_y(iced::Alignment::Center); + + // ── Content area ── + let content: Element<'static, Message> = match tab { + 0 => comfy_tab_content(&app.comfy_state, colors), + _ => chat_tab_content(&app.ai_chat_state, colors), + }; + + let panel = column![ + tab_bar, + horizontal_rule(1), + content, + ] + .spacing(0) + .padding(8); + + container(panel) + .width(Length::Fill) + .height(Length::Fill) + .style(move |_theme| styles::panel_background(colors)) + .into() +} + +/// A tab button that highlights when active. +fn tab_button( + label: &str, + active: bool, + tab_index: usize, + colors: ThemeColors, +) -> Element<'static, Message> { + let label_owned = label.to_string(); + let bg = if active { colors.accent } else { colors.bg_active }; + let fg = if active { iced::Color::WHITE } else { colors.text_secondary }; + + button(text(label_owned).size(11)) + .on_press(Message::AiAssistantTabChanged(tab_index)) + .padding([4, 12]) + .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { + let (background, text_color) = match status { + iced::widget::button::Status::Active => (bg, fg), + iced::widget::button::Status::Hovered => { + (colors.accent_hover, iced::Color::WHITE) + } + iced::widget::button::Status::Pressed => { + (colors.accent, iced::Color::WHITE) + } + iced::widget::button::Status::Disabled => { + (colors.bg_app, colors.text_disabled) + } + }; + iced::widget::button::Style { + background: Some(iced::Background::Color(background)), + text_color, + border: iced::Border::default().rounded(4), + ..Default::default() + } + }) + .into() +} + +/// ComfyUI tab content — placeholder pipeline viewer. +fn comfy_tab_content(state: &ComfyUiState, colors: ThemeColors) -> Element<'static, Message> { + let status_label = match state.status { + ComfyStatus::Unknown => "Unknown", + ComfyStatus::Running => "Running", + ComfyStatus::Error => "Error", + }; + let status_color = match state.status { + ComfyStatus::Unknown => colors.text_disabled, + ComfyStatus::Running => colors.accent_green, + ComfyStatus::Error => iced::Color::from_rgb(0.95, 0.3, 0.3), + }; + + let url_input = text_input("http://127.0.0.1:8188", &state.url) + .on_input(|s| Message::ComfyUiUrlChanged(s)) + .size(10) + .width(Length::Fill); + + let check_btn = button(text("Check Status").size(10)) + .on_press(Message::ComfyUiCheckStatus) + .padding([4, 10]); + + let status_row = row![ + text("Status:").size(10).color(colors.text_secondary), + text(status_label).size(10).color(status_color), + ] + .spacing(6) + .align_y(iced::Alignment::Center); + + column![ + text("ComfyUI Pipeline").size(13).color(colors.text_primary), + text("").size(4), + text("Connect to a running ComfyUI instance to run") + .size(10) + .color(colors.text_disabled), + text("stable diffusion pipelines and AI image generation.") + .size(10) + .color(colors.text_disabled), + text("").size(8), + row![ + text("Server URL:").size(10).color(colors.text_secondary), + url_input, + check_btn, + ] + .spacing(4) + .align_y(iced::Alignment::Center), + status_row, + text("").size(8), + container( + text("Pipeline playback and node graph editing will be added in a future update.") + .size(10) + .color(colors.text_disabled), + ) + .padding(8) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(colors.bg_hover)), + border: iced::Border::default().rounded(6), + ..Default::default() + }), + ] + .spacing(4) + .into() +} + +/// AI Chat tab content — streaming SSE chat interface. +fn chat_tab_content(state: &AiChatState, colors: ThemeColors) -> Element<'static, Message> { // ── Provider selector row ── let provider_label = text("Provider:").size(10).color(colors.text_secondary); let ollama_btn = make_provider_btn( @@ -90,11 +224,14 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag .spacing(4) .align_y(iced::Alignment::Center); + let hint = text("Model discovery is unavailable; enter the exact provider model ID.") + .size(9) + .color(colors.text_disabled); + // ── Message list ── let mut msg_list = column![].spacing(6); if state.messages.is_empty() && !state.is_streaming { - // Welcome message let welcome = container( column![ text("AI Creative Assistant").size(13).color(colors.accent), @@ -122,13 +259,11 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag msg_list = msg_list.push(welcome); } else { - // Render chat history for msg in &state.messages { let bubble = render_message_bubble(msg, colors); msg_list = msg_list.push(bubble); } - // Render streaming reasoning if enabled if state.config.reasoning_enabled && !state.current_reasoning.is_empty() { let reasoning_bubble = container( column![ @@ -156,7 +291,6 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag msg_list = msg_list.push(reasoning_bubble); } - // Render streaming response if in progress if state.is_streaming && !state.current_response.is_empty() { let streaming_bubble = container( text(state.current_response.clone()) @@ -178,7 +312,6 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag msg_list = msg_list.push(streaming_bubble); } - // Streaming indicator if state.is_streaming { let indicator = row![ text("...").size(10).color(colors.text_disabled), @@ -219,14 +352,10 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag .spacing(4) .align_y(iced::Alignment::Center); - // ── Assemble panel ── - let panel = column![ - text("AI Assistant").size(13).color(colors.text_primary), + column![ provider_row, config_row, - text("Model discovery is unavailable; enter the exact provider model ID.") - .size(9) - .color(colors.text_disabled), + hint, toggles_row, horizontal_rule(1), scrollable(msg_list).height(Length::Fill), @@ -234,16 +363,9 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag input_row, ] .spacing(4) - .padding(8); - - container(panel) - .width(Length::Fill) - .height(Length::Fill) - .style(move |_theme| styles::panel_background(colors)) - .into() + .into() } -/// Build an action button whose disabled, hover, pressed, and focus-border states are explicit. fn action_button( label: &'static str, message: Option, @@ -279,7 +401,6 @@ fn action_button( .into() } -/// Render a single chat message as a styled bubble. fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'static, Message> { if msg.is_reasoning { return container( @@ -302,7 +423,6 @@ fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'sta } match msg.role.as_str() { "user" => { - // User bubble — right-aligned, blue tint let label = text("You").size(9).color(colors.accent); let content = text(msg.content.clone()) .size(11) @@ -321,7 +441,6 @@ fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'sta row![iced::widget::Space::with_width(Length::Fill), bubble].into() } "tool" => { - // Tool result — monospace pill let content = text(msg.content.clone()).size(10).color(colors.accent); container(content) .padding([4, 8]) @@ -333,7 +452,6 @@ fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'sta .into() } _ => { - // Assistant bubble — left-aligned, slate tint let label = text("AI").size(9).color(colors.accent_green); let content = text(msg.content.clone()) .size(11) @@ -354,7 +472,6 @@ fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'sta } } -/// Create a provider selection button with active state highlighting. fn make_provider_btn( label: &str, is_active: bool, diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/geometry.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/geometry.rs index 78d73fa..dcf9809 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/geometry.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/geometry.rs @@ -12,30 +12,36 @@ use crate::panels::typography::{BODY, SECTION}; use crate::theme::ThemeColors; use hcie_engine_api::{LineCap, VectorBooleanOp, VectorShape}; use iced::widget::{ - button, checkbox, column, container, pick_list, row, scrollable, slider, text, Space, + button, checkbox, column, container, pick_list, row, scrollable, slider, text, text_input, + Space, }; use iced::{Element, Length}; /// Human-readable label for a shape at a given index. fn shape_label(shapes: &[VectorShape], i: usize) -> String { - match &shapes[i] { - VectorShape::Line { .. } => format!("Line #{}", i + 1), - VectorShape::Rect { .. } => format!("Rect #{}", i + 1), - VectorShape::Circle { .. } => format!("Circle #{}", i + 1), - VectorShape::Arrow { .. } => format!("Arrow #{}", i + 1), - VectorShape::Star { .. } => format!("Star #{}", i + 1), - VectorShape::Polygon { .. } => format!("Polygon #{}", i + 1), - VectorShape::Rhombus { .. } => format!("Rhombus #{}", i + 1), - VectorShape::Cylinder { .. } => format!("Cylinder #{}", i + 1), - VectorShape::Heart { .. } => format!("Heart #{}", i + 1), - VectorShape::Bubble { .. } => format!("Bubble #{}", i + 1), - VectorShape::Gear { .. } => format!("Gear #{}", i + 1), - VectorShape::Cross { .. } => format!("Cross #{}", i + 1), - VectorShape::Crescent { .. } => format!("Crescent #{}", i + 1), - VectorShape::Bolt { .. } => format!("Bolt #{}", i + 1), - VectorShape::Arrow4 { .. } => format!("4-way Arrow #{}", i + 1), - VectorShape::SvgShape { kind, .. } => format!("{} #{}", kind, i + 1), - VectorShape::FreePath { pts, .. } => format!("Path ({} pts) #{}", pts.len(), i + 1), + let name = shapes[i].name(); + if name.is_empty() { + match &shapes[i] { + VectorShape::Line { .. } => format!("Line #{}", i + 1), + VectorShape::Rect { .. } => format!("Rect #{}", i + 1), + VectorShape::Circle { .. } => format!("Circle #{}", i + 1), + VectorShape::Arrow { .. } => format!("Arrow #{}", i + 1), + VectorShape::Star { .. } => format!("Star #{}", i + 1), + VectorShape::Polygon { .. } => format!("Polygon #{}", i + 1), + VectorShape::Rhombus { .. } => format!("Rhombus #{}", i + 1), + VectorShape::Cylinder { .. } => format!("Cylinder #{}", i + 1), + VectorShape::Heart { .. } => format!("Heart #{}", i + 1), + VectorShape::Bubble { .. } => format!("Bubble #{}", i + 1), + VectorShape::Gear { .. } => format!("Gear #{}", i + 1), + VectorShape::Cross { .. } => format!("Cross #{}", i + 1), + VectorShape::Crescent { .. } => format!("Crescent #{}", i + 1), + VectorShape::Bolt { .. } => format!("Bolt #{}", i + 1), + VectorShape::Arrow4 { .. } => format!("4-way Arrow #{}", i + 1), + VectorShape::SvgShape { kind, .. } => format!("{} #{}", kind, i + 1), + VectorShape::FreePath { pts, .. } => format!("Path ({} pts) #{}", pts.len(), i + 1), + } + } else { + name } } @@ -80,6 +86,7 @@ pub fn view( selected_shape: Option, bool_shape_a: Option, bool_shape_b: Option, + layer_id: u64, ) -> Element<'static, Message> { let has_shapes = !shapes.is_empty(); let shape_count = shapes.len(); @@ -90,25 +97,46 @@ pub fn view( content = content.push(section_header("SHAPES".to_string(), colors)); if has_shapes { - // Delete button in header row (right-aligned) + // Delete and reorder buttons in header row (right-aligned) if selected_shape.is_some() { + let sel = selected_shape.unwrap(); + // The list is shown front-to-back: the last rendered shape (top of Z) + // appears at the top of the list, and the first rendered shape (back) + // appears at the bottom. Therefore: + // ▲ (MoveUp) moves the selected shape toward the front (higher index). + // ▼ (MoveDown) moves it toward the back (lower index). + let can_up = sel + 1 < shape_count; + let can_down = sel > 0; + let mut header = row![Space::with_width(Length::Fill)]; + if can_up { + header = header.push( + button(text("▲").size(BODY)) + .on_press(Message::VectorShapeMoveUp) + .padding([2, 6]), + ); + } + if can_down { + header = header.push( + button(text("▼").size(BODY)) + .on_press(Message::VectorShapeMoveDown) + .padding([2, 6]), + ); + } + header = header.push( + button(text("X").size(BODY)) + .on_press(Message::VectorShapeDelete) + .padding([2, 6]), + ); content = content.push( - container( - row![ - Space::with_width(Length::Fill), - button(text("X").size(BODY)) - .on_press(Message::VectorShapeDelete) - .padding([2, 6]), - ] - .width(Length::Fill), - ) - .padding([2, 0]), + container(header.spacing(4).width(Length::Fill)).padding([2, 0]), ); } - // Shape list + // Shape list — rendered front-to-back so the topmost (last index) + // shape appears at the top of the list. let mut shape_list = column![].spacing(1); - for i in 0..shape_count { + for display_i in 0..shape_count { + let i = shape_count - 1 - display_i; let label = shape_label(&shapes, i); let is_selected = selected_shape == Some(i); let c = colors; @@ -261,6 +289,41 @@ pub fn view( content = content.push(section_header(format!("GEOMETRY [#{}]", idx + 1), colors)); content = content.push(Space::with_height(4)); + // Shape name (editable) + let shape_name = shapes[idx].name(); + content = content.push( + row![ + text("Name").size(BODY).width(42), + text_input("Shape name", &shape_name) + .on_input(move |v| Message::VectorShapeRename(idx, v)) + .size(BODY) + .width(Length::Fill), + ] + .spacing(4) + .align_y(iced::Alignment::Center), + ); + content = content.push(Space::with_height(4)); + + // "Edit SVG" button — only for SvgShape variants (EGUI parity) + if matches!(&shapes[idx], VectorShape::SvgShape { .. }) { + let c = colors; + content = content.push( + button(text("Edit SVG").size(BODY)) + .on_press(Message::SvgEditorOpen { + layer_id, + shape_idx: idx, + }) + .padding([4, 8]) + .style(move |_theme, _status| iced::widget::button::Style { + background: Some(iced::Background::Color(c.accent)), + text_color: iced::Color::WHITE, + border: iced::Border::default().rounded(3), + ..Default::default() + }), + ); + content = content.push(Space::with_height(4)); + } + let (x1, y1, x2, y2) = shapes[idx].bounds(); let w = (x2 - x1).abs(); let h = (y2 - y1).abs(); diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/layers.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/layers.rs index 7ab7c6b..ecd1936 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/layers.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/layers.rs @@ -6,10 +6,16 @@ //! 2. Scrollable layer list — each row shows indent, collapse arrow, visibility, thumbnail, //! name, FX icon, and type badge. Layers with effects show expandable effect sub-items. //! 3. Bottom toolbar — add layer, add group, flatten, delete, move up/down +//! +//! Z-order convention: +//! The engine renders layers in array order: the first layer is at the back, the +//! last layer is at the front. The UI list mirrors this order, so the topmost +//! (front-most) layer appears at the bottom of the list. Therefore "Move Up" in +//! the toolbar moves the layer toward the end of the array / front of the canvas, +//! and "Move Down" moves it toward the start / back. use crate::app::Message; use crate::panels::styles; -use crate::panels::thumbnails; use crate::panels::typography::BODY; use crate::theme::ThemeColors; use crate::widgets::plain_slider::plain_slider; @@ -80,6 +86,9 @@ struct LayerEntry<'a> { } /// Build a flat rendering order from the layer list, respecting group hierarchy. +/// Layers are returned in visual order: the top/front-most layer first and the +/// bottom/back-most layer last. This matches Photoshop-style panels where the +/// layer you see on top of the canvas appears at the top of the list. fn build_tree<'a>(layers: &'a [LayerInfo]) -> Vec> { let mut children_of: std::collections::HashMap, Vec<&LayerInfo>> = std::collections::HashMap::new(); @@ -95,6 +104,8 @@ fn build_tree<'a>(layers: &'a [LayerInfo]) -> Vec> { result: &mut Vec>, ) { if let Some(children) = children_of.get(&parent_id) { + // children is already in reverse-engine order (front-most first); walk + // it as-is so the list renders front-to-back. for info in children { let is_group = info.layer_type == LayerType::Group; result.push(LayerEntry { @@ -123,10 +134,11 @@ fn flat_btn_style(colors: ThemeColors) -> iced::widget::button::Style { } } -/// Build the thumbnail element for a layer row. +/// Build the thumbnail element for a layer row from the cache or as a fallback icon. fn layer_thumbnail<'a>( entry: &LayerEntry<'a>, - engine: &hcie_engine_api::Engine, + thumb_cache: &std::collections::HashMap)>, + _thumb_gen: u64, colors: ThemeColors, ) -> Element<'a, Message> { if entry.is_group { @@ -137,15 +149,12 @@ fn layer_thumbnail<'a>( .into(); } - let info = entry.info; - let thumb = engine - .get_layer_pixels(info.id) - .filter(|px| !px.is_empty() && info.width > 0 && info.height > 0) - .map(|px| thumbnails::generate_thumbnail(&px, info.width, info.height, 48)); - - if let Some(pixels) = thumb { - let handle = iced::widget::image::Handle::from_rgba(48, 36, pixels); - let img = iced::widget::Image::new(handle).width(48).height(36); + if let Some((tw, th, pixels)) = thumb_cache.get(&entry.info.id) { + let (tw32, th32) = (*tw as u32, *th as u32); + let handle = iced::widget::image::Handle::from_rgba(tw32, th32, pixels.clone()); + let img = iced::widget::Image::new(handle) + .width(Length::Fixed(*tw as f32)) + .height(Length::Fixed(*th as f32)); return container(img) .width(50) .height(38) @@ -159,7 +168,7 @@ fn layer_thumbnail<'a>( } // Fallback: type-based icon - let (icon_char, icon_color) = match info.layer_type { + let (icon_char, icon_color) = match entry.info.layer_type { LayerType::Vector => ("V", iced::Color::from_rgb(0.4, 0.8, 0.4)), LayerType::Text => ("T", iced::Color::from_rgb(0.4, 0.6, 1.0)), LayerType::Mask => ("M", iced::Color::from_rgb(0.8, 0.4, 0.8)), @@ -191,6 +200,8 @@ pub fn view<'a>( active_layer_id: u64, engine: &hcie_engine_api::Engine, colors: ThemeColors, + thumb_cache: &'a std::collections::HashMap)>, + thumb_gen: u64, ) -> Element<'a, Message> { let entries = build_tree(layers); @@ -315,7 +326,7 @@ pub fn view<'a>( .style(move |_theme, _status| flat_btn_style(colors)); // Thumbnail - let thumb_elem = layer_thumbnail(entry, engine, colors); + let thumb_elem = layer_thumbnail(entry, thumb_cache, thumb_gen, colors); // Layer name let c = if is_active { diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs index 84c9d53..fe0bd21 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs @@ -16,6 +16,7 @@ use crate::app::Message; use crate::dock::state::{DockState, PaneType}; use crate::panels::typography; use crate::theme::ThemeColors; +use hcie_engine_api::Tool; use iced::widget::{column, container, horizontal_rule, mouse_area, row, text, Space}; use iced::{Element, Length}; @@ -26,25 +27,47 @@ use iced::{Element, Length}; /// carry their path so reordering the recent list cannot change the selected file. #[derive(Debug, Clone, PartialEq, Eq)] pub enum MenuCommand { + // File NewDocument, OpenFile, Save, SaveAs, OpenRecent(String), ClearRecent, + ImportFile, + ImportSvg, + ImportBrushes, + Export, + // Edit Undo, Redo, Copy, Cut, ClearPixels, Paste, + PasteSpecial, + Fill, + Stroke, + FreeTransform, + Preferences, + // Image CanvasSize, ImageSize, + Rotate90CW, + Rotate90CCW, + Rotate180, + FlipHorizontal, + FlipVertical, + ImageCrop, + InvertNegative, + // Layer AddLayer, DeleteLayer, LayerStyles, MergeDown, Flatten, + AlignLayer(String), + // Select SelectAll, Deselect, InvertSelection, @@ -57,16 +80,37 @@ pub enum MenuCommand { SaveSelection, LoadSelection, ToggleQuickMask, + ErodeBorder, + FadeBorder, + // Filter (distort, noise, pixelate, render, restore, stylize, texture) + FilterDistort(String), + FilterNoise(String), + FilterPixelate(String), + FilterRender(String), + FilterRestore(String), + FilterStylize(String), + FilterTexture(String), + // View ZoomIn, ZoomOut, FitArea, ActualPixels, + ResetPan, + ViewerToggle, + // Window + TogglePane(PaneType), + OpenDocuments, + ResetLayout, + // Tool + SelectTool(Tool), + // Theme + SetTheme(crate::theme::ThemePreset), + // Misc SelectCropTool, GaussianBlur, Mosaic, UnsharpMask, - TogglePane(PaneType), - SetTheme(crate::theme::ThemePreset), + About, } /// Describes one rendered menu row and its optional semantic operation. @@ -242,12 +286,17 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuItem::new("Open & Place..."), MenuItem::submenu("Open More"), MenuItem::separator(), + MenuItem::command("Import...", MenuCommand::ImportFile), + MenuItem::command("Import SVG...", MenuCommand::ImportSvg), + MenuItem::command("Import Brushes...", MenuCommand::ImportBrushes), + MenuItem::separator(), MenuItem::new("Share"), MenuItem::separator(), MenuItem::command_with_shortcut("Save", "Ctrl+S", MenuCommand::Save), MenuItem::command("Save as PSD", MenuCommand::SaveAs), MenuItem::submenu("Save More"), MenuItem::submenu("Export as"), + MenuItem::command("Export...", MenuCommand::Export), MenuItem::with_shortcut("Print...", "Ctrl+P"), MenuItem::separator(), MenuItem::new("Export Layers..."), @@ -292,13 +341,15 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuItem::command_with_shortcut("Paste", "Ctrl+V", MenuCommand::Paste), MenuItem::command("Clear", MenuCommand::ClearPixels), MenuItem::separator(), - MenuItem::with_shortcut("Fill...", "Shift+F5"), - MenuItem::new("Stroke..."), + MenuItem::command_with_shortcut("Paste Special", "Shift+Ctrl+V", MenuCommand::PasteSpecial), + MenuItem::separator(), + MenuItem::command_with_shortcut("Fill...", "Shift+F5", MenuCommand::Fill), + MenuItem::command("Stroke...", MenuCommand::Stroke), MenuItem::separator(), MenuItem::new("Content-Aware Scale"), MenuItem::new("Puppet Warp"), MenuItem::new("Perspective Warp"), - MenuItem::with_shortcut("Free Transform", "Alt+Ctrl+T"), + MenuItem::command_with_shortcut("Free Transform", "Alt+Ctrl+T", MenuCommand::FreeTransform), MenuItem::submenu("Transform"), MenuItem::new("Auto-Align"), MenuItem::new("Auto-Blend"), @@ -308,10 +359,40 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuItem::separator(), MenuItem::submenu("Define New"), MenuItem::new("Preset Manager..."), - MenuItem::with_shortcut("Preferences...", "Ctrl+K"), + MenuItem::command_with_shortcut("Preferences...", "Ctrl+K", MenuCommand::Preferences), MenuItem::new("Local Storage..."), ], }, + // ── Tools menu (Photopea-style) ── + MenuDef { + label: "Tools".to_string(), + items: vec![ + MenuItem::command_with_shortcut("Eyedropper", "I", MenuCommand::SelectTool(Tool::Eyedropper)), + MenuItem::command_with_shortcut("Pencil / Pen", "P", MenuCommand::SelectTool(Tool::Pen)), + MenuItem::command_with_shortcut("Art Brush", "B", MenuCommand::SelectTool(Tool::Brush)), + MenuItem::command_with_shortcut("Eraser", "E", MenuCommand::SelectTool(Tool::Eraser)), + MenuItem::command("Spray", MenuCommand::SelectTool(Tool::Spray)), + MenuItem::command_with_shortcut("Flood Fill", "F", MenuCommand::SelectTool(Tool::FloodFill)), + MenuItem::command_with_shortcut("Magic Wand", "W", MenuCommand::SelectTool(Tool::MagicWand)), + MenuItem::command_with_shortcut("Rect Selection", "M", MenuCommand::SelectTool(Tool::Select)), + MenuItem::command_with_shortcut("Lasso", "L", MenuCommand::SelectTool(Tool::Lasso)), + MenuItem::command("Polygon Select", MenuCommand::SelectTool(Tool::PolygonSelect)), + MenuItem::command_with_shortcut("Move", "V", MenuCommand::SelectTool(Tool::Move)), + MenuItem::command_with_shortcut("Crop", "C", MenuCommand::SelectTool(Tool::Crop)), + MenuItem::command_with_shortcut("Text", "T", MenuCommand::SelectTool(Tool::Text)), + MenuItem::command_with_shortcut("Gradient", "G", MenuCommand::SelectTool(Tool::Gradient)), + MenuItem::separator(), + MenuItem::disabled("Retouch"), + MenuItem::command("Red-eye Removal", MenuCommand::SelectTool(Tool::RedEyeRemoval)), + MenuItem::command("Spot Removal Patch", MenuCommand::SelectTool(Tool::SpotRemoval)), + MenuItem::command("Smart Patch", MenuCommand::SelectTool(Tool::SmartPatch)), + MenuItem::separator(), + MenuItem::disabled("AI"), + MenuItem::command("AI Object Removal", MenuCommand::SelectTool(Tool::AiObjectRemoval)), + MenuItem::command("Smart Select", MenuCommand::SelectTool(Tool::SmartSelect)), + MenuItem::command("Vision Select", MenuCommand::SelectTool(Tool::VisionSelect)), + ], + }, // ── Image menu (Photopea-style) ── MenuDef { label: "Image".to_string(), @@ -337,10 +418,16 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuCommand::ImageSize, ), MenuItem::submenu("Transform"), - MenuItem::new("Crop"), + MenuItem::command("Rotate 90° CW", MenuCommand::Rotate90CW), + MenuItem::command("Rotate 90° CCW", MenuCommand::Rotate90CCW), + MenuItem::command("Rotate 180°", MenuCommand::Rotate180), + MenuItem::command("Flip Horizontal", MenuCommand::FlipHorizontal), + MenuItem::command("Flip Vertical", MenuCommand::FlipVertical), + MenuItem::command("Crop", MenuCommand::ImageCrop), MenuItem::with_shortcut("Trim...", "Ctrl+."), MenuItem::new("Reveal All"), MenuItem::separator(), + MenuItem::command("Invert / Negative", MenuCommand::InvertNegative), MenuItem::new("Duplicate"), MenuItem::new("Apply Image..."), MenuItem::separator(), @@ -372,6 +459,12 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuItem::separator(), MenuItem::with_shortcut("Group Layers", "Ctrl+G"), MenuItem::submenu("Arrange"), + MenuItem::command("Align Left", MenuCommand::AlignLayer("left".to_string())), + MenuItem::command("Align Center", MenuCommand::AlignLayer("center".to_string())), + MenuItem::command("Align Right", MenuCommand::AlignLayer("right".to_string())), + MenuItem::command("Align Top", MenuCommand::AlignLayer("top".to_string())), + MenuItem::command("Align Middle", MenuCommand::AlignLayer("middle".to_string())), + MenuItem::command("Align Bottom", MenuCommand::AlignLayer("bottom".to_string())), MenuItem::submenu("Combine Shapes"), MenuItem::submenu("Animation"), MenuItem::separator(), @@ -416,6 +509,8 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { ), MenuItem::command("Border...", MenuCommand::BorderSelection), MenuItem::command("Smooth...", MenuCommand::SmoothSelection), + MenuItem::command("Erode Border", MenuCommand::ErodeBorder), + MenuItem::command("Fade Border", MenuCommand::FadeBorder), MenuItem::separator(), MenuItem::command( "New Selection", @@ -459,17 +554,92 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuItem::new("Liquify..."), MenuItem::new("Vanishing Point..."), MenuItem::separator(), - MenuItem::submenu("3D"), - MenuItem::submenu("Blur"), - MenuItem::submenu("Blur Gallery"), - MenuItem::submenu("Distort"), - MenuItem::submenu("Noise"), - MenuItem::submenu("Pixelate"), - MenuItem::submenu("Render"), - MenuItem::submenu("Sharpen"), - MenuItem::submenu("Stylize"), - MenuItem::submenu("Other"), - MenuItem::submenu("Fourier"), + // ── Blur ── + MenuItem::disabled("Blur"), + MenuItem::command("Box Blur", MenuCommand::FilterDistort("box_blur".into())), + MenuItem::command("Gaussian Blur", MenuCommand::FilterDistort("gaussian_blur".into())), + MenuItem::command("Motion Blur", MenuCommand::FilterDistort("motion_blur".into())), + MenuItem::command("Unsharp Mask", MenuCommand::FilterRestore("unsharp_mask".into())), + MenuItem::separator(), + // ── Distort ── + MenuItem::disabled("Distort"), + MenuItem::command("Pinch / Punch", MenuCommand::FilterDistort("pinch".into())), + MenuItem::command("Twirl", MenuCommand::FilterDistort("twirl".into())), + MenuItem::separator(), + // ── Noise ── + MenuItem::disabled("Noise"), + MenuItem::command("Add Noise", MenuCommand::FilterNoise("add_noise".into())), + MenuItem::command("Median Filter", MenuCommand::FilterRestore("median".into())), + MenuItem::command("Dehaze", MenuCommand::FilterRestore("dehaze".into())), + MenuItem::command("Noise Pattern", MenuCommand::FilterNoise("noise_pattern".into())), + MenuItem::separator(), + // ── Pixelate ── + MenuItem::disabled("Pixelate"), + MenuItem::command("Crystallize", MenuCommand::FilterPixelate("crystallize".into())), + MenuItem::command("Mosaic", MenuCommand::FilterPixelate("mosaic".into())), + MenuItem::separator(), + // ── Render ── + MenuItem::disabled("Render"), + MenuItem::command("Clouds", MenuCommand::FilterRender("clouds".into())), + MenuItem::separator(), + // ── Procedural Textures (flat-grouped; ICED menus are single-level) ── + MenuItem::disabled("Procedural Textures"), + MenuItem::disabled("Natural"), + MenuItem::command("Clouds Texture", MenuCommand::FilterTexture("texture_clouds".into())), + MenuItem::command("Grass", MenuCommand::FilterTexture("texture_grass".into())), + MenuItem::command("Sand", MenuCommand::FilterTexture("texture_sand".into())), + MenuItem::command("Dirt", MenuCommand::FilterTexture("texture_dirt".into())), + MenuItem::command("Water", MenuCommand::FilterTexture("texture_water".into())), + MenuItem::command("Rain", MenuCommand::FilterTexture("texture_rain".into())), + MenuItem::command("Stone", MenuCommand::FilterTexture("texture_stone".into())), + MenuItem::separator(), + MenuItem::disabled("Architecture"), + MenuItem::command("Bricks", MenuCommand::FilterTexture("texture_bricks".into())), + MenuItem::separator(), + MenuItem::disabled("Wood"), + MenuItem::command("Oak", MenuCommand::FilterTexture("texture_wood_oak".into())), + MenuItem::command("Pine", MenuCommand::FilterTexture("texture_wood_pine".into())), + MenuItem::command("Dark", MenuCommand::FilterTexture("texture_wood_dark".into())), + MenuItem::separator(), + MenuItem::disabled("Metal"), + MenuItem::command("Steel", MenuCommand::FilterTexture("texture_metal_steel".into())), + MenuItem::command("Gold", MenuCommand::FilterTexture("texture_metal_gold".into())), + MenuItem::command("Copper", MenuCommand::FilterTexture("texture_metal_copper".into())), + MenuItem::separator(), + MenuItem::disabled("Material"), + MenuItem::command("Linen", MenuCommand::FilterTexture("texture_linen".into())), + MenuItem::command("Glass", MenuCommand::FilterTexture("texture_glass".into())), + MenuItem::separator(), + MenuItem::disabled("Effect"), + MenuItem::command("Sky Replacement", MenuCommand::FilterTexture("sky_replacement".into())), + MenuItem::separator(), + // ── Sharpen / Restore ── + MenuItem::disabled("Sharpen / Restore"), + MenuItem::command("Sharpen", MenuCommand::FilterRestore("sharpen".into())), + MenuItem::command("Smart Patch", MenuCommand::FilterRestore("smart_patch".into())), + MenuItem::separator(), + // ── Stylize ── + MenuItem::disabled("Stylize"), + MenuItem::command("Emboss", MenuCommand::FilterStylize("emboss".into())), + MenuItem::command("Find Edges", MenuCommand::FilterStylize("find_edges".into())), + MenuItem::command("Oil Paint", MenuCommand::FilterStylize("oil_paint".into())), + MenuItem::command("Posterize", MenuCommand::FilterStylize("posterize".into())), + MenuItem::command("Threshold", MenuCommand::FilterStylize("threshold".into())), + MenuItem::separator(), + // ── Color / Light ── + MenuItem::disabled("Color / Light"), + MenuItem::command("Levels", MenuCommand::FilterRender("levels".into())), + MenuItem::command("Vibrance", MenuCommand::FilterRender("vibrance".into())), + MenuItem::command("Exposure", MenuCommand::FilterRender("exposure".into())), + MenuItem::command("Gamma Correction", MenuCommand::FilterRender("gamma".into())), + MenuItem::command("Black & White", MenuCommand::FilterStylize("bw".into())), + MenuItem::command("Gradient Map", MenuCommand::FilterRender("gradient_map".into())), + MenuItem::command("Channel Mixer", MenuCommand::FilterRender("channel_mixer".into())), + MenuItem::command("Selective Color", MenuCommand::FilterRender("selective_color".into())), + MenuItem::separator(), + // ── Fourier ── + MenuItem::disabled("Fourier"), + MenuItem::command("FFT Filter", MenuCommand::FilterRender("fft".into())), ], }, // ── View menu (Photopea-style) ── @@ -484,6 +654,7 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { "Ctrl+1", MenuCommand::ActualPixels, ), + MenuItem::command("Reset Pan", MenuCommand::ResetPan), MenuItem::new("Pattern Preview"), MenuItem::separator(), MenuItem::submenu("Mode"), @@ -501,6 +672,8 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuItem::new("Guides from Layer"), MenuItem::separator(), MenuItem::new("Clear Slices"), + MenuItem::separator(), + MenuItem::command("Viewer Mode", MenuCommand::ViewerToggle), ], }, // ── Window menu (Photopea-style) ── @@ -552,6 +725,9 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { "Theme: ProLight (Nord)", MenuCommand::SetTheme(crate::theme::ThemePreset::ProLight), ), + MenuItem::separator(), + MenuItem::command("Open Documents", MenuCommand::OpenDocuments), + MenuItem::command("Reset Layout", MenuCommand::ResetLayout), ], }, // ── More menu (Photopea-style) ── @@ -581,6 +757,8 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuItem::new("Swatches"), MenuItem::new("Tool Presets"), MenuItem::new("Vector Info"), + MenuItem::separator(), + MenuItem::command("About", MenuCommand::About), ], }, ] @@ -616,7 +794,7 @@ pub fn dropdown_overlay( let enabled = item.enabled; // Check if this is a Window menu item that should show a checkmark - let is_window_panel = menu_idx == 7 && item_idx <= 10; + let is_window_panel = menu_idx == 8 && item_idx <= 10; let is_checked = if is_window_panel { let pane_type = match item_idx { 0 => Some(PaneType::Layers), @@ -932,8 +1110,12 @@ mod tests { let expected = vec![ "File/New...", "File/Open...", + "File/Import...", + "File/Import SVG...", + "File/Import Brushes...", "File/Save", "File/Save as PSD", + "File/Export...", "Edit/Undo / Redo", "Edit/Step Forward", "Edit/Step Backward", @@ -941,11 +1123,49 @@ mod tests { "Edit/Copy", "Edit/Paste", "Edit/Clear", + "Edit/Paste Special", + "Edit/Fill...", + "Edit/Stroke...", + "Edit/Free Transform", + "Edit/Preferences...", + "Tools/Eyedropper", + "Tools/Pencil / Pen", + "Tools/Art Brush", + "Tools/Eraser", + "Tools/Spray", + "Tools/Flood Fill", + "Tools/Magic Wand", + "Tools/Rect Selection", + "Tools/Lasso", + "Tools/Polygon Select", + "Tools/Move", + "Tools/Crop", + "Tools/Text", + "Tools/Gradient", + "Tools/Red-eye Removal", + "Tools/Spot Removal Patch", + "Tools/Smart Patch", + "Tools/AI Object Removal", + "Tools/Smart Select", + "Tools/Vision Select", "Image/Canvas Size...", "Image/Image Size...", + "Image/Rotate 90° CW", + "Image/Rotate 90° CCW", + "Image/Rotate 180°", + "Image/Flip Horizontal", + "Image/Flip Vertical", + "Image/Crop", + "Image/Invert / Negative", "Layer/New", "Layer/Delete", "Layer/Layer Style", + "Layer/Align Left", + "Layer/Align Center", + "Layer/Align Right", + "Layer/Align Top", + "Layer/Align Middle", + "Layer/Align Bottom", "Layer/Merge Down", "Layer/Flatten Image", "Select/All", @@ -956,6 +1176,8 @@ mod tests { "Select/Feather...", "Select/Border...", "Select/Smooth...", + "Select/Erode Border", + "Select/Fade Border", "Select/New Selection", "Select/Add to Selection", "Select/Subtract from Selection", @@ -963,10 +1185,58 @@ mod tests { "Select/Quick Mask Mode", "Select/Load Selection", "Select/Save Selection", + "Filter/Box Blur", + "Filter/Gaussian Blur", + "Filter/Motion Blur", + "Filter/Unsharp Mask", + "Filter/Pinch / Punch", + "Filter/Twirl", + "Filter/Add Noise", + "Filter/Median Filter", + "Filter/Dehaze", + "Filter/Noise Pattern", + "Filter/Crystallize", + "Filter/Mosaic", + "Filter/Clouds", + "Filter/Clouds Texture", + "Filter/Grass", + "Filter/Sand", + "Filter/Dirt", + "Filter/Water", + "Filter/Rain", + "Filter/Stone", + "Filter/Bricks", + "Filter/Oak", + "Filter/Pine", + "Filter/Dark", + "Filter/Steel", + "Filter/Gold", + "Filter/Copper", + "Filter/Linen", + "Filter/Glass", + "Filter/Sky Replacement", + "Filter/Sharpen", + "Filter/Smart Patch", + "Filter/Emboss", + "Filter/Find Edges", + "Filter/Oil Paint", + "Filter/Posterize", + "Filter/Threshold", + "Filter/Levels", + "Filter/Vibrance", + "Filter/Exposure", + "Filter/Gamma Correction", + "Filter/Black & White", + "Filter/Gradient Map", + "Filter/Channel Mixer", + "Filter/Selective Color", + "Filter/FFT Filter", "View/Zoom In", "View/Zoom Out", "View/Fit The Area", "View/Pixel to Pixel", + "View/Reset Pan", + "View/Viewer Mode", "Window/Layers", "Window/History", "Window/Brushes & Tips", @@ -984,6 +1254,9 @@ mod tests { "Window/Theme: Amoled (Dracula)", "Window/Theme: PhotoshopLight (Solarized)", "Window/Theme: ProLight (Nord)", + "Window/Open Documents", + "Window/Reset Layout", + "More/About", ]; assert_eq!(actual, expected); diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/mod.rs index c9655a8..f6fe423 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/mod.rs @@ -16,6 +16,7 @@ pub mod properties; pub mod script_panel; pub mod status_bar; pub mod styles; +pub mod svg_editor; pub mod text_editor; pub mod thumbnails; pub mod title_bar; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs index f17acde..9c3c03b 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs @@ -17,6 +17,10 @@ use iced::{Element, Length}; /// Human-readable label for a shape at a given index. fn shape_label(shapes: &[VectorShape], i: usize) -> String { + let name = shapes[i].name(); + if !name.is_empty() { + return name; + } match &shapes[i] { VectorShape::Line { .. } => format!("Line #{}", i + 1), VectorShape::Rect { .. } => format!("Rect #{}", i + 1), @@ -132,7 +136,13 @@ pub fn view<'a>( } Tool::Select => selection_properties(selection_rect, colors), Tool::VectorSelect => { - vector_select_properties(selected_vector_shape, vector_shapes, vector_angle, colors) + vector_select_properties( + selected_vector_shape, + vector_shapes, + vector_angle, + active_layer_id, + colors, + ) } Tool::VectorRect | Tool::VectorCircle @@ -251,7 +261,8 @@ fn vector_select_properties<'a>( selected: Option, shapes: &[VectorShape], vector_angle: f32, - _colors: ThemeColors, + active_layer_id: u64, + colors: ThemeColors, ) -> Element<'a, Message> { match selected.and_then(|idx| shapes.get(idx).map(|s| (idx, s))) { Some((idx, shape)) => { @@ -283,10 +294,32 @@ fn vector_select_properties<'a>( VectorShape::FreePath { .. } => "Free Path".into(), }; + // "Edit SVG" button — only for SvgShape variants (EGUI parity) + let edit_svg_btn: Element<'a, Message> = + if matches!(shape, VectorShape::SvgShape { .. }) { + let c = colors; + button(text("Edit SVG").size(BODY)) + .on_press(Message::SvgEditorOpen { + layer_id: active_layer_id, + shape_idx: idx, + }) + .padding([4, 8]) + .style(move |_theme, _status| iced::widget::button::Style { + background: Some(iced::Background::Color(c.accent)), + text_color: iced::Color::WHITE, + border: iced::Border::default().rounded(3), + ..Default::default() + }) + .into() + } else { + text("").into() + }; + column![ text("Shape Properties").size(SECTION), row![text("Shape").size(BODY), text(label).size(BODY)].spacing(4), row![text("Type").size(BODY), text(shape_type).size(BODY)].spacing(4), + edit_svg_btn, plain_slider( "Stroke", stroke, diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/svg_editor.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/svg_editor.rs new file mode 100644 index 0000000..6d42ef5 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/svg_editor.rs @@ -0,0 +1,878 @@ +//! Interactive SVG node editor for simple polygon/path SVG shapes. +//! +//! The editor renders the parsed `SvgEditable` model on an iced Canvas. Nodes +//! can be selected, dragged, inserted at edge midpoints, removed, or edited by +//! numeric coordinates. Each interaction emits an application message so the +//! authoritative editor state remains in `HcieIcedApp` and the main canvas can +//! receive a live SVG preview. + +use crate::app::Message; +use crate::theme::ThemeColors; +use iced::mouse; +use iced::widget::canvas::{self, Canvas, Frame, Path, Program, Stroke}; +use iced::widget::{button, checkbox, column, container, row, scrollable, text, text_input, Space}; +use iced::{Color, Element, Length, Point, Rectangle, Size, Vector}; +use usvg::tiny_skia_path::PathSegment; + +/// Parse an SVG into the engine API's editable polygon model. +/// +/// **Purpose:** Keeps native line nodes intact when possible and provides a visual-editing fallback +/// for curves and arcs, which `SvgEditable::from_svg` intentionally rejects. **Logic & Workflow:** +/// First uses the public engine parser; on failure, `usvg` normalizes the document and curved +/// segments are sampled into line nodes. **Arguments:** `svg` is the complete SVG source. +/// **Returns:** An editable model or a descriptive parse error. **Side Effects / Dependencies:** +/// Parses XML through `usvg`; it performs no file or engine mutation. +pub fn parse_editable(svg: &str) -> Result { + hcie_engine_api::SvgEditable::from_svg(svg).or_else(|_| flatten_svg(svg)) +} + +/// Flatten all rendered SVG paths into editable polygons. +/// +/// **Arguments:** `svg` is complete SVG source. **Returns:** A polygon model preserving the source +/// view box. **Logic & Workflow:** Traverses normalized `usvg` paths and delegates curve sampling to +/// `flatten_group`. **Side Effects / Dependencies:** Uses `usvg` parsing only. +fn flatten_svg(svg: &str) -> Result { + let tree = usvg::Tree::from_data(svg.as_bytes(), &usvg::Options::default()) + .map_err(|error| error.to_string())?; + let mut polygons = Vec::new(); + flatten_group(tree.root(), &mut polygons); + if polygons.is_empty() { + return Err("SVG does not contain an editable path".to_string()); + } + Ok(hcie_engine_api::SvgEditable { + view_box: parse_view_box(svg).unwrap_or((0.0, 0.0, 1.0, 1.0)), + polygons, + }) +} + +/// Recursively collect line and sampled curve nodes from a normalized SVG group. +/// +/// **Arguments:** `group` is the current `usvg` group and `output` receives editable paths. +/// **Returns:** Nothing. **Logic & Workflow:** Move/line endpoints are retained; quadratic and cubic +/// segments are sampled at a screen-independent density; close commands flush one polygon. +/// **Side Effects / Dependencies:** Appends to `output`. +fn flatten_group(group: &usvg::Group, output: &mut Vec>) { + for child in group.children() { + match child { + usvg::Node::Path(path) => { + let mut current = Vec::new(); + let mut cursor = Point::ORIGIN; + for segment in path.data().segments() { + match segment { + PathSegment::MoveTo(point) => { + flush_path(&mut current, output); + cursor = Point::new(point.x, point.y); + current.push(hcie_engine_api::SvgNode { + x: point.x, + y: point.y, + }); + } + PathSegment::LineTo(point) => { + cursor = Point::new(point.x, point.y); + current.push(hcie_engine_api::SvgNode { + x: point.x, + y: point.y, + }); + } + PathSegment::QuadTo(control, point) => { + let start = cursor; + for step in 1..=12 { + let t = step as f32 / 12.0; + let inverse = 1.0 - t; + current.push(hcie_engine_api::SvgNode { + x: inverse * inverse * start.x + + 2.0 * inverse * t * control.x + + t * t * point.x, + y: inverse * inverse * start.y + + 2.0 * inverse * t * control.y + + t * t * point.y, + }); + } + cursor = Point::new(point.x, point.y); + } + PathSegment::CubicTo(first, second, point) => { + let start = cursor; + for step in 1..=16 { + let t = step as f32 / 16.0; + let inverse = 1.0 - t; + current.push(hcie_engine_api::SvgNode { + x: inverse.powi(3) * start.x + + 3.0 * inverse * inverse * t * first.x + + 3.0 * inverse * t * t * second.x + + t.powi(3) * point.x, + y: inverse.powi(3) * start.y + + 3.0 * inverse * inverse * t * first.y + + 3.0 * inverse * t * t * second.y + + t.powi(3) * point.y, + }); + } + cursor = Point::new(point.x, point.y); + } + PathSegment::Close => flush_path(&mut current, output), + } + } + flush_path(&mut current, output); + } + usvg::Node::Group(child_group) => flatten_group(child_group, output), + _ => {} + } + } +} + +/// Move a completed subpath into the editable output when it can form a visible polygon. +/// +/// **Arguments:** `current` is the active node buffer and `output` receives valid paths. +/// **Returns:** Nothing. **Side Effects:** Clears `current` and may append to `output`. +fn flush_path( + current: &mut Vec, + output: &mut Vec>, +) { + if current.len() > 2 { + output.push(std::mem::take(current)); + } else { + current.clear(); + } +} + +/// Extract a case-insensitive SVG `viewBox` attribute. +/// +/// **Arguments:** `svg` is raw XML. **Returns:** `(min_x, min_y, width, height)` when valid. +/// **Side Effects:** None. +fn parse_view_box(svg: &str) -> Option<(f32, f32, f32, f32)> { + let lower = svg.to_ascii_lowercase(); + let start = lower.find("viewbox")?; + let suffix = &svg[start + "viewbox".len()..]; + let equals = suffix.find('=')?; + let value = suffix[equals + 1..].trim_start(); + let quote = value.chars().next()?; + if quote != '"' && quote != '\'' { + return None; + } + let value = &value[quote.len_utf8()..]; + let end = value.find(quote)?; + let numbers: Vec = value[..end] + .split(|character: char| character.is_ascii_whitespace() || character == ',') + .filter(|part| !part.is_empty()) + .filter_map(|part| part.parse().ok()) + .collect(); + (numbers.len() == 4).then(|| (numbers[0], numbers[1], numbers[2], numbers[3])) +} + +/// Runtime state for the SVG editor dialog. +pub struct SvgEditorState { + /// Engine layer containing the edited vector shape. + pub layer_id: u64, + /// Index of the SVG shape within the vector layer. + pub shape_idx: usize, + /// SVG text captured before the editing session began. + pub original_svg: String, + /// Mutable polygon/node model used by the visual editor. + pub editable: hcie_engine_api::SvgEditable, + /// Currently selected polygon. + pub selected_poly: usize, + /// Currently selected node in `selected_poly`. + pub selected_node: Option, + /// View zoom factor. + pub zoom: f32, + /// View pan offset in dialog-canvas pixels. + pub pan: Vector, + /// Editable X-coordinate text, retained while the user types. + pub x_input: String, + /// Editable Y-coordinate text, retained while the user types. + pub y_input: String, + /// Whether moved nodes are quantized to `grid_size` SVG units. + pub snap_to_grid: bool, + /// Grid interval in SVG view-box units. + pub grid_size: f32, + /// Editable grid interval text, retained while the user types. + pub grid_input: String, +} + +impl SvgEditorState { + /// Select a node and synchronize the persistent coordinate inputs. + /// + /// **Arguments:** `polygon` and `node` identify an existing model point. + /// **Returns:** Nothing. **Side Effects:** Updates selection and text-entry state. + pub fn select_node(&mut self, polygon: usize, node: usize) { + let Some(value) = self + .editable + .polygons + .get(polygon) + .and_then(|path| path.get(node)) + .copied() + else { + return; + }; + self.selected_poly = polygon; + self.selected_node = Some(node); + self.x_input = format_coordinate(value.x); + self.y_input = format_coordinate(value.y); + } + + /// Quantize a coordinate when grid snapping is enabled. + /// + /// **Arguments:** `value` is an SVG view-box coordinate. **Returns:** The original or nearest + /// grid-aligned coordinate. **Side Effects:** None. + pub fn snapped(&self, value: f32) -> f32 { + if self.snap_to_grid && self.grid_size.is_finite() && self.grid_size > f32::EPSILON { + (value / self.grid_size).round() * self.grid_size + } else { + value + } + } + + /// Refresh coordinate inputs from the currently selected model node. + /// + /// **Arguments/Returns:** None. **Side Effects:** Replaces X/Y text with model coordinates. + pub fn refresh_coordinate_inputs(&mut self) { + let Some(node) = self.selected_node.and_then(|node| { + self.editable + .polygons + .get(self.selected_poly) + .and_then(|path| path.get(node)) + }) else { + self.x_input.clear(); + self.y_input.clear(); + return; + }; + self.x_input = format_coordinate(node.x); + self.y_input = format_coordinate(node.y); + } +} + +/// Format SVG coordinates compactly while retaining useful sub-pixel precision. +/// +/// **Arguments:** `value` is an SVG coordinate. **Returns:** A trimmed decimal string. +/// **Side Effects:** None. +fn format_coordinate(value: f32) -> String { + let formatted = format!("{value:.4}"); + formatted + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() +} + +/// Ephemeral pointer state owned by the Canvas program. +#[derive(Debug, Default, Clone, Copy)] +struct InteractionState { + dragging: Option<(usize, usize)>, + panning: bool, + last_pointer: Option, +} + +/// Canvas program that draws the SVG model and translates pointer gestures to +/// SVG coordinates. +#[derive(Debug, Clone)] +struct SvgCanvasProgram { + editable: hcie_engine_api::SvgEditable, + selected_poly: usize, + selected_node: Option, + zoom: f32, + pan: Vector, + grid_size: f32, + colors: ThemeColors, +} + +impl SvgCanvasProgram { + /// Compute the uniform viewBox-to-screen transform. + fn transform(&self, bounds: Rectangle) -> (f32, f32, f32) { + let (_, _, view_w, view_h) = self.editable.view_box; + let margin = 24.0; + let scale = ((bounds.width - margin * 2.0) / view_w.max(1.0)) + .min((bounds.height - margin * 2.0) / view_h.max(1.0)) + .max(0.01) + * self.zoom; + let draw_w = view_w * scale; + let draw_h = view_h * scale; + let ox = (bounds.width - draw_w) * 0.5 + self.pan.x; + let oy = (bounds.height - draw_h) * 0.5 + self.pan.y; + (ox, oy, scale) + } + + /// Convert an SVG coordinate into a local canvas point. + fn svg_to_screen(&self, bounds: Rectangle, x: f32, y: f32) -> Point { + let (vbx, vby, _, _) = self.editable.view_box; + let (ox, oy, scale) = self.transform(bounds); + Point::new(ox + (x - vbx) * scale, oy + (y - vby) * scale) + } + + /// Convert a local canvas point into an SVG coordinate. + fn screen_to_svg(&self, bounds: Rectangle, point: Point) -> (f32, f32) { + let (vbx, vby, _, _) = self.editable.view_box; + let (ox, oy, scale) = self.transform(bounds); + ((point.x - ox) / scale + vbx, (point.y - oy) / scale + vby) + } + + /// Find the nearest node in screen space. + fn hit_node(&self, bounds: Rectangle, point: Point) -> Option<(usize, usize)> { + let mut best = None; + for (polygon, nodes) in self.editable.polygons.iter().enumerate() { + for (node, value) in nodes.iter().enumerate() { + let distance = self.svg_to_screen(bounds, value.x, value.y).distance(point); + if distance <= 10.0 + && best + .as_ref() + .is_none_or(|(_, _, best_distance)| distance < *best_distance) + { + best = Some((polygon, node, distance)); + } + } + } + best.map(|(polygon, node, _)| (polygon, node)) + } + + /// Find an edge midpoint in screen space for insertion. + fn hit_midpoint(&self, bounds: Rectangle, point: Point) -> Option<(usize, usize)> { + let mut best = None; + for (polygon, nodes) in self.editable.polygons.iter().enumerate() { + if nodes.len() < 2 { + continue; + } + for edge in 0..nodes.len() { + let a = self.svg_to_screen(bounds, nodes[edge].x, nodes[edge].y); + let b = self.svg_to_screen( + bounds, + nodes[(edge + 1) % nodes.len()].x, + nodes[(edge + 1) % nodes.len()].y, + ); + let midpoint = Point::new((a.x + b.x) * 0.5, (a.y + b.y) * 0.5); + let distance = midpoint.distance(point); + if distance <= 9.0 + && best + .as_ref() + .is_none_or(|(_, _, best_distance)| distance < *best_distance) + { + best = Some((polygon, edge, distance)); + } + } + } + best.map(|(polygon, edge, _)| (polygon, edge)) + } +} + +impl Program for SvgCanvasProgram { + type State = InteractionState; + + fn draw( + &self, + _state: &Self::State, + renderer: &iced::Renderer, + _theme: &iced::Theme, + bounds: Rectangle, + _cursor: mouse::Cursor, + ) -> Vec { + let mut frame = Frame::new(renderer, bounds.size()); + frame.fill( + &Path::rectangle(Point::ORIGIN, bounds.size()), + self.colors.bg_app, + ); + + let (_, _, view_w, view_h) = self.editable.view_box; + let (ox, oy, scale) = self.transform(bounds); + let grid_step = (self.grid_size.max(0.0001) * scale).max(8.0); + let grid_color = Color::from_rgba(1.0, 1.0, 1.0, 0.06); + let mut x = ox.rem_euclid(grid_step); + while x < bounds.width { + frame.stroke( + &Path::line(Point::new(x, 0.0), Point::new(x, bounds.height)), + Stroke::default().with_color(grid_color).with_width(1.0), + ); + x += grid_step; + } + let mut y = oy.rem_euclid(grid_step); + while y < bounds.height { + frame.stroke( + &Path::line(Point::new(0.0, y), Point::new(bounds.width, y)), + Stroke::default().with_color(grid_color).with_width(1.0), + ); + y += grid_step; + } + + let view_box_rect = Path::rectangle( + Point::new(ox, oy), + Size::new(view_w * scale, view_h * scale), + ); + frame.stroke( + &view_box_rect, + Stroke::default() + .with_color(self.colors.border_high) + .with_width(1.0), + ); + + for (polygon_idx, polygon) in self.editable.polygons.iter().enumerate() { + if polygon.is_empty() { + continue; + } + let path = Path::new(|builder| { + let first = self.svg_to_screen(bounds, polygon[0].x, polygon[0].y); + builder.move_to(first); + for node in polygon.iter().skip(1) { + builder.line_to(self.svg_to_screen(bounds, node.x, node.y)); + } + if polygon.len() > 2 { + builder.close(); + } + }); + let fill = if polygon_idx == self.selected_poly { + Color::from_rgba( + self.colors.accent.r, + self.colors.accent.g, + self.colors.accent.b, + 0.24, + ) + } else { + Color::from_rgba( + self.colors.text_secondary.r, + self.colors.text_secondary.g, + self.colors.text_secondary.b, + 0.10, + ) + }; + frame.fill(&path, fill); + frame.stroke( + &path, + Stroke::default() + .with_color(if polygon_idx == self.selected_poly { + self.colors.accent + } else { + self.colors.text_secondary + }) + .with_width(1.5), + ); + + for edge in 0..polygon.len() { + if polygon.len() < 2 { + break; + } + let a = self.svg_to_screen(bounds, polygon[edge].x, polygon[edge].y); + let b = self.svg_to_screen( + bounds, + polygon[(edge + 1) % polygon.len()].x, + polygon[(edge + 1) % polygon.len()].y, + ); + let midpoint = Point::new((a.x + b.x) * 0.5, (a.y + b.y) * 0.5); + frame.fill( + &Path::circle(midpoint, 3.5), + Color::from_rgb(0.55, 0.55, 0.55), + ); + } + + for (node_idx, node) in polygon.iter().enumerate() { + let point = self.svg_to_screen(bounds, node.x, node.y); + let selected = + polygon_idx == self.selected_poly && self.selected_node == Some(node_idx); + frame.fill( + &Path::circle(point, if selected { 6.0 } else { 5.0 }), + if selected { + self.colors.accent + } else { + Color::WHITE + }, + ); + frame.stroke( + &Path::circle(point, if selected { 6.0 } else { 5.0 }), + Stroke::default().with_color(Color::BLACK).with_width(1.5), + ); + } + } + + vec![frame.into_geometry()] + } + + fn update( + &self, + state: &mut Self::State, + event: canvas::Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> (canvas::event::Status, Option) { + let position = cursor.position_in(bounds); + match event { + canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + let Some(position) = position else { + return (canvas::event::Status::Ignored, None); + }; + if let Some((polygon, node)) = self.hit_node(bounds, position) { + state.dragging = Some((polygon, node)); + return ( + canvas::event::Status::Captured, + Some(Message::SvgEditorSelectNode { polygon, node }), + ); + } + if let Some((polygon, edge)) = self.hit_midpoint(bounds, position) { + return ( + canvas::event::Status::Captured, + Some(Message::SvgEditorCanvasAddNode { polygon, edge }), + ); + } + ( + canvas::event::Status::Captured, + Some(Message::SvgEditorClearSelection), + ) + } + canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)) => { + state.panning = true; + state.last_pointer = position; + (canvas::event::Status::Captured, None) + } + canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => { + let Some(position) = position else { + return (canvas::event::Status::Ignored, None); + }; + if let Some((polygon, node)) = state.dragging { + let (x, y) = self.screen_to_svg(bounds, position); + return ( + canvas::event::Status::Captured, + Some(Message::SvgEditorMoveNode { + polygon, + node, + x, + y, + }), + ); + } + if state.panning { + let previous = state.last_pointer.replace(position).unwrap_or(position); + return ( + canvas::event::Status::Captured, + Some(Message::SvgEditorPan { + dx: position.x - previous.x, + dy: position.y - previous.y, + }), + ); + } + (canvas::event::Status::Ignored, None) + } + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + state.dragging = None; + ( + canvas::event::Status::Captured, + Some(Message::SvgEditorCanvasReleased), + ) + } + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)) => { + state.panning = false; + state.last_pointer = None; + (canvas::event::Status::Captured, None) + } + canvas::Event::Mouse(mouse::Event::WheelScrolled { delta }) => { + let amount = match delta { + mouse::ScrollDelta::Lines { y, .. } => y, + mouse::ScrollDelta::Pixels { y, .. } => y / 100.0, + }; + if amount.abs() > f32::EPSILON { + let factor = if amount > 0.0 { 1.1 } else { 1.0 / 1.1 }; + return ( + canvas::event::Status::Captured, + Some(Message::SvgEditorZoom(factor)), + ); + } + (canvas::event::Status::Ignored, None) + } + _ => (canvas::event::Status::Ignored, None), + } + } + + /// Show precise interaction cursors over nodes, insertion handles, and the canvas surface. + /// + /// **Arguments:** Runtime state, canvas bounds, and cursor location. **Returns:** The cursor + /// icon representing the available interaction. **Side Effects:** None. + fn mouse_interaction( + &self, + state: &Self::State, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> mouse::Interaction { + if state.dragging.is_some() { + return mouse::Interaction::Grabbing; + } + if state.panning { + return mouse::Interaction::Grabbing; + } + let Some(position) = cursor.position_in(bounds) else { + return mouse::Interaction::default(); + }; + if self.hit_node(bounds, position).is_some() { + mouse::Interaction::Grab + } else if self.hit_midpoint(bounds, position).is_some() { + mouse::Interaction::Crosshair + } else { + mouse::Interaction::default() + } + } +} + +/// Build the visual SVG editor dialog. +pub fn view(state: &SvgEditorState, colors: ThemeColors) -> Element<'static, Message> { + let poly_count = state.editable.polygons.len(); + if poly_count == 0 { + return container(text("No polygons to edit").size(12)) + .padding(24) + .into(); + } + let selected_poly = state.selected_poly.min(poly_count - 1); + let selected_node = state + .selected_node + .filter(|idx| *idx < state.editable.polygons[selected_poly].len()); + let selected_coords = selected_node.and_then(|idx| { + state.editable.polygons[selected_poly] + .get(idx) + .map(|node| (node.x, node.y)) + }); + let canvas = Canvas::new(SvgCanvasProgram { + editable: state.editable.clone(), + selected_poly, + selected_node, + zoom: state.zoom, + pan: state.pan, + grid_size: state.grid_size, + colors, + }) + .width(Length::Fill) + .height(Length::Fill); + + let mut polygons = column![text("ELEMENTS").size(10)].spacing(3); + for index in 0..poly_count { + let label = format!( + "Path {} · {} nodes", + index + 1, + state.editable.polygons[index].len() + ); + polygons = polygons.push( + button(text(label).size(10)) + .on_press(Message::SvgEditorSelectPoly(index)) + .width(Length::Fill) + .padding([3, 6]) + .style(move |_theme, _status| iced::widget::button::Style { + background: Some(iced::Background::Color(if index == selected_poly { + colors.accent + } else { + colors.bg_hover + })), + text_color: if index == selected_poly { + Color::WHITE + } else { + colors.text_primary + }, + border: iced::Border::default().rounded(3), + ..Default::default() + }), + ); + } + polygons = polygons.push(Space::with_height(8)); + polygons = polygons.push(text("NODES").size(10)); + for (index, node) in state.editable.polygons[selected_poly].iter().enumerate() { + let selected = selected_node == Some(index); + let label = format!( + "{:02} {:>8} {:>8}", + index + 1, + format_coordinate(node.x), + format_coordinate(node.y) + ); + polygons = polygons.push( + button(text(label).size(10)) + .on_press(Message::SvgEditorSelectNode { + polygon: selected_poly, + node: index, + }) + .width(Length::Fill) + .padding([3, 6]) + .style(move |_theme, status| iced::widget::button::Style { + background: Some(iced::Background::Color(if selected { + colors.accent + } else if matches!(status, iced::widget::button::Status::Hovered) { + colors.bg_hover + } else { + colors.bg_panel + })), + text_color: if selected { + Color::WHITE + } else { + colors.text_primary + }, + border: iced::Border::default().rounded(3), + ..Default::default() + }), + ); + } + let path_list = container(scrollable(polygons).height(Length::Fill)) + .width(220) + .height(Length::Fill) + .padding(8) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(colors.bg_app)), + border: iced::Border::default().color(colors.border_high).width(1), + ..Default::default() + }); + + let coordinates: Element<'static, Message> = if selected_coords.is_some() { + row![ + text("NODE").size(10), + text(format!("#{}", selected_node.unwrap_or(0) + 1)).size(10), + text("X").size(10), + text_input("X", &state.x_input) + .on_input(Message::SvgEditorXChanged) + .width(96), + text("Y").size(10), + text_input("Y", &state.y_input) + .on_input(Message::SvgEditorYChanged) + .width(96), + button(text("Delete node").size(10)) + .on_press(Message::SvgEditorRemoveNode) + .padding([4, 8]), + ] + .spacing(4) + .align_y(iced::Alignment::Center) + .into() + } else { + row![text("Select and drag a white node, or click a gray edge handle to insert.").size(10)] + .into() + }; + + let controls = row![ + button(text("Insert after").size(10)) + .on_press(Message::SvgEditorAddNode) + .padding([4, 8]), + button(text("Fit").size(10)) + .on_press(Message::SvgEditorFit) + .padding([4, 8]), + button(text("-").size(10)) + .on_press(Message::SvgEditorZoom(1.0 / 1.2)) + .padding([4, 8]), + button(text("+").size(10)) + .on_press(Message::SvgEditorZoom(1.2)) + .padding([4, 8]), + text(format!("{:.0}%", state.zoom * 100.0)).size(10), + checkbox("Snap", state.snap_to_grid).on_toggle(Message::SvgEditorSnapChanged), + text("Grid").size(10), + text_input("Grid", &state.grid_input) + .on_input(Message::SvgEditorGridChanged) + .width(64), + Space::with_width(Length::Fill), + button(text("Save").size(11)) + .on_press(Message::SvgEditorSave) + .padding([6, 14]), + button(text("Cancel").size(11)) + .on_press(Message::SvgEditorCancel) + .padding([6, 14]), + ] + .spacing(4) + .align_y(iced::Alignment::Center); + + let content = column![ + row![ + column![ + text("SVG Node Editor").size(16), + text("Direct path editing with live document preview").size(10), + ], + Space::with_width(Length::Fill), + text("Wheel: zoom · Middle-drag: pan").size(10), + ] + .align_y(iced::Alignment::Center), + row![ + path_list, + container(canvas) + .width(Length::Fill) + .height(Length::Fill) + .style(move |_theme| iced::widget::container::Style { + border: iced::Border::default().color(colors.border_high).width(1), + ..Default::default() + }) + ] + .spacing(8) + .height(Length::Fill), + coordinates, + controls, + ] + .spacing(8) + .padding(12) + .width(920) + .height(680); + + let dialog = container(content).style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(colors.bg_panel)), + border: iced::Border::default() + .rounded(6) + .color(colors.border_high) + .width(1), + shadow: iced::Shadow { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.45), + offset: Vector::new(0.0, 5.0), + blur_radius: 16.0, + }, + ..Default::default() + }); + + container(dialog) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .padding(20) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(Color::from_rgba( + 0.0, 0.0, 0.0, 0.58, + ))), + ..Default::default() + }) + .into() +} + +#[cfg(test)] +mod tests { + use super::{parse_editable, SvgEditorState}; + use iced::Vector; + + /// Build a deterministic state for selection and snapping tests. + fn state() -> SvgEditorState { + let editable = parse_editable( + r#""#, + ) + .expect("triangle should parse"); + SvgEditorState { + layer_id: 1, + shape_idx: 0, + original_svg: String::new(), + editable, + selected_poly: 0, + selected_node: None, + zoom: 1.0, + pan: Vector::ZERO, + x_input: String::new(), + y_input: String::new(), + snap_to_grid: true, + grid_size: 0.5, + grid_input: "0.5".to_string(), + } + } + + #[test] + fn straight_paths_retain_author_nodes() { + let editable = state().editable; + assert_eq!(editable.view_box, (0.0, 0.0, 10.0, 10.0)); + assert_eq!(editable.polygons.len(), 1); + assert_eq!(editable.polygons[0].len(), 3); + } + + #[test] + fn curved_paths_fall_back_to_dense_visual_nodes() { + let editable = parse_editable( + r#""#, + ) + .expect("cubic path should flatten"); + assert_eq!(editable.polygons.len(), 1); + assert!(editable.polygons[0].len() >= 19); + } + + #[test] + fn selection_updates_numeric_inputs_and_snap_is_deterministic() { + let mut state = state(); + state.select_node(0, 1); + assert_eq!(state.selected_node, Some(1)); + assert_eq!(state.x_input, "10"); + assert_eq!(state.y_input, "0"); + assert_eq!(state.snapped(2.74), 2.5); + state.snap_to_grid = false; + assert_eq!(state.snapped(2.74), 2.74); + } +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/title_bar.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/title_bar.rs index 2ff1293..d9b75d0 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/title_bar.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/title_bar.rs @@ -1,42 +1,35 @@ -//! Title bar — Photopea-style unified bar with menus, doc info, and window controls. +//! Title bar — Photopea-style unified bar with menus, search, panel toggle, doc info, and window controls. //! -//! **Purpose:** Combines menu bar and title bar into a single 24px bar matching Photopea's layout. -//! Layout: [File Edit Image Layer Select Filter View Window More] [doc info] [search] [─ □ ×] +//! **Purpose:** Combines menu bar and title bar into a single bar. +//! Layout: [icon] [File Edit Image ... More] [search] [≡] [HCIE v0.1 — doc name] [─ □ ×] //! -//! **Design reference:** Photopea image editor (93 screenshots analyzed). -//! - Menu items: 12px font, tight padding -//! - Active menu: light background with dark text -//! - Window controls: standard minimize/maximize/close +//! **Design reference:** EGUI title bar + Photopea image editor. +//! - Search box for tool/action search (EGUI parity) +//! - Panel list toggle button (EGUI parity) +//! - Double-click drag area to toggle maximize (EGUI parity) +//! - App icon + version string centered use crate::app::Message; use crate::panels::typography; use crate::theme::ThemeColors; -use iced::widget::{button, container, mouse_area, row, text}; +use iced::widget::{button, container, mouse_area, row, text, text_input}; use iced::{Element, Length}; /// Photopea-style menu labels matching the reference screenshots. pub(crate) const MENU_LABELS: &[&str] = &[ - "File", "Edit", "Image", "Layer", "Select", "Filter", "View", "Window", "More", + "File", "Edit", "Tools", "Image", "Layer", "Select", "Filter", "View", "Window", "More", ]; /// Horizontal title-menu metrics shared by button layout and dropdown placement. -/// -/// **Purpose:** Keeps menu buttons and dropdown anchors in one deterministic layout model. -/// **Logic & Workflow:** Widths include the measured 12px medium label advance plus the -/// title button's 20px horizontal padding. [`menu_anchor_x`] sums these exact rendered widths. -/// **Side Effects / Dependencies:** Values depend on the title bar font and padding below. pub(crate) const MENU_BUTTON_WIDTHS: &[f32] = - &[46.0, 46.0, 56.0, 60.0, 64.0, 55.0, 51.0, 71.0, 54.0]; + &[46.0, 46.0, 56.0, 56.0, 60.0, 64.0, 55.0, 51.0, 71.0, 54.0]; pub(crate) const MENU_LEFT_INSET: f32 = 4.0; /// Unified menu/title bar height shared with dropdown placement. pub(crate) const MENU_BAR_HEIGHT: f32 = 28.0; +const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + /// Returns the clamped left edge for a menu dropdown. -/// -/// **Arguments:** `menu_index` selects a title button, `viewport_width` is the available width, -/// and `dropdown_width` is the popup width. **Returns:** A viewport-safe X coordinate. -/// **Logic & Workflow:** Sums the same explicit widths assigned to preceding buttons, then clamps -/// the result so the dropdown remains visible on narrow windows. **Side Effects:** None. pub(crate) fn menu_anchor_x(menu_index: usize, viewport_width: f32, dropdown_width: f32) -> f32 { let natural = MENU_LEFT_INSET + MENU_BUTTON_WIDTHS @@ -46,10 +39,11 @@ pub(crate) fn menu_anchor_x(menu_index: usize, viewport_width: f32, dropdown_wid natural.min((viewport_width - dropdown_width).max(0.0)) } -/// Build the Photopea-style unified title/menu bar element. +/// Build the unified title/menu bar. /// -/// Layout: [File Edit Image ... More] [doc info] [─ □ ×] -/// The left portion (before window controls) is draggable. +/// Layout: [icon] [File Edit ... More] [search] [≡] [HCIE v0.1 — doc] [─ □ ×] +/// The area between the rightmost menu button and the window controls is draggable +/// and double-clicking it toggles maximize. pub fn view<'a>( doc_name: &'a str, modified: bool, @@ -58,8 +52,27 @@ pub fn view<'a>( _canvas_h: u32, colors: ThemeColors, active_menu: Option, + title_search: &'a str, + show_panel_list: bool, ) -> Element<'a, Message> { - // ── Menu items — readable 12px medium labels with balanced padding ── + // ── App icon ── + let icon = text("H") + .size(13) + .font(iced::Font { + weight: iced::font::Weight::Bold, + ..iced::Font::default() + }) + .style(move |_theme: &iced::Theme| iced::widget::text::Style { + color: Some(colors.accent), + }); + + let icon_container = container(icon) + .width(28) + .height(MENU_BAR_HEIGHT) + .center_y(Length::Shrink) + .center_x(Length::Shrink); + + // ── Menu items ── let mut menu_items = row![].spacing(0); for (idx, &label) in MENU_LABELS.iter().enumerate() { let is_open = active_menu == Some(idx); @@ -78,7 +91,6 @@ pub fn view<'a>( .style( move |_theme: &iced::Theme, status: iced::widget::button::Status| { if is_open { - // Active menu: light background with dark text (Photopea style) iced::widget::button::Style { background: Some(iced::Background::Color(c.menu_bg)), text_color: c.menu_text, @@ -108,18 +120,56 @@ pub fn view<'a>( menu_items = menu_items.push(btn); } - // ── Document info — Photopea style: "New Project.psd *" ── - let doc_info = text(if modified { - format!("{} *", doc_name) - } else { - doc_name.to_string() - }) - .size(11) - .style(move |_theme: &iced::Theme| iced::widget::text::Style { - color: Some(colors.text_secondary), - }); + // ── Search box ── + let search_input = text_input("Search…", title_search) + .on_input(|s| Message::TitleSearchChanged(s)) + .size(10) + .width(130.0) + .padding([2, 6]); - // ── Window controls — Photopea style: ─ □ × ── + // ── Panel list toggle (hamburger) ── + let panel_list_btn = { + let c = colors; + let label = if show_panel_list { "▣" } else { "≡" }; + button(text(label).size(14)) + .on_press(Message::PanelListToggle) + .padding([2, 8]) + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| { + let (bg, tc) = if show_panel_list { + (c.menu_bg, c.menu_text) + } else { + match status { + iced::widget::button::Status::Hovered => (c.bg_hover, c.text_primary), + _ => { + (iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0), c.text_secondary) + } + } + }; + iced::widget::button::Style { + background: Some(iced::Background::Color(bg)), + text_color: tc, + border: iced::Border::default(), + ..Default::default() + } + }, + ) + }; + + // ── Document info — centered: "HCIE v{version} — New Project.psd *" ── + let title_str = format!( + "HCIE v{} — {}{}", + APP_VERSION, + doc_name, + if modified { " *" } else { "" } + ); + let doc_info = text(title_str) + .size(11) + .style(move |_theme: &iced::Theme| iced::widget::text::Style { + color: Some(colors.text_secondary), + }); + + // ── Window controls ── let c = colors; let minimize_btn = button(text("─").size(12)) @@ -190,30 +240,35 @@ pub fn view<'a>( let window_controls = row![minimize_btn, maximize_btn, close_btn].spacing(0); - // Only the empty title region is draggable. Interactive menus are siblings, - // preventing their presses from also initiating a native window drag. - let draggable_left = mouse_area( - container(row![ - iced::widget::Space::with_width(Length::Fill), - doc_info - ]) - .width(Length::Fill) - .height(MENU_BAR_HEIGHT) - .align_y(iced::Alignment::Center) - .padding([0, 4]), + // ── Draggable center area ── + // Fills remaining space between the left cluster and window controls. + let draggable_center = mouse_area( + container(doc_info) + .width(Length::Fill) + .height(MENU_BAR_HEIGHT) + .center_y(Length::Shrink), ) .on_press(Message::WindowDrag) .interaction(iced::mouse::Interaction::Grab); - // ── Full bar ── - let bar = row![ + // ── Left cluster: icon, menus, search, panel toggle ── + let left_cluster = row![ + icon_container, container(menu_items).padding(iced::Padding { - left: MENU_LEFT_INSET, + left: 0.0, right: 0.0, top: 0.0, bottom: 0.0, }), - draggable_left, + search_input, + panel_list_btn, + ] + .align_y(iced::Alignment::Center); + + // ── Full bar ── + let bar = row![ + left_cluster, + draggable_center, window_controls, ] .align_y(iced::Alignment::Center); @@ -233,13 +288,14 @@ pub fn view<'a>( mod tests { use super::menu_anchor_x; - /// Verifies natural anchors and narrow-viewport clamping use rendered button metrics. #[test] fn menu_anchors_are_deterministic_across_viewport_widths() { assert_eq!(menu_anchor_x(0, 1280.0, 260.0), 4.0); + // Index 3 = Image: File(46) + Edit(46) + Tools(56) = 148 + 4 = 152 assert_eq!(menu_anchor_x(3, 1280.0, 260.0), 152.0); - assert_eq!(menu_anchor_x(8, 1280.0, 260.0), 453.0); - assert_eq!(menu_anchor_x(8, 500.0, 260.0), 240.0); - assert_eq!(menu_anchor_x(8, 180.0, 260.0), 0.0); + // Index 9 = More: sum 9 prior widths + 4 = (46+46+56+56+60+64+55+51+71)=505+4=509 + assert_eq!(menu_anchor_x(9, 1280.0, 260.0), 509.0); + assert_eq!(menu_anchor_x(9, 500.0, 260.0), 240.0); + assert_eq!(menu_anchor_x(9, 180.0, 260.0), 0.0); } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/plugins/built_in/debug_logger.rs b/hcie-iced-app/crates/hcie-iced-gui/src/plugins/built_in/debug_logger.rs new file mode 100644 index 0000000..ba9e034 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/plugins/built_in/debug_logger.rs @@ -0,0 +1,68 @@ +//! DebugSignalLogger — a built-in plugin that records every CoreEvent. +//! +//! Logs events with timestamps and can expose them for inspection. +//! Ported from the EGUI DebugSignalLogger, adapted for the Iced architecture. + +use crate::app::{HcieIcedApp, ToolState}; +use crate::plugins::{CoreEvent, HciePlugin, PluginAction}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub struct DebugSignalLogger { + name: &'static str, + version: &'static str, + events: Vec<(u128, String)>, + max_events: usize, +} + +impl DebugSignalLogger { + pub fn new() -> Self { + Self { + name: "debug-signal-logger", + version: "1.0.0", + events: Vec::new(), + max_events: 200, + } + } + + pub fn get_events(&self) -> &[(u128, String)] { + &self.events + } +} + +impl HciePlugin for DebugSignalLogger { + fn name(&self) -> &'static str { + self.name + } + + fn version(&self) -> &'static str { + self.version + } + + fn initialize(&mut self) { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(); + self.events.push((now, "Plugin initialized".to_string())); + } + + fn on_event(&mut self, event: &CoreEvent, _state: &mut ToolState) -> Vec { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(); + + let event_str = format!("{:?}", event); + self.events.push((now, event_str)); + + if self.events.len() > self.max_events { + self.events.remove(0); + } + + Vec::new() + } + + fn ui(&mut self, _app: &HcieIcedApp) -> Vec { + Vec::new() + } +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/plugins/built_in/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/plugins/built_in/mod.rs new file mode 100644 index 0000000..e2795f8 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/plugins/built_in/mod.rs @@ -0,0 +1,3 @@ +//! Built-in plugins that ship with HCIE by default. + +pub mod debug_logger; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/plugins/integration.rs b/hcie-iced-app/crates/hcie-iced-gui/src/plugins/integration.rs new file mode 100644 index 0000000..1111a9c --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/plugins/integration.rs @@ -0,0 +1,53 @@ +//! Plugin Integration — maps Iced Message events to CoreEvent and dispatches +//! them to the PluginRegistry. Ported from the EGUI PluginHost pattern. +//! +//! **Purpose:** Converts key Iced `Message` variants into `CoreEvent` values +//! and dispatches them to the registry. This is called at the top of `update()` +//! BEFORE the main match block, so the message is still available as a reference. +//! +//! **Logic & Workflow:** +//! 1. `try_dispatch()` is called at the start of `HcieIcedApp::update()`. +//! 2. It converts the incoming `Message` to an optional `CoreEvent`. +//! 3. If a conversion exists, it dispatches to all registered plugins. +//! 4. The returned `Vec` can be processed for action->message mapping. +//! +//! **Arguments & Returns:** +//! * `registry`: Mutable reference to the PluginRegistry. +//! * `message`: The incoming Iced Message (reference, unconsumed). +//! * `state`: Mutable reference to ToolState for plugin access. +//! * Returns the `Vec` from all plugins (can be ignored with `let _`). + +use crate::app::{Message, ToolState}; +use crate::plugins::registry::PluginRegistry; +use crate::plugins::{CoreEvent, PluginAction}; + +/// Dispatch a Message to the PluginRegistry if it maps to a CoreEvent. +/// Should be called at the top of `update()` before the main match block. +pub fn try_dispatch( + registry: &mut PluginRegistry, + message: &Message, + state: &mut ToolState, +) -> Vec { + if let Some(core_event) = message_to_core_event(message) { + registry.dispatch(&core_event, state) + } else { + Vec::new() + } +} + +/// Convert an Iced Message to an optional CoreEvent. +/// Only messages that have meaningful plugin-side side effects are included. +fn message_to_core_event(message: &Message) -> Option { + match message { + Message::ToolSelected(tool) => Some(CoreEvent::ToolChanged(*tool)), + Message::LayerAdd => Some(CoreEvent::LayerAdded(0)), + Message::LayerDelete(id) => Some(CoreEvent::LayerDeleted(*id as usize)), + Message::LayerSelect(id) => Some(CoreEvent::LayerSelected(*id as usize)), + Message::Undo => Some(CoreEvent::Undo), + Message::Redo => Some(CoreEvent::Redo), + Message::CompositeRefresh => Some(CoreEvent::RenderRequested), + Message::CanvasPointerPressed { .. } => Some(CoreEvent::DrawingStarted), + Message::CanvasPointerReleased => Some(CoreEvent::DrawingFinished), + _ => None, + } +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/plugins/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/plugins/mod.rs new file mode 100644 index 0000000..984165c --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/plugins/mod.rs @@ -0,0 +1,74 @@ +//! Plugin system for HCIE Iced GUI. +//! +//! Port of the EGUI plugin architecture to the Iced Elm-architecture. +//! `CoreEvent` and `PluginAction` are kept identical to the EGUI version. +//! The `HciePlugin::ui()` method is retained as a no-op since Iced uses +//! declarative views instead of immediate-mode UI contributions. + +pub mod built_in; +pub mod integration; +pub mod registry; + +use crate::app::{HcieIcedApp, ToolState}; +use hcie_engine_api::Tool; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Core events dispatched to every registered plugin. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum CoreEvent { + ToolChanged(Tool), + LayerAdded(usize), + LayerDeleted(usize), + LayerSelected(usize), + LayerModified(usize), + Undo, + Redo, + RenderRequested, + DrawingStarted, + DrawingFinished, + DocSwitched(usize), + DocCreated(usize), + DocClosed(usize), + FileOpened(PathBuf), + FileSaved(PathBuf), + SettingsChanged, + ThemeChanged, + FrameTick, + PluginBroadcast(String, serde_json::Value), + ExecuteAiActions(serde_json::Value), +} + +/// Actions a plugin can request in response to an event or UI interaction. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum PluginAction { + SetActiveTool(Tool), + AddLayer, + DeleteLayer(usize), + SelectLayer(usize), + Undo, + Redo, + Render, + SaveAs(PathBuf), + Broadcast(String, serde_json::Value), + Custom { + target_plugin: String, + key: String, + payload: serde_json::Value, + }, + AiActions(serde_json::Value), + Batch(Vec), +} + +/// Trait that all plugins must implement. +pub trait HciePlugin: Send + Sync { + fn name(&self) -> &'static str; + fn version(&self) -> &'static str; + fn initialize(&mut self) {} + fn on_event(&mut self, event: &CoreEvent, state: &mut ToolState) -> Vec; + /// Optional UI contribution. In the Iced architecture this is a no-op + /// placeholder; plugins that need UI should expose their own view functions. + fn ui(&mut self, _app: &HcieIcedApp) -> Vec { + Vec::new() + } +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/plugins/registry.rs b/hcie-iced-app/crates/hcie-iced-gui/src/plugins/registry.rs new file mode 100644 index 0000000..5296406 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/plugins/registry.rs @@ -0,0 +1,82 @@ +//! PluginRegistry — the runtime manager for all HciePlugin instances. +//! +//! Provides registration, event dispatch, action collection, and plugin enumeration. +//! Ported from the EGUI plugin system, adapted for the Iced architecture. + +use crate::app::ToolState; +use crate::plugins::{CoreEvent, HciePlugin, PluginAction}; +use std::collections::HashMap; + +/// Central registry that owns and manages all loaded plugins. +pub struct PluginRegistry { + plugins: Vec>, + /// Optional per-plugin debug log (name -> last few events) + debug_logs: HashMap>, + max_log_entries: usize, +} + +impl PluginRegistry { + pub fn new() -> Self { + Self { + plugins: Vec::new(), + debug_logs: HashMap::new(), + max_log_entries: 50, + } + } + + /// Register a new plugin instance. The plugin is initialized immediately. + pub fn register(&mut self, mut plugin: Box) { + plugin.initialize(); + let name = plugin.name().to_string(); + self.debug_logs.insert(name.clone(), Vec::new()); + self.plugins.push(plugin); + } + + /// Dispatch a CoreEvent to every registered plugin. + /// Collects all PluginActions returned and returns them as a flat list. + pub fn dispatch(&mut self, event: &CoreEvent, state: &mut ToolState) -> Vec { + let mut all_actions = Vec::new(); + + for plugin in &mut self.plugins { + let name = plugin.name().to_string(); + let actions = plugin.on_event(event, state); + + if let Some(log) = self.debug_logs.get_mut(&name) { + let event_str = format!("{:?}", event); + log.push(event_str); + if log.len() > self.max_log_entries { + log.remove(0); + } + } + + all_actions.extend(actions); + } + + all_actions + } + + /// Returns the list of currently loaded plugin names. + pub fn plugin_names(&self) -> Vec { + self.plugins.iter().map(|p| p.name().to_string()).collect() + } + + /// Returns a reference to the debug log of a specific plugin. + pub fn get_debug_log(&self, plugin_name: &str) -> Option<&[String]> { + self.debug_logs.get(plugin_name).map(|v| v.as_slice()) + } + + /// Number of registered plugins. + pub fn len(&self) -> usize { + self.plugins.len() + } + + pub fn is_empty(&self) -> bool { + self.plugins.is_empty() + } +} + +impl Default for PluginRegistry { + fn default() -> Self { + Self::new() + } +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/script/executor.rs b/hcie-iced-app/crates/hcie-iced-gui/src/script/executor.rs index 3fcd61e..cd8ae07 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/script/executor.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/script/executor.rs @@ -215,6 +215,7 @@ pub fn execute_actions(engine: &mut Engine, actions: &[ScriptAction]) { 255, ]; engine.add_vector_shape(hcie_engine_api::VectorShape::FreePath { + name: String::new(), pts: path_points, closed: *closed, stroke: *stroke_width, diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs b/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs index 1ee915f..6bf7d54 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs @@ -40,6 +40,9 @@ pub struct AppSettings { /// Selected application color preset. #[serde(default)] pub theme_preset: crate::theme::ThemePreset, + /// Last directory used by the image viewer (FastStone-like). + #[serde(default)] + pub viewer_last_dir: Option, } /// Tool-specific settings that persist between sessions. @@ -168,6 +171,7 @@ impl Default for AppSettings { window: WindowSettings::default(), dock_profiles: Vec::new(), theme_preset: crate::theme::ThemePreset::default(), + viewer_last_dir: None, } } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/vector_edit.rs b/hcie-iced-app/crates/hcie-iced-gui/src/vector_edit.rs index 8f64079..56fed30 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/vector_edit.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/vector_edit.rs @@ -347,6 +347,7 @@ mod tests { /// Creates a stable rectangle fixture for transform invariant tests. fn rect() -> VectorShape { VectorShape::Rect { + name: String::new(), x1: 10.0, y1: 20.0, x2: 110.0, diff --git a/hcie-iced-app/crates/iced-panel-adapter/src/theme.rs b/hcie-iced-app/crates/iced-panel-adapter/src/theme.rs index bff149e..8a0fd1d 100644 --- a/hcie-iced-app/crates/iced-panel-adapter/src/theme.rs +++ b/hcie-iced-app/crates/iced-panel-adapter/src/theme.rs @@ -132,7 +132,7 @@ impl ThemeColors { accent_hover: Color::from_rgb(0.940, 0.640, 0.260), accent_green: Color::from_rgb(0.298, 0.686, 0.314), danger: Color::from_rgb(0.780, 0.220, 0.267), - border_low: Color::from_rgb(0.110, 0.106, 0.086), + border_low: Color::from_rgb(0.165, 0.169, 0.141), border_high: Color::from_rgb(0.243, 0.251, 0.212), text_primary: Color::from_rgb(0.973, 0.973, 0.941), text_secondary: Color::from_rgb(0.580, 0.588, 0.533), @@ -153,7 +153,7 @@ impl ThemeColors { accent_hover: Color::from_rgb(1.000, 0.530, 0.820), accent_green: Color::from_rgb(0.298, 0.686, 0.314), danger: Color::from_rgb(0.753, 0.220, 0.294), - border_low: Color::from_rgb(0.118, 0.110, 0.165), + border_low: Color::from_rgb(0.173, 0.180, 0.227), border_high: Color::from_rgb(0.267, 0.275, 0.345), text_primary: Color::from_rgb(0.973, 0.973, 0.949), text_secondary: Color::from_rgb(0.588, 0.596, 0.667), diff --git a/hcie-io/src/svg_import.rs b/hcie-io/src/svg_import.rs index 38e719a..16d66f6 100644 --- a/hcie-io/src/svg_import.rs +++ b/hcie-io/src/svg_import.rs @@ -116,6 +116,7 @@ fn convert_path(path: &usvg::Path) -> Option { let pts: Vec<[f32; 2]> = raw_pts.into_iter().map(|(x, y)| [x, y]).collect(); Some(VectorShape::FreePath { + name: String::new(), pts, closed: is_closed, stroke: stroke_width, diff --git a/hcie-kra/src/lib.rs b/hcie-kra/src/lib.rs index a39566b..e83f9af 100644 --- a/hcie-kra/src/lib.rs +++ b/hcie-kra/src/lib.rs @@ -430,6 +430,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec 0.0 && h > 0.0 { shapes.push(VectorShape::Rect { + name: String::new(), x1: x, y1: y, x2: x + w, y2: y + h, stroke: stroke_width, @@ -448,6 +449,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec 0.0 { shapes.push(VectorShape::Circle { + name: String::new(), x1: cx - r, y1: cy - r, x2: cx + r, y2: cy + r, stroke: stroke_width, @@ -466,6 +468,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec 0.0 && ry > 0.0 { shapes.push(VectorShape::Circle { + name: String::new(), x1: cx - rx, y1: cy - ry, x2: cx + rx, y2: cy + ry, stroke: stroke_width, @@ -483,6 +486,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec Vec= 2 { shapes.push(VectorShape::FreePath { + name: String::new(), pts, closed: tag == "polygon", stroke: stroke_width, diff --git a/hcie-protocol/src/lib.rs b/hcie-protocol/src/lib.rs index cea0922..7869c45 100644 --- a/hcie-protocol/src/lib.rs +++ b/hcie-protocol/src/lib.rs @@ -300,6 +300,8 @@ fn default_hardness() -> f32 { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum VectorShape { Line { + #[serde(default)] + name: String, x1: f32, y1: f32, x2: f32, @@ -318,6 +320,8 @@ pub enum VectorShape { hardness: f32, }, Rect { + #[serde(default)] + name: String, x1: f32, y1: f32, x2: f32, @@ -336,6 +340,8 @@ pub enum VectorShape { hardness: f32, }, Circle { + #[serde(default)] + name: String, x1: f32, y1: f32, x2: f32, @@ -354,6 +360,8 @@ pub enum VectorShape { }, #[serde(skip_serializing)] Arrow { + #[serde(default)] + name: String, x1: f32, y1: f32, x2: f32, @@ -374,6 +382,8 @@ pub enum VectorShape { }, #[serde(skip_serializing)] Star { + #[serde(default)] + name: String, x1: f32, y1: f32, x2: f32, @@ -395,6 +405,8 @@ pub enum VectorShape { inner_radius: f32, }, Polygon { + #[serde(default)] + name: String, x1: f32, y1: f32, x2: f32, @@ -415,6 +427,8 @@ pub enum VectorShape { }, #[serde(skip_serializing)] Rhombus { + #[serde(default)] + name: String, x1: f32, y1: f32, x2: f32, @@ -433,6 +447,8 @@ pub enum VectorShape { }, #[serde(skip_serializing)] Cylinder { + #[serde(default)] + name: String, x1: f32, y1: f32, x2: f32, @@ -451,6 +467,8 @@ pub enum VectorShape { }, #[serde(skip_serializing)] Heart { + #[serde(default)] + name: String, x1: f32, y1: f32, x2: f32, @@ -469,6 +487,8 @@ pub enum VectorShape { }, #[serde(skip_serializing)] Bubble { + #[serde(default)] + name: String, x1: f32, y1: f32, x2: f32, @@ -487,6 +507,8 @@ pub enum VectorShape { }, #[serde(skip_serializing)] Gear { + #[serde(default)] + name: String, x1: f32, y1: f32, x2: f32, @@ -505,6 +527,8 @@ pub enum VectorShape { }, #[serde(skip_serializing)] Cross { + #[serde(default)] + name: String, x1: f32, y1: f32, x2: f32, @@ -523,6 +547,8 @@ pub enum VectorShape { }, #[serde(skip_serializing)] Crescent { + #[serde(default)] + name: String, x1: f32, y1: f32, x2: f32, @@ -541,6 +567,8 @@ pub enum VectorShape { }, #[serde(skip_serializing)] Bolt { + #[serde(default)] + name: String, x1: f32, y1: f32, x2: f32, @@ -559,6 +587,8 @@ pub enum VectorShape { }, #[serde(skip_serializing)] Arrow4 { + #[serde(default)] + name: String, x1: f32, y1: f32, x2: f32, @@ -576,6 +606,8 @@ pub enum VectorShape { hardness: f32, }, SvgShape { + #[serde(default)] + name: String, x1: f32, y1: f32, x2: f32, @@ -595,6 +627,8 @@ pub enum VectorShape { hardness: f32, }, FreePath { + #[serde(default)] + name: String, pts: Vec<[f32; 2]>, closed: bool, stroke: f32, @@ -2410,6 +2444,50 @@ impl VectorShape { } } + pub fn name(&self) -> String { + match self { + VectorShape::Line { name, .. } + | VectorShape::Rect { name, .. } + | VectorShape::Circle { name, .. } + | VectorShape::Arrow { name, .. } + | VectorShape::Star { name, .. } + | VectorShape::Polygon { name, .. } + | VectorShape::Rhombus { name, .. } + | VectorShape::Cylinder { name, .. } + | VectorShape::Heart { name, .. } + | VectorShape::Bubble { name, .. } + | VectorShape::Gear { name, .. } + | VectorShape::Cross { name, .. } + | VectorShape::Crescent { name, .. } + | VectorShape::Bolt { name, .. } + | VectorShape::Arrow4 { name, .. } + | VectorShape::SvgShape { name, .. } + | VectorShape::FreePath { name, .. } => name.clone(), + } + } + + pub fn set_name(&mut self, new_name: String) { + match self { + VectorShape::Line { name, .. } + | VectorShape::Rect { name, .. } + | VectorShape::Circle { name, .. } + | VectorShape::Arrow { name, .. } + | VectorShape::Star { name, .. } + | VectorShape::Polygon { name, .. } + | VectorShape::Rhombus { name, .. } + | VectorShape::Cylinder { name, .. } + | VectorShape::Heart { name, .. } + | VectorShape::Bubble { name, .. } + | VectorShape::Gear { name, .. } + | VectorShape::Cross { name, .. } + | VectorShape::Crescent { name, .. } + | VectorShape::Bolt { name, .. } + | VectorShape::Arrow4 { name, .. } + | VectorShape::SvgShape { name, .. } + | VectorShape::FreePath { name, .. } => *name = new_name, + } + } + pub fn angle(&self) -> f32 { match self { VectorShape::Line { angle, .. } diff --git a/hcie-vector/examples/vector_test.rs b/hcie-vector/examples/vector_test.rs index 037c104..3314870 100644 --- a/hcie-vector/examples/vector_test.rs +++ b/hcie-vector/examples/vector_test.rs @@ -410,6 +410,7 @@ impl App { fn make_shape(&self, x1: f32, y1: f32, x2: f32, y2: f32) -> VectorShape { match self.shape_kind { ShapeKind::Line => VectorShape::Line { + name: String::new(), x1, y1, x2, y2, stroke: self.stroke, color: self.color, @@ -420,6 +421,7 @@ impl App { hardness: self.hardness, }, ShapeKind::Rect => VectorShape::Rect { + name: String::new(), x1, y1, x2, y2, stroke: self.stroke, color: self.color, @@ -431,6 +433,7 @@ impl App { hardness: self.hardness, }, ShapeKind::Circle => VectorShape::Circle { + name: String::new(), x1, y1, x2, y2, stroke: self.stroke, color: self.color, @@ -441,6 +444,7 @@ impl App { hardness: self.hardness, }, ShapeKind::Arrow => VectorShape::Arrow { + name: String::new(), x1, y1, x2, y2, stroke: self.stroke, color: self.color, @@ -452,6 +456,7 @@ impl App { thick: self.arrow_thick, }, ShapeKind::Star => VectorShape::Star { + name: String::new(), x1, y1, x2, y2, stroke: self.stroke, color: self.color, @@ -464,6 +469,7 @@ impl App { inner_radius: self.star_inner_radius, }, ShapeKind::Polygon => VectorShape::Polygon { + name: String::new(), x1, y1, x2, y2, stroke: self.stroke, color: self.color, @@ -475,6 +481,7 @@ impl App { hardness: self.hardness, }, ShapeKind::Rhombus => VectorShape::Rhombus { + name: String::new(), x1, y1, x2, y2, stroke: self.stroke, color: self.color, @@ -485,6 +492,7 @@ impl App { hardness: self.hardness, }, ShapeKind::Cylinder => VectorShape::Cylinder { + name: String::new(), x1, y1, x2, y2, stroke: self.stroke, color: self.color, @@ -495,6 +503,7 @@ impl App { hardness: self.hardness, }, ShapeKind::Heart => VectorShape::Heart { + name: String::new(), x1, y1, x2, y2, stroke: self.stroke, color: self.color, @@ -505,6 +514,7 @@ impl App { hardness: self.hardness, }, ShapeKind::Bubble => VectorShape::Bubble { + name: String::new(), x1, y1, x2, y2, stroke: self.stroke, color: self.color, @@ -515,6 +525,7 @@ impl App { hardness: self.hardness, }, ShapeKind::Gear => VectorShape::Gear { + name: String::new(), x1, y1, x2, y2, stroke: self.stroke, color: self.color, @@ -525,6 +536,7 @@ impl App { hardness: self.hardness, }, ShapeKind::Cross => VectorShape::Cross { + name: String::new(), x1, y1, x2, y2, stroke: self.stroke, color: self.color, @@ -535,6 +547,7 @@ impl App { hardness: self.hardness, }, ShapeKind::Crescent => VectorShape::Crescent { + name: String::new(), x1, y1, x2, y2, stroke: self.stroke, color: self.color, @@ -545,6 +558,7 @@ impl App { hardness: self.hardness, }, ShapeKind::Bolt => VectorShape::Bolt { + name: String::new(), x1, y1, x2, y2, stroke: self.stroke, color: self.color, @@ -555,6 +569,7 @@ impl App { hardness: self.hardness, }, ShapeKind::Arrow4 => VectorShape::Arrow4 { + name: String::new(), x1, y1, x2, y2, stroke: self.stroke, color: self.color, @@ -572,6 +587,7 @@ impl App { [(x1 + x2) / 2.0, y2 + 50.0], ]; VectorShape::FreePath { + name: String::new(), pts, closed: true, stroke: self.stroke, diff --git a/hcie-vector/src/lib.rs b/hcie-vector/src/lib.rs index c0939b0..328ea63 100644 --- a/hcie-vector/src/lib.rs +++ b/hcie-vector/src/lib.rs @@ -751,8 +751,9 @@ fn render_shape(layer: &mut Layer, shape: &VectorShape) { draw_line(layer, pts[i].0, pts[i].1, pts[j].0, pts[j].1, px_color, *stroke, None); } } - SvgShape { svg, x1, y1, x2, y2, fill, color, fill_color, stroke, opacity, hardness, .. } => { - let points_list = svg_render::svg_to_points(svg, *x1, *y1, *x2, *y2).unwrap_or_default(); + SvgShape { svg, x1, y1, x2, y2, fill, color, fill_color, stroke, angle, opacity, hardness, .. } => { + let mut points_list = svg_render::svg_to_points(svg, *x1, *y1, *x2, *y2).unwrap_or_default(); + rotate_svg_points(&mut points_list, *x1, *y1, *x2, *y2, *angle); let alpha = (color[3] as f32 * opacity).round() as u8; let px_color = [color[0], color[1], color[2], alpha]; let prev = ACTIVE_HARDNESS.with(|h| h.get()); @@ -788,6 +789,35 @@ fn render_shape(layer: &mut Layer, shape: &VectorShape) { } } +/// Rotate tessellated SVG polygons around the center of their shape bounds. +/// +/// **Purpose:** Applies the `SvgShape::angle` field to SVG geometry before fill and stroke +/// rasterization. **Logic & Workflow:** Computes the same bounds center used by vector transform +/// handles, then rotates every tessellated point by the stored radian angle. A zero angle exits +/// without touching the point buffers. **Arguments:** `polygons` contains canvas-space SVG paths; +/// `x1`, `y1`, `x2`, and `y2` define the shape bounds; `angle` is expressed in radians. +/// **Returns:** Nothing. **Side Effects / Dependencies:** Mutates all points in `polygons` in place +/// and relies on the shared `rotate_point` geometry helper. +fn rotate_svg_points( + polygons: &mut [Vec<(f32, f32)>], + x1: f32, + y1: f32, + x2: f32, + y2: f32, + angle: f32, +) { + if angle.abs() <= f32::EPSILON { + return; + } + let cx = (x1 + x2) * 0.5; + let cy = (y1 + y2) * 0.5; + for polygon in polygons { + for point in polygon { + *point = rotate_point(point.0, point.1, cx, cy, angle); + } + } +} + fn normalized_rect(x1: f32, y1: f32, x2: f32, y2: f32) -> (f32, f32, f32, f32) { (x1.min(x2), y1.min(y2), x1.max(x2), y1.max(y2)) } @@ -1126,8 +1156,29 @@ mod tests { assert!((rx - 100.0).abs() < 1e-4); assert!((ry - 150.0).abs() < 1e-4); } + + /// Verify SVG tessellation applies its stored angle around the bounds center. + /// + /// **Purpose:** Prevents SVG-backed shapes from remaining visually fixed at zero degrees. + /// **Logic & Workflow:** Rotates two asymmetric points by 90 degrees in a 100x100 bounds and + /// compares their resulting canvas coordinates. **Arguments/Returns:** Standard unit test with + /// no inputs or return value. **Side Effects / Dependencies:** None. + #[test] + fn svg_points_follow_shape_angle() { + let mut polygons = vec![vec![(100.0, 50.0), (50.0, 25.0)]]; + rotate_svg_points( + &mut polygons, + 0.0, + 0.0, + 100.0, + 100.0, + std::f32::consts::FRAC_PI_2, + ); + assert!((polygons[0][0].0 - 50.0).abs() < 1e-4); + assert!((polygons[0][0].1 - 100.0).abs() < 1e-4); + assert!((polygons[0][1].0 - 75.0).abs() < 1e-4); + assert!((polygons[0][1].1 - 50.0).abs() < 1e-4); + } } - - diff --git a/hcie-vector/src/path_boolean.rs b/hcie-vector/src/path_boolean.rs index 17fbb9e..d7e1a94 100644 --- a/hcie-vector/src/path_boolean.rs +++ b/hcie-vector/src/path_boolean.rs @@ -397,6 +397,7 @@ pub fn boolean_shapes(op: BooleanOp, a: &VectorShape, b: &VectorShape) -> Option }; Some(VectorShape::FreePath { + name: String::new(), pts: result_pts, closed: true, stroke, diff --git a/line_count_report.txt b/line_count_report.txt index a98ac79..d4cf435 100644 --- a/line_count_report.txt +++ b/line_count_report.txt @@ -2,7 +2,7 @@ SATIR SAYISI RAPORU Proje: HCIE-Rust v4 Konum: /mnt/extra/00_PROJECTS/hcie-rust-v3.05 -Tarih: 2026-07-11 20:24:42 +Tarih: 2026-07-19 20:01:21 ========================================= ----------------------------------------- @@ -10,31 +10,31 @@ Tarih: 2026-07-11 20:24:42 ----------------------------------------- hcie-ai 2402 satır hcie-blend 530 satır - hcie-brush-engine 3753 satır + hcie-brush-engine 4845 satır hcie-color 85 satır - hcie-composite 985 satır + hcie-composite 1456 satır hcie-document 666 satır - hcie-draw 517 satır - hcie-egui-app 36320 satır - hcie-engine-api 5766 satır + hcie-draw 619 satır + hcie-egui-app 37419 satır + hcie-engine-api 8360 satır hcie-engine-api-orig 3874 satır hcie-filter 3238 satır - hcie-fx 4077 satır + hcie-fx 5535 satır hcie-history 100 satır - hcie-iced-app 6308 satır - hcie-io 11382 satır - hcie-kra 1392 satır + hcie-iced-app 36049 satır + hcie-io 12121 satır + hcie-kra 1393 satır hcie-native 153 satır - hcie-protocol 2872 satır + hcie-protocol 3678 satır hcie-psd 5120 satır hcie-psd-saver 1277 satır - hcie-selection 284 satır + hcie-selection 392 satır hcie-text 1010 satır hcie-tile 361 satır - hcie-vector 2298 satır + hcie-vector 2853 satır hcie-vision 3131 satır - TOPLAM RUST: 97901 satır + TOPLAM RUST: 136667 satır ----------------------------------------- C++ / HEADER (.cpp, .h) @@ -102,14 +102,14 @@ Tarih: 2026-07-11 20:24:42 MARKDOWN (.md) -------------------------------- - (kök dizin) 10994 satır + (kök dizin) 17154 satır - TOPLAM MARKDOWN: 10994 satır + TOPLAM MARKDOWN: 17154 satır ========================================= KOD SATIRLARI TOPLAMI (GRAND TOTAL) ========================================= - Rust: 97901 satır + Rust: 136667 satır C++ / Header: 5031 satır JavaScript: 0 satır Svelte: 0 satır @@ -119,9 +119,9 @@ Tarih: 2026-07-11 20:24:42 CSS: 0 satır Shell Script: 1445 satır ----------------------------------------- - KOD TOPLAMI: 106644 satır + KOD TOPLAMI: 145410 satır - RUST + C++/H TOPLAMI: 102932 satır + RUST + C++/H TOPLAMI: 141698 satır (JSON ve Markdown dosyaları yukarıda ayrı olarak gösterilmiştir) =========================================