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