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:
@@ -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.
|
||||
- EGUI’s reference editor has `svg_to_screen`, `screen_to_svg`, polygon rendering, node circles, midpoint hit testing, node dragging, add/remove controls, and a resizable canvas.
|
||||
- `SvgEditable` exposes `view_box`, `polygons`, `add_node`, `remove_node`, `move_node`, `from_svg`, and `to_svg`.
|
||||
- ICED already has interactive Canvas examples in `canvas/viewport.rs` and color picker programs in `color_picker.rs`.
|
||||
- `set_vector_shape_svg()` marks the vector layer/composite dirty and modified but does not push a vector history snapshot.
|
||||
- `Engine::apply_filter()` already snapshots the pre-filter pixels with description `Filter: <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 picker’s displayed source color remains stable and right-click does not alter foreground.
|
||||
- Preserve existing recent-swatch `.on_right_press(Message::BgColorChanged(c))` behavior.
|
||||
|
||||
- `hcie-iced-app/crates/hcie-iced-gui/src/app.rs`
|
||||
- Confirm `BgColorChanged` updates secondary color, shape synchronization, and persistence state as appropriate.
|
||||
- Do not change main canvas right-click context-menu behavior.
|
||||
|
||||
## Ordered Implementation Tasks
|
||||
|
||||
### 1. SVG editor state and messages
|
||||
|
||||
1. Extend `SvgEditorState` with:
|
||||
- `original_svg: String` for Cancel restoration.
|
||||
- `drag_node: Option<(usize, usize)>`.
|
||||
- `zoom: f32` and `pan: Vector` or equivalent.
|
||||
- `hovered_node`/`hovered_midpoint` and fit/grid state if needed.
|
||||
2. Add `Message` variants for:
|
||||
- canvas pointer pressed/moved/released;
|
||||
- node X/Y field changes;
|
||||
- zoom/pan/fit/reset;
|
||||
- retain existing polygon/node/add/remove/save/cancel messages where reusable.
|
||||
3. On SVG editor open, store the original SVG alongside the parsed editable state.
|
||||
|
||||
### 2. SVG coordinate and rendering model
|
||||
|
||||
4. Implement `svg_to_screen()` and `screen_to_svg()` using the viewBox, canvas bounds, margin, zoom, and pan. Preserve aspect ratio so nodes are not distorted.
|
||||
5. Implement a Canvas Program that:
|
||||
- draws the viewBox background/grid;
|
||||
- draws each polygon/path fill and outline;
|
||||
- draws node handles with selected/hovered styles;
|
||||
- draws smaller edge midpoint handles;
|
||||
- uses `Cursor::position_in(bounds)` for hit testing;
|
||||
- maps drag positions back to SVG coordinates.
|
||||
6. Implement hit testing for nearest node and edge midpoint with screen-space thresholds that remain usable at different zoom levels.
|
||||
|
||||
### 3. SVG live editing behavior
|
||||
|
||||
7. On node press, select the polygon/node and begin drag.
|
||||
8. On node drag, call `editable.move_node(...)`, update the numeric fields, and immediately emit/apply the current `to_svg()` to the active vector shape for live canvas preview.
|
||||
9. On midpoint click, call `editable.add_node(...)` and update selection/live preview.
|
||||
10. On Delete/Backspace or Remove button, remove the selected node when valid and update/live-preview.
|
||||
11. Numeric X/Y edits update the selected node and live-preview.
|
||||
12. Save keeps the current SVG, clears the original/session state, closes the editor, and requests composite refresh.
|
||||
13. Cancel calls `set_vector_shape_svg()` with `original_svg`, refreshes the composite, clears session state, and closes without retaining edits.
|
||||
14. Handle empty polygons, invalid selection indices, unsupported bezier SVGs, and zero-sized viewBoxes without panics.
|
||||
|
||||
### 4. Filter/panel behavior
|
||||
|
||||
15. Add a small `ensure_pane_open(PaneType)` helper if repeated logic is useful; otherwise use `is_pane_open` + `reopen_pane` directly.
|
||||
16. Call it for Brush selection and Filter selection as specified above.
|
||||
17. Widen filter slider rows and increase Filters pane minimum sizing. Verify at narrow and wide pane widths.
|
||||
18. Verify committed filter history by applying a filter, checking the history description, undoing, and redoing. Do not add history entries during preview slider movement.
|
||||
|
||||
### 5. Color behavior
|
||||
|
||||
19. Add right-click output support to the color-wheel/picker Canvas Program.
|
||||
20. Verify left-click changes foreground, right-click changes background, recent swatch right-click changes background, and both colors/recent colors persist across restart.
|
||||
|
||||
## Risks and Edge Cases
|
||||
|
||||
1. **SVG curve limitation:** `SvgEditable::from_svg()` rejects QuadTo/CubicTo. Show/retain a clear parse failure path; do not silently flatten curves.
|
||||
2. **Live SVG update cost:** Updating the full SVG string on every drag is acceptable for the small vector shape payload, but avoid cloning unrelated document pixels.
|
||||
3. **Cancel correctness:** Capture the original SVG before the first live update. Cancel must restore it exactly, including after multiple node additions/removals.
|
||||
4. **SVG history limitation:** The public GUI-facing API has no vector snapshot method for `set_vector_shape_svg()`. Do not modify locked engine crates in this scope; document that SVG edits are not currently represented as a dedicated undo entry.
|
||||
5. **Filter history semantics:** Do not push history from slider-preview events. Only the final `FilterApply`/direct `apply_filter` call should be undoable.
|
||||
6. **Pane reopening:** Use `reopen_pane` only when closed; calling `toggle_pane` would incorrectly close an already visible pane.
|
||||
7. **Color picker right-click:** Keep canvas context-menu right-click untouched; limit the new behavior to color picker surfaces.
|
||||
8. **Color persistence:** Avoid resetting `recent_colors`, `fg_color`, or `bg_color` during app initialization after `load_colors()`.
|
||||
|
||||
## Validation
|
||||
|
||||
1. `cargo check -p hcie-iced-gui`.
|
||||
2. `cargo test -p hcie-iced-gui` and existing menu tests.
|
||||
3. SVG manual flow:
|
||||
- Open a supported polygon SVG.
|
||||
- Select a node and drag it; verify the SVG and main canvas update during the drag.
|
||||
- Insert a node through an edge midpoint and move it.
|
||||
- Edit X/Y numerically.
|
||||
- Delete a node and verify minimum-node safeguards.
|
||||
- Cancel after several edits and verify exact original restoration.
|
||||
- Reopen, edit, Save, close, and verify the updated SVG persists.
|
||||
- Try a bezier SVG and verify a controlled unsupported-geometry failure.
|
||||
4. Filter history flow:
|
||||
- Select a filter and change sliders; verify preview movement does not create multiple history records.
|
||||
- Apply the filter; verify one `Filter: <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
@@ -2759,6 +2759,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"usvg 0.43.0",
|
||||
"zip",
|
||||
]
|
||||
|
||||
|
||||
@@ -208,6 +208,7 @@ pub fn execute_actions(engine: &mut Engine, actions: &[ScriptAction]) {
|
||||
255,
|
||||
];
|
||||
engine.add_vector_shape(hcie_engine_api::VectorShape::FreePath {
|
||||
name: String::new(),
|
||||
pts: path_points,
|
||||
closed: *closed,
|
||||
stroke: *stroke_width,
|
||||
|
||||
@@ -201,6 +201,7 @@ pub fn load_hcie(path: &Path, engine: &mut Engine) -> Result<(u32, u32, String),
|
||||
// Wait, if shapes is empty, add_vector_shape won't create the layer.
|
||||
// We can add a dummy line shape with opacity 0.0 to create a Vector layer.
|
||||
let dummy = VectorShape::Line {
|
||||
name: String::new(),
|
||||
x1: 0.0,
|
||||
y1: 0.0,
|
||||
x2: 0.0,
|
||||
|
||||
@@ -731,6 +731,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec<VectorS
|
||||
* scale_y;
|
||||
if w > 0.0 && h > 0.0 {
|
||||
shapes.push(VectorShape::Rect {
|
||||
name: String::new(),
|
||||
x1: x,
|
||||
y1: y,
|
||||
x2: x + w,
|
||||
@@ -766,6 +767,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec<VectorS
|
||||
* scale_x;
|
||||
if r > 0.0 {
|
||||
shapes.push(VectorShape::Circle {
|
||||
name: String::new(),
|
||||
x1: cx - r,
|
||||
y1: cy - r,
|
||||
x2: cx + r,
|
||||
@@ -805,6 +807,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec<VectorS
|
||||
* scale_y;
|
||||
if rx > 0.0 && ry > 0.0 {
|
||||
shapes.push(VectorShape::Circle {
|
||||
name: String::new(),
|
||||
x1: cx - rx,
|
||||
y1: cy - ry,
|
||||
x2: cx + rx,
|
||||
@@ -845,6 +848,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec<VectorS
|
||||
+ ty)
|
||||
* scale_y;
|
||||
shapes.push(VectorShape::Line {
|
||||
name: String::new(),
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
@@ -873,6 +877,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec<VectorS
|
||||
}
|
||||
if pts.len() >= 2 {
|
||||
shapes.push(VectorShape::FreePath {
|
||||
name: String::new(),
|
||||
pts,
|
||||
closed: tag == "polygon",
|
||||
stroke: stroke_width,
|
||||
|
||||
@@ -10,6 +10,10 @@ use crate::app::ToolState;
|
||||
use crate::event_bus::AppEvent;
|
||||
|
||||
fn shape_label(shapes: &[VectorShape], i: usize) -> String {
|
||||
let name = shapes[i].name();
|
||||
if !name.is_empty() {
|
||||
return name;
|
||||
}
|
||||
match &shapes[i] {
|
||||
VectorShape::Line { .. } => format!("Line #{}", i + 1),
|
||||
VectorShape::Rect { .. } => format!("Rect #{}", i + 1),
|
||||
|
||||
@@ -2184,6 +2184,7 @@ fn create_shape_from_drag(
|
||||
|
||||
match tool {
|
||||
Tool::VectorLine => VectorShape::Line {
|
||||
name: String::new(),
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
@@ -2197,6 +2198,7 @@ fn create_shape_from_drag(
|
||||
hardness,
|
||||
},
|
||||
Tool::VectorRect => VectorShape::Rect {
|
||||
name: String::new(),
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
@@ -2211,6 +2213,7 @@ fn create_shape_from_drag(
|
||||
hardness,
|
||||
},
|
||||
Tool::VectorCircle => VectorShape::Circle {
|
||||
name: String::new(),
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
@@ -2228,7 +2231,7 @@ fn create_shape_from_drag(
|
||||
p.insert("thick".to_string(), 0.0);
|
||||
match hcie_engine_api::create_vector_shape("arrow", x1, y1, x2, y2, &p) {
|
||||
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
|
||||
kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||||
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||||
angle: 0.0, opacity, hardness,
|
||||
},
|
||||
_ => unreachable!(),
|
||||
@@ -2240,13 +2243,14 @@ fn create_shape_from_drag(
|
||||
p.insert("inner_radius".to_string(), state.tool_configs.vector.star_inner_radius);
|
||||
match hcie_engine_api::create_vector_shape("star", x1, y1, x2, y2, &p) {
|
||||
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
|
||||
kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||||
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||||
angle: 0.0, opacity, hardness,
|
||||
},
|
||||
_ => unreachable!(),
|
||||
}
|
||||
},
|
||||
Tool::VectorPolygon => VectorShape::Polygon {
|
||||
name: String::new(),
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
@@ -2263,7 +2267,7 @@ fn create_shape_from_drag(
|
||||
Tool::VectorRhombus => {
|
||||
match hcie_engine_api::create_vector_shape("rhombus", x1, y1, x2, y2, &std::collections::HashMap::new()) {
|
||||
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
|
||||
kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||||
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||||
angle: 0.0, opacity, hardness,
|
||||
},
|
||||
_ => unreachable!(),
|
||||
@@ -2272,7 +2276,7 @@ fn create_shape_from_drag(
|
||||
Tool::VectorCylinder => {
|
||||
match hcie_engine_api::create_vector_shape("cylinder", x1, y1, x2, y2, &std::collections::HashMap::new()) {
|
||||
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
|
||||
kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||||
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||||
angle: 0.0, opacity, hardness,
|
||||
},
|
||||
_ => unreachable!(),
|
||||
@@ -2281,7 +2285,7 @@ fn create_shape_from_drag(
|
||||
Tool::VectorHeart => {
|
||||
match hcie_engine_api::create_vector_shape("heart", x1, y1, x2, y2, &std::collections::HashMap::new()) {
|
||||
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
|
||||
kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||||
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||||
angle: 0.0, opacity, hardness,
|
||||
},
|
||||
_ => unreachable!(),
|
||||
@@ -2290,7 +2294,7 @@ fn create_shape_from_drag(
|
||||
Tool::VectorBubble => {
|
||||
match hcie_engine_api::create_vector_shape("bubble", x1, y1, x2, y2, &std::collections::HashMap::new()) {
|
||||
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
|
||||
kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||||
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||||
angle: 0.0, opacity, hardness,
|
||||
},
|
||||
_ => unreachable!(),
|
||||
@@ -2299,7 +2303,7 @@ fn create_shape_from_drag(
|
||||
Tool::VectorGear => {
|
||||
match hcie_engine_api::create_vector_shape("gear", x1, y1, x2, y2, &std::collections::HashMap::new()) {
|
||||
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
|
||||
kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||||
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||||
angle: 0.0, opacity, hardness,
|
||||
},
|
||||
_ => unreachable!(),
|
||||
@@ -2308,7 +2312,7 @@ fn create_shape_from_drag(
|
||||
Tool::VectorCross => {
|
||||
match hcie_engine_api::create_vector_shape("cross", x1, y1, x2, y2, &std::collections::HashMap::new()) {
|
||||
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
|
||||
kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||||
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||||
angle: 0.0, opacity, hardness,
|
||||
},
|
||||
_ => unreachable!(),
|
||||
@@ -2317,7 +2321,7 @@ fn create_shape_from_drag(
|
||||
Tool::VectorCrescent => {
|
||||
match hcie_engine_api::create_vector_shape("crescent", x1, y1, x2, y2, &std::collections::HashMap::new()) {
|
||||
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
|
||||
kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||||
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||||
angle: 0.0, opacity, hardness,
|
||||
},
|
||||
_ => unreachable!(),
|
||||
@@ -2326,7 +2330,7 @@ fn create_shape_from_drag(
|
||||
Tool::VectorBolt => {
|
||||
match hcie_engine_api::create_vector_shape("bolt", x1, y1, x2, y2, &std::collections::HashMap::new()) {
|
||||
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
|
||||
kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||||
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||||
angle: 0.0, opacity, hardness,
|
||||
},
|
||||
_ => unreachable!(),
|
||||
@@ -2335,7 +2339,7 @@ fn create_shape_from_drag(
|
||||
Tool::VectorArrow4 => {
|
||||
match hcie_engine_api::create_vector_shape("arrow4", x1, y1, x2, y2, &std::collections::HashMap::new()) {
|
||||
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
|
||||
kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||||
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||||
angle: 0.0, opacity, hardness,
|
||||
},
|
||||
_ => unreachable!(),
|
||||
@@ -2344,6 +2348,7 @@ fn create_shape_from_drag(
|
||||
Tool::CustomShape(idx) => {
|
||||
if let Some(entry) = catalog.get(idx) {
|
||||
VectorShape::SvgShape {
|
||||
name: String::new(),
|
||||
x1, y1, x2, y2,
|
||||
kind: entry.kind.clone(),
|
||||
svg: entry.svg.clone(),
|
||||
@@ -2353,6 +2358,7 @@ fn create_shape_from_drag(
|
||||
} else {
|
||||
// Fallback: empty SVG rectangle
|
||||
VectorShape::SvgShape {
|
||||
name: String::new(),
|
||||
x1, y1, x2, y2,
|
||||
kind: "custom".to_string(),
|
||||
svg: r#"<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 {
|
||||
name: String::new(),
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
|
||||
@@ -438,6 +438,7 @@ pub fn create_vector_shape(
|
||||
) -> VectorShape {
|
||||
let svg = hcie_vector::svg_templates::create_svg(kind, params);
|
||||
VectorShape::SvgShape {
|
||||
name: String::new(),
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
@@ -467,6 +468,7 @@ pub fn create_vector_shape_from_svg(
|
||||
y2: f32,
|
||||
) -> VectorShape {
|
||||
VectorShape::SvgShape {
|
||||
name: String::new(),
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
@@ -1630,6 +1632,20 @@ impl Engine {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn reorder_vector_shape(&mut self, layer_id: u64, from_idx: usize, to_idx: usize) {
|
||||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||||
if let LayerData::Vector { ref mut shapes } = layer.data {
|
||||
if from_idx < shapes.len() && to_idx < shapes.len() {
|
||||
let shape = shapes.remove(from_idx);
|
||||
shapes.insert(to_idx, shape);
|
||||
layer.dirty = true;
|
||||
self.document.composite_dirty = true;
|
||||
self.document.modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_vector_shape(&mut self, layer_id: u64, shape_idx: usize) {
|
||||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||||
if let LayerData::Vector { ref mut shapes } = layer.data {
|
||||
@@ -3086,6 +3102,7 @@ impl Engine {
|
||||
.collect();
|
||||
if !path_pts.is_empty() {
|
||||
let shape = hcie_protocol::VectorShape::FreePath {
|
||||
name: String::new(),
|
||||
pts: path_pts,
|
||||
closed,
|
||||
stroke: stroke_width,
|
||||
@@ -3192,6 +3209,7 @@ impl Engine {
|
||||
let fc = parse_hex_color(fill_hex);
|
||||
let sc = parse_hex_color(stroke_hex);
|
||||
let shape = VectorShape::Circle {
|
||||
name: String::new(),
|
||||
x1: cx - rx,
|
||||
y1: cy - ry,
|
||||
x2: cx + rx,
|
||||
@@ -3218,6 +3236,7 @@ impl Engine {
|
||||
let fc = parse_hex_color(fill_hex);
|
||||
let sc = parse_hex_color(stroke_hex);
|
||||
let shape = VectorShape::Rect {
|
||||
name: String::new(),
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
@@ -3366,6 +3385,7 @@ impl Engine {
|
||||
[0u8; 4]
|
||||
};
|
||||
let shape = VectorShape::FreePath {
|
||||
name: String::new(),
|
||||
pts: px_pts,
|
||||
closed: comp.closed,
|
||||
stroke: stroke_width,
|
||||
|
||||
@@ -109,6 +109,7 @@ fn red_rect_over_green_rect() {
|
||||
fn vector_rect_golden() {
|
||||
let mut engine = Engine::new(16, 16);
|
||||
let shape = VectorShape::Rect {
|
||||
name: String::new(),
|
||||
x1: 0.0,
|
||||
y1: 0.0,
|
||||
x2: 16.0,
|
||||
@@ -222,6 +223,7 @@ fn vector_fill_toggle_and_delete() {
|
||||
|
||||
// Create a rect with fill=false
|
||||
let shape = VectorShape::Rect {
|
||||
name: String::new(),
|
||||
x1: 2.0,
|
||||
y1: 2.0,
|
||||
x2: 14.0,
|
||||
|
||||
@@ -34,3 +34,4 @@ reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
futures-util = "0.3"
|
||||
zip = { workspace = true }
|
||||
usvg = { workspace = true }
|
||||
|
||||
@@ -140,6 +140,38 @@ impl Default for AiChatConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// ComfyUI connection status.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ComfyStatus {
|
||||
Unknown,
|
||||
Running,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl Default for ComfyStatus {
|
||||
fn default() -> Self {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// State for the ComfyUI pipeline viewer tab.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ComfyUiState {
|
||||
/// ComfyUI server URL.
|
||||
pub url: String,
|
||||
/// Last known connection status.
|
||||
pub status: ComfyStatus,
|
||||
}
|
||||
|
||||
impl Default for ComfyUiState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
url: "http://127.0.0.1:8188".to_string(),
|
||||
status: ComfyStatus::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete AI chat state including messages, input buffer, and streaming state.
|
||||
#[derive(Debug)]
|
||||
pub struct AiChatState {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,8 @@ pub struct ScreenshotRequest {
|
||||
pub auto_hide_panel: Option<String>,
|
||||
/// Whether the post-close welcome surface is activated before capture.
|
||||
pub welcome: bool,
|
||||
/// Whether a deterministic SVG editor fixture is opened before capture.
|
||||
pub svg_editor: bool,
|
||||
/// PNG output path supplied by the caller.
|
||||
pub output: PathBuf,
|
||||
}
|
||||
@@ -73,6 +75,7 @@ where
|
||||
floating_panel: None,
|
||||
auto_hide_panel: None,
|
||||
welcome: false,
|
||||
svg_editor: false,
|
||||
output: PathBuf::from(output),
|
||||
});
|
||||
index += 2;
|
||||
@@ -87,6 +90,7 @@ where
|
||||
floating_panel: Some(panel.to_owned()),
|
||||
auto_hide_panel: None,
|
||||
welcome: false,
|
||||
svg_editor: false,
|
||||
output: PathBuf::from(output),
|
||||
});
|
||||
index += 3;
|
||||
@@ -101,6 +105,7 @@ where
|
||||
floating_panel: None,
|
||||
auto_hide_panel: None,
|
||||
welcome: false,
|
||||
svg_editor: false,
|
||||
output: PathBuf::from(output),
|
||||
});
|
||||
index += 3;
|
||||
@@ -114,6 +119,7 @@ where
|
||||
floating_panel: None,
|
||||
auto_hide_panel: None,
|
||||
welcome: true,
|
||||
svg_editor: false,
|
||||
output: PathBuf::from(output),
|
||||
});
|
||||
index += 2;
|
||||
@@ -128,11 +134,26 @@ where
|
||||
floating_panel: None,
|
||||
auto_hide_panel: Some(panel.to_owned()),
|
||||
welcome: false,
|
||||
svg_editor: false,
|
||||
output: PathBuf::from(output),
|
||||
});
|
||||
index += 3;
|
||||
continue;
|
||||
}
|
||||
"--screenshot-svg-editor" => {
|
||||
ensure_no_screenshot(&parsed)?;
|
||||
let output = required_operand(&tokens, index + 1, "--screenshot-svg-editor")?;
|
||||
parsed.screenshot = Some(ScreenshotRequest {
|
||||
panel: None,
|
||||
floating_panel: None,
|
||||
auto_hide_panel: None,
|
||||
welcome: false,
|
||||
svg_editor: true,
|
||||
output: PathBuf::from(output),
|
||||
});
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
_ if token.starts_with('-') => {
|
||||
return Err(format!("unknown argument: {token}"));
|
||||
}
|
||||
@@ -173,7 +194,7 @@ fn ensure_no_screenshot(parsed: &CliArgs) -> Result<(), String> {
|
||||
|
||||
/// Returns the command-line help shown by the binary.
|
||||
pub fn help_text() -> &'static str {
|
||||
"Usage: hcie-iced [OPTIONS] [FILE]\n\nHCIE - Iced Edition\n\nOptions:\n -h, --help Print this help message\n --screenshot OUTPUT Capture the full viewport as PNG\n --screenshot-panel PANEL OUTPUT\n Capture a named panel as PNG\n --screenshot-welcome OUTPUT Capture the post-close welcome surface\n\nArguments:\n FILE Image file to open on startup"
|
||||
"Usage: hcie-iced [OPTIONS] [FILE]\n\nHCIE - Iced Edition\n\nOptions:\n -h, --help Print this help message\n --screenshot OUTPUT Capture the full viewport as PNG\n --screenshot-panel PANEL OUTPUT\n Capture a named panel as PNG\n --screenshot-welcome OUTPUT Capture the post-close welcome surface\n --screenshot-svg-editor OUTPUT\n Capture the SVG node editor fixture\n\nArguments:\n FILE Image file to open on startup"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -192,6 +213,7 @@ mod tests {
|
||||
floating_panel: None,
|
||||
auto_hide_panel: None,
|
||||
welcome: false,
|
||||
svg_editor: false,
|
||||
output: PathBuf::from("full.png"),
|
||||
})
|
||||
);
|
||||
@@ -203,6 +225,14 @@ mod tests {
|
||||
assert_eq!(args.screenshot.unwrap().panel.as_deref(), Some("Geometry"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_svg_editor_screenshot() {
|
||||
let args = parse_args(["--screenshot-svg-editor", "svg-editor.png"]).unwrap();
|
||||
let screenshot = args.screenshot.expect("screenshot request");
|
||||
assert!(screenshot.svg_editor);
|
||||
assert_eq!(screenshot.output, PathBuf::from("svg-editor.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_positional_files() {
|
||||
assert!(parse_args(["one.png", "two.png"]).is_err());
|
||||
|
||||
@@ -591,6 +591,14 @@ impl canvas::Program<Message> for ColorWheel {
|
||||
return (canvas::event::Status::Captured, Some(self.message(color)));
|
||||
}
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => {
|
||||
if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) {
|
||||
return (
|
||||
canvas::event::Status::Captured,
|
||||
Some(Message::BgColorChanged(color)),
|
||||
);
|
||||
}
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => {
|
||||
if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) {
|
||||
return (canvas::event::Status::Captured, Some(self.message(color)));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Confirm dialog — save/discard/cancel confirmation.
|
||||
//! Confirm dialog — save/discard/cancel confirmation and selection operation parameter dialog.
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::theme::ThemeColors;
|
||||
@@ -28,18 +28,37 @@ pub fn close_confirm_view(doc_name: &str, colors: ThemeColors) -> Element<'stati
|
||||
super::modal(dialog, colors)
|
||||
}
|
||||
|
||||
/// Build a selection operation dialog (grow/shrink/feather).
|
||||
/// Return the slider range and unit suffix for a selection operation.
|
||||
fn selection_op_range(op: &str) -> (f32, f32, &'static str) {
|
||||
match op {
|
||||
"Erode" => (1.0, 50.0, "px"),
|
||||
"Fade" => (0.0, 100.0, "radius"),
|
||||
"Feather" => (0.0, 250.0, "px"),
|
||||
"Border" => (1.0, 200.0, "px"),
|
||||
_ => (1.0, 100.0, "px"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a selection operation dialog (grow/shrink/feather/erode/fade).
|
||||
pub fn selection_op_view(
|
||||
op_name: &str,
|
||||
value: f32,
|
||||
min: f32,
|
||||
max: f32,
|
||||
_min: f32,
|
||||
_max: f32,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'static, Message> {
|
||||
let op_owned = op_name.to_string();
|
||||
let value_slider = plain_slider("Value", value, min..=max, 1.0, "", 0, |v| {
|
||||
Message::SelectionOpValue(v)
|
||||
});
|
||||
let (range_min, range_max, unit) = selection_op_range(op_name);
|
||||
let step = if unit == "radius" { 0.5 } else { 1.0 };
|
||||
let value_slider = plain_slider(
|
||||
&format!("Value ({})", unit),
|
||||
value,
|
||||
range_min..=range_max,
|
||||
step,
|
||||
"",
|
||||
0,
|
||||
|v| Message::SelectionOpValue(v),
|
||||
);
|
||||
|
||||
let apply_btn = super::primary_btn("Apply", Message::SelectionOpApply, colors);
|
||||
let cancel_btn = super::secondary_btn("Cancel", Message::DialogClose, colors);
|
||||
|
||||
@@ -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 dock_profile;
|
||||
pub mod image_size;
|
||||
pub mod expand_canvas;
|
||||
pub mod new_image;
|
||||
pub mod save_error;
|
||||
|
||||
/// Wraps dialog content in an opaque themed card over a translucent full-screen scrim.
|
||||
///
|
||||
|
||||
@@ -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),
|
||||
height: axis(120.0, 300.0, f32::INFINITY, 35),
|
||||
},
|
||||
PaneType::Brushes | PaneType::Filters => PaneSizePolicy {
|
||||
PaneType::Brushes => PaneSizePolicy {
|
||||
width: axis(160.0, 300.0, f32::INFINITY, 30),
|
||||
height: axis(120.0, 280.0, f32::INFINITY, 30),
|
||||
},
|
||||
PaneType::Filters => PaneSizePolicy {
|
||||
width: axis(240.0, 400.0, f32::INFINITY, 30),
|
||||
height: axis(140.0, 320.0, f32::INFINITY, 30),
|
||||
},
|
||||
PaneType::Properties | PaneType::Geometry | PaneType::ToolSettings => PaneSizePolicy {
|
||||
width: axis(160.0, 280.0, f32::INFINITY, 25),
|
||||
height: axis(120.0, 280.0, f32::INFINITY, 25),
|
||||
|
||||
@@ -169,6 +169,8 @@ pub fn panel_body<'a>(
|
||||
doc.engine.active_layer_id(),
|
||||
&doc.engine,
|
||||
colors,
|
||||
&doc.layer_thumbnails,
|
||||
doc.thumb_gen,
|
||||
)
|
||||
}
|
||||
PaneType::History => {
|
||||
@@ -250,7 +252,7 @@ pub fn panel_body<'a>(
|
||||
app.script_is_running,
|
||||
colors,
|
||||
),
|
||||
PaneType::AiChat => crate::panels::ai_chat_panel::view(&app.ai_chat_state, colors),
|
||||
PaneType::AiChat => crate::panels::ai_chat_panel::view(app, colors),
|
||||
PaneType::AiScript => crate::panels::ai_script_panel::view(app),
|
||||
PaneType::LayerDetails => {
|
||||
let doc = &app.documents[app.active_doc];
|
||||
@@ -261,16 +263,16 @@ pub fn panel_body<'a>(
|
||||
)
|
||||
}
|
||||
PaneType::Geometry => {
|
||||
let shapes = app.documents[app.active_doc]
|
||||
.engine
|
||||
.active_vector_shapes()
|
||||
.unwrap_or_default();
|
||||
let doc = &app.documents[app.active_doc];
|
||||
let shapes = doc.engine.active_vector_shapes().unwrap_or_default();
|
||||
let active_id = doc.engine.active_layer_id();
|
||||
crate::panels::geometry::view(
|
||||
shapes,
|
||||
colors,
|
||||
app.documents[app.active_doc].selected_vector_shape,
|
||||
doc.selected_vector_shape,
|
||||
app.bool_shape_a,
|
||||
app.bool_shape_b,
|
||||
active_id,
|
||||
)
|
||||
}
|
||||
PaneType::ToolSettings => {
|
||||
|
||||
@@ -14,6 +14,7 @@ mod dock;
|
||||
mod i18n;
|
||||
mod io;
|
||||
mod panels;
|
||||
mod plugins;
|
||||
mod raster;
|
||||
mod screenshot;
|
||||
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,
|
||||
//! streaming response display, chat bubbles, reasoning toggle, and canvas context toggle.
|
||||
//! Uses ThemeColors for consistent styling.
|
||||
//! Mirrors the egui AI Assistant panel which has both ComfyUI and AI Chat tabs.
|
||||
//! The active tab is controlled by `app.ai_assistant_tab` (0 = ComfyUI, 1 = AI Chat).
|
||||
//! Uses ThemeColors for consistent styling across both tabs.
|
||||
|
||||
use crate::ai_chat::{AiChatState, ChatMessage};
|
||||
use crate::ai_chat::{AiChatState, ChatMessage, ComfyStatus, ComfyUiState};
|
||||
use crate::app::Message;
|
||||
use crate::panels::styles;
|
||||
use crate::theme::ThemeColors;
|
||||
use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, text_input};
|
||||
use iced::{Element, Length};
|
||||
use crate::app::HcieIcedApp;
|
||||
|
||||
/// Build the AI chat panel with full streaming UI.
|
||||
pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Message> {
|
||||
/// Build the AI Assistant panel with tab switcher and sub-tab content.
|
||||
pub fn view(app: &HcieIcedApp, colors: ThemeColors) -> Element<'static, Message> {
|
||||
let tab = app.ai_assistant_tab;
|
||||
|
||||
// ── Tab bar ──
|
||||
let comfy_tab_btn = tab_button("ComfyUI", tab == 0, 0, colors);
|
||||
let chat_tab_btn = tab_button("AI Chat", tab == 1, 1, colors);
|
||||
let tab_bar = row![comfy_tab_btn, chat_tab_btn]
|
||||
.spacing(0)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
// ── Content area ──
|
||||
let content: Element<'static, Message> = match tab {
|
||||
0 => comfy_tab_content(&app.comfy_state, colors),
|
||||
_ => chat_tab_content(&app.ai_chat_state, colors),
|
||||
};
|
||||
|
||||
let panel = column![
|
||||
tab_bar,
|
||||
horizontal_rule(1),
|
||||
content,
|
||||
]
|
||||
.spacing(0)
|
||||
.padding(8);
|
||||
|
||||
container(panel)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| styles::panel_background(colors))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A tab button that highlights when active.
|
||||
fn tab_button(
|
||||
label: &str,
|
||||
active: bool,
|
||||
tab_index: usize,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'static, Message> {
|
||||
let label_owned = label.to_string();
|
||||
let bg = if active { colors.accent } else { colors.bg_active };
|
||||
let fg = if active { iced::Color::WHITE } else { colors.text_secondary };
|
||||
|
||||
button(text(label_owned).size(11))
|
||||
.on_press(Message::AiAssistantTabChanged(tab_index))
|
||||
.padding([4, 12])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
let (background, text_color) = match status {
|
||||
iced::widget::button::Status::Active => (bg, fg),
|
||||
iced::widget::button::Status::Hovered => {
|
||||
(colors.accent_hover, iced::Color::WHITE)
|
||||
}
|
||||
iced::widget::button::Status::Pressed => {
|
||||
(colors.accent, iced::Color::WHITE)
|
||||
}
|
||||
iced::widget::button::Status::Disabled => {
|
||||
(colors.bg_app, colors.text_disabled)
|
||||
}
|
||||
};
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(background)),
|
||||
text_color,
|
||||
border: iced::Border::default().rounded(4),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// ComfyUI tab content — placeholder pipeline viewer.
|
||||
fn comfy_tab_content(state: &ComfyUiState, colors: ThemeColors) -> Element<'static, Message> {
|
||||
let status_label = match state.status {
|
||||
ComfyStatus::Unknown => "Unknown",
|
||||
ComfyStatus::Running => "Running",
|
||||
ComfyStatus::Error => "Error",
|
||||
};
|
||||
let status_color = match state.status {
|
||||
ComfyStatus::Unknown => colors.text_disabled,
|
||||
ComfyStatus::Running => colors.accent_green,
|
||||
ComfyStatus::Error => iced::Color::from_rgb(0.95, 0.3, 0.3),
|
||||
};
|
||||
|
||||
let url_input = text_input("http://127.0.0.1:8188", &state.url)
|
||||
.on_input(|s| Message::ComfyUiUrlChanged(s))
|
||||
.size(10)
|
||||
.width(Length::Fill);
|
||||
|
||||
let check_btn = button(text("Check Status").size(10))
|
||||
.on_press(Message::ComfyUiCheckStatus)
|
||||
.padding([4, 10]);
|
||||
|
||||
let status_row = row![
|
||||
text("Status:").size(10).color(colors.text_secondary),
|
||||
text(status_label).size(10).color(status_color),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
column![
|
||||
text("ComfyUI Pipeline").size(13).color(colors.text_primary),
|
||||
text("").size(4),
|
||||
text("Connect to a running ComfyUI instance to run")
|
||||
.size(10)
|
||||
.color(colors.text_disabled),
|
||||
text("stable diffusion pipelines and AI image generation.")
|
||||
.size(10)
|
||||
.color(colors.text_disabled),
|
||||
text("").size(8),
|
||||
row![
|
||||
text("Server URL:").size(10).color(colors.text_secondary),
|
||||
url_input,
|
||||
check_btn,
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center),
|
||||
status_row,
|
||||
text("").size(8),
|
||||
container(
|
||||
text("Pipeline playback and node graph editing will be added in a future update.")
|
||||
.size(10)
|
||||
.color(colors.text_disabled),
|
||||
)
|
||||
.padding(8)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_hover)),
|
||||
border: iced::Border::default().rounded(6),
|
||||
..Default::default()
|
||||
}),
|
||||
]
|
||||
.spacing(4)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// AI Chat tab content — streaming SSE chat interface.
|
||||
fn chat_tab_content(state: &AiChatState, colors: ThemeColors) -> Element<'static, Message> {
|
||||
// ── Provider selector row ──
|
||||
let provider_label = text("Provider:").size(10).color(colors.text_secondary);
|
||||
let ollama_btn = make_provider_btn(
|
||||
@@ -90,11 +224,14 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
let hint = text("Model discovery is unavailable; enter the exact provider model ID.")
|
||||
.size(9)
|
||||
.color(colors.text_disabled);
|
||||
|
||||
// ── Message list ──
|
||||
let mut msg_list = column![].spacing(6);
|
||||
|
||||
if state.messages.is_empty() && !state.is_streaming {
|
||||
// Welcome message
|
||||
let welcome = container(
|
||||
column![
|
||||
text("AI Creative Assistant").size(13).color(colors.accent),
|
||||
@@ -122,13 +259,11 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
|
||||
|
||||
msg_list = msg_list.push(welcome);
|
||||
} else {
|
||||
// Render chat history
|
||||
for msg in &state.messages {
|
||||
let bubble = render_message_bubble(msg, colors);
|
||||
msg_list = msg_list.push(bubble);
|
||||
}
|
||||
|
||||
// Render streaming reasoning if enabled
|
||||
if state.config.reasoning_enabled && !state.current_reasoning.is_empty() {
|
||||
let reasoning_bubble = container(
|
||||
column![
|
||||
@@ -156,7 +291,6 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
|
||||
msg_list = msg_list.push(reasoning_bubble);
|
||||
}
|
||||
|
||||
// Render streaming response if in progress
|
||||
if state.is_streaming && !state.current_response.is_empty() {
|
||||
let streaming_bubble = container(
|
||||
text(state.current_response.clone())
|
||||
@@ -178,7 +312,6 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
|
||||
msg_list = msg_list.push(streaming_bubble);
|
||||
}
|
||||
|
||||
// Streaming indicator
|
||||
if state.is_streaming {
|
||||
let indicator = row![
|
||||
text("...").size(10).color(colors.text_disabled),
|
||||
@@ -219,14 +352,10 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
// ── Assemble panel ──
|
||||
let panel = column![
|
||||
text("AI Assistant").size(13).color(colors.text_primary),
|
||||
column![
|
||||
provider_row,
|
||||
config_row,
|
||||
text("Model discovery is unavailable; enter the exact provider model ID.")
|
||||
.size(9)
|
||||
.color(colors.text_disabled),
|
||||
hint,
|
||||
toggles_row,
|
||||
horizontal_rule(1),
|
||||
scrollable(msg_list).height(Length::Fill),
|
||||
@@ -234,16 +363,9 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
|
||||
input_row,
|
||||
]
|
||||
.spacing(4)
|
||||
.padding(8);
|
||||
|
||||
container(panel)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| styles::panel_background(colors))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Build an action button whose disabled, hover, pressed, and focus-border states are explicit.
|
||||
fn action_button(
|
||||
label: &'static str,
|
||||
message: Option<Message>,
|
||||
@@ -279,7 +401,6 @@ fn action_button(
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Render a single chat message as a styled bubble.
|
||||
fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'static, Message> {
|
||||
if msg.is_reasoning {
|
||||
return container(
|
||||
@@ -302,7 +423,6 @@ fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'sta
|
||||
}
|
||||
match msg.role.as_str() {
|
||||
"user" => {
|
||||
// User bubble — right-aligned, blue tint
|
||||
let label = text("You").size(9).color(colors.accent);
|
||||
let content = text(msg.content.clone())
|
||||
.size(11)
|
||||
@@ -321,7 +441,6 @@ fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'sta
|
||||
row![iced::widget::Space::with_width(Length::Fill), bubble].into()
|
||||
}
|
||||
"tool" => {
|
||||
// Tool result — monospace pill
|
||||
let content = text(msg.content.clone()).size(10).color(colors.accent);
|
||||
container(content)
|
||||
.padding([4, 8])
|
||||
@@ -333,7 +452,6 @@ fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'sta
|
||||
.into()
|
||||
}
|
||||
_ => {
|
||||
// Assistant bubble — left-aligned, slate tint
|
||||
let label = text("AI").size(9).color(colors.accent_green);
|
||||
let content = text(msg.content.clone())
|
||||
.size(11)
|
||||
@@ -354,7 +472,6 @@ fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'sta
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a provider selection button with active state highlighting.
|
||||
fn make_provider_btn(
|
||||
label: &str,
|
||||
is_active: bool,
|
||||
|
||||
@@ -12,12 +12,15 @@ use crate::panels::typography::{BODY, SECTION};
|
||||
use crate::theme::ThemeColors;
|
||||
use hcie_engine_api::{LineCap, VectorBooleanOp, VectorShape};
|
||||
use iced::widget::{
|
||||
button, checkbox, column, container, pick_list, row, scrollable, slider, text, Space,
|
||||
button, checkbox, column, container, pick_list, row, scrollable, slider, text, text_input,
|
||||
Space,
|
||||
};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Human-readable label for a shape at a given index.
|
||||
fn shape_label(shapes: &[VectorShape], i: usize) -> String {
|
||||
let name = shapes[i].name();
|
||||
if name.is_empty() {
|
||||
match &shapes[i] {
|
||||
VectorShape::Line { .. } => format!("Line #{}", 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::FreePath { pts, .. } => format!("Path ({} pts) #{}", pts.len(), i + 1),
|
||||
}
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a section header bar with the given label.
|
||||
@@ -80,6 +86,7 @@ pub fn view(
|
||||
selected_shape: Option<usize>,
|
||||
bool_shape_a: Option<usize>,
|
||||
bool_shape_b: Option<usize>,
|
||||
layer_id: u64,
|
||||
) -> Element<'static, Message> {
|
||||
let has_shapes = !shapes.is_empty();
|
||||
let shape_count = shapes.len();
|
||||
@@ -90,25 +97,46 @@ pub fn view(
|
||||
content = content.push(section_header("SHAPES".to_string(), colors));
|
||||
|
||||
if has_shapes {
|
||||
// Delete button in header row (right-aligned)
|
||||
// Delete and reorder buttons in header row (right-aligned)
|
||||
if selected_shape.is_some() {
|
||||
content = content.push(
|
||||
container(
|
||||
row![
|
||||
Space::with_width(Length::Fill),
|
||||
let sel = selected_shape.unwrap();
|
||||
// The list is shown front-to-back: the last rendered shape (top of Z)
|
||||
// appears at the top of the list, and the first rendered shape (back)
|
||||
// appears at the bottom. Therefore:
|
||||
// ▲ (MoveUp) moves the selected shape toward the front (higher index).
|
||||
// ▼ (MoveDown) moves it toward the back (lower index).
|
||||
let can_up = sel + 1 < shape_count;
|
||||
let can_down = sel > 0;
|
||||
let mut header = row![Space::with_width(Length::Fill)];
|
||||
if can_up {
|
||||
header = header.push(
|
||||
button(text("▲").size(BODY))
|
||||
.on_press(Message::VectorShapeMoveUp)
|
||||
.padding([2, 6]),
|
||||
);
|
||||
}
|
||||
if can_down {
|
||||
header = header.push(
|
||||
button(text("▼").size(BODY))
|
||||
.on_press(Message::VectorShapeMoveDown)
|
||||
.padding([2, 6]),
|
||||
);
|
||||
}
|
||||
header = header.push(
|
||||
button(text("X").size(BODY))
|
||||
.on_press(Message::VectorShapeDelete)
|
||||
.padding([2, 6]),
|
||||
]
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.padding([2, 0]),
|
||||
);
|
||||
content = content.push(
|
||||
container(header.spacing(4).width(Length::Fill)).padding([2, 0]),
|
||||
);
|
||||
}
|
||||
|
||||
// Shape list
|
||||
// Shape list — rendered front-to-back so the topmost (last index)
|
||||
// shape appears at the top of the list.
|
||||
let mut shape_list = column![].spacing(1);
|
||||
for i in 0..shape_count {
|
||||
for display_i in 0..shape_count {
|
||||
let i = shape_count - 1 - display_i;
|
||||
let label = shape_label(&shapes, i);
|
||||
let is_selected = selected_shape == Some(i);
|
||||
let c = colors;
|
||||
@@ -261,6 +289,41 @@ pub fn view(
|
||||
content = content.push(section_header(format!("GEOMETRY [#{}]", idx + 1), colors));
|
||||
content = content.push(Space::with_height(4));
|
||||
|
||||
// Shape name (editable)
|
||||
let shape_name = shapes[idx].name();
|
||||
content = content.push(
|
||||
row![
|
||||
text("Name").size(BODY).width(42),
|
||||
text_input("Shape name", &shape_name)
|
||||
.on_input(move |v| Message::VectorShapeRename(idx, v))
|
||||
.size(BODY)
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center),
|
||||
);
|
||||
content = content.push(Space::with_height(4));
|
||||
|
||||
// "Edit SVG" button — only for SvgShape variants (EGUI parity)
|
||||
if matches!(&shapes[idx], VectorShape::SvgShape { .. }) {
|
||||
let c = colors;
|
||||
content = content.push(
|
||||
button(text("Edit SVG").size(BODY))
|
||||
.on_press(Message::SvgEditorOpen {
|
||||
layer_id,
|
||||
shape_idx: idx,
|
||||
})
|
||||
.padding([4, 8])
|
||||
.style(move |_theme, _status| iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.accent)),
|
||||
text_color: iced::Color::WHITE,
|
||||
border: iced::Border::default().rounded(3),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
content = content.push(Space::with_height(4));
|
||||
}
|
||||
|
||||
let (x1, y1, x2, y2) = shapes[idx].bounds();
|
||||
let w = (x2 - x1).abs();
|
||||
let h = (y2 - y1).abs();
|
||||
|
||||
@@ -6,10 +6,16 @@
|
||||
//! 2. Scrollable layer list — each row shows indent, collapse arrow, visibility, thumbnail,
|
||||
//! name, FX icon, and type badge. Layers with effects show expandable effect sub-items.
|
||||
//! 3. Bottom toolbar — add layer, add group, flatten, delete, move up/down
|
||||
//!
|
||||
//! Z-order convention:
|
||||
//! The engine renders layers in array order: the first layer is at the back, the
|
||||
//! last layer is at the front. The UI list mirrors this order, so the topmost
|
||||
//! (front-most) layer appears at the bottom of the list. Therefore "Move Up" in
|
||||
//! the toolbar moves the layer toward the end of the array / front of the canvas,
|
||||
//! and "Move Down" moves it toward the start / back.
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::panels::styles;
|
||||
use crate::panels::thumbnails;
|
||||
use crate::panels::typography::BODY;
|
||||
use crate::theme::ThemeColors;
|
||||
use crate::widgets::plain_slider::plain_slider;
|
||||
@@ -80,6 +86,9 @@ struct LayerEntry<'a> {
|
||||
}
|
||||
|
||||
/// Build a flat rendering order from the layer list, respecting group hierarchy.
|
||||
/// Layers are returned in visual order: the top/front-most layer first and the
|
||||
/// bottom/back-most layer last. This matches Photoshop-style panels where the
|
||||
/// layer you see on top of the canvas appears at the top of the list.
|
||||
fn build_tree<'a>(layers: &'a [LayerInfo]) -> Vec<LayerEntry<'a>> {
|
||||
let mut children_of: std::collections::HashMap<Option<u64>, Vec<&LayerInfo>> =
|
||||
std::collections::HashMap::new();
|
||||
@@ -95,6 +104,8 @@ fn build_tree<'a>(layers: &'a [LayerInfo]) -> Vec<LayerEntry<'a>> {
|
||||
result: &mut Vec<LayerEntry<'a>>,
|
||||
) {
|
||||
if let Some(children) = children_of.get(&parent_id) {
|
||||
// children is already in reverse-engine order (front-most first); walk
|
||||
// it as-is so the list renders front-to-back.
|
||||
for info in children {
|
||||
let is_group = info.layer_type == LayerType::Group;
|
||||
result.push(LayerEntry {
|
||||
@@ -123,10 +134,11 @@ fn flat_btn_style(colors: ThemeColors) -> iced::widget::button::Style {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the thumbnail element for a layer row.
|
||||
/// Build the thumbnail element for a layer row from the cache or as a fallback icon.
|
||||
fn layer_thumbnail<'a>(
|
||||
entry: &LayerEntry<'a>,
|
||||
engine: &hcie_engine_api::Engine,
|
||||
thumb_cache: &std::collections::HashMap<u64, (u32, u32, Vec<u8>)>,
|
||||
_thumb_gen: u64,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'a, Message> {
|
||||
if entry.is_group {
|
||||
@@ -137,15 +149,12 @@ fn layer_thumbnail<'a>(
|
||||
.into();
|
||||
}
|
||||
|
||||
let info = entry.info;
|
||||
let thumb = engine
|
||||
.get_layer_pixels(info.id)
|
||||
.filter(|px| !px.is_empty() && info.width > 0 && info.height > 0)
|
||||
.map(|px| thumbnails::generate_thumbnail(&px, info.width, info.height, 48));
|
||||
|
||||
if let Some(pixels) = thumb {
|
||||
let handle = iced::widget::image::Handle::from_rgba(48, 36, pixels);
|
||||
let img = iced::widget::Image::new(handle).width(48).height(36);
|
||||
if let Some((tw, th, pixels)) = thumb_cache.get(&entry.info.id) {
|
||||
let (tw32, th32) = (*tw as u32, *th as u32);
|
||||
let handle = iced::widget::image::Handle::from_rgba(tw32, th32, pixels.clone());
|
||||
let img = iced::widget::Image::new(handle)
|
||||
.width(Length::Fixed(*tw as f32))
|
||||
.height(Length::Fixed(*th as f32));
|
||||
return container(img)
|
||||
.width(50)
|
||||
.height(38)
|
||||
@@ -159,7 +168,7 @@ fn layer_thumbnail<'a>(
|
||||
}
|
||||
|
||||
// Fallback: type-based icon
|
||||
let (icon_char, icon_color) = match info.layer_type {
|
||||
let (icon_char, icon_color) = match entry.info.layer_type {
|
||||
LayerType::Vector => ("V", iced::Color::from_rgb(0.4, 0.8, 0.4)),
|
||||
LayerType::Text => ("T", iced::Color::from_rgb(0.4, 0.6, 1.0)),
|
||||
LayerType::Mask => ("M", iced::Color::from_rgb(0.8, 0.4, 0.8)),
|
||||
@@ -191,6 +200,8 @@ pub fn view<'a>(
|
||||
active_layer_id: u64,
|
||||
engine: &hcie_engine_api::Engine,
|
||||
colors: ThemeColors,
|
||||
thumb_cache: &'a std::collections::HashMap<u64, (u32, u32, Vec<u8>)>,
|
||||
thumb_gen: u64,
|
||||
) -> Element<'a, Message> {
|
||||
let entries = build_tree(layers);
|
||||
|
||||
@@ -315,7 +326,7 @@ pub fn view<'a>(
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
|
||||
// Thumbnail
|
||||
let thumb_elem = layer_thumbnail(entry, engine, colors);
|
||||
let thumb_elem = layer_thumbnail(entry, thumb_cache, thumb_gen, colors);
|
||||
|
||||
// Layer name
|
||||
let c = if is_active {
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::app::Message;
|
||||
use crate::dock::state::{DockState, PaneType};
|
||||
use crate::panels::typography;
|
||||
use crate::theme::ThemeColors;
|
||||
use hcie_engine_api::Tool;
|
||||
use iced::widget::{column, container, horizontal_rule, mouse_area, row, text, Space};
|
||||
use iced::{Element, Length};
|
||||
|
||||
@@ -26,25 +27,47 @@ use iced::{Element, Length};
|
||||
/// carry their path so reordering the recent list cannot change the selected file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MenuCommand {
|
||||
// File
|
||||
NewDocument,
|
||||
OpenFile,
|
||||
Save,
|
||||
SaveAs,
|
||||
OpenRecent(String),
|
||||
ClearRecent,
|
||||
ImportFile,
|
||||
ImportSvg,
|
||||
ImportBrushes,
|
||||
Export,
|
||||
// Edit
|
||||
Undo,
|
||||
Redo,
|
||||
Copy,
|
||||
Cut,
|
||||
ClearPixels,
|
||||
Paste,
|
||||
PasteSpecial,
|
||||
Fill,
|
||||
Stroke,
|
||||
FreeTransform,
|
||||
Preferences,
|
||||
// Image
|
||||
CanvasSize,
|
||||
ImageSize,
|
||||
Rotate90CW,
|
||||
Rotate90CCW,
|
||||
Rotate180,
|
||||
FlipHorizontal,
|
||||
FlipVertical,
|
||||
ImageCrop,
|
||||
InvertNegative,
|
||||
// Layer
|
||||
AddLayer,
|
||||
DeleteLayer,
|
||||
LayerStyles,
|
||||
MergeDown,
|
||||
Flatten,
|
||||
AlignLayer(String),
|
||||
// Select
|
||||
SelectAll,
|
||||
Deselect,
|
||||
InvertSelection,
|
||||
@@ -57,16 +80,37 @@ pub enum MenuCommand {
|
||||
SaveSelection,
|
||||
LoadSelection,
|
||||
ToggleQuickMask,
|
||||
ErodeBorder,
|
||||
FadeBorder,
|
||||
// Filter (distort, noise, pixelate, render, restore, stylize, texture)
|
||||
FilterDistort(String),
|
||||
FilterNoise(String),
|
||||
FilterPixelate(String),
|
||||
FilterRender(String),
|
||||
FilterRestore(String),
|
||||
FilterStylize(String),
|
||||
FilterTexture(String),
|
||||
// View
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
FitArea,
|
||||
ActualPixels,
|
||||
ResetPan,
|
||||
ViewerToggle,
|
||||
// Window
|
||||
TogglePane(PaneType),
|
||||
OpenDocuments,
|
||||
ResetLayout,
|
||||
// Tool
|
||||
SelectTool(Tool),
|
||||
// Theme
|
||||
SetTheme(crate::theme::ThemePreset),
|
||||
// Misc
|
||||
SelectCropTool,
|
||||
GaussianBlur,
|
||||
Mosaic,
|
||||
UnsharpMask,
|
||||
TogglePane(PaneType),
|
||||
SetTheme(crate::theme::ThemePreset),
|
||||
About,
|
||||
}
|
||||
|
||||
/// Describes one rendered menu row and its optional semantic operation.
|
||||
@@ -242,12 +286,17 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
MenuItem::new("Open & Place..."),
|
||||
MenuItem::submenu("Open More"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::command("Import...", MenuCommand::ImportFile),
|
||||
MenuItem::command("Import SVG...", MenuCommand::ImportSvg),
|
||||
MenuItem::command("Import Brushes...", MenuCommand::ImportBrushes),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Share"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::command_with_shortcut("Save", "Ctrl+S", MenuCommand::Save),
|
||||
MenuItem::command("Save as PSD", MenuCommand::SaveAs),
|
||||
MenuItem::submenu("Save More"),
|
||||
MenuItem::submenu("Export as"),
|
||||
MenuItem::command("Export...", MenuCommand::Export),
|
||||
MenuItem::with_shortcut("Print...", "Ctrl+P"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Export Layers..."),
|
||||
@@ -292,13 +341,15 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
MenuItem::command_with_shortcut("Paste", "Ctrl+V", MenuCommand::Paste),
|
||||
MenuItem::command("Clear", MenuCommand::ClearPixels),
|
||||
MenuItem::separator(),
|
||||
MenuItem::with_shortcut("Fill...", "Shift+F5"),
|
||||
MenuItem::new("Stroke..."),
|
||||
MenuItem::command_with_shortcut("Paste Special", "Shift+Ctrl+V", MenuCommand::PasteSpecial),
|
||||
MenuItem::separator(),
|
||||
MenuItem::command_with_shortcut("Fill...", "Shift+F5", MenuCommand::Fill),
|
||||
MenuItem::command("Stroke...", MenuCommand::Stroke),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Content-Aware Scale"),
|
||||
MenuItem::new("Puppet Warp"),
|
||||
MenuItem::new("Perspective Warp"),
|
||||
MenuItem::with_shortcut("Free Transform", "Alt+Ctrl+T"),
|
||||
MenuItem::command_with_shortcut("Free Transform", "Alt+Ctrl+T", MenuCommand::FreeTransform),
|
||||
MenuItem::submenu("Transform"),
|
||||
MenuItem::new("Auto-Align"),
|
||||
MenuItem::new("Auto-Blend"),
|
||||
@@ -308,10 +359,40 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
MenuItem::separator(),
|
||||
MenuItem::submenu("Define New"),
|
||||
MenuItem::new("Preset Manager..."),
|
||||
MenuItem::with_shortcut("Preferences...", "Ctrl+K"),
|
||||
MenuItem::command_with_shortcut("Preferences...", "Ctrl+K", MenuCommand::Preferences),
|
||||
MenuItem::new("Local Storage..."),
|
||||
],
|
||||
},
|
||||
// ── Tools menu (Photopea-style) ──
|
||||
MenuDef {
|
||||
label: "Tools".to_string(),
|
||||
items: vec![
|
||||
MenuItem::command_with_shortcut("Eyedropper", "I", MenuCommand::SelectTool(Tool::Eyedropper)),
|
||||
MenuItem::command_with_shortcut("Pencil / Pen", "P", MenuCommand::SelectTool(Tool::Pen)),
|
||||
MenuItem::command_with_shortcut("Art Brush", "B", MenuCommand::SelectTool(Tool::Brush)),
|
||||
MenuItem::command_with_shortcut("Eraser", "E", MenuCommand::SelectTool(Tool::Eraser)),
|
||||
MenuItem::command("Spray", MenuCommand::SelectTool(Tool::Spray)),
|
||||
MenuItem::command_with_shortcut("Flood Fill", "F", MenuCommand::SelectTool(Tool::FloodFill)),
|
||||
MenuItem::command_with_shortcut("Magic Wand", "W", MenuCommand::SelectTool(Tool::MagicWand)),
|
||||
MenuItem::command_with_shortcut("Rect Selection", "M", MenuCommand::SelectTool(Tool::Select)),
|
||||
MenuItem::command_with_shortcut("Lasso", "L", MenuCommand::SelectTool(Tool::Lasso)),
|
||||
MenuItem::command("Polygon Select", MenuCommand::SelectTool(Tool::PolygonSelect)),
|
||||
MenuItem::command_with_shortcut("Move", "V", MenuCommand::SelectTool(Tool::Move)),
|
||||
MenuItem::command_with_shortcut("Crop", "C", MenuCommand::SelectTool(Tool::Crop)),
|
||||
MenuItem::command_with_shortcut("Text", "T", MenuCommand::SelectTool(Tool::Text)),
|
||||
MenuItem::command_with_shortcut("Gradient", "G", MenuCommand::SelectTool(Tool::Gradient)),
|
||||
MenuItem::separator(),
|
||||
MenuItem::disabled("Retouch"),
|
||||
MenuItem::command("Red-eye Removal", MenuCommand::SelectTool(Tool::RedEyeRemoval)),
|
||||
MenuItem::command("Spot Removal Patch", MenuCommand::SelectTool(Tool::SpotRemoval)),
|
||||
MenuItem::command("Smart Patch", MenuCommand::SelectTool(Tool::SmartPatch)),
|
||||
MenuItem::separator(),
|
||||
MenuItem::disabled("AI"),
|
||||
MenuItem::command("AI Object Removal", MenuCommand::SelectTool(Tool::AiObjectRemoval)),
|
||||
MenuItem::command("Smart Select", MenuCommand::SelectTool(Tool::SmartSelect)),
|
||||
MenuItem::command("Vision Select", MenuCommand::SelectTool(Tool::VisionSelect)),
|
||||
],
|
||||
},
|
||||
// ── Image menu (Photopea-style) ──
|
||||
MenuDef {
|
||||
label: "Image".to_string(),
|
||||
@@ -337,10 +418,16 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
MenuCommand::ImageSize,
|
||||
),
|
||||
MenuItem::submenu("Transform"),
|
||||
MenuItem::new("Crop"),
|
||||
MenuItem::command("Rotate 90° CW", MenuCommand::Rotate90CW),
|
||||
MenuItem::command("Rotate 90° CCW", MenuCommand::Rotate90CCW),
|
||||
MenuItem::command("Rotate 180°", MenuCommand::Rotate180),
|
||||
MenuItem::command("Flip Horizontal", MenuCommand::FlipHorizontal),
|
||||
MenuItem::command("Flip Vertical", MenuCommand::FlipVertical),
|
||||
MenuItem::command("Crop", MenuCommand::ImageCrop),
|
||||
MenuItem::with_shortcut("Trim...", "Ctrl+."),
|
||||
MenuItem::new("Reveal All"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::command("Invert / Negative", MenuCommand::InvertNegative),
|
||||
MenuItem::new("Duplicate"),
|
||||
MenuItem::new("Apply Image..."),
|
||||
MenuItem::separator(),
|
||||
@@ -372,6 +459,12 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
MenuItem::separator(),
|
||||
MenuItem::with_shortcut("Group Layers", "Ctrl+G"),
|
||||
MenuItem::submenu("Arrange"),
|
||||
MenuItem::command("Align Left", MenuCommand::AlignLayer("left".to_string())),
|
||||
MenuItem::command("Align Center", MenuCommand::AlignLayer("center".to_string())),
|
||||
MenuItem::command("Align Right", MenuCommand::AlignLayer("right".to_string())),
|
||||
MenuItem::command("Align Top", MenuCommand::AlignLayer("top".to_string())),
|
||||
MenuItem::command("Align Middle", MenuCommand::AlignLayer("middle".to_string())),
|
||||
MenuItem::command("Align Bottom", MenuCommand::AlignLayer("bottom".to_string())),
|
||||
MenuItem::submenu("Combine Shapes"),
|
||||
MenuItem::submenu("Animation"),
|
||||
MenuItem::separator(),
|
||||
@@ -416,6 +509,8 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
),
|
||||
MenuItem::command("Border...", MenuCommand::BorderSelection),
|
||||
MenuItem::command("Smooth...", MenuCommand::SmoothSelection),
|
||||
MenuItem::command("Erode Border", MenuCommand::ErodeBorder),
|
||||
MenuItem::command("Fade Border", MenuCommand::FadeBorder),
|
||||
MenuItem::separator(),
|
||||
MenuItem::command(
|
||||
"New Selection",
|
||||
@@ -459,17 +554,92 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
MenuItem::new("Liquify..."),
|
||||
MenuItem::new("Vanishing Point..."),
|
||||
MenuItem::separator(),
|
||||
MenuItem::submenu("3D"),
|
||||
MenuItem::submenu("Blur"),
|
||||
MenuItem::submenu("Blur Gallery"),
|
||||
MenuItem::submenu("Distort"),
|
||||
MenuItem::submenu("Noise"),
|
||||
MenuItem::submenu("Pixelate"),
|
||||
MenuItem::submenu("Render"),
|
||||
MenuItem::submenu("Sharpen"),
|
||||
MenuItem::submenu("Stylize"),
|
||||
MenuItem::submenu("Other"),
|
||||
MenuItem::submenu("Fourier"),
|
||||
// ── Blur ──
|
||||
MenuItem::disabled("Blur"),
|
||||
MenuItem::command("Box Blur", MenuCommand::FilterDistort("box_blur".into())),
|
||||
MenuItem::command("Gaussian Blur", MenuCommand::FilterDistort("gaussian_blur".into())),
|
||||
MenuItem::command("Motion Blur", MenuCommand::FilterDistort("motion_blur".into())),
|
||||
MenuItem::command("Unsharp Mask", MenuCommand::FilterRestore("unsharp_mask".into())),
|
||||
MenuItem::separator(),
|
||||
// ── Distort ──
|
||||
MenuItem::disabled("Distort"),
|
||||
MenuItem::command("Pinch / Punch", MenuCommand::FilterDistort("pinch".into())),
|
||||
MenuItem::command("Twirl", MenuCommand::FilterDistort("twirl".into())),
|
||||
MenuItem::separator(),
|
||||
// ── Noise ──
|
||||
MenuItem::disabled("Noise"),
|
||||
MenuItem::command("Add Noise", MenuCommand::FilterNoise("add_noise".into())),
|
||||
MenuItem::command("Median Filter", MenuCommand::FilterRestore("median".into())),
|
||||
MenuItem::command("Dehaze", MenuCommand::FilterRestore("dehaze".into())),
|
||||
MenuItem::command("Noise Pattern", MenuCommand::FilterNoise("noise_pattern".into())),
|
||||
MenuItem::separator(),
|
||||
// ── Pixelate ──
|
||||
MenuItem::disabled("Pixelate"),
|
||||
MenuItem::command("Crystallize", MenuCommand::FilterPixelate("crystallize".into())),
|
||||
MenuItem::command("Mosaic", MenuCommand::FilterPixelate("mosaic".into())),
|
||||
MenuItem::separator(),
|
||||
// ── Render ──
|
||||
MenuItem::disabled("Render"),
|
||||
MenuItem::command("Clouds", MenuCommand::FilterRender("clouds".into())),
|
||||
MenuItem::separator(),
|
||||
// ── Procedural Textures (flat-grouped; ICED menus are single-level) ──
|
||||
MenuItem::disabled("Procedural Textures"),
|
||||
MenuItem::disabled("Natural"),
|
||||
MenuItem::command("Clouds Texture", MenuCommand::FilterTexture("texture_clouds".into())),
|
||||
MenuItem::command("Grass", MenuCommand::FilterTexture("texture_grass".into())),
|
||||
MenuItem::command("Sand", MenuCommand::FilterTexture("texture_sand".into())),
|
||||
MenuItem::command("Dirt", MenuCommand::FilterTexture("texture_dirt".into())),
|
||||
MenuItem::command("Water", MenuCommand::FilterTexture("texture_water".into())),
|
||||
MenuItem::command("Rain", MenuCommand::FilterTexture("texture_rain".into())),
|
||||
MenuItem::command("Stone", MenuCommand::FilterTexture("texture_stone".into())),
|
||||
MenuItem::separator(),
|
||||
MenuItem::disabled("Architecture"),
|
||||
MenuItem::command("Bricks", MenuCommand::FilterTexture("texture_bricks".into())),
|
||||
MenuItem::separator(),
|
||||
MenuItem::disabled("Wood"),
|
||||
MenuItem::command("Oak", MenuCommand::FilterTexture("texture_wood_oak".into())),
|
||||
MenuItem::command("Pine", MenuCommand::FilterTexture("texture_wood_pine".into())),
|
||||
MenuItem::command("Dark", MenuCommand::FilterTexture("texture_wood_dark".into())),
|
||||
MenuItem::separator(),
|
||||
MenuItem::disabled("Metal"),
|
||||
MenuItem::command("Steel", MenuCommand::FilterTexture("texture_metal_steel".into())),
|
||||
MenuItem::command("Gold", MenuCommand::FilterTexture("texture_metal_gold".into())),
|
||||
MenuItem::command("Copper", MenuCommand::FilterTexture("texture_metal_copper".into())),
|
||||
MenuItem::separator(),
|
||||
MenuItem::disabled("Material"),
|
||||
MenuItem::command("Linen", MenuCommand::FilterTexture("texture_linen".into())),
|
||||
MenuItem::command("Glass", MenuCommand::FilterTexture("texture_glass".into())),
|
||||
MenuItem::separator(),
|
||||
MenuItem::disabled("Effect"),
|
||||
MenuItem::command("Sky Replacement", MenuCommand::FilterTexture("sky_replacement".into())),
|
||||
MenuItem::separator(),
|
||||
// ── Sharpen / Restore ──
|
||||
MenuItem::disabled("Sharpen / Restore"),
|
||||
MenuItem::command("Sharpen", MenuCommand::FilterRestore("sharpen".into())),
|
||||
MenuItem::command("Smart Patch", MenuCommand::FilterRestore("smart_patch".into())),
|
||||
MenuItem::separator(),
|
||||
// ── Stylize ──
|
||||
MenuItem::disabled("Stylize"),
|
||||
MenuItem::command("Emboss", MenuCommand::FilterStylize("emboss".into())),
|
||||
MenuItem::command("Find Edges", MenuCommand::FilterStylize("find_edges".into())),
|
||||
MenuItem::command("Oil Paint", MenuCommand::FilterStylize("oil_paint".into())),
|
||||
MenuItem::command("Posterize", MenuCommand::FilterStylize("posterize".into())),
|
||||
MenuItem::command("Threshold", MenuCommand::FilterStylize("threshold".into())),
|
||||
MenuItem::separator(),
|
||||
// ── Color / Light ──
|
||||
MenuItem::disabled("Color / Light"),
|
||||
MenuItem::command("Levels", MenuCommand::FilterRender("levels".into())),
|
||||
MenuItem::command("Vibrance", MenuCommand::FilterRender("vibrance".into())),
|
||||
MenuItem::command("Exposure", MenuCommand::FilterRender("exposure".into())),
|
||||
MenuItem::command("Gamma Correction", MenuCommand::FilterRender("gamma".into())),
|
||||
MenuItem::command("Black & White", MenuCommand::FilterStylize("bw".into())),
|
||||
MenuItem::command("Gradient Map", MenuCommand::FilterRender("gradient_map".into())),
|
||||
MenuItem::command("Channel Mixer", MenuCommand::FilterRender("channel_mixer".into())),
|
||||
MenuItem::command("Selective Color", MenuCommand::FilterRender("selective_color".into())),
|
||||
MenuItem::separator(),
|
||||
// ── Fourier ──
|
||||
MenuItem::disabled("Fourier"),
|
||||
MenuItem::command("FFT Filter", MenuCommand::FilterRender("fft".into())),
|
||||
],
|
||||
},
|
||||
// ── View menu (Photopea-style) ──
|
||||
@@ -484,6 +654,7 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
"Ctrl+1",
|
||||
MenuCommand::ActualPixels,
|
||||
),
|
||||
MenuItem::command("Reset Pan", MenuCommand::ResetPan),
|
||||
MenuItem::new("Pattern Preview"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::submenu("Mode"),
|
||||
@@ -501,6 +672,8 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
MenuItem::new("Guides from Layer"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Clear Slices"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::command("Viewer Mode", MenuCommand::ViewerToggle),
|
||||
],
|
||||
},
|
||||
// ── Window menu (Photopea-style) ──
|
||||
@@ -552,6 +725,9 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
"Theme: ProLight (Nord)",
|
||||
MenuCommand::SetTheme(crate::theme::ThemePreset::ProLight),
|
||||
),
|
||||
MenuItem::separator(),
|
||||
MenuItem::command("Open Documents", MenuCommand::OpenDocuments),
|
||||
MenuItem::command("Reset Layout", MenuCommand::ResetLayout),
|
||||
],
|
||||
},
|
||||
// ── More menu (Photopea-style) ──
|
||||
@@ -581,6 +757,8 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
MenuItem::new("Swatches"),
|
||||
MenuItem::new("Tool Presets"),
|
||||
MenuItem::new("Vector Info"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::command("About", MenuCommand::About),
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -616,7 +794,7 @@ pub fn dropdown_overlay(
|
||||
let enabled = item.enabled;
|
||||
|
||||
// Check if this is a Window menu item that should show a checkmark
|
||||
let is_window_panel = menu_idx == 7 && item_idx <= 10;
|
||||
let is_window_panel = menu_idx == 8 && item_idx <= 10;
|
||||
let is_checked = if is_window_panel {
|
||||
let pane_type = match item_idx {
|
||||
0 => Some(PaneType::Layers),
|
||||
@@ -932,8 +1110,12 @@ mod tests {
|
||||
let expected = vec![
|
||||
"File/New...",
|
||||
"File/Open...",
|
||||
"File/Import...",
|
||||
"File/Import SVG...",
|
||||
"File/Import Brushes...",
|
||||
"File/Save",
|
||||
"File/Save as PSD",
|
||||
"File/Export...",
|
||||
"Edit/Undo / Redo",
|
||||
"Edit/Step Forward",
|
||||
"Edit/Step Backward",
|
||||
@@ -941,11 +1123,49 @@ mod tests {
|
||||
"Edit/Copy",
|
||||
"Edit/Paste",
|
||||
"Edit/Clear",
|
||||
"Edit/Paste Special",
|
||||
"Edit/Fill...",
|
||||
"Edit/Stroke...",
|
||||
"Edit/Free Transform",
|
||||
"Edit/Preferences...",
|
||||
"Tools/Eyedropper",
|
||||
"Tools/Pencil / Pen",
|
||||
"Tools/Art Brush",
|
||||
"Tools/Eraser",
|
||||
"Tools/Spray",
|
||||
"Tools/Flood Fill",
|
||||
"Tools/Magic Wand",
|
||||
"Tools/Rect Selection",
|
||||
"Tools/Lasso",
|
||||
"Tools/Polygon Select",
|
||||
"Tools/Move",
|
||||
"Tools/Crop",
|
||||
"Tools/Text",
|
||||
"Tools/Gradient",
|
||||
"Tools/Red-eye Removal",
|
||||
"Tools/Spot Removal Patch",
|
||||
"Tools/Smart Patch",
|
||||
"Tools/AI Object Removal",
|
||||
"Tools/Smart Select",
|
||||
"Tools/Vision Select",
|
||||
"Image/Canvas Size...",
|
||||
"Image/Image Size...",
|
||||
"Image/Rotate 90° CW",
|
||||
"Image/Rotate 90° CCW",
|
||||
"Image/Rotate 180°",
|
||||
"Image/Flip Horizontal",
|
||||
"Image/Flip Vertical",
|
||||
"Image/Crop",
|
||||
"Image/Invert / Negative",
|
||||
"Layer/New",
|
||||
"Layer/Delete",
|
||||
"Layer/Layer Style",
|
||||
"Layer/Align Left",
|
||||
"Layer/Align Center",
|
||||
"Layer/Align Right",
|
||||
"Layer/Align Top",
|
||||
"Layer/Align Middle",
|
||||
"Layer/Align Bottom",
|
||||
"Layer/Merge Down",
|
||||
"Layer/Flatten Image",
|
||||
"Select/All",
|
||||
@@ -956,6 +1176,8 @@ mod tests {
|
||||
"Select/Feather...",
|
||||
"Select/Border...",
|
||||
"Select/Smooth...",
|
||||
"Select/Erode Border",
|
||||
"Select/Fade Border",
|
||||
"Select/New Selection",
|
||||
"Select/Add to Selection",
|
||||
"Select/Subtract from Selection",
|
||||
@@ -963,10 +1185,58 @@ mod tests {
|
||||
"Select/Quick Mask Mode",
|
||||
"Select/Load Selection",
|
||||
"Select/Save Selection",
|
||||
"Filter/Box Blur",
|
||||
"Filter/Gaussian Blur",
|
||||
"Filter/Motion Blur",
|
||||
"Filter/Unsharp Mask",
|
||||
"Filter/Pinch / Punch",
|
||||
"Filter/Twirl",
|
||||
"Filter/Add Noise",
|
||||
"Filter/Median Filter",
|
||||
"Filter/Dehaze",
|
||||
"Filter/Noise Pattern",
|
||||
"Filter/Crystallize",
|
||||
"Filter/Mosaic",
|
||||
"Filter/Clouds",
|
||||
"Filter/Clouds Texture",
|
||||
"Filter/Grass",
|
||||
"Filter/Sand",
|
||||
"Filter/Dirt",
|
||||
"Filter/Water",
|
||||
"Filter/Rain",
|
||||
"Filter/Stone",
|
||||
"Filter/Bricks",
|
||||
"Filter/Oak",
|
||||
"Filter/Pine",
|
||||
"Filter/Dark",
|
||||
"Filter/Steel",
|
||||
"Filter/Gold",
|
||||
"Filter/Copper",
|
||||
"Filter/Linen",
|
||||
"Filter/Glass",
|
||||
"Filter/Sky Replacement",
|
||||
"Filter/Sharpen",
|
||||
"Filter/Smart Patch",
|
||||
"Filter/Emboss",
|
||||
"Filter/Find Edges",
|
||||
"Filter/Oil Paint",
|
||||
"Filter/Posterize",
|
||||
"Filter/Threshold",
|
||||
"Filter/Levels",
|
||||
"Filter/Vibrance",
|
||||
"Filter/Exposure",
|
||||
"Filter/Gamma Correction",
|
||||
"Filter/Black & White",
|
||||
"Filter/Gradient Map",
|
||||
"Filter/Channel Mixer",
|
||||
"Filter/Selective Color",
|
||||
"Filter/FFT Filter",
|
||||
"View/Zoom In",
|
||||
"View/Zoom Out",
|
||||
"View/Fit The Area",
|
||||
"View/Pixel to Pixel",
|
||||
"View/Reset Pan",
|
||||
"View/Viewer Mode",
|
||||
"Window/Layers",
|
||||
"Window/History",
|
||||
"Window/Brushes & Tips",
|
||||
@@ -984,6 +1254,9 @@ mod tests {
|
||||
"Window/Theme: Amoled (Dracula)",
|
||||
"Window/Theme: PhotoshopLight (Solarized)",
|
||||
"Window/Theme: ProLight (Nord)",
|
||||
"Window/Open Documents",
|
||||
"Window/Reset Layout",
|
||||
"More/About",
|
||||
];
|
||||
|
||||
assert_eq!(actual, expected);
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod properties;
|
||||
pub mod script_panel;
|
||||
pub mod status_bar;
|
||||
pub mod styles;
|
||||
pub mod svg_editor;
|
||||
pub mod text_editor;
|
||||
pub mod thumbnails;
|
||||
pub mod title_bar;
|
||||
|
||||
@@ -17,6 +17,10 @@ use iced::{Element, Length};
|
||||
|
||||
/// Human-readable label for a shape at a given index.
|
||||
fn shape_label(shapes: &[VectorShape], i: usize) -> String {
|
||||
let name = shapes[i].name();
|
||||
if !name.is_empty() {
|
||||
return name;
|
||||
}
|
||||
match &shapes[i] {
|
||||
VectorShape::Line { .. } => format!("Line #{}", i + 1),
|
||||
VectorShape::Rect { .. } => format!("Rect #{}", i + 1),
|
||||
@@ -132,7 +136,13 @@ pub fn view<'a>(
|
||||
}
|
||||
Tool::Select => selection_properties(selection_rect, colors),
|
||||
Tool::VectorSelect => {
|
||||
vector_select_properties(selected_vector_shape, vector_shapes, vector_angle, colors)
|
||||
vector_select_properties(
|
||||
selected_vector_shape,
|
||||
vector_shapes,
|
||||
vector_angle,
|
||||
active_layer_id,
|
||||
colors,
|
||||
)
|
||||
}
|
||||
Tool::VectorRect
|
||||
| Tool::VectorCircle
|
||||
@@ -251,7 +261,8 @@ fn vector_select_properties<'a>(
|
||||
selected: Option<usize>,
|
||||
shapes: &[VectorShape],
|
||||
vector_angle: f32,
|
||||
_colors: ThemeColors,
|
||||
active_layer_id: u64,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'a, Message> {
|
||||
match selected.and_then(|idx| shapes.get(idx).map(|s| (idx, s))) {
|
||||
Some((idx, shape)) => {
|
||||
@@ -283,10 +294,32 @@ fn vector_select_properties<'a>(
|
||||
VectorShape::FreePath { .. } => "Free Path".into(),
|
||||
};
|
||||
|
||||
// "Edit SVG" button — only for SvgShape variants (EGUI parity)
|
||||
let edit_svg_btn: Element<'a, Message> =
|
||||
if matches!(shape, VectorShape::SvgShape { .. }) {
|
||||
let c = colors;
|
||||
button(text("Edit SVG").size(BODY))
|
||||
.on_press(Message::SvgEditorOpen {
|
||||
layer_id: active_layer_id,
|
||||
shape_idx: idx,
|
||||
})
|
||||
.padding([4, 8])
|
||||
.style(move |_theme, _status| iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.accent)),
|
||||
text_color: iced::Color::WHITE,
|
||||
border: iced::Border::default().rounded(3),
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
} else {
|
||||
text("").into()
|
||||
};
|
||||
|
||||
column![
|
||||
text("Shape Properties").size(SECTION),
|
||||
row![text("Shape").size(BODY), text(label).size(BODY)].spacing(4),
|
||||
row![text("Type").size(BODY), text(shape_type).size(BODY)].spacing(4),
|
||||
edit_svg_btn,
|
||||
plain_slider(
|
||||
"Stroke",
|
||||
stroke,
|
||||
|
||||
@@ -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.
|
||||
//! Layout: [File Edit Image Layer Select Filter View Window More] [doc info] [search] [─ □ ×]
|
||||
//! **Purpose:** Combines menu bar and title bar into a single bar.
|
||||
//! Layout: [icon] [File Edit Image ... More] [search] [≡] [HCIE v0.1 — doc name] [─ □ ×]
|
||||
//!
|
||||
//! **Design reference:** Photopea image editor (93 screenshots analyzed).
|
||||
//! - Menu items: 12px font, tight padding
|
||||
//! - Active menu: light background with dark text
|
||||
//! - Window controls: standard minimize/maximize/close
|
||||
//! **Design reference:** EGUI title bar + Photopea image editor.
|
||||
//! - Search box for tool/action search (EGUI parity)
|
||||
//! - Panel list toggle button (EGUI parity)
|
||||
//! - Double-click drag area to toggle maximize (EGUI parity)
|
||||
//! - App icon + version string centered
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::panels::typography;
|
||||
use crate::theme::ThemeColors;
|
||||
use iced::widget::{button, container, mouse_area, row, text};
|
||||
use iced::widget::{button, container, mouse_area, row, text, text_input};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Photopea-style menu labels matching the reference screenshots.
|
||||
pub(crate) const MENU_LABELS: &[&str] = &[
|
||||
"File", "Edit", "Image", "Layer", "Select", "Filter", "View", "Window", "More",
|
||||
"File", "Edit", "Tools", "Image", "Layer", "Select", "Filter", "View", "Window", "More",
|
||||
];
|
||||
|
||||
/// Horizontal title-menu metrics shared by button layout and dropdown placement.
|
||||
///
|
||||
/// **Purpose:** Keeps menu buttons and dropdown anchors in one deterministic layout model.
|
||||
/// **Logic & Workflow:** Widths include the measured 12px medium label advance plus the
|
||||
/// title button's 20px horizontal padding. [`menu_anchor_x`] sums these exact rendered widths.
|
||||
/// **Side Effects / Dependencies:** Values depend on the title bar font and padding below.
|
||||
pub(crate) const MENU_BUTTON_WIDTHS: &[f32] =
|
||||
&[46.0, 46.0, 56.0, 60.0, 64.0, 55.0, 51.0, 71.0, 54.0];
|
||||
&[46.0, 46.0, 56.0, 56.0, 60.0, 64.0, 55.0, 51.0, 71.0, 54.0];
|
||||
pub(crate) const MENU_LEFT_INSET: f32 = 4.0;
|
||||
/// Unified menu/title bar height shared with dropdown placement.
|
||||
pub(crate) const MENU_BAR_HEIGHT: f32 = 28.0;
|
||||
|
||||
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Returns the clamped left edge for a menu dropdown.
|
||||
///
|
||||
/// **Arguments:** `menu_index` selects a title button, `viewport_width` is the available width,
|
||||
/// and `dropdown_width` is the popup width. **Returns:** A viewport-safe X coordinate.
|
||||
/// **Logic & Workflow:** Sums the same explicit widths assigned to preceding buttons, then clamps
|
||||
/// the result so the dropdown remains visible on narrow windows. **Side Effects:** None.
|
||||
pub(crate) fn menu_anchor_x(menu_index: usize, viewport_width: f32, dropdown_width: f32) -> f32 {
|
||||
let natural = MENU_LEFT_INSET
|
||||
+ MENU_BUTTON_WIDTHS
|
||||
@@ -46,10 +39,11 @@ pub(crate) fn menu_anchor_x(menu_index: usize, viewport_width: f32, dropdown_wid
|
||||
natural.min((viewport_width - dropdown_width).max(0.0))
|
||||
}
|
||||
|
||||
/// Build the Photopea-style unified title/menu bar element.
|
||||
/// Build the unified title/menu bar.
|
||||
///
|
||||
/// Layout: [File Edit Image ... More] [doc info] [─ □ ×]
|
||||
/// The left portion (before window controls) is draggable.
|
||||
/// Layout: [icon] [File Edit ... More] [search] [≡] [HCIE v0.1 — doc] [─ □ ×]
|
||||
/// The area between the rightmost menu button and the window controls is draggable
|
||||
/// and double-clicking it toggles maximize.
|
||||
pub fn view<'a>(
|
||||
doc_name: &'a str,
|
||||
modified: bool,
|
||||
@@ -58,8 +52,27 @@ pub fn view<'a>(
|
||||
_canvas_h: u32,
|
||||
colors: ThemeColors,
|
||||
active_menu: Option<usize>,
|
||||
title_search: &'a str,
|
||||
show_panel_list: bool,
|
||||
) -> Element<'a, Message> {
|
||||
// ── Menu items — readable 12px medium labels with balanced padding ──
|
||||
// ── App icon ──
|
||||
let icon = text("H")
|
||||
.size(13)
|
||||
.font(iced::Font {
|
||||
weight: iced::font::Weight::Bold,
|
||||
..iced::Font::default()
|
||||
})
|
||||
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(colors.accent),
|
||||
});
|
||||
|
||||
let icon_container = container(icon)
|
||||
.width(28)
|
||||
.height(MENU_BAR_HEIGHT)
|
||||
.center_y(Length::Shrink)
|
||||
.center_x(Length::Shrink);
|
||||
|
||||
// ── Menu items ──
|
||||
let mut menu_items = row![].spacing(0);
|
||||
for (idx, &label) in MENU_LABELS.iter().enumerate() {
|
||||
let is_open = active_menu == Some(idx);
|
||||
@@ -78,7 +91,6 @@ pub fn view<'a>(
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
if is_open {
|
||||
// Active menu: light background with dark text (Photopea style)
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.menu_bg)),
|
||||
text_color: c.menu_text,
|
||||
@@ -108,18 +120,56 @@ pub fn view<'a>(
|
||||
menu_items = menu_items.push(btn);
|
||||
}
|
||||
|
||||
// ── Document info — Photopea style: "New Project.psd *" ──
|
||||
let doc_info = text(if modified {
|
||||
format!("{} *", doc_name)
|
||||
// ── Search box ──
|
||||
let search_input = text_input("Search…", title_search)
|
||||
.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 {
|
||||
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)
|
||||
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(colors.text_secondary),
|
||||
});
|
||||
|
||||
// ── Window controls — Photopea style: ─ □ × ──
|
||||
// ── Window controls ──
|
||||
let c = colors;
|
||||
|
||||
let minimize_btn = button(text("─").size(12))
|
||||
@@ -190,30 +240,35 @@ pub fn view<'a>(
|
||||
|
||||
let window_controls = row![minimize_btn, maximize_btn, close_btn].spacing(0);
|
||||
|
||||
// Only the empty title region is draggable. Interactive menus are siblings,
|
||||
// preventing their presses from also initiating a native window drag.
|
||||
let draggable_left = mouse_area(
|
||||
container(row![
|
||||
iced::widget::Space::with_width(Length::Fill),
|
||||
doc_info
|
||||
])
|
||||
// ── Draggable center area ──
|
||||
// Fills remaining space between the left cluster and window controls.
|
||||
let draggable_center = mouse_area(
|
||||
container(doc_info)
|
||||
.width(Length::Fill)
|
||||
.height(MENU_BAR_HEIGHT)
|
||||
.align_y(iced::Alignment::Center)
|
||||
.padding([0, 4]),
|
||||
.center_y(Length::Shrink),
|
||||
)
|
||||
.on_press(Message::WindowDrag)
|
||||
.interaction(iced::mouse::Interaction::Grab);
|
||||
|
||||
// ── Full bar ──
|
||||
let bar = row![
|
||||
// ── Left cluster: icon, menus, search, panel toggle ──
|
||||
let left_cluster = row![
|
||||
icon_container,
|
||||
container(menu_items).padding(iced::Padding {
|
||||
left: MENU_LEFT_INSET,
|
||||
left: 0.0,
|
||||
right: 0.0,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
}),
|
||||
draggable_left,
|
||||
search_input,
|
||||
panel_list_btn,
|
||||
]
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
// ── Full bar ──
|
||||
let bar = row![
|
||||
left_cluster,
|
||||
draggable_center,
|
||||
window_controls,
|
||||
]
|
||||
.align_y(iced::Alignment::Center);
|
||||
@@ -233,13 +288,14 @@ pub fn view<'a>(
|
||||
mod tests {
|
||||
use super::menu_anchor_x;
|
||||
|
||||
/// Verifies natural anchors and narrow-viewport clamping use rendered button metrics.
|
||||
#[test]
|
||||
fn menu_anchors_are_deterministic_across_viewport_widths() {
|
||||
assert_eq!(menu_anchor_x(0, 1280.0, 260.0), 4.0);
|
||||
// Index 3 = Image: File(46) + Edit(46) + Tools(56) = 148 + 4 = 152
|
||||
assert_eq!(menu_anchor_x(3, 1280.0, 260.0), 152.0);
|
||||
assert_eq!(menu_anchor_x(8, 1280.0, 260.0), 453.0);
|
||||
assert_eq!(menu_anchor_x(8, 500.0, 260.0), 240.0);
|
||||
assert_eq!(menu_anchor_x(8, 180.0, 260.0), 0.0);
|
||||
// Index 9 = More: sum 9 prior widths + 4 = (46+46+56+56+60+64+55+51+71)=505+4=509
|
||||
assert_eq!(menu_anchor_x(9, 1280.0, 260.0), 509.0);
|
||||
assert_eq!(menu_anchor_x(9, 500.0, 260.0), 240.0);
|
||||
assert_eq!(menu_anchor_x(9, 180.0, 260.0), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
engine.add_vector_shape(hcie_engine_api::VectorShape::FreePath {
|
||||
name: String::new(),
|
||||
pts: path_points,
|
||||
closed: *closed,
|
||||
stroke: *stroke_width,
|
||||
|
||||
@@ -40,6 +40,9 @@ pub struct AppSettings {
|
||||
/// Selected application color preset.
|
||||
#[serde(default)]
|
||||
pub theme_preset: crate::theme::ThemePreset,
|
||||
/// Last directory used by the image viewer (FastStone-like).
|
||||
#[serde(default)]
|
||||
pub viewer_last_dir: Option<String>,
|
||||
}
|
||||
|
||||
/// Tool-specific settings that persist between sessions.
|
||||
@@ -168,6 +171,7 @@ impl Default for AppSettings {
|
||||
window: WindowSettings::default(),
|
||||
dock_profiles: Vec::new(),
|
||||
theme_preset: crate::theme::ThemePreset::default(),
|
||||
viewer_last_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,6 +347,7 @@ mod tests {
|
||||
/// Creates a stable rectangle fixture for transform invariant tests.
|
||||
fn rect() -> VectorShape {
|
||||
VectorShape::Rect {
|
||||
name: String::new(),
|
||||
x1: 10.0,
|
||||
y1: 20.0,
|
||||
x2: 110.0,
|
||||
|
||||
@@ -132,7 +132,7 @@ impl ThemeColors {
|
||||
accent_hover: Color::from_rgb(0.940, 0.640, 0.260),
|
||||
accent_green: Color::from_rgb(0.298, 0.686, 0.314),
|
||||
danger: Color::from_rgb(0.780, 0.220, 0.267),
|
||||
border_low: Color::from_rgb(0.110, 0.106, 0.086),
|
||||
border_low: Color::from_rgb(0.165, 0.169, 0.141),
|
||||
border_high: Color::from_rgb(0.243, 0.251, 0.212),
|
||||
text_primary: Color::from_rgb(0.973, 0.973, 0.941),
|
||||
text_secondary: Color::from_rgb(0.580, 0.588, 0.533),
|
||||
@@ -153,7 +153,7 @@ impl ThemeColors {
|
||||
accent_hover: Color::from_rgb(1.000, 0.530, 0.820),
|
||||
accent_green: Color::from_rgb(0.298, 0.686, 0.314),
|
||||
danger: Color::from_rgb(0.753, 0.220, 0.294),
|
||||
border_low: Color::from_rgb(0.118, 0.110, 0.165),
|
||||
border_low: Color::from_rgb(0.173, 0.180, 0.227),
|
||||
border_high: Color::from_rgb(0.267, 0.275, 0.345),
|
||||
text_primary: Color::from_rgb(0.973, 0.973, 0.949),
|
||||
text_secondary: Color::from_rgb(0.588, 0.596, 0.667),
|
||||
|
||||
@@ -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();
|
||||
|
||||
Some(VectorShape::FreePath {
|
||||
name: String::new(),
|
||||
pts,
|
||||
closed: is_closed,
|
||||
stroke: stroke_width,
|
||||
|
||||
@@ -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;
|
||||
if w > 0.0 && h > 0.0 {
|
||||
shapes.push(VectorShape::Rect {
|
||||
name: String::new(),
|
||||
x1: x, y1: y,
|
||||
x2: x + w, y2: y + h,
|
||||
stroke: stroke_width,
|
||||
@@ -448,6 +449,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec<VectorS
|
||||
let r = attrs.get("r").map(|v| parse_svg_float(v, 0.0)).unwrap_or(0.0) * scale_x;
|
||||
if r > 0.0 {
|
||||
shapes.push(VectorShape::Circle {
|
||||
name: String::new(),
|
||||
x1: cx - r, y1: cy - r,
|
||||
x2: cx + r, y2: cy + r,
|
||||
stroke: stroke_width,
|
||||
@@ -466,6 +468,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec<VectorS
|
||||
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 {
|
||||
shapes.push(VectorShape::Circle {
|
||||
name: String::new(),
|
||||
x1: cx - rx, y1: cy - ry,
|
||||
x2: cx + rx, y2: cy + ry,
|
||||
stroke: stroke_width,
|
||||
@@ -483,6 +486,7 @@ fn parse_svg_shapes(svg_bytes: &[u8], scale_x: f32, scale_y: f32) -> Vec<VectorS
|
||||
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;
|
||||
shapes.push(VectorShape::Line {
|
||||
name: String::new(),
|
||||
x1, y1, x2, y2,
|
||||
stroke: stroke_width,
|
||||
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 {
|
||||
shapes.push(VectorShape::FreePath {
|
||||
name: String::new(),
|
||||
pts,
|
||||
closed: tag == "polygon",
|
||||
stroke: stroke_width,
|
||||
|
||||
@@ -300,6 +300,8 @@ fn default_hardness() -> f32 {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum VectorShape {
|
||||
Line {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
x2: f32,
|
||||
@@ -318,6 +320,8 @@ pub enum VectorShape {
|
||||
hardness: f32,
|
||||
},
|
||||
Rect {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
x2: f32,
|
||||
@@ -336,6 +340,8 @@ pub enum VectorShape {
|
||||
hardness: f32,
|
||||
},
|
||||
Circle {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
x2: f32,
|
||||
@@ -354,6 +360,8 @@ pub enum VectorShape {
|
||||
},
|
||||
#[serde(skip_serializing)]
|
||||
Arrow {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
x2: f32,
|
||||
@@ -374,6 +382,8 @@ pub enum VectorShape {
|
||||
},
|
||||
#[serde(skip_serializing)]
|
||||
Star {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
x2: f32,
|
||||
@@ -395,6 +405,8 @@ pub enum VectorShape {
|
||||
inner_radius: f32,
|
||||
},
|
||||
Polygon {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
x2: f32,
|
||||
@@ -415,6 +427,8 @@ pub enum VectorShape {
|
||||
},
|
||||
#[serde(skip_serializing)]
|
||||
Rhombus {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
x2: f32,
|
||||
@@ -433,6 +447,8 @@ pub enum VectorShape {
|
||||
},
|
||||
#[serde(skip_serializing)]
|
||||
Cylinder {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
x2: f32,
|
||||
@@ -451,6 +467,8 @@ pub enum VectorShape {
|
||||
},
|
||||
#[serde(skip_serializing)]
|
||||
Heart {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
x2: f32,
|
||||
@@ -469,6 +487,8 @@ pub enum VectorShape {
|
||||
},
|
||||
#[serde(skip_serializing)]
|
||||
Bubble {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
x2: f32,
|
||||
@@ -487,6 +507,8 @@ pub enum VectorShape {
|
||||
},
|
||||
#[serde(skip_serializing)]
|
||||
Gear {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
x2: f32,
|
||||
@@ -505,6 +527,8 @@ pub enum VectorShape {
|
||||
},
|
||||
#[serde(skip_serializing)]
|
||||
Cross {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
x2: f32,
|
||||
@@ -523,6 +547,8 @@ pub enum VectorShape {
|
||||
},
|
||||
#[serde(skip_serializing)]
|
||||
Crescent {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
x2: f32,
|
||||
@@ -541,6 +567,8 @@ pub enum VectorShape {
|
||||
},
|
||||
#[serde(skip_serializing)]
|
||||
Bolt {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
x2: f32,
|
||||
@@ -559,6 +587,8 @@ pub enum VectorShape {
|
||||
},
|
||||
#[serde(skip_serializing)]
|
||||
Arrow4 {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
x2: f32,
|
||||
@@ -576,6 +606,8 @@ pub enum VectorShape {
|
||||
hardness: f32,
|
||||
},
|
||||
SvgShape {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
x2: f32,
|
||||
@@ -595,6 +627,8 @@ pub enum VectorShape {
|
||||
hardness: f32,
|
||||
},
|
||||
FreePath {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
pts: Vec<[f32; 2]>,
|
||||
closed: bool,
|
||||
stroke: f32,
|
||||
@@ -2410,6 +2444,50 @@ impl VectorShape {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> String {
|
||||
match self {
|
||||
VectorShape::Line { name, .. }
|
||||
| VectorShape::Rect { name, .. }
|
||||
| VectorShape::Circle { name, .. }
|
||||
| VectorShape::Arrow { name, .. }
|
||||
| VectorShape::Star { name, .. }
|
||||
| VectorShape::Polygon { name, .. }
|
||||
| VectorShape::Rhombus { name, .. }
|
||||
| VectorShape::Cylinder { name, .. }
|
||||
| VectorShape::Heart { name, .. }
|
||||
| VectorShape::Bubble { name, .. }
|
||||
| VectorShape::Gear { name, .. }
|
||||
| VectorShape::Cross { name, .. }
|
||||
| VectorShape::Crescent { name, .. }
|
||||
| VectorShape::Bolt { name, .. }
|
||||
| VectorShape::Arrow4 { name, .. }
|
||||
| VectorShape::SvgShape { name, .. }
|
||||
| VectorShape::FreePath { name, .. } => name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_name(&mut self, new_name: String) {
|
||||
match self {
|
||||
VectorShape::Line { name, .. }
|
||||
| VectorShape::Rect { name, .. }
|
||||
| VectorShape::Circle { name, .. }
|
||||
| VectorShape::Arrow { name, .. }
|
||||
| VectorShape::Star { name, .. }
|
||||
| VectorShape::Polygon { name, .. }
|
||||
| VectorShape::Rhombus { name, .. }
|
||||
| VectorShape::Cylinder { name, .. }
|
||||
| VectorShape::Heart { name, .. }
|
||||
| VectorShape::Bubble { name, .. }
|
||||
| VectorShape::Gear { name, .. }
|
||||
| VectorShape::Cross { name, .. }
|
||||
| VectorShape::Crescent { name, .. }
|
||||
| VectorShape::Bolt { name, .. }
|
||||
| VectorShape::Arrow4 { name, .. }
|
||||
| VectorShape::SvgShape { name, .. }
|
||||
| VectorShape::FreePath { name, .. } => *name = new_name,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn angle(&self) -> f32 {
|
||||
match self {
|
||||
VectorShape::Line { angle, .. }
|
||||
|
||||
@@ -410,6 +410,7 @@ impl App {
|
||||
fn make_shape(&self, x1: f32, y1: f32, x2: f32, y2: f32) -> VectorShape {
|
||||
match self.shape_kind {
|
||||
ShapeKind::Line => VectorShape::Line {
|
||||
name: String::new(),
|
||||
x1, y1, x2, y2,
|
||||
stroke: self.stroke,
|
||||
color: self.color,
|
||||
@@ -420,6 +421,7 @@ impl App {
|
||||
hardness: self.hardness,
|
||||
},
|
||||
ShapeKind::Rect => VectorShape::Rect {
|
||||
name: String::new(),
|
||||
x1, y1, x2, y2,
|
||||
stroke: self.stroke,
|
||||
color: self.color,
|
||||
@@ -431,6 +433,7 @@ impl App {
|
||||
hardness: self.hardness,
|
||||
},
|
||||
ShapeKind::Circle => VectorShape::Circle {
|
||||
name: String::new(),
|
||||
x1, y1, x2, y2,
|
||||
stroke: self.stroke,
|
||||
color: self.color,
|
||||
@@ -441,6 +444,7 @@ impl App {
|
||||
hardness: self.hardness,
|
||||
},
|
||||
ShapeKind::Arrow => VectorShape::Arrow {
|
||||
name: String::new(),
|
||||
x1, y1, x2, y2,
|
||||
stroke: self.stroke,
|
||||
color: self.color,
|
||||
@@ -452,6 +456,7 @@ impl App {
|
||||
thick: self.arrow_thick,
|
||||
},
|
||||
ShapeKind::Star => VectorShape::Star {
|
||||
name: String::new(),
|
||||
x1, y1, x2, y2,
|
||||
stroke: self.stroke,
|
||||
color: self.color,
|
||||
@@ -464,6 +469,7 @@ impl App {
|
||||
inner_radius: self.star_inner_radius,
|
||||
},
|
||||
ShapeKind::Polygon => VectorShape::Polygon {
|
||||
name: String::new(),
|
||||
x1, y1, x2, y2,
|
||||
stroke: self.stroke,
|
||||
color: self.color,
|
||||
@@ -475,6 +481,7 @@ impl App {
|
||||
hardness: self.hardness,
|
||||
},
|
||||
ShapeKind::Rhombus => VectorShape::Rhombus {
|
||||
name: String::new(),
|
||||
x1, y1, x2, y2,
|
||||
stroke: self.stroke,
|
||||
color: self.color,
|
||||
@@ -485,6 +492,7 @@ impl App {
|
||||
hardness: self.hardness,
|
||||
},
|
||||
ShapeKind::Cylinder => VectorShape::Cylinder {
|
||||
name: String::new(),
|
||||
x1, y1, x2, y2,
|
||||
stroke: self.stroke,
|
||||
color: self.color,
|
||||
@@ -495,6 +503,7 @@ impl App {
|
||||
hardness: self.hardness,
|
||||
},
|
||||
ShapeKind::Heart => VectorShape::Heart {
|
||||
name: String::new(),
|
||||
x1, y1, x2, y2,
|
||||
stroke: self.stroke,
|
||||
color: self.color,
|
||||
@@ -505,6 +514,7 @@ impl App {
|
||||
hardness: self.hardness,
|
||||
},
|
||||
ShapeKind::Bubble => VectorShape::Bubble {
|
||||
name: String::new(),
|
||||
x1, y1, x2, y2,
|
||||
stroke: self.stroke,
|
||||
color: self.color,
|
||||
@@ -515,6 +525,7 @@ impl App {
|
||||
hardness: self.hardness,
|
||||
},
|
||||
ShapeKind::Gear => VectorShape::Gear {
|
||||
name: String::new(),
|
||||
x1, y1, x2, y2,
|
||||
stroke: self.stroke,
|
||||
color: self.color,
|
||||
@@ -525,6 +536,7 @@ impl App {
|
||||
hardness: self.hardness,
|
||||
},
|
||||
ShapeKind::Cross => VectorShape::Cross {
|
||||
name: String::new(),
|
||||
x1, y1, x2, y2,
|
||||
stroke: self.stroke,
|
||||
color: self.color,
|
||||
@@ -535,6 +547,7 @@ impl App {
|
||||
hardness: self.hardness,
|
||||
},
|
||||
ShapeKind::Crescent => VectorShape::Crescent {
|
||||
name: String::new(),
|
||||
x1, y1, x2, y2,
|
||||
stroke: self.stroke,
|
||||
color: self.color,
|
||||
@@ -545,6 +558,7 @@ impl App {
|
||||
hardness: self.hardness,
|
||||
},
|
||||
ShapeKind::Bolt => VectorShape::Bolt {
|
||||
name: String::new(),
|
||||
x1, y1, x2, y2,
|
||||
stroke: self.stroke,
|
||||
color: self.color,
|
||||
@@ -555,6 +569,7 @@ impl App {
|
||||
hardness: self.hardness,
|
||||
},
|
||||
ShapeKind::Arrow4 => VectorShape::Arrow4 {
|
||||
name: String::new(),
|
||||
x1, y1, x2, y2,
|
||||
stroke: self.stroke,
|
||||
color: self.color,
|
||||
@@ -572,6 +587,7 @@ impl App {
|
||||
[(x1 + x2) / 2.0, y2 + 50.0],
|
||||
];
|
||||
VectorShape::FreePath {
|
||||
name: String::new(),
|
||||
pts,
|
||||
closed: true,
|
||||
stroke: self.stroke,
|
||||
|
||||
+55
-4
@@ -751,8 +751,9 @@ fn render_shape(layer: &mut Layer, shape: &VectorShape) {
|
||||
draw_line(layer, pts[i].0, pts[i].1, pts[j].0, pts[j].1, px_color, *stroke, None);
|
||||
}
|
||||
}
|
||||
SvgShape { svg, x1, y1, x2, y2, fill, color, fill_color, stroke, opacity, hardness, .. } => {
|
||||
let points_list = svg_render::svg_to_points(svg, *x1, *y1, *x2, *y2).unwrap_or_default();
|
||||
SvgShape { svg, x1, y1, x2, y2, fill, color, fill_color, stroke, angle, opacity, hardness, .. } => {
|
||||
let mut points_list = svg_render::svg_to_points(svg, *x1, *y1, *x2, *y2).unwrap_or_default();
|
||||
rotate_svg_points(&mut points_list, *x1, *y1, *x2, *y2, *angle);
|
||||
let alpha = (color[3] as f32 * opacity).round() as u8;
|
||||
let px_color = [color[0], color[1], color[2], alpha];
|
||||
let prev = ACTIVE_HARDNESS.with(|h| h.get());
|
||||
@@ -788,6 +789,35 @@ fn render_shape(layer: &mut Layer, shape: &VectorShape) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate tessellated SVG polygons around the center of their shape bounds.
|
||||
///
|
||||
/// **Purpose:** Applies the `SvgShape::angle` field to SVG geometry before fill and stroke
|
||||
/// rasterization. **Logic & Workflow:** Computes the same bounds center used by vector transform
|
||||
/// handles, then rotates every tessellated point by the stored radian angle. A zero angle exits
|
||||
/// without touching the point buffers. **Arguments:** `polygons` contains canvas-space SVG paths;
|
||||
/// `x1`, `y1`, `x2`, and `y2` define the shape bounds; `angle` is expressed in radians.
|
||||
/// **Returns:** Nothing. **Side Effects / Dependencies:** Mutates all points in `polygons` in place
|
||||
/// and relies on the shared `rotate_point` geometry helper.
|
||||
fn rotate_svg_points(
|
||||
polygons: &mut [Vec<(f32, f32)>],
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
x2: f32,
|
||||
y2: f32,
|
||||
angle: f32,
|
||||
) {
|
||||
if angle.abs() <= f32::EPSILON {
|
||||
return;
|
||||
}
|
||||
let cx = (x1 + x2) * 0.5;
|
||||
let cy = (y1 + y2) * 0.5;
|
||||
for polygon in polygons {
|
||||
for point in polygon {
|
||||
*point = rotate_point(point.0, point.1, cx, cy, angle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_rect(x1: f32, y1: f32, x2: f32, y2: f32) -> (f32, f32, f32, f32) {
|
||||
(x1.min(x2), y1.min(y2), x1.max(x2), y1.max(y2))
|
||||
}
|
||||
@@ -1126,8 +1156,29 @@ mod tests {
|
||||
assert!((rx - 100.0).abs() < 1e-4);
|
||||
assert!((ry - 150.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
/// Verify SVG tessellation applies its stored angle around the bounds center.
|
||||
///
|
||||
/// **Purpose:** Prevents SVG-backed shapes from remaining visually fixed at zero degrees.
|
||||
/// **Logic & Workflow:** Rotates two asymmetric points by 90 degrees in a 100x100 bounds and
|
||||
/// compares their resulting canvas coordinates. **Arguments/Returns:** Standard unit test with
|
||||
/// no inputs or return value. **Side Effects / Dependencies:** None.
|
||||
#[test]
|
||||
fn svg_points_follow_shape_angle() {
|
||||
let mut polygons = vec![vec![(100.0, 50.0), (50.0, 25.0)]];
|
||||
rotate_svg_points(
|
||||
&mut polygons,
|
||||
0.0,
|
||||
0.0,
|
||||
100.0,
|
||||
100.0,
|
||||
std::f32::consts::FRAC_PI_2,
|
||||
);
|
||||
assert!((polygons[0][0].0 - 50.0).abs() < 1e-4);
|
||||
assert!((polygons[0][0].1 - 100.0).abs() < 1e-4);
|
||||
assert!((polygons[0][1].0 - 75.0).abs() < 1e-4);
|
||||
assert!((polygons[0][1].1 - 50.0).abs() < 1e-4);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -397,6 +397,7 @@ pub fn boolean_shapes(op: BooleanOp, a: &VectorShape, b: &VectorShape) -> Option
|
||||
};
|
||||
|
||||
Some(VectorShape::FreePath {
|
||||
name: String::new(),
|
||||
pts: result_pts,
|
||||
closed: true,
|
||||
stroke,
|
||||
|
||||
+19
-19
@@ -2,7 +2,7 @@
|
||||
SATIR SAYISI RAPORU
|
||||
Proje: HCIE-Rust v4
|
||||
Konum: /mnt/extra/00_PROJECTS/hcie-rust-v3.05
|
||||
Tarih: 2026-07-11 20:24:42
|
||||
Tarih: 2026-07-19 20:01:21
|
||||
=========================================
|
||||
|
||||
-----------------------------------------
|
||||
@@ -10,31 +10,31 @@ Tarih: 2026-07-11 20:24:42
|
||||
-----------------------------------------
|
||||
hcie-ai 2402 satır
|
||||
hcie-blend 530 satır
|
||||
hcie-brush-engine 3753 satır
|
||||
hcie-brush-engine 4845 satır
|
||||
hcie-color 85 satır
|
||||
hcie-composite 985 satır
|
||||
hcie-composite 1456 satır
|
||||
hcie-document 666 satır
|
||||
hcie-draw 517 satır
|
||||
hcie-egui-app 36320 satır
|
||||
hcie-engine-api 5766 satır
|
||||
hcie-draw 619 satır
|
||||
hcie-egui-app 37419 satır
|
||||
hcie-engine-api 8360 satır
|
||||
hcie-engine-api-orig 3874 satır
|
||||
hcie-filter 3238 satır
|
||||
hcie-fx 4077 satır
|
||||
hcie-fx 5535 satır
|
||||
hcie-history 100 satır
|
||||
hcie-iced-app 6308 satır
|
||||
hcie-io 11382 satır
|
||||
hcie-kra 1392 satır
|
||||
hcie-iced-app 36049 satır
|
||||
hcie-io 12121 satır
|
||||
hcie-kra 1393 satır
|
||||
hcie-native 153 satır
|
||||
hcie-protocol 2872 satır
|
||||
hcie-protocol 3678 satır
|
||||
hcie-psd 5120 satır
|
||||
hcie-psd-saver 1277 satır
|
||||
hcie-selection 284 satır
|
||||
hcie-selection 392 satır
|
||||
hcie-text 1010 satır
|
||||
hcie-tile 361 satır
|
||||
hcie-vector 2298 satır
|
||||
hcie-vector 2853 satır
|
||||
hcie-vision 3131 satır
|
||||
|
||||
TOPLAM RUST: 97901 satır
|
||||
TOPLAM RUST: 136667 satır
|
||||
|
||||
-----------------------------------------
|
||||
C++ / HEADER (.cpp, .h)
|
||||
@@ -102,14 +102,14 @@ Tarih: 2026-07-11 20:24:42
|
||||
|
||||
MARKDOWN (.md)
|
||||
--------------------------------
|
||||
(kök dizin) 10994 satır
|
||||
(kök dizin) 17154 satır
|
||||
|
||||
TOPLAM MARKDOWN: 10994 satır
|
||||
TOPLAM MARKDOWN: 17154 satır
|
||||
|
||||
=========================================
|
||||
KOD SATIRLARI TOPLAMI (GRAND TOTAL)
|
||||
=========================================
|
||||
Rust: 97901 satır
|
||||
Rust: 136667 satır
|
||||
C++ / Header: 5031 satır
|
||||
JavaScript: 0 satır
|
||||
Svelte: 0 satır
|
||||
@@ -119,9 +119,9 @@ Tarih: 2026-07-11 20:24:42
|
||||
CSS: 0 satır
|
||||
Shell Script: 1445 satır
|
||||
-----------------------------------------
|
||||
KOD TOPLAMI: 106644 satır
|
||||
KOD TOPLAMI: 145410 satır
|
||||
|
||||
RUST + C++/H TOPLAMI: 102932 satır
|
||||
RUST + C++/H TOPLAMI: 141698 satır
|
||||
|
||||
(JSON ve Markdown dosyaları yukarıda ayrı olarak gösterilmiştir)
|
||||
=========================================
|
||||
|
||||
Reference in New Issue
Block a user