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