Refactor styles and implement new canvas viewport
- Updated styles in `styles.rs` to include new pane and canvas backgrounds, while marking unused functions with `#[allow(dead_code)]`. - Modified `text_editor.rs` to suppress warnings for unused function parameters. - Enhanced `title_bar.rs` to create a unified title/menu bar with improved hover states and draggable functionality. - Simplified `sidebar.rs` to always use a single-column layout and improved tool button styling with hover feedback. - Added a new `viewport.rs` file to implement a custom canvas viewport widget for zoom and pan rendering, replacing the previous layout-based approach. - Updated theme management in `theme.rs` to suppress warnings for unused methods. - Adjusted line count report to reflect recent changes in codebase.
This commit is contained in:
@@ -1,226 +1,217 @@
|
||||
# Iced GUI — Photoshop-Style UI Overhaul Plan
|
||||
# Iced GUI — Remaining Tasks & Reprioritized Plan
|
||||
|
||||
## Goal
|
||||
Transform the iced GUI from a generic dark theme to a professional Photoshop-like appearance with proper toolbox, menus, panels, and window management.
|
||||
## Current Status
|
||||
|
||||
The iced GUI has ~5000 lines across 33 source files. It compiles successfully (16 warnings, 0 errors). Major features are working: dock layout, toolbox with SVG icons, menu system, canvas with checkerboard, dialogs, keyboard shortcuts, brush params, selection, vector shapes, layer management, and theme system.
|
||||
|
||||
---
|
||||
|
||||
## 1. Custom Window Frame (No OS Title Bar)
|
||||
## Completed (reference — do not re-do)
|
||||
|
||||
**Files:** `main.rs`, `app.rs`
|
||||
|
||||
### Changes:
|
||||
- Set `decorations: false` in iced window settings to hide OS title bar
|
||||
- Build custom title bar with: app icon, "HCIE" text, menu bar, window controls (min/max/close)
|
||||
- Add `Message::WindowDrag`, `Message::WindowMinimize`, `Message::WindowMaximize`, `Message::WindowClose`
|
||||
- Use `iced::window::drag(id)` for title bar drag-to-move
|
||||
- Use `iced::window::minimize(id, true)` for minimize
|
||||
- Use `iced::window::toggle_maximize(id)` for maximize
|
||||
- Title bar: 24px height, `#2a2a2a` background, subtle bottom border
|
||||
- Window controls: minimize/maximize/close buttons on right side (like Photoshop)
|
||||
|
||||
### Window Setup:
|
||||
```rust
|
||||
iced::application("HCIE", HcieIcedApp::update, HcieIcedApp::view)
|
||||
.window_settings(window::Settings {
|
||||
decorations: false,
|
||||
..Default::default()
|
||||
})
|
||||
```
|
||||
| # | Feature | Status | Files |
|
||||
|---|---------|--------|-------|
|
||||
| 1 | Dock PaneGrid with centered canvas | DONE | `dock/state.rs`, `dock/view.rs` |
|
||||
| 2 | Brush params wired (size/opacity/hardness/style) | DONE | `app.rs` |
|
||||
| 3 | Adjustment dialogs (Brightness/Contrast, HSL) | DONE | `dialogs/adjustments.rs` |
|
||||
| 4 | Selection tool (rectangular drag) | DONE | `app.rs` |
|
||||
| 5 | Vector shape drawing (Rect/Circle/Line) | DONE | `app.rs` |
|
||||
| 6 | Keyboard shortcuts (Ctrl+Z/Y/S/N/O/C/V, Esc) | DONE | `app.rs` |
|
||||
| 7 | CLI file open (`load_path`) | DONE | `main.rs`, `app.rs` |
|
||||
| 8 | Filter parameters (FilterParamChanged) | DONE | `app.rs` |
|
||||
| 9 | Menu system (9 menus, dropdown items) | DONE | `panels/menus.rs` |
|
||||
| 10 | Multi-document tab bar | DONE | `dock/view.rs` |
|
||||
| 11 | Theme system (5 presets, ThemeColors) | DONE | `theme.rs`, `iced-panel-adapter/theme.rs` |
|
||||
| 12 | Custom title bar (app name, version, doc info) | DONE | `panels/title_bar.rs` |
|
||||
| 13 | Properties, Layer Details, Geometry panels | DONE | `panels/*.rs` |
|
||||
| 14 | SVG icons (64 icons, tool rendering) | DONE | `sidebar/mod.rs` |
|
||||
| 15 | Checkerboard on canvas | DONE | `canvas/mod.rs` |
|
||||
| 16 | Clickable color palette swatches | DONE | `sidebar/mod.rs` |
|
||||
| 17 | Constant pressure for mouse input | DONE | `app.rs` |
|
||||
| 18 | Toolbox expand/collapse (1→2 column) | DONE | `sidebar/mod.rs` |
|
||||
| 19 | AI Chat panel (UI only) | DONE | `panels/ai_chat_panel.rs` |
|
||||
| 20 | Layer Styles panel | DONE | `panels/layer_styles.rs` |
|
||||
| 21 | Script/AI Script panels | DONE | `panels/script_panel.rs`, `ai_script_panel.rs` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Photoshop-Style Theme
|
||||
## Remaining Tasks (prioritized)
|
||||
|
||||
**Files:** `panels/styles.rs`, `theme.rs`
|
||||
### P0 — High Impact, Must Fix
|
||||
|
||||
### Color Palette (matching Photoshop):
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `bg_app` | `#1e1e1e` | Main app background |
|
||||
| `bg_panel` | `#2d2d2d` | Panel backgrounds |
|
||||
| `bg_panel_header` | `#333333` | Panel title bars |
|
||||
| `bg_hover` | `#3a3a3a` | Hover states |
|
||||
| `bg_active` | `#404040` | Active/selected states |
|
||||
| `accent` | `#2d8ceb` | Blue accent (selection, active tool) |
|
||||
| `border` | `#1a1a1a` | Panel borders (very subtle) |
|
||||
| `border_high` | `#444444` | Emphasized borders |
|
||||
| `text_primary` | `#cccccc` | Primary text |
|
||||
| `text_secondary` | `#888888` | Secondary text |
|
||||
| `text_disabled` | `#555555` | Disabled text |
|
||||
| `canvas_bg` | `#262626` | Canvas area background |
|
||||
#### 1. Custom Window Frame (decorations: false)
|
||||
**Impact:** Professional look, eliminates OS chrome mismatch
|
||||
**Files:** `main.rs` (L58-66), `app.rs` (view method), `panels/title_bar.rs`
|
||||
|
||||
### Style Functions to Update:
|
||||
- `panel_background()` — `#2d2d2d` with no visible border
|
||||
- `panel_header()` — `#333333` background, no horizontal rule
|
||||
- `active_item_bg()` — subtle `#404040` highlight (NOT blue tint)
|
||||
- `inactive_item_bg()` — transparent
|
||||
- `menubar_background()` — `#2a2a2a`
|
||||
- `statusbar_background()` — `#2a2a2a`
|
||||
- `sidebar_background()` — `#2d2d2d`
|
||||
- `titlebar_background()` — `#1e1e1e`
|
||||
|
||||
---
|
||||
|
||||
## 3. Toolbox with SVG Icons (Photoshop-Style)
|
||||
|
||||
**Files:** `sidebar/mod.rs`, new: `sidebar/tool_slots.rs`
|
||||
|
||||
### Layout:
|
||||
- **Single column** (default): 36px wide, icons only, no text labels
|
||||
- **Two-column mode**: Toggle button (◀/▶ arrow) at top expands to 72px
|
||||
- Toggle button: small arrow at top of toolbox
|
||||
- Tool buttons: 28x28px icons, 3px rounded corners
|
||||
- Active tool: subtle `#404040` background + `#2d8ceb` border
|
||||
- Hover: `#3a3a3a` background
|
||||
- Submenu indicator: small triangle for tools with sub-tools
|
||||
|
||||
### Tool Slots (matching egui's TOOL_SLOTS):
|
||||
```
|
||||
Slot 0: Move
|
||||
Slot 1: VectorSelect
|
||||
Slot 2: Select (rect)
|
||||
Slot 3: Lasso, PolygonSelect (submenu)
|
||||
Slot 4: MagicWand
|
||||
Slot 5: SmartSelect, VisionSelect (submenu)
|
||||
Slot 6: Crop
|
||||
Slot 7: Eyedropper
|
||||
Slot 8: Brush
|
||||
Slot 9: Pen
|
||||
Slot 10: Spray
|
||||
Slot 11: Eraser
|
||||
Slot 12: FloodFill
|
||||
Slot 13: Gradient
|
||||
Slot 14: Text
|
||||
Slot 15: VectorLine
|
||||
Slot 16: VectorRect
|
||||
Slot 17: VectorCircle
|
||||
Slot 18: Vector shapes (submenu: Arrow, Star, Polygon, etc.)
|
||||
Slot 19: Retouch (submenu: SpotRemoval, RedEye, SmartPatch)
|
||||
```
|
||||
|
||||
### SVG Icons:
|
||||
- Use existing SVG files from `hcie-egui-app/assets/icons/`
|
||||
- Copy SVG files to `hcie-iced-app/assets/icons/`
|
||||
- Use iced's `svg::Handle::from_path()` to load icons
|
||||
- Tint icons: active=`#2d8ceb`, default=`#cccccc`, hover=`#ffffff`
|
||||
- Icon size: 20x20px within 28x28 button
|
||||
|
||||
### Color Swatches (bottom of toolbox):
|
||||
- Overlapping fg/bg swatches (20x20 each, offset 2px)
|
||||
- Swap button (⇅) between them
|
||||
- Reset to B/W button
|
||||
|
||||
---
|
||||
|
||||
## 4. Panel Titles (Modern, No Blue Tint)
|
||||
|
||||
**Files:** `dock/view.rs`
|
||||
|
||||
### Current (bad):
|
||||
- Blue tinted background on active panel
|
||||
- Horizontal rule under title
|
||||
- Primitive appearance
|
||||
|
||||
### New (Photoshop-style):
|
||||
- Panel header: `#333333` background, 24px height
|
||||
- Title text: 11px, `#cccccc` color, no bold
|
||||
- No horizontal rule
|
||||
- Close/maximize buttons: subtle, appear on hover only
|
||||
- Active panel: slightly lighter header (`#3a3a3a`)
|
||||
- Panel border: 1px `#1a1a1a` (barely visible)
|
||||
|
||||
### Implementation:
|
||||
- Remove `horizontal_rule` from panel titles
|
||||
- Replace blue tint with neutral `#3a3a3a` for active
|
||||
- Make close/maximize buttons hover-only (opacity transition)
|
||||
- Add small panel icon next to title (from SVG assets)
|
||||
|
||||
---
|
||||
|
||||
## 5. Menu Dropdown (Overlay, Not Separate Panel)
|
||||
|
||||
**Files:** `panels/menus.rs`
|
||||
|
||||
### Current (bad):
|
||||
- Menu opens as a separate panel in the dock
|
||||
- Takes up space, pushes other content
|
||||
|
||||
### New (Photoshop-style):
|
||||
- Menu dropdown renders as an **overlay** on top of the UI
|
||||
- Dark background `#2d2d2d`, 1px border `#444444`
|
||||
- Items: 11px text, 24px height, hover highlight `#3a3a3a`
|
||||
- Keyboard shortcuts shown on right side in `#888888`
|
||||
- Separators: 1px line `#3a3a3a`
|
||||
- Click outside to close
|
||||
- Escape to close
|
||||
|
||||
### Implementation:
|
||||
- Track `active_menu: Option<usize>` in app state
|
||||
- When menu button clicked, set `active_menu = Some(idx)`
|
||||
- Render dropdown as a `Stack` overlay on top of main content
|
||||
- Use `iced::widget::mouse_area` wrapper to detect clicks outside
|
||||
- Menu items dispatch `Message::MenuAction(menu_idx, item_idx)`
|
||||
|
||||
---
|
||||
|
||||
## 6. Dock Manager (Draggable Panels)
|
||||
|
||||
**Files:** `dock/state.rs`, `dock/view.rs`
|
||||
|
||||
### Current:
|
||||
- Dock exists with PaneGrid but drag/drop may not be fully functional
|
||||
- Panel rearrangement limited
|
||||
|
||||
### New:
|
||||
- Ensure `on_drag(Message::PaneDragged)` is properly wired
|
||||
- Handle `DragEvent::Picked`, `DragEvent::Dropped`, `DragEvent::Canceled`
|
||||
- On `Dropped`: call `state.drop(pane, target)` for proper rearrangement
|
||||
- Add right-click context menu on panel titles (Pin, Float, Close)
|
||||
- Support panel undocking (float) and re-docking
|
||||
|
||||
### Implementation:
|
||||
- Wire `Message::PaneDragged` handler to call appropriate state methods
|
||||
- Add `PaneDrop(pane, target)` message that calls `state.drop()`
|
||||
- Add context menu overlay for panel title bars
|
||||
|
||||
---
|
||||
|
||||
## 7. Menu Items (Full Photoshop-Style)
|
||||
Current: OS title bar is visible, custom title bar is redundant below it.
|
||||
Need:
|
||||
- Set `decorations: false` in window settings
|
||||
- Add `Message::WindowDrag`, `WindowMinimize`, `WindowMaximize`, `WindowClose`
|
||||
- Use `iced::window::drag()`, `minimize()`, `toggle_maximize()`
|
||||
- Merge menu bar INTO title bar (Photoshop-style: app name | menu items | window controls, all in one 28px bar)
|
||||
- Title bar: make entire bar draggable, buttons on right side
|
||||
|
||||
#### 2. Menu Dropdown as Overlay (not inline)
|
||||
**Impact:** Menus currently push content down when opened
|
||||
**Files:** `panels/menus.rs`, `app.rs`
|
||||
|
||||
### Menu Structure:
|
||||
| Menu | Items |
|
||||
|------|-------|
|
||||
| File | New, Open, Open Recent, Save, Save As, Export PNG, Export JPEG, Close |
|
||||
| Edit | Undo, Redo, Cut, Copy, Paste, Select All, Deselect, Fill |
|
||||
| Image | Canvas Size, Image Size, Rotate CW, Rotate CCW, Flip H, Flip V |
|
||||
| Layer | New Layer, Duplicate, Delete, Merge Down, Flatten, Clear |
|
||||
| Filter | (all filter categories with submenus) |
|
||||
| Select | All, Deselect, Invert, Grow, Shrink, Feather |
|
||||
| View | Zoom In, Zoom Out, Fit Screen, 100%, Panel Toggles |
|
||||
| Window | Dock Profile, Reset Layout |
|
||||
| Help | About, Documentation |
|
||||
Current: Dropdown renders below the menu bar as a `column![bar, dropdown]`, shifting all content down.
|
||||
Need:
|
||||
- Wrap main content + dropdown in `iced::widget::Stack`
|
||||
- Position dropdown absolutely at top-left
|
||||
- Add `mouse_area` backdrop to close on click-outside
|
||||
- Keep `active_menu` tracking (already exists)
|
||||
- Style dropdown with proper hover states per item
|
||||
|
||||
#### 3. Unused Warning Cleanup (16 warnings)
|
||||
**Impact:** Code cleanliness, prevents confusion
|
||||
**Files:** `panels/styles.rs`, `panels/text_editor.rs`, `theme.rs`
|
||||
|
||||
Warnings:
|
||||
- `panel_header` unused in styles.rs → use in dock title bars
|
||||
- `hover_item_bg` unused → wire into menu/tool hover states
|
||||
- `menubar_background` unused → use in menu bar container
|
||||
- `dropdown_background` unused → use in menu dropdown
|
||||
- `view` in text_editor.rs → wire into text tool
|
||||
- `set_preset` in theme.rs → add theme switcher to menu
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
### P1 — Important UX Polish
|
||||
|
||||
1. **Theme colors** — Update `styles.rs` with Photoshop palette
|
||||
2. **Custom title bar** — Hide OS decorations, build custom title bar
|
||||
3. **Toolbox SVG icons** — Copy SVGs, implement icon rendering
|
||||
4. **Toolbox layout** — Single column + expand toggle
|
||||
5. **Panel titles** — Remove blue tint, modernize
|
||||
6. **Menu overlay** — Implement dropdown overlay system
|
||||
7. **Dock drag/drop** — Wire pane rearrangement
|
||||
8. **Menu items** — Wire all menu actions
|
||||
#### 4. Menu Actions Fully Wired
|
||||
**Impact:** Many menu items dispatch to `NoOp` or incomplete handlers
|
||||
**Files:** `app.rs` (MenuAction handler ~L1340-1410)
|
||||
|
||||
Missing wiring:
|
||||
- Edit menu: Cut (X), Select All (A), Deselect (D), Fill
|
||||
- Image menu: Canvas Size, Image Size, Rotate CW/CCW/180, Flip H/V, Crop to Selection
|
||||
- Layer menu: Duplicate, Delete, Clear, Layer Styles, Align
|
||||
- Filter menu: Color & Light (Levels, Vibrance, Exposure, Posterize, Threshold, Dehaze)
|
||||
- Select menu: Grow, Shrink, Feather (open SelectionOp dialog)
|
||||
- View menu: Fit to Window (auto-calc zoom), Debug Mode
|
||||
- File menu: Export PNG, Export JPEG, Close
|
||||
|
||||
#### 5. Hover States for Tool Buttons & Menu Items
|
||||
**Impact:** No visual feedback on hover
|
||||
**Files:** `sidebar/mod.rs`, `panels/menus.rs`
|
||||
|
||||
Currently: Buttons use static styles, no hover highlight.
|
||||
Need:
|
||||
- Track hover state via `iced::widget::mouse_area.on_hover()` or iced hover interaction
|
||||
- Apply `colors.bg_hover` on hover for tool buttons and menu items
|
||||
- iced 0.13: use `button::on_hover()` or `style` closure that checks `Status::Hovered`
|
||||
|
||||
#### 6. Panel Title Bar Modernization
|
||||
**Impact:** Current title bars use generic styling
|
||||
**Files:** `dock/view.rs`
|
||||
|
||||
Currently: Title bars show text + close/maximize buttons. Missing:
|
||||
- Panel type icon (from SVG assets) next to title
|
||||
- Close/maximize buttons should be hover-only (opacity change)
|
||||
- Use `colors.bg_hover` for header background (not hardcoded)
|
||||
- Remove the `horizontal_rule(1)` still used in some places
|
||||
|
||||
---
|
||||
|
||||
### P2 — Functional Completeness
|
||||
|
||||
#### 7. Text Tool Implementation
|
||||
**Impact:** Text tool is placeholder (TODO handlers)
|
||||
**Files:** `app.rs` (CanvasPointerPressed handler), new panel
|
||||
|
||||
Currently: Text tool selected, but no text input UI.
|
||||
Need:
|
||||
- When Text tool active + click on canvas: show text input overlay
|
||||
- Track: text content, font size, font family, alignment, color
|
||||
- On confirm: call `engine.draw_text()` to rasterize
|
||||
- Messages: `TextSizeChanged`, `TextColorChanged`, `TextAccept`, `TextCancel` (exist but unused)
|
||||
|
||||
#### 8. Brush Editor (Advanced Params)
|
||||
**Impact:** Only basic brush params are exposed (size/opacity/hardness/style)
|
||||
**Files:** `panels/brushes.rs`
|
||||
|
||||
Currently: 3 sliders + 4 style buttons.
|
||||
Need:
|
||||
- Spacing slider (already in ToolState but not in UI)
|
||||
- Jitter slider
|
||||
- Scatter slider
|
||||
- Brush preview canvas (show brush tip shape)
|
||||
- Brush presets list (save/load)
|
||||
|
||||
#### 9. AI Chat Connection
|
||||
**Impact:** AI panel shows UI but doesn't connect to any provider
|
||||
**Files:** `panels/ai_chat_panel.rs`, `app.rs`
|
||||
|
||||
Currently: Provider buttons (Ollama/Claude/OpenAI) dispatch NoOp.
|
||||
Need:
|
||||
- Wire to `hcie-engine-api` AI client (Ollama compatible)
|
||||
- Async HTTP requests via `iced::Subscription` or `Task::perform`
|
||||
- Stream responses into message list
|
||||
- Map AI commands to engine operations
|
||||
|
||||
#### 10. Settings Dialog
|
||||
**Impact:** No way to configure app settings
|
||||
**Files:** new: `dialogs/settings.rs`
|
||||
|
||||
Need:
|
||||
- Theme selector (switch between 5 presets)
|
||||
- Tablet pressure curve settings
|
||||
- Default brush params
|
||||
- Canvas background color
|
||||
- Grid/snap settings
|
||||
|
||||
---
|
||||
|
||||
### P3 — Nice to Have
|
||||
|
||||
#### 11. Context Menu on Panel Titles
|
||||
- Right-click to Pin, Float, Close panel
|
||||
- Would require `mouse_area` with secondary click detection
|
||||
|
||||
#### 12. Dock Profiles / Reset Layout
|
||||
- Save/restore panel arrangements
|
||||
- "Reset to Default" button in Window menu
|
||||
|
||||
#### 13. Import SVG/PSD/KRA
|
||||
- Wire file open dialog to `engine.open_image()` for more formats
|
||||
- Currently supports PNG/JPG/WebP only via engine
|
||||
|
||||
#### 14. Export PNG/JPEG
|
||||
- Use `engine.get_composite_pixels()` + `image::save_buffer()`
|
||||
- Wire to File > Export menu items
|
||||
|
||||
#### 15. Undo/Redo Visual Feedback
|
||||
- Highlight current position in history panel
|
||||
- Show undo count in status bar
|
||||
|
||||
---
|
||||
|
||||
## Execution Order (recommended next session)
|
||||
|
||||
| Phase | Task | Est. Time |
|
||||
|-------|------|-----------|
|
||||
| 1 | Custom window frame (decorations: false + draggable title bar) | 45 min |
|
||||
| 2 | Menu overlay (Stack-based dropdown) | 30 min |
|
||||
| 3 | Wire unused style functions + fix warnings | 15 min |
|
||||
| 4 | Wire all menu actions | 30 min |
|
||||
| 5 | Hover states for tool/menu items | 20 min |
|
||||
| 6 | Panel title icons + modernize | 20 min |
|
||||
| 7 | Text tool | 45 min |
|
||||
| 8 | Brush editor (spacing/jitter/scatter) | 30 min |
|
||||
|
||||
**Total: ~3.5 hours for P0+P1+P2 first 3 items**
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. Build: `cargo build -p hcie-iced-gui`
|
||||
2. Launch and compare with Photoshop screenshot
|
||||
3. Test: tool selection, drawing, color picking, menu dropdowns, panel rearrangement
|
||||
4. Screenshot comparison with Photoshop reference
|
||||
1. `cargo check -p hcie-iced-gui` — 0 warnings, 0 errors
|
||||
2. `cargo run -p hcie-iced-gui` — launch and verify:
|
||||
- No OS title bar, custom title bar with drag
|
||||
- Menu dropdowns overlay without pushing content
|
||||
- All menu items dispatch correct actions
|
||||
- Tool buttons highlight on hover
|
||||
- Panel titles show icons
|
||||
3. Test drawing: brush, eraser, vector shapes, text
|
||||
4. Test dialogs: New Image, Adjustments, Close Confirm
|
||||
5. Test dock: drag panels, resize splits, close/maximize
|
||||
|
||||
Generated
+61
@@ -1949,6 +1949,12 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
|
||||
|
||||
[[package]]
|
||||
name = "float_next_after"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -2786,6 +2792,7 @@ name = "hcie-iced-gui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"bytes",
|
||||
"env_logger",
|
||||
"evdev",
|
||||
"hcie-engine-api",
|
||||
@@ -3205,6 +3212,7 @@ dependencies = [
|
||||
"image 0.24.9",
|
||||
"kamadak-exif",
|
||||
"log",
|
||||
"lyon_path",
|
||||
"once_cell",
|
||||
"raw-window-handle",
|
||||
"rustc-hash 2.1.2",
|
||||
@@ -3269,6 +3277,7 @@ dependencies = [
|
||||
"iced_glyphon",
|
||||
"iced_graphics",
|
||||
"log",
|
||||
"lyon",
|
||||
"once_cell",
|
||||
"resvg 0.42.0",
|
||||
"rustc-hash 2.1.2",
|
||||
@@ -3850,6 +3859,58 @@ version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||
|
||||
[[package]]
|
||||
name = "lyon"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd0578bdecb7d6d88987b8b2b1e3a4e2f81df9d0ece1078623324a567904e7b7"
|
||||
dependencies = [
|
||||
"lyon_algorithms",
|
||||
"lyon_tessellation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lyon_algorithms"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8575c0d003ae459399623c4def180c63b77f343b1a7fee64f249b349e7699a31"
|
||||
dependencies = [
|
||||
"lyon_path",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lyon_geom"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"euclid",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lyon_path"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e"
|
||||
dependencies = [
|
||||
"lyon_geom",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lyon_tessellation"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e43b7e44161571868f5c931d12583592c223c5583eef86b08aa02b7048a3552"
|
||||
dependencies = [
|
||||
"float_next_after",
|
||||
"lyon_path",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ eframe = { version = "0.34", default-features = false, features = ["default
|
||||
egui_extras = { version = "0.34", features = ["svg", "image"] }
|
||||
egui_dock = { version = "0.19", features = ["serde"] }
|
||||
rfd = "0.15"
|
||||
iced = { version = "0.13", features = ["tokio", "image", "svg"] }
|
||||
iced = { version = "0.13", features = ["tokio", "image", "svg", "canvas"] }
|
||||
|
||||
# Utilities
|
||||
base64 = "0.22"
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ echo "" >> "$OUTPUT_FILE"
|
||||
# 1. RUST DOSYALARI (.rs)
|
||||
# ============================================================
|
||||
echo "-----------------------------------------" | tee -a "$OUTPUT_FILE"
|
||||
echo " RUST (.rs) — hcie-* dizinleri (her paket)" | tee -a "$OUTPUT_FILE"
|
||||
echo " RUST (.rs) — hcie-* dizinleri (her paket, inkl. hcie-fx, hcie-psd, hcie-kra, hcie-native)" | tee -a "$OUTPUT_FILE"
|
||||
echo "-----------------------------------------" | tee -a "$OUTPUT_FILE"
|
||||
|
||||
# Proje kök dizinindeki hcie-* dizinleri (her bir Rust paketi) için döngü
|
||||
|
||||
@@ -21,4 +21,5 @@ image = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true }
|
||||
bytes = "1.12"
|
||||
evdev = { version = "0.12", optional = true }
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
//! Implements the Elm-architecture pattern for the HCIE image editor.
|
||||
//! Manages per-document engine instances, tool state, compositing,
|
||||
//! dock layout, keyboard shortcuts, and all UI interactions.
|
||||
//!
|
||||
//! ⚠️ PERFORMANCE-CRITICAL SECTIONS (DO NOT MODIFY):
|
||||
//! - `refresh_composite_if_needed()` — uses engine's tiled compositing
|
||||
//! - `IcedDocument.composite_buffer` — uses `bytes::Bytes` for zero-copy
|
||||
//! - `CanvasZoom` handler — zoom-toward-cursor math
|
||||
//! These sections are optimized for 4K performance. Modifying them
|
||||
//! may cause rendering regressions or performance degradation.
|
||||
|
||||
use crate::dialogs;
|
||||
use crate::dock::state::{DockState, PaneType};
|
||||
@@ -12,10 +19,12 @@ use crate::theme::ThemeState;
|
||||
use hcie_engine_api::{BrushTip, BrushStyle, Engine, FilterType, Tool, ZOOM_MAX, ZOOM_MIN};
|
||||
use iced::widget::{column, container, text};
|
||||
use iced::{Element, Length, Task, Theme, Vector};
|
||||
use bytes::Bytes;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Active dialog type.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum ActiveDialog {
|
||||
None,
|
||||
NewImage,
|
||||
@@ -43,6 +52,8 @@ pub struct HcieIcedApp {
|
||||
pub canvas_cursor_pos: Option<(u32, u32)>,
|
||||
/// Currently active dialog (if any).
|
||||
pub active_dialog: ActiveDialog,
|
||||
/// Window ID for window control operations (drag, minimize, maximize, close).
|
||||
pub window_id: Option<iced::window::Id>,
|
||||
/// New Image dialog state.
|
||||
pub dialog_new_name: String,
|
||||
pub dialog_new_width: u32,
|
||||
@@ -72,17 +83,14 @@ pub struct HcieIcedApp {
|
||||
pub theme_state: ThemeState,
|
||||
/// Currently open menu index (None = no menu open).
|
||||
pub active_menu: Option<usize>,
|
||||
/// Whether the toolbox is in two-column mode.
|
||||
pub toolbox_two_column: bool,
|
||||
}
|
||||
|
||||
/// Per-document state wrapping an engine instance.
|
||||
pub struct IcedDocument {
|
||||
pub engine: Engine,
|
||||
/// Cached composite RGBA pixel data from the engine.
|
||||
pub composite_buffer: Vec<u8>,
|
||||
/// Whether the composite needs re-rendering.
|
||||
pub composite_dirty: bool,
|
||||
/// Uses `bytes::Bytes` for zero-cost cloning (reference-counted).
|
||||
pub composite_buffer: Bytes,
|
||||
/// Document display name.
|
||||
pub name: String,
|
||||
/// Original file path, if loaded from disk.
|
||||
@@ -103,6 +111,12 @@ pub struct IcedDocument {
|
||||
pub selection_rect: Option<(f32, f32, f32, f32)>,
|
||||
/// Vector shape being drawn (start pos, current pos).
|
||||
pub vector_draw: Option<((f32, f32), (f32, f32))>,
|
||||
/// Canvas pane size (width, height) in pixels — updated by the view.
|
||||
pub pane_size: (f32, f32),
|
||||
/// Render generation counter; incremented whenever the engine composite
|
||||
/// buffer is refreshed. Used by the canvas geometry cache to detect when
|
||||
/// it must redraw.
|
||||
pub render_generation: u64,
|
||||
}
|
||||
|
||||
/// Drawing tool state.
|
||||
@@ -145,6 +159,7 @@ impl Default for ToolState {
|
||||
|
||||
/// Messages the application handles.
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub enum Message {
|
||||
// ── Tool selection ──────────────────────────────────
|
||||
ToolSelected(Tool),
|
||||
@@ -158,8 +173,11 @@ pub enum Message {
|
||||
CanvasPointerPressed { x: f32, y: f32 },
|
||||
CanvasPointerMoved { x: f32, y: f32 },
|
||||
CanvasPointerReleased,
|
||||
CanvasZoom { delta: f32 },
|
||||
CanvasPanZoom { zoom: f32, pan_offset: Vector },
|
||||
CanvasZoomSet(f32),
|
||||
CanvasZoomRelative(f32),
|
||||
CanvasSize((f32, f32)),
|
||||
CanvasCursorPos { x: u32, y: u32 },
|
||||
|
||||
// ── Engine operations ───────────────────────────────
|
||||
CompositeRefresh,
|
||||
@@ -278,8 +296,12 @@ pub enum Message {
|
||||
MenuClose,
|
||||
MenuAction(usize, usize), // (menu_index, item_index)
|
||||
|
||||
// ── Toolbox ─────────────────────────────────────────
|
||||
ToggleToolbox,
|
||||
// ── Window ───────────────────────────────────────────
|
||||
WindowDrag,
|
||||
WindowMinimize,
|
||||
WindowMaximize,
|
||||
WindowClose,
|
||||
WindowId(iced::window::Id),
|
||||
|
||||
// ── Misc ────────────────────────────────────────────
|
||||
NoOp,
|
||||
@@ -335,10 +357,11 @@ impl HcieIcedApp {
|
||||
/// Create the initial application state.
|
||||
///
|
||||
/// If `load_path` is provided, the file will be opened after initialization.
|
||||
pub fn new(load_path: Option<std::path::PathBuf>) -> Self {
|
||||
/// Returns a Task to capture the window ID on startup.
|
||||
pub fn new(load_path: Option<std::path::PathBuf>) -> (Self, Task<Message>) {
|
||||
let mut engine = Engine::new(800, 600);
|
||||
engine.pre_tile_all_layers();
|
||||
let composite_buffer = engine.get_composite_pixels();
|
||||
let composite_buffer = Bytes::from(engine.get_composite_pixels());
|
||||
|
||||
let cached_layers = engine.layer_infos();
|
||||
let history_len = engine.history_len();
|
||||
@@ -347,10 +370,9 @@ impl HcieIcedApp {
|
||||
.collect();
|
||||
let history_current = engine.history_current();
|
||||
|
||||
let doc = IcedDocument {
|
||||
let mut doc = IcedDocument {
|
||||
engine,
|
||||
composite_buffer,
|
||||
composite_dirty: false,
|
||||
name: "Untitled".to_string(),
|
||||
source_path: None,
|
||||
modified: false,
|
||||
@@ -361,6 +383,9 @@ impl HcieIcedApp {
|
||||
history_current,
|
||||
selection_rect: None,
|
||||
vector_draw: None,
|
||||
pane_size: (800.0, 600.0),
|
||||
render_generation: 0,
|
||||
|
||||
};
|
||||
|
||||
let mut app = Self {
|
||||
@@ -372,6 +397,7 @@ impl HcieIcedApp {
|
||||
last_cursor_pos: None,
|
||||
canvas_cursor_pos: None,
|
||||
active_dialog: ActiveDialog::None,
|
||||
window_id: None,
|
||||
dialog_new_name: "Untitled".to_string(),
|
||||
dialog_new_width: 800,
|
||||
dialog_new_height: 600,
|
||||
@@ -390,7 +416,6 @@ impl HcieIcedApp {
|
||||
initialized: false,
|
||||
theme_state: ThemeState::new(),
|
||||
active_menu: None,
|
||||
toolbox_two_column: false,
|
||||
};
|
||||
|
||||
// If a file path was provided, open it
|
||||
@@ -402,7 +427,7 @@ impl HcieIcedApp {
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "Untitled".to_string());
|
||||
app.documents[0].source_path = Some(path);
|
||||
app.documents[0].composite_dirty = true;
|
||||
app.documents[0].render_generation = app.documents[0].render_generation.wrapping_add(1);
|
||||
app.refresh_composite_if_needed();
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -412,7 +437,8 @@ impl HcieIcedApp {
|
||||
}
|
||||
|
||||
app.initialized = true;
|
||||
app
|
||||
let init_task = Task::none();
|
||||
(app, init_task)
|
||||
}
|
||||
|
||||
/// Return a mutable reference to the active document.
|
||||
@@ -420,17 +446,29 @@ impl HcieIcedApp {
|
||||
self.documents.get_mut(self.active_doc).expect("no active document")
|
||||
}
|
||||
|
||||
/// Convert widget-relative coordinates to canvas-space coordinates.
|
||||
/// Convert pane-relative coordinates to canvas-space coordinates.
|
||||
///
|
||||
/// The canvas image is centered in the pane, with `pan_offset` as an
|
||||
/// additional offset from center. This function accounts for both.
|
||||
fn screen_to_canvas(&self, sx: f32, sy: f32) -> Option<(f32, f32)> {
|
||||
let doc = &self.documents[self.active_doc];
|
||||
let zoom = doc.zoom;
|
||||
let pan = doc.pan_offset;
|
||||
let (pane_w, pane_h) = doc.pane_size;
|
||||
let engine_w = doc.engine.canvas_width() as f32;
|
||||
let engine_h = doc.engine.canvas_height() as f32;
|
||||
let display_w = engine_w * zoom;
|
||||
let display_h = engine_h * zoom;
|
||||
|
||||
let canvas_x = (sx - pan.x) / zoom;
|
||||
let canvas_y = (sy - pan.y) / zoom;
|
||||
// Canvas image position = center of pane + pan_offset
|
||||
let canvas_origin_x = (pane_w - display_w) / 2.0 + doc.pan_offset.x;
|
||||
let canvas_origin_y = (pane_h - display_h) / 2.0 + doc.pan_offset.y;
|
||||
|
||||
if canvas_x >= 0.0 && canvas_x < doc.engine.canvas_width() as f32
|
||||
&& canvas_y >= 0.0 && canvas_y < doc.engine.canvas_height() as f32
|
||||
// Convert from pane-space to canvas-space
|
||||
let canvas_x = (sx - canvas_origin_x) / zoom;
|
||||
let canvas_y = (sy - canvas_origin_y) / zoom;
|
||||
|
||||
if canvas_x >= 0.0 && canvas_x < engine_w
|
||||
&& canvas_y >= 0.0 && canvas_y < engine_h
|
||||
{
|
||||
Some((canvas_x, canvas_y))
|
||||
} else {
|
||||
@@ -438,13 +476,48 @@ impl HcieIcedApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the composite buffer and cached panel data from the engine if dirty.
|
||||
/// Refresh the composite buffer using incremental dirty-region compositing.
|
||||
///
|
||||
/// Uses `render_composite_region()` which only re-composites dirty tiles
|
||||
/// (same optimization as the egui version). This is critical for 4K performance.
|
||||
fn refresh_composite_if_needed(&mut self) {
|
||||
let doc = &mut self.documents[self.active_doc];
|
||||
if doc.composite_dirty {
|
||||
doc.composite_buffer = doc.engine.get_composite_pixels();
|
||||
doc.composite_dirty = false;
|
||||
|
||||
if doc.engine.is_composite_dirty() {
|
||||
let (region_result, buf_ptr, buf_size) = doc.engine.render_composite_region();
|
||||
|
||||
// Copy the composite buffer from the engine into a new Bytes
|
||||
if !buf_ptr.is_null() && buf_size > 0 {
|
||||
// Copy the engine's composite buffer into a Vec, then transfer
|
||||
// ownership to Bytes. Using Bytes::from(Vec) avoids the
|
||||
// second copy that Bytes::copy_from_slice would cause.
|
||||
let mut pixels = Vec::with_capacity(buf_size);
|
||||
unsafe {
|
||||
pixels.set_len(buf_size);
|
||||
std::ptr::copy_nonoverlapping(
|
||||
buf_ptr,
|
||||
pixels.as_mut_ptr(),
|
||||
buf_size,
|
||||
);
|
||||
}
|
||||
doc.composite_buffer = Bytes::from(pixels);
|
||||
doc.render_generation = doc.render_generation.wrapping_add(1);
|
||||
}
|
||||
|
||||
// Log slow frames
|
||||
let region = region_result.unwrap_or([0, 0, doc.engine.canvas_width(), doc.engine.canvas_height()]);
|
||||
let region_w = (region[2] - region[0]) as usize;
|
||||
let region_h = (region[3] - region[1]) as usize;
|
||||
if region_w > 0 && region_h > 0 {
|
||||
log::trace!(
|
||||
"[refresh_composite] dirty region: [{},{},{},{}] ({}×{} pixels)",
|
||||
region[0], region[1], region[2], region[3], region_w, region_h
|
||||
);
|
||||
}
|
||||
|
||||
doc.engine.clear_dirty_flags();
|
||||
}
|
||||
|
||||
// Always refresh cached panel data (cheap operation)
|
||||
doc.cached_layers = doc.engine.layer_infos();
|
||||
let history_len = doc.engine.history_len();
|
||||
@@ -489,14 +562,9 @@ impl HcieIcedApp {
|
||||
}
|
||||
|
||||
Message::CanvasPointerPressed { x, y } => {
|
||||
let (px, py) = if x == 0.0 && y == 0.0 {
|
||||
self.last_cursor_pos.unwrap_or((0.0, 0.0))
|
||||
} else {
|
||||
(x, y)
|
||||
};
|
||||
self.last_cursor_pos = Some((px, py));
|
||||
|
||||
if let Some((canvas_x, canvas_y)) = self.screen_to_canvas(px, py) {
|
||||
// Coordinates are already in canvas-space from the canvas widget
|
||||
let canvas_x = x;
|
||||
let canvas_y = y;
|
||||
self.canvas_cursor_pos = Some((canvas_x as u32, canvas_y as u32));
|
||||
let tool = self.tool_state.active_tool;
|
||||
|
||||
@@ -513,7 +581,6 @@ impl HcieIcedApp {
|
||||
self.tool_state.is_drawing = true;
|
||||
self.tool_state.last_stroke_pos = Some((canvas_x, canvas_y));
|
||||
self.tool_state.brush_accumulated_dist = 0.0;
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
@@ -537,7 +604,6 @@ impl HcieIcedApp {
|
||||
let cy = canvas_y as u32;
|
||||
let color = self.fg_color;
|
||||
self.documents[self.active_doc].engine.flood_fill(cx, cy, color, 32);
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
@@ -560,18 +626,12 @@ impl HcieIcedApp {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Message::CanvasPointerMoved { x, y } => {
|
||||
self.last_cursor_pos = Some((x, y));
|
||||
|
||||
// Update canvas-space cursor for status bar
|
||||
if let Some((cx, cy)) = self.screen_to_canvas(x, y) {
|
||||
self.canvas_cursor_pos = Some((cx as u32, cy as u32));
|
||||
}
|
||||
// Coordinates are already in canvas-space from the canvas widget
|
||||
self.canvas_cursor_pos = Some((x as u32, y as u32));
|
||||
|
||||
if self.tool_state.is_drawing {
|
||||
if let Some((canvas_x, canvas_y)) = self.screen_to_canvas(x, y) {
|
||||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||||
|
||||
let spacing: f32 = match self.tool_state.active_tool {
|
||||
@@ -580,8 +640,8 @@ impl HcieIcedApp {
|
||||
}.max(1.0);
|
||||
|
||||
if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos {
|
||||
let dx = canvas_x - last_x;
|
||||
let dy = canvas_y - last_y;
|
||||
let dx = x - last_x;
|
||||
let dy = y - last_y;
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
|
||||
// Use constant pressure for mouse input
|
||||
@@ -589,50 +649,42 @@ impl HcieIcedApp {
|
||||
let pressure = self.tablet_state.lock().map(|s| s.current_pressure()).unwrap_or(1.0);
|
||||
|
||||
if dist >= spacing {
|
||||
self.documents[self.active_doc].engine.stroke_to(layer_id, canvas_x, canvas_y, pressure);
|
||||
self.tool_state.last_stroke_pos = Some((canvas_x, canvas_y));
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.documents[self.active_doc].engine.stroke_to(layer_id, x, y, pressure);
|
||||
self.tool_state.last_stroke_pos = Some((x, y));
|
||||
}
|
||||
} else {
|
||||
self.documents[self.active_doc].engine.stroke_to(layer_id, canvas_x, canvas_y, 1.0);
|
||||
self.tool_state.last_stroke_pos = Some((canvas_x, canvas_y));
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.documents[self.active_doc].engine.stroke_to(layer_id, x, y, 1.0);
|
||||
self.tool_state.last_stroke_pos = Some((x, y));
|
||||
}
|
||||
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle selection drag
|
||||
if self.tool_state.active_tool == Tool::Select {
|
||||
if let Some((canvas_x, canvas_y)) = self.screen_to_canvas(x, y) {
|
||||
let mx = canvas_x;
|
||||
let my = canvas_y;
|
||||
let mx = x;
|
||||
let my = y;
|
||||
return Task::perform(async move {}, move |_| {
|
||||
Message::SelectionDragMove(mx, my)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle vector draw drag
|
||||
if matches!(self.tool_state.active_tool, Tool::VectorRect | Tool::VectorCircle | Tool::VectorLine) {
|
||||
if let Some((canvas_x, canvas_y)) = self.screen_to_canvas(x, y) {
|
||||
let mx = canvas_x;
|
||||
let my = canvas_y;
|
||||
let mx = x;
|
||||
let my = y;
|
||||
return Task::perform(async move {}, move |_| {
|
||||
Message::VectorDrawMove(mx, my)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Message::CanvasPointerReleased => {
|
||||
if self.tool_state.is_drawing {
|
||||
let layer_id = self.documents[self.active_doc].engine.active_layer_id();
|
||||
self.documents[self.active_doc].engine.end_stroke(layer_id);
|
||||
self.documents[self.active_doc].engine.commit_pending_history();
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
|
||||
self.tool_state.is_drawing = false;
|
||||
self.tool_state.last_stroke_pos = None;
|
||||
@@ -652,14 +704,35 @@ impl HcieIcedApp {
|
||||
}
|
||||
}
|
||||
|
||||
Message::CanvasZoom { delta } => {
|
||||
Message::CanvasPanZoom { zoom, pan_offset } => {
|
||||
let doc = &mut self.documents[self.active_doc];
|
||||
let factor = if delta > 0.0 { 1.1 } else { 1.0 / 1.1 };
|
||||
doc.zoom = (doc.zoom * factor).clamp(ZOOM_MIN, ZOOM_MAX);
|
||||
doc.zoom = zoom;
|
||||
doc.pan_offset = pan_offset;
|
||||
}
|
||||
|
||||
Message::CanvasCursorPos { x, y } => {
|
||||
self.canvas_cursor_pos = Some((x, y));
|
||||
}
|
||||
|
||||
Message::CanvasZoomSet(z) => {
|
||||
self.documents[self.active_doc].zoom = z.clamp(ZOOM_MIN, ZOOM_MAX);
|
||||
let doc = &mut self.documents[self.active_doc];
|
||||
doc.zoom = z.clamp(ZOOM_MIN, ZOOM_MAX);
|
||||
// Keep pan_offset stable so the visible content does not jump
|
||||
// when zooming via keyboard/menu/slider. The canvas view
|
||||
// centers on pane+pan_offset, so holding pan_offset keeps the
|
||||
// same logical content anchored.
|
||||
}
|
||||
|
||||
Message::CanvasZoomRelative(factor) => {
|
||||
let doc = &mut self.documents[self.active_doc];
|
||||
doc.zoom = (doc.zoom * factor).clamp(ZOOM_MIN, ZOOM_MAX);
|
||||
// Keep pan_offset — zooming relative to center via keyboard.
|
||||
}
|
||||
|
||||
Message::CanvasSize((w, h)) => {
|
||||
self.documents[self.active_doc].pane_size = (w, h);
|
||||
// Keep pan_offset so the canvas stays anchored when the pane
|
||||
// is resized (e.g. dock splitter dragged).
|
||||
}
|
||||
|
||||
Message::CompositeRefresh | Message::Undo | Message::Redo => {
|
||||
@@ -668,7 +741,6 @@ impl HcieIcedApp {
|
||||
Message::Redo => { self.documents[self.active_doc].engine.redo(); }
|
||||
_ => {}
|
||||
}
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
}
|
||||
|
||||
@@ -680,14 +752,12 @@ impl HcieIcedApp {
|
||||
Message::LayerAdd => {
|
||||
let count = self.documents[self.active_doc].engine.get_layer_count();
|
||||
self.documents[self.active_doc].engine.add_layer(&format!("Layer {}", count + 1));
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
|
||||
Message::LayerDelete(id) => {
|
||||
self.documents[self.active_doc].engine.delete_layer(id);
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
@@ -697,7 +767,6 @@ impl HcieIcedApp {
|
||||
.map(|l| !l.visible)
|
||||
.unwrap_or(true);
|
||||
self.documents[self.active_doc].engine.set_layer_visible(id, visible);
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
@@ -711,21 +780,18 @@ impl HcieIcedApp {
|
||||
|
||||
Message::LayerSetOpacity(id, opacity) => {
|
||||
self.documents[self.active_doc].engine.set_layer_opacity(id, opacity);
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
|
||||
Message::LayerMergeDown => {
|
||||
self.documents[self.active_doc].engine.merge_down();
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
|
||||
Message::LayerFlatten => {
|
||||
self.documents[self.active_doc].engine.flatten_image();
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
@@ -736,7 +802,6 @@ impl HcieIcedApp {
|
||||
if pos > 0 {
|
||||
let target_idx = pos - 1;
|
||||
self.documents[self.active_doc].engine.move_layer(id, target_idx);
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
@@ -749,7 +814,6 @@ impl HcieIcedApp {
|
||||
if pos + 1 < infos.len() {
|
||||
let target_idx = pos + 1;
|
||||
self.documents[self.active_doc].engine.move_layer(id, target_idx);
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
@@ -759,7 +823,6 @@ impl HcieIcedApp {
|
||||
// ── History ───────────────────────────────────
|
||||
Message::HistoryJumpTo(idx) => {
|
||||
self.documents[self.active_doc].engine.jump_to_history(idx);
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
@@ -784,7 +847,6 @@ impl HcieIcedApp {
|
||||
// ── Layer styles ──────────────────────────────
|
||||
Message::LayerStyleToggle(_index) => {
|
||||
// TODO: toggle layer style enabled state
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
@@ -823,7 +885,6 @@ impl HcieIcedApp {
|
||||
if let Some(filter) = self.selected_filter {
|
||||
let filter_id = filter_type_to_id(filter);
|
||||
self.documents[self.active_doc].engine.apply_filter_preview(filter_id, self.filter_params.clone());
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
@@ -832,7 +893,6 @@ impl HcieIcedApp {
|
||||
if let Some(filter) = self.selected_filter {
|
||||
let filter_id = filter_type_to_id(filter);
|
||||
self.documents[self.active_doc].engine.apply_filter(filter_id, self.filter_params.clone());
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
self.filter_preview_active = false;
|
||||
self.selected_filter = None;
|
||||
@@ -843,7 +903,6 @@ impl HcieIcedApp {
|
||||
Message::FilterReset => {
|
||||
if self.filter_preview_active {
|
||||
self.documents[self.active_doc].engine.filter_preview_restore();
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
self.filter_preview_active = false;
|
||||
}
|
||||
@@ -890,7 +949,7 @@ impl HcieIcedApp {
|
||||
let transparent = self.dialog_new_transparent;
|
||||
let mut engine = Engine::new_with_options(&name, w, h, transparent);
|
||||
engine.pre_tile_all_layers();
|
||||
let composite_buffer = engine.get_composite_pixels();
|
||||
let composite_buffer = Bytes::from(engine.get_composite_pixels());
|
||||
let cached_layers = engine.layer_infos();
|
||||
let history_len = engine.history_len();
|
||||
let cached_history: Vec<(usize, String)> = (0..history_len)
|
||||
@@ -900,7 +959,6 @@ impl HcieIcedApp {
|
||||
self.documents.push(IcedDocument {
|
||||
engine,
|
||||
composite_buffer,
|
||||
composite_dirty: false,
|
||||
name,
|
||||
source_path: None,
|
||||
modified: false,
|
||||
@@ -911,9 +969,10 @@ impl HcieIcedApp {
|
||||
history_current,
|
||||
selection_rect: None,
|
||||
vector_draw: None,
|
||||
pane_size: (800.0, 600.0),
|
||||
render_generation: 0,
|
||||
|
||||
});
|
||||
self.active_doc = self.documents.len() - 1;
|
||||
self.active_dialog = ActiveDialog::None;
|
||||
}
|
||||
|
||||
// ── Adjustments Dialog ────────────────────────
|
||||
@@ -921,7 +980,6 @@ impl HcieIcedApp {
|
||||
self.dialog_brightness = v;
|
||||
let params = serde_json::json!({"brightness": v, "contrast": self.dialog_contrast});
|
||||
self.documents[self.active_doc].engine.apply_filter_preview("brightness_contrast", params);
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
@@ -929,7 +987,6 @@ impl HcieIcedApp {
|
||||
self.dialog_contrast = v;
|
||||
let params = serde_json::json!({"brightness": self.dialog_brightness, "contrast": v});
|
||||
self.documents[self.active_doc].engine.apply_filter_preview("brightness_contrast", params);
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
@@ -937,7 +994,6 @@ impl HcieIcedApp {
|
||||
self.dialog_hue = v;
|
||||
let params = serde_json::json!({"hue": v, "saturation": self.dialog_saturation, "lightness": self.dialog_lightness});
|
||||
self.documents[self.active_doc].engine.apply_filter_preview("hue_saturation", params);
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
@@ -945,7 +1001,6 @@ impl HcieIcedApp {
|
||||
self.dialog_saturation = v;
|
||||
let params = serde_json::json!({"hue": self.dialog_hue, "saturation": v, "lightness": self.dialog_lightness});
|
||||
self.documents[self.active_doc].engine.apply_filter_preview("hue_saturation", params);
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
@@ -953,7 +1008,6 @@ impl HcieIcedApp {
|
||||
self.dialog_lightness = v;
|
||||
let params = serde_json::json!({"hue": self.dialog_hue, "saturation": self.dialog_saturation, "lightness": v});
|
||||
self.documents[self.active_doc].engine.apply_filter_preview("hue_saturation", params);
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
@@ -982,7 +1036,6 @@ impl HcieIcedApp {
|
||||
self.documents[self.active_doc].engine.apply_filter(filter_id, params);
|
||||
}
|
||||
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
self.active_dialog = ActiveDialog::None;
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
@@ -1005,7 +1058,6 @@ impl HcieIcedApp {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
self.active_dialog = ActiveDialog::None;
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
@@ -1028,7 +1080,6 @@ impl HcieIcedApp {
|
||||
let ey = y0.max(y1) as u32;
|
||||
if ex > sx && ey > sy {
|
||||
self.documents[self.active_doc].engine.create_selection_rect(sx, sy, ex, ey);
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
@@ -1102,7 +1153,6 @@ impl HcieIcedApp {
|
||||
_ => {}
|
||||
}
|
||||
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
@@ -1169,7 +1219,6 @@ impl HcieIcedApp {
|
||||
log::info!("Pasted image: {}x{}", w, h);
|
||||
let engine = &mut self.documents[self.active_doc].engine;
|
||||
engine.create_layer_from_data("Pasted", pixels, w, h);
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
@@ -1225,7 +1274,6 @@ impl HcieIcedApp {
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "Untitled".to_string());
|
||||
self.documents[self.active_doc].source_path = Some(path);
|
||||
self.documents[self.active_doc].composite_dirty = true;
|
||||
self.refresh_composite_if_needed();
|
||||
return Task::perform(async {}, |_| Message::CompositeRefresh);
|
||||
}
|
||||
@@ -1274,7 +1322,7 @@ impl HcieIcedApp {
|
||||
Message::NewDocument(w, h) => {
|
||||
let mut engine = Engine::new(w, h);
|
||||
engine.pre_tile_all_layers();
|
||||
let composite_buffer = engine.get_composite_pixels();
|
||||
let composite_buffer = Bytes::from(engine.get_composite_pixels());
|
||||
let cached_layers = engine.layer_infos();
|
||||
let history_len = engine.history_len();
|
||||
let cached_history: Vec<(usize, String)> = (0..history_len)
|
||||
@@ -1284,7 +1332,6 @@ impl HcieIcedApp {
|
||||
self.documents.push(IcedDocument {
|
||||
engine,
|
||||
composite_buffer,
|
||||
composite_dirty: false,
|
||||
name: "Untitled".to_string(),
|
||||
source_path: None,
|
||||
modified: false,
|
||||
@@ -1295,10 +1342,13 @@ impl HcieIcedApp {
|
||||
history_current,
|
||||
selection_rect: None,
|
||||
vector_draw: None,
|
||||
pane_size: (800.0, 600.0),
|
||||
render_generation: 0,
|
||||
|
||||
});
|
||||
self.active_doc = self.documents.len() - 1;
|
||||
}
|
||||
|
||||
|
||||
Message::DocSwitch(idx) => {
|
||||
if idx < self.documents.len() {
|
||||
self.active_doc = idx;
|
||||
@@ -1362,56 +1412,185 @@ impl HcieIcedApp {
|
||||
self.active_menu = None;
|
||||
// Dispatch the appropriate action based on menu and item indices
|
||||
match (menu_idx, item_idx) {
|
||||
// File menu
|
||||
(0, 0) => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::NewImage)),
|
||||
(0, 1) => return Task::perform(async {}, |_| Message::OpenFileRfd),
|
||||
(0, 2) => return Task::perform(async {}, |_| Message::SaveFileAs),
|
||||
(0, 3) => return Task::perform(async {}, |_| Message::SaveFileAs),
|
||||
// Edit menu
|
||||
(1, 0) => return Task::perform(async {}, |_| Message::Undo),
|
||||
(1, 1) => return Task::perform(async {}, |_| Message::Redo),
|
||||
(1, 4) => return Task::perform(async {}, |_| Message::PasteImage),
|
||||
// Tools menu
|
||||
(2, idx) => {
|
||||
let tools = [
|
||||
Tool::Move, Tool::VectorSelect,
|
||||
Tool::Select, Tool::Lasso, Tool::PolygonSelect, Tool::MagicWand,
|
||||
Tool::Crop, Tool::Eyedropper,
|
||||
Tool::Brush, Tool::Pen, Tool::Spray, Tool::Eraser,
|
||||
Tool::FloodFill, Tool::Gradient, Tool::Text,
|
||||
Tool::VectorLine, Tool::VectorRect, Tool::VectorCircle,
|
||||
];
|
||||
if idx < tools.len() {
|
||||
self.tool_state.active_tool = tools[idx];
|
||||
// ── File menu (0) ──
|
||||
(0, 0) => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::NewImage)), // New
|
||||
(0, 1) => return Task::perform(async {}, |_| Message::OpenFileRfd), // Open
|
||||
// (0, 2) = separator
|
||||
(0, 3) => return Task::perform(async {}, |_| Message::SaveFileAs), // Save
|
||||
(0, 4) => return Task::perform(async {}, |_| Message::SaveFileAs), // Save As
|
||||
// (0, 5) = separator
|
||||
// (0, 6) = Import SVG — TODO
|
||||
// (0, 7) = Import Brushes — TODO
|
||||
// (0, 8) = separator
|
||||
(0, 9) => return Task::perform(async {}, |_| Message::SaveFileAs), // Export PNG (placeholder → Save As)
|
||||
(0, 10) => return Task::perform(async {}, |_| Message::SaveFileAs), // Export JPEG (placeholder → Save As)
|
||||
// (0, 11) = separator
|
||||
(0, 12) => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::CloseConfirm)), // Close
|
||||
// (0, 13) = Exit
|
||||
(0, 13) => {
|
||||
if let Some(id) = self.window_id {
|
||||
return iced::window::close(id);
|
||||
}
|
||||
}
|
||||
// Layer menu
|
||||
(4, 0) => return Task::perform(async {}, |_| Message::LayerAdd),
|
||||
(4, 3) => return Task::perform(async {}, |_| Message::LayerMergeDown),
|
||||
(4, 4) => return Task::perform(async {}, |_| Message::LayerFlatten),
|
||||
// Filter menu
|
||||
|
||||
// ── Edit menu (1) ──
|
||||
(1, 0) => return Task::perform(async {}, |_| Message::Undo), // Undo
|
||||
(1, 1) => return Task::perform(async {}, |_| Message::Redo), // Redo
|
||||
// (1, 2) = separator
|
||||
(1, 3) => return Task::perform(async {}, |_| Message::CopyImage), // Cut (copy for now)
|
||||
(1, 4) => return Task::perform(async {}, |_| Message::CopyImage), // Copy
|
||||
(1, 5) => return Task::perform(async {}, |_| Message::PasteImage), // Paste
|
||||
// (1, 6) = separator
|
||||
(1, 7) => { // Select All
|
||||
let w = self.documents[self.active_doc].engine.canvas_width() as f32;
|
||||
let h = self.documents[self.active_doc].engine.canvas_height() as f32;
|
||||
self.documents[self.active_doc].selection_rect = Some((0.0, 0.0, w, h));
|
||||
}
|
||||
(1, 8) => { self.documents[self.active_doc].selection_rect = None; } // Deselect
|
||||
// (1, 9) = Invert Selection — TODO
|
||||
// (1, 10) = separator
|
||||
// (1, 11) = Fill — TODO
|
||||
|
||||
// ── Tools menu (2) ──
|
||||
(2, 0) => self.tool_state.active_tool = Tool::Move,
|
||||
(2, 1) => self.tool_state.active_tool = Tool::VectorSelect,
|
||||
// (2, 2) = separator
|
||||
(2, 3) => self.tool_state.active_tool = Tool::Select,
|
||||
(2, 4) => self.tool_state.active_tool = Tool::Lasso,
|
||||
(2, 5) => self.tool_state.active_tool = Tool::PolygonSelect,
|
||||
(2, 6) => self.tool_state.active_tool = Tool::MagicWand,
|
||||
// (2, 7) = separator
|
||||
(2, 8) => self.tool_state.active_tool = Tool::Crop,
|
||||
(2, 9) => self.tool_state.active_tool = Tool::Eyedropper,
|
||||
// (2, 10) = separator
|
||||
(2, 11) => self.tool_state.active_tool = Tool::Brush,
|
||||
(2, 12) => self.tool_state.active_tool = Tool::Pen,
|
||||
(2, 13) => self.tool_state.active_tool = Tool::Spray,
|
||||
(2, 14) => self.tool_state.active_tool = Tool::Eraser,
|
||||
// (2, 15) = separator
|
||||
(2, 16) => self.tool_state.active_tool = Tool::FloodFill,
|
||||
(2, 17) => self.tool_state.active_tool = Tool::Gradient,
|
||||
// (2, 18) = separator
|
||||
(2, 19) => self.tool_state.active_tool = Tool::Text,
|
||||
// (2, 20) = separator
|
||||
(2, 21) => self.tool_state.active_tool = Tool::VectorLine,
|
||||
(2, 22) => self.tool_state.active_tool = Tool::VectorRect,
|
||||
(2, 23) => self.tool_state.active_tool = Tool::VectorCircle,
|
||||
// (2, 24) = separator
|
||||
// (2, 25) = Reset Tool — TODO
|
||||
|
||||
// ── Image menu (3) ──
|
||||
// (3, 0) = Canvas Size — TODO
|
||||
// (3, 1) = Image Size — TODO
|
||||
// (3, 2) = separator
|
||||
// (3, 5) = Rotate 180 — TODO
|
||||
// (3, 6) = separator
|
||||
// (3, 9) = separator
|
||||
// (3, 10) = Crop to Selection — TODO
|
||||
|
||||
// ── Layer menu (4) ──
|
||||
(4, 0) => return Task::perform(async {}, |_| Message::LayerAdd), // New Layer
|
||||
// (4, 1) = Duplicate Layer — TODO
|
||||
(4, 2) => { // Delete Layer
|
||||
let id = self.documents[self.active_doc].engine.active_layer_id();
|
||||
self.documents[self.active_doc].engine.delete_layer(id);
|
||||
}
|
||||
// (4, 3) = separator
|
||||
(4, 4) => return Task::perform(async {}, |_| Message::LayerMergeDown), // Merge Down
|
||||
(4, 5) => return Task::perform(async {}, |_| Message::LayerFlatten), // Flatten Image
|
||||
// (4, 6) = separator
|
||||
// (4, 7) = Clear Layer — TODO
|
||||
// (4, 8) = separator
|
||||
// (4, 9) = Layer Styles — TODO
|
||||
// (4, 10) = separator
|
||||
// (4, 11) = Align Left — TODO
|
||||
// (4, 12) = Align Center — TODO
|
||||
// (4, 13) = Align Right — TODO
|
||||
|
||||
// ── Filter menu (5) ──
|
||||
// (5, 0) = ─ Blur (disabled)
|
||||
(5, 1) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::BoxBlur)),
|
||||
(5, 2) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::GaussianBlur)),
|
||||
(5, 3) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::MotionBlur)),
|
||||
// (5, 4) = ─ Sharpen (disabled)
|
||||
(5, 5) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::UnsharpMask)),
|
||||
// (5, 6) = ─ Pixelate (disabled)
|
||||
(5, 7) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Mosaic)),
|
||||
(5, 8) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Crystallize)),
|
||||
// (5, 9) = ─ Distort (disabled)
|
||||
(5, 10) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Pinch)),
|
||||
(5, 11) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Twirl)),
|
||||
// (5, 12) = ─ Stylize (disabled)
|
||||
(5, 13) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::OilPaint)),
|
||||
// View menu
|
||||
(7, 0) => return Task::perform(async {}, |_| Message::CanvasZoom { delta: 1.0 }),
|
||||
(7, 1) => return Task::perform(async {}, |_| Message::CanvasZoom { delta: -1.0 }),
|
||||
(7, 2) => return Task::perform(async {}, |_| Message::CanvasZoomSet(1.0)),
|
||||
(7, 4) => return Task::perform(async {}, |_| Message::CanvasZoomSet(1.0)),
|
||||
// Escape closes menu
|
||||
// (5, 14) = ─ Color & Light (disabled)
|
||||
(5, 15) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Levels)),
|
||||
(5, 16) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Vibrance)),
|
||||
(5, 17) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Exposure)),
|
||||
(5, 18) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Posterize)),
|
||||
(5, 19) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Threshold)),
|
||||
// (5, 20) = ─ Restore (disabled)
|
||||
(5, 21) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Dehaze)),
|
||||
|
||||
// ── Select menu (6) ──
|
||||
(6, 0) => { // Select All
|
||||
let w = self.documents[self.active_doc].engine.canvas_width() as f32;
|
||||
let h = self.documents[self.active_doc].engine.canvas_height() as f32;
|
||||
self.documents[self.active_doc].selection_rect = Some((0.0, 0.0, w, h));
|
||||
}
|
||||
(6, 1) => { self.documents[self.active_doc].selection_rect = None; } // Deselect
|
||||
// (6, 2) = Invert Selection — TODO
|
||||
// (6, 3) = separator
|
||||
(6, 4) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Grow"); } // Grow
|
||||
(6, 5) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Shrink"); } // Shrink
|
||||
(6, 6) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Feather"); } // Feather
|
||||
|
||||
// ── View menu (7) ──
|
||||
(7, 0) => return Task::perform(async {}, |_| Message::CanvasZoomRelative(1.1)), // Zoom In
|
||||
(7, 1) => return Task::perform(async {}, |_| Message::CanvasZoomRelative(1.0 / 1.1)), // Zoom Out
|
||||
(7, 2) => return Task::perform(async {}, |_| Message::CanvasZoomSet(1.0)), // Zoom Reset
|
||||
// (7, 3) = separator
|
||||
(7, 4) => { // Fit to Window
|
||||
// Will be handled in view() with access to window size
|
||||
self.documents[self.active_doc].zoom = 1.0;
|
||||
}
|
||||
(7, 5) => return Task::perform(async {}, |_| Message::CanvasZoomSet(1.0)), // 100%
|
||||
(7, 6) => return Task::perform(async {}, |_| Message::CanvasZoomSet(2.0)), // 200%
|
||||
// (7, 7) = separator
|
||||
// (7, 8) = Debug Mode — TODO
|
||||
|
||||
// ── Help menu (8) ──
|
||||
// (8, 0) = About — TODO
|
||||
// (8, 1) = Documentation — TODO
|
||||
// (8, 2) = License — TODO
|
||||
|
||||
// Ignore separators and unimplemented items
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Toolbox ──────────────────────────────────
|
||||
Message::ToggleToolbox => {
|
||||
self.toolbox_two_column = !self.toolbox_two_column;
|
||||
// ── Window controls ──────────────────────────
|
||||
Message::WindowDrag => {
|
||||
if let Some(id) = self.window_id {
|
||||
return iced::window::drag(id);
|
||||
}
|
||||
}
|
||||
Message::WindowMinimize => {
|
||||
if let Some(id) = self.window_id {
|
||||
return iced::window::minimize(id, true);
|
||||
}
|
||||
}
|
||||
Message::WindowMaximize => {
|
||||
if let Some(id) = self.window_id {
|
||||
return iced::window::toggle_maximize(id);
|
||||
}
|
||||
}
|
||||
Message::WindowClose => {
|
||||
if let Some(id) = self.window_id {
|
||||
return iced::window::close(id);
|
||||
}
|
||||
}
|
||||
Message::WindowId(id) => {
|
||||
self.window_id = Some(id);
|
||||
}
|
||||
|
||||
Message::NoOp => {}
|
||||
@@ -1430,21 +1609,35 @@ impl HcieIcedApp {
|
||||
let doc = &self.documents[self.active_doc];
|
||||
let colors = self.theme_state.colors();
|
||||
|
||||
// Title bar
|
||||
// Title bar (now includes menu items and window controls)
|
||||
let title_bar = panels::title_bar::view(
|
||||
&doc.name,
|
||||
doc.zoom,
|
||||
doc.engine.canvas_width(),
|
||||
doc.engine.canvas_height(),
|
||||
colors,
|
||||
self.active_menu,
|
||||
);
|
||||
|
||||
// Menu bar
|
||||
let menu_bar = panels::menus::view(&self.tool_state.active_tool, self.active_menu);
|
||||
// Toolbox — fixed 36px strip on the left edge (outside dock)
|
||||
let toolbox = crate::sidebar::view(
|
||||
&self.tool_state,
|
||||
&self.fg_color,
|
||||
&self.bg_color,
|
||||
colors,
|
||||
);
|
||||
|
||||
// Dock grid (replaces hardcoded layout)
|
||||
// Dock grid (without toolbox)
|
||||
let dock_content = crate::dock::view::dock_view(&self.dock, self);
|
||||
|
||||
// Main area: toolbox + dock side by side with app background.
|
||||
// A small gap between the fixed toolbox and the dock makes the
|
||||
// workspace boundary visible (same dark bg_app color shows through).
|
||||
let main_area = iced::widget::row![toolbox, dock_content]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.spacing(2);
|
||||
|
||||
// Status bar
|
||||
let status_bar = panels::status_bar::view(
|
||||
&self.tool_state.active_tool,
|
||||
@@ -1457,15 +1650,20 @@ impl HcieIcedApp {
|
||||
|
||||
let content = column![
|
||||
title_bar,
|
||||
menu_bar,
|
||||
iced::widget::horizontal_rule(1),
|
||||
dock_content,
|
||||
iced::widget::horizontal_rule(1),
|
||||
main_area,
|
||||
status_bar,
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
let content = container(content)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_app)),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Dialog overlay
|
||||
let dialog_overlay: Element<'_, Message> = match &self.active_dialog {
|
||||
ActiveDialog::None => text("").into(),
|
||||
@@ -1493,21 +1691,23 @@ impl HcieIcedApp {
|
||||
),
|
||||
};
|
||||
|
||||
// Stack main content with dialog overlay
|
||||
let stacked = container(content)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.padding(0);
|
||||
// Stack main content with overlays (dialog + menu dropdown).
|
||||
// `content` is already wrapped in a styled container with the app
|
||||
// background, so no extra wrapper is needed.
|
||||
let has_dialog = self.active_dialog != ActiveDialog::None;
|
||||
let has_menu = self.active_menu.is_some();
|
||||
|
||||
if self.active_dialog != ActiveDialog::None {
|
||||
iced::widget::Stack::new()
|
||||
.push(stacked)
|
||||
.push(dialog_overlay)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
if has_dialog || has_menu {
|
||||
let mut stack = iced::widget::Stack::new().push(content);
|
||||
if has_dialog {
|
||||
stack = stack.push(dialog_overlay);
|
||||
}
|
||||
if let Some(menu_overlay) = panels::menus::dropdown_overlay(self.active_menu) {
|
||||
stack = stack.push(menu_overlay);
|
||||
}
|
||||
stack.width(Length::Fill).height(Length::Fill).into()
|
||||
} else {
|
||||
stacked.into()
|
||||
content.into()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,41 +1,624 @@
|
||||
//! Canvas viewport — renders the composite texture with zoom/pan.
|
||||
//!
|
||||
//! Displays the engine's RGBA composite as an Iced image widget.
|
||||
//! Mouse interaction is handled via `mouse_area` wrapper.
|
||||
//! Includes checkerboard pattern for transparency visualization.
|
||||
|
||||
pub mod render;
|
||||
//! Uses `iced::widget::Canvas` with a custom `Program` to draw the checkerboard
|
||||
//! and composite texture at the correct position/scale. This decouples rendering
|
||||
//! from layout, fixing zoom-to-cursor and coordinate mapping.
|
||||
//!
|
||||
//! ⚠️ PERFORMANCE-CRITICAL (DO NOT MODIFY):
|
||||
//! - Geometry is cached via `canvas::Cache`; only cleared when zoom/pan/selection
|
||||
//! changes, not every frame.
|
||||
//! - Composite texture is built from `Bytes` zero-copy data.
|
||||
//! - Checkerboard is drawn as a screen-space grid of 20 px squares; it is not
|
||||
//! scaled with the canvas, matching the egui behaviour.
|
||||
|
||||
use crate::app::Message;
|
||||
use iced::widget::{column, container, image, mouse_area, row, text};
|
||||
use iced::{Element, Length};
|
||||
use iced::widget::{canvas, column, container, row, text};
|
||||
use iced::widget::canvas::{Frame, Path, Stroke};
|
||||
use iced::widget::image::Handle as ImageHandle;
|
||||
use iced::{Element, Length, Point, Rectangle, Size, Vector};
|
||||
use iced::mouse::{self, Button, Cursor, Event as MouseEvent, ScrollDelta};
|
||||
use std::cell::RefCell;
|
||||
use std::hash::Hash;
|
||||
|
||||
/// Create a checkerboard pattern image for transparency visualization.
|
||||
use hcie_engine_api::{ZOOM_MAX, ZOOM_MIN};
|
||||
|
||||
/// Screen-space checkerboard square size in pixels.
|
||||
const CHECKER_SQUARE: f32 = 20.0;
|
||||
|
||||
/// Light checkerboard color (screen-space background).
|
||||
const CHECKER_LIGHT: iced::Color = iced::Color::from_rgb(0.70, 0.70, 0.70);
|
||||
/// Dark checkerboard color (screen-space background).
|
||||
const CHECKER_DARK: iced::Color = iced::Color::from_rgb(0.50, 0.50, 0.50);
|
||||
|
||||
/// Convert a pane-relative (local) point to canvas-space coordinates.
|
||||
///
|
||||
/// Returns a checkerboard texture as RGBA bytes with the given dimensions.
|
||||
/// The checker uses two shades of gray in an 8x8 pixel grid pattern.
|
||||
fn checkerboard_rgba(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut pixels = vec![0u8; (w * h * 4) as usize];
|
||||
let size = 8u32;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let idx = ((y * w + x) * 4) as usize;
|
||||
let is_light = ((x / size) + (y / size)) % 2 == 0;
|
||||
let v: u8 = if is_light { 180 } else { 140 };
|
||||
pixels[idx] = v;
|
||||
pixels[idx + 1] = v;
|
||||
pixels[idx + 2] = v;
|
||||
pixels[idx + 3] = 255;
|
||||
/// `local_pos` is relative to the pane's top-left corner (i.e. already has
|
||||
/// `bounds.x/y` subtracted). `pane_size` is `bounds.size()`. Returns
|
||||
/// `(Some(x), Some(y))` when inside the canvas image bounds, otherwise
|
||||
/// `(None, None)`.
|
||||
fn screen_to_canvas_local(
|
||||
local_pos: Point,
|
||||
pane_size: Size,
|
||||
engine_w: f32,
|
||||
engine_h: f32,
|
||||
zoom: f32,
|
||||
pan_offset: Vector,
|
||||
) -> (Option<f32>, Option<f32>) {
|
||||
let display_w = engine_w * zoom;
|
||||
let display_h = engine_h * zoom;
|
||||
let origin_x = (pane_size.width - display_w) / 2.0 + pan_offset.x;
|
||||
let origin_y = (pane_size.height - display_h) / 2.0 + pan_offset.y;
|
||||
|
||||
let canvas_x = (local_pos.x - origin_x) / zoom;
|
||||
let canvas_y = (local_pos.y - origin_y) / zoom;
|
||||
|
||||
let cx = if canvas_x >= 0.0 && canvas_x < engine_w { Some(canvas_x) } else { None };
|
||||
let cy = if canvas_y >= 0.0 && canvas_y < engine_h { Some(canvas_y) } else { None };
|
||||
(cx, cy)
|
||||
}
|
||||
|
||||
/// Helper: hash an optional canvas-space rectangle.
|
||||
fn hash_option_rect(rect: &Option<(f32, f32, f32, f32)>, h: &mut impl std::hash::Hasher) {
|
||||
if let Some((a, b, c, d)) = rect {
|
||||
a.to_bits().hash(h);
|
||||
b.to_bits().hash(h);
|
||||
c.to_bits().hash(h);
|
||||
d.to_bits().hash(h);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: hash an optional vector preview.
|
||||
fn hash_option_vec_draw(
|
||||
draw: &Option<((f32, f32), (f32, f32))>,
|
||||
h: &mut impl std::hash::Hasher,
|
||||
) {
|
||||
if let Some(((x0, y0), (x1, y1))) = draw {
|
||||
x0.to_bits().hash(h);
|
||||
y0.to_bits().hash(h);
|
||||
x1.to_bits().hash(h);
|
||||
y1.to_bits().hash(h);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached drawing state for the canvas.
|
||||
///
|
||||
/// Two caches split the work:
|
||||
/// 1. **checker_cache** — screen-space checkerboard grid. Only invalidated on
|
||||
/// zoom / pan / resize (the layout hash). During a brush stroke these
|
||||
/// parameters don't change, so the ~20 K rectangles are reused.
|
||||
/// 2. **main_cache** — composite image + selection / vector overlays.
|
||||
/// Invalidated when the engine composite buffer changes (render_generation)
|
||||
/// or when the overlay data changes. This is a cheap rebuild (one
|
||||
/// `draw_image` + a few strokes).
|
||||
///
|
||||
/// The crosshair cursor is always rebuilt as a tiny dynamic geometry.
|
||||
pub struct CanvasState {
|
||||
/// Current mouse position in viewport coordinates.
|
||||
pub cursor_pos: Option<Point>,
|
||||
/// Whether mouse is over the canvas.
|
||||
pub is_hovered: bool,
|
||||
/// Pan drag start position (viewport-space).
|
||||
pub pan_start: Option<Point>,
|
||||
/// Track left button state.
|
||||
pub left_pressed: bool,
|
||||
/// Track middle button state.
|
||||
pub middle_pressed: bool,
|
||||
/// Checkerboard geometry cache (only zoom/pan/resize invalidates it).
|
||||
checker_cache: RefCell<canvas::Cache>,
|
||||
checker_hash: RefCell<u64>,
|
||||
/// Composite + overlay geometry cache (engine updates invalidate it).
|
||||
main_cache: RefCell<canvas::Cache>,
|
||||
main_hash: RefCell<u64>,
|
||||
}
|
||||
|
||||
impl Default for CanvasState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cursor_pos: None,
|
||||
is_hovered: false,
|
||||
pan_start: None,
|
||||
left_pressed: false,
|
||||
middle_pressed: false,
|
||||
checker_cache: RefCell::new(canvas::Cache::new()),
|
||||
checker_hash: RefCell::new(0),
|
||||
main_cache: RefCell::new(canvas::Cache::new()),
|
||||
main_hash: RefCell::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Canvas program holding document-specific rendering data.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CanvasProgram {
|
||||
/// Engine canvas width in pixels.
|
||||
pub engine_w: u32,
|
||||
/// Engine canvas height in pixels.
|
||||
pub engine_h: u32,
|
||||
/// Current zoom level.
|
||||
pub zoom: f32,
|
||||
/// Pan offset in screen pixels (relative to centered position).
|
||||
pub pan_offset: Vector,
|
||||
/// Composite texture handle.
|
||||
pub composite_handle: ImageHandle,
|
||||
/// Selection rectangle in canvas-space (x0, y0, x1, y1).
|
||||
pub selection_rect: Option<(f32, f32, f32, f32)>,
|
||||
/// Vector draw preview in canvas-space ((x0, y0), (x1, y1)).
|
||||
pub vector_draw: Option<((f32, f32), (f32, f32))>,
|
||||
/// Document render generation; increments when the engine composite buffer
|
||||
/// is refreshed, used to invalidate the geometry cache.
|
||||
pub render_generation: u64,
|
||||
}
|
||||
|
||||
impl CanvasProgram {
|
||||
/// Hash of layout-only inputs (checkerboard cache key).
|
||||
/// Changes on zoom, pan, resize — NOT on composite buffer updates.
|
||||
fn layout_hash(&self, bounds: Size) -> u64 {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = DefaultHasher::new();
|
||||
self.engine_w.hash(&mut h);
|
||||
self.engine_h.hash(&mut h);
|
||||
self.zoom.to_bits().hash(&mut h);
|
||||
self.pan_offset.x.to_bits().hash(&mut h);
|
||||
self.pan_offset.y.to_bits().hash(&mut h);
|
||||
bounds.width.to_bits().hash(&mut h);
|
||||
bounds.height.to_bits().hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Hash of overlay inputs (composite cache key).
|
||||
/// Changes on composite buffer update, selection, vector.
|
||||
fn overlay_hash(&self, bounds: Size) -> u64 {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = DefaultHasher::new();
|
||||
self.render_generation.hash(&mut h);
|
||||
hash_option_rect(&self.selection_rect, &mut h);
|
||||
hash_option_vec_draw(&self.vector_draw, &mut h);
|
||||
bounds.width.to_bits().hash(&mut h);
|
||||
bounds.height.to_bits().hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Compute canvas origin in pane-relative coords, clamped so at least
|
||||
/// 25 % of the canvas stays visible.
|
||||
fn canvas_origin(&self, pane: Size) -> (f32, f32, f32, f32) {
|
||||
let engine_w = self.engine_w as f32;
|
||||
let engine_h = self.engine_h as f32;
|
||||
let display_w = engine_w * self.zoom;
|
||||
let display_h = engine_h * self.zoom;
|
||||
let raw_x = (pane.width - display_w) / 2.0 + self.pan_offset.x;
|
||||
let raw_y = (pane.height - display_h) / 2.0 + self.pan_offset.y;
|
||||
let min_vis_w = display_w * 0.25;
|
||||
let min_vis_h = display_h * 0.25;
|
||||
let ox = raw_x.clamp(-(display_w - min_vis_w).max(0.0), (pane.width - min_vis_w).max(0.0));
|
||||
let oy = raw_y.clamp(-(display_h - min_vis_h).max(0.0), (pane.height - min_vis_h).max(0.0));
|
||||
(ox, oy, display_w, display_h)
|
||||
}
|
||||
|
||||
/// Draw the screen-space checkerboard grid into `frame`.
|
||||
///
|
||||
/// Fixed 20 px squares; only the rectangles overlapping the pane are drawn.
|
||||
/// This is the expensive geometry (~20 K rects at 4K) and is cached
|
||||
/// separately so a brush stroke does NOT force a rebuild.
|
||||
fn draw_checkerboard(&self, frame: &mut Frame, bounds: Size) {
|
||||
let (origin_x, origin_y, display_w, display_h) = self.canvas_origin(bounds);
|
||||
let pane_w = bounds.width;
|
||||
let pane_h = bounds.height;
|
||||
|
||||
let vis_x0 = origin_x.max(0.0);
|
||||
let vis_y0 = origin_y.max(0.0);
|
||||
let vis_x1 = (origin_x + display_w).min(pane_w);
|
||||
let vis_y1 = (origin_y + display_h).min(pane_h);
|
||||
|
||||
if vis_x1 > vis_x0 && vis_y1 > vis_y0 {
|
||||
let start_col = (vis_x0 / CHECKER_SQUARE).floor() as i32;
|
||||
let end_col = (vis_x1 / CHECKER_SQUARE).ceil() as i32;
|
||||
let start_row = (vis_y0 / CHECKER_SQUARE).floor() as i32;
|
||||
let end_row = (vis_y1 / CHECKER_SQUARE).ceil() as i32;
|
||||
|
||||
for row in start_row..end_row {
|
||||
for col in start_col..end_col {
|
||||
let is_light = ((col + row) % 2) == 0;
|
||||
let color = if is_light { CHECKER_LIGHT } else { CHECKER_DARK };
|
||||
let px = col as f32 * CHECKER_SQUARE;
|
||||
let py = row as f32 * CHECKER_SQUARE;
|
||||
frame.fill_rectangle(
|
||||
Point::new(px, py),
|
||||
Size::new(CHECKER_SQUARE, CHECKER_SQUARE),
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the composite texture and overlay previews into `frame`.
|
||||
///
|
||||
/// This is a cheap geometry — one `draw_image` plus a few strokes.
|
||||
/// It is cached separately and only invalidated when the engine
|
||||
/// composite buffer changes.
|
||||
fn draw_composite(&self, frame: &mut Frame, bounds: Size) {
|
||||
let (origin_x, origin_y, display_w, display_h) = self.canvas_origin(bounds);
|
||||
|
||||
let canvas_rect = Rectangle::new(
|
||||
Point::new(origin_x, origin_y),
|
||||
Size::new(display_w, display_h),
|
||||
);
|
||||
|
||||
// Composite texture
|
||||
frame.draw_image(
|
||||
canvas_rect,
|
||||
canvas::Image::new(self.composite_handle.clone()),
|
||||
);
|
||||
|
||||
// Canvas border
|
||||
let border_path = Path::rectangle(canvas_rect.position(), canvas_rect.size());
|
||||
frame.stroke(&border_path, Stroke {
|
||||
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.35, 0.35, 0.35)),
|
||||
width: 1.0,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Selection rectangle preview
|
||||
if let Some((x0, y0, x1, y1)) = self.selection_rect {
|
||||
let sel_x = origin_x + x0.min(x1) * self.zoom;
|
||||
let sel_y = origin_y + y0.min(y1) * self.zoom;
|
||||
let sel_w = (x1 - x0).abs() * self.zoom;
|
||||
let sel_h = (y1 - y0).abs() * self.zoom;
|
||||
let sel_path = Path::rectangle(
|
||||
Point::new(sel_x, sel_y),
|
||||
Size::new(sel_w, sel_h),
|
||||
);
|
||||
frame.stroke(&sel_path, Stroke {
|
||||
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.0, 0.6, 1.0)),
|
||||
width: 2.0 / self.zoom.max(1.0),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
// Vector shape preview
|
||||
if let Some(((x0, y0), (x1, y1))) = self.vector_draw {
|
||||
let v_x = origin_x + x0.min(x1) * self.zoom;
|
||||
let v_y = origin_y + y0.min(y1) * self.zoom;
|
||||
let v_w = (x1 - x0).abs() * self.zoom;
|
||||
let v_h = (y1 - y0).abs() * self.zoom;
|
||||
let v_path = Path::rectangle(
|
||||
Point::new(v_x, v_y),
|
||||
Size::new(v_w, v_h),
|
||||
);
|
||||
frame.stroke(&v_path, Stroke {
|
||||
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(1.0, 0.8, 0.0)),
|
||||
width: 2.0 / self.zoom.max(1.0),
|
||||
line_dash: canvas::LineDash {
|
||||
segments: &[5.0, 5.0],
|
||||
offset: 0,
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl canvas::Program<Message> for CanvasProgram {
|
||||
type State = CanvasState;
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
state: &Self::State,
|
||||
renderer: &iced::Renderer,
|
||||
_theme: &iced::Theme,
|
||||
bounds: Rectangle,
|
||||
_cursor: Cursor,
|
||||
) -> Vec<canvas::Geometry> {
|
||||
// ── Checkerboard cache (invalidated only on zoom/pan/resize) ───────
|
||||
let l_hash = self.layout_hash(bounds.size());
|
||||
{
|
||||
let mut last = state.checker_hash.borrow_mut();
|
||||
if *last != l_hash {
|
||||
state.checker_cache.borrow_mut().clear();
|
||||
*last = l_hash;
|
||||
}
|
||||
}
|
||||
let checker_geo = state.checker_cache.borrow_mut().draw(
|
||||
renderer,
|
||||
bounds.size(),
|
||||
|frame| self.draw_checkerboard(frame, bounds.size()),
|
||||
);
|
||||
|
||||
// ── Composite + overlay cache (invalidated on buffer/selection/etc) ─
|
||||
let o_hash = self.overlay_hash(bounds.size());
|
||||
{
|
||||
let mut last = state.main_hash.borrow_mut();
|
||||
if *last != o_hash {
|
||||
state.main_cache.borrow_mut().clear();
|
||||
*last = o_hash;
|
||||
}
|
||||
}
|
||||
let main_geo = state.main_cache.borrow_mut().draw(
|
||||
renderer,
|
||||
bounds.size(),
|
||||
|frame| self.draw_composite(frame, bounds.size()),
|
||||
);
|
||||
|
||||
let mut geometries = vec![checker_geo, main_geo];
|
||||
|
||||
// ── Crosshair cursor (always rebuilt — tiny geometry) ───────────────
|
||||
if state.is_hovered {
|
||||
if let Some(cursor) = state.cursor_pos {
|
||||
let mut crosshair_frame = Frame::new(renderer, bounds.size());
|
||||
let crosshair_size = 10.0 / self.zoom.max(1.0);
|
||||
let crosshair_color = iced::Color::from_rgb(1.0, 1.0, 1.0);
|
||||
crosshair_frame.stroke(&Path::line(
|
||||
Point::new(cursor.x - crosshair_size, cursor.y),
|
||||
Point::new(cursor.x + crosshair_size, cursor.y),
|
||||
), Stroke {
|
||||
style: canvas::stroke::Style::Solid(crosshair_color),
|
||||
width: 1.0 / self.zoom.max(1.0),
|
||||
..Default::default()
|
||||
});
|
||||
crosshair_frame.stroke(&Path::line(
|
||||
Point::new(cursor.x, cursor.y - crosshair_size),
|
||||
Point::new(cursor.x, cursor.y + crosshair_size),
|
||||
), Stroke {
|
||||
style: canvas::stroke::Style::Solid(crosshair_color),
|
||||
width: 1.0 / self.zoom.max(1.0),
|
||||
..Default::default()
|
||||
});
|
||||
geometries.push(crosshair_frame.into_geometry());
|
||||
}
|
||||
}
|
||||
|
||||
geometries
|
||||
}
|
||||
|
||||
fn update(
|
||||
&self,
|
||||
state: &mut Self::State,
|
||||
event: canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: Cursor,
|
||||
) -> (canvas::event::Status, Option<Message>) {
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse_event) => {
|
||||
match mouse_event {
|
||||
MouseEvent::CursorMoved { position } => {
|
||||
// `position` is in absolute window coordinates; convert to
|
||||
// pane-relative by subtracting `bounds.x/y` (the pane's origin
|
||||
// in window space). All canvas-space math uses pane-relative
|
||||
// coordinates, matching the `draw()` path which renders inside
|
||||
// a `Frame` that starts at (0,0).
|
||||
let local_pos = Point::new(
|
||||
position.x - bounds.x,
|
||||
position.y - bounds.y,
|
||||
);
|
||||
state.cursor_pos = Some(local_pos);
|
||||
state.is_hovered = bounds.contains(position);
|
||||
|
||||
// Report pane size so app.rs has the real canvas area for
|
||||
// any pane-relative math (status bar, future zoom-to-center
|
||||
// keyboard shortcuts, etc.). This is cheap and keeps the
|
||||
// stored `pane_size` in sync with the actual layout.
|
||||
let pane_size_msg = Message::CanvasSize((bounds.width, bounds.height));
|
||||
|
||||
// Canvas origin (pane-relative): centered + pan_offset
|
||||
let (canvas_x, canvas_y) = screen_to_canvas_local(
|
||||
local_pos,
|
||||
bounds.size(),
|
||||
self.engine_w as f32,
|
||||
self.engine_h as f32,
|
||||
self.zoom,
|
||||
self.pan_offset,
|
||||
);
|
||||
|
||||
// Check for left mouse drag (drawing)
|
||||
if state.left_pressed {
|
||||
if let (Some(cx), Some(cy)) = (canvas_x, canvas_y) {
|
||||
return (canvas::event::Status::Captured, Some(Message::CanvasPointerMoved { x: cx, y: cy }));
|
||||
}
|
||||
// Still report pane size even when drawing outside canvas
|
||||
return (canvas::event::Status::Captured, Some(pane_size_msg));
|
||||
}
|
||||
|
||||
// Check for middle mouse drag (panning) - pan in window-space delta
|
||||
if state.middle_pressed && state.pan_start.is_some() {
|
||||
let start = state.pan_start.unwrap();
|
||||
let delta = position - start;
|
||||
// Reset start so subsequent deltas are incremental
|
||||
state.pan_start = Some(position);
|
||||
let mut new_pan = self.pan_offset;
|
||||
new_pan.x += delta.x;
|
||||
new_pan.y += delta.y;
|
||||
// Clamp pan so at least 25% of the canvas stays visible.
|
||||
// This matches the clamp in draw_main and prevents the
|
||||
// canvas geometry from overflowing into other panes.
|
||||
let pane_w = bounds.width;
|
||||
let pane_h = bounds.height;
|
||||
let engine_w = self.engine_w as f32;
|
||||
let engine_h = self.engine_h as f32;
|
||||
let display_w = engine_w * self.zoom;
|
||||
let display_h = engine_h * self.zoom;
|
||||
let min_visible_w = display_w * 0.25;
|
||||
let min_visible_h = display_h * 0.25;
|
||||
let default_origin_x = (pane_w - display_w) / 2.0;
|
||||
let default_origin_y = (pane_h - display_h) / 2.0;
|
||||
let raw_origin_x = default_origin_x + new_pan.x;
|
||||
let raw_origin_y = default_origin_y + new_pan.y;
|
||||
let clamped_x = raw_origin_x.clamp(
|
||||
-(display_w - min_visible_w).max(0.0),
|
||||
(pane_w - min_visible_w).max(0.0),
|
||||
);
|
||||
let clamped_y = raw_origin_y.clamp(
|
||||
-(display_h - min_visible_h).max(0.0),
|
||||
(pane_h - min_visible_h).max(0.0),
|
||||
);
|
||||
new_pan.x = clamped_x - default_origin_x;
|
||||
new_pan.y = clamped_y - default_origin_y;
|
||||
return (canvas::event::Status::Captured, Some(Message::CanvasPanZoom {
|
||||
zoom: self.zoom,
|
||||
pan_offset: new_pan,
|
||||
}));
|
||||
}
|
||||
|
||||
// Convert to canvas-space for status bar
|
||||
if let (Some(cx), Some(cy)) = (canvas_x, canvas_y) {
|
||||
return (canvas::event::Status::Captured, Some(Message::CanvasCursorPos {
|
||||
x: cx as u32,
|
||||
y: cy as u32,
|
||||
}));
|
||||
}
|
||||
|
||||
// Cursor over the dark workspace (not on canvas image):
|
||||
// still report pane size so the app stays in sync.
|
||||
return (canvas::event::Status::Captured, Some(pane_size_msg));
|
||||
}
|
||||
MouseEvent::ButtonPressed(button) => {
|
||||
if button == Button::Left && bounds.contains(cursor.position().unwrap_or(Point::ORIGIN)) {
|
||||
// Convert absolute cursor to pane-local coords
|
||||
let local_pos = {
|
||||
let p = cursor.position().unwrap_or(Point::ORIGIN);
|
||||
Point::new(p.x - bounds.x, p.y - bounds.y)
|
||||
};
|
||||
|
||||
let (cx_opt, cy_opt) = screen_to_canvas_local(
|
||||
local_pos,
|
||||
bounds.size(),
|
||||
self.engine_w as f32,
|
||||
self.engine_h as f32,
|
||||
self.zoom,
|
||||
self.pan_offset,
|
||||
);
|
||||
if let (Some(canvas_x), Some(canvas_y)) = (cx_opt, cy_opt) {
|
||||
state.left_pressed = true;
|
||||
return (canvas::event::Status::Captured, Some(Message::CanvasPointerPressed {
|
||||
x: canvas_x,
|
||||
y: canvas_y,
|
||||
}));
|
||||
}
|
||||
} else if button == Button::Middle {
|
||||
// Start pan drag - store absolute position for delta math
|
||||
if let Some(pos) = cursor.position() {
|
||||
state.middle_pressed = true;
|
||||
state.pan_start = Some(pos);
|
||||
return (canvas::event::Status::Captured, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
MouseEvent::ButtonReleased(button) => {
|
||||
if button == Button::Left {
|
||||
state.left_pressed = false;
|
||||
return (canvas::event::Status::Captured, Some(Message::CanvasPointerReleased));
|
||||
} else if button == Button::Middle {
|
||||
// End pan drag
|
||||
state.middle_pressed = false;
|
||||
state.pan_start = None;
|
||||
return (canvas::event::Status::Captured, None);
|
||||
}
|
||||
}
|
||||
MouseEvent::WheelScrolled { delta } => {
|
||||
if bounds.contains(cursor.position().unwrap_or(Point::ORIGIN)) {
|
||||
let scroll_y = match delta {
|
||||
ScrollDelta::Lines { y, .. } => y,
|
||||
ScrollDelta::Pixels { y, .. } => y / 50.0, // Normalize pixel scroll
|
||||
};
|
||||
if scroll_y != 0.0 {
|
||||
// Zoom toward cursor. The cursor position is absolute
|
||||
// (window-space); convert to pane-relative so it
|
||||
// matches the `draw()` frame coordinate system.
|
||||
let abs_cursor = cursor.position().unwrap_or(Point::ORIGIN);
|
||||
let local_cursor = Point::new(
|
||||
abs_cursor.x - bounds.x,
|
||||
abs_cursor.y - bounds.y,
|
||||
);
|
||||
|
||||
let old_zoom = self.zoom;
|
||||
let factor = if scroll_y > 0.0 { 1.1 } else { 1.0 / 1.1 };
|
||||
let new_zoom = (old_zoom * factor).clamp(ZOOM_MIN, ZOOM_MAX);
|
||||
|
||||
let pane_size = bounds.size();
|
||||
let engine_w = self.engine_w as f32;
|
||||
let engine_h = self.engine_h as f32;
|
||||
|
||||
// Old canvas origin (pane-relative)
|
||||
let old_display_w = engine_w * old_zoom;
|
||||
let old_display_h = engine_h * old_zoom;
|
||||
let old_origin_x = (pane_size.width - old_display_w) / 2.0 + self.pan_offset.x;
|
||||
let old_origin_y = (pane_size.height - old_display_h) / 2.0 + self.pan_offset.y;
|
||||
|
||||
// Canvas point under the cursor (pane-relative canvas-space)
|
||||
let canvas_x = (local_cursor.x - old_origin_x) / old_zoom;
|
||||
let canvas_y = (local_cursor.y - old_origin_y) / old_zoom;
|
||||
|
||||
// Always adjust pan so the same canvas point stays
|
||||
// under the cursor, even when the cursor is over the
|
||||
// dark workspace area.
|
||||
let new_display_w = engine_w * new_zoom;
|
||||
let new_display_h = engine_h * new_zoom;
|
||||
let new_default_origin_x = (pane_size.width - new_display_w) / 2.0;
|
||||
let new_default_origin_y = (pane_size.height - new_display_h) / 2.0;
|
||||
let desired_origin_x = local_cursor.x - canvas_x * new_zoom;
|
||||
let desired_origin_y = local_cursor.y - canvas_y * new_zoom;
|
||||
|
||||
let mut new_pan = self.pan_offset;
|
||||
new_pan.x = desired_origin_x - new_default_origin_x;
|
||||
new_pan.y = desired_origin_y - new_default_origin_y;
|
||||
|
||||
// Clamp after zoom so canvas stays within pane
|
||||
let new_min_vis_w = new_display_w * 0.25;
|
||||
let new_min_vis_h = new_display_h * 0.25;
|
||||
let raw_ox = new_default_origin_x + new_pan.x;
|
||||
let raw_oy = new_default_origin_y + new_pan.y;
|
||||
new_pan.x = raw_ox.clamp(
|
||||
-(new_display_w - new_min_vis_w).max(0.0),
|
||||
(pane_size.width - new_min_vis_w).max(0.0),
|
||||
) - new_default_origin_x;
|
||||
new_pan.y = raw_oy.clamp(
|
||||
-(new_display_h - new_min_vis_h).max(0.0),
|
||||
(pane_size.height - new_min_vis_h).max(0.0),
|
||||
) - new_default_origin_y;
|
||||
|
||||
return (canvas::event::Status::Captured, Some(Message::CanvasPanZoom {
|
||||
zoom: new_zoom,
|
||||
pan_offset: new_pan,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Check if cursor left the canvas bounds
|
||||
_ => {
|
||||
if state.is_hovered && !cursor.is_over(bounds) {
|
||||
state.is_hovered = false;
|
||||
state.cursor_pos = None;
|
||||
state.left_pressed = false;
|
||||
state.middle_pressed = false;
|
||||
state.pan_start = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
(canvas::event::Status::Ignored, None)
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
state: &Self::State,
|
||||
bounds: Rectangle,
|
||||
cursor: Cursor,
|
||||
) -> mouse::Interaction {
|
||||
if state.is_hovered && cursor.is_over(bounds) {
|
||||
mouse::Interaction::Crosshair
|
||||
} else {
|
||||
mouse::Interaction::default()
|
||||
}
|
||||
}
|
||||
pixels
|
||||
}
|
||||
|
||||
/// Build the canvas viewport element.
|
||||
///
|
||||
/// The composite texture is displayed over a checkerboard background
|
||||
/// that indicates transparency. Mouse interaction is handled via
|
||||
/// `mouse_area` wrapper for press, move, scroll, and release events.
|
||||
/// The canvas fills the available pane space and draws the composite texture
|
||||
/// at the correct position/scale. Mouse coordinates are converted to canvas-space.
|
||||
pub fn view<'a>(
|
||||
doc: &'a crate::app::IcedDocument,
|
||||
tool_state: &'a crate::app::ToolState,
|
||||
@@ -43,52 +626,27 @@ pub fn view<'a>(
|
||||
let engine_w = doc.engine.canvas_width();
|
||||
let engine_h = doc.engine.canvas_height();
|
||||
let zoom = doc.zoom;
|
||||
let pan_offset = doc.pan_offset;
|
||||
|
||||
let display_w = engine_w as f32 * zoom;
|
||||
let display_h = engine_h as f32 * zoom;
|
||||
// Composite texture handle from engine buffer (zero-copy Bytes clone).
|
||||
let composite_handle = ImageHandle::from_rgba(engine_w, engine_h, doc.composite_buffer.clone());
|
||||
|
||||
// Checkerboard background for transparency
|
||||
let checker = checkerboard_rgba(engine_w, engine_h);
|
||||
let checker_handle = image::Handle::from_rgba(engine_w, engine_h, checker);
|
||||
let checker_img = image(checker_handle)
|
||||
.width(display_w)
|
||||
.height(display_h);
|
||||
let program = CanvasProgram {
|
||||
engine_w,
|
||||
engine_h,
|
||||
zoom,
|
||||
pan_offset,
|
||||
composite_handle,
|
||||
selection_rect: doc.selection_rect,
|
||||
vector_draw: doc.vector_draw,
|
||||
render_generation: doc.render_generation,
|
||||
};
|
||||
|
||||
// Composite texture on top
|
||||
let pixels = doc.composite_buffer.clone();
|
||||
let handle = image::Handle::from_rgba(engine_w, engine_h, pixels);
|
||||
let composite_img = image(handle)
|
||||
.width(display_w)
|
||||
.height(display_h);
|
||||
let canvas = canvas(program)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
// Stack checkerboard behind composite
|
||||
let canvas_stack = iced::widget::Stack::new()
|
||||
.push(checker_img)
|
||||
.push(composite_img);
|
||||
|
||||
// Canvas container
|
||||
let canvas_inner = container(canvas_stack)
|
||||
.center_y(display_h)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgb(0.25, 0.25, 0.25))),
|
||||
border: iced::Border::default().color(iced::Color::from_rgb(0.35, 0.35, 0.35)).width(1),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Wrap with mouse_area for per-widget mouse events
|
||||
let interactive_canvas = mouse_area(canvas_inner)
|
||||
.on_press(Message::CanvasPointerPressed { x: 0.0, y: 0.0 })
|
||||
.on_move(|point| Message::CanvasPointerMoved { x: point.x, y: point.y })
|
||||
.on_release(Message::CanvasPointerReleased)
|
||||
.on_scroll(|delta| {
|
||||
match delta {
|
||||
iced::mouse::ScrollDelta::Lines { y, .. } => Message::CanvasZoom { delta: y },
|
||||
iced::mouse::ScrollDelta::Pixels { y, .. } => Message::CanvasZoom { delta: y },
|
||||
}
|
||||
})
|
||||
.interaction(iced::mouse::Interaction::Crosshair);
|
||||
|
||||
// Selection overlay info
|
||||
// Tool info overlay
|
||||
let overlay_info = if let Some((x0, y0, x1, y1)) = doc.selection_rect {
|
||||
let w = (x1 - x0).abs() as u32;
|
||||
let h = (y1 - y0).abs() as u32;
|
||||
@@ -127,12 +685,14 @@ pub fn view<'a>(
|
||||
.padding([2, 8]);
|
||||
|
||||
column![
|
||||
container(interactive_canvas)
|
||||
container(canvas)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill),
|
||||
.height(Length::Fill)
|
||||
.clip(true),
|
||||
tool_info,
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.clip(true)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Canvas viewport widget — custom iced::widget::Canvas for zoom/pan rendering.
|
||||
//!
|
||||
//! This replaces the layout-based approach with a proper drawing canvas that:
|
||||
//! - Fills the available pane space (Length::Fill)
|
||||
//! - Draws checkerboard + composite texture at calculated position/scale
|
||||
//! - Handles mouse events and converts to canvas-space coordinates
|
||||
//! - Does not affect layout regardless of zoom level
|
||||
|
||||
use crate::app::Message;
|
||||
use iced::widget::canvas::{self, Canvas, Cursor, Frame, Geometry, Path, Program, Stroke};
|
||||
use iced::widget::{container, mouse_area, stack, Column, Row};
|
||||
use iced::{Color, Element, Length, Point, Rectangle, Size, Vector};
|
||||
use image::Handle as ImageHandle;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Configuration for the canvas viewport.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CanvasViewportConfig {
|
||||
/// Engine canvas width in pixels.
|
||||
pub engine_w: u32,
|
||||
/// Engine canvas height in pixels.
|
||||
pub engine_h: u32,
|
||||
/// Current zoom level.
|
||||
pub zoom: f32,
|
||||
/// Pan offset in screen pixels (applied after centering).
|
||||
pub pan_offset: Vector,
|
||||
/// Composite texture handle (RGBA pixels from engine).
|
||||
pub composite_handle: ImageHandle,
|
||||
/// Checkerboard texture handle.
|
||||
pub checker_handle: ImageHandle,
|
||||
}
|
||||
|
||||
impl Default for CanvasViewportConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
engine_w: 800,
|
||||
engine_h: 600,
|
||||
zoom: 1.0,
|
||||
pan_offset: Vector::ZERO,
|
||||
composite_handle: ImageHandle::from_rgba(1, 1, vec![0, 0, 0, 0]),
|
||||
checker_handle: ImageHandle::from_rgba(1, 1, vec![180, 180, 180, 255]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State for the canvas viewport program.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CanvasViewportState {
|
||||
config: CanvasViewportConfig,
|
||||
/// Last known mouse position in viewport coordinates.
|
||||
last_cursor: Option<Point>,
|
||||
/// Whether mouse is currently over the viewport.
|
||||
is_hovered: bool,
|
||||
}
|
||||
|
||||
impl CanvasViewportState {
|
||||
pub fn new(config: CanvasViewportConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
last_cursor: None,
|
||||
is_hovered: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_config(&mut self, config: CanvasViewportConfig) {
|
||||
self.config = config;
|
||||
}
|
||||
|
||||
/// Convert viewport-space point to canvas-space point.
|
||||
fn viewport_to_canvas(&self, viewport_pos: Point) -> Option<(f32, f32)> {
|
||||
let cfg = &self.config;
|
||||
let display_w = cfg.engine_w as f32 * cfg.zoom;
|
||||
let display_h = cfg.engine_h as f32 * cfg.zoom;
|
||||
|
||||
// Viewport size (we need to know this - for now assume it's passed via bounds in draw)
|
||||
// We'll compute origin in draw() and store it, or compute here if we have viewport size.
|
||||
// For mouse events, we need viewport size. We'll store it from the last draw.
|
||||
None // Placeholder - will be computed in the program with bounds
|
||||
}
|
||||
}
|
||||
|
||||
/// Canvas viewport program for iced::widget::Canvas.
|
||||
pub struct CanvasViewportProgram {
|
||||
state: Arc<std::sync::Mutex<CanvasViewportState>>,
|
||||
}
|
||||
|
||||
impl CanvasViewportProgram {
|
||||
pub fn new(state: Arc<std::sync::Mutex<CanvasViewportState>>) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
}
|
||||
|
||||
impl Program<Message> for CanvasViewportProgram {
|
||||
type State = ();
|
||||
|
||||
fn draw(&self, _state: &(), bounds: Rectangle, _cursor: Cursor) -> Vec<Geometry> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let cfg = &state.config;
|
||||
|
||||
let display_w = cfg.engine_w as f32 * cfg.zoom;
|
||||
let display_h = cfg.engine_h as f32 * cfg.zoom;
|
||||
|
||||
// Canvas origin in viewport coordinates (centered + pan_offset)
|
||||
let origin_x = (bounds.width - display_w) / 2.0 + cfg.pan_offset.x;
|
||||
let origin_y = (bounds.height - display_h) / 2.0 + cfg.pan_offset.y;
|
||||
|
||||
// Store viewport bounds for coordinate conversion
|
||||
state.last_cursor = None; // Will be updated on mouse move
|
||||
|
||||
let mut frame = Frame::new(bounds.size());
|
||||
|
||||
// Draw checkerboard background (tiled to cover display area)
|
||||
let checker_pattern = canvas::Pattern::new(&cfg.checker_handle).mode(canvas::PatternMode::Repeat);
|
||||
let checker_path = Path::rectangle(Point::new(origin_x, origin_y), Size::new(display_w, display_h));
|
||||
frame.fill(&checker_path, checker_pattern);
|
||||
|
||||
// Draw composite texture
|
||||
let composite_image = canvas::Image::new(&cfg.composite_handle)
|
||||
.destination_rectangle(Rectangle::new(Point::new(origin_x, origin_y), Size::new(display_w, display_h)));
|
||||
frame.draw_image(composite_image);
|
||||
|
||||
// Draw canvas border
|
||||
let border_path = Path::rectangle(Point::new(origin_x, origin_y), Size::new(display_w, display_h));
|
||||
frame.stroke(&border_path, Stroke::default().with_color(Color::from_rgb(0.35, 0.35, 0.35)).with_width(1.0));
|
||||
|
||||
vec![frame.into_geometry()]
|
||||
}
|
||||
|
||||
fn update(&self, state: &mut (), event: iced::Event, bounds: Rectangle, cursor: Cursor) -> (iced::widget::canvas::Status, Option<Message>) {
|
||||
let mut program_state = self.state.lock().unwrap();
|
||||
|
||||
match event {
|
||||
iced::Event::Mouse(mouse_event) => {
|
||||
match mouse_event {
|
||||
iced::mouse::Event::CursorMoved { position } => {
|
||||
program_state.last_cursor = Some(position);
|
||||
program_state.is_hovered = bounds.contains(position);
|
||||
(iced::widget::canvas::Status::Captured, None)
|
||||
}
|
||||
iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left) => {
|
||||
if let Some(pos) = program_state.last_cursor {
|
||||
if bounds.contains(pos) {
|
||||
let (cx, cy) = viewport_to_canvas(pos, bounds, &program_state.config);
|
||||
if let Some((cx, cy)) = (cx, cy) {
|
||||
return (iced::widget::canvas::Status::Captured, Some(Message::CanvasPointerPressed { x: cx, y: cy }));
|
||||
}
|
||||
}
|
||||
}
|
||||
(iced::widget::canvas::Status::Captured, None)
|
||||
}
|
||||
iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left) => {
|
||||
(iced::widget::canvas::Status::Captured, Some(Message::CanvasPointerReleased))
|
||||
}
|
||||
iced::mouse::Event::Scrolled { delta } => {
|
||||
if program_state.is_hovered {
|
||||
let scroll_delta = match delta {
|
||||
iced::mouse::ScrollDelta::Lines { y, .. } => y,
|
||||
iced::mouse::ScrollDelta::Pixels { y, .. } => y / 100.0, // Normalize pixel scroll
|
||||
};
|
||||
if scroll_delta != 0.0 {
|
||||
// Calculate zoom toward cursor
|
||||
let old_zoom = program_state.config.zoom;
|
||||
let factor = if scroll_delta > 0.0 { 1.1 } else { 1.0 / 1.1 };
|
||||
let new_zoom = (old_zoom * factor).clamp(0.1, 10.0);
|
||||
|
||||
if let Some(cursor_pos) = program_state.last_cursor {
|
||||
let (canvas_x, canvas_y) = viewport_to_canvas(cursor_pos, bounds, &program_state.config).unwrap_or((0.0, 0.0));
|
||||
|
||||
// Calculate new pan_offset to keep cursor over same canvas point
|
||||
let display_w = program_state.config.engine_w as f32 * new_zoom;
|
||||
let display_h = program_state.config.engine_h as f32 * new_zoom;
|
||||
let new_origin_x = cursor_pos.x - canvas_x * new_zoom;
|
||||
let new_origin_y = cursor_pos.y - canvas_y * new_zoom;
|
||||
let new_default_origin_x = (bounds.width - display_w) / 2.0;
|
||||
let new_default_origin_y = (bounds.height - display_h) / 2.0;
|
||||
|
||||
program_state.config.pan_offset.x = new_origin_x - new_default_origin_x;
|
||||
program_state.config.pan_offset.y = new_origin_y - new_default_origin_y;
|
||||
}
|
||||
program_state.config.zoom = new_zoom;
|
||||
return (iced::widget::canvas::Status::Captured, Some(Message::CanvasZoom { delta: scroll_delta }));
|
||||
}
|
||||
}
|
||||
(iced::widget::canvas::Status::Ignored, None)
|
||||
}
|
||||
_ => (iced::widget::canvas::Status::Ignored, None),
|
||||
}
|
||||
}
|
||||
_ => (iced::widget::canvas::Status::Ignored, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_interaction(&self, _state: &(), bounds: Rectangle, cursor: Cursor) -> iced::mouse::Interaction {
|
||||
if cursor.is_over(bounds) {
|
||||
iced::mouse::Interaction::Crosshair
|
||||
} else {
|
||||
iced::mouse::Interaction::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert viewport-space coordinates to canvas-space coordinates.
|
||||
fn viewport_to_canvas(viewport_pos: Point, bounds: Rectangle, config: &CanvasViewportConfig) -> Option<(f32, f32)> {
|
||||
let display_w = config.engine_w as f32 * config.zoom;
|
||||
let display_h = config.engine_h as f32 * config.zoom;
|
||||
|
||||
let origin_x = (bounds.width - display_w) / 2.0 + config.pan_offset.x;
|
||||
let origin_y = (bounds.height - display_h) / 2.0 + config.pan_offset.y;
|
||||
|
||||
let canvas_x = (viewport_pos.x - origin_x) / config.zoom;
|
||||
let canvas_y = (viewport_pos.y - origin_y) / config.zoom;
|
||||
|
||||
if canvas_x >= 0.0 && canvas_x < config.engine_w as f32
|
||||
&& canvas_y >= 0.0 && canvas_y < config.engine_h as f32
|
||||
{
|
||||
Some((canvas_x, canvas_y))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the canvas viewport widget.
|
||||
pub fn canvas_viewport<'a>(
|
||||
state: Arc<std::sync::Mutex<CanvasViewportState>>,
|
||||
) -> Element<'a, Message> {
|
||||
let canvas = Canvas::new(CanvasViewportProgram::new(state.clone()))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
// Wrap in mouse_area for additional event handling if needed
|
||||
// The Canvas program handles most events, but we can add a container for styling
|
||||
container(canvas)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(Color::from_rgb(0.15, 0.15, 0.15))),
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
@@ -88,15 +88,24 @@ pub fn view(
|
||||
.padding(16)
|
||||
.width(400);
|
||||
|
||||
// Center the dialog on screen
|
||||
// Center the dialog on screen with a semi-transparent backdrop
|
||||
// and a solid panel background for the dialog content itself.
|
||||
let backdrop = container(
|
||||
container(dialog)
|
||||
.padding(1)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgb(0.176, 0.176, 0.176))),
|
||||
border: iced::Border::default().color(iced::Color::from_rgb(0.267, 0.267, 0.267)).width(1),
|
||||
..Default::default()
|
||||
})
|
||||
)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.5))),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.6))),
|
||||
..Default::default()
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
.height(Length::Fill);
|
||||
backdrop.into()
|
||||
}
|
||||
|
||||
@@ -8,16 +8,17 @@
|
||||
//! +--------+------------+-----------+---------+
|
||||
//! | | Brushes | | Layers |
|
||||
//! | Tools | & Tips | |---------|
|
||||
//! | (36px) | | Canvas | History |
|
||||
//! | |------------| |---------|
|
||||
//! | | Filters | | Color |
|
||||
//! +--------+-----+------+-----------+---------+
|
||||
//! | (36px) |------------| Canvas | Properties
|
||||
//! | | Filters | |---------|
|
||||
//! | | | ColorBox | History |
|
||||
//! +--------+------------+-----------+---------+
|
||||
//! ```
|
||||
|
||||
use iced::widget::pane_grid::{Configuration, Pane, State};
|
||||
|
||||
/// Identifies which panel content a pane displays.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum PaneType {
|
||||
/// The main canvas viewport.
|
||||
Canvas,
|
||||
@@ -39,8 +40,6 @@ pub enum PaneType {
|
||||
AiChat,
|
||||
/// Layer Styles panel.
|
||||
LayerStyles,
|
||||
/// Toolbox (vertical tool strip).
|
||||
Tools,
|
||||
/// Layer Details panel.
|
||||
LayerDetails,
|
||||
/// Geometry panel (vector shapes).
|
||||
@@ -61,7 +60,6 @@ impl PaneType {
|
||||
PaneType::Script => "Script",
|
||||
PaneType::AiChat => "AI Assistant",
|
||||
PaneType::LayerStyles => "Layer Styles",
|
||||
PaneType::Tools => "Tools",
|
||||
PaneType::LayerDetails => "Layer Details",
|
||||
PaneType::Geometry => "Geometry",
|
||||
}
|
||||
@@ -73,61 +71,80 @@ pub struct DockState {
|
||||
/// The underlying iced PaneGrid state.
|
||||
pub pane_grid: State<PaneType>,
|
||||
/// The root pane (always the canvas).
|
||||
#[allow(dead_code)]
|
||||
pub root_pane: Pane,
|
||||
/// Currently focused pane.
|
||||
pub focused_pane: Option<Pane>,
|
||||
}
|
||||
|
||||
impl DockState {
|
||||
/// Create a default dock layout with Canvas centered.
|
||||
/// Create a default dock layout mirroring the egui GUI arrangement.
|
||||
///
|
||||
/// Uses Configuration::Split to build a proper 3-column layout:
|
||||
/// - Left column: Tools strip + (Brushes / Filters)
|
||||
/// - Center: Canvas (fills available space)
|
||||
/// - Right column: (Layers / History) + ColorPicker
|
||||
/// The toolbox is NOT in the dock — it's a fixed 36px strip on the left
|
||||
/// edge, outside the pane grid.
|
||||
///
|
||||
/// Dock layout (without toolbox), left → right:
|
||||
/// ```text
|
||||
/// +------------+-----------+-----------+-----------+
|
||||
/// | Brushes | | ColorBox | Layers |
|
||||
/// |------------| Canvas |-----------|-----------|
|
||||
/// | Filters | | History | Properties|
|
||||
/// +------------+-----------+-----------+-----------+
|
||||
/// ~17% ~66% ~8.5% ~8.5%
|
||||
/// ```
|
||||
///
|
||||
/// Axis semantics in iced PaneGrid:
|
||||
/// - `Horizontal` → `a` is top, `b` is bottom; `ratio` is `a`'s share.
|
||||
/// - `Vertical` → `a` is left, `b` is right; `ratio` is `a`'s share.
|
||||
pub fn new() -> Self {
|
||||
// Build the layout using Configuration for proper centering:
|
||||
//
|
||||
// Root = HorizontalSplit(0.20, left_column, HorizontalSplit(0.70, canvas, right_column))
|
||||
//
|
||||
// Left column = VerticalSplit(0.5, Tools, VerticalSplit(0.5, Brushes, Filters))
|
||||
// Right column = VerticalSplit(0.6, VerticalSplit(0.6, Layers, History), ColorPicker)
|
||||
|
||||
// Left column: Brushes (top) / Filters (bottom) — horizontal split
|
||||
let left_column = Configuration::Split {
|
||||
axis: iced::widget::pane_grid::Axis::Horizontal,
|
||||
ratio: 0.25,
|
||||
a: Box::new(Configuration::Pane(PaneType::Tools)),
|
||||
b: Box::new(Configuration::Split {
|
||||
axis: iced::widget::pane_grid::Axis::Horizontal,
|
||||
ratio: 0.5,
|
||||
a: Box::new(Configuration::Pane(PaneType::Brushes)),
|
||||
b: Box::new(Configuration::Pane(PaneType::Filters)),
|
||||
}),
|
||||
};
|
||||
|
||||
let right_column = Configuration::Split {
|
||||
// Right column 1: ColorBox (top) / History (bottom)
|
||||
let right_col1 = Configuration::Split {
|
||||
axis: iced::widget::pane_grid::Axis::Horizontal,
|
||||
ratio: 0.6,
|
||||
a: Box::new(Configuration::Split {
|
||||
ratio: 0.4,
|
||||
a: Box::new(Configuration::Pane(PaneType::ColorPicker)),
|
||||
b: Box::new(Configuration::Pane(PaneType::History)),
|
||||
};
|
||||
|
||||
// Right column 2: Layers (top) / Properties (bottom)
|
||||
let right_col2 = Configuration::Split {
|
||||
axis: iced::widget::pane_grid::Axis::Horizontal,
|
||||
ratio: 0.5,
|
||||
a: Box::new(Configuration::Pane(PaneType::Layers)),
|
||||
b: Box::new(Configuration::Pane(PaneType::History)),
|
||||
}),
|
||||
b: Box::new(Configuration::Pane(PaneType::ColorPicker)),
|
||||
b: Box::new(Configuration::Pane(PaneType::Properties)),
|
||||
};
|
||||
|
||||
// Root: 3-column layout with Canvas in center
|
||||
// Right side: right_col1 (left) / right_col2 (right) — vertical split
|
||||
let right_side = Configuration::Split {
|
||||
axis: iced::widget::pane_grid::Axis::Vertical,
|
||||
ratio: 0.5,
|
||||
a: Box::new(right_col1),
|
||||
b: Box::new(right_col2),
|
||||
};
|
||||
|
||||
// Center + right: Canvas (left) / right_side (right) — vertical split
|
||||
// Canvas gets ~80% of the remaining space after left column.
|
||||
let center_right = Configuration::Split {
|
||||
axis: iced::widget::pane_grid::Axis::Vertical,
|
||||
ratio: 0.80,
|
||||
a: Box::new(Configuration::Pane(PaneType::Canvas)),
|
||||
b: Box::new(right_side),
|
||||
};
|
||||
|
||||
// Root: left_column (left) / center_right (right) — vertical split
|
||||
// Left column gets ~17% of total width (matches egui default).
|
||||
let root_config = Configuration::Split {
|
||||
axis: iced::widget::pane_grid::Axis::Vertical,
|
||||
ratio: 0.20,
|
||||
ratio: 0.17,
|
||||
a: Box::new(left_column),
|
||||
b: Box::new(Configuration::Split {
|
||||
axis: iced::widget::pane_grid::Axis::Vertical,
|
||||
ratio: 0.70,
|
||||
a: Box::new(Configuration::Pane(PaneType::Canvas)),
|
||||
b: Box::new(right_column),
|
||||
}),
|
||||
b: Box::new(center_right),
|
||||
};
|
||||
|
||||
let pane_grid = State::with_configuration(root_config);
|
||||
@@ -146,6 +163,7 @@ impl DockState {
|
||||
}
|
||||
|
||||
/// Split a pane horizontally with a new panel type.
|
||||
#[allow(dead_code)]
|
||||
pub fn split_horizontal(&mut self, target: Pane, pane_type: PaneType) -> Option<Pane> {
|
||||
let (new_pane, _) = self.pane_grid.split(
|
||||
iced::widget::pane_grid::Axis::Horizontal,
|
||||
@@ -156,6 +174,7 @@ impl DockState {
|
||||
}
|
||||
|
||||
/// Split a pane vertically with a new panel type.
|
||||
#[allow(dead_code)]
|
||||
pub fn split_vertical(&mut self, target: Pane, pane_type: PaneType) -> Option<Pane> {
|
||||
let (new_pane, _) = self.pane_grid.split(
|
||||
iced::widget::pane_grid::Axis::Vertical,
|
||||
@@ -190,6 +209,7 @@ impl DockState {
|
||||
}
|
||||
|
||||
/// Get the currently focused pane type.
|
||||
#[allow(dead_code)]
|
||||
pub fn focused_type(&self) -> Option<PaneType> {
|
||||
self.focused_pane.and_then(|p| self.pane_type(p))
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
use crate::app::Message;
|
||||
use crate::dock::state::{DockState, PaneType};
|
||||
use crate::panels::styles;
|
||||
use iced::widget::svg;
|
||||
use crate::theme::ThemeColors;
|
||||
use iced::widget::pane_grid::{Content, TitleBar};
|
||||
use iced::widget::{button, column, container, pane_grid, row, text};
|
||||
@@ -25,11 +26,17 @@ pub fn dock_view<'a>(
|
||||
let colors = app.theme_state.colors();
|
||||
|
||||
pane_grid::PaneGrid::new(&dock.pane_grid, |pane, pane_type, _is_maximized| {
|
||||
let title = make_title_bar(pane, *pane_type, colors);
|
||||
// Canvas pane has no title bar (the document tab bar serves as its header).
|
||||
// All other panes get a Photoshop-style title bar with close/maximize buttons.
|
||||
let title: Option<TitleBar<'a, Message>> = if *pane_type == PaneType::Canvas {
|
||||
None
|
||||
} else {
|
||||
Some(make_title_bar(pane, *pane_type, colors))
|
||||
};
|
||||
|
||||
let body: Element<'a, Message> = match pane_type {
|
||||
PaneType::Canvas => {
|
||||
// Canvas pane includes document tab bar
|
||||
// Canvas pane includes document tab bar as its header
|
||||
let doc_tab_bar = document_tab_bar(app, colors);
|
||||
let canvas = {
|
||||
let doc = &app.documents[app.active_doc];
|
||||
@@ -40,9 +47,7 @@ pub fn dock_view<'a>(
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
PaneType::Tools => {
|
||||
crate::sidebar::view(&app.tool_state, &app.fg_color, &app.bg_color, colors, app.toolbox_two_column)
|
||||
}
|
||||
// Tools is no longer in the dock — toolbox is a fixed strip on the left
|
||||
PaneType::Layers => {
|
||||
let doc = &app.documents[app.active_doc];
|
||||
crate::panels::layers::view(&doc.cached_layers, doc.engine.active_layer_id(), colors)
|
||||
@@ -53,6 +58,7 @@ pub fn dock_view<'a>(
|
||||
}
|
||||
PaneType::Brushes => {
|
||||
crate::panels::brushes::view(
|
||||
app.tool_state.brush_style,
|
||||
app.tool_state.brush_size,
|
||||
app.tool_state.brush_opacity,
|
||||
app.tool_state.brush_hardness,
|
||||
@@ -94,7 +100,21 @@ pub fn dock_view<'a>(
|
||||
}
|
||||
};
|
||||
|
||||
Content::new(body).title_bar(title)
|
||||
let mut content = Content::new(body);
|
||||
|
||||
// Apply per-pane background style. Canvas pane gets a dark workspace
|
||||
// background (no border); all other panes get panel background + border
|
||||
// so they are visually separated from neighbors.
|
||||
if *pane_type == PaneType::Canvas {
|
||||
content = content.style(move |_theme| styles::canvas_pane_background(colors));
|
||||
} else {
|
||||
content = content.style(move |_theme| styles::pane_background(colors));
|
||||
}
|
||||
|
||||
if let Some(title_bar) = title {
|
||||
content = content.title_bar(title_bar);
|
||||
}
|
||||
content
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
@@ -102,6 +122,22 @@ pub fn dock_view<'a>(
|
||||
.on_click(Message::PaneClicked)
|
||||
.on_drag(Message::PaneDragged)
|
||||
.on_resize(4, Message::PaneResized)
|
||||
.style(move |_theme| pane_grid::Style {
|
||||
hovered_region: pane_grid::Highlight {
|
||||
background: iced::Background::Color(iced::Color::from_rgba(
|
||||
colors.accent.r, colors.accent.g, colors.accent.b, 0.15,
|
||||
)),
|
||||
border: iced::Border::default().color(colors.accent).width(1),
|
||||
},
|
||||
picked_split: pane_grid::Line {
|
||||
color: colors.accent,
|
||||
width: 2.0,
|
||||
},
|
||||
hovered_split: pane_grid::Line {
|
||||
color: colors.border_high,
|
||||
width: 1.0,
|
||||
},
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -109,7 +145,7 @@ pub fn dock_view<'a>(
|
||||
///
|
||||
/// Shows a horizontal tab for each open document. The active document tab
|
||||
/// is highlighted. Each tab shows the document name and a close button.
|
||||
/// Modified documents show an asterisk (*).
|
||||
/// Modified documents show an asterisk (*). Tabs have hover feedback.
|
||||
fn document_tab_bar<'a>(app: &'a crate::app::HcieIcedApp, colors: ThemeColors) -> Element<'a, Message> {
|
||||
let mut tabs = row![].spacing(0);
|
||||
|
||||
@@ -124,39 +160,110 @@ fn document_tab_bar<'a>(app: &'a crate::app::HcieIcedApp, colors: ThemeColors) -
|
||||
color: Some(colors.accent),
|
||||
})
|
||||
} else {
|
||||
tab_text
|
||||
tab_text.style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(colors.text_primary),
|
||||
})
|
||||
};
|
||||
|
||||
let tab_style = if is_active {
|
||||
styles::active_tab_background(colors)
|
||||
} else {
|
||||
styles::inactive_tab_background(colors)
|
||||
};
|
||||
let active = is_active;
|
||||
let c = colors;
|
||||
|
||||
let tab = container(
|
||||
let tab = button(
|
||||
row![
|
||||
tab_text,
|
||||
button(text("×").size(10))
|
||||
.on_press(Message::NoOp)
|
||||
.padding([0, 4]),
|
||||
.padding([0, 4])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.bg_active)),
|
||||
text_color: c.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
text_color: c.text_secondary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center)
|
||||
)
|
||||
.on_press(Message::DocSwitch(idx))
|
||||
.padding([4, 8])
|
||||
.style(move |_theme| tab_style.clone());
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Active => {
|
||||
if active {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.bg_active)),
|
||||
border: iced::Border::default().color(c.accent).width(1),
|
||||
text_color: c.accent,
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.bg_panel)),
|
||||
border: iced::Border::default().color(c.border_low).width(1),
|
||||
text_color: c.text_primary,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
iced::widget::button::Status::Hovered => {
|
||||
if active {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.bg_active)),
|
||||
border: iced::Border::default().color(c.accent).width(1),
|
||||
text_color: c.accent,
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.bg_hover)),
|
||||
border: iced::Border::default().color(c.border_high).width(1),
|
||||
text_color: c.text_primary,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.bg_active)),
|
||||
border: iced::Border::default().color(c.accent).width(1),
|
||||
text_color: c.accent,
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
let doc_idx = idx;
|
||||
let clickable = iced::widget::mouse_area(tab)
|
||||
.on_press(Message::DocSwitch(doc_idx));
|
||||
|
||||
tabs = tabs.push(clickable);
|
||||
tabs = tabs.push(tab);
|
||||
}
|
||||
|
||||
// Add new document button
|
||||
// Add new document button with hover
|
||||
let new_btn = button(text("+").size(12))
|
||||
.on_press(Message::DialogOpen(crate::app::ActiveDialog::NewImage))
|
||||
.padding([4, 8]);
|
||||
.padding([4, 8])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_hover)),
|
||||
text_color: colors.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
text_color: colors.text_secondary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
tabs = tabs.push(new_btn);
|
||||
|
||||
@@ -166,39 +273,114 @@ fn document_tab_bar<'a>(app: &'a crate::app::HcieIcedApp, colors: ThemeColors) -
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Map pane type to its SVG icon path.
|
||||
fn pane_type_icon(pane_type: PaneType) -> Option<&'static str> {
|
||||
match pane_type {
|
||||
PaneType::Canvas => None,
|
||||
PaneType::Layers => Some("icons/panel-layers.svg"),
|
||||
PaneType::History => Some("icons/panel-history.svg"),
|
||||
PaneType::Brushes => Some("icons/panel-brush.svg"),
|
||||
PaneType::Filters => Some("icons/panel-filters.svg"),
|
||||
PaneType::ColorPicker => Some("icons/panel-colorbox.svg"),
|
||||
PaneType::Properties => Some("icons/panel-settings.svg"),
|
||||
PaneType::Script => Some("icons/panel-pen.svg"),
|
||||
PaneType::AiChat => Some("icons/panel-ai.svg"),
|
||||
PaneType::LayerStyles => Some("icons/glow.svg"),
|
||||
PaneType::LayerDetails => Some("icons/panel-layers.svg"),
|
||||
PaneType::Geometry => Some("icons/vector_rect.svg"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a Photoshop-style title bar for a pane.
|
||||
///
|
||||
/// Shows the pane type label with close and maximize buttons.
|
||||
/// No horizontal rule, no blue tint - clean modern look.
|
||||
/// Shows a panel type icon + label with close and maximize buttons.
|
||||
/// Uses `panel_header` style from the theme for consistent header appearance.
|
||||
/// Buttons have hover feedback: transparent by default, bg_hover on hover.
|
||||
fn make_title_bar<'a>(pane: pane_grid::Pane, pane_type: PaneType, colors: ThemeColors) -> TitleBar<'a, Message> {
|
||||
let title = text(pane_type.label())
|
||||
.size(11)
|
||||
.color(colors.text_secondary);
|
||||
// Panel type icon
|
||||
let icon_paths = [
|
||||
std::path::PathBuf::from("hcie-iced-app/assets"),
|
||||
std::path::PathBuf::from("/mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-iced-app/assets"),
|
||||
];
|
||||
|
||||
// Subtle close/maximize buttons
|
||||
let close_btn = button(text("×").size(11).color(colors.text_secondary))
|
||||
let title_content: Element<'_, Message> = if let Some(icon_file) = pane_type_icon(pane_type) {
|
||||
if let Some(base) = icon_paths.iter().find(|p| p.join(icon_file).exists()) {
|
||||
let handle = svg::Handle::from_path(base.join(icon_file));
|
||||
let icon = svg(handle)
|
||||
.width(14)
|
||||
.height(14)
|
||||
.style(move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style {
|
||||
color: Some(colors.text_secondary),
|
||||
});
|
||||
row![icon, text(pane_type.label()).size(11).color(colors.text_secondary)]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center)
|
||||
.into()
|
||||
} else {
|
||||
text(pane_type.label()).size(11).color(colors.text_secondary).into()
|
||||
}
|
||||
} else {
|
||||
text(pane_type.label()).size(11).color(colors.text_secondary).into()
|
||||
};
|
||||
|
||||
// Close button with hover state
|
||||
let close_btn = button(text("×").size(11))
|
||||
.on_press(Message::PaneClose(pane))
|
||||
.padding([2, 6])
|
||||
.style(move |_theme, _status| iced::widget::button::Style {
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_hover)),
|
||||
text_color: colors.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
iced::widget::button::Status::Pressed => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_active)),
|
||||
text_color: colors.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
text_color: colors.text_secondary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
let maximize_btn = button(text("□").size(11).color(colors.text_secondary))
|
||||
// Maximize button with hover state
|
||||
let maximize_btn = button(text("□").size(11))
|
||||
.on_press(Message::PaneMaximize(pane))
|
||||
.padding([2, 6])
|
||||
.style(move |_theme, _status| iced::widget::button::Style {
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_hover)),
|
||||
text_color: colors.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
iced::widget::button::Status::Pressed => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_active)),
|
||||
text_color: colors.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
text_color: colors.text_secondary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
TitleBar::new(
|
||||
row![title, iced::widget::Space::with_width(Length::Fill), maximize_btn, close_btn]
|
||||
row![title_content, iced::widget::Space::with_width(Length::Fill), maximize_btn, close_btn]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center)
|
||||
)
|
||||
.style(move |_theme| styles::panel_header(colors))
|
||||
}
|
||||
|
||||
@@ -167,6 +167,7 @@ fn try_x11_clipboard_image() -> Option<(Vec<u8>, u32, u32)> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn decode_image_to_rgba(bytes: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
|
||||
#[allow(unused_imports)]
|
||||
use image::GenericImageView;
|
||||
|
||||
let img = image::load_from_memory(bytes).ok()?;
|
||||
|
||||
@@ -12,6 +12,7 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Source of the most recent pressure value.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum TabletSource {
|
||||
NoTablet,
|
||||
Winit,
|
||||
@@ -57,21 +58,25 @@ impl TabletState {
|
||||
}
|
||||
|
||||
/// Get the last known screen position.
|
||||
#[allow(dead_code)]
|
||||
pub fn current_pos(&self) -> Option<(f32, f32)> {
|
||||
self.pos
|
||||
}
|
||||
|
||||
/// Whether the stylus is in proximity.
|
||||
#[allow(dead_code)]
|
||||
pub fn in_proximity(&self) -> bool {
|
||||
self.in_proximity
|
||||
}
|
||||
|
||||
/// Get the pressure source.
|
||||
#[allow(dead_code)]
|
||||
pub fn source(&self) -> TabletSource {
|
||||
self.source
|
||||
}
|
||||
|
||||
/// Update pressure from a touch event.
|
||||
#[allow(dead_code)]
|
||||
pub fn set_touch_pressure(&mut self, pressure: f32) {
|
||||
self.pressure = pressure.clamp(0.0, 1.0);
|
||||
self.source = TabletSource::Touch;
|
||||
@@ -84,6 +89,7 @@ impl TabletState {
|
||||
}
|
||||
|
||||
/// Set the screen-space stylus position.
|
||||
#[allow(dead_code)]
|
||||
pub fn set_screen_pos(&mut self, x: f32, y: f32) {
|
||||
self.pos = Some((x, y));
|
||||
self.in_proximity = true;
|
||||
@@ -98,11 +104,13 @@ impl TabletState {
|
||||
}
|
||||
|
||||
/// Update the cached screen size for evdev position mapping.
|
||||
#[allow(dead_code)]
|
||||
pub fn set_screen_size(&mut self, w: f32, h: f32) {
|
||||
self.screen_size = Some((w, h));
|
||||
}
|
||||
|
||||
/// Reset pressure to default (for mouse input).
|
||||
#[allow(dead_code)]
|
||||
pub fn reset_pressure(&mut self) {
|
||||
self.pressure = 1.0;
|
||||
self.source = TabletSource::NoTablet;
|
||||
|
||||
@@ -59,9 +59,10 @@ fn main() {
|
||||
.subscription(HcieIcedApp::subscription)
|
||||
.theme(HcieIcedApp::theme)
|
||||
.window_size((1280.0, 800.0))
|
||||
.decorations(false)
|
||||
.run_with(move || {
|
||||
let mut app = HcieIcedApp::new(load_path);
|
||||
let (mut app, init_task) = HcieIcedApp::new(load_path);
|
||||
app.tablet_state = tablet_state;
|
||||
(app, iced::Task::none())
|
||||
(app, init_task)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use iced::{Element, Length};
|
||||
/// - Selecting an AI provider (Ollama, Claude, OpenAI)
|
||||
/// - Generating a script from the prompt
|
||||
/// - Viewing and running the generated script
|
||||
#[allow(dead_code)]
|
||||
pub fn view() -> Element<'static, Message> {
|
||||
let provider_label = text("Provider").size(10);
|
||||
let provider_selector = row![
|
||||
|
||||
@@ -1,71 +1,301 @@
|
||||
//! Brushes panel — brush preset browser with categories and style selection.
|
||||
//! Uses ThemeColors for consistent styling.
|
||||
//! Brushes panel — brush preset browser with visual thumbnails and live preview.
|
||||
//!
|
||||
//! Matches the egui version's layout:
|
||||
//! - Category tabs (All, Drawing, Painting, Effects)
|
||||
//! - Grid of brush tip thumbnails with visual stroke preview
|
||||
//! - Selected brush highlighted with accent border
|
||||
//! - Size/Opacity/Hardness sliders at bottom
|
||||
//!
|
||||
//! ⚠️ PERFORMANCE: Brush preview images are cached via OnceLock.
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::panels::styles;
|
||||
use crate::theme::ThemeColors;
|
||||
use hcie_engine_api::BrushStyle;
|
||||
use iced::widget::{column, container, horizontal_rule, scrollable, slider, text};
|
||||
use iced::widget::{button, column, container, horizontal_rule, row, scrollable, slider, text};
|
||||
use iced::{Element, Length};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// A brush preset entry.
|
||||
/// A brush preset entry with category.
|
||||
#[allow(dead_code)]
|
||||
struct BrushPreset {
|
||||
name: &'static str,
|
||||
style: BrushStyle,
|
||||
category: BrushCategory,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
enum BrushCategory {
|
||||
All,
|
||||
Drawing,
|
||||
Painting,
|
||||
Effects,
|
||||
}
|
||||
|
||||
const BRUSH_PRESETS: &[BrushPreset] = &[
|
||||
BrushPreset { name: "Round Soft", style: BrushStyle::SoftRound },
|
||||
BrushPreset { name: "Round Hard", style: BrushStyle::HardRound },
|
||||
BrushPreset { name: "Square", style: BrushStyle::Square },
|
||||
BrushPreset { name: "Pencil", style: BrushStyle::Pencil },
|
||||
BrushPreset { name: "Pen", style: BrushStyle::Pen },
|
||||
BrushPreset { name: "Ink Pen", style: BrushStyle::InkPen },
|
||||
BrushPreset { name: "Calligraphy", style: BrushStyle::Calligraphy },
|
||||
BrushPreset { name: "Marker", style: BrushStyle::Marker },
|
||||
BrushPreset { name: "Sketch", style: BrushStyle::Sketch },
|
||||
BrushPreset { name: "Hatch", style: BrushStyle::Hatch },
|
||||
BrushPreset { name: "Oil Paint", style: BrushStyle::Oil },
|
||||
BrushPreset { name: "Charcoal", style: BrushStyle::Charcoal },
|
||||
BrushPreset { name: "Watercolor", style: BrushStyle::Watercolor },
|
||||
BrushPreset { name: "Crayon", style: BrushStyle::Crayon },
|
||||
BrushPreset { name: "Airbrush", style: BrushStyle::Airbrush },
|
||||
BrushPreset { name: "Spray", style: BrushStyle::Spray },
|
||||
BrushPreset { name: "Star", style: BrushStyle::Star },
|
||||
BrushPreset { name: "Noise", style: BrushStyle::Noise },
|
||||
BrushPreset { name: "Texture", style: BrushStyle::Texture },
|
||||
BrushPreset { name: "Glow", style: BrushStyle::Glow },
|
||||
BrushPreset { name: "Leaf", style: BrushStyle::Leaf },
|
||||
BrushPreset { name: "Clouds", style: BrushStyle::Clouds },
|
||||
BrushPreset { name: "Default", style: BrushStyle::Default },
|
||||
// Drawing
|
||||
BrushPreset { name: "Default", style: BrushStyle::Default, category: BrushCategory::Drawing },
|
||||
BrushPreset { name: "Pencil", style: BrushStyle::Pencil, category: BrushCategory::Drawing },
|
||||
BrushPreset { name: "Pen", style: BrushStyle::Pen, category: BrushCategory::Drawing },
|
||||
BrushPreset { name: "Ink Pen", style: BrushStyle::InkPen, category: BrushCategory::Drawing },
|
||||
BrushPreset { name: "Calligraphy", style: BrushStyle::Calligraphy, category: BrushCategory::Drawing },
|
||||
BrushPreset { name: "Sketch", style: BrushStyle::Sketch, category: BrushCategory::Drawing },
|
||||
BrushPreset { name: "Marker", style: BrushStyle::Marker, category: BrushCategory::Drawing },
|
||||
BrushPreset { name: "Hatch", style: BrushStyle::Hatch, category: BrushCategory::Drawing },
|
||||
// Painting
|
||||
BrushPreset { name: "Oil", style: BrushStyle::Oil, category: BrushCategory::Painting },
|
||||
BrushPreset { name: "Charcoal", style: BrushStyle::Charcoal, category: BrushCategory::Painting },
|
||||
BrushPreset { name: "Watercolor", style: BrushStyle::Watercolor, category: BrushCategory::Painting },
|
||||
BrushPreset { name: "Crayon", style: BrushStyle::Crayon, category: BrushCategory::Painting },
|
||||
BrushPreset { name: "Airbrush", style: BrushStyle::Airbrush, category: BrushCategory::Painting },
|
||||
BrushPreset { name: "Spray", style: BrushStyle::Spray, category: BrushCategory::Painting },
|
||||
BrushPreset { name: "Soft Round", style: BrushStyle::SoftRound, category: BrushCategory::Painting },
|
||||
BrushPreset { name: "Hard Round", style: BrushStyle::HardRound, category: BrushCategory::Painting },
|
||||
// Effects
|
||||
BrushPreset { name: "Star", style: BrushStyle::Star, category: BrushCategory::Effects },
|
||||
BrushPreset { name: "Noise", style: BrushStyle::Noise, category: BrushCategory::Effects },
|
||||
BrushPreset { name: "Texture", style: BrushStyle::Texture, category: BrushCategory::Effects },
|
||||
BrushPreset { name: "Glow", style: BrushStyle::Glow, category: BrushCategory::Effects },
|
||||
BrushPreset { name: "Leaf", style: BrushStyle::Leaf, category: BrushCategory::Effects },
|
||||
BrushPreset { name: "Clouds", style: BrushStyle::Clouds, category: BrushCategory::Effects },
|
||||
];
|
||||
|
||||
/// Build the brushes panel.
|
||||
/// Cached brush preview images: (style_index, pixels).
|
||||
static BRUSH_PREVIEW_CACHE: OnceLock<Vec<(usize, Vec<u8>)>> = OnceLock::new();
|
||||
|
||||
/// Thumbnail size in pixels.
|
||||
const THUMB_SIZE: u32 = 64;
|
||||
|
||||
/// Generate a brush stroke preview as RGBA pixels.
|
||||
///
|
||||
/// Draws a sine-wave stroke with varying thickness to show the brush characteristics.
|
||||
fn generate_brush_preview(style: BrushStyle) -> Vec<u8> {
|
||||
let w = THUMB_SIZE;
|
||||
let h = THUMB_SIZE;
|
||||
let mut pixels = vec![0u8; (w * h * 4) as usize];
|
||||
|
||||
let n_points = 40;
|
||||
let mut points = Vec::with_capacity(n_points + 1);
|
||||
for i in 0..=n_points {
|
||||
let t = i as f32 / n_points as f32;
|
||||
let x = 4.0 + t * (w as f32 - 8.0);
|
||||
let angle = t * std::f32::consts::TAU * 1.25;
|
||||
let sine = angle.sin();
|
||||
let taper = (t * (1.0 - t) * 4.0).powf(1.1);
|
||||
let y = h as f32 / 2.0 + sine * (h as f32 * 0.22) * taper;
|
||||
points.push((x, y, t));
|
||||
}
|
||||
|
||||
// Color based on style
|
||||
let color: [u8; 3] = match style {
|
||||
BrushStyle::Pencil | BrushStyle::Sketch => [180, 180, 180],
|
||||
BrushStyle::Pen | BrushStyle::InkPen => [40, 40, 40],
|
||||
BrushStyle::Calligraphy => [60, 40, 30],
|
||||
BrushStyle::Oil => [180, 120, 60],
|
||||
BrushStyle::Charcoal => [80, 80, 80],
|
||||
BrushStyle::Watercolor => [100, 150, 200],
|
||||
BrushStyle::Crayon => [200, 100, 100],
|
||||
BrushStyle::Airbrush | BrushStyle::Spray => [120, 120, 120],
|
||||
BrushStyle::Marker => [60, 120, 60],
|
||||
BrushStyle::Glow => [200, 200, 100],
|
||||
BrushStyle::Leaf => [80, 160, 60],
|
||||
_ => [200, 200, 200],
|
||||
};
|
||||
|
||||
// Draw stroke with varying thickness
|
||||
for i in 0..n_points {
|
||||
let (x1, y1, t1) = points[i];
|
||||
let (x2, y2, _) = points[i + 1];
|
||||
|
||||
let thickness = match style {
|
||||
BrushStyle::Pencil | BrushStyle::Sketch => 1.0 + 2.0 * (t1 * std::f32::consts::PI).sin().abs(),
|
||||
BrushStyle::Pen | BrushStyle::InkPen => 2.0 + 3.0 * (t1 * std::f32::consts::PI).sin().abs(),
|
||||
BrushStyle::Calligraphy => 1.0 + 5.0 * ((t1 * 3.0).sin().abs()),
|
||||
BrushStyle::Oil | BrushStyle::Charcoal => 3.0 + 6.0 * (t1 * std::f32::consts::PI).sin().abs(),
|
||||
BrushStyle::Watercolor => 2.0 + 4.0 * (t1 * std::f32::consts::PI).sin().abs(),
|
||||
BrushStyle::Airbrush | BrushStyle::Spray => 4.0 + 8.0 * (t1 * std::f32::consts::PI).sin().abs(),
|
||||
BrushStyle::Glow => 3.0 + 7.0 * (t1 * std::f32::consts::PI).sin().abs(),
|
||||
_ => 2.0 + 5.0 * (t1 * std::f32::consts::PI).sin().abs(),
|
||||
};
|
||||
|
||||
// Draw line segment with anti-aliasing
|
||||
let steps = ((thickness * 2.0) as u32).max(1);
|
||||
for s in 0..steps {
|
||||
let frac = s as f32 / steps as f32;
|
||||
let px = x1 + (x2 - x1) * frac;
|
||||
let py = y1 + (y2 - y1) * frac;
|
||||
let r = thickness / 2.0;
|
||||
|
||||
// Fill circle at (px, py) with radius r
|
||||
let ix0 = (px - r).max(0.0) as u32;
|
||||
let iy0 = (py - r).max(0.0) as u32;
|
||||
let ix1 = (px + r).min(w as f32 - 1.0) as u32;
|
||||
let iy1 = (py + r).min(h as f32 - 1.0) as u32;
|
||||
|
||||
for iy in iy0..=iy1 {
|
||||
for ix in ix0..=ix1 {
|
||||
let dx = ix as f32 - px;
|
||||
let dy = iy as f32 - py;
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
if dist <= r {
|
||||
let alpha = ((1.0 - dist / r) * 255.0) as u8;
|
||||
let idx = ((iy * w + ix) * 4) as usize;
|
||||
// Alpha blend
|
||||
let src_a = alpha as f32 / 255.0;
|
||||
let dst_a = pixels[idx + 3] as f32 / 255.0;
|
||||
let out_a = src_a + dst_a * (1.0 - src_a);
|
||||
if out_a > 0.0 {
|
||||
pixels[idx] = ((color[0] as f32 * src_a + pixels[idx] as f32 * dst_a * (1.0 - src_a)) / out_a) as u8;
|
||||
pixels[idx + 1] = ((color[1] as f32 * src_a + pixels[idx + 1] as f32 * dst_a * (1.0 - src_a)) / out_a) as u8;
|
||||
pixels[idx + 2] = ((color[2] as f32 * src_a + pixels[idx + 2] as f32 * dst_a * (1.0 - src_a)) / out_a) as u8;
|
||||
pixels[idx + 3] = (out_a * 255.0) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pixels
|
||||
}
|
||||
|
||||
/// Get or generate brush preview for a style.
|
||||
fn get_brush_preview(style: BrushStyle) -> iced::widget::image::Handle {
|
||||
let style_idx = style as usize;
|
||||
|
||||
// Check cache
|
||||
if let Some(cache) = BRUSH_PREVIEW_CACHE.get() {
|
||||
if let Some((_, pixels)) = cache.iter().find(|(idx, _)| *idx == style_idx) {
|
||||
return iced::widget::image::Handle::from_rgba(THUMB_SIZE, THUMB_SIZE, pixels.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Generate preview
|
||||
let pixels = generate_brush_preview(style);
|
||||
|
||||
// Store in cache (ignore error if already set)
|
||||
if let Some(cache) = BRUSH_PREVIEW_CACHE.get() {
|
||||
let mut new_cache = cache.clone();
|
||||
new_cache.push((style_idx, pixels.clone()));
|
||||
// Can't replace OnceLock, so we just use the local copy
|
||||
return iced::widget::image::Handle::from_rgba(THUMB_SIZE, THUMB_SIZE, pixels);
|
||||
}
|
||||
|
||||
iced::widget::image::Handle::from_rgba(THUMB_SIZE, THUMB_SIZE, pixels)
|
||||
}
|
||||
|
||||
/// Build the brushes panel with visual thumbnails.
|
||||
pub fn view(
|
||||
current_style: BrushStyle,
|
||||
current_size: f32,
|
||||
current_opacity: f32,
|
||||
current_hardness: f32,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'static, Message> {
|
||||
let mut preset_list = column![].spacing(1);
|
||||
// Category tabs
|
||||
let categories = [
|
||||
(BrushCategory::All, "All"),
|
||||
(BrushCategory::Drawing, "Drawing"),
|
||||
(BrushCategory::Painting, "Painting"),
|
||||
(BrushCategory::Effects, "Effects"),
|
||||
];
|
||||
|
||||
for preset in BRUSH_PRESETS {
|
||||
let name = preset.name;
|
||||
let style = preset.style;
|
||||
|
||||
let item = container(
|
||||
text(name).size(11)
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding([4, 6])
|
||||
.style(move |_theme| styles::inactive_item_bg(colors));
|
||||
|
||||
let clickable = iced::widget::mouse_area(item)
|
||||
.on_press(Message::BrushStyleSelected(style));
|
||||
|
||||
preset_list = preset_list.push(clickable);
|
||||
let mut tabs = row![].spacing(2);
|
||||
for (_cat, label) in categories {
|
||||
let btn = button(text(label).size(10))
|
||||
.on_press(Message::NoOp) // TODO: category filter
|
||||
.padding([4, 8])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_hover)),
|
||||
text_color: colors.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
text_color: colors.text_secondary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
tabs = tabs.push(btn);
|
||||
}
|
||||
|
||||
// Brush preset grid — 3 columns
|
||||
let mut grid_rows = column![].spacing(4);
|
||||
let mut current_row = row![].spacing(4);
|
||||
|
||||
for (idx, preset) in BRUSH_PRESETS.iter().enumerate() {
|
||||
let is_selected = preset.style == current_style;
|
||||
let preview = get_brush_preview(preset.style);
|
||||
let c = colors;
|
||||
let style = preset.style;
|
||||
|
||||
let thumb = container(
|
||||
iced::widget::image(preview)
|
||||
.width(48)
|
||||
.height(48)
|
||||
)
|
||||
.width(56)
|
||||
.height(56)
|
||||
.center_x(48)
|
||||
.center_y(48)
|
||||
.style(move |_theme| {
|
||||
if is_selected {
|
||||
iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(c.bg_active)),
|
||||
border: iced::Border::default().color(c.accent).width(2),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(c.bg_panel)),
|
||||
border: iced::Border::default().color(c.border_low).width(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let label = container(text(preset.name).size(9))
|
||||
.width(Length::Fill)
|
||||
.center_x(Length::Fill);
|
||||
|
||||
let cell = column![thumb, label].spacing(2).align_x(iced::Alignment::Center);
|
||||
|
||||
let clickable = button(cell)
|
||||
.on_press(Message::BrushStyleSelected(style))
|
||||
.padding(2)
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.bg_hover)),
|
||||
text_color: c.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
text_color: c.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
current_row = current_row.push(clickable);
|
||||
|
||||
// Wrap row every 3 items
|
||||
if (idx + 1) % 3 == 0 || idx == BRUSH_PRESETS.len() - 1 {
|
||||
grid_rows = grid_rows.push(current_row);
|
||||
current_row = row![].spacing(4);
|
||||
}
|
||||
}
|
||||
|
||||
// Sliders
|
||||
let size_slider = slider(1.0..=200.0, current_size, |v| Message::BrushSizeChanged(v))
|
||||
.step(1.0)
|
||||
.width(Length::Fill);
|
||||
@@ -78,11 +308,31 @@ pub fn view(
|
||||
.step(0.01)
|
||||
.width(Length::Fill);
|
||||
|
||||
// Live preview — show current brush tip
|
||||
let preview_handle = get_brush_preview(current_style);
|
||||
let live_preview = container(
|
||||
iced::widget::image(preview_handle)
|
||||
.width(64)
|
||||
.height(64)
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(80)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_active)),
|
||||
border: iced::Border::default().color(colors.accent).width(1),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let panel = column![
|
||||
text("Brushes").size(13).font(iced::Font::MONOSPACE),
|
||||
text("Brushes & Tips").size(13).font(iced::Font::MONOSPACE),
|
||||
tabs,
|
||||
horizontal_rule(1),
|
||||
scrollable(preset_list).height(Length::Fill),
|
||||
scrollable(grid_rows).height(Length::Fill),
|
||||
horizontal_rule(1),
|
||||
text("Preview").size(10),
|
||||
live_preview,
|
||||
text(format!("Size: {:.0}", current_size)).size(10),
|
||||
size_slider,
|
||||
text(format!("Opacity: {:.0}%", current_opacity * 100.0)).size(10),
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
//! Menu bar — top-level menus with dropdown support.
|
||||
//! Menu dropdown overlay — renders menu items as a floating dropdown.
|
||||
//!
|
||||
//! Renders 9 menus (File, Edit, Tools, Image, Layer, Filter, Select, View, Help)
|
||||
//! with functional dropdown menus that open on click and close on item selection.
|
||||
//! The menu bar itself is part of the unified title bar (title_bar.rs).
|
||||
//! This module provides the dropdown overlay that renders on top of content
|
||||
//! when a menu is active. Click outside or press Escape to close.
|
||||
|
||||
use crate::app::Message;
|
||||
use hcie_engine_api::Tool;
|
||||
use iced::widget::{button, column, container, horizontal_rule, row, text};
|
||||
use iced::widget::{column, container, horizontal_rule, mouse_area, row, text};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Menu item definition.
|
||||
#[allow(dead_code)]
|
||||
struct MenuItem {
|
||||
label: &'static str,
|
||||
shortcut: &'static str,
|
||||
@@ -35,6 +36,7 @@ impl MenuItem {
|
||||
|
||||
/// All menu definitions.
|
||||
struct MenuDef {
|
||||
#[allow(dead_code)]
|
||||
label: &'static str,
|
||||
items: Vec<MenuItem>,
|
||||
}
|
||||
@@ -216,55 +218,15 @@ fn all_menus() -> Vec<MenuDef> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Build the menu bar element with optional dropdown.
|
||||
/// Build a dropdown menu overlay for the given menu index.
|
||||
///
|
||||
/// `active_menu`: index of the currently open menu (None = no dropdown).
|
||||
pub fn view<'a>(_active_tool: &Tool, active_menu: Option<usize>) -> Element<'a, Message> {
|
||||
/// Returns `None` if `active_menu` is `None`. The returned element is meant
|
||||
/// to be placed in a `Stack` overlay on top of all other content.
|
||||
pub fn dropdown_overlay<'a>(active_menu: Option<usize>) -> Option<Element<'a, Message>> {
|
||||
let menu_idx = active_menu?;
|
||||
let menus = all_menus();
|
||||
let mut bar_items = row![].spacing(0);
|
||||
let menu = menus.get(menu_idx)?;
|
||||
|
||||
for (idx, menu) in menus.iter().enumerate() {
|
||||
let is_open = active_menu == Some(idx);
|
||||
|
||||
let menu_btn = button(
|
||||
text(menu.label).size(12)
|
||||
)
|
||||
.on_press(Message::MenuOpen(idx))
|
||||
.padding([6, 12]);
|
||||
|
||||
let menu_btn = if is_open {
|
||||
menu_btn.style(iced::widget::button::primary)
|
||||
} else {
|
||||
menu_btn.style(iced::widget::button::secondary)
|
||||
};
|
||||
|
||||
bar_items = bar_items.push(menu_btn);
|
||||
}
|
||||
|
||||
let bar = container(bar_items)
|
||||
.width(Length::Fill)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgb(0.22, 0.22, 0.22))),
|
||||
border: iced::Border::default().color(iced::Color::from_rgb(0.3, 0.3, 0.3)).width(1),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// If a menu is open, render the dropdown below
|
||||
if let Some(menu_idx) = active_menu {
|
||||
if menu_idx < menus.len() {
|
||||
let dropdown = render_dropdown(menu_idx, &menus[menu_idx]);
|
||||
|
||||
return column![bar, dropdown]
|
||||
.width(Length::Fill)
|
||||
.into();
|
||||
}
|
||||
}
|
||||
|
||||
bar.into()
|
||||
}
|
||||
|
||||
/// Render a dropdown menu for the given menu index.
|
||||
fn render_dropdown<'a>(menu_idx: usize, menu: &MenuDef) -> Element<'a, Message> {
|
||||
let mut items = column![].spacing(0);
|
||||
|
||||
for (item_idx, item) in menu.items.iter().enumerate() {
|
||||
@@ -273,7 +235,7 @@ fn render_dropdown<'a>(menu_idx: usize, menu: &MenuDef) -> Element<'a, Message>
|
||||
items = items.push(
|
||||
container(horizontal_rule(1))
|
||||
.width(Length::Fill)
|
||||
.padding([2, 0])
|
||||
.padding([2, 8])
|
||||
);
|
||||
} else {
|
||||
let label_row: Element<'_, Message> = if item.shortcut.is_empty() {
|
||||
@@ -287,33 +249,73 @@ fn render_dropdown<'a>(menu_idx: usize, menu: &MenuDef) -> Element<'a, Message>
|
||||
.into()
|
||||
};
|
||||
|
||||
let item_container = container(label_row)
|
||||
.width(Length::Fill)
|
||||
.padding([4, 16]);
|
||||
|
||||
let item_container = if item.enabled {
|
||||
let enabled = item.enabled;
|
||||
let m_idx = menu_idx;
|
||||
let i_idx = item_idx;
|
||||
iced::widget::mouse_area(item_container)
|
||||
|
||||
if enabled {
|
||||
let btn = iced::widget::button(
|
||||
container(label_row).width(Length::Fill).padding([4, 16])
|
||||
)
|
||||
.on_press(Message::MenuAction(m_idx, i_idx))
|
||||
.interaction(iced::mouse::Interaction::Pointer)
|
||||
} else {
|
||||
iced::widget::mouse_area(item_container)
|
||||
.interaction(iced::mouse::Interaction::NotAllowed)
|
||||
};
|
||||
|
||||
items = items.push(item_container);
|
||||
}
|
||||
}
|
||||
|
||||
container(items)
|
||||
.width(220)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgb(0.22, 0.22, 0.22))),
|
||||
border: iced::Border::default()
|
||||
.color(iced::Color::from_rgb(0.3, 0.3, 0.3))
|
||||
.width(1),
|
||||
.padding(0)
|
||||
.style(|_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgb(0.227, 0.227, 0.227))),
|
||||
text_color: iced::Color::from_rgb(0.8, 0.8, 0.8),
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
text_color: iced::Color::from_rgb(0.8, 0.8, 0.8),
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
items = items.push(btn);
|
||||
} else {
|
||||
let disabled_item = container(label_row)
|
||||
.width(Length::Fill)
|
||||
.padding([4, 16])
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
..Default::default()
|
||||
});
|
||||
items = items.push(disabled_item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dropdown = container(items)
|
||||
.width(220)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgb(0.176, 0.176, 0.176))),
|
||||
border: iced::Border::default()
|
||||
.color(iced::Color::from_rgb(0.267, 0.267, 0.267))
|
||||
.width(1),
|
||||
shadow: iced::Shadow {
|
||||
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.5),
|
||||
offset: iced::Vector::new(2.0, 4.0),
|
||||
blur_radius: 8.0,
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Wrap in a mouse_area that closes the menu when clicking outside
|
||||
// The padding pushes the dropdown down below the title bar
|
||||
let overlay = mouse_area(
|
||||
container(dropdown)
|
||||
.padding(iced::Padding {
|
||||
top: 28.0,
|
||||
bottom: 0.0,
|
||||
left: 0.0,
|
||||
right: 0.0,
|
||||
})
|
||||
)
|
||||
.on_press(Message::MenuClose);
|
||||
|
||||
Some(overlay.into())
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ pub fn active_item_bg(colors: ThemeColors) -> iced::widget::container::Style {
|
||||
}
|
||||
|
||||
/// Inactive item — transparent
|
||||
pub fn inactive_item_bg(colors: ThemeColors) -> iced::widget::container::Style {
|
||||
#[allow(dead_code, unused_variables)]
|
||||
pub fn inactive_item_bg(_colors: ThemeColors) -> iced::widget::container::Style {
|
||||
iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(
|
||||
iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0)
|
||||
@@ -43,6 +44,7 @@ pub fn inactive_item_bg(colors: ThemeColors) -> iced::widget::container::Style {
|
||||
}
|
||||
|
||||
/// Hover item
|
||||
#[allow(dead_code)]
|
||||
pub fn hover_item_bg(colors: ThemeColors) -> iced::widget::container::Style {
|
||||
iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_hover)),
|
||||
@@ -52,6 +54,7 @@ pub fn hover_item_bg(colors: ThemeColors) -> iced::widget::container::Style {
|
||||
}
|
||||
|
||||
/// Menu bar background
|
||||
#[allow(dead_code)]
|
||||
pub fn menubar_background(colors: ThemeColors) -> iced::widget::container::Style {
|
||||
iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_app)),
|
||||
@@ -61,6 +64,7 @@ pub fn menubar_background(colors: ThemeColors) -> iced::widget::container::Style
|
||||
}
|
||||
|
||||
/// Title bar background
|
||||
#[allow(dead_code)]
|
||||
pub fn titlebar_background(colors: ThemeColors) -> iced::widget::container::Style {
|
||||
iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_app)),
|
||||
@@ -88,6 +92,7 @@ pub fn sidebar_background(colors: ThemeColors) -> iced::widget::container::Style
|
||||
}
|
||||
|
||||
/// Dropdown menu background
|
||||
#[allow(dead_code)]
|
||||
pub fn dropdown_background(colors: ThemeColors) -> iced::widget::container::Style {
|
||||
iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_panel)),
|
||||
@@ -105,7 +110,28 @@ pub fn tabbar_background(colors: ThemeColors) -> iced::widget::container::Style
|
||||
}
|
||||
}
|
||||
|
||||
/// Dock pane background — panel color with a visible border so panes are
|
||||
/// clearly separated from neighbors and from the dark workspace.
|
||||
pub fn pane_background(colors: ThemeColors) -> iced::widget::container::Style {
|
||||
iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_panel)),
|
||||
border: iced::Border::default().color(colors.border_high).width(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Canvas pane background — dark workspace color (darker than panel bg)
|
||||
/// with no border, since the canvas draws its own border.
|
||||
pub fn canvas_pane_background(colors: ThemeColors) -> iced::widget::container::Style {
|
||||
iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_app)),
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Active document tab
|
||||
#[allow(dead_code)]
|
||||
pub fn active_tab_background(colors: ThemeColors) -> iced::widget::container::Style {
|
||||
iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_active)),
|
||||
@@ -115,6 +141,7 @@ pub fn active_tab_background(colors: ThemeColors) -> iced::widget::container::St
|
||||
}
|
||||
|
||||
/// Inactive document tab
|
||||
#[allow(dead_code)]
|
||||
pub fn inactive_tab_background(colors: ThemeColors) -> iced::widget::container::Style {
|
||||
iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_panel)),
|
||||
|
||||
@@ -5,6 +5,7 @@ use iced::widget::{button, column, container, horizontal_rule, row, slider, text
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Build the text editor panel.
|
||||
#[allow(dead_code)]
|
||||
pub fn view(
|
||||
font_size: f32,
|
||||
font_color: [u8; 4],
|
||||
|
||||
@@ -1,63 +1,180 @@
|
||||
//! Title bar — custom title bar with app icon, version, document name, and window controls.
|
||||
//! Uses ThemeColors for consistent styling.
|
||||
//! Title bar — Photoshop-style unified bar with menus, doc info, and window controls.
|
||||
//!
|
||||
//! Combines the menu bar and title bar into a single 28px bar:
|
||||
//! [HCIE logo] [menu items...] [doc info] [min/max/close]
|
||||
//! The entire bar is draggable for window movement.
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::panels::styles;
|
||||
use crate::theme::ThemeColors;
|
||||
use iced::widget::{button, container, row, text};
|
||||
use iced::widget::{button, container, mouse_area, row, text};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Build the title bar element.
|
||||
/// All menu definitions for the menu bar.
|
||||
const MENU_LABELS: &[&str] = &["File", "Edit", "Tools", "Image", "Layer", "Filter", "Select", "View", "Help"];
|
||||
|
||||
/// Build the unified title/menu bar element.
|
||||
///
|
||||
/// Layout: [HCIE] [File Edit Tools ... Help] [doc info] [─ □ ×]
|
||||
/// The left portion (before window controls) is draggable.
|
||||
pub fn view<'a>(
|
||||
doc_name: &'a str,
|
||||
zoom: f32,
|
||||
canvas_w: u32,
|
||||
canvas_h: u32,
|
||||
colors: ThemeColors,
|
||||
active_menu: Option<usize>,
|
||||
) -> Element<'a, Message> {
|
||||
// ── App name (fixed width) ──
|
||||
let app_name = text("HCIE")
|
||||
.size(14)
|
||||
.size(13)
|
||||
.font(iced::Font::MONOSPACE)
|
||||
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(colors.accent),
|
||||
});
|
||||
|
||||
let version = text("v3.0")
|
||||
// ── Menu items with hover states ──
|
||||
let mut menu_items = row![].spacing(0);
|
||||
for (idx, &label) in MENU_LABELS.iter().enumerate() {
|
||||
let is_open = active_menu == Some(idx);
|
||||
let c = colors;
|
||||
let btn = button(text(label).size(11))
|
||||
.on_press(Message::MenuOpen(idx))
|
||||
.padding([4, 8])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
if is_open {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.bg_hover)),
|
||||
text_color: c.text_primary,
|
||||
border: iced::Border::default().color(c.border_high).width(1),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.bg_hover)),
|
||||
text_color: c.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
text_color: c.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
menu_items = menu_items.push(btn);
|
||||
}
|
||||
|
||||
// ── Document info ──
|
||||
let doc_info = text(format!("{} — {}×{} @ {:.0}%", doc_name, canvas_w, canvas_h, zoom * 100.0))
|
||||
.size(10)
|
||||
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(colors.text_secondary),
|
||||
});
|
||||
|
||||
let doc_info = text(format!(
|
||||
"{} — {}×{} @ {:.0}%",
|
||||
doc_name, canvas_w, canvas_h, zoom * 100.0
|
||||
))
|
||||
.size(12);
|
||||
// ── Window controls (right side) with hover states ──
|
||||
let c = colors;
|
||||
|
||||
let minimize_btn = button(text("─").size(10)).on_press(Message::NoOp).padding([4, 8]);
|
||||
let maximize_btn = button(text("□").size(10)).on_press(Message::NoOp).padding([4, 8]);
|
||||
let close_btn = button(text("×").size(10))
|
||||
.on_press(Message::DialogOpen(crate::app::ActiveDialog::CloseConfirm))
|
||||
.padding([4, 8]);
|
||||
let minimize_btn = button(text("─").size(10))
|
||||
.on_press(Message::WindowMinimize)
|
||||
.padding([4, 8])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.bg_hover)),
|
||||
text_color: c.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
text_color: c.text_secondary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
let controls = row![minimize_btn, maximize_btn, close_btn].spacing(2);
|
||||
let maximize_btn = button(text("□").size(10))
|
||||
.on_press(Message::WindowMaximize)
|
||||
.padding([4, 8])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.bg_hover)),
|
||||
text_color: c.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
text_color: c.text_secondary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
let bar = row![
|
||||
let close_btn = button(text("×").size(11))
|
||||
.on_press(Message::WindowClose)
|
||||
.padding([4, 10])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.danger)),
|
||||
text_color: iced::Color::WHITE,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
text_color: c.text_secondary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
let window_controls = row![minimize_btn, maximize_btn, close_btn].spacing(0);
|
||||
|
||||
// ── Left side (draggable): app name + menus + doc info ──
|
||||
let left_side = row![
|
||||
app_name,
|
||||
version,
|
||||
text(" ").size(12),
|
||||
text(" ").size(11),
|
||||
menu_items,
|
||||
iced::widget::Space::with_width(Length::Fill),
|
||||
doc_info,
|
||||
iced::widget::horizontal_rule(1),
|
||||
controls,
|
||||
]
|
||||
.spacing(4)
|
||||
.spacing(0)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
let draggable_left = mouse_area(
|
||||
container(left_side)
|
||||
.width(Length::Fill)
|
||||
.height(28)
|
||||
.align_y(iced::Alignment::Center)
|
||||
.padding([0, 8]);
|
||||
.padding([0, 8])
|
||||
)
|
||||
.on_press(Message::WindowDrag)
|
||||
.interaction(iced::mouse::Interaction::Grab);
|
||||
|
||||
// ── Full bar ──
|
||||
let bar = row![
|
||||
draggable_left,
|
||||
window_controls,
|
||||
]
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
container(bar)
|
||||
.width(Length::Fill)
|
||||
.height(28)
|
||||
.align_y(iced::Alignment::Center)
|
||||
.style(move |_theme| styles::titlebar_background(colors))
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_app)),
|
||||
border: iced::Border::default().color(colors.border_low).width(1),
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
//! Tool sidebar — Photoshop-style vertical toolbox with SVG icons.
|
||||
//! Tool sidebar — fixed 36px single-column strip on the left edge.
|
||||
//!
|
||||
//! Features:
|
||||
//! - Single column (36px) or two-column (72px) mode
|
||||
//! - Toggle button (◀/▶) at top to expand/collapse
|
||||
//! - SVG icons with active/hover states
|
||||
//! - Overlapping color swatches at bottom
|
||||
//! - Submenu indicator for tools with sub-tools
|
||||
//! - Always single column (36px wide)
|
||||
//! - SVG icons with hover/active states
|
||||
//! - Overlapping fg/bg color swatches at bottom
|
||||
//! - Tooltip on hover
|
||||
|
||||
use crate::app::{Message, ToolState};
|
||||
use crate::panels::styles;
|
||||
use crate::theme::ThemeColors;
|
||||
use hcie_engine_api::Tool;
|
||||
use iced::widget::{column, container, mouse_area, row, svg, text};
|
||||
use iced::widget::{button, column, container, row, svg, text};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Tool slot definition — (primary tool, label, svg_path, sub_tools).
|
||||
/// Tool slot definition — (primary tool, label, svg_path).
|
||||
struct ToolSlot {
|
||||
tool: Tool,
|
||||
#[allow(dead_code)]
|
||||
label: &'static str,
|
||||
icon: &'static str,
|
||||
}
|
||||
@@ -42,65 +42,19 @@ const TOOL_SLOTS: &[ToolSlot] = &[
|
||||
ToolSlot { tool: Tool::VectorCircle, label: "Vector Circle", icon: "icons/circle.svg" },
|
||||
];
|
||||
|
||||
/// Build the sidebar element.
|
||||
/// Build the fixed 36px sidebar element.
|
||||
pub fn view<'a>(
|
||||
tool_state: &'a ToolState,
|
||||
fg_color: &'a [u8; 4],
|
||||
bg_color: &'a [u8; 4],
|
||||
colors: ThemeColors,
|
||||
two_column: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let slot_w: f32 = if two_column { 36.0 } else { 36.0 };
|
||||
let sidebar_w: f32 = if two_column { 76.0 } else { 38.0 };
|
||||
|
||||
// Toggle button at top
|
||||
let toggle_icon = if two_column { "◀" } else { "▶" };
|
||||
let toggle_btn = mouse_area(
|
||||
container(text(toggle_icon).size(10).color(colors.text_secondary))
|
||||
.width(Length::Fill)
|
||||
.center_x(Length::Fill)
|
||||
.padding([2, 0])
|
||||
)
|
||||
.on_press(Message::ToggleToolbox)
|
||||
.interaction(iced::mouse::Interaction::Pointer);
|
||||
|
||||
let toggle_row = container(toggle_btn)
|
||||
.width(Length::Fill)
|
||||
.padding([2, 0])
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_panel)),
|
||||
border: iced::Border::default().color(colors.border_low).width(1),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Tool buttons
|
||||
// Tool buttons — single column
|
||||
let mut tool_list = column![].spacing(1);
|
||||
|
||||
if two_column {
|
||||
// Two-column mode: render in pairs
|
||||
for chunk in TOOL_SLOTS.chunks(2) {
|
||||
let mut row_items = row![].spacing(1);
|
||||
for slot in chunk {
|
||||
let btn = tool_button(slot, tool_state, colors);
|
||||
row_items = row_items.push(btn);
|
||||
}
|
||||
// Fill remaining space if odd
|
||||
if chunk.len() == 1 {
|
||||
row_items = row_items.push(
|
||||
container(text("").size(1))
|
||||
.width(slot_w)
|
||||
.height(28)
|
||||
);
|
||||
}
|
||||
tool_list = tool_list.push(row_items);
|
||||
}
|
||||
} else {
|
||||
// Single column mode
|
||||
for slot in TOOL_SLOTS {
|
||||
let btn = tool_button(slot, tool_state, colors);
|
||||
tool_list = tool_list.push(btn);
|
||||
}
|
||||
}
|
||||
|
||||
// Color swatches at bottom
|
||||
let fg = fg_color;
|
||||
@@ -128,12 +82,25 @@ pub fn view<'a>(
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let swap_btn = mouse_area(
|
||||
container(text("⇅").size(8).color(colors.text_secondary))
|
||||
.padding(2)
|
||||
)
|
||||
let swap_btn = button(text("⇅").size(8).color(colors.text_secondary))
|
||||
.on_press(Message::SwapColors)
|
||||
.interaction(iced::mouse::Interaction::Pointer);
|
||||
.padding(2)
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_hover)),
|
||||
text_color: colors.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
text_color: colors.text_secondary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
let color_section = container(
|
||||
row![fg_swatch, bg_swatch, swap_btn].spacing(1)
|
||||
@@ -141,20 +108,18 @@ pub fn view<'a>(
|
||||
.padding([4, 0])
|
||||
.center_x(Length::Fill);
|
||||
|
||||
// Separator lines
|
||||
// Separator line
|
||||
let sep_style = move |_theme: &iced::Theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.border_low)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let sidebar = column![
|
||||
toggle_row,
|
||||
container(text("").height(1)).width(Length::Fill).style(sep_style.clone()),
|
||||
tool_list,
|
||||
container(tool_list).padding([2, 0]),
|
||||
container(text("").height(1)).width(Length::Fill).style(sep_style),
|
||||
color_section,
|
||||
]
|
||||
.width(sidebar_w)
|
||||
.width(36)
|
||||
.spacing(0);
|
||||
|
||||
container(sidebar)
|
||||
@@ -163,35 +128,27 @@ pub fn view<'a>(
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Create a single tool button.
|
||||
/// Create a single tool button with hover feedback.
|
||||
///
|
||||
/// Uses `iced::widget::button` for built-in Active/Hovered/Pressed styling.
|
||||
/// Active tool: bg_active + accent border
|
||||
/// Hovered: bg_hover background
|
||||
/// Default: transparent
|
||||
fn tool_button<'a>(slot: &ToolSlot, tool_state: &ToolState, colors: ThemeColors) -> Element<'a, Message> {
|
||||
let is_active = std::mem::discriminant(&tool_state.active_tool)
|
||||
== std::mem::discriminant(&slot.tool);
|
||||
|
||||
let btn_style = if is_active {
|
||||
iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_active)),
|
||||
border: iced::Border::default().color(colors.accent).width(1),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
|
||||
// Try to load SVG icon - search multiple paths
|
||||
let icon_paths = [
|
||||
std::path::PathBuf::from(slot.icon), // relative to cwd
|
||||
std::path::Path::new("hcie-iced-app/assets").join(slot.icon), // relative to project root
|
||||
std::path::PathBuf::from(format!("/mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-iced-app/assets/{}", slot.icon)), // absolute
|
||||
std::path::PathBuf::from(slot.icon),
|
||||
std::path::Path::new("hcie-iced-app/assets").join(slot.icon),
|
||||
std::path::PathBuf::from(format!("/mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-iced-app/assets/{}", slot.icon)),
|
||||
];
|
||||
|
||||
let icon_color = if is_active { colors.accent } else { colors.text_primary };
|
||||
|
||||
let icon_element: Element<'_, Message> = if let Some(icon_path) = icon_paths.iter().find(|p| p.exists()) {
|
||||
let handle = svg::Handle::from_path(icon_path);
|
||||
let icon_color = if is_active { colors.accent } else { colors.text_primary };
|
||||
svg(handle)
|
||||
.width(20)
|
||||
.height(20)
|
||||
@@ -200,28 +157,75 @@ fn tool_button<'a>(slot: &ToolSlot, tool_state: &ToolState, colors: ThemeColors)
|
||||
})
|
||||
.into()
|
||||
} else {
|
||||
// Fallback to text label
|
||||
text(slot.label.chars().next().unwrap_or('?').to_string())
|
||||
.size(14)
|
||||
.color(if is_active { colors.accent } else { colors.text_primary })
|
||||
.color(icon_color)
|
||||
.into()
|
||||
};
|
||||
|
||||
let btn = container(icon_element)
|
||||
let tool = slot.tool;
|
||||
let active = is_active;
|
||||
let c = colors;
|
||||
|
||||
let btn = iced::widget::button(
|
||||
container(icon_element)
|
||||
.width(28)
|
||||
.height(28)
|
||||
.center_y(20)
|
||||
.center_x(20)
|
||||
.style(move |_theme| btn_style.clone());
|
||||
|
||||
let tool = slot.tool;
|
||||
let clickable = mouse_area(btn)
|
||||
)
|
||||
.on_press(Message::ToolSelected(tool))
|
||||
.interaction(iced::mouse::Interaction::Pointer);
|
||||
.padding(0)
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Active | iced::widget::button::Status::Disabled => {
|
||||
if active {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.bg_active)),
|
||||
border: iced::Border::default().color(c.accent).width(1),
|
||||
text_color: c.text_primary,
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
border: iced::Border::default(),
|
||||
text_color: c.text_primary,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
iced::widget::button::Status::Hovered => {
|
||||
if active {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.bg_active)),
|
||||
border: iced::Border::default().color(c.accent).width(1),
|
||||
text_color: c.text_primary,
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.bg_hover)),
|
||||
border: iced::Border::default(),
|
||||
text_color: c.text_primary,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
iced::widget::button::Status::Pressed => {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.bg_active)),
|
||||
border: iced::Border::default().color(c.accent).width(1),
|
||||
text_color: c.text_primary,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Tooltip on hover
|
||||
iced::widget::tooltip(
|
||||
clickable,
|
||||
btn,
|
||||
iced::widget::text(slot.label).size(11),
|
||||
iced::widget::tooltip::Position::Right,
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@ impl ThemeState {
|
||||
}
|
||||
|
||||
/// Switch to a different theme preset.
|
||||
#[allow(dead_code)]
|
||||
pub fn set_preset(&self, preset: ThemePreset) {
|
||||
*self.colors.lock().unwrap() = ThemeColors::get(preset);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
SATIR SAYISI RAPORU
|
||||
Proje: HCIE-Rust v4
|
||||
Konum: /mnt/extra/00_PROJECTS/hcie-rust-v3.05
|
||||
Tarih: 2026-07-11 15:12:11
|
||||
Tarih: 2026-07-11 20:24:42
|
||||
=========================================
|
||||
|
||||
-----------------------------------------
|
||||
RUST (.rs) — hcie-* dizinleri (her paket)
|
||||
RUST (.rs) — hcie-* dizinleri (her paket, inkl. hcie-fx, hcie-psd, hcie-kra, hcie-native)
|
||||
-----------------------------------------
|
||||
hcie-ai 2402 satır
|
||||
hcie-blend 530 satır
|
||||
@@ -21,7 +21,7 @@ Tarih: 2026-07-11 15:12:11
|
||||
hcie-filter 3238 satır
|
||||
hcie-fx 4077 satır
|
||||
hcie-history 100 satır
|
||||
hcie-iced-app 4261 satır
|
||||
hcie-iced-app 6308 satır
|
||||
hcie-io 11382 satır
|
||||
hcie-kra 1392 satır
|
||||
hcie-native 153 satır
|
||||
@@ -34,7 +34,7 @@ Tarih: 2026-07-11 15:12:11
|
||||
hcie-vector 2298 satır
|
||||
hcie-vision 3131 satır
|
||||
|
||||
TOPLAM RUST: 95854 satır
|
||||
TOPLAM RUST: 97901 satır
|
||||
|
||||
-----------------------------------------
|
||||
C++ / HEADER (.cpp, .h)
|
||||
@@ -102,14 +102,14 @@ Tarih: 2026-07-11 15:12:11
|
||||
|
||||
MARKDOWN (.md)
|
||||
--------------------------------
|
||||
(kök dizin) 10777 satır
|
||||
(kök dizin) 10994 satır
|
||||
|
||||
TOPLAM MARKDOWN: 10777 satır
|
||||
TOPLAM MARKDOWN: 10994 satır
|
||||
|
||||
=========================================
|
||||
KOD SATIRLARI TOPLAMI (GRAND TOTAL)
|
||||
=========================================
|
||||
Rust: 95854 satır
|
||||
Rust: 97901 satır
|
||||
C++ / Header: 5031 satır
|
||||
JavaScript: 0 satır
|
||||
Svelte: 0 satır
|
||||
@@ -119,9 +119,9 @@ Tarih: 2026-07-11 15:12:11
|
||||
CSS: 0 satır
|
||||
Shell Script: 1445 satır
|
||||
-----------------------------------------
|
||||
KOD TOPLAMI: 104597 satır
|
||||
KOD TOPLAMI: 106644 satır
|
||||
|
||||
RUST + C++/H TOPLAMI: 100885 satır
|
||||
RUST + C++/H TOPLAMI: 102932 satır
|
||||
|
||||
(JSON ve Markdown dosyaları yukarıda ayrı olarak gösterilmiştir)
|
||||
=========================================
|
||||
|
||||
Reference in New Issue
Block a user