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:
2026-07-12 02:05:31 +03:00
parent f4ad22b55b
commit 660694f00f
23 changed files with 2514 additions and 837 deletions
+194 -203
View File
@@ -1,226 +1,217 @@
# Iced GUI — Photoshop-Style UI Overhaul Plan # Iced GUI — Remaining Tasks & Reprioritized Plan
## Goal ## Current Status
Transform the iced GUI from a generic dark theme to a professional Photoshop-like appearance with proper toolbox, menus, panels, and window management.
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` | # | Feature | Status | Files |
|---|---------|--------|-------|
### Changes: | 1 | Dock PaneGrid with centered canvas | DONE | `dock/state.rs`, `dock/view.rs` |
- Set `decorations: false` in iced window settings to hide OS title bar | 2 | Brush params wired (size/opacity/hardness/style) | DONE | `app.rs` |
- Build custom title bar with: app icon, "HCIE" text, menu bar, window controls (min/max/close) | 3 | Adjustment dialogs (Brightness/Contrast, HSL) | DONE | `dialogs/adjustments.rs` |
- Add `Message::WindowDrag`, `Message::WindowMinimize`, `Message::WindowMaximize`, `Message::WindowClose` | 4 | Selection tool (rectangular drag) | DONE | `app.rs` |
- Use `iced::window::drag(id)` for title bar drag-to-move | 5 | Vector shape drawing (Rect/Circle/Line) | DONE | `app.rs` |
- Use `iced::window::minimize(id, true)` for minimize | 6 | Keyboard shortcuts (Ctrl+Z/Y/S/N/O/C/V, Esc) | DONE | `app.rs` |
- Use `iced::window::toggle_maximize(id)` for maximize | 7 | CLI file open (`load_path`) | DONE | `main.rs`, `app.rs` |
- Title bar: 24px height, `#2a2a2a` background, subtle bottom border | 8 | Filter parameters (FilterParamChanged) | DONE | `app.rs` |
- Window controls: minimize/maximize/close buttons on right side (like Photoshop) | 9 | Menu system (9 menus, dropdown items) | DONE | `panels/menus.rs` |
| 10 | Multi-document tab bar | DONE | `dock/view.rs` |
### Window Setup: | 11 | Theme system (5 presets, ThemeColors) | DONE | `theme.rs`, `iced-panel-adapter/theme.rs` |
```rust | 12 | Custom title bar (app name, version, doc info) | DONE | `panels/title_bar.rs` |
iced::application("HCIE", HcieIcedApp::update, HcieIcedApp::view) | 13 | Properties, Layer Details, Geometry panels | DONE | `panels/*.rs` |
.window_settings(window::Settings { | 14 | SVG icons (64 icons, tool rendering) | DONE | `sidebar/mod.rs` |
decorations: false, | 15 | Checkerboard on canvas | DONE | `canvas/mod.rs` |
..Default::default() | 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): #### 1. Custom Window Frame (decorations: false)
| Token | Value | Usage | **Impact:** Professional look, eliminates OS chrome mismatch
|-------|-------|-------| **Files:** `main.rs` (L58-66), `app.rs` (view method), `panels/title_bar.rs`
| `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 |
### Style Functions to Update: Current: OS title bar is visible, custom title bar is redundant below it.
- `panel_background()``#2d2d2d` with no visible border Need:
- `panel_header()``#333333` background, no horizontal rule - Set `decorations: false` in window settings
- `active_item_bg()` — subtle `#404040` highlight (NOT blue tint) - Add `Message::WindowDrag`, `WindowMinimize`, `WindowMaximize`, `WindowClose`
- `inactive_item_bg()` — transparent - Use `iced::window::drag()`, `minimize()`, `toggle_maximize()`
- `menubar_background()``#2a2a2a` - Merge menu bar INTO title bar (Photoshop-style: app name | menu items | window controls, all in one 28px bar)
- `statusbar_background()``#2a2a2a` - Title bar: make entire bar draggable, buttons on right side
- `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)
#### 2. Menu Dropdown as Overlay (not inline)
**Impact:** Menus currently push content down when opened
**Files:** `panels/menus.rs`, `app.rs` **Files:** `panels/menus.rs`, `app.rs`
### Menu Structure: Current: Dropdown renders below the menu bar as a `column![bar, dropdown]`, shifting all content down.
| Menu | Items | Need:
|------|-------| - Wrap main content + dropdown in `iced::widget::Stack`
| File | New, Open, Open Recent, Save, Save As, Export PNG, Export JPEG, Close | - Position dropdown absolutely at top-left
| Edit | Undo, Redo, Cut, Copy, Paste, Select All, Deselect, Fill | - Add `mouse_area` backdrop to close on click-outside
| Image | Canvas Size, Image Size, Rotate CW, Rotate CCW, Flip H, Flip V | - Keep `active_menu` tracking (already exists)
| Layer | New Layer, Duplicate, Delete, Merge Down, Flatten, Clear | - Style dropdown with proper hover states per item
| Filter | (all filter categories with submenus) |
| Select | All, Deselect, Invert, Grow, Shrink, Feather | #### 3. Unused Warning Cleanup (16 warnings)
| View | Zoom In, Zoom Out, Fit Screen, 100%, Panel Toggles | **Impact:** Code cleanliness, prevents confusion
| Window | Dock Profile, Reset Layout | **Files:** `panels/styles.rs`, `panels/text_editor.rs`, `theme.rs`
| Help | About, Documentation |
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 #### 4. Menu Actions Fully Wired
2. **Custom title bar** — Hide OS decorations, build custom title bar **Impact:** Many menu items dispatch to `NoOp` or incomplete handlers
3. **Toolbox SVG icons** — Copy SVGs, implement icon rendering **Files:** `app.rs` (MenuAction handler ~L1340-1410)
4. **Toolbox layout** — Single column + expand toggle
5. **Panel titles** — Remove blue tint, modernize Missing wiring:
6. **Menu overlay** — Implement dropdown overlay system - Edit menu: Cut (X), Select All (A), Deselect (D), Fill
7. **Dock drag/drop** — Wire pane rearrangement - Image menu: Canvas Size, Image Size, Rotate CW/CCW/180, Flip H/V, Crop to Selection
8. **Menu items** — Wire all menu actions - 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 ## Verification
1. Build: `cargo build -p hcie-iced-gui` 1. `cargo check -p hcie-iced-gui` — 0 warnings, 0 errors
2. Launch and compare with Photoshop screenshot 2. `cargo run -p hcie-iced-gui` — launch and verify:
3. Test: tool selection, drawing, color picking, menu dropdowns, panel rearrangement - No OS title bar, custom title bar with drag
4. Screenshot comparison with Photoshop reference - 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
View File
@@ -1949,6 +1949,12 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
[[package]]
name = "float_next_after"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8"
[[package]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@@ -2786,6 +2792,7 @@ name = "hcie-iced-gui"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"arboard", "arboard",
"bytes",
"env_logger", "env_logger",
"evdev", "evdev",
"hcie-engine-api", "hcie-engine-api",
@@ -3205,6 +3212,7 @@ dependencies = [
"image 0.24.9", "image 0.24.9",
"kamadak-exif", "kamadak-exif",
"log", "log",
"lyon_path",
"once_cell", "once_cell",
"raw-window-handle", "raw-window-handle",
"rustc-hash 2.1.2", "rustc-hash 2.1.2",
@@ -3269,6 +3277,7 @@ dependencies = [
"iced_glyphon", "iced_glyphon",
"iced_graphics", "iced_graphics",
"log", "log",
"lyon",
"once_cell", "once_cell",
"resvg 0.42.0", "resvg 0.42.0",
"rustc-hash 2.1.2", "rustc-hash 2.1.2",
@@ -3850,6 +3859,58 @@ version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 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]] [[package]]
name = "malloc_buf" name = "malloc_buf"
version = "0.0.6" version = "0.0.6"
+1 -1
View File
@@ -62,7 +62,7 @@ eframe = { version = "0.34", default-features = false, features = ["default
egui_extras = { version = "0.34", features = ["svg", "image"] } egui_extras = { version = "0.34", features = ["svg", "image"] }
egui_dock = { version = "0.19", features = ["serde"] } egui_dock = { version = "0.19", features = ["serde"] }
rfd = "0.15" rfd = "0.15"
iced = { version = "0.13", features = ["tokio", "image", "svg"] } iced = { version = "0.13", features = ["tokio", "image", "svg", "canvas"] }
# Utilities # Utilities
base64 = "0.22" base64 = "0.22"
+1 -1
View File
@@ -101,7 +101,7 @@ echo "" >> "$OUTPUT_FILE"
# 1. RUST DOSYALARI (.rs) # 1. RUST DOSYALARI (.rs)
# ============================================================ # ============================================================
echo "-----------------------------------------" | tee -a "$OUTPUT_FILE" 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" echo "-----------------------------------------" | tee -a "$OUTPUT_FILE"
# Proje kök dizinindeki hcie-* dizinleri (her bir Rust paketi) için döngü # 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 } serde_json = { workspace = true }
rfd = { workspace = true } rfd = { workspace = true }
arboard = { workspace = true } arboard = { workspace = true }
bytes = "1.12"
evdev = { version = "0.12", optional = true } evdev = { version = "0.12", optional = true }
+353 -153
View File
@@ -3,6 +3,13 @@
//! Implements the Elm-architecture pattern for the HCIE image editor. //! Implements the Elm-architecture pattern for the HCIE image editor.
//! Manages per-document engine instances, tool state, compositing, //! Manages per-document engine instances, tool state, compositing,
//! dock layout, keyboard shortcuts, and all UI interactions. //! 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::dialogs;
use crate::dock::state::{DockState, PaneType}; 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 hcie_engine_api::{BrushTip, BrushStyle, Engine, FilterType, Tool, ZOOM_MAX, ZOOM_MIN};
use iced::widget::{column, container, text}; use iced::widget::{column, container, text};
use iced::{Element, Length, Task, Theme, Vector}; use iced::{Element, Length, Task, Theme, Vector};
use bytes::Bytes;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
/// Active dialog type. /// Active dialog type.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
#[allow(dead_code)]
pub enum ActiveDialog { pub enum ActiveDialog {
None, None,
NewImage, NewImage,
@@ -43,6 +52,8 @@ pub struct HcieIcedApp {
pub canvas_cursor_pos: Option<(u32, u32)>, pub canvas_cursor_pos: Option<(u32, u32)>,
/// Currently active dialog (if any). /// Currently active dialog (if any).
pub active_dialog: ActiveDialog, 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. /// New Image dialog state.
pub dialog_new_name: String, pub dialog_new_name: String,
pub dialog_new_width: u32, pub dialog_new_width: u32,
@@ -72,17 +83,14 @@ pub struct HcieIcedApp {
pub theme_state: ThemeState, pub theme_state: ThemeState,
/// Currently open menu index (None = no menu open). /// Currently open menu index (None = no menu open).
pub active_menu: Option<usize>, 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. /// Per-document state wrapping an engine instance.
pub struct IcedDocument { pub struct IcedDocument {
pub engine: Engine, pub engine: Engine,
/// Cached composite RGBA pixel data from the engine. /// Cached composite RGBA pixel data from the engine.
pub composite_buffer: Vec<u8>, /// Uses `bytes::Bytes` for zero-cost cloning (reference-counted).
/// Whether the composite needs re-rendering. pub composite_buffer: Bytes,
pub composite_dirty: bool,
/// Document display name. /// Document display name.
pub name: String, pub name: String,
/// Original file path, if loaded from disk. /// Original file path, if loaded from disk.
@@ -103,6 +111,12 @@ pub struct IcedDocument {
pub selection_rect: Option<(f32, f32, f32, f32)>, pub selection_rect: Option<(f32, f32, f32, f32)>,
/// Vector shape being drawn (start pos, current pos). /// Vector shape being drawn (start pos, current pos).
pub vector_draw: Option<((f32, f32), (f32, f32))>, 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. /// Drawing tool state.
@@ -145,6 +159,7 @@ impl Default for ToolState {
/// Messages the application handles. /// Messages the application handles.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum Message { pub enum Message {
// ── Tool selection ────────────────────────────────── // ── Tool selection ──────────────────────────────────
ToolSelected(Tool), ToolSelected(Tool),
@@ -158,8 +173,11 @@ pub enum Message {
CanvasPointerPressed { x: f32, y: f32 }, CanvasPointerPressed { x: f32, y: f32 },
CanvasPointerMoved { x: f32, y: f32 }, CanvasPointerMoved { x: f32, y: f32 },
CanvasPointerReleased, CanvasPointerReleased,
CanvasZoom { delta: f32 }, CanvasPanZoom { zoom: f32, pan_offset: Vector },
CanvasZoomSet(f32), CanvasZoomSet(f32),
CanvasZoomRelative(f32),
CanvasSize((f32, f32)),
CanvasCursorPos { x: u32, y: u32 },
// ── Engine operations ─────────────────────────────── // ── Engine operations ───────────────────────────────
CompositeRefresh, CompositeRefresh,
@@ -278,8 +296,12 @@ pub enum Message {
MenuClose, MenuClose,
MenuAction(usize, usize), // (menu_index, item_index) MenuAction(usize, usize), // (menu_index, item_index)
// ── Toolbox ───────────────────────────────────────── // ── Window ───────────────────────────────────────────
ToggleToolbox, WindowDrag,
WindowMinimize,
WindowMaximize,
WindowClose,
WindowId(iced::window::Id),
// ── Misc ──────────────────────────────────────────── // ── Misc ────────────────────────────────────────────
NoOp, NoOp,
@@ -335,10 +357,11 @@ impl HcieIcedApp {
/// Create the initial application state. /// Create the initial application state.
/// ///
/// If `load_path` is provided, the file will be opened after initialization. /// 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); let mut engine = Engine::new(800, 600);
engine.pre_tile_all_layers(); 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 cached_layers = engine.layer_infos();
let history_len = engine.history_len(); let history_len = engine.history_len();
@@ -347,10 +370,9 @@ impl HcieIcedApp {
.collect(); .collect();
let history_current = engine.history_current(); let history_current = engine.history_current();
let doc = IcedDocument { let mut doc = IcedDocument {
engine, engine,
composite_buffer, composite_buffer,
composite_dirty: false,
name: "Untitled".to_string(), name: "Untitled".to_string(),
source_path: None, source_path: None,
modified: false, modified: false,
@@ -361,6 +383,9 @@ impl HcieIcedApp {
history_current, history_current,
selection_rect: None, selection_rect: None,
vector_draw: None, vector_draw: None,
pane_size: (800.0, 600.0),
render_generation: 0,
}; };
let mut app = Self { let mut app = Self {
@@ -372,6 +397,7 @@ impl HcieIcedApp {
last_cursor_pos: None, last_cursor_pos: None,
canvas_cursor_pos: None, canvas_cursor_pos: None,
active_dialog: ActiveDialog::None, active_dialog: ActiveDialog::None,
window_id: None,
dialog_new_name: "Untitled".to_string(), dialog_new_name: "Untitled".to_string(),
dialog_new_width: 800, dialog_new_width: 800,
dialog_new_height: 600, dialog_new_height: 600,
@@ -390,7 +416,6 @@ impl HcieIcedApp {
initialized: false, initialized: false,
theme_state: ThemeState::new(), theme_state: ThemeState::new(),
active_menu: None, active_menu: None,
toolbox_two_column: false,
}; };
// If a file path was provided, open it // If a file path was provided, open it
@@ -402,7 +427,7 @@ impl HcieIcedApp {
.map(|n| n.to_string_lossy().to_string()) .map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".to_string()); .unwrap_or_else(|| "Untitled".to_string());
app.documents[0].source_path = Some(path); 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(); app.refresh_composite_if_needed();
} }
Err(e) => { Err(e) => {
@@ -412,7 +437,8 @@ impl HcieIcedApp {
} }
app.initialized = true; app.initialized = true;
app let init_task = Task::none();
(app, init_task)
} }
/// Return a mutable reference to the active document. /// 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") 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)> { fn screen_to_canvas(&self, sx: f32, sy: f32) -> Option<(f32, f32)> {
let doc = &self.documents[self.active_doc]; let doc = &self.documents[self.active_doc];
let zoom = doc.zoom; 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; // Canvas image position = center of pane + pan_offset
let canvas_y = (sy - pan.y) / zoom; 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 // Convert from pane-space to canvas-space
&& canvas_y >= 0.0 && canvas_y < doc.engine.canvas_height() as f32 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)) Some((canvas_x, canvas_y))
} else { } 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) { fn refresh_composite_if_needed(&mut self) {
let doc = &mut self.documents[self.active_doc]; let doc = &mut self.documents[self.active_doc];
if doc.composite_dirty {
doc.composite_buffer = doc.engine.get_composite_pixels(); if doc.engine.is_composite_dirty() {
doc.composite_dirty = false; 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) // Always refresh cached panel data (cheap operation)
doc.cached_layers = doc.engine.layer_infos(); doc.cached_layers = doc.engine.layer_infos();
let history_len = doc.engine.history_len(); let history_len = doc.engine.history_len();
@@ -489,14 +562,9 @@ impl HcieIcedApp {
} }
Message::CanvasPointerPressed { x, y } => { Message::CanvasPointerPressed { x, y } => {
let (px, py) = if x == 0.0 && y == 0.0 { // Coordinates are already in canvas-space from the canvas widget
self.last_cursor_pos.unwrap_or((0.0, 0.0)) let canvas_x = x;
} else { let canvas_y = y;
(x, y)
};
self.last_cursor_pos = Some((px, py));
if let Some((canvas_x, canvas_y)) = self.screen_to_canvas(px, py) {
self.canvas_cursor_pos = Some((canvas_x as u32, canvas_y as u32)); self.canvas_cursor_pos = Some((canvas_x as u32, canvas_y as u32));
let tool = self.tool_state.active_tool; let tool = self.tool_state.active_tool;
@@ -513,7 +581,6 @@ impl HcieIcedApp {
self.tool_state.is_drawing = true; self.tool_state.is_drawing = true;
self.tool_state.last_stroke_pos = Some((canvas_x, canvas_y)); self.tool_state.last_stroke_pos = Some((canvas_x, canvas_y));
self.tool_state.brush_accumulated_dist = 0.0; self.tool_state.brush_accumulated_dist = 0.0;
self.documents[self.active_doc].composite_dirty = true;
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
@@ -537,7 +604,6 @@ impl HcieIcedApp {
let cy = canvas_y as u32; let cy = canvas_y as u32;
let color = self.fg_color; let color = self.fg_color;
self.documents[self.active_doc].engine.flood_fill(cx, cy, color, 32); 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(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
@@ -560,18 +626,12 @@ impl HcieIcedApp {
_ => {} _ => {}
} }
} }
}
Message::CanvasPointerMoved { x, y } => { Message::CanvasPointerMoved { x, y } => {
self.last_cursor_pos = Some((x, y)); // Coordinates are already in canvas-space from the canvas widget
self.canvas_cursor_pos = Some((x as u32, y as u32));
// 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));
}
if self.tool_state.is_drawing { 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 layer_id = self.documents[self.active_doc].engine.active_layer_id();
let spacing: f32 = match self.tool_state.active_tool { let spacing: f32 = match self.tool_state.active_tool {
@@ -580,8 +640,8 @@ impl HcieIcedApp {
}.max(1.0); }.max(1.0);
if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos { if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos {
let dx = canvas_x - last_x; let dx = x - last_x;
let dy = canvas_y - last_y; let dy = y - last_y;
let dist = (dx * dx + dy * dy).sqrt(); let dist = (dx * dx + dy * dy).sqrt();
// Use constant pressure for mouse input // 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); let pressure = self.tablet_state.lock().map(|s| s.current_pressure()).unwrap_or(1.0);
if dist >= spacing { if dist >= spacing {
self.documents[self.active_doc].engine.stroke_to(layer_id, canvas_x, canvas_y, pressure); self.documents[self.active_doc].engine.stroke_to(layer_id, x, y, pressure);
self.tool_state.last_stroke_pos = Some((canvas_x, canvas_y)); self.tool_state.last_stroke_pos = Some((x, y));
self.documents[self.active_doc].composite_dirty = true;
} }
} else { } else {
self.documents[self.active_doc].engine.stroke_to(layer_id, canvas_x, canvas_y, 1.0); self.documents[self.active_doc].engine.stroke_to(layer_id, x, y, 1.0);
self.tool_state.last_stroke_pos = Some((canvas_x, canvas_y)); self.tool_state.last_stroke_pos = Some((x, y));
self.documents[self.active_doc].composite_dirty = true;
} }
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
}
// Handle selection drag // Handle selection drag
if self.tool_state.active_tool == Tool::Select { if self.tool_state.active_tool == Tool::Select {
if let Some((canvas_x, canvas_y)) = self.screen_to_canvas(x, y) { let mx = x;
let mx = canvas_x; let my = y;
let my = canvas_y;
return Task::perform(async move {}, move |_| { return Task::perform(async move {}, move |_| {
Message::SelectionDragMove(mx, my) Message::SelectionDragMove(mx, my)
}); });
} }
}
// Handle vector draw drag // Handle vector draw drag
if matches!(self.tool_state.active_tool, Tool::VectorRect | Tool::VectorCircle | Tool::VectorLine) { 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 = x;
let mx = canvas_x; let my = y;
let my = canvas_y;
return Task::perform(async move {}, move |_| { return Task::perform(async move {}, move |_| {
Message::VectorDrawMove(mx, my) Message::VectorDrawMove(mx, my)
}); });
} }
} }
}
Message::CanvasPointerReleased => { Message::CanvasPointerReleased => {
if self.tool_state.is_drawing { if self.tool_state.is_drawing {
let layer_id = self.documents[self.active_doc].engine.active_layer_id(); 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.end_stroke(layer_id);
self.documents[self.active_doc].engine.commit_pending_history(); 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.is_drawing = false;
self.tool_state.last_stroke_pos = None; 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 doc = &mut self.documents[self.active_doc];
let factor = if delta > 0.0 { 1.1 } else { 1.0 / 1.1 }; doc.zoom = zoom;
doc.zoom = (doc.zoom * factor).clamp(ZOOM_MIN, ZOOM_MAX); doc.pan_offset = pan_offset;
}
Message::CanvasCursorPos { x, y } => {
self.canvas_cursor_pos = Some((x, y));
} }
Message::CanvasZoomSet(z) => { 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 => { Message::CompositeRefresh | Message::Undo | Message::Redo => {
@@ -668,7 +741,6 @@ impl HcieIcedApp {
Message::Redo => { self.documents[self.active_doc].engine.redo(); } Message::Redo => { self.documents[self.active_doc].engine.redo(); }
_ => {} _ => {}
} }
self.documents[self.active_doc].composite_dirty = true;
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
} }
@@ -680,14 +752,12 @@ impl HcieIcedApp {
Message::LayerAdd => { Message::LayerAdd => {
let count = self.documents[self.active_doc].engine.get_layer_count(); 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].engine.add_layer(&format!("Layer {}", count + 1));
self.documents[self.active_doc].composite_dirty = true;
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
Message::LayerDelete(id) => { Message::LayerDelete(id) => {
self.documents[self.active_doc].engine.delete_layer(id); self.documents[self.active_doc].engine.delete_layer(id);
self.documents[self.active_doc].composite_dirty = true;
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
@@ -697,7 +767,6 @@ impl HcieIcedApp {
.map(|l| !l.visible) .map(|l| !l.visible)
.unwrap_or(true); .unwrap_or(true);
self.documents[self.active_doc].engine.set_layer_visible(id, visible); self.documents[self.active_doc].engine.set_layer_visible(id, visible);
self.documents[self.active_doc].composite_dirty = true;
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
@@ -711,21 +780,18 @@ impl HcieIcedApp {
Message::LayerSetOpacity(id, opacity) => { Message::LayerSetOpacity(id, opacity) => {
self.documents[self.active_doc].engine.set_layer_opacity(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(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
Message::LayerMergeDown => { Message::LayerMergeDown => {
self.documents[self.active_doc].engine.merge_down(); self.documents[self.active_doc].engine.merge_down();
self.documents[self.active_doc].composite_dirty = true;
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
Message::LayerFlatten => { Message::LayerFlatten => {
self.documents[self.active_doc].engine.flatten_image(); self.documents[self.active_doc].engine.flatten_image();
self.documents[self.active_doc].composite_dirty = true;
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
@@ -736,7 +802,6 @@ impl HcieIcedApp {
if pos > 0 { if pos > 0 {
let target_idx = pos - 1; let target_idx = pos - 1;
self.documents[self.active_doc].engine.move_layer(id, target_idx); self.documents[self.active_doc].engine.move_layer(id, target_idx);
self.documents[self.active_doc].composite_dirty = true;
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
@@ -749,7 +814,6 @@ impl HcieIcedApp {
if pos + 1 < infos.len() { if pos + 1 < infos.len() {
let target_idx = pos + 1; let target_idx = pos + 1;
self.documents[self.active_doc].engine.move_layer(id, target_idx); self.documents[self.active_doc].engine.move_layer(id, target_idx);
self.documents[self.active_doc].composite_dirty = true;
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
@@ -759,7 +823,6 @@ impl HcieIcedApp {
// ── History ─────────────────────────────────── // ── History ───────────────────────────────────
Message::HistoryJumpTo(idx) => { Message::HistoryJumpTo(idx) => {
self.documents[self.active_doc].engine.jump_to_history(idx); self.documents[self.active_doc].engine.jump_to_history(idx);
self.documents[self.active_doc].composite_dirty = true;
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
@@ -784,7 +847,6 @@ impl HcieIcedApp {
// ── Layer styles ────────────────────────────── // ── Layer styles ──────────────────────────────
Message::LayerStyleToggle(_index) => { Message::LayerStyleToggle(_index) => {
// TODO: toggle layer style enabled state // TODO: toggle layer style enabled state
self.documents[self.active_doc].composite_dirty = true;
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
@@ -823,7 +885,6 @@ impl HcieIcedApp {
if let Some(filter) = self.selected_filter { if let Some(filter) = self.selected_filter {
let filter_id = filter_type_to_id(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].engine.apply_filter_preview(filter_id, self.filter_params.clone());
self.documents[self.active_doc].composite_dirty = true;
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
@@ -832,7 +893,6 @@ impl HcieIcedApp {
if let Some(filter) = self.selected_filter { if let Some(filter) = self.selected_filter {
let filter_id = filter_type_to_id(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].engine.apply_filter(filter_id, self.filter_params.clone());
self.documents[self.active_doc].composite_dirty = true;
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
self.filter_preview_active = false; self.filter_preview_active = false;
self.selected_filter = None; self.selected_filter = None;
@@ -843,7 +903,6 @@ impl HcieIcedApp {
Message::FilterReset => { Message::FilterReset => {
if self.filter_preview_active { if self.filter_preview_active {
self.documents[self.active_doc].engine.filter_preview_restore(); self.documents[self.active_doc].engine.filter_preview_restore();
self.documents[self.active_doc].composite_dirty = true;
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
self.filter_preview_active = false; self.filter_preview_active = false;
} }
@@ -890,7 +949,7 @@ impl HcieIcedApp {
let transparent = self.dialog_new_transparent; let transparent = self.dialog_new_transparent;
let mut engine = Engine::new_with_options(&name, w, h, transparent); let mut engine = Engine::new_with_options(&name, w, h, transparent);
engine.pre_tile_all_layers(); 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 cached_layers = engine.layer_infos();
let history_len = engine.history_len(); let history_len = engine.history_len();
let cached_history: Vec<(usize, String)> = (0..history_len) let cached_history: Vec<(usize, String)> = (0..history_len)
@@ -900,7 +959,6 @@ impl HcieIcedApp {
self.documents.push(IcedDocument { self.documents.push(IcedDocument {
engine, engine,
composite_buffer, composite_buffer,
composite_dirty: false,
name, name,
source_path: None, source_path: None,
modified: false, modified: false,
@@ -911,9 +969,10 @@ impl HcieIcedApp {
history_current, history_current,
selection_rect: None, selection_rect: None,
vector_draw: 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 ──────────────────────── // ── Adjustments Dialog ────────────────────────
@@ -921,7 +980,6 @@ impl HcieIcedApp {
self.dialog_brightness = v; self.dialog_brightness = v;
let params = serde_json::json!({"brightness": v, "contrast": self.dialog_contrast}); 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].engine.apply_filter_preview("brightness_contrast", params);
self.documents[self.active_doc].composite_dirty = true;
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
@@ -929,7 +987,6 @@ impl HcieIcedApp {
self.dialog_contrast = v; self.dialog_contrast = v;
let params = serde_json::json!({"brightness": self.dialog_brightness, "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].engine.apply_filter_preview("brightness_contrast", params);
self.documents[self.active_doc].composite_dirty = true;
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
@@ -937,7 +994,6 @@ impl HcieIcedApp {
self.dialog_hue = v; self.dialog_hue = v;
let params = serde_json::json!({"hue": v, "saturation": self.dialog_saturation, "lightness": self.dialog_lightness}); 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].engine.apply_filter_preview("hue_saturation", params);
self.documents[self.active_doc].composite_dirty = true;
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
@@ -945,7 +1001,6 @@ impl HcieIcedApp {
self.dialog_saturation = v; self.dialog_saturation = v;
let params = serde_json::json!({"hue": self.dialog_hue, "saturation": v, "lightness": self.dialog_lightness}); 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].engine.apply_filter_preview("hue_saturation", params);
self.documents[self.active_doc].composite_dirty = true;
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
@@ -953,7 +1008,6 @@ impl HcieIcedApp {
self.dialog_lightness = v; self.dialog_lightness = v;
let params = serde_json::json!({"hue": self.dialog_hue, "saturation": self.dialog_saturation, "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].engine.apply_filter_preview("hue_saturation", params);
self.documents[self.active_doc].composite_dirty = true;
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); 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].engine.apply_filter(filter_id, params);
} }
self.documents[self.active_doc].composite_dirty = true;
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
self.active_dialog = ActiveDialog::None; self.active_dialog = ActiveDialog::None;
return Task::perform(async {}, |_| Message::CompositeRefresh); 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.refresh_composite_if_needed();
self.active_dialog = ActiveDialog::None; self.active_dialog = ActiveDialog::None;
return Task::perform(async {}, |_| Message::CompositeRefresh); return Task::perform(async {}, |_| Message::CompositeRefresh);
@@ -1028,7 +1080,6 @@ impl HcieIcedApp {
let ey = y0.max(y1) as u32; let ey = y0.max(y1) as u32;
if ex > sx && ey > sy { if ex > sx && ey > sy {
self.documents[self.active_doc].engine.create_selection_rect(sx, sy, ex, ey); 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(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); 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(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
@@ -1169,7 +1219,6 @@ impl HcieIcedApp {
log::info!("Pasted image: {}x{}", w, h); log::info!("Pasted image: {}x{}", w, h);
let engine = &mut self.documents[self.active_doc].engine; let engine = &mut self.documents[self.active_doc].engine;
engine.create_layer_from_data("Pasted", pixels, w, h); engine.create_layer_from_data("Pasted", pixels, w, h);
self.documents[self.active_doc].composite_dirty = true;
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
@@ -1225,7 +1274,6 @@ impl HcieIcedApp {
.map(|n| n.to_string_lossy().to_string()) .map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".to_string()); .unwrap_or_else(|| "Untitled".to_string());
self.documents[self.active_doc].source_path = Some(path); self.documents[self.active_doc].source_path = Some(path);
self.documents[self.active_doc].composite_dirty = true;
self.refresh_composite_if_needed(); self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh); return Task::perform(async {}, |_| Message::CompositeRefresh);
} }
@@ -1274,7 +1322,7 @@ impl HcieIcedApp {
Message::NewDocument(w, h) => { Message::NewDocument(w, h) => {
let mut engine = Engine::new(w, h); let mut engine = Engine::new(w, h);
engine.pre_tile_all_layers(); 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 cached_layers = engine.layer_infos();
let history_len = engine.history_len(); let history_len = engine.history_len();
let cached_history: Vec<(usize, String)> = (0..history_len) let cached_history: Vec<(usize, String)> = (0..history_len)
@@ -1284,7 +1332,6 @@ impl HcieIcedApp {
self.documents.push(IcedDocument { self.documents.push(IcedDocument {
engine, engine,
composite_buffer, composite_buffer,
composite_dirty: false,
name: "Untitled".to_string(), name: "Untitled".to_string(),
source_path: None, source_path: None,
modified: false, modified: false,
@@ -1295,10 +1342,13 @@ impl HcieIcedApp {
history_current, history_current,
selection_rect: None, selection_rect: None,
vector_draw: None, vector_draw: None,
pane_size: (800.0, 600.0),
render_generation: 0,
}); });
self.active_doc = self.documents.len() - 1;
} }
Message::DocSwitch(idx) => { Message::DocSwitch(idx) => {
if idx < self.documents.len() { if idx < self.documents.len() {
self.active_doc = idx; self.active_doc = idx;
@@ -1362,56 +1412,185 @@ impl HcieIcedApp {
self.active_menu = None; self.active_menu = None;
// Dispatch the appropriate action based on menu and item indices // Dispatch the appropriate action based on menu and item indices
match (menu_idx, item_idx) { match (menu_idx, item_idx) {
// File menu // ── File menu (0) ──
(0, 0) => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::NewImage)), (0, 0) => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::NewImage)), // New
(0, 1) => return Task::perform(async {}, |_| Message::OpenFileRfd), (0, 1) => return Task::perform(async {}, |_| Message::OpenFileRfd), // Open
(0, 2) => return Task::perform(async {}, |_| Message::SaveFileAs), // (0, 2) = separator
(0, 3) => return Task::perform(async {}, |_| Message::SaveFileAs), (0, 3) => return Task::perform(async {}, |_| Message::SaveFileAs), // Save
// Edit menu (0, 4) => return Task::perform(async {}, |_| Message::SaveFileAs), // Save As
(1, 0) => return Task::perform(async {}, |_| Message::Undo), // (0, 5) = separator
(1, 1) => return Task::perform(async {}, |_| Message::Redo), // (0, 6) = Import SVG — TODO
(1, 4) => return Task::perform(async {}, |_| Message::PasteImage), // (0, 7) = Import Brushes — TODO
// Tools menu // (0, 8) = separator
(2, idx) => { (0, 9) => return Task::perform(async {}, |_| Message::SaveFileAs), // Export PNG (placeholder → Save As)
let tools = [ (0, 10) => return Task::perform(async {}, |_| Message::SaveFileAs), // Export JPEG (placeholder → Save As)
Tool::Move, Tool::VectorSelect, // (0, 11) = separator
Tool::Select, Tool::Lasso, Tool::PolygonSelect, Tool::MagicWand, (0, 12) => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::CloseConfirm)), // Close
Tool::Crop, Tool::Eyedropper, // (0, 13) = Exit
Tool::Brush, Tool::Pen, Tool::Spray, Tool::Eraser, (0, 13) => {
Tool::FloodFill, Tool::Gradient, Tool::Text, if let Some(id) = self.window_id {
Tool::VectorLine, Tool::VectorRect, Tool::VectorCircle, return iced::window::close(id);
];
if idx < tools.len() {
self.tool_state.active_tool = tools[idx];
} }
} }
// Layer menu
(4, 0) => return Task::perform(async {}, |_| Message::LayerAdd), // ── Edit menu (1) ──
(4, 3) => return Task::perform(async {}, |_| Message::LayerMergeDown), (1, 0) => return Task::perform(async {}, |_| Message::Undo), // Undo
(4, 4) => return Task::perform(async {}, |_| Message::LayerFlatten), (1, 1) => return Task::perform(async {}, |_| Message::Redo), // Redo
// Filter menu // (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, 1) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::BoxBlur)),
(5, 2) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::GaussianBlur)), (5, 2) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::GaussianBlur)),
(5, 3) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::MotionBlur)), (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, 5) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::UnsharpMask)),
// (5, 6) = ─ Pixelate (disabled)
(5, 7) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Mosaic)), (5, 7) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Mosaic)),
(5, 8) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Crystallize)), (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, 10) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Pinch)),
(5, 11) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Twirl)), (5, 11) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Twirl)),
// (5, 12) = ─ Stylize (disabled)
(5, 13) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::OilPaint)), (5, 13) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::OilPaint)),
// View menu // (5, 14) = ─ Color & Light (disabled)
(7, 0) => return Task::perform(async {}, |_| Message::CanvasZoom { delta: 1.0 }), (5, 15) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Levels)),
(7, 1) => return Task::perform(async {}, |_| Message::CanvasZoom { delta: -1.0 }), (5, 16) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Vibrance)),
(7, 2) => return Task::perform(async {}, |_| Message::CanvasZoomSet(1.0)), (5, 17) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Exposure)),
(7, 4) => return Task::perform(async {}, |_| Message::CanvasZoomSet(1.0)), (5, 18) => return Task::perform(async {}, |_| Message::FilterSelect(FilterType::Posterize)),
// Escape closes menu (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 ────────────────────────────────── // ── Window controls ──────────────────────────
Message::ToggleToolbox => { Message::WindowDrag => {
self.toolbox_two_column = !self.toolbox_two_column; 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 => {} Message::NoOp => {}
@@ -1430,21 +1609,35 @@ impl HcieIcedApp {
let doc = &self.documents[self.active_doc]; let doc = &self.documents[self.active_doc];
let colors = self.theme_state.colors(); let colors = self.theme_state.colors();
// Title bar // Title bar (now includes menu items and window controls)
let title_bar = panels::title_bar::view( let title_bar = panels::title_bar::view(
&doc.name, &doc.name,
doc.zoom, doc.zoom,
doc.engine.canvas_width(), doc.engine.canvas_width(),
doc.engine.canvas_height(), doc.engine.canvas_height(),
colors, colors,
self.active_menu,
); );
// Menu bar // Toolbox — fixed 36px strip on the left edge (outside dock)
let menu_bar = panels::menus::view(&self.tool_state.active_tool, self.active_menu); 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); 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 // Status bar
let status_bar = panels::status_bar::view( let status_bar = panels::status_bar::view(
&self.tool_state.active_tool, &self.tool_state.active_tool,
@@ -1457,15 +1650,20 @@ impl HcieIcedApp {
let content = column![ let content = column![
title_bar, title_bar,
menu_bar, main_area,
iced::widget::horizontal_rule(1),
dock_content,
iced::widget::horizontal_rule(1),
status_bar, status_bar,
] ]
.width(Length::Fill) .width(Length::Fill)
.height(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 // Dialog overlay
let dialog_overlay: Element<'_, Message> = match &self.active_dialog { let dialog_overlay: Element<'_, Message> = match &self.active_dialog {
ActiveDialog::None => text("").into(), ActiveDialog::None => text("").into(),
@@ -1493,21 +1691,23 @@ impl HcieIcedApp {
), ),
}; };
// Stack main content with dialog overlay // Stack main content with overlays (dialog + menu dropdown).
let stacked = container(content) // `content` is already wrapped in a styled container with the app
.width(Length::Fill) // background, so no extra wrapper is needed.
.height(Length::Fill) let has_dialog = self.active_dialog != ActiveDialog::None;
.padding(0); let has_menu = self.active_menu.is_some();
if self.active_dialog != ActiveDialog::None { if has_dialog || has_menu {
iced::widget::Stack::new() let mut stack = iced::widget::Stack::new().push(content);
.push(stacked) if has_dialog {
.push(dialog_overlay) stack = stack.push(dialog_overlay);
.width(Length::Fill) }
.height(Length::Fill) if let Some(menu_overlay) = panels::menus::dropdown_overlay(self.active_menu) {
.into() stack = stack.push(menu_overlay);
}
stack.width(Length::Fill).height(Length::Fill).into()
} else { } else {
stacked.into() content.into()
} }
} }
@@ -1,41 +1,624 @@
//! Canvas viewport — renders the composite texture with zoom/pan. //! Canvas viewport — renders the composite texture with zoom/pan.
//! //!
//! Displays the engine's RGBA composite as an Iced image widget. //! Uses `iced::widget::Canvas` with a custom `Program` to draw the checkerboard
//! Mouse interaction is handled via `mouse_area` wrapper. //! and composite texture at the correct position/scale. This decouples rendering
//! Includes checkerboard pattern for transparency visualization. //! from layout, fixing zoom-to-cursor and coordinate mapping.
//!
pub mod render; //! ⚠️ 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 crate::app::Message;
use iced::widget::{column, container, image, mouse_area, row, text}; use iced::widget::{canvas, column, container, row, text};
use iced::{Element, Length}; 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. /// `local_pos` is relative to the pane's top-left corner (i.e. already has
/// The checker uses two shades of gray in an 8x8 pixel grid pattern. /// `bounds.x/y` subtracted). `pane_size` is `bounds.size()`. Returns
fn checkerboard_rgba(w: u32, h: u32) -> Vec<u8> { /// `(Some(x), Some(y))` when inside the canvas image bounds, otherwise
let mut pixels = vec![0u8; (w * h * 4) as usize]; /// `(None, None)`.
let size = 8u32; fn screen_to_canvas_local(
for y in 0..h { local_pos: Point,
for x in 0..w { pane_size: Size,
let idx = ((y * w + x) * 4) as usize; engine_w: f32,
let is_light = ((x / size) + (y / size)) % 2 == 0; engine_h: f32,
let v: u8 = if is_light { 180 } else { 140 }; zoom: f32,
pixels[idx] = v; pan_offset: Vector,
pixels[idx + 1] = v; ) -> (Option<f32>, Option<f32>) {
pixels[idx + 2] = v; let display_w = engine_w * zoom;
pixels[idx + 3] = 255; 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. /// Build the canvas viewport element.
/// ///
/// The composite texture is displayed over a checkerboard background /// The canvas fills the available pane space and draws the composite texture
/// that indicates transparency. Mouse interaction is handled via /// at the correct position/scale. Mouse coordinates are converted to canvas-space.
/// `mouse_area` wrapper for press, move, scroll, and release events.
pub fn view<'a>( pub fn view<'a>(
doc: &'a crate::app::IcedDocument, doc: &'a crate::app::IcedDocument,
tool_state: &'a crate::app::ToolState, tool_state: &'a crate::app::ToolState,
@@ -43,52 +626,27 @@ pub fn view<'a>(
let engine_w = doc.engine.canvas_width(); let engine_w = doc.engine.canvas_width();
let engine_h = doc.engine.canvas_height(); let engine_h = doc.engine.canvas_height();
let zoom = doc.zoom; let zoom = doc.zoom;
let pan_offset = doc.pan_offset;
let display_w = engine_w as f32 * zoom; // Composite texture handle from engine buffer (zero-copy Bytes clone).
let display_h = engine_h as f32 * zoom; let composite_handle = ImageHandle::from_rgba(engine_w, engine_h, doc.composite_buffer.clone());
// Checkerboard background for transparency let program = CanvasProgram {
let checker = checkerboard_rgba(engine_w, engine_h); engine_w,
let checker_handle = image::Handle::from_rgba(engine_w, engine_h, checker); engine_h,
let checker_img = image(checker_handle) zoom,
.width(display_w) pan_offset,
.height(display_h); composite_handle,
selection_rect: doc.selection_rect,
vector_draw: doc.vector_draw,
render_generation: doc.render_generation,
};
// Composite texture on top let canvas = canvas(program)
let pixels = doc.composite_buffer.clone(); .width(Length::Fill)
let handle = image::Handle::from_rgba(engine_w, engine_h, pixels); .height(Length::Fill);
let composite_img = image(handle)
.width(display_w)
.height(display_h);
// Stack checkerboard behind composite // Tool info overlay
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
let overlay_info = if let Some((x0, y0, x1, y1)) = doc.selection_rect { let overlay_info = if let Some((x0, y0, x1, y1)) = doc.selection_rect {
let w = (x1 - x0).abs() as u32; let w = (x1 - x0).abs() as u32;
let h = (y1 - y0).abs() as u32; let h = (y1 - y0).abs() as u32;
@@ -127,12 +685,14 @@ pub fn view<'a>(
.padding([2, 8]); .padding([2, 8]);
column![ column![
container(interactive_canvas) container(canvas)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill), .height(Length::Fill)
.clip(true),
tool_info, tool_info,
] ]
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.clip(true)
.into() .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) .padding(16)
.width(400); .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) 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_x(Length::Fill)
.center_y(Length::Fill) .center_y(Length::Fill)
.style(|_theme| iced::widget::container::Style { .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() ..Default::default()
}) })
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill);
.into() backdrop.into()
} }
@@ -8,16 +8,17 @@
//! +--------+------------+-----------+---------+ //! +--------+------------+-----------+---------+
//! | | Brushes | | Layers | //! | | Brushes | | Layers |
//! | Tools | & Tips | |---------| //! | Tools | & Tips | |---------|
//! | (36px) | | Canvas | History | //! | (36px) |------------| Canvas | Properties
//! | |------------| |---------| //! | | Filters | |---------|
//! | | Filters | | Color | //! | | | ColorBox | History |
//! +--------+-----+------+-----------+---------+ //! +--------+------------+-----------+---------+
//! ``` //! ```
use iced::widget::pane_grid::{Configuration, Pane, State}; use iced::widget::pane_grid::{Configuration, Pane, State};
/// Identifies which panel content a pane displays. /// Identifies which panel content a pane displays.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum PaneType { pub enum PaneType {
/// The main canvas viewport. /// The main canvas viewport.
Canvas, Canvas,
@@ -39,8 +40,6 @@ pub enum PaneType {
AiChat, AiChat,
/// Layer Styles panel. /// Layer Styles panel.
LayerStyles, LayerStyles,
/// Toolbox (vertical tool strip).
Tools,
/// Layer Details panel. /// Layer Details panel.
LayerDetails, LayerDetails,
/// Geometry panel (vector shapes). /// Geometry panel (vector shapes).
@@ -61,7 +60,6 @@ impl PaneType {
PaneType::Script => "Script", PaneType::Script => "Script",
PaneType::AiChat => "AI Assistant", PaneType::AiChat => "AI Assistant",
PaneType::LayerStyles => "Layer Styles", PaneType::LayerStyles => "Layer Styles",
PaneType::Tools => "Tools",
PaneType::LayerDetails => "Layer Details", PaneType::LayerDetails => "Layer Details",
PaneType::Geometry => "Geometry", PaneType::Geometry => "Geometry",
} }
@@ -73,61 +71,80 @@ pub struct DockState {
/// The underlying iced PaneGrid state. /// The underlying iced PaneGrid state.
pub pane_grid: State<PaneType>, pub pane_grid: State<PaneType>,
/// The root pane (always the canvas). /// The root pane (always the canvas).
#[allow(dead_code)]
pub root_pane: Pane, pub root_pane: Pane,
/// Currently focused pane. /// Currently focused pane.
pub focused_pane: Option<Pane>, pub focused_pane: Option<Pane>,
} }
impl DockState { 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: /// The toolbox is NOT in the dock — it's a fixed 36px strip on the left
/// - Left column: Tools strip + (Brushes / Filters) /// edge, outside the pane grid.
/// - Center: Canvas (fills available space) ///
/// - Right column: (Layers / History) + ColorPicker /// 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 { pub fn new() -> Self {
// Build the layout using Configuration for proper centering: // Left column: Brushes (top) / Filters (bottom) — horizontal split
//
// 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)
let left_column = Configuration::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, axis: iced::widget::pane_grid::Axis::Horizontal,
ratio: 0.5, ratio: 0.5,
a: Box::new(Configuration::Pane(PaneType::Brushes)), a: Box::new(Configuration::Pane(PaneType::Brushes)),
b: Box::new(Configuration::Pane(PaneType::Filters)), 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, axis: iced::widget::pane_grid::Axis::Horizontal,
ratio: 0.6, ratio: 0.4,
a: Box::new(Configuration::Split { 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, axis: iced::widget::pane_grid::Axis::Horizontal,
ratio: 0.5, ratio: 0.5,
a: Box::new(Configuration::Pane(PaneType::Layers)), a: Box::new(Configuration::Pane(PaneType::Layers)),
b: Box::new(Configuration::Pane(PaneType::History)), b: Box::new(Configuration::Pane(PaneType::Properties)),
}),
b: Box::new(Configuration::Pane(PaneType::ColorPicker)),
}; };
// 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 { let root_config = Configuration::Split {
axis: iced::widget::pane_grid::Axis::Vertical, axis: iced::widget::pane_grid::Axis::Vertical,
ratio: 0.20, ratio: 0.17,
a: Box::new(left_column), a: Box::new(left_column),
b: Box::new(Configuration::Split { b: Box::new(center_right),
axis: iced::widget::pane_grid::Axis::Vertical,
ratio: 0.70,
a: Box::new(Configuration::Pane(PaneType::Canvas)),
b: Box::new(right_column),
}),
}; };
let pane_grid = State::with_configuration(root_config); let pane_grid = State::with_configuration(root_config);
@@ -146,6 +163,7 @@ impl DockState {
} }
/// Split a pane horizontally with a new panel type. /// 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> { pub fn split_horizontal(&mut self, target: Pane, pane_type: PaneType) -> Option<Pane> {
let (new_pane, _) = self.pane_grid.split( let (new_pane, _) = self.pane_grid.split(
iced::widget::pane_grid::Axis::Horizontal, iced::widget::pane_grid::Axis::Horizontal,
@@ -156,6 +174,7 @@ impl DockState {
} }
/// Split a pane vertically with a new panel type. /// 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> { pub fn split_vertical(&mut self, target: Pane, pane_type: PaneType) -> Option<Pane> {
let (new_pane, _) = self.pane_grid.split( let (new_pane, _) = self.pane_grid.split(
iced::widget::pane_grid::Axis::Vertical, iced::widget::pane_grid::Axis::Vertical,
@@ -190,6 +209,7 @@ impl DockState {
} }
/// Get the currently focused pane type. /// Get the currently focused pane type.
#[allow(dead_code)]
pub fn focused_type(&self) -> Option<PaneType> { pub fn focused_type(&self) -> Option<PaneType> {
self.focused_pane.and_then(|p| self.pane_type(p)) self.focused_pane.and_then(|p| self.pane_type(p))
} }
@@ -8,6 +8,7 @@
use crate::app::Message; use crate::app::Message;
use crate::dock::state::{DockState, PaneType}; use crate::dock::state::{DockState, PaneType};
use crate::panels::styles; use crate::panels::styles;
use iced::widget::svg;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use iced::widget::pane_grid::{Content, TitleBar}; use iced::widget::pane_grid::{Content, TitleBar};
use iced::widget::{button, column, container, pane_grid, row, text}; 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(); let colors = app.theme_state.colors();
pane_grid::PaneGrid::new(&dock.pane_grid, |pane, pane_type, _is_maximized| { 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 { let body: Element<'a, Message> = match pane_type {
PaneType::Canvas => { 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 doc_tab_bar = document_tab_bar(app, colors);
let canvas = { let canvas = {
let doc = &app.documents[app.active_doc]; let doc = &app.documents[app.active_doc];
@@ -40,9 +47,7 @@ pub fn dock_view<'a>(
.height(Length::Fill) .height(Length::Fill)
.into() .into()
} }
PaneType::Tools => { // Tools is no longer in the dock — toolbox is a fixed strip on the left
crate::sidebar::view(&app.tool_state, &app.fg_color, &app.bg_color, colors, app.toolbox_two_column)
}
PaneType::Layers => { PaneType::Layers => {
let doc = &app.documents[app.active_doc]; let doc = &app.documents[app.active_doc];
crate::panels::layers::view(&doc.cached_layers, doc.engine.active_layer_id(), colors) crate::panels::layers::view(&doc.cached_layers, doc.engine.active_layer_id(), colors)
@@ -53,6 +58,7 @@ pub fn dock_view<'a>(
} }
PaneType::Brushes => { PaneType::Brushes => {
crate::panels::brushes::view( crate::panels::brushes::view(
app.tool_state.brush_style,
app.tool_state.brush_size, app.tool_state.brush_size,
app.tool_state.brush_opacity, app.tool_state.brush_opacity,
app.tool_state.brush_hardness, 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) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
@@ -102,6 +122,22 @@ pub fn dock_view<'a>(
.on_click(Message::PaneClicked) .on_click(Message::PaneClicked)
.on_drag(Message::PaneDragged) .on_drag(Message::PaneDragged)
.on_resize(4, Message::PaneResized) .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() .into()
} }
@@ -109,7 +145,7 @@ pub fn dock_view<'a>(
/// ///
/// Shows a horizontal tab for each open document. The active document tab /// Shows a horizontal tab for each open document. The active document tab
/// is highlighted. Each tab shows the document name and a close button. /// 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> { fn document_tab_bar<'a>(app: &'a crate::app::HcieIcedApp, colors: ThemeColors) -> Element<'a, Message> {
let mut tabs = row![].spacing(0); 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), color: Some(colors.accent),
}) })
} else { } 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 { let active = is_active;
styles::active_tab_background(colors) let c = colors;
} else {
styles::inactive_tab_background(colors)
};
let tab = container( let tab = button(
row![ row![
tab_text, tab_text,
button(text("×").size(10)) button(text("×").size(10))
.on_press(Message::NoOp) .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) .spacing(4)
.align_y(iced::Alignment::Center) .align_y(iced::Alignment::Center)
) )
.on_press(Message::DocSwitch(idx))
.padding([4, 8]) .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; tabs = tabs.push(tab);
let clickable = iced::widget::mouse_area(tab)
.on_press(Message::DocSwitch(doc_idx));
tabs = tabs.push(clickable);
} }
// Add new document button // Add new document button with hover
let new_btn = button(text("+").size(12)) let new_btn = button(text("+").size(12))
.on_press(Message::DialogOpen(crate::app::ActiveDialog::NewImage)) .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); tabs = tabs.push(new_btn);
@@ -166,39 +273,114 @@ fn document_tab_bar<'a>(app: &'a crate::app::HcieIcedApp, colors: ThemeColors) -
.into() .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. /// Create a Photoshop-style title bar for a pane.
/// ///
/// Shows the pane type label with close and maximize buttons. /// Shows a panel type icon + label with close and maximize buttons.
/// No horizontal rule, no blue tint - clean modern look. /// 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> { fn make_title_bar<'a>(pane: pane_grid::Pane, pane_type: PaneType, colors: ThemeColors) -> TitleBar<'a, Message> {
let title = text(pane_type.label()) // Panel type icon
.size(11) let icon_paths = [
.color(colors.text_secondary); 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 title_content: Element<'_, Message> = if let Some(icon_file) = pane_type_icon(pane_type) {
let close_btn = button(text("×").size(11).color(colors.text_secondary)) 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)) .on_press(Message::PaneClose(pane))
.padding([2, 6]) .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))), background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
text_color: colors.text_secondary, text_color: colors.text_secondary,
border: iced::Border::default(), border: iced::Border::default(),
..Default::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)) .on_press(Message::PaneMaximize(pane))
.padding([2, 6]) .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))), background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
text_color: colors.text_secondary, text_color: colors.text_secondary,
border: iced::Border::default(), border: iced::Border::default(),
..Default::default() ..Default::default()
},
}
}); });
TitleBar::new( 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) .spacing(2)
.align_y(iced::Alignment::Center) .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"))] #[cfg(not(target_arch = "wasm32"))]
fn decode_image_to_rgba(bytes: &[u8]) -> Option<(Vec<u8>, u32, u32)> { fn decode_image_to_rgba(bytes: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
#[allow(unused_imports)]
use image::GenericImageView; use image::GenericImageView;
let img = image::load_from_memory(bytes).ok()?; let img = image::load_from_memory(bytes).ok()?;
@@ -12,6 +12,7 @@ use std::sync::{Arc, Mutex};
/// Source of the most recent pressure value. /// Source of the most recent pressure value.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum TabletSource { pub enum TabletSource {
NoTablet, NoTablet,
Winit, Winit,
@@ -57,21 +58,25 @@ impl TabletState {
} }
/// Get the last known screen position. /// Get the last known screen position.
#[allow(dead_code)]
pub fn current_pos(&self) -> Option<(f32, f32)> { pub fn current_pos(&self) -> Option<(f32, f32)> {
self.pos self.pos
} }
/// Whether the stylus is in proximity. /// Whether the stylus is in proximity.
#[allow(dead_code)]
pub fn in_proximity(&self) -> bool { pub fn in_proximity(&self) -> bool {
self.in_proximity self.in_proximity
} }
/// Get the pressure source. /// Get the pressure source.
#[allow(dead_code)]
pub fn source(&self) -> TabletSource { pub fn source(&self) -> TabletSource {
self.source self.source
} }
/// Update pressure from a touch event. /// Update pressure from a touch event.
#[allow(dead_code)]
pub fn set_touch_pressure(&mut self, pressure: f32) { pub fn set_touch_pressure(&mut self, pressure: f32) {
self.pressure = pressure.clamp(0.0, 1.0); self.pressure = pressure.clamp(0.0, 1.0);
self.source = TabletSource::Touch; self.source = TabletSource::Touch;
@@ -84,6 +89,7 @@ impl TabletState {
} }
/// Set the screen-space stylus position. /// Set the screen-space stylus position.
#[allow(dead_code)]
pub fn set_screen_pos(&mut self, x: f32, y: f32) { pub fn set_screen_pos(&mut self, x: f32, y: f32) {
self.pos = Some((x, y)); self.pos = Some((x, y));
self.in_proximity = true; self.in_proximity = true;
@@ -98,11 +104,13 @@ impl TabletState {
} }
/// Update the cached screen size for evdev position mapping. /// Update the cached screen size for evdev position mapping.
#[allow(dead_code)]
pub fn set_screen_size(&mut self, w: f32, h: f32) { pub fn set_screen_size(&mut self, w: f32, h: f32) {
self.screen_size = Some((w, h)); self.screen_size = Some((w, h));
} }
/// Reset pressure to default (for mouse input). /// Reset pressure to default (for mouse input).
#[allow(dead_code)]
pub fn reset_pressure(&mut self) { pub fn reset_pressure(&mut self) {
self.pressure = 1.0; self.pressure = 1.0;
self.source = TabletSource::NoTablet; self.source = TabletSource::NoTablet;
@@ -59,9 +59,10 @@ fn main() {
.subscription(HcieIcedApp::subscription) .subscription(HcieIcedApp::subscription)
.theme(HcieIcedApp::theme) .theme(HcieIcedApp::theme)
.window_size((1280.0, 800.0)) .window_size((1280.0, 800.0))
.decorations(false)
.run_with(move || { .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.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) /// - Selecting an AI provider (Ollama, Claude, OpenAI)
/// - Generating a script from the prompt /// - Generating a script from the prompt
/// - Viewing and running the generated script /// - Viewing and running the generated script
#[allow(dead_code)]
pub fn view() -> Element<'static, Message> { pub fn view() -> Element<'static, Message> {
let provider_label = text("Provider").size(10); let provider_label = text("Provider").size(10);
let provider_selector = row![ let provider_selector = row![
@@ -1,71 +1,301 @@
//! Brushes panel — brush preset browser with categories and style selection. //! Brushes panel — brush preset browser with visual thumbnails and live preview.
//! Uses ThemeColors for consistent styling. //!
//! 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::app::Message;
use crate::panels::styles; use crate::panels::styles;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use hcie_engine_api::BrushStyle; 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 iced::{Element, Length};
use std::sync::OnceLock;
/// A brush preset entry. /// A brush preset entry with category.
#[allow(dead_code)]
struct BrushPreset { struct BrushPreset {
name: &'static str, name: &'static str,
style: BrushStyle, style: BrushStyle,
category: BrushCategory,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
enum BrushCategory {
All,
Drawing,
Painting,
Effects,
} }
const BRUSH_PRESETS: &[BrushPreset] = &[ const BRUSH_PRESETS: &[BrushPreset] = &[
BrushPreset { name: "Round Soft", style: BrushStyle::SoftRound }, // Drawing
BrushPreset { name: "Round Hard", style: BrushStyle::HardRound }, BrushPreset { name: "Default", style: BrushStyle::Default, category: BrushCategory::Drawing },
BrushPreset { name: "Square", style: BrushStyle::Square }, BrushPreset { name: "Pencil", style: BrushStyle::Pencil, category: BrushCategory::Drawing },
BrushPreset { name: "Pencil", style: BrushStyle::Pencil }, BrushPreset { name: "Pen", style: BrushStyle::Pen, category: BrushCategory::Drawing },
BrushPreset { name: "Pen", style: BrushStyle::Pen }, BrushPreset { name: "Ink Pen", style: BrushStyle::InkPen, category: BrushCategory::Drawing },
BrushPreset { name: "Ink Pen", style: BrushStyle::InkPen }, BrushPreset { name: "Calligraphy", style: BrushStyle::Calligraphy, category: BrushCategory::Drawing },
BrushPreset { name: "Calligraphy", style: BrushStyle::Calligraphy }, BrushPreset { name: "Sketch", style: BrushStyle::Sketch, category: BrushCategory::Drawing },
BrushPreset { name: "Marker", style: BrushStyle::Marker }, BrushPreset { name: "Marker", style: BrushStyle::Marker, category: BrushCategory::Drawing },
BrushPreset { name: "Sketch", style: BrushStyle::Sketch }, BrushPreset { name: "Hatch", style: BrushStyle::Hatch, category: BrushCategory::Drawing },
BrushPreset { name: "Hatch", style: BrushStyle::Hatch }, // Painting
BrushPreset { name: "Oil Paint", style: BrushStyle::Oil }, BrushPreset { name: "Oil", style: BrushStyle::Oil, category: BrushCategory::Painting },
BrushPreset { name: "Charcoal", style: BrushStyle::Charcoal }, BrushPreset { name: "Charcoal", style: BrushStyle::Charcoal, category: BrushCategory::Painting },
BrushPreset { name: "Watercolor", style: BrushStyle::Watercolor }, BrushPreset { name: "Watercolor", style: BrushStyle::Watercolor, category: BrushCategory::Painting },
BrushPreset { name: "Crayon", style: BrushStyle::Crayon }, BrushPreset { name: "Crayon", style: BrushStyle::Crayon, category: BrushCategory::Painting },
BrushPreset { name: "Airbrush", style: BrushStyle::Airbrush }, BrushPreset { name: "Airbrush", style: BrushStyle::Airbrush, category: BrushCategory::Painting },
BrushPreset { name: "Spray", style: BrushStyle::Spray }, BrushPreset { name: "Spray", style: BrushStyle::Spray, category: BrushCategory::Painting },
BrushPreset { name: "Star", style: BrushStyle::Star }, BrushPreset { name: "Soft Round", style: BrushStyle::SoftRound, category: BrushCategory::Painting },
BrushPreset { name: "Noise", style: BrushStyle::Noise }, BrushPreset { name: "Hard Round", style: BrushStyle::HardRound, category: BrushCategory::Painting },
BrushPreset { name: "Texture", style: BrushStyle::Texture }, // Effects
BrushPreset { name: "Glow", style: BrushStyle::Glow }, BrushPreset { name: "Star", style: BrushStyle::Star, category: BrushCategory::Effects },
BrushPreset { name: "Leaf", style: BrushStyle::Leaf }, BrushPreset { name: "Noise", style: BrushStyle::Noise, category: BrushCategory::Effects },
BrushPreset { name: "Clouds", style: BrushStyle::Clouds }, BrushPreset { name: "Texture", style: BrushStyle::Texture, category: BrushCategory::Effects },
BrushPreset { name: "Default", style: BrushStyle::Default }, 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( pub fn view(
current_style: BrushStyle,
current_size: f32, current_size: f32,
current_opacity: f32, current_opacity: f32,
current_hardness: f32, current_hardness: f32,
colors: ThemeColors, colors: ThemeColors,
) -> Element<'static, Message> { ) -> 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 mut tabs = row![].spacing(2);
let name = preset.name; for (_cat, label) in categories {
let style = preset.style; let btn = button(text(label).size(10))
.on_press(Message::NoOp) // TODO: category filter
let item = container( .padding([4, 8])
text(name).size(11) .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
) match status {
.width(Length::Fill) iced::widget::button::Status::Hovered => iced::widget::button::Style {
.padding([4, 6]) background: Some(iced::Background::Color(colors.bg_hover)),
.style(move |_theme| styles::inactive_item_bg(colors)); text_color: colors.text_primary,
border: iced::Border::default(),
let clickable = iced::widget::mouse_area(item) ..Default::default()
.on_press(Message::BrushStyleSelected(style)); },
_ => iced::widget::button::Style {
preset_list = preset_list.push(clickable); 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)) let size_slider = slider(1.0..=200.0, current_size, |v| Message::BrushSizeChanged(v))
.step(1.0) .step(1.0)
.width(Length::Fill); .width(Length::Fill);
@@ -78,11 +308,31 @@ pub fn view(
.step(0.01) .step(0.01)
.width(Length::Fill); .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![ let panel = column![
text("Brushes").size(13).font(iced::Font::MONOSPACE), text("Brushes & Tips").size(13).font(iced::Font::MONOSPACE),
tabs,
horizontal_rule(1), horizontal_rule(1),
scrollable(preset_list).height(Length::Fill), scrollable(grid_rows).height(Length::Fill),
horizontal_rule(1), horizontal_rule(1),
text("Preview").size(10),
live_preview,
text(format!("Size: {:.0}", current_size)).size(10), text(format!("Size: {:.0}", current_size)).size(10),
size_slider, size_slider,
text(format!("Opacity: {:.0}%", current_opacity * 100.0)).size(10), 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) //! The menu bar itself is part of the unified title bar (title_bar.rs).
//! with functional dropdown menus that open on click and close on item selection. //! 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 crate::app::Message;
use hcie_engine_api::Tool; use iced::widget::{column, container, horizontal_rule, mouse_area, row, text};
use iced::widget::{button, column, container, horizontal_rule, row, text};
use iced::{Element, Length}; use iced::{Element, Length};
/// Menu item definition. /// Menu item definition.
#[allow(dead_code)]
struct MenuItem { struct MenuItem {
label: &'static str, label: &'static str,
shortcut: &'static str, shortcut: &'static str,
@@ -35,6 +36,7 @@ impl MenuItem {
/// All menu definitions. /// All menu definitions.
struct MenuDef { struct MenuDef {
#[allow(dead_code)]
label: &'static str, label: &'static str,
items: Vec<MenuItem>, 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). /// Returns `None` if `active_menu` is `None`. The returned element is meant
pub fn view<'a>(_active_tool: &Tool, active_menu: Option<usize>) -> Element<'a, Message> { /// 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 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); let mut items = column![].spacing(0);
for (item_idx, item) in menu.items.iter().enumerate() { 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( items = items.push(
container(horizontal_rule(1)) container(horizontal_rule(1))
.width(Length::Fill) .width(Length::Fill)
.padding([2, 0]) .padding([2, 8])
); );
} else { } else {
let label_row: Element<'_, Message> = if item.shortcut.is_empty() { 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() .into()
}; };
let item_container = container(label_row) let enabled = item.enabled;
.width(Length::Fill)
.padding([4, 16]);
let item_container = if item.enabled {
let m_idx = menu_idx; let m_idx = menu_idx;
let i_idx = item_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)) .on_press(Message::MenuAction(m_idx, i_idx))
.interaction(iced::mouse::Interaction::Pointer) .padding(0)
} else { .style(|_theme: &iced::Theme, status: iced::widget::button::Status| {
iced::widget::mouse_area(item_container) match status {
.interaction(iced::mouse::Interaction::NotAllowed) 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),
items = items.push(item_container); border: iced::Border::default(),
}
}
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),
..Default::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 /// 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 { iced::widget::container::Style {
background: Some(iced::Background::Color( background: Some(iced::Background::Color(
iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0) 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 /// Hover item
#[allow(dead_code)]
pub fn hover_item_bg(colors: ThemeColors) -> iced::widget::container::Style { pub fn hover_item_bg(colors: ThemeColors) -> iced::widget::container::Style {
iced::widget::container::Style { iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_hover)), 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 /// Menu bar background
#[allow(dead_code)]
pub fn menubar_background(colors: ThemeColors) -> iced::widget::container::Style { pub fn menubar_background(colors: ThemeColors) -> iced::widget::container::Style {
iced::widget::container::Style { iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_app)), 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 /// Title bar background
#[allow(dead_code)]
pub fn titlebar_background(colors: ThemeColors) -> iced::widget::container::Style { pub fn titlebar_background(colors: ThemeColors) -> iced::widget::container::Style {
iced::widget::container::Style { iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_app)), 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 /// Dropdown menu background
#[allow(dead_code)]
pub fn dropdown_background(colors: ThemeColors) -> iced::widget::container::Style { pub fn dropdown_background(colors: ThemeColors) -> iced::widget::container::Style {
iced::widget::container::Style { iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_panel)), 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 /// Active document tab
#[allow(dead_code)]
pub fn active_tab_background(colors: ThemeColors) -> iced::widget::container::Style { pub fn active_tab_background(colors: ThemeColors) -> iced::widget::container::Style {
iced::widget::container::Style { iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_active)), 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 /// Inactive document tab
#[allow(dead_code)]
pub fn inactive_tab_background(colors: ThemeColors) -> iced::widget::container::Style { pub fn inactive_tab_background(colors: ThemeColors) -> iced::widget::container::Style {
iced::widget::container::Style { iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_panel)), 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}; use iced::{Element, Length};
/// Build the text editor panel. /// Build the text editor panel.
#[allow(dead_code)]
pub fn view( pub fn view(
font_size: f32, font_size: f32,
font_color: [u8; 4], font_color: [u8; 4],
@@ -1,63 +1,180 @@
//! Title bar — custom title bar with app icon, version, document name, and window controls. //! Title bar — Photoshop-style unified bar with menus, doc info, and window controls.
//! Uses ThemeColors for consistent styling. //!
//! 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::app::Message;
use crate::panels::styles;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use iced::widget::{button, container, row, text}; use iced::widget::{button, container, mouse_area, row, text};
use iced::{Element, Length}; 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>( pub fn view<'a>(
doc_name: &'a str, doc_name: &'a str,
zoom: f32, zoom: f32,
canvas_w: u32, canvas_w: u32,
canvas_h: u32, canvas_h: u32,
colors: ThemeColors, colors: ThemeColors,
active_menu: Option<usize>,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
// ── App name (fixed width) ──
let app_name = text("HCIE") let app_name = text("HCIE")
.size(14) .size(13)
.font(iced::Font::MONOSPACE) .font(iced::Font::MONOSPACE)
.style(move |_theme: &iced::Theme| iced::widget::text::Style { .style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(colors.accent), 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) .size(10)
.style(move |_theme: &iced::Theme| iced::widget::text::Style { .style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(colors.text_secondary), color: Some(colors.text_secondary),
}); });
let doc_info = text(format!( // ── Window controls (right side) with hover states ──
"{}{}×{} @ {:.0}%", let c = colors;
doc_name, canvas_w, canvas_h, zoom * 100.0
))
.size(12);
let minimize_btn = button(text("").size(10)).on_press(Message::NoOp).padding([4, 8]); let minimize_btn = button(text("").size(10))
let maximize_btn = button(text("").size(10)).on_press(Message::NoOp).padding([4, 8]); .on_press(Message::WindowMinimize)
let close_btn = button(text("×").size(10)) .padding([4, 8])
.on_press(Message::DialogOpen(crate::app::ActiveDialog::CloseConfirm)) .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
.padding([4, 8]); 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, app_name,
version, text(" ").size(11),
text(" ").size(12), menu_items,
iced::widget::Space::with_width(Length::Fill),
doc_info, 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) .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) container(bar)
.width(Length::Fill) .width(Length::Fill)
.height(28) .height(28)
.align_y(iced::Alignment::Center) .style(move |_theme| iced::widget::container::Style {
.style(move |_theme| styles::titlebar_background(colors)) background: Some(iced::Background::Color(colors.bg_app)),
border: iced::Border::default().color(colors.border_low).width(1),
..Default::default()
})
.into() .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: //! Features:
//! - Single column (36px) or two-column (72px) mode //! - Always single column (36px wide)
//! - Toggle button (◀/▶) at top to expand/collapse //! - SVG icons with hover/active states
//! - SVG icons with active/hover states //! - Overlapping fg/bg color swatches at bottom
//! - Overlapping color swatches at bottom //! - Tooltip on hover
//! - Submenu indicator for tools with sub-tools
use crate::app::{Message, ToolState}; use crate::app::{Message, ToolState};
use crate::panels::styles; use crate::panels::styles;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use hcie_engine_api::Tool; 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}; use iced::{Element, Length};
/// Tool slot definition — (primary tool, label, svg_path, sub_tools). /// Tool slot definition — (primary tool, label, svg_path).
struct ToolSlot { struct ToolSlot {
tool: Tool, tool: Tool,
#[allow(dead_code)]
label: &'static str, label: &'static str,
icon: &'static str, icon: &'static str,
} }
@@ -42,65 +42,19 @@ const TOOL_SLOTS: &[ToolSlot] = &[
ToolSlot { tool: Tool::VectorCircle, label: "Vector Circle", icon: "icons/circle.svg" }, 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>( pub fn view<'a>(
tool_state: &'a ToolState, tool_state: &'a ToolState,
fg_color: &'a [u8; 4], fg_color: &'a [u8; 4],
bg_color: &'a [u8; 4], bg_color: &'a [u8; 4],
colors: ThemeColors, colors: ThemeColors,
two_column: bool,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let slot_w: f32 = if two_column { 36.0 } else { 36.0 }; // Tool buttons — single column
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
let mut tool_list = column![].spacing(1); 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 { for slot in TOOL_SLOTS {
let btn = tool_button(slot, tool_state, colors); let btn = tool_button(slot, tool_state, colors);
tool_list = tool_list.push(btn); tool_list = tool_list.push(btn);
} }
}
// Color swatches at bottom // Color swatches at bottom
let fg = fg_color; let fg = fg_color;
@@ -128,12 +82,25 @@ pub fn view<'a>(
..Default::default() ..Default::default()
}); });
let swap_btn = mouse_area( let swap_btn = button(text("").size(8).color(colors.text_secondary))
container(text("").size(8).color(colors.text_secondary))
.padding(2)
)
.on_press(Message::SwapColors) .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( let color_section = container(
row![fg_swatch, bg_swatch, swap_btn].spacing(1) row![fg_swatch, bg_swatch, swap_btn].spacing(1)
@@ -141,20 +108,18 @@ pub fn view<'a>(
.padding([4, 0]) .padding([4, 0])
.center_x(Length::Fill); .center_x(Length::Fill);
// Separator lines // Separator line
let sep_style = move |_theme: &iced::Theme| iced::widget::container::Style { let sep_style = move |_theme: &iced::Theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.border_low)), background: Some(iced::Background::Color(colors.border_low)),
..Default::default() ..Default::default()
}; };
let sidebar = column![ let sidebar = column![
toggle_row, container(tool_list).padding([2, 0]),
container(text("").height(1)).width(Length::Fill).style(sep_style.clone()),
tool_list,
container(text("").height(1)).width(Length::Fill).style(sep_style), container(text("").height(1)).width(Length::Fill).style(sep_style),
color_section, color_section,
] ]
.width(sidebar_w) .width(36)
.spacing(0); .spacing(0);
container(sidebar) container(sidebar)
@@ -163,35 +128,27 @@ pub fn view<'a>(
.into() .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> { fn tool_button<'a>(slot: &ToolSlot, tool_state: &ToolState, colors: ThemeColors) -> Element<'a, Message> {
let is_active = std::mem::discriminant(&tool_state.active_tool) let is_active = std::mem::discriminant(&tool_state.active_tool)
== std::mem::discriminant(&slot.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 // Try to load SVG icon - search multiple paths
let icon_paths = [ let icon_paths = [
std::path::PathBuf::from(slot.icon), // relative to cwd std::path::PathBuf::from(slot.icon),
std::path::Path::new("hcie-iced-app/assets").join(slot.icon), // relative to project root 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)), // absolute 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 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 handle = svg::Handle::from_path(icon_path);
let icon_color = if is_active { colors.accent } else { colors.text_primary };
svg(handle) svg(handle)
.width(20) .width(20)
.height(20) .height(20)
@@ -200,28 +157,75 @@ fn tool_button<'a>(slot: &ToolSlot, tool_state: &ToolState, colors: ThemeColors)
}) })
.into() .into()
} else { } else {
// Fallback to text label
text(slot.label.chars().next().unwrap_or('?').to_string()) text(slot.label.chars().next().unwrap_or('?').to_string())
.size(14) .size(14)
.color(if is_active { colors.accent } else { colors.text_primary }) .color(icon_color)
.into() .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) .width(28)
.height(28) .height(28)
.center_y(20) .center_y(20)
.center_x(20) .center_x(20)
.style(move |_theme| btn_style.clone()); )
let tool = slot.tool;
let clickable = mouse_area(btn)
.on_press(Message::ToolSelected(tool)) .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 // Tooltip on hover
iced::widget::tooltip( iced::widget::tooltip(
clickable, btn,
iced::widget::text(slot.label).size(11), iced::widget::text(slot.label).size(11),
iced::widget::tooltip::Position::Right, iced::widget::tooltip::Position::Right,
) )
@@ -27,6 +27,7 @@ impl ThemeState {
} }
/// Switch to a different theme preset. /// Switch to a different theme preset.
#[allow(dead_code)]
pub fn set_preset(&self, preset: ThemePreset) { pub fn set_preset(&self, preset: ThemePreset) {
*self.colors.lock().unwrap() = ThemeColors::get(preset); *self.colors.lock().unwrap() = ThemeColors::get(preset);
} }
+9 -9
View File
@@ -2,11 +2,11 @@
SATIR SAYISI RAPORU SATIR SAYISI RAPORU
Proje: HCIE-Rust v4 Proje: HCIE-Rust v4
Konum: /mnt/extra/00_PROJECTS/hcie-rust-v3.05 Konum: /mnt/extra/00_PROJECTS/hcie-rust-v3.05
Tarih: 2026-07-11 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-ai 2402 satır
hcie-blend 530 satır hcie-blend 530 satır
@@ -21,7 +21,7 @@ Tarih: 2026-07-11 15:12:11
hcie-filter 3238 satır hcie-filter 3238 satır
hcie-fx 4077 satır hcie-fx 4077 satır
hcie-history 100 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-io 11382 satır
hcie-kra 1392 satır hcie-kra 1392 satır
hcie-native 153 satır hcie-native 153 satır
@@ -34,7 +34,7 @@ Tarih: 2026-07-11 15:12:11
hcie-vector 2298 satır hcie-vector 2298 satır
hcie-vision 3131 satır hcie-vision 3131 satır
TOPLAM RUST: 95854 satır TOPLAM RUST: 97901 satır
----------------------------------------- -----------------------------------------
C++ / HEADER (.cpp, .h) C++ / HEADER (.cpp, .h)
@@ -102,14 +102,14 @@ Tarih: 2026-07-11 15:12:11
MARKDOWN (.md) 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) KOD SATIRLARI TOPLAMI (GRAND TOTAL)
========================================= =========================================
Rust: 95854 satır Rust: 97901 satır
C++ / Header: 5031 satır C++ / Header: 5031 satır
JavaScript: 0 satır JavaScript: 0 satır
Svelte: 0 satır Svelte: 0 satır
@@ -119,9 +119,9 @@ Tarih: 2026-07-11 15:12:11
CSS: 0 satır CSS: 0 satır
Shell Script: 1445 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) (JSON ve Markdown dosyaları yukarıda ayrı olarak gösterilmiştir)
========================================= =========================================