feat(svg-editor): implement interactive SVG node editor with parsing and editing capabilities

feat(debug-logger): add DebugSignalLogger plugin for event logging and inspection

feat(plugins): introduce built-in plugins module and integrate debug logger

feat(plugin-integration): create integration layer for mapping Iced Messages to CoreEvents

feat(plugin-registry): establish PluginRegistry for managing plugins and event dispatching
This commit is contained in:
2026-07-20 01:43:22 +03:00
parent 6e0d179be9
commit 2fb47520b3
46 changed files with 4093 additions and 314 deletions
@@ -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<SvgEditorState>` 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
@@ -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: <id>")`; 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.
- EGUIs 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: <id>`; `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 pickers 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: <id>` 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.
Generated
+1
View File
@@ -2759,6 +2759,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
"usvg 0.43.0",
"zip", "zip",
] ]
@@ -208,6 +208,7 @@ pub fn execute_actions(engine: &mut Engine, actions: &[ScriptAction]) {
255, 255,
]; ];
engine.add_vector_shape(hcie_engine_api::VectorShape::FreePath { engine.add_vector_shape(hcie_engine_api::VectorShape::FreePath {
name: String::new(),
pts: path_points, pts: path_points,
closed: *closed, closed: *closed,
stroke: *stroke_width, stroke: *stroke_width,
@@ -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. // 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. // We can add a dummy line shape with opacity 0.0 to create a Vector layer.
let dummy = VectorShape::Line { let dummy = VectorShape::Line {
name: String::new(),
x1: 0.0, x1: 0.0,
y1: 0.0, y1: 0.0,
x2: 0.0, x2: 0.0,
@@ -731,6 +731,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec<VectorS
* scale_y; * scale_y;
if w > 0.0 && h > 0.0 { if w > 0.0 && h > 0.0 {
shapes.push(VectorShape::Rect { shapes.push(VectorShape::Rect {
name: String::new(),
x1: x, x1: x,
y1: y, y1: y,
x2: x + w, x2: x + w,
@@ -766,6 +767,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec<VectorS
* scale_x; * scale_x;
if r > 0.0 { if r > 0.0 {
shapes.push(VectorShape::Circle { shapes.push(VectorShape::Circle {
name: String::new(),
x1: cx - r, x1: cx - r,
y1: cy - r, y1: cy - r,
x2: cx + r, x2: cx + r,
@@ -805,6 +807,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec<VectorS
* scale_y; * scale_y;
if rx > 0.0 && ry > 0.0 { if rx > 0.0 && ry > 0.0 {
shapes.push(VectorShape::Circle { shapes.push(VectorShape::Circle {
name: String::new(),
x1: cx - rx, x1: cx - rx,
y1: cy - ry, y1: cy - ry,
x2: cx + rx, x2: cx + rx,
@@ -845,6 +848,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec<VectorS
+ ty) + ty)
* scale_y; * scale_y;
shapes.push(VectorShape::Line { shapes.push(VectorShape::Line {
name: String::new(),
x1, x1,
y1, y1,
x2, x2,
@@ -873,6 +877,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec<VectorS
} }
if pts.len() >= 2 { if pts.len() >= 2 {
shapes.push(VectorShape::FreePath { shapes.push(VectorShape::FreePath {
name: String::new(),
pts, pts,
closed: tag == "polygon", closed: tag == "polygon",
stroke: stroke_width, stroke: stroke_width,
@@ -10,6 +10,10 @@ use crate::app::ToolState;
use crate::event_bus::AppEvent; use crate::event_bus::AppEvent;
fn shape_label(shapes: &[VectorShape], i: usize) -> String { fn shape_label(shapes: &[VectorShape], i: usize) -> String {
let name = shapes[i].name();
if !name.is_empty() {
return name;
}
match &shapes[i] { match &shapes[i] {
VectorShape::Line { .. } => format!("Line #{}", i + 1), VectorShape::Line { .. } => format!("Line #{}", i + 1),
VectorShape::Rect { .. } => format!("Rect #{}", i + 1), VectorShape::Rect { .. } => format!("Rect #{}", i + 1),
@@ -2184,6 +2184,7 @@ fn create_shape_from_drag(
match tool { match tool {
Tool::VectorLine => VectorShape::Line { Tool::VectorLine => VectorShape::Line {
name: String::new(),
x1, x1,
y1, y1,
x2, x2,
@@ -2197,6 +2198,7 @@ fn create_shape_from_drag(
hardness, hardness,
}, },
Tool::VectorRect => VectorShape::Rect { Tool::VectorRect => VectorShape::Rect {
name: String::new(),
x1, x1,
y1, y1,
x2, x2,
@@ -2211,6 +2213,7 @@ fn create_shape_from_drag(
hardness, hardness,
}, },
Tool::VectorCircle => VectorShape::Circle { Tool::VectorCircle => VectorShape::Circle {
name: String::new(),
x1, x1,
y1, y1,
x2, x2,
@@ -2228,7 +2231,7 @@ fn create_shape_from_drag(
p.insert("thick".to_string(), 0.0); p.insert("thick".to_string(), 0.0);
match hcie_engine_api::create_vector_shape("arrow", x1, y1, x2, y2, &p) { match hcie_engine_api::create_vector_shape("arrow", x1, y1, x2, y2, &p) {
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape { 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, angle: 0.0, opacity, hardness,
}, },
_ => unreachable!(), _ => unreachable!(),
@@ -2240,13 +2243,14 @@ fn create_shape_from_drag(
p.insert("inner_radius".to_string(), state.tool_configs.vector.star_inner_radius); 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) { match hcie_engine_api::create_vector_shape("star", x1, y1, x2, y2, &p) {
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape { 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, angle: 0.0, opacity, hardness,
}, },
_ => unreachable!(), _ => unreachable!(),
} }
}, },
Tool::VectorPolygon => VectorShape::Polygon { Tool::VectorPolygon => VectorShape::Polygon {
name: String::new(),
x1, x1,
y1, y1,
x2, x2,
@@ -2263,7 +2267,7 @@ fn create_shape_from_drag(
Tool::VectorRhombus => { Tool::VectorRhombus => {
match hcie_engine_api::create_vector_shape("rhombus", x1, y1, x2, y2, &std::collections::HashMap::new()) { match hcie_engine_api::create_vector_shape("rhombus", x1, y1, x2, y2, &std::collections::HashMap::new()) {
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape { 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, angle: 0.0, opacity, hardness,
}, },
_ => unreachable!(), _ => unreachable!(),
@@ -2272,7 +2276,7 @@ fn create_shape_from_drag(
Tool::VectorCylinder => { Tool::VectorCylinder => {
match hcie_engine_api::create_vector_shape("cylinder", x1, y1, x2, y2, &std::collections::HashMap::new()) { match hcie_engine_api::create_vector_shape("cylinder", x1, y1, x2, y2, &std::collections::HashMap::new()) {
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape { 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, angle: 0.0, opacity, hardness,
}, },
_ => unreachable!(), _ => unreachable!(),
@@ -2281,7 +2285,7 @@ fn create_shape_from_drag(
Tool::VectorHeart => { Tool::VectorHeart => {
match hcie_engine_api::create_vector_shape("heart", x1, y1, x2, y2, &std::collections::HashMap::new()) { match hcie_engine_api::create_vector_shape("heart", x1, y1, x2, y2, &std::collections::HashMap::new()) {
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape { 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, angle: 0.0, opacity, hardness,
}, },
_ => unreachable!(), _ => unreachable!(),
@@ -2290,7 +2294,7 @@ fn create_shape_from_drag(
Tool::VectorBubble => { Tool::VectorBubble => {
match hcie_engine_api::create_vector_shape("bubble", x1, y1, x2, y2, &std::collections::HashMap::new()) { match hcie_engine_api::create_vector_shape("bubble", x1, y1, x2, y2, &std::collections::HashMap::new()) {
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape { 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, angle: 0.0, opacity, hardness,
}, },
_ => unreachable!(), _ => unreachable!(),
@@ -2299,7 +2303,7 @@ fn create_shape_from_drag(
Tool::VectorGear => { Tool::VectorGear => {
match hcie_engine_api::create_vector_shape("gear", x1, y1, x2, y2, &std::collections::HashMap::new()) { match hcie_engine_api::create_vector_shape("gear", x1, y1, x2, y2, &std::collections::HashMap::new()) {
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape { 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, angle: 0.0, opacity, hardness,
}, },
_ => unreachable!(), _ => unreachable!(),
@@ -2308,7 +2312,7 @@ fn create_shape_from_drag(
Tool::VectorCross => { Tool::VectorCross => {
match hcie_engine_api::create_vector_shape("cross", x1, y1, x2, y2, &std::collections::HashMap::new()) { match hcie_engine_api::create_vector_shape("cross", x1, y1, x2, y2, &std::collections::HashMap::new()) {
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape { 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, angle: 0.0, opacity, hardness,
}, },
_ => unreachable!(), _ => unreachable!(),
@@ -2317,7 +2321,7 @@ fn create_shape_from_drag(
Tool::VectorCrescent => { Tool::VectorCrescent => {
match hcie_engine_api::create_vector_shape("crescent", x1, y1, x2, y2, &std::collections::HashMap::new()) { match hcie_engine_api::create_vector_shape("crescent", x1, y1, x2, y2, &std::collections::HashMap::new()) {
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape { 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, angle: 0.0, opacity, hardness,
}, },
_ => unreachable!(), _ => unreachable!(),
@@ -2326,7 +2330,7 @@ fn create_shape_from_drag(
Tool::VectorBolt => { Tool::VectorBolt => {
match hcie_engine_api::create_vector_shape("bolt", x1, y1, x2, y2, &std::collections::HashMap::new()) { match hcie_engine_api::create_vector_shape("bolt", x1, y1, x2, y2, &std::collections::HashMap::new()) {
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape { 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, angle: 0.0, opacity, hardness,
}, },
_ => unreachable!(), _ => unreachable!(),
@@ -2335,7 +2339,7 @@ fn create_shape_from_drag(
Tool::VectorArrow4 => { Tool::VectorArrow4 => {
match hcie_engine_api::create_vector_shape("arrow4", x1, y1, x2, y2, &std::collections::HashMap::new()) { match hcie_engine_api::create_vector_shape("arrow4", x1, y1, x2, y2, &std::collections::HashMap::new()) {
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape { 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, angle: 0.0, opacity, hardness,
}, },
_ => unreachable!(), _ => unreachable!(),
@@ -2344,6 +2348,7 @@ fn create_shape_from_drag(
Tool::CustomShape(idx) => { Tool::CustomShape(idx) => {
if let Some(entry) = catalog.get(idx) { if let Some(entry) = catalog.get(idx) {
VectorShape::SvgShape { VectorShape::SvgShape {
name: String::new(),
x1, y1, x2, y2, x1, y1, x2, y2,
kind: entry.kind.clone(), kind: entry.kind.clone(),
svg: entry.svg.clone(), svg: entry.svg.clone(),
@@ -2353,6 +2358,7 @@ fn create_shape_from_drag(
} else { } else {
// Fallback: empty SVG rectangle // Fallback: empty SVG rectangle
VectorShape::SvgShape { VectorShape::SvgShape {
name: String::new(),
x1, y1, x2, y2, x1, y1, x2, y2,
kind: "custom".to_string(), kind: "custom".to_string(),
svg: r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><path d="M 0,0 L 1,0 L 1,1 L 0,1 Z"/></svg>"#.to_string(), svg: r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><path d="M 0,0 L 1,0 L 1,1 L 0,1 Z"/></svg>"#.to_string(),
@@ -2362,6 +2368,7 @@ fn create_shape_from_drag(
} }
}, },
_ => VectorShape::Line { _ => VectorShape::Line {
name: String::new(),
x1, x1,
y1, y1,
x2, x2,
+20
View File
@@ -438,6 +438,7 @@ pub fn create_vector_shape(
) -> VectorShape { ) -> VectorShape {
let svg = hcie_vector::svg_templates::create_svg(kind, params); let svg = hcie_vector::svg_templates::create_svg(kind, params);
VectorShape::SvgShape { VectorShape::SvgShape {
name: String::new(),
x1, x1,
y1, y1,
x2, x2,
@@ -467,6 +468,7 @@ pub fn create_vector_shape_from_svg(
y2: f32, y2: f32,
) -> VectorShape { ) -> VectorShape {
VectorShape::SvgShape { VectorShape::SvgShape {
name: String::new(),
x1, x1,
y1, y1,
x2, x2,
@@ -1630,6 +1632,20 @@ impl Engine {
None 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) { 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 Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data { if let LayerData::Vector { ref mut shapes } = layer.data {
@@ -3086,6 +3102,7 @@ impl Engine {
.collect(); .collect();
if !path_pts.is_empty() { if !path_pts.is_empty() {
let shape = hcie_protocol::VectorShape::FreePath { let shape = hcie_protocol::VectorShape::FreePath {
name: String::new(),
pts: path_pts, pts: path_pts,
closed, closed,
stroke: stroke_width, stroke: stroke_width,
@@ -3192,6 +3209,7 @@ impl Engine {
let fc = parse_hex_color(fill_hex); let fc = parse_hex_color(fill_hex);
let sc = parse_hex_color(stroke_hex); let sc = parse_hex_color(stroke_hex);
let shape = VectorShape::Circle { let shape = VectorShape::Circle {
name: String::new(),
x1: cx - rx, x1: cx - rx,
y1: cy - ry, y1: cy - ry,
x2: cx + rx, x2: cx + rx,
@@ -3218,6 +3236,7 @@ impl Engine {
let fc = parse_hex_color(fill_hex); let fc = parse_hex_color(fill_hex);
let sc = parse_hex_color(stroke_hex); let sc = parse_hex_color(stroke_hex);
let shape = VectorShape::Rect { let shape = VectorShape::Rect {
name: String::new(),
x1, x1,
y1, y1,
x2, x2,
@@ -3366,6 +3385,7 @@ impl Engine {
[0u8; 4] [0u8; 4]
}; };
let shape = VectorShape::FreePath { let shape = VectorShape::FreePath {
name: String::new(),
pts: px_pts, pts: px_pts,
closed: comp.closed, closed: comp.closed,
stroke: stroke_width, stroke: stroke_width,
@@ -109,6 +109,7 @@ fn red_rect_over_green_rect() {
fn vector_rect_golden() { fn vector_rect_golden() {
let mut engine = Engine::new(16, 16); let mut engine = Engine::new(16, 16);
let shape = VectorShape::Rect { let shape = VectorShape::Rect {
name: String::new(),
x1: 0.0, x1: 0.0,
y1: 0.0, y1: 0.0,
x2: 16.0, x2: 16.0,
@@ -222,6 +223,7 @@ fn vector_fill_toggle_and_delete() {
// Create a rect with fill=false // Create a rect with fill=false
let shape = VectorShape::Rect { let shape = VectorShape::Rect {
name: String::new(),
x1: 2.0, x1: 2.0,
y1: 2.0, y1: 2.0,
x2: 14.0, x2: 14.0,
@@ -34,3 +34,4 @@ reqwest = { version = "0.12", features = ["json", "stream"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
futures-util = "0.3" futures-util = "0.3"
zip = { workspace = true } zip = { workspace = true }
usvg = { workspace = true }
@@ -140,6 +140,38 @@ impl Default for AiChatConfig {
} }
} }
/// ComfyUI connection status.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ComfyStatus {
Unknown,
Running,
Error,
}
impl Default for ComfyStatus {
fn default() -> Self {
Self::Unknown
}
}
/// State for the ComfyUI pipeline viewer tab.
#[derive(Debug, Clone)]
pub struct ComfyUiState {
/// ComfyUI server URL.
pub url: String,
/// Last known connection status.
pub status: ComfyStatus,
}
impl Default for ComfyUiState {
fn default() -> Self {
Self {
url: "http://127.0.0.1:8188".to_string(),
status: ComfyStatus::Unknown,
}
}
}
/// Complete AI chat state including messages, input buffer, and streaming state. /// Complete AI chat state including messages, input buffer, and streaming state.
#[derive(Debug)] #[derive(Debug)]
pub struct AiChatState { pub struct AiChatState {
File diff suppressed because it is too large Load Diff
+31 -1
View File
@@ -19,6 +19,8 @@ pub struct ScreenshotRequest {
pub auto_hide_panel: Option<String>, pub auto_hide_panel: Option<String>,
/// Whether the post-close welcome surface is activated before capture. /// Whether the post-close welcome surface is activated before capture.
pub welcome: bool, pub welcome: bool,
/// Whether a deterministic SVG editor fixture is opened before capture.
pub svg_editor: bool,
/// PNG output path supplied by the caller. /// PNG output path supplied by the caller.
pub output: PathBuf, pub output: PathBuf,
} }
@@ -73,6 +75,7 @@ where
floating_panel: None, floating_panel: None,
auto_hide_panel: None, auto_hide_panel: None,
welcome: false, welcome: false,
svg_editor: false,
output: PathBuf::from(output), output: PathBuf::from(output),
}); });
index += 2; index += 2;
@@ -87,6 +90,7 @@ where
floating_panel: Some(panel.to_owned()), floating_panel: Some(panel.to_owned()),
auto_hide_panel: None, auto_hide_panel: None,
welcome: false, welcome: false,
svg_editor: false,
output: PathBuf::from(output), output: PathBuf::from(output),
}); });
index += 3; index += 3;
@@ -101,6 +105,7 @@ where
floating_panel: None, floating_panel: None,
auto_hide_panel: None, auto_hide_panel: None,
welcome: false, welcome: false,
svg_editor: false,
output: PathBuf::from(output), output: PathBuf::from(output),
}); });
index += 3; index += 3;
@@ -114,6 +119,7 @@ where
floating_panel: None, floating_panel: None,
auto_hide_panel: None, auto_hide_panel: None,
welcome: true, welcome: true,
svg_editor: false,
output: PathBuf::from(output), output: PathBuf::from(output),
}); });
index += 2; index += 2;
@@ -128,11 +134,26 @@ where
floating_panel: None, floating_panel: None,
auto_hide_panel: Some(panel.to_owned()), auto_hide_panel: Some(panel.to_owned()),
welcome: false, welcome: false,
svg_editor: false,
output: PathBuf::from(output), output: PathBuf::from(output),
}); });
index += 3; index += 3;
continue; 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('-') => { _ if token.starts_with('-') => {
return Err(format!("unknown argument: {token}")); 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. /// Returns the command-line help shown by the binary.
pub fn help_text() -> &'static str { 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)] #[cfg(test)]
@@ -192,6 +213,7 @@ mod tests {
floating_panel: None, floating_panel: None,
auto_hide_panel: None, auto_hide_panel: None,
welcome: false, welcome: false,
svg_editor: false,
output: PathBuf::from("full.png"), output: PathBuf::from("full.png"),
}) })
); );
@@ -203,6 +225,14 @@ mod tests {
assert_eq!(args.screenshot.unwrap().panel.as_deref(), Some("Geometry")); 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] #[test]
fn rejects_duplicate_positional_files() { fn rejects_duplicate_positional_files() {
assert!(parse_args(["one.png", "two.png"]).is_err()); assert!(parse_args(["one.png", "two.png"]).is_err());
@@ -591,6 +591,14 @@ impl canvas::Program<Message> for ColorWheel {
return (canvas::event::Status::Captured, Some(self.message(color))); 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 => { canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => {
if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) { if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) {
return (canvas::event::Status::Captured, Some(self.message(color))); return (canvas::event::Status::Captured, Some(self.message(color)));
@@ -1,4 +1,4 @@
//! Confirm dialog — save/discard/cancel confirmation. //! Confirm dialog — save/discard/cancel confirmation and selection operation parameter dialog.
use crate::app::Message; use crate::app::Message;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
@@ -28,18 +28,37 @@ pub fn close_confirm_view(doc_name: &str, colors: ThemeColors) -> Element<'stati
super::modal(dialog, colors) 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( pub fn selection_op_view(
op_name: &str, op_name: &str,
value: f32, value: f32,
min: f32, _min: f32,
max: f32, _max: f32,
colors: ThemeColors, colors: ThemeColors,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let op_owned = op_name.to_string(); let op_owned = op_name.to_string();
let value_slider = plain_slider("Value", value, min..=max, 1.0, "", 0, |v| { let (range_min, range_max, unit) = selection_op_range(op_name);
Message::SelectionOpValue(v) 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 apply_btn = super::primary_btn("Apply", Message::SelectionOpApply, colors);
let cancel_btn = super::secondary_btn("Cancel", Message::DialogClose, colors); let cancel_btn = super::secondary_btn("Cancel", Message::DialogClose, colors);
@@ -0,0 +1,36 @@
//! Canvas Expand dialog — shown when pasted content exceeds the canvas size.
//!
//! Offers to expand the canvas to fit the pasted content, or cancel.
use crate::app::Message;
use crate::dialogs::{modal, primary_btn, secondary_btn};
use crate::theme::ThemeColors;
use iced::widget::{column, row, text, Space};
use iced::Element;
/// Build the canvas expand dialog view.
pub fn view(paste_w: u32, paste_h: u32, canvas_w: u32, canvas_h: u32, colors: ThemeColors) -> Element<'static, Message> {
let body = column![
text("Pasted Image Exceeds Canvas")
.size(16),
Space::with_height(12),
text(format!(
"Pasted image ({}x{}) is larger than the canvas ({}x{}). \
Expand the document to fit the pasted content?",
paste_w, paste_h, canvas_w, canvas_h
))
.size(12),
Space::with_height(16),
row![
primary_btn("Expand", Message::DialogCloseSave, colors),
Space::with_width(8),
secondary_btn("Cancel", Message::DialogClose, colors),
]
.spacing(0),
]
.spacing(0)
.padding(24)
.max_width(420);
modal(body, colors)
}
@@ -11,7 +11,9 @@ pub mod canvas_size;
pub mod confirm; pub mod confirm;
pub mod dock_profile; pub mod dock_profile;
pub mod image_size; pub mod image_size;
pub mod expand_canvas;
pub mod new_image; pub mod new_image;
pub mod save_error;
/// Wraps dialog content in an opaque themed card over a translucent full-screen scrim. /// Wraps dialog content in an opaque themed card over a translucent full-screen scrim.
/// ///
@@ -0,0 +1,64 @@
//! Save Error dialog — shown when a file save/export operation fails.
//!
//! Displays the error, the target path, and offers a suggested alternative path.
//! The user can retry, save to the suggested path, or close.
use crate::app::Message;
use crate::dialogs::{modal, primary_btn, secondary_btn};
use crate::theme::ThemeColors;
use iced::widget::{column, container, row, text, Space};
use iced::{Element, Length};
use std::path::PathBuf;
/// State for the save error dialog.
#[derive(Debug, Clone)]
pub struct SaveErrorState {
pub operation: String,
pub path: PathBuf,
pub error: String,
pub suggestion: PathBuf,
}
/// Build the save error dialog view.
pub fn view(state: &SaveErrorState, colors: ThemeColors) -> Element<'static, Message> {
let body = column![
text(format!("{} — Failed", state.operation))
.size(15),
Space::with_height(12),
text("Target path:")
.size(11),
container(
text(state.path.to_string_lossy().to_string())
.size(11)
)
.padding(4)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.08))),
border: iced::Border::default().rounded(2),
..Default::default()
})
.width(Length::Fill),
Space::with_height(8),
text(format!("Error: {}", state.error))
.size(11),
Space::with_height(4),
text("Suggested action:")
.size(11),
text(state.suggestion.to_string_lossy().to_string())
.size(11),
Space::with_height(12),
row![
primary_btn("Save to Suggested Path", Message::DialogCloseSave, colors),
Space::with_width(8),
secondary_btn("Try Again", Message::DialogCloseDiscard, colors),
Space::with_width(8),
secondary_btn("Close", Message::DialogClose, colors),
]
.spacing(0),
]
.spacing(0)
.padding(24)
.max_width(420);
modal(body, colors)
}
@@ -79,10 +79,14 @@ pub fn panel_size_policy(panel: PaneType) -> PaneSizePolicy {
width: axis(160.0, 300.0, f32::INFINITY, 35), width: axis(160.0, 300.0, f32::INFINITY, 35),
height: axis(120.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), width: axis(160.0, 300.0, f32::INFINITY, 30),
height: axis(120.0, 280.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 { PaneType::Properties | PaneType::Geometry | PaneType::ToolSettings => PaneSizePolicy {
width: axis(160.0, 280.0, f32::INFINITY, 25), width: axis(160.0, 280.0, f32::INFINITY, 25),
height: axis(120.0, 280.0, f32::INFINITY, 25), height: axis(120.0, 280.0, f32::INFINITY, 25),
@@ -169,6 +169,8 @@ pub fn panel_body<'a>(
doc.engine.active_layer_id(), doc.engine.active_layer_id(),
&doc.engine, &doc.engine,
colors, colors,
&doc.layer_thumbnails,
doc.thumb_gen,
) )
} }
PaneType::History => { PaneType::History => {
@@ -250,7 +252,7 @@ pub fn panel_body<'a>(
app.script_is_running, app.script_is_running,
colors, 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::AiScript => crate::panels::ai_script_panel::view(app),
PaneType::LayerDetails => { PaneType::LayerDetails => {
let doc = &app.documents[app.active_doc]; let doc = &app.documents[app.active_doc];
@@ -261,16 +263,16 @@ pub fn panel_body<'a>(
) )
} }
PaneType::Geometry => { PaneType::Geometry => {
let shapes = app.documents[app.active_doc] let doc = &app.documents[app.active_doc];
.engine let shapes = doc.engine.active_vector_shapes().unwrap_or_default();
.active_vector_shapes() let active_id = doc.engine.active_layer_id();
.unwrap_or_default();
crate::panels::geometry::view( crate::panels::geometry::view(
shapes, shapes,
colors, colors,
app.documents[app.active_doc].selected_vector_shape, doc.selected_vector_shape,
app.bool_shape_a, app.bool_shape_a,
app.bool_shape_b, app.bool_shape_b,
active_id,
) )
} }
PaneType::ToolSettings => { PaneType::ToolSettings => {
@@ -14,6 +14,7 @@ mod dock;
mod i18n; mod i18n;
mod io; mod io;
mod panels; mod panels;
mod plugins;
mod raster; mod raster;
mod screenshot; mod screenshot;
mod script; mod script;
@@ -1,18 +1,152 @@
//! AI Chat panel — AI assistant with streaming SSE responses, tool execution, and DSL scripts. //! AI Assistant panel with two sub-tabs: ComfyUI pipeline viewer and AI Chat.
//! //!
//! Full implementation of the AI chat interface including provider selection, //! Mirrors the egui AI Assistant panel which has both ComfyUI and AI Chat tabs.
//! streaming response display, chat bubbles, reasoning toggle, and canvas context toggle. //! The active tab is controlled by `app.ai_assistant_tab` (0 = ComfyUI, 1 = AI Chat).
//! Uses ThemeColors for consistent styling. //! 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::app::Message;
use crate::panels::styles; use crate::panels::styles;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, text_input}; use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, text_input};
use iced::{Element, Length}; use iced::{Element, Length};
use crate::app::HcieIcedApp;
/// Build the AI chat panel with full streaming UI. /// Build the AI Assistant panel with tab switcher and sub-tab content.
pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Message> { 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 ── // ── Provider selector row ──
let provider_label = text("Provider:").size(10).color(colors.text_secondary); let provider_label = text("Provider:").size(10).color(colors.text_secondary);
let ollama_btn = make_provider_btn( let ollama_btn = make_provider_btn(
@@ -90,11 +224,14 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
.spacing(4) .spacing(4)
.align_y(iced::Alignment::Center); .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 ── // ── Message list ──
let mut msg_list = column![].spacing(6); let mut msg_list = column![].spacing(6);
if state.messages.is_empty() && !state.is_streaming { if state.messages.is_empty() && !state.is_streaming {
// Welcome message
let welcome = container( let welcome = container(
column![ column![
text("AI Creative Assistant").size(13).color(colors.accent), 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); msg_list = msg_list.push(welcome);
} else { } else {
// Render chat history
for msg in &state.messages { for msg in &state.messages {
let bubble = render_message_bubble(msg, colors); let bubble = render_message_bubble(msg, colors);
msg_list = msg_list.push(bubble); msg_list = msg_list.push(bubble);
} }
// Render streaming reasoning if enabled
if state.config.reasoning_enabled && !state.current_reasoning.is_empty() { if state.config.reasoning_enabled && !state.current_reasoning.is_empty() {
let reasoning_bubble = container( let reasoning_bubble = container(
column![ column![
@@ -156,7 +291,6 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
msg_list = msg_list.push(reasoning_bubble); msg_list = msg_list.push(reasoning_bubble);
} }
// Render streaming response if in progress
if state.is_streaming && !state.current_response.is_empty() { if state.is_streaming && !state.current_response.is_empty() {
let streaming_bubble = container( let streaming_bubble = container(
text(state.current_response.clone()) 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); msg_list = msg_list.push(streaming_bubble);
} }
// Streaming indicator
if state.is_streaming { if state.is_streaming {
let indicator = row![ let indicator = row![
text("...").size(10).color(colors.text_disabled), text("...").size(10).color(colors.text_disabled),
@@ -219,14 +352,10 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
.spacing(4) .spacing(4)
.align_y(iced::Alignment::Center); .align_y(iced::Alignment::Center);
// ── Assemble panel ── column![
let panel = column![
text("AI Assistant").size(13).color(colors.text_primary),
provider_row, provider_row,
config_row, config_row,
text("Model discovery is unavailable; enter the exact provider model ID.") hint,
.size(9)
.color(colors.text_disabled),
toggles_row, toggles_row,
horizontal_rule(1), horizontal_rule(1),
scrollable(msg_list).height(Length::Fill), scrollable(msg_list).height(Length::Fill),
@@ -234,16 +363,9 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
input_row, input_row,
] ]
.spacing(4) .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( fn action_button(
label: &'static str, label: &'static str,
message: Option<Message>, message: Option<Message>,
@@ -279,7 +401,6 @@ fn action_button(
.into() .into()
} }
/// Render a single chat message as a styled bubble.
fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'static, Message> { fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'static, Message> {
if msg.is_reasoning { if msg.is_reasoning {
return container( return container(
@@ -302,7 +423,6 @@ fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'sta
} }
match msg.role.as_str() { match msg.role.as_str() {
"user" => { "user" => {
// User bubble — right-aligned, blue tint
let label = text("You").size(9).color(colors.accent); let label = text("You").size(9).color(colors.accent);
let content = text(msg.content.clone()) let content = text(msg.content.clone())
.size(11) .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() row![iced::widget::Space::with_width(Length::Fill), bubble].into()
} }
"tool" => { "tool" => {
// Tool result — monospace pill
let content = text(msg.content.clone()).size(10).color(colors.accent); let content = text(msg.content.clone()).size(10).color(colors.accent);
container(content) container(content)
.padding([4, 8]) .padding([4, 8])
@@ -333,7 +452,6 @@ fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'sta
.into() .into()
} }
_ => { _ => {
// Assistant bubble — left-aligned, slate tint
let label = text("AI").size(9).color(colors.accent_green); let label = text("AI").size(9).color(colors.accent_green);
let content = text(msg.content.clone()) let content = text(msg.content.clone())
.size(11) .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( fn make_provider_btn(
label: &str, label: &str,
is_active: bool, is_active: bool,
@@ -12,12 +12,15 @@ use crate::panels::typography::{BODY, SECTION};
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use hcie_engine_api::{LineCap, VectorBooleanOp, VectorShape}; use hcie_engine_api::{LineCap, VectorBooleanOp, VectorShape};
use iced::widget::{ 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}; use iced::{Element, Length};
/// Human-readable label for a shape at a given index. /// Human-readable label for a shape at a given index.
fn shape_label(shapes: &[VectorShape], i: usize) -> String { fn shape_label(shapes: &[VectorShape], i: usize) -> String {
let name = shapes[i].name();
if name.is_empty() {
match &shapes[i] { match &shapes[i] {
VectorShape::Line { .. } => format!("Line #{}", i + 1), VectorShape::Line { .. } => format!("Line #{}", i + 1),
VectorShape::Rect { .. } => format!("Rect #{}", i + 1), VectorShape::Rect { .. } => format!("Rect #{}", i + 1),
@@ -37,6 +40,9 @@ fn shape_label(shapes: &[VectorShape], i: usize) -> String {
VectorShape::SvgShape { kind, .. } => format!("{} #{}", kind, i + 1), VectorShape::SvgShape { kind, .. } => format!("{} #{}", kind, i + 1),
VectorShape::FreePath { pts, .. } => format!("Path ({} pts) #{}", pts.len(), i + 1), VectorShape::FreePath { pts, .. } => format!("Path ({} pts) #{}", pts.len(), i + 1),
} }
} else {
name
}
} }
/// Build a section header bar with the given label. /// Build a section header bar with the given label.
@@ -80,6 +86,7 @@ pub fn view(
selected_shape: Option<usize>, selected_shape: Option<usize>,
bool_shape_a: Option<usize>, bool_shape_a: Option<usize>,
bool_shape_b: Option<usize>, bool_shape_b: Option<usize>,
layer_id: u64,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let has_shapes = !shapes.is_empty(); let has_shapes = !shapes.is_empty();
let shape_count = shapes.len(); let shape_count = shapes.len();
@@ -90,25 +97,46 @@ pub fn view(
content = content.push(section_header("SHAPES".to_string(), colors)); content = content.push(section_header("SHAPES".to_string(), colors));
if has_shapes { if has_shapes {
// Delete button in header row (right-aligned) // Delete and reorder buttons in header row (right-aligned)
if selected_shape.is_some() { if selected_shape.is_some() {
content = content.push( let sel = selected_shape.unwrap();
container( // The list is shown front-to-back: the last rendered shape (top of Z)
row![ // appears at the top of the list, and the first rendered shape (back)
Space::with_width(Length::Fill), // 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)) button(text("X").size(BODY))
.on_press(Message::VectorShapeDelete) .on_press(Message::VectorShapeDelete)
.padding([2, 6]), .padding([2, 6]),
] );
.width(Length::Fill), content = content.push(
) container(header.spacing(4).width(Length::Fill)).padding([2, 0]),
.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); 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 label = shape_label(&shapes, i);
let is_selected = selected_shape == Some(i); let is_selected = selected_shape == Some(i);
let c = colors; let c = colors;
@@ -261,6 +289,41 @@ pub fn view(
content = content.push(section_header(format!("GEOMETRY [#{}]", idx + 1), colors)); content = content.push(section_header(format!("GEOMETRY [#{}]", idx + 1), colors));
content = content.push(Space::with_height(4)); 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 (x1, y1, x2, y2) = shapes[idx].bounds();
let w = (x2 - x1).abs(); let w = (x2 - x1).abs();
let h = (y2 - y1).abs(); let h = (y2 - y1).abs();
@@ -6,10 +6,16 @@
//! 2. Scrollable layer list — each row shows indent, collapse arrow, visibility, thumbnail, //! 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. //! 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 //! 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::app::Message;
use crate::panels::styles; use crate::panels::styles;
use crate::panels::thumbnails;
use crate::panels::typography::BODY; use crate::panels::typography::BODY;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use crate::widgets::plain_slider::plain_slider; 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. /// Build a flat rendering order from the layer list, respecting group hierarchy.
/// Layers are returned in visual order: the top/front-most layer first and the
/// bottom/back-most layer last. This matches Photoshop-style panels where the
/// layer you see on top of the canvas appears at the top of the list.
fn build_tree<'a>(layers: &'a [LayerInfo]) -> Vec<LayerEntry<'a>> { fn build_tree<'a>(layers: &'a [LayerInfo]) -> Vec<LayerEntry<'a>> {
let mut children_of: std::collections::HashMap<Option<u64>, Vec<&LayerInfo>> = let mut children_of: std::collections::HashMap<Option<u64>, Vec<&LayerInfo>> =
std::collections::HashMap::new(); std::collections::HashMap::new();
@@ -95,6 +104,8 @@ fn build_tree<'a>(layers: &'a [LayerInfo]) -> Vec<LayerEntry<'a>> {
result: &mut Vec<LayerEntry<'a>>, result: &mut Vec<LayerEntry<'a>>,
) { ) {
if let Some(children) = children_of.get(&parent_id) { 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 { for info in children {
let is_group = info.layer_type == LayerType::Group; let is_group = info.layer_type == LayerType::Group;
result.push(LayerEntry { 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>( fn layer_thumbnail<'a>(
entry: &LayerEntry<'a>, entry: &LayerEntry<'a>,
engine: &hcie_engine_api::Engine, thumb_cache: &std::collections::HashMap<u64, (u32, u32, Vec<u8>)>,
_thumb_gen: u64,
colors: ThemeColors, colors: ThemeColors,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
if entry.is_group { if entry.is_group {
@@ -137,15 +149,12 @@ fn layer_thumbnail<'a>(
.into(); .into();
} }
let info = entry.info; if let Some((tw, th, pixels)) = thumb_cache.get(&entry.info.id) {
let thumb = engine let (tw32, th32) = (*tw as u32, *th as u32);
.get_layer_pixels(info.id) let handle = iced::widget::image::Handle::from_rgba(tw32, th32, pixels.clone());
.filter(|px| !px.is_empty() && info.width > 0 && info.height > 0) let img = iced::widget::Image::new(handle)
.map(|px| thumbnails::generate_thumbnail(&px, info.width, info.height, 48)); .width(Length::Fixed(*tw as f32))
.height(Length::Fixed(*th as f32));
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);
return container(img) return container(img)
.width(50) .width(50)
.height(38) .height(38)
@@ -159,7 +168,7 @@ fn layer_thumbnail<'a>(
} }
// Fallback: type-based icon // 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::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::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)), 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, active_layer_id: u64,
engine: &hcie_engine_api::Engine, engine: &hcie_engine_api::Engine,
colors: ThemeColors, colors: ThemeColors,
thumb_cache: &'a std::collections::HashMap<u64, (u32, u32, Vec<u8>)>,
thumb_gen: u64,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let entries = build_tree(layers); let entries = build_tree(layers);
@@ -315,7 +326,7 @@ pub fn view<'a>(
.style(move |_theme, _status| flat_btn_style(colors)); .style(move |_theme, _status| flat_btn_style(colors));
// Thumbnail // Thumbnail
let thumb_elem = layer_thumbnail(entry, engine, colors); let thumb_elem = layer_thumbnail(entry, thumb_cache, thumb_gen, colors);
// Layer name // Layer name
let c = if is_active { let c = if is_active {
@@ -16,6 +16,7 @@ use crate::app::Message;
use crate::dock::state::{DockState, PaneType}; use crate::dock::state::{DockState, PaneType};
use crate::panels::typography; use crate::panels::typography;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use hcie_engine_api::Tool;
use iced::widget::{column, container, horizontal_rule, mouse_area, row, text, Space}; use iced::widget::{column, container, horizontal_rule, mouse_area, row, text, Space};
use iced::{Element, Length}; 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. /// carry their path so reordering the recent list cannot change the selected file.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum MenuCommand { pub enum MenuCommand {
// File
NewDocument, NewDocument,
OpenFile, OpenFile,
Save, Save,
SaveAs, SaveAs,
OpenRecent(String), OpenRecent(String),
ClearRecent, ClearRecent,
ImportFile,
ImportSvg,
ImportBrushes,
Export,
// Edit
Undo, Undo,
Redo, Redo,
Copy, Copy,
Cut, Cut,
ClearPixels, ClearPixels,
Paste, Paste,
PasteSpecial,
Fill,
Stroke,
FreeTransform,
Preferences,
// Image
CanvasSize, CanvasSize,
ImageSize, ImageSize,
Rotate90CW,
Rotate90CCW,
Rotate180,
FlipHorizontal,
FlipVertical,
ImageCrop,
InvertNegative,
// Layer
AddLayer, AddLayer,
DeleteLayer, DeleteLayer,
LayerStyles, LayerStyles,
MergeDown, MergeDown,
Flatten, Flatten,
AlignLayer(String),
// Select
SelectAll, SelectAll,
Deselect, Deselect,
InvertSelection, InvertSelection,
@@ -57,16 +80,37 @@ pub enum MenuCommand {
SaveSelection, SaveSelection,
LoadSelection, LoadSelection,
ToggleQuickMask, 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, ZoomIn,
ZoomOut, ZoomOut,
FitArea, FitArea,
ActualPixels, ActualPixels,
ResetPan,
ViewerToggle,
// Window
TogglePane(PaneType),
OpenDocuments,
ResetLayout,
// Tool
SelectTool(Tool),
// Theme
SetTheme(crate::theme::ThemePreset),
// Misc
SelectCropTool, SelectCropTool,
GaussianBlur, GaussianBlur,
Mosaic, Mosaic,
UnsharpMask, UnsharpMask,
TogglePane(PaneType), About,
SetTheme(crate::theme::ThemePreset),
} }
/// Describes one rendered menu row and its optional semantic operation. /// Describes one rendered menu row and its optional semantic operation.
@@ -242,12 +286,17 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::new("Open & Place..."), MenuItem::new("Open & Place..."),
MenuItem::submenu("Open More"), MenuItem::submenu("Open More"),
MenuItem::separator(), 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::new("Share"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::command_with_shortcut("Save", "Ctrl+S", MenuCommand::Save), MenuItem::command_with_shortcut("Save", "Ctrl+S", MenuCommand::Save),
MenuItem::command("Save as PSD", MenuCommand::SaveAs), MenuItem::command("Save as PSD", MenuCommand::SaveAs),
MenuItem::submenu("Save More"), MenuItem::submenu("Save More"),
MenuItem::submenu("Export as"), MenuItem::submenu("Export as"),
MenuItem::command("Export...", MenuCommand::Export),
MenuItem::with_shortcut("Print...", "Ctrl+P"), MenuItem::with_shortcut("Print...", "Ctrl+P"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::new("Export Layers..."), MenuItem::new("Export Layers..."),
@@ -292,13 +341,15 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::command_with_shortcut("Paste", "Ctrl+V", MenuCommand::Paste), MenuItem::command_with_shortcut("Paste", "Ctrl+V", MenuCommand::Paste),
MenuItem::command("Clear", MenuCommand::ClearPixels), MenuItem::command("Clear", MenuCommand::ClearPixels),
MenuItem::separator(), MenuItem::separator(),
MenuItem::with_shortcut("Fill...", "Shift+F5"), MenuItem::command_with_shortcut("Paste Special", "Shift+Ctrl+V", MenuCommand::PasteSpecial),
MenuItem::new("Stroke..."), MenuItem::separator(),
MenuItem::command_with_shortcut("Fill...", "Shift+F5", MenuCommand::Fill),
MenuItem::command("Stroke...", MenuCommand::Stroke),
MenuItem::separator(), MenuItem::separator(),
MenuItem::new("Content-Aware Scale"), MenuItem::new("Content-Aware Scale"),
MenuItem::new("Puppet Warp"), MenuItem::new("Puppet Warp"),
MenuItem::new("Perspective 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::submenu("Transform"),
MenuItem::new("Auto-Align"), MenuItem::new("Auto-Align"),
MenuItem::new("Auto-Blend"), MenuItem::new("Auto-Blend"),
@@ -308,10 +359,40 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::separator(), MenuItem::separator(),
MenuItem::submenu("Define New"), MenuItem::submenu("Define New"),
MenuItem::new("Preset Manager..."), MenuItem::new("Preset Manager..."),
MenuItem::with_shortcut("Preferences...", "Ctrl+K"), MenuItem::command_with_shortcut("Preferences...", "Ctrl+K", MenuCommand::Preferences),
MenuItem::new("Local Storage..."), 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) ── // ── Image menu (Photopea-style) ──
MenuDef { MenuDef {
label: "Image".to_string(), label: "Image".to_string(),
@@ -337,10 +418,16 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuCommand::ImageSize, MenuCommand::ImageSize,
), ),
MenuItem::submenu("Transform"), 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::with_shortcut("Trim...", "Ctrl+."),
MenuItem::new("Reveal All"), MenuItem::new("Reveal All"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::command("Invert / Negative", MenuCommand::InvertNegative),
MenuItem::new("Duplicate"), MenuItem::new("Duplicate"),
MenuItem::new("Apply Image..."), MenuItem::new("Apply Image..."),
MenuItem::separator(), MenuItem::separator(),
@@ -372,6 +459,12 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::separator(), MenuItem::separator(),
MenuItem::with_shortcut("Group Layers", "Ctrl+G"), MenuItem::with_shortcut("Group Layers", "Ctrl+G"),
MenuItem::submenu("Arrange"), 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("Combine Shapes"),
MenuItem::submenu("Animation"), MenuItem::submenu("Animation"),
MenuItem::separator(), MenuItem::separator(),
@@ -416,6 +509,8 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
), ),
MenuItem::command("Border...", MenuCommand::BorderSelection), MenuItem::command("Border...", MenuCommand::BorderSelection),
MenuItem::command("Smooth...", MenuCommand::SmoothSelection), MenuItem::command("Smooth...", MenuCommand::SmoothSelection),
MenuItem::command("Erode Border", MenuCommand::ErodeBorder),
MenuItem::command("Fade Border", MenuCommand::FadeBorder),
MenuItem::separator(), MenuItem::separator(),
MenuItem::command( MenuItem::command(
"New Selection", "New Selection",
@@ -459,17 +554,92 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::new("Liquify..."), MenuItem::new("Liquify..."),
MenuItem::new("Vanishing Point..."), MenuItem::new("Vanishing Point..."),
MenuItem::separator(), MenuItem::separator(),
MenuItem::submenu("3D"), // ── Blur ──
MenuItem::submenu("Blur"), MenuItem::disabled("Blur"),
MenuItem::submenu("Blur Gallery"), MenuItem::command("Box Blur", MenuCommand::FilterDistort("box_blur".into())),
MenuItem::submenu("Distort"), MenuItem::command("Gaussian Blur", MenuCommand::FilterDistort("gaussian_blur".into())),
MenuItem::submenu("Noise"), MenuItem::command("Motion Blur", MenuCommand::FilterDistort("motion_blur".into())),
MenuItem::submenu("Pixelate"), MenuItem::command("Unsharp Mask", MenuCommand::FilterRestore("unsharp_mask".into())),
MenuItem::submenu("Render"), MenuItem::separator(),
MenuItem::submenu("Sharpen"), // ── Distort ──
MenuItem::submenu("Stylize"), MenuItem::disabled("Distort"),
MenuItem::submenu("Other"), MenuItem::command("Pinch / Punch", MenuCommand::FilterDistort("pinch".into())),
MenuItem::submenu("Fourier"), 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) ── // ── View menu (Photopea-style) ──
@@ -484,6 +654,7 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
"Ctrl+1", "Ctrl+1",
MenuCommand::ActualPixels, MenuCommand::ActualPixels,
), ),
MenuItem::command("Reset Pan", MenuCommand::ResetPan),
MenuItem::new("Pattern Preview"), MenuItem::new("Pattern Preview"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::submenu("Mode"), MenuItem::submenu("Mode"),
@@ -501,6 +672,8 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::new("Guides from Layer"), MenuItem::new("Guides from Layer"),
MenuItem::separator(), MenuItem::separator(),
MenuItem::new("Clear Slices"), MenuItem::new("Clear Slices"),
MenuItem::separator(),
MenuItem::command("Viewer Mode", MenuCommand::ViewerToggle),
], ],
}, },
// ── Window menu (Photopea-style) ── // ── Window menu (Photopea-style) ──
@@ -552,6 +725,9 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
"Theme: ProLight (Nord)", "Theme: ProLight (Nord)",
MenuCommand::SetTheme(crate::theme::ThemePreset::ProLight), MenuCommand::SetTheme(crate::theme::ThemePreset::ProLight),
), ),
MenuItem::separator(),
MenuItem::command("Open Documents", MenuCommand::OpenDocuments),
MenuItem::command("Reset Layout", MenuCommand::ResetLayout),
], ],
}, },
// ── More menu (Photopea-style) ── // ── More menu (Photopea-style) ──
@@ -581,6 +757,8 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::new("Swatches"), MenuItem::new("Swatches"),
MenuItem::new("Tool Presets"), MenuItem::new("Tool Presets"),
MenuItem::new("Vector Info"), MenuItem::new("Vector Info"),
MenuItem::separator(),
MenuItem::command("About", MenuCommand::About),
], ],
}, },
] ]
@@ -616,7 +794,7 @@ pub fn dropdown_overlay(
let enabled = item.enabled; let enabled = item.enabled;
// Check if this is a Window menu item that should show a checkmark // 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 is_checked = if is_window_panel {
let pane_type = match item_idx { let pane_type = match item_idx {
0 => Some(PaneType::Layers), 0 => Some(PaneType::Layers),
@@ -932,8 +1110,12 @@ mod tests {
let expected = vec![ let expected = vec![
"File/New...", "File/New...",
"File/Open...", "File/Open...",
"File/Import...",
"File/Import SVG...",
"File/Import Brushes...",
"File/Save", "File/Save",
"File/Save as PSD", "File/Save as PSD",
"File/Export...",
"Edit/Undo / Redo", "Edit/Undo / Redo",
"Edit/Step Forward", "Edit/Step Forward",
"Edit/Step Backward", "Edit/Step Backward",
@@ -941,11 +1123,49 @@ mod tests {
"Edit/Copy", "Edit/Copy",
"Edit/Paste", "Edit/Paste",
"Edit/Clear", "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/Canvas Size...",
"Image/Image 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/New",
"Layer/Delete", "Layer/Delete",
"Layer/Layer Style", "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/Merge Down",
"Layer/Flatten Image", "Layer/Flatten Image",
"Select/All", "Select/All",
@@ -956,6 +1176,8 @@ mod tests {
"Select/Feather...", "Select/Feather...",
"Select/Border...", "Select/Border...",
"Select/Smooth...", "Select/Smooth...",
"Select/Erode Border",
"Select/Fade Border",
"Select/New Selection", "Select/New Selection",
"Select/Add to Selection", "Select/Add to Selection",
"Select/Subtract from Selection", "Select/Subtract from Selection",
@@ -963,10 +1185,58 @@ mod tests {
"Select/Quick Mask Mode", "Select/Quick Mask Mode",
"Select/Load Selection", "Select/Load Selection",
"Select/Save 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 In",
"View/Zoom Out", "View/Zoom Out",
"View/Fit The Area", "View/Fit The Area",
"View/Pixel to Pixel", "View/Pixel to Pixel",
"View/Reset Pan",
"View/Viewer Mode",
"Window/Layers", "Window/Layers",
"Window/History", "Window/History",
"Window/Brushes & Tips", "Window/Brushes & Tips",
@@ -984,6 +1254,9 @@ mod tests {
"Window/Theme: Amoled (Dracula)", "Window/Theme: Amoled (Dracula)",
"Window/Theme: PhotoshopLight (Solarized)", "Window/Theme: PhotoshopLight (Solarized)",
"Window/Theme: ProLight (Nord)", "Window/Theme: ProLight (Nord)",
"Window/Open Documents",
"Window/Reset Layout",
"More/About",
]; ];
assert_eq!(actual, expected); assert_eq!(actual, expected);
@@ -16,6 +16,7 @@ pub mod properties;
pub mod script_panel; pub mod script_panel;
pub mod status_bar; pub mod status_bar;
pub mod styles; pub mod styles;
pub mod svg_editor;
pub mod text_editor; pub mod text_editor;
pub mod thumbnails; pub mod thumbnails;
pub mod title_bar; pub mod title_bar;
@@ -17,6 +17,10 @@ use iced::{Element, Length};
/// Human-readable label for a shape at a given index. /// Human-readable label for a shape at a given index.
fn shape_label(shapes: &[VectorShape], i: usize) -> String { fn shape_label(shapes: &[VectorShape], i: usize) -> String {
let name = shapes[i].name();
if !name.is_empty() {
return name;
}
match &shapes[i] { match &shapes[i] {
VectorShape::Line { .. } => format!("Line #{}", i + 1), VectorShape::Line { .. } => format!("Line #{}", i + 1),
VectorShape::Rect { .. } => format!("Rect #{}", i + 1), VectorShape::Rect { .. } => format!("Rect #{}", i + 1),
@@ -132,7 +136,13 @@ pub fn view<'a>(
} }
Tool::Select => selection_properties(selection_rect, colors), Tool::Select => selection_properties(selection_rect, colors),
Tool::VectorSelect => { 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::VectorRect
| Tool::VectorCircle | Tool::VectorCircle
@@ -251,7 +261,8 @@ fn vector_select_properties<'a>(
selected: Option<usize>, selected: Option<usize>,
shapes: &[VectorShape], shapes: &[VectorShape],
vector_angle: f32, vector_angle: f32,
_colors: ThemeColors, active_layer_id: u64,
colors: ThemeColors,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
match selected.and_then(|idx| shapes.get(idx).map(|s| (idx, s))) { match selected.and_then(|idx| shapes.get(idx).map(|s| (idx, s))) {
Some((idx, shape)) => { Some((idx, shape)) => {
@@ -283,10 +294,32 @@ fn vector_select_properties<'a>(
VectorShape::FreePath { .. } => "Free Path".into(), 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![ column![
text("Shape Properties").size(SECTION), text("Shape Properties").size(SECTION),
row![text("Shape").size(BODY), text(label).size(BODY)].spacing(4), row![text("Shape").size(BODY), text(label).size(BODY)].spacing(4),
row![text("Type").size(BODY), text(shape_type).size(BODY)].spacing(4), row![text("Type").size(BODY), text(shape_type).size(BODY)].spacing(4),
edit_svg_btn,
plain_slider( plain_slider(
"Stroke", "Stroke",
stroke, stroke,
@@ -0,0 +1,878 @@
//! Interactive SVG node editor for simple polygon/path SVG shapes.
//!
//! The editor renders the parsed `SvgEditable` model on an iced Canvas. Nodes
//! can be selected, dragged, inserted at edge midpoints, removed, or edited by
//! numeric coordinates. Each interaction emits an application message so the
//! authoritative editor state remains in `HcieIcedApp` and the main canvas can
//! receive a live SVG preview.
use crate::app::Message;
use crate::theme::ThemeColors;
use iced::mouse;
use iced::widget::canvas::{self, Canvas, Frame, Path, Program, Stroke};
use iced::widget::{button, checkbox, column, container, row, scrollable, text, text_input, Space};
use iced::{Color, Element, Length, Point, Rectangle, Size, Vector};
use usvg::tiny_skia_path::PathSegment;
/// Parse an SVG into the engine API's editable polygon model.
///
/// **Purpose:** Keeps native line nodes intact when possible and provides a visual-editing fallback
/// for curves and arcs, which `SvgEditable::from_svg` intentionally rejects. **Logic & Workflow:**
/// First uses the public engine parser; on failure, `usvg` normalizes the document and curved
/// segments are sampled into line nodes. **Arguments:** `svg` is the complete SVG source.
/// **Returns:** An editable model or a descriptive parse error. **Side Effects / Dependencies:**
/// Parses XML through `usvg`; it performs no file or engine mutation.
pub fn parse_editable(svg: &str) -> Result<hcie_engine_api::SvgEditable, String> {
hcie_engine_api::SvgEditable::from_svg(svg).or_else(|_| flatten_svg(svg))
}
/// Flatten all rendered SVG paths into editable polygons.
///
/// **Arguments:** `svg` is complete SVG source. **Returns:** A polygon model preserving the source
/// view box. **Logic & Workflow:** Traverses normalized `usvg` paths and delegates curve sampling to
/// `flatten_group`. **Side Effects / Dependencies:** Uses `usvg` parsing only.
fn flatten_svg(svg: &str) -> Result<hcie_engine_api::SvgEditable, String> {
let tree = usvg::Tree::from_data(svg.as_bytes(), &usvg::Options::default())
.map_err(|error| error.to_string())?;
let mut polygons = Vec::new();
flatten_group(tree.root(), &mut polygons);
if polygons.is_empty() {
return Err("SVG does not contain an editable path".to_string());
}
Ok(hcie_engine_api::SvgEditable {
view_box: parse_view_box(svg).unwrap_or((0.0, 0.0, 1.0, 1.0)),
polygons,
})
}
/// Recursively collect line and sampled curve nodes from a normalized SVG group.
///
/// **Arguments:** `group` is the current `usvg` group and `output` receives editable paths.
/// **Returns:** Nothing. **Logic & Workflow:** Move/line endpoints are retained; quadratic and cubic
/// segments are sampled at a screen-independent density; close commands flush one polygon.
/// **Side Effects / Dependencies:** Appends to `output`.
fn flatten_group(group: &usvg::Group, output: &mut Vec<Vec<hcie_engine_api::SvgNode>>) {
for child in group.children() {
match child {
usvg::Node::Path(path) => {
let mut current = Vec::new();
let mut cursor = Point::ORIGIN;
for segment in path.data().segments() {
match segment {
PathSegment::MoveTo(point) => {
flush_path(&mut current, output);
cursor = Point::new(point.x, point.y);
current.push(hcie_engine_api::SvgNode {
x: point.x,
y: point.y,
});
}
PathSegment::LineTo(point) => {
cursor = Point::new(point.x, point.y);
current.push(hcie_engine_api::SvgNode {
x: point.x,
y: point.y,
});
}
PathSegment::QuadTo(control, point) => {
let start = cursor;
for step in 1..=12 {
let t = step as f32 / 12.0;
let inverse = 1.0 - t;
current.push(hcie_engine_api::SvgNode {
x: inverse * inverse * start.x
+ 2.0 * inverse * t * control.x
+ t * t * point.x,
y: inverse * inverse * start.y
+ 2.0 * inverse * t * control.y
+ t * t * point.y,
});
}
cursor = Point::new(point.x, point.y);
}
PathSegment::CubicTo(first, second, point) => {
let start = cursor;
for step in 1..=16 {
let t = step as f32 / 16.0;
let inverse = 1.0 - t;
current.push(hcie_engine_api::SvgNode {
x: inverse.powi(3) * start.x
+ 3.0 * inverse * inverse * t * first.x
+ 3.0 * inverse * t * t * second.x
+ t.powi(3) * point.x,
y: inverse.powi(3) * start.y
+ 3.0 * inverse * inverse * t * first.y
+ 3.0 * inverse * t * t * second.y
+ t.powi(3) * point.y,
});
}
cursor = Point::new(point.x, point.y);
}
PathSegment::Close => flush_path(&mut current, output),
}
}
flush_path(&mut current, output);
}
usvg::Node::Group(child_group) => flatten_group(child_group, output),
_ => {}
}
}
}
/// Move a completed subpath into the editable output when it can form a visible polygon.
///
/// **Arguments:** `current` is the active node buffer and `output` receives valid paths.
/// **Returns:** Nothing. **Side Effects:** Clears `current` and may append to `output`.
fn flush_path(
current: &mut Vec<hcie_engine_api::SvgNode>,
output: &mut Vec<Vec<hcie_engine_api::SvgNode>>,
) {
if current.len() > 2 {
output.push(std::mem::take(current));
} else {
current.clear();
}
}
/// Extract a case-insensitive SVG `viewBox` attribute.
///
/// **Arguments:** `svg` is raw XML. **Returns:** `(min_x, min_y, width, height)` when valid.
/// **Side Effects:** None.
fn parse_view_box(svg: &str) -> Option<(f32, f32, f32, f32)> {
let lower = svg.to_ascii_lowercase();
let start = lower.find("viewbox")?;
let suffix = &svg[start + "viewbox".len()..];
let equals = suffix.find('=')?;
let value = suffix[equals + 1..].trim_start();
let quote = value.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
let value = &value[quote.len_utf8()..];
let end = value.find(quote)?;
let numbers: Vec<f32> = value[..end]
.split(|character: char| character.is_ascii_whitespace() || character == ',')
.filter(|part| !part.is_empty())
.filter_map(|part| part.parse().ok())
.collect();
(numbers.len() == 4).then(|| (numbers[0], numbers[1], numbers[2], numbers[3]))
}
/// Runtime state for the SVG editor dialog.
pub struct SvgEditorState {
/// Engine layer containing the edited vector shape.
pub layer_id: u64,
/// Index of the SVG shape within the vector layer.
pub shape_idx: usize,
/// SVG text captured before the editing session began.
pub original_svg: String,
/// Mutable polygon/node model used by the visual editor.
pub editable: hcie_engine_api::SvgEditable,
/// Currently selected polygon.
pub selected_poly: usize,
/// Currently selected node in `selected_poly`.
pub selected_node: Option<usize>,
/// View zoom factor.
pub zoom: f32,
/// View pan offset in dialog-canvas pixels.
pub pan: Vector,
/// Editable X-coordinate text, retained while the user types.
pub x_input: String,
/// Editable Y-coordinate text, retained while the user types.
pub y_input: String,
/// Whether moved nodes are quantized to `grid_size` SVG units.
pub snap_to_grid: bool,
/// Grid interval in SVG view-box units.
pub grid_size: f32,
/// Editable grid interval text, retained while the user types.
pub grid_input: String,
}
impl SvgEditorState {
/// Select a node and synchronize the persistent coordinate inputs.
///
/// **Arguments:** `polygon` and `node` identify an existing model point.
/// **Returns:** Nothing. **Side Effects:** Updates selection and text-entry state.
pub fn select_node(&mut self, polygon: usize, node: usize) {
let Some(value) = self
.editable
.polygons
.get(polygon)
.and_then(|path| path.get(node))
.copied()
else {
return;
};
self.selected_poly = polygon;
self.selected_node = Some(node);
self.x_input = format_coordinate(value.x);
self.y_input = format_coordinate(value.y);
}
/// Quantize a coordinate when grid snapping is enabled.
///
/// **Arguments:** `value` is an SVG view-box coordinate. **Returns:** The original or nearest
/// grid-aligned coordinate. **Side Effects:** None.
pub fn snapped(&self, value: f32) -> f32 {
if self.snap_to_grid && self.grid_size.is_finite() && self.grid_size > f32::EPSILON {
(value / self.grid_size).round() * self.grid_size
} else {
value
}
}
/// Refresh coordinate inputs from the currently selected model node.
///
/// **Arguments/Returns:** None. **Side Effects:** Replaces X/Y text with model coordinates.
pub fn refresh_coordinate_inputs(&mut self) {
let Some(node) = self.selected_node.and_then(|node| {
self.editable
.polygons
.get(self.selected_poly)
.and_then(|path| path.get(node))
}) else {
self.x_input.clear();
self.y_input.clear();
return;
};
self.x_input = format_coordinate(node.x);
self.y_input = format_coordinate(node.y);
}
}
/// Format SVG coordinates compactly while retaining useful sub-pixel precision.
///
/// **Arguments:** `value` is an SVG coordinate. **Returns:** A trimmed decimal string.
/// **Side Effects:** None.
fn format_coordinate(value: f32) -> String {
let formatted = format!("{value:.4}");
formatted
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
}
/// Ephemeral pointer state owned by the Canvas program.
#[derive(Debug, Default, Clone, Copy)]
struct InteractionState {
dragging: Option<(usize, usize)>,
panning: bool,
last_pointer: Option<Point>,
}
/// Canvas program that draws the SVG model and translates pointer gestures to
/// SVG coordinates.
#[derive(Debug, Clone)]
struct SvgCanvasProgram {
editable: hcie_engine_api::SvgEditable,
selected_poly: usize,
selected_node: Option<usize>,
zoom: f32,
pan: Vector,
grid_size: f32,
colors: ThemeColors,
}
impl SvgCanvasProgram {
/// Compute the uniform viewBox-to-screen transform.
fn transform(&self, bounds: Rectangle) -> (f32, f32, f32) {
let (_, _, view_w, view_h) = self.editable.view_box;
let margin = 24.0;
let scale = ((bounds.width - margin * 2.0) / view_w.max(1.0))
.min((bounds.height - margin * 2.0) / view_h.max(1.0))
.max(0.01)
* self.zoom;
let draw_w = view_w * scale;
let draw_h = view_h * scale;
let ox = (bounds.width - draw_w) * 0.5 + self.pan.x;
let oy = (bounds.height - draw_h) * 0.5 + self.pan.y;
(ox, oy, scale)
}
/// Convert an SVG coordinate into a local canvas point.
fn svg_to_screen(&self, bounds: Rectangle, x: f32, y: f32) -> Point {
let (vbx, vby, _, _) = self.editable.view_box;
let (ox, oy, scale) = self.transform(bounds);
Point::new(ox + (x - vbx) * scale, oy + (y - vby) * scale)
}
/// Convert a local canvas point into an SVG coordinate.
fn screen_to_svg(&self, bounds: Rectangle, point: Point) -> (f32, f32) {
let (vbx, vby, _, _) = self.editable.view_box;
let (ox, oy, scale) = self.transform(bounds);
((point.x - ox) / scale + vbx, (point.y - oy) / scale + vby)
}
/// Find the nearest node in screen space.
fn hit_node(&self, bounds: Rectangle, point: Point) -> Option<(usize, usize)> {
let mut best = None;
for (polygon, nodes) in self.editable.polygons.iter().enumerate() {
for (node, value) in nodes.iter().enumerate() {
let distance = self.svg_to_screen(bounds, value.x, value.y).distance(point);
if distance <= 10.0
&& best
.as_ref()
.is_none_or(|(_, _, best_distance)| distance < *best_distance)
{
best = Some((polygon, node, distance));
}
}
}
best.map(|(polygon, node, _)| (polygon, node))
}
/// Find an edge midpoint in screen space for insertion.
fn hit_midpoint(&self, bounds: Rectangle, point: Point) -> Option<(usize, usize)> {
let mut best = None;
for (polygon, nodes) in self.editable.polygons.iter().enumerate() {
if nodes.len() < 2 {
continue;
}
for edge in 0..nodes.len() {
let a = self.svg_to_screen(bounds, nodes[edge].x, nodes[edge].y);
let b = self.svg_to_screen(
bounds,
nodes[(edge + 1) % nodes.len()].x,
nodes[(edge + 1) % nodes.len()].y,
);
let midpoint = Point::new((a.x + b.x) * 0.5, (a.y + b.y) * 0.5);
let distance = midpoint.distance(point);
if distance <= 9.0
&& best
.as_ref()
.is_none_or(|(_, _, best_distance)| distance < *best_distance)
{
best = Some((polygon, edge, distance));
}
}
}
best.map(|(polygon, edge, _)| (polygon, edge))
}
}
impl Program<Message> for SvgCanvasProgram {
type State = InteractionState;
fn draw(
&self,
_state: &Self::State,
renderer: &iced::Renderer,
_theme: &iced::Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let mut frame = Frame::new(renderer, bounds.size());
frame.fill(
&Path::rectangle(Point::ORIGIN, bounds.size()),
self.colors.bg_app,
);
let (_, _, view_w, view_h) = self.editable.view_box;
let (ox, oy, scale) = self.transform(bounds);
let grid_step = (self.grid_size.max(0.0001) * scale).max(8.0);
let grid_color = Color::from_rgba(1.0, 1.0, 1.0, 0.06);
let mut x = ox.rem_euclid(grid_step);
while x < bounds.width {
frame.stroke(
&Path::line(Point::new(x, 0.0), Point::new(x, bounds.height)),
Stroke::default().with_color(grid_color).with_width(1.0),
);
x += grid_step;
}
let mut y = oy.rem_euclid(grid_step);
while y < bounds.height {
frame.stroke(
&Path::line(Point::new(0.0, y), Point::new(bounds.width, y)),
Stroke::default().with_color(grid_color).with_width(1.0),
);
y += grid_step;
}
let view_box_rect = Path::rectangle(
Point::new(ox, oy),
Size::new(view_w * scale, view_h * scale),
);
frame.stroke(
&view_box_rect,
Stroke::default()
.with_color(self.colors.border_high)
.with_width(1.0),
);
for (polygon_idx, polygon) in self.editable.polygons.iter().enumerate() {
if polygon.is_empty() {
continue;
}
let path = Path::new(|builder| {
let first = self.svg_to_screen(bounds, polygon[0].x, polygon[0].y);
builder.move_to(first);
for node in polygon.iter().skip(1) {
builder.line_to(self.svg_to_screen(bounds, node.x, node.y));
}
if polygon.len() > 2 {
builder.close();
}
});
let fill = if polygon_idx == self.selected_poly {
Color::from_rgba(
self.colors.accent.r,
self.colors.accent.g,
self.colors.accent.b,
0.24,
)
} else {
Color::from_rgba(
self.colors.text_secondary.r,
self.colors.text_secondary.g,
self.colors.text_secondary.b,
0.10,
)
};
frame.fill(&path, fill);
frame.stroke(
&path,
Stroke::default()
.with_color(if polygon_idx == self.selected_poly {
self.colors.accent
} else {
self.colors.text_secondary
})
.with_width(1.5),
);
for edge in 0..polygon.len() {
if polygon.len() < 2 {
break;
}
let a = self.svg_to_screen(bounds, polygon[edge].x, polygon[edge].y);
let b = self.svg_to_screen(
bounds,
polygon[(edge + 1) % polygon.len()].x,
polygon[(edge + 1) % polygon.len()].y,
);
let midpoint = Point::new((a.x + b.x) * 0.5, (a.y + b.y) * 0.5);
frame.fill(
&Path::circle(midpoint, 3.5),
Color::from_rgb(0.55, 0.55, 0.55),
);
}
for (node_idx, node) in polygon.iter().enumerate() {
let point = self.svg_to_screen(bounds, node.x, node.y);
let selected =
polygon_idx == self.selected_poly && self.selected_node == Some(node_idx);
frame.fill(
&Path::circle(point, if selected { 6.0 } else { 5.0 }),
if selected {
self.colors.accent
} else {
Color::WHITE
},
);
frame.stroke(
&Path::circle(point, if selected { 6.0 } else { 5.0 }),
Stroke::default().with_color(Color::BLACK).with_width(1.5),
);
}
}
vec![frame.into_geometry()]
}
fn update(
&self,
state: &mut Self::State,
event: canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> (canvas::event::Status, Option<Message>) {
let position = cursor.position_in(bounds);
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
let Some(position) = position else {
return (canvas::event::Status::Ignored, None);
};
if let Some((polygon, node)) = self.hit_node(bounds, position) {
state.dragging = Some((polygon, node));
return (
canvas::event::Status::Captured,
Some(Message::SvgEditorSelectNode { polygon, node }),
);
}
if let Some((polygon, edge)) = self.hit_midpoint(bounds, position) {
return (
canvas::event::Status::Captured,
Some(Message::SvgEditorCanvasAddNode { polygon, edge }),
);
}
(
canvas::event::Status::Captured,
Some(Message::SvgEditorClearSelection),
)
}
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)) => {
state.panning = true;
state.last_pointer = position;
(canvas::event::Status::Captured, None)
}
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
let Some(position) = position else {
return (canvas::event::Status::Ignored, None);
};
if let Some((polygon, node)) = state.dragging {
let (x, y) = self.screen_to_svg(bounds, position);
return (
canvas::event::Status::Captured,
Some(Message::SvgEditorMoveNode {
polygon,
node,
x,
y,
}),
);
}
if state.panning {
let previous = state.last_pointer.replace(position).unwrap_or(position);
return (
canvas::event::Status::Captured,
Some(Message::SvgEditorPan {
dx: position.x - previous.x,
dy: position.y - previous.y,
}),
);
}
(canvas::event::Status::Ignored, None)
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
state.dragging = None;
(
canvas::event::Status::Captured,
Some(Message::SvgEditorCanvasReleased),
)
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)) => {
state.panning = false;
state.last_pointer = None;
(canvas::event::Status::Captured, None)
}
canvas::Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
let amount = match delta {
mouse::ScrollDelta::Lines { y, .. } => y,
mouse::ScrollDelta::Pixels { y, .. } => y / 100.0,
};
if amount.abs() > f32::EPSILON {
let factor = if amount > 0.0 { 1.1 } else { 1.0 / 1.1 };
return (
canvas::event::Status::Captured,
Some(Message::SvgEditorZoom(factor)),
);
}
(canvas::event::Status::Ignored, None)
}
_ => (canvas::event::Status::Ignored, None),
}
}
/// Show precise interaction cursors over nodes, insertion handles, and the canvas surface.
///
/// **Arguments:** Runtime state, canvas bounds, and cursor location. **Returns:** The cursor
/// icon representing the available interaction. **Side Effects:** None.
fn mouse_interaction(
&self,
state: &Self::State,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> mouse::Interaction {
if state.dragging.is_some() {
return mouse::Interaction::Grabbing;
}
if state.panning {
return mouse::Interaction::Grabbing;
}
let Some(position) = cursor.position_in(bounds) else {
return mouse::Interaction::default();
};
if self.hit_node(bounds, position).is_some() {
mouse::Interaction::Grab
} else if self.hit_midpoint(bounds, position).is_some() {
mouse::Interaction::Crosshair
} else {
mouse::Interaction::default()
}
}
}
/// Build the visual SVG editor dialog.
pub fn view(state: &SvgEditorState, colors: ThemeColors) -> Element<'static, Message> {
let poly_count = state.editable.polygons.len();
if poly_count == 0 {
return container(text("No polygons to edit").size(12))
.padding(24)
.into();
}
let selected_poly = state.selected_poly.min(poly_count - 1);
let selected_node = state
.selected_node
.filter(|idx| *idx < state.editable.polygons[selected_poly].len());
let selected_coords = selected_node.and_then(|idx| {
state.editable.polygons[selected_poly]
.get(idx)
.map(|node| (node.x, node.y))
});
let canvas = Canvas::new(SvgCanvasProgram {
editable: state.editable.clone(),
selected_poly,
selected_node,
zoom: state.zoom,
pan: state.pan,
grid_size: state.grid_size,
colors,
})
.width(Length::Fill)
.height(Length::Fill);
let mut polygons = column![text("ELEMENTS").size(10)].spacing(3);
for index in 0..poly_count {
let label = format!(
"Path {} · {} nodes",
index + 1,
state.editable.polygons[index].len()
);
polygons = polygons.push(
button(text(label).size(10))
.on_press(Message::SvgEditorSelectPoly(index))
.width(Length::Fill)
.padding([3, 6])
.style(move |_theme, _status| iced::widget::button::Style {
background: Some(iced::Background::Color(if index == selected_poly {
colors.accent
} else {
colors.bg_hover
})),
text_color: if index == selected_poly {
Color::WHITE
} else {
colors.text_primary
},
border: iced::Border::default().rounded(3),
..Default::default()
}),
);
}
polygons = polygons.push(Space::with_height(8));
polygons = polygons.push(text("NODES").size(10));
for (index, node) in state.editable.polygons[selected_poly].iter().enumerate() {
let selected = selected_node == Some(index);
let label = format!(
"{:02} {:>8} {:>8}",
index + 1,
format_coordinate(node.x),
format_coordinate(node.y)
);
polygons = polygons.push(
button(text(label).size(10))
.on_press(Message::SvgEditorSelectNode {
polygon: selected_poly,
node: index,
})
.width(Length::Fill)
.padding([3, 6])
.style(move |_theme, status| iced::widget::button::Style {
background: Some(iced::Background::Color(if selected {
colors.accent
} else if matches!(status, iced::widget::button::Status::Hovered) {
colors.bg_hover
} else {
colors.bg_panel
})),
text_color: if selected {
Color::WHITE
} else {
colors.text_primary
},
border: iced::Border::default().rounded(3),
..Default::default()
}),
);
}
let path_list = container(scrollable(polygons).height(Length::Fill))
.width(220)
.height(Length::Fill)
.padding(8)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_app)),
border: iced::Border::default().color(colors.border_high).width(1),
..Default::default()
});
let coordinates: Element<'static, Message> = if selected_coords.is_some() {
row![
text("NODE").size(10),
text(format!("#{}", selected_node.unwrap_or(0) + 1)).size(10),
text("X").size(10),
text_input("X", &state.x_input)
.on_input(Message::SvgEditorXChanged)
.width(96),
text("Y").size(10),
text_input("Y", &state.y_input)
.on_input(Message::SvgEditorYChanged)
.width(96),
button(text("Delete node").size(10))
.on_press(Message::SvgEditorRemoveNode)
.padding([4, 8]),
]
.spacing(4)
.align_y(iced::Alignment::Center)
.into()
} else {
row![text("Select and drag a white node, or click a gray edge handle to insert.").size(10)]
.into()
};
let controls = row![
button(text("Insert after").size(10))
.on_press(Message::SvgEditorAddNode)
.padding([4, 8]),
button(text("Fit").size(10))
.on_press(Message::SvgEditorFit)
.padding([4, 8]),
button(text("-").size(10))
.on_press(Message::SvgEditorZoom(1.0 / 1.2))
.padding([4, 8]),
button(text("+").size(10))
.on_press(Message::SvgEditorZoom(1.2))
.padding([4, 8]),
text(format!("{:.0}%", state.zoom * 100.0)).size(10),
checkbox("Snap", state.snap_to_grid).on_toggle(Message::SvgEditorSnapChanged),
text("Grid").size(10),
text_input("Grid", &state.grid_input)
.on_input(Message::SvgEditorGridChanged)
.width(64),
Space::with_width(Length::Fill),
button(text("Save").size(11))
.on_press(Message::SvgEditorSave)
.padding([6, 14]),
button(text("Cancel").size(11))
.on_press(Message::SvgEditorCancel)
.padding([6, 14]),
]
.spacing(4)
.align_y(iced::Alignment::Center);
let content = column![
row![
column![
text("SVG Node Editor").size(16),
text("Direct path editing with live document preview").size(10),
],
Space::with_width(Length::Fill),
text("Wheel: zoom · Middle-drag: pan").size(10),
]
.align_y(iced::Alignment::Center),
row![
path_list,
container(canvas)
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
border: iced::Border::default().color(colors.border_high).width(1),
..Default::default()
})
]
.spacing(8)
.height(Length::Fill),
coordinates,
controls,
]
.spacing(8)
.padding(12)
.width(920)
.height(680);
let dialog = container(content).style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_panel)),
border: iced::Border::default()
.rounded(6)
.color(colors.border_high)
.width(1),
shadow: iced::Shadow {
color: Color::from_rgba(0.0, 0.0, 0.0, 0.45),
offset: Vector::new(0.0, 5.0),
blur_radius: 16.0,
},
..Default::default()
});
container(dialog)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.padding(20)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(Color::from_rgba(
0.0, 0.0, 0.0, 0.58,
))),
..Default::default()
})
.into()
}
#[cfg(test)]
mod tests {
use super::{parse_editable, SvgEditorState};
use iced::Vector;
/// Build a deterministic state for selection and snapping tests.
fn state() -> SvgEditorState {
let editable = parse_editable(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><path d="M 0,0 L 10,0 L 10,10 Z"/></svg>"#,
)
.expect("triangle should parse");
SvgEditorState {
layer_id: 1,
shape_idx: 0,
original_svg: String::new(),
editable,
selected_poly: 0,
selected_node: None,
zoom: 1.0,
pan: Vector::ZERO,
x_input: String::new(),
y_input: String::new(),
snap_to_grid: true,
grid_size: 0.5,
grid_input: "0.5".to_string(),
}
}
#[test]
fn straight_paths_retain_author_nodes() {
let editable = state().editable;
assert_eq!(editable.view_box, (0.0, 0.0, 10.0, 10.0));
assert_eq!(editable.polygons.len(), 1);
assert_eq!(editable.polygons[0].len(), 3);
}
#[test]
fn curved_paths_fall_back_to_dense_visual_nodes() {
let editable = parse_editable(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M 0,50 C 20,0 80,0 100,50 L 100,100 L 0,100 Z"/></svg>"#,
)
.expect("cubic path should flatten");
assert_eq!(editable.polygons.len(), 1);
assert!(editable.polygons[0].len() >= 19);
}
#[test]
fn selection_updates_numeric_inputs_and_snap_is_deterministic() {
let mut state = state();
state.select_node(0, 1);
assert_eq!(state.selected_node, Some(1));
assert_eq!(state.x_input, "10");
assert_eq!(state.y_input, "0");
assert_eq!(state.snapped(2.74), 2.5);
state.snap_to_grid = false;
assert_eq!(state.snapped(2.74), 2.74);
}
}
@@ -1,42 +1,35 @@
//! Title bar — Photopea-style unified bar with menus, doc info, and window controls. //! Title bar — Photopea-style unified bar with menus, search, panel toggle, doc info, and window controls.
//! //!
//! **Purpose:** Combines menu bar and title bar into a single 24px bar matching Photopea's layout. //! **Purpose:** Combines menu bar and title bar into a single bar.
//! Layout: [File Edit Image Layer Select Filter View Window More] [doc info] [search] [─ □ ×] //! Layout: [icon] [File Edit Image ... More] [search] [≡] [HCIE v0.1 — doc name] [─ □ ×]
//! //!
//! **Design reference:** Photopea image editor (93 screenshots analyzed). //! **Design reference:** EGUI title bar + Photopea image editor.
//! - Menu items: 12px font, tight padding //! - Search box for tool/action search (EGUI parity)
//! - Active menu: light background with dark text //! - Panel list toggle button (EGUI parity)
//! - Window controls: standard minimize/maximize/close //! - Double-click drag area to toggle maximize (EGUI parity)
//! - App icon + version string centered
use crate::app::Message; use crate::app::Message;
use crate::panels::typography; use crate::panels::typography;
use crate::theme::ThemeColors; 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}; use iced::{Element, Length};
/// Photopea-style menu labels matching the reference screenshots. /// Photopea-style menu labels matching the reference screenshots.
pub(crate) const MENU_LABELS: &[&str] = &[ 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. /// 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] = 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; pub(crate) const MENU_LEFT_INSET: f32 = 4.0;
/// Unified menu/title bar height shared with dropdown placement. /// Unified menu/title bar height shared with dropdown placement.
pub(crate) const MENU_BAR_HEIGHT: f32 = 28.0; 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. /// 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 { pub(crate) fn menu_anchor_x(menu_index: usize, viewport_width: f32, dropdown_width: f32) -> f32 {
let natural = MENU_LEFT_INSET let natural = MENU_LEFT_INSET
+ MENU_BUTTON_WIDTHS + 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)) 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] [─ □ ×] /// Layout: [icon] [File Edit ... More] [search] [≡] [HCIE v0.1 — doc] [─ □ ×]
/// The left portion (before window controls) is draggable. /// The area between the rightmost menu button and the window controls is draggable
/// and double-clicking it toggles maximize.
pub fn view<'a>( pub fn view<'a>(
doc_name: &'a str, doc_name: &'a str,
modified: bool, modified: bool,
@@ -58,8 +52,27 @@ pub fn view<'a>(
_canvas_h: u32, _canvas_h: u32,
colors: ThemeColors, colors: ThemeColors,
active_menu: Option<usize>, active_menu: Option<usize>,
title_search: &'a str,
show_panel_list: bool,
) -> Element<'a, Message> { ) -> 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); let mut menu_items = row![].spacing(0);
for (idx, &label) in MENU_LABELS.iter().enumerate() { for (idx, &label) in MENU_LABELS.iter().enumerate() {
let is_open = active_menu == Some(idx); let is_open = active_menu == Some(idx);
@@ -78,7 +91,6 @@ pub fn view<'a>(
.style( .style(
move |_theme: &iced::Theme, status: iced::widget::button::Status| { move |_theme: &iced::Theme, status: iced::widget::button::Status| {
if is_open { if is_open {
// Active menu: light background with dark text (Photopea style)
iced::widget::button::Style { iced::widget::button::Style {
background: Some(iced::Background::Color(c.menu_bg)), background: Some(iced::Background::Color(c.menu_bg)),
text_color: c.menu_text, text_color: c.menu_text,
@@ -108,18 +120,56 @@ pub fn view<'a>(
menu_items = menu_items.push(btn); menu_items = menu_items.push(btn);
} }
// ── Document info — Photopea style: "New Project.psd *" ── // ── Search box ──
let doc_info = text(if modified { let search_input = text_input("Search…", title_search)
format!("{} *", doc_name) .on_input(|s| Message::TitleSearchChanged(s))
.size(10)
.width(130.0)
.padding([2, 6]);
// ── 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 { } else {
doc_name.to_string() 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) .size(11)
.style(move |_theme: &iced::Theme| iced::widget::text::Style { .style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(colors.text_secondary), color: Some(colors.text_secondary),
}); });
// ── Window controls — Photopea style: ─ □ × ── // ── Window controls ──
let c = colors; let c = colors;
let minimize_btn = button(text("").size(12)) 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); let window_controls = row![minimize_btn, maximize_btn, close_btn].spacing(0);
// Only the empty title region is draggable. Interactive menus are siblings, // ── Draggable center area ──
// preventing their presses from also initiating a native window drag. // Fills remaining space between the left cluster and window controls.
let draggable_left = mouse_area( let draggable_center = mouse_area(
container(row![ container(doc_info)
iced::widget::Space::with_width(Length::Fill),
doc_info
])
.width(Length::Fill) .width(Length::Fill)
.height(MENU_BAR_HEIGHT) .height(MENU_BAR_HEIGHT)
.align_y(iced::Alignment::Center) .center_y(Length::Shrink),
.padding([0, 4]),
) )
.on_press(Message::WindowDrag) .on_press(Message::WindowDrag)
.interaction(iced::mouse::Interaction::Grab); .interaction(iced::mouse::Interaction::Grab);
// ── Full bar ── // ── Left cluster: icon, menus, search, panel toggle ──
let bar = row![ let left_cluster = row![
icon_container,
container(menu_items).padding(iced::Padding { container(menu_items).padding(iced::Padding {
left: MENU_LEFT_INSET, left: 0.0,
right: 0.0, right: 0.0,
top: 0.0, top: 0.0,
bottom: 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, window_controls,
] ]
.align_y(iced::Alignment::Center); .align_y(iced::Alignment::Center);
@@ -233,13 +288,14 @@ pub fn view<'a>(
mod tests { mod tests {
use super::menu_anchor_x; use super::menu_anchor_x;
/// Verifies natural anchors and narrow-viewport clamping use rendered button metrics.
#[test] #[test]
fn menu_anchors_are_deterministic_across_viewport_widths() { fn menu_anchors_are_deterministic_across_viewport_widths() {
assert_eq!(menu_anchor_x(0, 1280.0, 260.0), 4.0); 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(3, 1280.0, 260.0), 152.0);
assert_eq!(menu_anchor_x(8, 1280.0, 260.0), 453.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(8, 500.0, 260.0), 240.0); assert_eq!(menu_anchor_x(9, 1280.0, 260.0), 509.0);
assert_eq!(menu_anchor_x(8, 180.0, 260.0), 0.0); assert_eq!(menu_anchor_x(9, 500.0, 260.0), 240.0);
assert_eq!(menu_anchor_x(9, 180.0, 260.0), 0.0);
} }
} }
@@ -0,0 +1,68 @@
//! DebugSignalLogger — a built-in plugin that records every CoreEvent.
//!
//! Logs events with timestamps and can expose them for inspection.
//! Ported from the EGUI DebugSignalLogger, adapted for the Iced architecture.
use crate::app::{HcieIcedApp, ToolState};
use crate::plugins::{CoreEvent, HciePlugin, PluginAction};
use std::time::{SystemTime, UNIX_EPOCH};
pub struct DebugSignalLogger {
name: &'static str,
version: &'static str,
events: Vec<(u128, String)>,
max_events: usize,
}
impl DebugSignalLogger {
pub fn new() -> Self {
Self {
name: "debug-signal-logger",
version: "1.0.0",
events: Vec::new(),
max_events: 200,
}
}
pub fn get_events(&self) -> &[(u128, String)] {
&self.events
}
}
impl HciePlugin for DebugSignalLogger {
fn name(&self) -> &'static str {
self.name
}
fn version(&self) -> &'static str {
self.version
}
fn initialize(&mut self) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis();
self.events.push((now, "Plugin initialized".to_string()));
}
fn on_event(&mut self, event: &CoreEvent, _state: &mut ToolState) -> Vec<PluginAction> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis();
let event_str = format!("{:?}", event);
self.events.push((now, event_str));
if self.events.len() > self.max_events {
self.events.remove(0);
}
Vec::new()
}
fn ui(&mut self, _app: &HcieIcedApp) -> Vec<PluginAction> {
Vec::new()
}
}
@@ -0,0 +1,3 @@
//! Built-in plugins that ship with HCIE by default.
pub mod debug_logger;
@@ -0,0 +1,53 @@
//! Plugin Integration — maps Iced Message events to CoreEvent and dispatches
//! them to the PluginRegistry. Ported from the EGUI PluginHost pattern.
//!
//! **Purpose:** Converts key Iced `Message` variants into `CoreEvent` values
//! and dispatches them to the registry. This is called at the top of `update()`
//! BEFORE the main match block, so the message is still available as a reference.
//!
//! **Logic & Workflow:**
//! 1. `try_dispatch()` is called at the start of `HcieIcedApp::update()`.
//! 2. It converts the incoming `Message` to an optional `CoreEvent`.
//! 3. If a conversion exists, it dispatches to all registered plugins.
//! 4. The returned `Vec<PluginAction>` can be processed for action->message mapping.
//!
//! **Arguments & Returns:**
//! * `registry`: Mutable reference to the PluginRegistry.
//! * `message`: The incoming Iced Message (reference, unconsumed).
//! * `state`: Mutable reference to ToolState for plugin access.
//! * Returns the `Vec<PluginAction>` from all plugins (can be ignored with `let _`).
use crate::app::{Message, ToolState};
use crate::plugins::registry::PluginRegistry;
use crate::plugins::{CoreEvent, PluginAction};
/// Dispatch a Message to the PluginRegistry if it maps to a CoreEvent.
/// Should be called at the top of `update()` before the main match block.
pub fn try_dispatch(
registry: &mut PluginRegistry,
message: &Message,
state: &mut ToolState,
) -> Vec<PluginAction> {
if let Some(core_event) = message_to_core_event(message) {
registry.dispatch(&core_event, state)
} else {
Vec::new()
}
}
/// Convert an Iced Message to an optional CoreEvent.
/// Only messages that have meaningful plugin-side side effects are included.
fn message_to_core_event(message: &Message) -> Option<CoreEvent> {
match message {
Message::ToolSelected(tool) => Some(CoreEvent::ToolChanged(*tool)),
Message::LayerAdd => Some(CoreEvent::LayerAdded(0)),
Message::LayerDelete(id) => Some(CoreEvent::LayerDeleted(*id as usize)),
Message::LayerSelect(id) => Some(CoreEvent::LayerSelected(*id as usize)),
Message::Undo => Some(CoreEvent::Undo),
Message::Redo => Some(CoreEvent::Redo),
Message::CompositeRefresh => Some(CoreEvent::RenderRequested),
Message::CanvasPointerPressed { .. } => Some(CoreEvent::DrawingStarted),
Message::CanvasPointerReleased => Some(CoreEvent::DrawingFinished),
_ => None,
}
}
@@ -0,0 +1,74 @@
//! Plugin system for HCIE Iced GUI.
//!
//! Port of the EGUI plugin architecture to the Iced Elm-architecture.
//! `CoreEvent` and `PluginAction` are kept identical to the EGUI version.
//! The `HciePlugin::ui()` method is retained as a no-op since Iced uses
//! declarative views instead of immediate-mode UI contributions.
pub mod built_in;
pub mod integration;
pub mod registry;
use crate::app::{HcieIcedApp, ToolState};
use hcie_engine_api::Tool;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Core events dispatched to every registered plugin.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CoreEvent {
ToolChanged(Tool),
LayerAdded(usize),
LayerDeleted(usize),
LayerSelected(usize),
LayerModified(usize),
Undo,
Redo,
RenderRequested,
DrawingStarted,
DrawingFinished,
DocSwitched(usize),
DocCreated(usize),
DocClosed(usize),
FileOpened(PathBuf),
FileSaved(PathBuf),
SettingsChanged,
ThemeChanged,
FrameTick,
PluginBroadcast(String, serde_json::Value),
ExecuteAiActions(serde_json::Value),
}
/// Actions a plugin can request in response to an event or UI interaction.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PluginAction {
SetActiveTool(Tool),
AddLayer,
DeleteLayer(usize),
SelectLayer(usize),
Undo,
Redo,
Render,
SaveAs(PathBuf),
Broadcast(String, serde_json::Value),
Custom {
target_plugin: String,
key: String,
payload: serde_json::Value,
},
AiActions(serde_json::Value),
Batch(Vec<PluginAction>),
}
/// Trait that all plugins must implement.
pub trait HciePlugin: Send + Sync {
fn name(&self) -> &'static str;
fn version(&self) -> &'static str;
fn initialize(&mut self) {}
fn on_event(&mut self, event: &CoreEvent, state: &mut ToolState) -> Vec<PluginAction>;
/// Optional UI contribution. In the Iced architecture this is a no-op
/// placeholder; plugins that need UI should expose their own view functions.
fn ui(&mut self, _app: &HcieIcedApp) -> Vec<PluginAction> {
Vec::new()
}
}
@@ -0,0 +1,82 @@
//! PluginRegistry — the runtime manager for all HciePlugin instances.
//!
//! Provides registration, event dispatch, action collection, and plugin enumeration.
//! Ported from the EGUI plugin system, adapted for the Iced architecture.
use crate::app::ToolState;
use crate::plugins::{CoreEvent, HciePlugin, PluginAction};
use std::collections::HashMap;
/// Central registry that owns and manages all loaded plugins.
pub struct PluginRegistry {
plugins: Vec<Box<dyn HciePlugin>>,
/// Optional per-plugin debug log (name -> last few events)
debug_logs: HashMap<String, Vec<String>>,
max_log_entries: usize,
}
impl PluginRegistry {
pub fn new() -> Self {
Self {
plugins: Vec::new(),
debug_logs: HashMap::new(),
max_log_entries: 50,
}
}
/// Register a new plugin instance. The plugin is initialized immediately.
pub fn register(&mut self, mut plugin: Box<dyn HciePlugin>) {
plugin.initialize();
let name = plugin.name().to_string();
self.debug_logs.insert(name.clone(), Vec::new());
self.plugins.push(plugin);
}
/// Dispatch a CoreEvent to every registered plugin.
/// Collects all PluginActions returned and returns them as a flat list.
pub fn dispatch(&mut self, event: &CoreEvent, state: &mut ToolState) -> Vec<PluginAction> {
let mut all_actions = Vec::new();
for plugin in &mut self.plugins {
let name = plugin.name().to_string();
let actions = plugin.on_event(event, state);
if let Some(log) = self.debug_logs.get_mut(&name) {
let event_str = format!("{:?}", event);
log.push(event_str);
if log.len() > self.max_log_entries {
log.remove(0);
}
}
all_actions.extend(actions);
}
all_actions
}
/// Returns the list of currently loaded plugin names.
pub fn plugin_names(&self) -> Vec<String> {
self.plugins.iter().map(|p| p.name().to_string()).collect()
}
/// Returns a reference to the debug log of a specific plugin.
pub fn get_debug_log(&self, plugin_name: &str) -> Option<&[String]> {
self.debug_logs.get(plugin_name).map(|v| v.as_slice())
}
/// Number of registered plugins.
pub fn len(&self) -> usize {
self.plugins.len()
}
pub fn is_empty(&self) -> bool {
self.plugins.is_empty()
}
}
impl Default for PluginRegistry {
fn default() -> Self {
Self::new()
}
}
@@ -215,6 +215,7 @@ pub fn execute_actions(engine: &mut Engine, actions: &[ScriptAction]) {
255, 255,
]; ];
engine.add_vector_shape(hcie_engine_api::VectorShape::FreePath { engine.add_vector_shape(hcie_engine_api::VectorShape::FreePath {
name: String::new(),
pts: path_points, pts: path_points,
closed: *closed, closed: *closed,
stroke: *stroke_width, stroke: *stroke_width,
@@ -40,6 +40,9 @@ pub struct AppSettings {
/// Selected application color preset. /// Selected application color preset.
#[serde(default)] #[serde(default)]
pub theme_preset: crate::theme::ThemePreset, pub theme_preset: crate::theme::ThemePreset,
/// Last directory used by the image viewer (FastStone-like).
#[serde(default)]
pub viewer_last_dir: Option<String>,
} }
/// Tool-specific settings that persist between sessions. /// Tool-specific settings that persist between sessions.
@@ -168,6 +171,7 @@ impl Default for AppSettings {
window: WindowSettings::default(), window: WindowSettings::default(),
dock_profiles: Vec::new(), dock_profiles: Vec::new(),
theme_preset: crate::theme::ThemePreset::default(), theme_preset: crate::theme::ThemePreset::default(),
viewer_last_dir: None,
} }
} }
} }
@@ -347,6 +347,7 @@ mod tests {
/// Creates a stable rectangle fixture for transform invariant tests. /// Creates a stable rectangle fixture for transform invariant tests.
fn rect() -> VectorShape { fn rect() -> VectorShape {
VectorShape::Rect { VectorShape::Rect {
name: String::new(),
x1: 10.0, x1: 10.0,
y1: 20.0, y1: 20.0,
x2: 110.0, x2: 110.0,
@@ -132,7 +132,7 @@ impl ThemeColors {
accent_hover: Color::from_rgb(0.940, 0.640, 0.260), accent_hover: Color::from_rgb(0.940, 0.640, 0.260),
accent_green: Color::from_rgb(0.298, 0.686, 0.314), accent_green: Color::from_rgb(0.298, 0.686, 0.314),
danger: Color::from_rgb(0.780, 0.220, 0.267), 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), border_high: Color::from_rgb(0.243, 0.251, 0.212),
text_primary: Color::from_rgb(0.973, 0.973, 0.941), text_primary: Color::from_rgb(0.973, 0.973, 0.941),
text_secondary: Color::from_rgb(0.580, 0.588, 0.533), 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_hover: Color::from_rgb(1.000, 0.530, 0.820),
accent_green: Color::from_rgb(0.298, 0.686, 0.314), accent_green: Color::from_rgb(0.298, 0.686, 0.314),
danger: Color::from_rgb(0.753, 0.220, 0.294), 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), border_high: Color::from_rgb(0.267, 0.275, 0.345),
text_primary: Color::from_rgb(0.973, 0.973, 0.949), text_primary: Color::from_rgb(0.973, 0.973, 0.949),
text_secondary: Color::from_rgb(0.588, 0.596, 0.667), text_secondary: Color::from_rgb(0.588, 0.596, 0.667),
+1
View File
@@ -116,6 +116,7 @@ fn convert_path(path: &usvg::Path) -> Option<VectorShape> {
let pts: Vec<[f32; 2]> = raw_pts.into_iter().map(|(x, y)| [x, y]).collect(); let pts: Vec<[f32; 2]> = raw_pts.into_iter().map(|(x, y)| [x, y]).collect();
Some(VectorShape::FreePath { Some(VectorShape::FreePath {
name: String::new(),
pts, pts,
closed: is_closed, closed: is_closed,
stroke: stroke_width, stroke: stroke_width,
+5
View File
@@ -430,6 +430,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec<VectorS
let h = attrs.get("height").map(|v| parse_svg_float(v, 0.0)).unwrap_or(0.0) * scale_y; let h = attrs.get("height").map(|v| parse_svg_float(v, 0.0)).unwrap_or(0.0) * scale_y;
if w > 0.0 && h > 0.0 { if w > 0.0 && h > 0.0 {
shapes.push(VectorShape::Rect { shapes.push(VectorShape::Rect {
name: String::new(),
x1: x, y1: y, x1: x, y1: y,
x2: x + w, y2: y + h, x2: x + w, y2: y + h,
stroke: stroke_width, stroke: stroke_width,
@@ -448,6 +449,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec<VectorS
let r = attrs.get("r").map(|v| parse_svg_float(v, 0.0)).unwrap_or(0.0) * scale_x; let r = attrs.get("r").map(|v| parse_svg_float(v, 0.0)).unwrap_or(0.0) * scale_x;
if r > 0.0 { if r > 0.0 {
shapes.push(VectorShape::Circle { shapes.push(VectorShape::Circle {
name: String::new(),
x1: cx - r, y1: cy - r, x1: cx - r, y1: cy - r,
x2: cx + r, y2: cy + r, x2: cx + r, y2: cy + r,
stroke: stroke_width, stroke: stroke_width,
@@ -466,6 +468,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec<VectorS
let ry = attrs.get("ry").map(|v| parse_svg_float(v, 0.0)).unwrap_or(0.0) * scale_y; let ry = attrs.get("ry").map(|v| parse_svg_float(v, 0.0)).unwrap_or(0.0) * scale_y;
if rx > 0.0 && ry > 0.0 { if rx > 0.0 && ry > 0.0 {
shapes.push(VectorShape::Circle { shapes.push(VectorShape::Circle {
name: String::new(),
x1: cx - rx, y1: cy - ry, x1: cx - rx, y1: cy - ry,
x2: cx + rx, y2: cy + ry, x2: cx + rx, y2: cy + ry,
stroke: stroke_width, stroke: stroke_width,
@@ -483,6 +486,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec<VectorS
let x2 = (attrs.get("x2").map(|v| parse_svg_float(v, 0.0)).unwrap_or(0.0) + tx) * scale_x; let x2 = (attrs.get("x2").map(|v| parse_svg_float(v, 0.0)).unwrap_or(0.0) + tx) * scale_x;
let y2 = (attrs.get("y2").map(|v| parse_svg_float(v, 0.0)).unwrap_or(0.0) + ty) * scale_y; let y2 = (attrs.get("y2").map(|v| parse_svg_float(v, 0.0)).unwrap_or(0.0) + ty) * scale_y;
shapes.push(VectorShape::Line { shapes.push(VectorShape::Line {
name: String::new(),
x1, y1, x2, y2, x1, y1, x2, y2,
stroke: stroke_width, stroke: stroke_width,
color: stroke_color, color: stroke_color,
@@ -506,6 +510,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec<VectorS
} }
if pts.len() >= 2 { if pts.len() >= 2 {
shapes.push(VectorShape::FreePath { shapes.push(VectorShape::FreePath {
name: String::new(),
pts, pts,
closed: tag == "polygon", closed: tag == "polygon",
stroke: stroke_width, stroke: stroke_width,
+78
View File
@@ -300,6 +300,8 @@ fn default_hardness() -> f32 {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum VectorShape { pub enum VectorShape {
Line { Line {
#[serde(default)]
name: String,
x1: f32, x1: f32,
y1: f32, y1: f32,
x2: f32, x2: f32,
@@ -318,6 +320,8 @@ pub enum VectorShape {
hardness: f32, hardness: f32,
}, },
Rect { Rect {
#[serde(default)]
name: String,
x1: f32, x1: f32,
y1: f32, y1: f32,
x2: f32, x2: f32,
@@ -336,6 +340,8 @@ pub enum VectorShape {
hardness: f32, hardness: f32,
}, },
Circle { Circle {
#[serde(default)]
name: String,
x1: f32, x1: f32,
y1: f32, y1: f32,
x2: f32, x2: f32,
@@ -354,6 +360,8 @@ pub enum VectorShape {
}, },
#[serde(skip_serializing)] #[serde(skip_serializing)]
Arrow { Arrow {
#[serde(default)]
name: String,
x1: f32, x1: f32,
y1: f32, y1: f32,
x2: f32, x2: f32,
@@ -374,6 +382,8 @@ pub enum VectorShape {
}, },
#[serde(skip_serializing)] #[serde(skip_serializing)]
Star { Star {
#[serde(default)]
name: String,
x1: f32, x1: f32,
y1: f32, y1: f32,
x2: f32, x2: f32,
@@ -395,6 +405,8 @@ pub enum VectorShape {
inner_radius: f32, inner_radius: f32,
}, },
Polygon { Polygon {
#[serde(default)]
name: String,
x1: f32, x1: f32,
y1: f32, y1: f32,
x2: f32, x2: f32,
@@ -415,6 +427,8 @@ pub enum VectorShape {
}, },
#[serde(skip_serializing)] #[serde(skip_serializing)]
Rhombus { Rhombus {
#[serde(default)]
name: String,
x1: f32, x1: f32,
y1: f32, y1: f32,
x2: f32, x2: f32,
@@ -433,6 +447,8 @@ pub enum VectorShape {
}, },
#[serde(skip_serializing)] #[serde(skip_serializing)]
Cylinder { Cylinder {
#[serde(default)]
name: String,
x1: f32, x1: f32,
y1: f32, y1: f32,
x2: f32, x2: f32,
@@ -451,6 +467,8 @@ pub enum VectorShape {
}, },
#[serde(skip_serializing)] #[serde(skip_serializing)]
Heart { Heart {
#[serde(default)]
name: String,
x1: f32, x1: f32,
y1: f32, y1: f32,
x2: f32, x2: f32,
@@ -469,6 +487,8 @@ pub enum VectorShape {
}, },
#[serde(skip_serializing)] #[serde(skip_serializing)]
Bubble { Bubble {
#[serde(default)]
name: String,
x1: f32, x1: f32,
y1: f32, y1: f32,
x2: f32, x2: f32,
@@ -487,6 +507,8 @@ pub enum VectorShape {
}, },
#[serde(skip_serializing)] #[serde(skip_serializing)]
Gear { Gear {
#[serde(default)]
name: String,
x1: f32, x1: f32,
y1: f32, y1: f32,
x2: f32, x2: f32,
@@ -505,6 +527,8 @@ pub enum VectorShape {
}, },
#[serde(skip_serializing)] #[serde(skip_serializing)]
Cross { Cross {
#[serde(default)]
name: String,
x1: f32, x1: f32,
y1: f32, y1: f32,
x2: f32, x2: f32,
@@ -523,6 +547,8 @@ pub enum VectorShape {
}, },
#[serde(skip_serializing)] #[serde(skip_serializing)]
Crescent { Crescent {
#[serde(default)]
name: String,
x1: f32, x1: f32,
y1: f32, y1: f32,
x2: f32, x2: f32,
@@ -541,6 +567,8 @@ pub enum VectorShape {
}, },
#[serde(skip_serializing)] #[serde(skip_serializing)]
Bolt { Bolt {
#[serde(default)]
name: String,
x1: f32, x1: f32,
y1: f32, y1: f32,
x2: f32, x2: f32,
@@ -559,6 +587,8 @@ pub enum VectorShape {
}, },
#[serde(skip_serializing)] #[serde(skip_serializing)]
Arrow4 { Arrow4 {
#[serde(default)]
name: String,
x1: f32, x1: f32,
y1: f32, y1: f32,
x2: f32, x2: f32,
@@ -576,6 +606,8 @@ pub enum VectorShape {
hardness: f32, hardness: f32,
}, },
SvgShape { SvgShape {
#[serde(default)]
name: String,
x1: f32, x1: f32,
y1: f32, y1: f32,
x2: f32, x2: f32,
@@ -595,6 +627,8 @@ pub enum VectorShape {
hardness: f32, hardness: f32,
}, },
FreePath { FreePath {
#[serde(default)]
name: String,
pts: Vec<[f32; 2]>, pts: Vec<[f32; 2]>,
closed: bool, closed: bool,
stroke: f32, 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 { pub fn angle(&self) -> f32 {
match self { match self {
VectorShape::Line { angle, .. } VectorShape::Line { angle, .. }
+16
View File
@@ -410,6 +410,7 @@ impl App {
fn make_shape(&self, x1: f32, y1: f32, x2: f32, y2: f32) -> VectorShape { fn make_shape(&self, x1: f32, y1: f32, x2: f32, y2: f32) -> VectorShape {
match self.shape_kind { match self.shape_kind {
ShapeKind::Line => VectorShape::Line { ShapeKind::Line => VectorShape::Line {
name: String::new(),
x1, y1, x2, y2, x1, y1, x2, y2,
stroke: self.stroke, stroke: self.stroke,
color: self.color, color: self.color,
@@ -420,6 +421,7 @@ impl App {
hardness: self.hardness, hardness: self.hardness,
}, },
ShapeKind::Rect => VectorShape::Rect { ShapeKind::Rect => VectorShape::Rect {
name: String::new(),
x1, y1, x2, y2, x1, y1, x2, y2,
stroke: self.stroke, stroke: self.stroke,
color: self.color, color: self.color,
@@ -431,6 +433,7 @@ impl App {
hardness: self.hardness, hardness: self.hardness,
}, },
ShapeKind::Circle => VectorShape::Circle { ShapeKind::Circle => VectorShape::Circle {
name: String::new(),
x1, y1, x2, y2, x1, y1, x2, y2,
stroke: self.stroke, stroke: self.stroke,
color: self.color, color: self.color,
@@ -441,6 +444,7 @@ impl App {
hardness: self.hardness, hardness: self.hardness,
}, },
ShapeKind::Arrow => VectorShape::Arrow { ShapeKind::Arrow => VectorShape::Arrow {
name: String::new(),
x1, y1, x2, y2, x1, y1, x2, y2,
stroke: self.stroke, stroke: self.stroke,
color: self.color, color: self.color,
@@ -452,6 +456,7 @@ impl App {
thick: self.arrow_thick, thick: self.arrow_thick,
}, },
ShapeKind::Star => VectorShape::Star { ShapeKind::Star => VectorShape::Star {
name: String::new(),
x1, y1, x2, y2, x1, y1, x2, y2,
stroke: self.stroke, stroke: self.stroke,
color: self.color, color: self.color,
@@ -464,6 +469,7 @@ impl App {
inner_radius: self.star_inner_radius, inner_radius: self.star_inner_radius,
}, },
ShapeKind::Polygon => VectorShape::Polygon { ShapeKind::Polygon => VectorShape::Polygon {
name: String::new(),
x1, y1, x2, y2, x1, y1, x2, y2,
stroke: self.stroke, stroke: self.stroke,
color: self.color, color: self.color,
@@ -475,6 +481,7 @@ impl App {
hardness: self.hardness, hardness: self.hardness,
}, },
ShapeKind::Rhombus => VectorShape::Rhombus { ShapeKind::Rhombus => VectorShape::Rhombus {
name: String::new(),
x1, y1, x2, y2, x1, y1, x2, y2,
stroke: self.stroke, stroke: self.stroke,
color: self.color, color: self.color,
@@ -485,6 +492,7 @@ impl App {
hardness: self.hardness, hardness: self.hardness,
}, },
ShapeKind::Cylinder => VectorShape::Cylinder { ShapeKind::Cylinder => VectorShape::Cylinder {
name: String::new(),
x1, y1, x2, y2, x1, y1, x2, y2,
stroke: self.stroke, stroke: self.stroke,
color: self.color, color: self.color,
@@ -495,6 +503,7 @@ impl App {
hardness: self.hardness, hardness: self.hardness,
}, },
ShapeKind::Heart => VectorShape::Heart { ShapeKind::Heart => VectorShape::Heart {
name: String::new(),
x1, y1, x2, y2, x1, y1, x2, y2,
stroke: self.stroke, stroke: self.stroke,
color: self.color, color: self.color,
@@ -505,6 +514,7 @@ impl App {
hardness: self.hardness, hardness: self.hardness,
}, },
ShapeKind::Bubble => VectorShape::Bubble { ShapeKind::Bubble => VectorShape::Bubble {
name: String::new(),
x1, y1, x2, y2, x1, y1, x2, y2,
stroke: self.stroke, stroke: self.stroke,
color: self.color, color: self.color,
@@ -515,6 +525,7 @@ impl App {
hardness: self.hardness, hardness: self.hardness,
}, },
ShapeKind::Gear => VectorShape::Gear { ShapeKind::Gear => VectorShape::Gear {
name: String::new(),
x1, y1, x2, y2, x1, y1, x2, y2,
stroke: self.stroke, stroke: self.stroke,
color: self.color, color: self.color,
@@ -525,6 +536,7 @@ impl App {
hardness: self.hardness, hardness: self.hardness,
}, },
ShapeKind::Cross => VectorShape::Cross { ShapeKind::Cross => VectorShape::Cross {
name: String::new(),
x1, y1, x2, y2, x1, y1, x2, y2,
stroke: self.stroke, stroke: self.stroke,
color: self.color, color: self.color,
@@ -535,6 +547,7 @@ impl App {
hardness: self.hardness, hardness: self.hardness,
}, },
ShapeKind::Crescent => VectorShape::Crescent { ShapeKind::Crescent => VectorShape::Crescent {
name: String::new(),
x1, y1, x2, y2, x1, y1, x2, y2,
stroke: self.stroke, stroke: self.stroke,
color: self.color, color: self.color,
@@ -545,6 +558,7 @@ impl App {
hardness: self.hardness, hardness: self.hardness,
}, },
ShapeKind::Bolt => VectorShape::Bolt { ShapeKind::Bolt => VectorShape::Bolt {
name: String::new(),
x1, y1, x2, y2, x1, y1, x2, y2,
stroke: self.stroke, stroke: self.stroke,
color: self.color, color: self.color,
@@ -555,6 +569,7 @@ impl App {
hardness: self.hardness, hardness: self.hardness,
}, },
ShapeKind::Arrow4 => VectorShape::Arrow4 { ShapeKind::Arrow4 => VectorShape::Arrow4 {
name: String::new(),
x1, y1, x2, y2, x1, y1, x2, y2,
stroke: self.stroke, stroke: self.stroke,
color: self.color, color: self.color,
@@ -572,6 +587,7 @@ impl App {
[(x1 + x2) / 2.0, y2 + 50.0], [(x1 + x2) / 2.0, y2 + 50.0],
]; ];
VectorShape::FreePath { VectorShape::FreePath {
name: String::new(),
pts, pts,
closed: true, closed: true,
stroke: self.stroke, stroke: self.stroke,
+55 -4
View File
@@ -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); 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, .. } => { SvgShape { svg, x1, y1, x2, y2, fill, color, fill_color, stroke, angle, opacity, hardness, .. } => {
let points_list = svg_render::svg_to_points(svg, *x1, *y1, *x2, *y2).unwrap_or_default(); 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 alpha = (color[3] as f32 * opacity).round() as u8;
let px_color = [color[0], color[1], color[2], alpha]; let px_color = [color[0], color[1], color[2], alpha];
let prev = ACTIVE_HARDNESS.with(|h| h.get()); 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) { 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)) (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!((rx - 100.0).abs() < 1e-4);
assert!((ry - 150.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);
}
} }
+1
View File
@@ -397,6 +397,7 @@ pub fn boolean_shapes(op: BooleanOp, a: &VectorShape, b: &VectorShape) -> Option
}; };
Some(VectorShape::FreePath { Some(VectorShape::FreePath {
name: String::new(),
pts: result_pts, pts: result_pts,
closed: true, closed: true,
stroke, stroke,
+19 -19
View File
@@ -2,7 +2,7 @@
SATIR SAYISI RAPORU SATIR SAYISI RAPORU
Proje: HCIE-Rust v4 Proje: HCIE-Rust v4
Konum: /mnt/extra/00_PROJECTS/hcie-rust-v3.05 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-ai 2402 satır
hcie-blend 530 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-color 85 satır
hcie-composite 985 satır hcie-composite 1456 satır
hcie-document 666 satır hcie-document 666 satır
hcie-draw 517 satır hcie-draw 619 satır
hcie-egui-app 36320 satır hcie-egui-app 37419 satır
hcie-engine-api 5766 satır hcie-engine-api 8360 satır
hcie-engine-api-orig 3874 satır hcie-engine-api-orig 3874 satır
hcie-filter 3238 satır hcie-filter 3238 satır
hcie-fx 4077 satır hcie-fx 5535 satır
hcie-history 100 satır hcie-history 100 satır
hcie-iced-app 6308 satır hcie-iced-app 36049 satır
hcie-io 11382 satır hcie-io 12121 satır
hcie-kra 1392 satır hcie-kra 1393 satır
hcie-native 153 satır hcie-native 153 satır
hcie-protocol 2872 satır hcie-protocol 3678 satır
hcie-psd 5120 satır hcie-psd 5120 satır
hcie-psd-saver 1277 satır hcie-psd-saver 1277 satır
hcie-selection 284 satır hcie-selection 392 satır
hcie-text 1010 satır hcie-text 1010 satır
hcie-tile 361 satır hcie-tile 361 satır
hcie-vector 2298 satır hcie-vector 2853 satır
hcie-vision 3131 satır hcie-vision 3131 satır
TOPLAM RUST: 97901 satır TOPLAM RUST: 136667 satır
----------------------------------------- -----------------------------------------
C++ / HEADER (.cpp, .h) C++ / HEADER (.cpp, .h)
@@ -102,14 +102,14 @@ Tarih: 2026-07-11 20:24:42
MARKDOWN (.md) 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) KOD SATIRLARI TOPLAMI (GRAND TOTAL)
========================================= =========================================
Rust: 97901 satır Rust: 136667 satır
C++ / Header: 5031 satır C++ / Header: 5031 satır
JavaScript: 0 satır JavaScript: 0 satır
Svelte: 0 satır Svelte: 0 satır
@@ -119,9 +119,9 @@ Tarih: 2026-07-11 20:24:42
CSS: 0 satır CSS: 0 satır
Shell Script: 1445 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) (JSON ve Markdown dosyaları yukarıda ayrı olarak gösterilmiştir)
========================================= =========================================