feat(gui): implement selection mask state and vector editing functionality

- Add `selection/state.rs` to manage selection masks, including combining masks and calculating bounds.
- Introduce `vector_edit.rs` for vector shape manipulation, handling drag sessions, and hit testing for handles.
- Create `feature_scorecard.rs` to ensure stable feature identifiers and regression gates for GUI components.
- Implement raster behavior tests in `raster_test.rs` to validate gradient application against masks.
This commit is contained in:
2026-07-16 18:41:05 +03:00
parent 026e268a10
commit 1240d25011
70 changed files with 11547 additions and 4357 deletions
@@ -0,0 +1,267 @@
# ICED GUI Parity: Scored Migration and Vector Tool Plan
## Goal
Bring every retained user-facing ICED editor feature to **at least 9/10 relative to egui**. Work proceeds in scored cycles. A cycle is incomplete until the complete scorecard is rerun and touched features reach target without regressing earlier work.
## Constraints
- GUI changes only under `hcie-iced-app/crates/`; `hcie-engine-api` remains the sole engine entry point.
- Preserve persistent wgpu textures, dirty-region uploads, viewport mapping, nearest filtering, and the `RefCell` dirty accumulator.
- Theme changes are UI-only. They must never invoke image/viewer loading, imports, dialogs, document replacement, or pixel mutation.
- Reachable `NoOp`, static fake controls, wrong menu aliases, and materially incorrect fallbacks score at most 2/10.
- Add ICED screenshot capture first; inspect screenshots after every visual change.
- Preserve unrelated working-tree changes.
## Scoring
| Score | Meaning |
|---|---|
| 0 | Absent/unusable |
| 1-2 | Placeholder; no real action |
| 3-4 | Partial, major paths missing |
| 5-6 | Basic workflow, substantial defects |
| 7 | Main workflow works, notable gaps |
| 8 | Near parity, minor gaps |
| 9 | Production parity: correct UI/action/undo/persistence as applicable, tested and visually verified |
| 10 | No known gap under covered scenarios |
## Baseline Inventory
Scores reflect the current working tree; the old vector `2.5/10` assessment is obsolete.
### Shell, Menus, Docking
| Feature | Current | Key gap | Target |
|---|---:|---|---:|
| Shell/title/tabs/window controls | 7 | Document close is `NoOp`; title drag overlaps menu region | 9 |
| Menu typography | 5 | Dropdown labels are thin/mixed contrast | 9 |
| Dropdown placement/event capture | 4 | Character-count X estimate; shifted/click-through risk | 9 |
| Menu command coverage | 3 | Most enabled-looking commands are labels/comments | 9 |
| Theme switching | 6 | Reported image-load side effect untested; no persistence | 9 |
| Dock resize/maximize/close | 8 | Main behavior works | 9 |
| Dock relocation | 6 | Drop only swaps; directional drop regions ignored | 9 |
| Dock profile/layout persistence | 5 | Does not serialize exact split tree/order/ratios | 9 |
| Panel reopen/focus | 7 | Coarse hardcoded placement | 9 |
| Settings persistence | 6 | Theme/toolbox/exact dock state missing | 9 |
| Internationalization | 3 | Most strings bypass i18n | 9 |
| ICED screenshot harness | 1 | F12 only refreshes | 9 |
### Canvas and Tools
| Feature | Current | Key gap | Target |
|---|---:|---|---:|
| GPU canvas/checkerboard/partial upload | 9 | Protected path active | 9 |
| Pan/zoom/100% | 8 | True fit-to-area incomplete | 9 |
| Fit/navigation/pinch/space-pan | 5 | Ctrl+0 sets 1.0; missing expected navigation | 9 |
| Brush/pressure | 8 | Modifier UX/cursor footprint | 9 |
| Pen | 5 | Mostly treated as Brush unlike egui pen segments | 9 |
| Eraser | 8 | Per-tool config/cursor polish | 9 |
| Spray | 7 | Verify density/particle settings reach engine | 9 |
| Flood fill | 7 | Contiguous/AA/history validation | 9 |
| Gradient | 5 | Stroke approximation, not true linear/radial gradient | 9 |
| Eyedropper | 7 | Ctrl-click temporary pick missing | 9 |
| Brush cursor/Shift resize HUD | 3 | Fixed crosshair only | 9 |
| Canvas context menu | 5 | Cut maps to Copy; partial actions | 9 |
| Rulers/grid/guides/snap/pixel grid | 1 | Menu shells only | 9 |
| Status/coordinates/pressure | 7 | Needs live cross-theme verification | 9 |
| Retouch tools | 2 | Selectable toolbox entries, incomplete execution/options | 9 |
### Selection, Transform, Clipboard
| Feature | Current | Key gap | Target |
|---|---:|---|---:|
| Rectangle selection | 8 | Minor parity/tests | 9 |
| Lasso/polygon | 7 | Preview/edge/close behavior | 9 |
| Magic wand | 7 | AA/contiguous partially `NoOp` | 9 |
| SmartSelect/SAM | 3 | Magic-wand fallback, no real backend flow | 9 |
| VisionSelect | 3 | Rectangle fallback, no model/task flow | 9 |
| Marching ants/fill | 5 | Bounds, not irregular mask edges | 9 |
| Raster transform | 7 | Undo/aspect/handles verification | 9 |
| Crop | 8 | Minor parity/tests | 9 |
| Copy/paste | 7 | External/internal selection tests | 9 |
| Cut/clear | 2 | Cut is Copy; Clear incomplete | 9 |
| Selection modify/load/save/quick mask | 4 | Some operations exist; exposed gaps remain | 9 |
### Vector and Text
| Feature | Current | Key gap | Target |
|---|---:|---|---:|
| Creation, 15 shape types | 8 | Verify layer safety/undo | 9 |
| Accurate previews | 7 | Several shapes remain approximate | 9 |
| VectorSelect/cycling | 7 | Implemented but insufficiently tested | 9 |
| Handles/hit testing | 7 | Rotated/hover/zoom behavior needs hardening | 9 |
| Move/8-way resize/rotate | 7 | Constraints, undo, edge cases | 9 |
| Apply/Cancel/snapshot | 7 | Verify exact restoration and overlay UX | 9 |
| Flip H/V | 6 | Verify supported shapes/output | 9 |
| Delete | 8 | Main path exists | 9 |
| Geometry list/selection | 8 | Main path exists | 9 |
| Bounds/fill/stroke/opacity/hardness | 8 | Precision/sync/tests | 9 |
| Line caps | 7 | Always-expanded list, weak selector UX | 9 |
| Boolean operations | 7 | Validation/failure/undo/result selection | 9 |
| Tool↔shape sync | 6 | Properties/Geometry/toolbar can diverge | 9 |
| Text create/inline edit | 6 | Focus/multiline/existing-layer edit/cancel | 9 |
| Text properties/effects | 6 | Full egui parity missing | 9 |
### Panels and Workflows
| Feature | Current | Key gap | Target |
|---|---:|---|---:|
| Layers | 8 | Hierarchy/duplicate/masks/fill opacity verification | 9 |
| History | 7 | Every operation needs correct undo boundary | 9 |
| Brushes & Tips | 7 | Category filter `NoOp`; advanced editor gaps | 9 |
| Color/palette/recent/alpha | 7 | Continuous wheel/recent UX/vector sync | 9 |
| Filters/preview | 8 | Audit every schema/default/apply/reset | 9 |
| Properties | 7 | Duplicate state surfaces may diverge | 9 |
| Tool Settings/toolbar | 7 | AA toggles/buttons remain `NoOp` | 9 |
| Layer Styles | 8 | Add-style placeholder/undo validation | 9 |
| Layer Details | 7 | Field/action parity audit | 9 |
| Script | 8 | Diagnostics/integration tests | 9 |
| AI Chat | 7 | Cancel/error/model discovery tests | 9 |
| AI Script | 7 | Provider/error/validation robustness | 9 |
| Viewer/browser | 7 | Cache/fullscreen/error/open-edit tests | 9 |
| Dialogs | 8 | Focus/validation/modal behavior | 9 |
| Plugins/actions/channels/histogram/navigator exposed by More | 1 | Labels only | 9 |
### Files and Documents
| Feature | Current | Key gap | Target |
|---|---:|---|---:|
| New/open/recent/import | 8 | Error and format coverage | 9 |
| Save/save-as/export | 6 | Unsupported menu claims/export paths | 9 |
| Multi-document tabs/dirty close | 6 | Tab close `NoOp`; dirty coverage | 9 |
| Image/canvas size/crop/merge/flatten | 8 | Regression tests | 9 |
| Layer duplicate/group/masks/rasterize/smart object | 3 | Menu shells/TODOs | 9 |
| Print/share/file-info/automation | 1 | Menu shells | 9 |
## Score Evidence Rules
A score may rise only when:
- UI is reachable and correctly positioned.
- A semantic message and real handler exist; no `NoOp` or wrong alias.
- State/engine output is correct.
- Undo/dirty state and persistence are correct where expected.
- Automated test passes.
- Visual behavior has an inspected screenshot.
Generate after every cycle under `target/iced-audit/`: full score table, failing evidence, test output, and screenshots. Add `tests/feature_scorecard.rs` with stable feature IDs and evidence test names.
## Cycle 0: Audit and Verification Infrastructure
1. Build a canonical registry from egui panes, tools, menus, dialogs, shortcuts and canvas interactions.
2. Add tests that fail for reachable `NoOp`, Cut→Copy aliases, enabled menu items without semantic handlers, or menu definition/index drift.
3. Add ICED screenshot CLI: `--screenshot output.png` and `--screenshot-panel "Panel" output.png`; settle layout/texture then crop from measured pane bounds.
4. Capture baseline full viewport, all menus, collapsed/expanded toolbox, Geometry, Properties, Layers, Color, and selected-vector canvas.
5. Run build/tests and record baseline scores. Do not raise scores without evidence.
6. **Recheck all scores.**
## Cycle 1: Requested UX Fixes
### Menus
Files: `panels/title_bar.rs`, `panels/menus.rs`, `app.rs`.
1. Replace approximate character-width offsets with measured menu-button anchor rectangles or an overlay anchored in the same layout subtree.
2. Place each dropdown at `anchor.x/anchor.bottom`, clamp to viewport, and make its outside-click layer fill/capture the viewport.
3. Separate draggable title space from menu buttons; menu clicks must not emit `WindowDrag` or reach underlying canvas/viewer.
4. Use a bundled readable UI font and Medium/Semibold weight for dropdown labels, shortcuts and arrows; use theme contrast tokens.
5. Replace raw `(menu_idx,item_idx)` dispatch with semantic `MenuCommand` generated from the menu definition.
6. Test all nine anchors at multiple widths/DPI; screenshot every dropdown in dark/light themes.
### Theme side-effect regression
1. Make theme commands synchronous: set colors, persist preset, close menu, repaint only.
2. Instrument file/import/viewer-preview/dialog/document replacement calls.
3. Test all themes preserve document ID/path/layers/composite hash/viewer selection/recent files/file-dialog state.
4. Switch themes while drawing and with viewer active/inactive; prove no image load or texture reset.
### Movable/reconfigurable panels
Files: `dock/state.rs`, `dock/view.rs`, `app.rs`, `settings.rs`, dock-profile dialog.
1. Handle full PaneGrid drop target: center swap; left/right/top/bottom close-and-split in requested side/order.
2. Preserve exactly one Canvas and one instance per utility pane; prevent closing/moving away the last Canvas.
3. Serialize actual dock tree recursively: pane type, split axis, ratio, child order, maximized/focused state.
4. Restore defensively with dedupe, ratio clamps, unknown-pane tolerance and default fallback.
5. Save/load/rename/delete exact profile layouts; add Painting, Vector, AI and Viewer presets.
6. Test every panel move/resize/close/reopen/maximize/save/reload.
### Expandable two-column toolbox
Files: `sidebar/mod.rs`, `app.rs`, `settings.rs`.
1. Persist collapsed 36px one-column and expanded ~72px two-column mode.
2. Add accessible chevron and shortcut.
3. Short click activates last-used subtool; secondary click/long press opens subtools; outside/Escape closes.
4. Expanded mode exposes all tools via a scrollable grouped two-column grid.
5. Every selectable tool must execute a real action or be visibly disabled with explanation.
6. Verify all 15 vector shapes, VectorSelect, Smart/Vision selection and retouch tools.
7. Screenshot both modes and popups at minimum height.
### Cycle 1 gate
Recheck the complete scorecard. Do not proceed until menu readability/placement, theme isolation, docking relocation/persistence and toolbox mode are each ≥9.
## Cycle 2: Vector Completion
1. Test selection, deselection, overlap cycling and active-layer changes.
2. Make handle hit areas screen-constant across zoom/pan. Render rotated geometry handles with hover/active styles and correct cursors.
3. Test move, all eight resize handles and rotation for every shape; prevent NaN/min-size/inversion errors; add Shift aspect and Alt center constraints.
4. Use one undo transaction per drag.
5. Render viewport-clamped Apply, Cancel, Flip H/V and Delete controls. Snapshot once; Apply commits once; Cancel restores exact state.
6. Centralize `sync_tool_from_shape` and `sync_shape_from_tool` so Geometry, Properties, toolbar, Tool Settings and color picker agree.
7. Replace always-expanded Geometry selectors with pick lists/popovers; add precise numeric inputs and shape-specific controls.
8. Validate boolean operands, failures, one undo entry and result selection for Union/Intersect/Subtract/Xor.
9. Share geometry generation between live preview and committed shape for all 15 variants plus FreePath; preserve anti-alias density.
10. Verify safe auto-vector-layer creation on raster/locked layers.
11. **Recheck every vector row; none below 9.**
## Cycle 3: Canvas, Selection, Raster, Text, Clipboard
1. Extract/cache actual mask edges for marching ants and fill irregular selected pixels, not bounds.
2. Complete lasso/polygon previews, MagicWand AA/contiguous and selection modify/load/save/quick-mask.
3. Make raster transform fully undoable with aspect lock and on-canvas Apply/Cancel.
4. Implement real Cut/Clear and selection-aware internal/external clipboard.
5. Add Ctrl-click eyedropper, Shift brush resize, space-pan, true fit-to-area and actual brush footprint cursor.
6. Give Pen distinct egui behavior; prove Spray settings reach engine; implement true linear/radial gradients.
7. Complete inline text focus/multiline/live preview/rotated caret/edit-existing/Accept/Cancel/properties/effects.
8. Wire real SmartSelect/VisionSelect/retouch backends; fake fallbacks cannot score 9. Disable with explanation only as a temporary state.
9. Implement exposed rulers/grid/guides/snap/pixel-grid states and rendering.
10. **Recheck all scores; no Cycle 3 row below 9.**
## Cycle 4: Panels
1. Layers: duplicate, grouping/hierarchy, reorder, fill opacity, masks/rasterize where API exists, context actions and history.
2. Brushes: category filter, presets/editing/imports/thumbnails/per-tool isolation.
3. Color: continuous wheel/SL drag, 40 recent wrapping/collapse/right-click-background, theme style and vector sync.
4. Filters: audit every FilterType schema/default/range/reset/preview/apply/error.
5. Establish one authoritative tool/property state; remove contradictory duplicates and all reachable `NoOp`.
6. Complete Layer Styles/Details/History undo/empty/selection behavior.
7. Complete dialog validation/focus/Enter/Escape/modal capture/responsiveness.
8. Add panel UI tests/screenshots in every theme.
9. **Recheck all scores; no panel row below 9.**
## Cycle 5: Menus, Files, Viewer, Script, AI, Remaining Features
1. Derive menu labels and semantic dispatch from one source; implement every enabled item.
2. Where support truly does not exist, disable with tooltip rather than presenting an enabled no-op. Retained egui features still require implementation.
3. Complete document close/dirty prompts, recent failures, save/export format selection/errors.
4. Complete viewer cache/navigation/fullscreen/open-edit/malformed-file behavior.
5. Script/AI: cancellation, provider/model discovery, network errors, diagnostics, execution confirmation, history/refresh.
6. Add real egui-supported More panels/actions (notably Plugins and Layer Styles); remove unsupported Photopea-only claims.
7. Apply i18n keys to every visible string and persist language.
8. Ensure menu and shortcut dispatch the same semantic command.
9. **Recheck the complete inventory; repeat Cycle 5 for every row below 9.**
## Final Regression Cycle
Run:
- `cargo fmt --check`
- `cargo build -p hcie-iced-gui`
- `cargo test -p hcie-iced-gui`
- scorecard/UI/integration tests
- `cargo test -p hcie-engine-api --test visual_regression` for engine-visible pixels
- `cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture` for stroke/canvas work
Then launch ICED, exercise every scorecard scenario, compare final screenshots to Cycle 0, prove theme composite hashes are unchanged, verify dock restart recovery, and rescore everything. **Stop only when every retained feature is ≥9/10.**
## Mandatory Scenarios
- Every menu anchor, narrow/wide viewport, DPI scales, dark/light themes.
- Six themes with active drawing and viewer active/inactive; no document mutation.
- Every pane moved to every region, resized, closed/reopened, maximized, persisted.
- Toolbox one/two columns, all subtools, minimum height, Escape/outside click.
- Every vector shape create/select/move/8-resize/rotate/apply/cancel/delete/flip/properties/boolean.
- Rect/lasso/polygon/magic/AI selection, irregular ants, transform, crop, clipboard, undo.
- Multi-document open/switch/close/dirty, import/export failures.
- 4K continuous stroke, pan/zoom, vector drag and theme switch without full-upload regressions.
## Risks
- PaneGrid may not support floating windows/tabs directly. Required acceptance is arbitrary split relocation and exact persistence; floating windows are optional unless added without state/event duplication.
- Some menu claims exceed egui/engine support. Honest disabled states are preferable to fake enabled actions, but egui parity features must be implemented.
- Document engine API gaps before any locked-crate proposal; never import internal crates into GUI.
- Re-read files before patches because the working tree has concurrent/uncommitted changes.
## Done Definition
- No retained feature below 9.
- No reachable production `NoOp` or wrong semantic alias.
- No enabled menu item without a tested handler.
- Panels relocate, resize and restore exactly.
- Menus are readable and exactly anchored.
- Theme changes cannot load/mutate images.
- Toolbox has persisted one/two-column modes and all vector tools are connected.
- Vector handles/editing are accurate, undoable and visually verified.
- Protected GPU optimizations remain intact.
Generated
+1
View File
@@ -3054,6 +3054,7 @@ version = "0.1.0"
dependencies = [
"hcie-engine-api",
"iced",
"serde",
"serde_json",
]
@@ -0,0 +1,136 @@
# Vector Tool Migration — Scoring & Implementation Plan
## All Vector Tool Features — Current ICED Score (0-10)
| # | Feature | egui | ICED | Gap |
|---|---------|------|------|-----|
| 1 | Shape creation (15 types) via drag | 10 | 8 | Previews for 11 shapes are bounding-box fallback |
| 2 | Auto-vector-layer creation | 10 | 10 | None |
| 3 | VectorSelect click-to-select | 10 | 0 | **MISSING** |
| 4 | VectorEditHandle hit-testing | 10 | 0 | **MISSING** |
| 5 | Shape drag-move via handles | 10 | 0 | **MISSING** |
| 6 | Shape resize via handles | 10 | 0 | **MISSING** |
| 7 | Shape rotation via handle | 10 | 0 | **MISSING** |
| 8 | Shape cycling (overlapping) | 10 | 0 | **MISSING** |
| 9 | Geometry panel: shape list | 10 | 2 | Hardcoded `&[]`, no selection |
| 10 | Geometry panel: shape selection | 10 | 0 | **MISSING** |
| 11 | Geometry panel: X1/Y1/X2/Y2 bounds | 10 | 0 | **MISSING** |
| 12 | Geometry panel: fill toggle | 10 | 0 | **MISSING** |
| 13 | Geometry panel: fill color picker | 10 | 0 | **MISSING** |
| 14 | Geometry panel: stroke color picker | 10 | 0 | **MISSING** |
| 15 | Geometry panel: opacity slider | 10 | 0 | **MISSING** |
| 16 | Geometry panel: hardness slider | 10 | 0 | **MISSING** |
| 17 | Geometry panel: line cap controls | 10 | 0 | **MISSING** |
| 18 | Geometry panel: boolean operations | 10 | 0 | **MISSING** |
| 19 | Shape Apply/Cancel overlay | 10 | 0 | **MISSING** |
| 20 | Shape Flip H/V | 10 | 0 | **MISSING** |
| 21 | shape_sync bidirectional sync | 10 | 0 | **MISSING** |
| 22 | Shape deletion | 10 | 0 | **MISSING** |
| 23 | Accurate previews (Arrow, etc.) | 10 | 3 | 11 shapes use bounding-box fallback |
| 24 | Tool settings panel (stroke, fill, etc.) | 10 | 7 | Missing hardness, line cap |
| 25 | Shape-specific preview (Star, Polygon) | 10 | 6 | Only 4 shapes have accurate preview |
**Overall ICED Score: ~2.5/10** — Shape creation works but selection/editing is completely missing.
---
## Implementation Phases
### Phase A: VectorSelect Core (Features 3-8, 21-22) — Score Target: 8/10
**Sub-goal A1: VectorSelect state and click-to-select**
- Add `selected_vector_shape: Option<usize>` to app state
- Add `vector_shapes_snapshot: Option<Vec<VectorShape>>` for undo
- Add `vector_edit_handle: VectorEditHandle` state
- Add `vector_drag_last: Option<(f32, f32)>` tracking
- Implement `VectorSelect` click handling in canvas
- Call `engine.find_vector_shape_at()` for hit-testing
- Support overlapping shape cycling
**Sub-goal A2: VectorEditHandle rendering on canvas**
- Render 8 resize handles (white squares with black stroke)
- Render rotation handle (blue circle above top-center)
- Render selection outline (blue stroke around shape)
- Hit-test handles on click
**Sub-goal A3: Shape drag/resize/rotate via handles**
- Move: drag shape body → `engine.modify_vector_shape()`
- Resize: drag corner/edge handles → `engine.modify_vector_shape()`
- Rotate: drag rotation handle → `engine.set_vector_shape_angle()`
**Sub-goal A4: Apply/Cancel overlay**
- Show Apply (checkmark) and Cancel (X) buttons when shape is selected
- Apply: commit changes, clear snapshot, deselect
- Cancel: restore snapshot, deselect
- Flip H/V buttons for applicable shapes
**Sub-goal A5: Shape deletion**
- Delete key or X button in geometry panel
- `engine.delete_vector_shape()` or equivalent
---
### Phase B: Geometry Panel (Features 9-18) — Score Target: 8/10
**Sub-goal B1: Shape list with selection**
- Render all shapes in active vector layer
- Click to select/deselect
- Delete button per shape
**Sub-goal B2: Bounds editing (X1/Y1/X2/Y2)**
- 4 sliders for shape bounds
- `engine.set_vector_shape_bounds()` on change
**Sub-goal B3: Fill toggle + fill color**
- Checkbox for fill on/off
- Color picker for fill color
- `engine.set_vector_shape_fill()` and `set_vector_shape_fill_color()`
**Sub-goal B4: Stroke color picker**
- Color picker for stroke color
- `engine.set_vector_shape_color()`
**Sub-goal B5: Opacity and Hardness sliders**
- Slider for opacity (0-1)
- Slider for hardness (0-1)
- `engine.set_vector_shape_opacity()` and `set_vector_shape_hardness()`
**Sub-goal B6: Line cap controls**
- Start/End cap combo boxes (for Line shapes)
- `engine.set_vector_shape_line_caps()`
**Sub-goal B7: Boolean operations**
- Shape A/B selectors
- Union/Intersect/Subtract/Xor buttons
- `engine.boolean_vector_shapes()`
---
### Phase C: Shape Sync & Polish (Features 21, 23-25) — Score Target: 9/10
**Sub-goal C1: shape_sync bidirectional sync**
- `sync_shape_from_tool()`: push tool state to selected shape
- `sync_tool_from_shape()`: pull shape properties to tool state
**Sub-goal C2: Accurate previews for all 15 shapes**
- Arrow, Rhombus, Cylinder, Heart, Bubble, Gear, Cross, Crescent, Bolt, Arrow4
- FreePath preview
**Sub-goal C3: Tool settings panel completion**
- Add hardness slider
- Add line cap controls
---
## Execution Order
1. **A1** → A2 → A3 → A4 → A5 (VectorSelect core)
2. **B1** → B2 → B3 → B4 → B5 → B6 → B7 (Geometry panel)
3. **C1** → C2 → C3 (Sync & polish)
## Recheck Points
After each phase, recheck scores:
- After Phase A: VectorSelect features should be 8/10
- After Phase B: Geometry panel features should be 8/10
- After Phase C: All vector features should be 9/10
+228 -80
View File
@@ -7,6 +7,9 @@
use hcie_engine_api::{Engine, EngineCommand};
use serde_json::json;
const MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
const MAX_INLINE_TOOL_CALLS: usize = 64;
/// System prompt that defines the AI assistant's capabilities and tool-calling format.
pub const SYSTEM_PROMPT: &str = r##"You are an expert digital artist AI assistant integrated inside HCIE-Rust, a pixel-grade digital painting and image editing application.
You can talk to the user AND draw or edit their canvas.
@@ -203,7 +206,11 @@ pub fn parse_coords(s: &str) -> Option<(f32, f32)> {
}
/// Execute a parsed JSON tool call directly on the Engine.
pub fn execute_tool_call(engine: &mut Engine, name: &str, args: &serde_json::Value) -> Result<String, String> {
pub fn execute_tool_call(
engine: &mut Engine,
name: &str,
args: &serde_json::Value,
) -> Result<String, String> {
match name {
"create_layer" | "add_layer" => {
let layer_name = args["name"].as_str().unwrap_or("Layer");
@@ -213,13 +220,23 @@ pub fn execute_tool_call(engine: &mut Engine, name: &str, args: &serde_json::Val
}
"select_layer" => {
let layer_name = args["name"].as_str().unwrap_or("");
if let Some(layer) = engine.layer_infos().iter().find(|l| l.name.eq_ignore_ascii_case(layer_name)) {
if let Some(layer) = engine
.layer_infos()
.iter()
.find(|l| l.name.eq_ignore_ascii_case(layer_name))
{
engine.set_active_layer(layer.id);
Ok(format!("Selected layer '{}' (ID: {})", layer_name, layer.id))
Ok(format!(
"Selected layer '{}' (ID: {})",
layer_name, layer.id
))
} else {
let id = engine.add_layer(layer_name);
engine.set_active_layer(id);
Ok(format!("Layer '{}' not found. Created and selected it.", layer_name))
Ok(format!(
"Layer '{}' not found. Created and selected it.",
layer_name
))
}
}
"draw_rect" => {
@@ -236,7 +253,10 @@ pub fn execute_tool_call(engine: &mut Engine, name: &str, args: &serde_json::Val
let rx = args["rx"].as_f64().unwrap_or(0.0) as f32;
let ry = args["ry"].as_f64().unwrap_or(0.0) as f32;
engine.draw_ellipse(cx, cy, rx, ry);
Ok(format!("Drew ellipse center ({}, {}) radius ({}, {})", cx, cy, rx, ry))
Ok(format!(
"Drew ellipse center ({}, {}) radius ({}, {})",
cx, cy, rx, ry
))
}
"draw_line" => {
let x0 = args["x0"].as_f64().unwrap_or(0.0) as f32;
@@ -244,7 +264,10 @@ pub fn execute_tool_call(engine: &mut Engine, name: &str, args: &serde_json::Val
let x1 = args["x1"].as_f64().unwrap_or(0.0) as f32;
let y1 = args["y1"].as_f64().unwrap_or(0.0) as f32;
engine.draw_line(x0, y0, x1, y1);
Ok(format!("Drew line from ({}, {}) to ({}, {})", x0, y0, x1, y1))
Ok(format!(
"Drew line from ({}, {}) to ({}, {})",
x0, y0, x1, y1
))
}
"add_text" => {
let text = args["text"].as_str().unwrap_or("");
@@ -256,7 +279,12 @@ pub fn execute_tool_call(engine: &mut Engine, name: &str, args: &serde_json::Val
let color = parse_hex_color(color_hex).unwrap_or([0, 0, 0, 255]);
let effects = vec![];
engine.add_text(
text, font, size, x, y, color,
text,
font,
size,
x,
y,
color,
0.0,
hcie_engine_api::TextAlignment::Left,
hcie_engine_api::TextOrientation::Horizontal,
@@ -283,7 +311,7 @@ pub fn execute_tool_call(engine: &mut Engine, name: &str, args: &serde_json::Val
engine.draw_stroke(&points);
Ok(format!("Drew brush stroke with {} points", points.len()))
}
_ => Err(format!("Unknown tool: {}", name))
_ => Err(format!("Unknown tool: {}", name)),
}
}
@@ -308,10 +336,22 @@ pub fn execute_command_on_engine(engine: &mut Engine, cmd: &EngineCommand) {
EngineCommand::DrawStroke { points } => {
engine.draw_stroke(points);
}
EngineCommand::AddText { text, font, size, x, y, color } => {
EngineCommand::AddText {
text,
font,
size,
x,
y,
color,
} => {
let effects = vec![];
engine.add_text(
text, font, *size, *x, *y, *color,
text,
font,
*size,
*x,
*y,
*color,
0.0,
hcie_engine_api::TextAlignment::Left,
hcie_engine_api::TextOrientation::Horizontal,
@@ -354,7 +394,9 @@ pub fn parse_dsl_script(script: &str, _engine: &Engine) -> Vec<EngineCommand> {
"select_layer" => {
if parts.len() >= 2 {
let name = parts[1..].join(" ");
let id_opt = _engine.layer_infos().iter()
let id_opt = _engine
.layer_infos()
.iter()
.find(|l| l.name.eq_ignore_ascii_case(&name))
.map(|l| l.id);
if let Some(id) = id_opt {
@@ -396,7 +438,12 @@ pub fn parse_dsl_script(script: &str, _engine: &Engine) -> Vec<EngineCommand> {
} else {
brush_size / 2.0
};
commands.push(EngineCommand::DrawEllipse { cx, cy, rx: r, ry: r });
commands.push(EngineCommand::DrawEllipse {
cx,
cy,
rx: r,
ry: r,
});
}
}
}
@@ -430,7 +477,14 @@ pub fn parse_dsl_script(script: &str, _engine: &Engine) -> Vec<EngineCommand> {
} else {
current_color
};
commands.push(EngineCommand::AddText { text, font, size, x, y, color });
commands.push(EngineCommand::AddText {
text,
font,
size,
x,
y,
color,
});
}
}
"filter" => {
@@ -438,7 +492,8 @@ pub fn parse_dsl_script(script: &str, _engine: &Engine) -> Vec<EngineCommand> {
let filter_id = parts[1].to_string();
let params = if parts.len() >= 3 {
let json_str = parts[2..].join(" ");
serde_json::from_str::<serde_json::Value>(&json_str).unwrap_or(serde_json::Value::Null)
serde_json::from_str::<serde_json::Value>(&json_str)
.unwrap_or(serde_json::Value::Null)
} else {
serde_json::Value::Null
};
@@ -465,7 +520,11 @@ pub fn parse_dsl_script(script: &str, _engine: &Engine) -> Vec<EngineCommand> {
/// Scan the entire text for ```json or ```dsl code blocks and execute them on the engine.
pub fn execute_inline_tools(text: &str, engine: &mut Engine) -> Vec<String> {
let mut results = Vec::new();
if text.len() > MAX_RESPONSE_BYTES {
return vec!["Tool payload rejected: response exceeds 4 MiB".to_string()];
}
let mut start = 0;
let mut executed = 0;
// Parse JSON block tool calls
while let Some(open_idx) = text[start..].find("```json") {
@@ -478,21 +537,32 @@ pub fn execute_inline_tools(text: &str, engine: &mut Engine) -> Vec<String> {
if val.is_array() {
if let Some(arr) = val.as_array() {
for item in arr {
if executed >= MAX_INLINE_TOOL_CALLS {
results.push(
"Tool payload stopped at the 64-call safety limit".to_string(),
);
return results;
}
if let Some(tool_name) = item["tool"].as_str() {
executed += 1;
let params = &item["parameters"];
match execute_tool_call(engine, tool_name, params) {
Ok(msg) => results.push(format!("🔧 {}: {}", tool_name, msg)),
Err(err) => results.push(format!("❌ Tool Error ({}): {}", tool_name, err)),
Err(err) => results
.push(format!("❌ Tool Error ({}): {}", tool_name, err)),
}
}
}
}
} else if val.is_object() {
if let Some(tool_name) = val["tool"].as_str() {
executed += 1;
let params = &val["parameters"];
match execute_tool_call(engine, tool_name, params) {
Ok(msg) => results.push(format!("🔧 {}: {}", tool_name, msg)),
Err(err) => results.push(format!("❌ Tool Error ({}): {}", tool_name, err)),
Err(err) => {
results.push(format!("❌ Tool Error ({}): {}", tool_name, err))
}
}
}
}
@@ -547,11 +617,12 @@ pub fn build_messages_json(
}));
// Conversation history
for msg in history {
let last_user = history.iter().rposition(|message| message.role == "user");
for (index, msg) in history.iter().enumerate() {
if msg.role == "user" || msg.role == "assistant" {
messages_json.push(json!({
"role": &msg.role,
"content": if msg.content == final_prompt { final_prompt } else { &msg.content }
"content": if Some(index) == last_user { final_prompt } else { &msg.content }
}));
}
}
@@ -565,97 +636,174 @@ pub fn build_messages_json(
/// and accumulates the response text. Returns the full response on success or
/// an error message on failure. This function is designed to be used with
/// `Task::perform` in the Iced application.
pub async fn stream_llm_response(
pub fn stream_llm_response(
provider: AiProvider,
url: String,
model: String,
messages: Vec<serde_json::Value>,
) -> Result<String, String> {
let body = json!({
"model": model,
"messages": messages,
"stream": true,
"temperature": 0.7
});
let url_endpoint = if url.ends_with("/v1/chat/completions") || url.ends_with("/api/chat") {
url.clone()
} else {
format!("{}/v1/chat/completions", url.trim_end_matches('/'))
};
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let response = client.post(&url_endpoint).json(&body).send().await
.map_err(|e| format!("Network Failure: {}", e))?;
let status = response.status();
if !status.is_success() {
let err_msg = response.text().await.unwrap_or_else(|_| "Unknown HTTP error".to_string());
return Err(format!("HTTP Error {}: {}", status, err_msg));
}
let mut full_accumulated = String::new();
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
let mut buffer = String::new();
while let Some(chunk_result) = stream.next().await {
) -> impl futures_util::Stream<Item = ChatUpdate> {
iced::stream::channel(32, move |mut output| async move {
use futures_util::{SinkExt, StreamExt};
let result = async {
let (url_endpoint, body) = match provider {
AiProvider::Ollama => (
format!("{}/api/chat", url.trim_end_matches('/')),
json!({"model": model, "messages": messages, "stream": true}),
),
AiProvider::Claude => (
format!("{}/v1/messages", url.trim_end_matches('/')),
json!({"model": model, "system": SYSTEM_PROMPT, "messages": messages.into_iter().filter(|message| message["role"] != "system").collect::<Vec<_>>(), "max_tokens": 4096, "stream": true}),
),
AiProvider::OpenAI => (
format!("{}/chat/completions", url.trim_end_matches('/')),
json!({"model": model, "messages": messages, "stream": true, "temperature": 0.7}),
),
};
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(15))
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
let mut request = client.post(&url_endpoint).json(&body);
match provider {
AiProvider::Claude => {
let key = std::env::var("ANTHROPIC_API_KEY").map_err(|_| "ANTHROPIC_API_KEY is not set".to_string())?;
request = request.header("x-api-key", key).header("anthropic-version", "2023-06-01");
}
AiProvider::OpenAI => {
let key = std::env::var("OPENAI_API_KEY").map_err(|_| "OPENAI_API_KEY is not set".to_string())?;
request = request.bearer_auth(key);
}
AiProvider::Ollama => {}
}
let response = request.send().await.map_err(|e| format!("Network failure: {e}"))?;
let status = response.status();
if !status.is_success() {
let detail = response.text().await.unwrap_or_else(|_| "response body unavailable".into());
return Err(format!("HTTP {status}: {}", detail.chars().take(1000).collect::<String>()));
}
let mut full_accumulated = String::new();
let mut stream = response.bytes_stream();
let mut buffer = String::new();
while let Some(chunk_result) = stream.next().await {
match chunk_result {
Ok(chunk) => {
buffer.push_str(&String::from_utf8_lossy(&chunk));
// Process complete lines
while let Some(newline_pos) = buffer.find('\n') {
let line = buffer[..newline_pos].trim().to_string();
buffer = buffer[newline_pos + 1..].to_string();
buffer.drain(..=newline_pos);
if line.is_empty() {
continue;
}
let json_part = line.strip_prefix("data: ").unwrap_or(&line);
if line.starts_with("data: ") {
let json_part = &line[6..];
if json_part.trim() == "[DONE]" {
break;
continue;
}
if let Ok(v) = serde_json::from_str::<serde_json::Value>(json_part) {
if let Some(choices) = v["choices"].as_array() {
if !choices.is_empty() {
let delta = &choices[0]["delta"];
// Handle reasoning content (deep thinking models)
if let Some(_reasoning) = delta["reasoning_content"].as_str() {
// Reasoning chunks are logged but not accumulated in this path
}
// Handle regular content
if let Some(content) = delta["content"].as_str() {
if !content.is_empty() {
full_accumulated.push_str(content);
}
}
}
let delta = &v["choices"][0]["delta"];
if let Some(reasoning) = delta["reasoning_content"].as_str().or_else(|| delta["reasoning"].as_str()) {
output.send(ChatUpdate::ReasoningChunk(reasoning.to_string())).await.ok();
}
let content = delta["content"].as_str().or_else(|| v["delta"]["text"].as_str());
if let Some(content) = content.filter(|text| !text.is_empty()) {
full_accumulated.push_str(content);
output.send(ChatUpdate::Chunk(content.to_string())).await.ok();
}
}
} else {
// Fallback to Ollama native chat ND-JSON format
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) {
if let Some(reasoning) = v["message"]["thinking"].as_str() {
output.send(ChatUpdate::ReasoningChunk(reasoning.to_string())).await.ok();
}
if let Some(content) = v["message"]["content"].as_str() {
if !content.is_empty() {
full_accumulated.push_str(content);
output.send(ChatUpdate::Chunk(content.to_string())).await.ok();
}
}
}
}
if full_accumulated.len() > MAX_RESPONSE_BYTES {
return Err("AI response exceeded the 4 MiB safety limit".to_string());
}
}
}
Err(e) => {
return Err(format!("Stream read error: {}", e));
}
Err(e) => return Err(format!("Stream read error: {e}")),
}
}
Ok(full_accumulated)
}.await;
output.send(ChatUpdate::Finished(result)).await.ok();
})
}
/// Format every visible message as a complete plain-text transcript.
pub fn transcript_text(messages: &[ChatMessage]) -> String {
messages
.iter()
.map(|message| {
let role = if message.is_reasoning {
"Reasoning"
} else {
message.role.as_str()
};
format!("{}:\n{}", role, message.content)
})
.collect::<Vec<_>>()
.join("\n\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canvas_context_replaces_only_latest_user_payload() {
let history = vec![
ChatMessage {
role: "user".into(),
content: "old".into(),
is_reasoning: false,
},
ChatMessage {
role: "assistant".into(),
content: "reply".into(),
is_reasoning: false,
},
ChatMessage {
role: "user".into(),
content: "new".into(),
is_reasoning: false,
},
];
let payload = build_messages_json(&history, "new\n[canvas]", "system");
assert_eq!(payload[1]["content"], "old");
assert_eq!(payload[3]["content"], "new\n[canvas]");
}
Ok(full_accumulated)
#[test]
fn transcript_includes_reasoning_and_tool_results() {
let messages = vec![
ChatMessage {
role: "assistant".into(),
content: "answer".into(),
is_reasoning: false,
},
ChatMessage {
role: "assistant".into(),
content: "thought".into(),
is_reasoning: true,
},
ChatMessage {
role: "tool".into(),
content: "result".into(),
is_reasoning: false,
},
];
let transcript = transcript_text(&messages);
assert!(transcript.contains("assistant:\nanswer"));
assert!(transcript.contains("Reasoning:\nthought"));
assert!(transcript.contains("tool:\nresult"));
}
}
@@ -10,7 +10,6 @@ Output ONLY valid HCIE-Script DSL commands with no explanation, no markdown, no
Available commands:
- layer Name — create raster layer
- vector_layer Name — create vector layer
- select_layer Name
- brush STYLE — round, square, pencil, inkpen, charcoal, watercolor, marker, oil, airbrush, spray, clouds, dirt, tree, meadow, rock, bristle, leaf, wetpaint, sketch, hatch, calligraphy, blender, mixer, glow, crayon
- color #RRGGBB — set foreground color
@@ -82,7 +81,10 @@ pub async fn generate_via_ollama(provider: &Provider, prompt: &str) -> Result<St
return Err(format!("API error: {}", resp.status()));
}
let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse error: {}", e))?;
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Parse error: {}", e))?;
let content = json["message"]["content"]
.as_str()
.ok_or_else(|| "No content in response".to_string())?;
@@ -110,8 +112,12 @@ pub async fn generate_via_claude(provider: &Provider, prompt: &str) -> Result<St
.build()
.map_err(|e| format!("Client error: {}", e))?;
let api_key = std::env::var("ANTHROPIC_API_KEY")
.map_err(|_| "ANTHROPIC_API_KEY is not set".to_string())?;
let resp = client
.post(&url)
.header("x-api-key", api_key)
.header("anthropic-version", "2023-06-01")
.json(&body)
.send()
.await
@@ -123,7 +129,10 @@ pub async fn generate_via_claude(provider: &Provider, prompt: &str) -> Result<St
return Err(format!("API error {}: {}", status, text));
}
let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse error: {}", e))?;
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Parse error: {}", e))?;
let content = json["content"][0]["text"]
.as_str()
.ok_or_else(|| "No content in response".to_string())?;
@@ -136,7 +145,10 @@ pub async fn generate_via_claude(provider: &Provider, prompt: &str) -> Result<St
/// Uses the standard Chat Completions format with the system prompt as the
/// first message. Returns the cleaned DSL script text.
pub async fn generate_via_openai(provider: &Provider, prompt: &str) -> Result<String, String> {
let url = format!("{}/v1/chat/completions", provider.base_url.trim_end_matches('/'));
let url = format!(
"{}/chat/completions",
provider.base_url.trim_end_matches('/')
);
let body = serde_json::json!({
"model": provider.model,
"messages": [
@@ -151,8 +163,11 @@ pub async fn generate_via_openai(provider: &Provider, prompt: &str) -> Result<St
.build()
.map_err(|e| format!("Client error: {}", e))?;
let api_key =
std::env::var("OPENAI_API_KEY").map_err(|_| "OPENAI_API_KEY is not set".to_string())?;
let resp = client
.post(&url)
.bearer_auth(api_key)
.json(&body)
.send()
.await
@@ -164,7 +179,10 @@ pub async fn generate_via_openai(provider: &Provider, prompt: &str) -> Result<St
return Err(format!("API error {}: {}", status, text));
}
let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse error: {}", e))?;
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Parse error: {}", e))?;
let content = json["choices"][0]["message"]["content"]
.as_str()
.ok_or_else(|| "No content in response".to_string())?;
@@ -186,14 +204,49 @@ pub fn clean_script_output(text: &str) -> String {
text.trim().to_string()
}
/// Validate a generated payload before exposing Run as an executable action.
pub fn validate_script_output(text: &str) -> Result<String, String> {
let cleaned = clean_script_output(text);
if cleaned.is_empty() {
return Err("Provider returned an empty script".to_string());
}
if cleaned.len() > 512 * 1024 {
return Err("Generated script exceeds the 512 KiB safety limit".to_string());
}
crate::script::parser::parse_dsl_script(&cleaned)
.map_err(|error| format!("Generated script is invalid: {error}"))?;
Ok(cleaned)
}
/// Generate a script using the given provider type and configuration (async).
///
/// Dispatches to the appropriate provider function based on the `provider_type`
/// string ("ollama", "claude", or "openai"). Defaults to Ollama.
pub async fn generate_script(provider_type: &str, provider: &Provider, prompt: &str) -> Result<String, String> {
pub async fn generate_script(
provider_type: &str,
provider: &Provider,
prompt: &str,
) -> Result<String, String> {
match provider_type {
"claude" => generate_via_claude(provider, prompt).await,
"openai" => generate_via_openai(provider, prompt).await,
_ => generate_via_ollama(provider, prompt).await,
"ollama" => generate_via_ollama(provider, prompt).await,
unsupported => Err(format!("Unsupported AI provider: {unsupported}")),
}
.and_then(|output| validate_script_output(&output))
}
#[cfg(test)]
mod tests {
use super::validate_script_output;
#[test]
fn generated_output_must_parse_before_run_is_enabled() {
assert!(
validate_script_output("```dsl\nlayer Sky\ncolor #112233\nrect 0,0 10,10\n```").is_ok()
);
assert!(validate_script_output("paint magically")
.unwrap_err()
.contains("invalid"));
}
}
File diff suppressed because it is too large Load Diff
@@ -265,7 +265,8 @@ fn harvest_brs2_sampled_brushes(data: &[u8], base_pos: usize) -> Vec<BrushPreset
} else {
(w, h)
};
if let Some(decoded) = try_decode_and_scale(slice, w as usize, h as usize, final_w, final_h) {
if let Some(decoded) = try_decode_and_scale(slice, w as usize, h as usize, final_w, final_h)
{
presets.push(BrushPreset {
id: format!("abr_brs2_{}_{}x{}", base_pos + i, final_w, final_h),
name: format!("Brush {}x{}", final_w, final_h),
@@ -1,30 +1,46 @@
//! Cached extraction of exact selection-mask edges.
//!
//! **Purpose:** Avoids rescanning a full selection mask every animation frame. **Logic & Workflow:**
//! A content fingerprint, dimensions, and length identify the current mask; changed content is
//! rescanned into pixel-boundary line segments. **Side Effects / Dependencies:** Allocates only
//! when the mask changes and shares the result through `Arc`.
use std::sync::Arc;
#[derive(Clone, Default)]
pub struct SelectionEdgeCache {
mask_ptr: usize,
fingerprint: u64,
mask_len: usize,
edges: Arc<Vec<(u32, u32, u32, u32)>>,
}
impl SelectionEdgeCache {
/// Returns cached edges only when mask content and dimensions still match.
pub fn get(&self, mask: &[u8]) -> Option<Arc<Vec<(u32, u32, u32, u32)>>> {
if mask.as_ptr() as usize == self.mask_ptr && mask.len() == self.mask_len {
if mask_fingerprint(mask) == self.fingerprint && mask.len() == self.mask_len {
Some(Arc::clone(&self.edges))
} else {
None
}
}
/// Rebuilds and stores exact pixel-boundary edges for `mask`.
pub fn rebuild(&mut self, mask: &[u8], w: u32, h: u32) -> Arc<Vec<(u32, u32, u32, u32)>> {
let edges = Arc::new(extract_selection_edges(mask, w, h));
self.mask_ptr = mask.as_ptr() as usize;
self.fingerprint = mask_fingerprint(mask);
self.mask_len = mask.len();
self.edges = Arc::clone(&edges);
edges
}
}
/// Computes a fast deterministic fingerprint that detects in-place mask mutations.
fn mask_fingerprint(mask: &[u8]) -> u64 {
mask.iter().fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(0x1000_0000_01b3)
})
}
pub fn extract_selection_edges(
selection_mask: &[u8],
canvas_width: u32,
File diff suppressed because it is too large Load Diff
@@ -17,11 +17,11 @@
//!
use crate::app::Message;
use iced::widget::shader;
use iced::mouse;
use iced::{Rectangle, Point, Vector};
use iced::advanced::Shell;
use iced::mouse;
use iced::widget::shader;
use iced::widget::shader::wgpu::util::DeviceExt;
use iced::{Point, Rectangle, Vector};
use hcie_engine_api::{ZOOM_MAX, ZOOM_MIN};
@@ -88,13 +88,31 @@ const VERTEX_LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayou
/// Position [0,1] × [0,1], UV [0,1] × [0,1].
const QUAD_VERTICES: [CanvasVertex; 6] = [
// Triangle 1: top-left, bottom-left, bottom-right
CanvasVertex { position: [0.0, 0.0], uv: [0.0, 0.0] },
CanvasVertex { position: [0.0, 1.0], uv: [0.0, 1.0] },
CanvasVertex { position: [1.0, 1.0], uv: [1.0, 1.0] },
CanvasVertex {
position: [0.0, 0.0],
uv: [0.0, 0.0],
},
CanvasVertex {
position: [0.0, 1.0],
uv: [0.0, 1.0],
},
CanvasVertex {
position: [1.0, 1.0],
uv: [1.0, 1.0],
},
// Triangle 2: top-left, bottom-right, top-right
CanvasVertex { position: [0.0, 0.0], uv: [0.0, 0.0] },
CanvasVertex { position: [1.0, 1.0], uv: [1.0, 1.0] },
CanvasVertex { position: [1.0, 0.0], uv: [1.0, 0.0] },
CanvasVertex {
position: [0.0, 0.0],
uv: [0.0, 0.0],
},
CanvasVertex {
position: [1.0, 1.0],
uv: [1.0, 1.0],
},
CanvasVertex {
position: [1.0, 0.0],
uv: [1.0, 0.0],
},
];
// ─── wgpu re-export (from iced's shader module) ──────────────────────────────
@@ -153,7 +171,9 @@ impl CanvasShaderPipeline {
) -> Self {
log::debug!(
"[CanvasShaderPipeline::new] Creating pipeline for {}×{} canvas, format={:?}",
canvas_w, canvas_h, format
canvas_w,
canvas_h,
format
);
// ── Texture ──────────────────────────────────────────────────────
@@ -175,7 +195,8 @@ impl CanvasShaderPipeline {
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
// Upload initial pixel data
if !initial_pixels.is_empty() && initial_pixels.len() == (canvas_w * canvas_h * 4) as usize {
if !initial_pixels.is_empty() && initial_pixels.len() == (canvas_w * canvas_h * 4) as usize
{
queue.write_texture(
wgpu::ImageCopyTexture {
texture: &texture,
@@ -197,7 +218,9 @@ impl CanvasShaderPipeline {
);
log::debug!(
"[CanvasShaderPipeline::new] Uploaded initial {}×{} texture ({} bytes)",
canvas_w, canvas_h, initial_pixels.len()
canvas_w,
canvas_h,
initial_pixels.len()
);
}
@@ -301,7 +324,9 @@ impl CanvasShaderPipeline {
// ── Shader module ────────────────────────────────────────────────
let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("hcie-canvas-shader"),
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!("canvas.wgsl"))),
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
"canvas.wgsl"
))),
});
// ── Pipeline layout ─────────────────────────────────────────────
@@ -345,7 +370,8 @@ impl CanvasShaderPipeline {
log::info!(
"[CanvasShaderPipeline::new] Pipeline created successfully for {}×{} canvas",
canvas_w, canvas_h
canvas_w,
canvas_h
);
Self {
@@ -380,7 +406,10 @@ impl CanvasShaderPipeline {
) {
log::info!(
"[CanvasShaderPipeline::resize_texture] {}×{} → {}×{}",
self.texture_w, self.texture_h, canvas_w, canvas_h
self.texture_w,
self.texture_h,
canvas_w,
canvas_h
);
self.texture = device.create_texture(&wgpu::TextureDescriptor {
@@ -398,7 +427,9 @@ impl CanvasShaderPipeline {
view_formats: &[],
});
self.texture_view = self.texture.create_view(&wgpu::TextureViewDescriptor::default());
self.texture_view = self
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
// Upload full pixel data
if pixels.len() == (canvas_w * canvas_h * 4) as usize {
@@ -583,15 +614,19 @@ impl shader::Primitive for CanvasShaderPrimitive {
// ── Resize texture if canvas dimensions changed ──────────────────
if pipeline.texture_w != self.canvas_w || pipeline.texture_h != self.canvas_h {
pipeline.resize_texture(
device, queue,
self.canvas_w, self.canvas_h,
device,
queue,
self.canvas_w,
self.canvas_h,
&self.composite_pixels,
);
} else if self.full_upload {
// Full upload requested (e.g., after loading a file)
pipeline.resize_texture(
device, queue,
self.canvas_w, self.canvas_h,
device,
queue,
self.canvas_w,
self.canvas_h,
&self.composite_pixels,
);
} else if let Some(region) = self.dirty_region {
@@ -612,8 +647,14 @@ impl shader::Primitive for CanvasShaderPrimitive {
let raw_y = (viewport_h - 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 origin_x = raw_x.clamp(-(display_w - min_vis_w).max(0.0), (viewport_w - min_vis_w).max(0.0));
let origin_y = raw_y.clamp(-(display_h - min_vis_h).max(0.0), (viewport_h - min_vis_h).max(0.0));
let origin_x = raw_x.clamp(
-(display_w - min_vis_w).max(0.0),
(viewport_w - min_vis_w).max(0.0),
);
let origin_y = raw_y.clamp(
-(display_h - min_vis_h).max(0.0),
(viewport_h - min_vis_h).max(0.0),
);
// Convert to NDC: viewport [0, viewport_w] → NDC [-1, 1]
// The quad vertices are in [0,1], vertex shader does:
@@ -645,8 +686,10 @@ impl shader::Primitive for CanvasShaderPrimitive {
log::trace!(
"[CanvasShaderPrimitive::prepare] {:.2}ms | viewport={}×{} | canvas={}×{} | zoom={:.2}",
elapsed.as_secs_f64() * 1000.0,
viewport_w as u32, viewport_h as u32,
self.canvas_w, self.canvas_h,
viewport_w as u32,
viewport_h as u32,
self.canvas_w,
self.canvas_h,
self.zoom
);
}
@@ -723,6 +766,8 @@ pub struct CanvasShaderState {
pub left_pressed: bool,
/// Track middle button state.
pub middle_pressed: bool,
/// Distinguishes Space+left temporary pan from a physical middle-button pan.
pub space_left_pan: bool,
}
/// Canvas shader program — implements `iced::widget::shader::Program`.
@@ -745,6 +790,8 @@ pub struct CanvasShaderProgram {
pub dirty_region: Option<[u32; 4]>,
/// Whether a full texture upload is needed (first frame, resize, file load).
pub full_upload: bool,
/// Whether Space temporarily changes left-drag into panning.
pub space_pan: bool,
}
/// Convert a viewport-local point to canvas-space coordinates.
@@ -767,14 +814,28 @@ fn screen_to_canvas_local(
let raw_y = (viewport_h - display_h) / 2.0 + pan_offset.y;
let min_vis_w = display_w * 0.25;
let min_vis_h = display_h * 0.25;
let origin_x = raw_x.clamp(-(display_w - min_vis_w).max(0.0), (viewport_w - min_vis_w).max(0.0));
let origin_y = raw_y.clamp(-(display_h - min_vis_h).max(0.0), (viewport_h - min_vis_h).max(0.0));
let origin_x = raw_x.clamp(
-(display_w - min_vis_w).max(0.0),
(viewport_w - min_vis_w).max(0.0),
);
let origin_y = raw_y.clamp(
-(display_h - min_vis_h).max(0.0),
(viewport_h - min_vis_h).max(0.0),
);
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 };
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)
}
@@ -798,10 +859,7 @@ impl shader::Program<Message> for CanvasShaderProgram {
shader::Event::Mouse(mouse_event) => {
match mouse_event {
mouse::Event::CursorMoved { position } => {
let local_pos = Point::new(
position.x - bounds.x,
position.y - bounds.y,
);
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);
@@ -820,7 +878,10 @@ impl shader::Program<Message> for CanvasShaderProgram {
// Left mouse drag (drawing)
if state.left_pressed {
if let (Some(cx), Some(cy)) = (canvas_x, canvas_y) {
return (iced::event::Status::Captured, Some(Message::CanvasPointerMoved { x: cx, y: cy }));
return (
iced::event::Status::Captured,
Some(Message::CanvasPointerMoved { x: cx, y: cy }),
);
}
return (iced::event::Status::Captured, Some(pane_size_msg));
}
@@ -854,25 +915,39 @@ impl shader::Program<Message> for CanvasShaderProgram {
(bounds.height - min_vis_h).max(0.0),
) - default_oy;
return (iced::event::Status::Captured, Some(Message::CanvasPanZoom {
zoom: self.zoom,
pan_offset: new_pan,
}));
return (
iced::event::Status::Captured,
Some(Message::CanvasPanZoom {
zoom: self.zoom,
pan_offset: new_pan,
}),
);
}
// Cursor over canvas → status bar coords
if let (Some(cx), Some(cy)) = (canvas_x, canvas_y) {
return (iced::event::Status::Captured, Some(Message::CanvasCursorPos {
x: cx as u32,
y: cy as u32,
}));
return (
iced::event::Status::Captured,
Some(Message::CanvasCursorPos {
x: cx as u32,
y: cy as u32,
}),
);
}
return (iced::event::Status::Captured, Some(pane_size_msg));
}
mouse::Event::ButtonPressed(button) => {
if button == mouse::Button::Left && bounds.contains(cursor.position().unwrap_or(Point::ORIGIN)) {
if button == mouse::Button::Left
&& bounds.contains(cursor.position().unwrap_or(Point::ORIGIN))
{
if self.space_pan {
state.middle_pressed = true;
state.space_left_pan = true;
state.pan_start = cursor.position();
return (iced::event::Status::Captured, None);
}
let local_pos = {
let p = cursor.position().unwrap_or(Point::ORIGIN);
Point::new(p.x - bounds.x, p.y - bounds.y)
@@ -888,10 +963,10 @@ impl shader::Program<Message> for CanvasShaderProgram {
);
if let (Some(cx), Some(cy)) = (cx_opt, cy_opt) {
state.left_pressed = true;
return (iced::event::Status::Captured, Some(Message::CanvasPointerPressed {
x: cx,
y: cy,
}));
return (
iced::event::Status::Captured,
Some(Message::CanvasPointerPressed { x: cx, y: cy }),
);
}
} else if button == mouse::Button::Middle {
if let Some(pos) = cursor.position() {
@@ -904,15 +979,24 @@ impl shader::Program<Message> for CanvasShaderProgram {
if bounds.contains(pos) {
let local_pos = Point::new(pos.x - bounds.x, pos.y - bounds.y);
let (cx_opt, cy_opt) = screen_to_canvas_local(
local_pos, bounds.width, bounds.height, self.engine_w as f32, self.engine_h as f32, self.zoom, self.pan_offset
local_pos,
bounds.width,
bounds.height,
self.engine_w as f32,
self.engine_h as f32,
self.zoom,
self.pan_offset,
);
if let (Some(cx), Some(cy)) = (cx_opt, cy_opt) {
return (iced::event::Status::Captured, Some(Message::CanvasPointerRightClicked {
x: cx,
y: cy,
screen_x: pos.x,
screen_y: pos.y,
}));
return (
iced::event::Status::Captured,
Some(Message::CanvasPointerRightClicked {
x: cx,
y: cy,
screen_x: pos.x,
screen_y: pos.y,
}),
);
}
}
}
@@ -921,8 +1005,17 @@ impl shader::Program<Message> for CanvasShaderProgram {
mouse::Event::ButtonReleased(button) => {
if button == mouse::Button::Left {
if state.space_left_pan {
state.middle_pressed = false;
state.space_left_pan = false;
state.pan_start = None;
return (iced::event::Status::Captured, None);
}
state.left_pressed = false;
return (iced::event::Status::Captured, Some(Message::CanvasPointerReleased));
return (
iced::event::Status::Captured,
Some(Message::CanvasPointerReleased),
);
} else if button == mouse::Button::Middle {
state.middle_pressed = false;
state.pan_start = None;
@@ -938,10 +1031,8 @@ impl shader::Program<Message> for CanvasShaderProgram {
};
if scroll_y != 0.0 {
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 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 };
@@ -952,8 +1043,10 @@ impl shader::Program<Message> for CanvasShaderProgram {
let old_display_w = engine_w * old_zoom;
let old_display_h = engine_h * old_zoom;
let old_origin_x = (bounds.width - old_display_w) / 2.0 + self.pan_offset.x;
let old_origin_y = (bounds.height - old_display_h) / 2.0 + self.pan_offset.y;
let old_origin_x =
(bounds.width - old_display_w) / 2.0 + self.pan_offset.x;
let old_origin_y =
(bounds.height - old_display_h) / 2.0 + self.pan_offset.y;
let canvas_x = (local_cursor.x - old_origin_x) / old_zoom;
let canvas_y = (local_cursor.y - old_origin_y) / old_zoom;
@@ -983,10 +1076,13 @@ impl shader::Program<Message> for CanvasShaderProgram {
(bounds.height - new_min_vis_h).max(0.0),
) - new_default_oy;
return (iced::event::Status::Captured, Some(Message::CanvasPanZoom {
zoom: new_zoom,
pan_offset: new_pan,
}));
return (
iced::event::Status::Captured,
Some(Message::CanvasPanZoom {
zoom: new_zoom,
pan_offset: new_pan,
}),
);
}
}
}
@@ -0,0 +1,160 @@
//! Command-line parsing for startup files and automated GUI screenshots.
//!
//! **Purpose:** Converts process arguments into a validated launch configuration without
//! terminating the process, allowing both the binary and integration tests to use one parser.
//! **Logic & Workflow:** Options are consumed left-to-right, screenshot options consume their
//! required operands, `--` ends option parsing, and at most one startup file is accepted.
//! **Side Effects / Dependencies:** This module performs no I/O and does not inspect the paths.
use std::path::PathBuf;
/// Describes an automated screenshot requested by the command line.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScreenshotRequest {
/// Optional exact dock-pane label. `None` captures the complete viewport.
pub panel: Option<String>,
/// PNG output path supplied by the caller.
pub output: PathBuf,
}
/// Validated application launch arguments.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CliArgs {
/// Optional image or project to open after application initialization.
pub load_path: Option<PathBuf>,
/// Optional automated screenshot operation.
pub screenshot: Option<ScreenshotRequest>,
/// Whether usage text should be printed instead of launching the GUI.
pub help: bool,
}
/// Parses command-line arguments after the executable name.
///
/// **Arguments:** `args` yields UTF-8 command-line tokens in their original order.
/// **Returns:** A validated [`CliArgs`] value, or a user-facing error for malformed input.
/// **Logic & Workflow:** Recognized options are parsed before positional files; `--` permits a
/// path beginning with a dash. Duplicate screenshot operations and startup files are rejected.
/// **Side Effects / Dependencies:** None.
pub fn parse_args<I, S>(args: I) -> Result<CliArgs, String>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let tokens: Vec<String> = args.into_iter().map(Into::into).collect();
let mut parsed = CliArgs::default();
let mut positional_only = false;
let mut index = 0;
while index < tokens.len() {
let token = &tokens[index];
if !positional_only {
match token.as_str() {
"--" => {
positional_only = true;
index += 1;
continue;
}
"-h" | "--help" => {
parsed.help = true;
index += 1;
continue;
}
"--screenshot" => {
ensure_no_screenshot(&parsed)?;
let output = required_operand(&tokens, index + 1, "--screenshot")?;
parsed.screenshot = Some(ScreenshotRequest {
panel: None,
output: PathBuf::from(output),
});
index += 2;
continue;
}
"--screenshot-panel" => {
ensure_no_screenshot(&parsed)?;
let panel = required_operand(&tokens, index + 1, "--screenshot-panel")?;
let output = required_operand(&tokens, index + 2, "--screenshot-panel")?;
parsed.screenshot = Some(ScreenshotRequest {
panel: Some(panel.to_owned()),
output: PathBuf::from(output),
});
index += 3;
continue;
}
_ if token.starts_with('-') => {
return Err(format!("unknown argument: {token}"));
}
_ => {}
}
}
if parsed.load_path.replace(PathBuf::from(token)).is_some() {
return Err("only one startup file may be specified".to_string());
}
index += 1;
}
Ok(parsed)
}
/// Returns an option operand or a descriptive missing-value error.
fn required_operand<'a>(
tokens: &'a [String],
index: usize,
option: &str,
) -> Result<&'a str, String> {
tokens
.get(index)
.filter(|value| !value.is_empty())
.map(String::as_str)
.ok_or_else(|| format!("{option} requires more arguments"))
}
/// Rejects multiple screenshot operations in one launch.
fn ensure_no_screenshot(parsed: &CliArgs) -> Result<(), String> {
if parsed.screenshot.is_some() {
Err("only one screenshot operation may be specified".to_string())
} else {
Ok(())
}
}
/// Returns the command-line help shown by the binary.
pub fn help_text() -> &'static str {
"Usage: hcie-iced [OPTIONS] [FILE]\n\nHCIE - Iced Edition\n\nOptions:\n -h, --help Print this help message\n --screenshot OUTPUT Capture the full viewport as PNG\n --screenshot-panel PANEL OUTPUT\n Capture a named panel as PNG\n\nArguments:\n FILE Image file to open on startup"
}
#[cfg(test)]
mod tests {
use super::{parse_args, ScreenshotRequest};
use std::path::PathBuf;
#[test]
fn parses_full_screenshot_and_startup_file() {
let args = parse_args(["--screenshot", "full.png", "drawing.hcie"]).unwrap();
assert_eq!(args.load_path, Some(PathBuf::from("drawing.hcie")));
assert_eq!(
args.screenshot,
Some(ScreenshotRequest {
panel: None,
output: PathBuf::from("full.png"),
})
);
}
#[test]
fn parses_named_panel_screenshot() {
let args = parse_args(["--screenshot-panel", "Geometry", "geometry.png"]).unwrap();
assert_eq!(args.screenshot.unwrap().panel.as_deref(), Some("Geometry"));
}
#[test]
fn rejects_duplicate_positional_files() {
assert!(parse_args(["one.png", "two.png"]).is_err());
}
#[test]
fn supports_dash_prefixed_file_after_terminator() {
let args = parse_args(["--", "-drawing.png"]).unwrap();
assert_eq!(args.load_path, Some(PathBuf::from("-drawing.png")));
}
}
@@ -6,11 +6,28 @@
//! - HSL sliders
//! - 10x10 palette grid
//! - Recent colors
//! - HSV color picker popup for geometry and properties panels
//! - Shared targeted popup selector for vector and layer-style colors
use crate::app::Message;
use iced::widget::{button, column, container, horizontal_rule, row, slider, text, text_input};
use iced::{Element, Length, Padding};
use crate::theme::ThemeColors;
use iced::mouse;
use iced::widget::canvas::{self, Canvas, Frame, Path, Stroke};
use iced::widget::{
button, column, container, horizontal_rule, row, scrollable, slider, text, text_input,
};
use iced::{Element, Length, Padding, Point, Rectangle, Size};
/// Identifies the application value edited by the shared color selector.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorPickerTarget {
Foreground,
Background,
GeometryFill,
GeometryStroke,
StyleMain(usize),
StyleHighlight(usize),
StyleShadow(usize),
}
/// HSV color representation (Hue, Saturation, Value)
#[derive(Debug, Clone, Copy)]
@@ -34,7 +51,7 @@ pub fn rgb_to_hsv(r: u8, g: u8, b: u8) -> Hsv {
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let d = max - min;
let h = if max == min {
0.0
} else if max == r {
@@ -44,15 +61,11 @@ pub fn rgb_to_hsv(r: u8, g: u8, b: u8) -> Hsv {
} else {
((r - g) / d + 4.0) / 6.0
};
let s = if max == 0.0 { 0.0 } else { d / max };
let v = max;
Hsv {
h: h * 360.0,
s,
v,
}
Hsv { h: h * 360.0, s, v }
}
/// Convert HSV to RGB.
@@ -75,86 +88,39 @@ pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
_ => (v, p, q),
}
};
((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8)
(to_byte(r), to_byte(g), to_byte(b))
}
/// Create a compact HSV color picker popup for inline use in panels.
/// Returns a color swatch that opens a popup when clicked.
pub fn color_popup<'a>(
/// Builds a compact swatch row that opens the shared application popup.
pub fn color_swatch<'a>(
color: [u8; 4],
label: &'a str,
on_change: fn([u8; 4]) -> Message,
target: ColorPickerTarget,
) -> Element<'a, Message> {
let r = color[0];
let g = color[1];
let b = color[2];
let a = color[3];
let hsv = rgb_to_hsv(r, g, b);
// Color swatch button
let swatch = container(text(""))
.width(20)
.height(20)
.width(30)
.height(22)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, a as f32 / 255.0,
color[0] as f32 / 255.0,
color[1] as f32 / 255.0,
color[2] as f32 / 255.0,
color[3] as f32 / 255.0,
))),
border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1).rounded(2),
border: iced::Border::default()
.color(iced::Color::from_rgb(0.55, 0.55, 0.55))
.width(1)
.rounded(3),
..Default::default()
});
// HSV sliders
let h_slider = slider(0.0..=360.0, hsv.h, move |v| {
let (r, g, b) = hsv_to_rgb(v, hsv.s, hsv.v);
on_change([r, g, b, a])
}).step(1.0);
let s_slider = slider(0.0..=100.0, hsv.s * 100.0, move |v| {
let (r, g, b) = hsv_to_rgb(hsv.h, v / 100.0, hsv.v);
on_change([r, g, b, a])
}).step(1.0);
let v_slider = slider(0.0..=100.0, hsv.v * 100.0, move |v| {
let (r, g, b) = hsv_to_rgb(hsv.h, hsv.s, v / 100.0);
on_change([r, g, b, a])
}).step(1.0);
// Alpha slider
let a_slider = slider(0..=255u8, a, move |v| {
on_change([r, g, b, v])
}).width(Length::Fill);
// Hex input
let hex_str = format!("{:02X}{:02X}{:02X}", r, g, b);
let alpha = a;
let hex_input = text_input("#", &hex_str)
.on_input(move |input| {
let clean = input.trim_start_matches('#').to_uppercase();
if clean.len() == 6 {
if let (Ok(r), Ok(g), Ok(b)) = (
u8::from_str_radix(&clean[0..2], 16),
u8::from_str_radix(&clean[2..4], 16),
u8::from_str_radix(&clean[4..6], 16),
) {
return on_change([r, g, b, alpha]);
}
}
Message::NoOp
})
.width(70)
.font(iced::Font::MONOSPACE)
.size(10);
column![
row![text(label).size(10), swatch].spacing(4).align_y(iced::Alignment::Center),
row![text("H").size(9), h_slider, text(format!("{:.0}°", hsv.h)).size(9)].spacing(2),
row![text("S").size(9), s_slider, text(format!("{:.0}%", hsv.s * 100.0)).size(9)].spacing(2),
row![text("V").size(9), v_slider, text(format!("{:.0}%", hsv.v * 100.0)).size(9)].spacing(2),
row![text("A").size(9), a_slider, text(format!("{:.0}%", a as f32 / 255.0 * 100.0)).size(9)].spacing(2),
row![text("Hex:").size(10), hex_input].spacing(4).align_y(iced::Alignment::Center),
]
.spacing(2)
.into()
let trigger = iced::widget::mouse_area(swatch)
.on_press(Message::OpenColorPicker(target, color))
.interaction(mouse::Interaction::Pointer);
row![text(label).size(10).width(Length::Fill), trigger]
.spacing(6)
.align_y(iced::Alignment::Center)
.width(Length::Fill)
.into()
}
/// A predefined color palette (10x10 grid) — generated algorithmically to match egui.
@@ -173,16 +139,22 @@ fn palette_color(row: usize, col: usize) -> [u8; 3] {
}
/// Convert RGB to HSL.
fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
pub fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
let r = r as f32 / 255.0;
let g = g as f32 / 255.0;
let b = b as f32 / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) / 2.0;
if max == min { return (0.0, 0.0, l); }
if max == min {
return (0.0, 0.0, l);
}
let d = max - min;
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
let s = if l > 0.5 {
d / (2.0 - max - min)
} else {
d / (max + min)
};
let h = if max == r {
((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0
} else if max == g {
@@ -194,24 +166,437 @@ fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
}
/// Convert HSL to RGB.
fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
let h = h / 360.0;
let (r, g, b) = if s == 0.0 {
(l, l, l)
} else {
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
let q = if l < 0.5 {
l * (1.0 + s)
} else {
l + s - l * s
};
let p = 2.0 * l - q;
(hue_to_rgb(p, q, h + 1.0 / 3.0), hue_to_rgb(p, q, h), hue_to_rgb(p, q, h - 1.0 / 3.0))
(
hue_to_rgb(p, q, h + 1.0 / 3.0),
hue_to_rgb(p, q, h),
hue_to_rgb(p, q, h - 1.0 / 3.0),
)
};
((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8)
(to_byte(r), to_byte(g), to_byte(b))
}
fn to_byte(value: f32) -> u8 {
(value.clamp(0.0, 1.0) * 255.0).round() as u8
}
/// Parses `RRGGBB` or `RRGGBBAA`, preserving the supplied alpha for six digits.
pub fn parse_hex_color(value: &str, current_alpha: u8) -> Option<[u8; 4]> {
let value = value.trim().trim_start_matches('#');
if value.len() != 6 && value.len() != 8 {
return None;
}
let r = u8::from_str_radix(&value[0..2], 16).ok()?;
let g = u8::from_str_radix(&value[2..4], 16).ok()?;
let b = u8::from_str_radix(&value[4..6], 16).ok()?;
let a = if value.len() == 8 {
u8::from_str_radix(&value[6..8], 16).ok()?
} else {
current_alpha
};
Some([r, g, b, a])
}
fn hue_to_rgb(p: f32, q: f32, t: f32) -> f32 {
let t = if t < 0.0 { t + 1.0 } else if t > 1.0 { t - 1.0 } else { t };
if t < 1.0 / 6.0 { p + (q - p) * 6.0 * t }
else if t < 1.0 / 2.0 { q }
else if t < 2.0 / 3.0 { p + (q - p) * (2.0 / 3.0 - t) * 6.0 }
else { p }
let t = if t < 0.0 {
t + 1.0
} else if t > 1.0 {
t - 1.0
} else {
t
};
if t < 1.0 / 6.0 {
p + (q - p) * 6.0 * t
} else if t < 1.0 / 2.0 {
q
} else if t < 2.0 / 3.0 {
p + (q - p) * (2.0 / 3.0 - t) * 6.0
} else {
p
}
}
/// Builds the shared popup overlay for the active target.
///
/// The popup keeps alpha and hexadecimal editing common to all three compact
/// tabs. RGB and HSL expose sliders plus numeric inputs, while Wheel uses a
/// custom Iced canvas with continuous hue-ring and saturation/lightness hit
/// testing. Changes are emitted immediately through `PopupColorChanged`.
pub fn popup_view(
color: [u8; 4],
hex_value: &str,
tab: usize,
colors: ThemeColors,
) -> Element<'static, Message> {
let (h, s, l) = rgb_to_hsl(color[0], color[1], color[2]);
let tab_button = |label: &'static str, index: usize| {
let active = tab == index;
button(text(label).size(10))
.on_press(Message::ColorPickerTabChanged(index))
.padding([4, 10])
.style(move |_theme, status| iced::widget::button::Style {
background: Some(iced::Background::Color(if active {
colors.bg_active
} else if status == iced::widget::button::Status::Hovered {
colors.bg_hover
} else {
colors.bg_panel
})),
text_color: if active {
colors.accent
} else {
colors.text_primary
},
border: iced::Border::default()
.color(if active {
colors.accent
} else {
colors.border_low
})
.width(1)
.rounded(3),
..Default::default()
})
};
let body: Element<'static, Message> = match tab {
0 => rgb_popup_section(color),
1 => Canvas::new(ColorWheel { color })
.width(Length::Fixed(220.0))
.height(Length::Fixed(220.0))
.into(),
_ => hsl_popup_section(color, h, s, l),
};
let alpha = color[3];
let alpha_row = row![
text("A").size(10).width(16),
slider(0..=255u8, alpha, move |value| {
Message::PopupColorChanged([color[0], color[1], color[2], value])
})
.width(Length::Fill),
text(format!("{alpha}")).size(10).width(28),
]
.spacing(5)
.align_y(iced::Alignment::Center);
let popup = column![
row![
text("Color Selector").size(12).width(Length::Fill),
button(text("X").size(10))
.on_press(Message::CloseColorPicker)
.padding([2, 7]),
]
.align_y(iced::Alignment::Center),
row![
tab_button("RGB", 0),
tab_button("Wheel", 1),
tab_button("HSL", 2)
]
.spacing(3),
body,
alpha_row,
row![
text("Hex").size(10),
text_input("#RRGGBBAA", hex_value)
.on_input(Message::ColorPickerHexChanged)
.font(iced::Font::MONOSPACE)
.size(10)
.width(Length::Fill),
]
.spacing(6)
.align_y(iced::Alignment::Center),
]
.spacing(7)
.padding(10)
.width(Length::Fill);
let panel = container(scrollable(popup).height(Length::Shrink))
.width(Length::Fill)
.max_width(320.0)
.max_height(520.0)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_panel)),
border: iced::Border::default()
.color(colors.border_high)
.width(1)
.rounded(6),
shadow: iced::Shadow {
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.65),
offset: iced::Vector::new(0.0, 3.0),
blur_radius: 14.0,
},
..Default::default()
});
container(panel)
.padding(8)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into()
}
/// Builds RGB sliders and directly editable byte values.
fn rgb_popup_section(color: [u8; 4]) -> Element<'static, Message> {
let channel = |label, index: usize| {
let slider_color = color;
let input_color = color;
row![
text(label).size(10).width(16),
slider(0..=255u8, color[index], move |value| {
let mut next = slider_color;
next[index] = value;
Message::PopupColorChanged(next)
})
.width(Length::Fill),
text_input("0", &color[index].to_string())
.on_input(move |value| match value.parse::<u8>() {
Ok(value) => {
let mut next = input_color;
next[index] = value;
Message::PopupColorChanged(next)
}
Err(_) => Message::NoOp,
})
.size(10)
.width(42),
]
.spacing(5)
.align_y(iced::Alignment::Center)
};
column![channel("R", 0), channel("G", 1), channel("B", 2)]
.spacing(5)
.into()
}
/// Builds HSL sliders and numeric readouts without duplicating conversion logic.
fn hsl_popup_section(color: [u8; 4], h: f32, s: f32, l: f32) -> Element<'static, Message> {
let alpha = color[3];
let hue = slider(0.0..=360.0, h, move |value| {
let (r, g, b) = hsl_to_rgb(value, s, l);
Message::PopupColorChanged([r, g, b, alpha])
});
let saturation = slider(0.0..=100.0, s * 100.0, move |value| {
let (r, g, b) = hsl_to_rgb(h, value / 100.0, l);
Message::PopupColorChanged([r, g, b, alpha])
});
let lightness = slider(0.0..=100.0, l * 100.0, move |value| {
let (r, g, b) = hsl_to_rgb(h, s, value / 100.0);
Message::PopupColorChanged([r, g, b, alpha])
});
column![
popup_slider_row("H", hue, format!("{h:.0}")),
popup_slider_row("S", saturation, format!("{:.0}", s * 100.0)),
popup_slider_row("L", lightness, format!("{:.0}", l * 100.0)),
]
.spacing(5)
.into()
}
/// Aligns one popup slider with a compact numeric readout.
fn popup_slider_row<'a>(
label: &'static str,
control: iced::widget::Slider<'a, f32, Message>,
value: String,
) -> Element<'a, Message> {
row![
text(label).size(10).width(16),
control.width(Length::Fill),
text(value).size(10).width(34)
]
.spacing(5)
.align_y(iced::Alignment::Center)
.into()
}
/// Interactive hue ring and saturation/lightness square.
#[derive(Debug, Clone, Copy)]
struct ColorWheel {
color: [u8; 4],
}
/// Tracks whether a wheel drag began over an editable region.
#[derive(Debug, Default)]
struct ColorWheelState {
dragging: bool,
}
impl ColorWheel {
/// Maps a local wheel position to HSL, preserving the current component when
/// the pointer lies outside both the ring and center square.
fn color_at(self, bounds: Rectangle, position: Point) -> Option<[u8; 4]> {
let size = bounds.width.min(bounds.height);
let center = Point::new(bounds.width / 2.0, bounds.height / 2.0);
let wheel_radius = size * 0.40;
let inner_radius = size * 0.24;
let square_size = inner_radius * 1.7;
let square = Rectangle::new(
Point::new(center.x - square_size / 2.0, center.y - square_size / 2.0),
Size::new(square_size, square_size),
);
let (mut h, mut s, mut l) = rgb_to_hsl(self.color[0], self.color[1], self.color[2]);
if square.contains(position) {
s = ((position.x - square.x) / square.width).clamp(0.0, 1.0);
l = ((square.y + square.height - position.y) / square.height).clamp(0.0, 1.0);
} else {
let dx = position.x - center.x;
let dy = position.y - center.y;
let distance = (dx * dx + dy * dy).sqrt();
if distance < inner_radius - 5.0 || distance > wheel_radius + 8.0 {
return None;
}
h = (dy.atan2(dx).to_degrees() + 90.0 + 360.0) % 360.0;
}
let (r, g, b) = hsl_to_rgb(h, s, l);
Some([r, g, b, self.color[3]])
}
}
impl canvas::Program<Message> for ColorWheel {
type State = ColorWheelState;
fn draw(
&self,
_state: &Self::State,
renderer: &iced::Renderer,
_theme: &iced::Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let mut frame = Frame::new(renderer, bounds.size());
let size = bounds.width.min(bounds.height);
let center = Point::new(bounds.width / 2.0, bounds.height / 2.0);
let outer = size * 0.40;
let inner = size * 0.24;
let (h, s, l) = rgb_to_hsl(self.color[0], self.color[1], self.color[2]);
for segment in 0..120 {
let h1 = segment as f32 * 3.0;
let h2 = (segment + 1) as f32 * 3.0;
let a1 = (h1 - 90.0).to_radians();
let a2 = (h2 - 90.0).to_radians();
let path = Path::new(|builder| {
builder.move_to(Point::new(
center.x + a1.cos() * outer,
center.y + a1.sin() * outer,
));
builder.line_to(Point::new(
center.x + a2.cos() * outer,
center.y + a2.sin() * outer,
));
builder.line_to(Point::new(
center.x + a2.cos() * inner,
center.y + a2.sin() * inner,
));
builder.line_to(Point::new(
center.x + a1.cos() * inner,
center.y + a1.sin() * inner,
));
builder.close();
});
let (r, g, b) = hsl_to_rgb((h1 + h2) * 0.5, 1.0, 0.5);
frame.fill(&path, iced::Color::from_rgb8(r, g, b));
}
let square_size = inner * 1.7;
let square_origin = Point::new(center.x - square_size / 2.0, center.y - square_size / 2.0);
let cells = 20;
let cell = square_size / cells as f32;
for y in 0..cells {
for x in 0..cells {
let saturation = (x as f32 + 0.5) / cells as f32;
let lightness = 1.0 - (y as f32 + 0.5) / cells as f32;
let (r, g, b) = hsl_to_rgb(h, saturation, lightness);
frame.fill_rectangle(
Point::new(
square_origin.x + x as f32 * cell,
square_origin.y + y as f32 * cell,
),
Size::new(cell + 0.5, cell + 0.5),
iced::Color::from_rgb8(r, g, b),
);
}
}
let hue_angle = (h - 90.0).to_radians();
let hue_point = Point::new(
center.x + hue_angle.cos() * (outer + inner) * 0.5,
center.y + hue_angle.sin() * (outer + inner) * 0.5,
);
let sl_point = Point::new(
square_origin.x + s * square_size,
square_origin.y + (1.0 - l) * square_size,
);
for point in [hue_point, sl_point] {
frame.fill(&Path::circle(point, 5.0), iced::Color::WHITE);
frame.stroke(
&Path::circle(point, 5.0),
Stroke::default()
.with_color(iced::Color::BLACK)
.with_width(1.5),
);
}
vec![frame.into_geometry()]
}
fn update(
&self,
state: &mut Self::State,
event: canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> (canvas::event::Status, Option<Message>) {
let local = cursor.position_in(bounds);
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) {
state.dragging = true;
return (
canvas::event::Status::Captured,
Some(Message::PopupColorChanged(color)),
);
}
}
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => {
if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) {
return (
canvas::event::Status::Captured,
Some(Message::PopupColorChanged(color)),
);
}
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
state.dragging = false;
}
_ => {}
}
(canvas::event::Status::Ignored, None)
}
fn mouse_interaction(
&self,
_state: &Self::State,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> mouse::Interaction {
if cursor.is_over(bounds) {
mouse::Interaction::Crosshair
} else {
mouse::Interaction::default()
}
}
}
/// Build the full color picker panel.
@@ -234,9 +619,15 @@ pub fn view<'a>(
.height(20)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
fg[0] as f32 / 255.0, fg[1] as f32 / 255.0, fg[2] as f32 / 255.0, fg[3] as f32 / 255.0,
fg[0] as f32 / 255.0,
fg[1] as f32 / 255.0,
fg[2] as f32 / 255.0,
fg[3] as f32 / 255.0,
))),
border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1).rounded(2),
border: iced::Border::default()
.color(iced::Color::from_rgb(0.4, 0.4, 0.4))
.width(1)
.rounded(2),
..Default::default()
});
let secondary_swatch = container(text(""))
@@ -244,15 +635,23 @@ pub fn view<'a>(
.height(20)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
bg[0] as f32 / 255.0, bg[1] as f32 / 255.0, bg[2] as f32 / 255.0, bg[3] as f32 / 255.0,
bg[0] as f32 / 255.0,
bg[1] as f32 / 255.0,
bg[2] as f32 / 255.0,
bg[3] as f32 / 255.0,
))),
border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1).rounded(2),
border: iced::Border::default()
.color(iced::Color::from_rgb(0.4, 0.4, 0.4))
.width(1)
.rounded(2),
..Default::default()
});
let swap_btn = button(text("\u{21C5}").size(11))
.on_press(Message::SwapColors)
.padding([2, 4]);
let swatch_row = row![primary_swatch, secondary_swatch, swap_btn].spacing(4).align_y(iced::Alignment::Center);
let swatch_row = row![primary_swatch, secondary_swatch, swap_btn]
.spacing(4)
.align_y(iced::Alignment::Center);
// ── 2. Hex input ──
// Preserve the current alpha channel: the hex field only edits RGB, but the
@@ -277,7 +676,9 @@ pub fn view<'a>(
.width(70)
.font(iced::Font::MONOSPACE)
.size(10);
let hex_row = row![text("Hex:").size(10), hex_input].spacing(4).align_y(iced::Alignment::Center);
let hex_row = row![text("Hex:").size(10), hex_input]
.spacing(4)
.align_y(iced::Alignment::Center);
// ── 2b. Alpha slider ──
// Allows transparency editing, which egui supports in the layer-style panel
@@ -300,13 +701,18 @@ pub fn view<'a>(
// ── 3. Tab bar ──
let tab_w = 30;
let make_tab = |label: &'static str, idx: usize, color_tab: usize| {
button(text(label).size(10)).padding([2, tab_w / 2 - 4])
button(text(label).size(10))
.padding([2, tab_w / 2 - 4])
.on_press(Message::ColorTabChanged(idx))
.style(move |_theme, _status| iced::widget::button::Style {
background: if color_tab == idx {
Some(iced::Background::Color(iced::Color::from_rgb(0.35, 0.35, 0.35)))
Some(iced::Background::Color(iced::Color::from_rgb(
0.35, 0.35, 0.35,
)))
} else {
Some(iced::Background::Color(iced::Color::from_rgb(0.22, 0.22, 0.22)))
Some(iced::Background::Color(iced::Color::from_rgb(
0.22, 0.22, 0.22,
)))
},
text_color: iced::Color::from_rgb(0.8, 0.8, 0.8),
border: iced::Border::default().rounded(2),
@@ -317,7 +723,8 @@ pub fn view<'a>(
make_tab("W", 0, color_tab),
make_tab("H", 1, color_tab),
make_tab("G", 2, color_tab),
].spacing(1);
]
.spacing(1);
// ── 4. Tab content ──
let tab_content: Element<'_, Message> = match color_tab {
@@ -332,7 +739,11 @@ pub fn view<'a>(
let c = color;
let is_selected = c == *fg_color;
let brightness = c[0] as f32 * 0.299 + c[1] as f32 * 0.587 + c[2] as f32 * 0.114;
let dot_color = if brightness > 128.0 { iced::Color::BLACK } else { iced::Color::WHITE };
let dot_color = if brightness > 128.0 {
iced::Color::BLACK
} else {
iced::Color::WHITE
};
let dot = text(if is_selected { "\u{2022}" } else { "" })
.size(10)
@@ -347,10 +758,16 @@ pub fn view<'a>(
.center_y(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, c[3] as f32 / 255.0,
c[0] as f32 / 255.0,
c[1] as f32 / 255.0,
c[2] as f32 / 255.0,
c[3] as f32 / 255.0,
))),
border: if is_selected {
iced::Border::default().color(dot_color).width(1.0).rounded(2)
iced::Border::default()
.color(dot_color)
.width(1.0)
.rounded(2)
} else {
iced::Border::default().rounded(2)
},
@@ -394,11 +811,16 @@ fn build_wheel_view<'a>(h: f32, s: f32, l: f32, _fg_color: &'a [u8; 4]) -> Eleme
for i in 0..36 {
let hue = i as f32 * 10.0;
let (r, g, b) = hsl_to_rgb(hue, 1.0, 0.5);
let cell_color = iced::Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0);
let cell_color =
iced::Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0);
let is_current_h = (h - hue).abs() < 5.0 || (h - hue).abs() > 355.0;
let is_selected = is_current_h && (s - 1.0).abs() < 0.1 && (l - 0.5).abs() < 0.1;
let brightness = r as f32 * 0.299 + g as f32 * 0.587 + b as f32 * 0.114;
let dot_color = if brightness > 128.0 { iced::Color::BLACK } else { iced::Color::WHITE };
let dot_color = if brightness > 128.0 {
iced::Color::BLACK
} else {
iced::Color::WHITE
};
let dot = text(if is_selected { "\u{2022}" } else { "" })
.size(8)
@@ -442,9 +864,14 @@ fn build_wheel_view<'a>(h: f32, s: f32, l: f32, _fg_color: &'a [u8; 4]) -> Eleme
let sat = col_idx as f32 / 11.0;
let (r, g, b) = hsl_to_rgb(h, sat, lightness);
let is_current = (s - sat).abs() < 0.05 && (l - lightness).abs() < 0.05;
let cell_color = iced::Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0);
let cell_color =
iced::Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0);
let brightness = r as f32 * 0.299 + g as f32 * 0.587 + b as f32 * 0.114;
let dot_color = if brightness > 128.0 { iced::Color::BLACK } else { iced::Color::WHITE };
let dot_color = if brightness > 128.0 {
iced::Color::BLACK
} else {
iced::Color::WHITE
};
let dot = text(if is_current { "\u{2022}" } else { "" })
.size(8)
@@ -481,8 +908,14 @@ fn build_wheel_view<'a>(h: f32, s: f32, l: f32, _fg_color: &'a [u8; 4]) -> Eleme
}
// HSL readout
let readout = text(format!("H:{:.0}\u{00B0} S:{:.0}% L:{:.0}%", h, s * 100.0, l * 100.0))
.size(9).font(iced::Font::MONOSPACE);
let readout = text(format!(
"H:{:.0}\u{00B0} S:{:.0}% L:{:.0}%",
h,
s * 100.0,
l * 100.0
))
.size(9)
.font(iced::Font::MONOSPACE);
column![
text("Hue").size(9),
@@ -500,20 +933,38 @@ fn build_slider_view<'a>(h: f32, s: f32, l: f32) -> Element<'a, Message> {
let h_slider = slider(0.0..=360.0, h, move |v: f32| {
let (r, g, b) = hsl_to_rgb(v, s, l);
Message::FgColorChanged([r, g, b, 255])
}).step(1.0);
})
.step(1.0);
let s_slider = slider(0.0..=100.0, s * 100.0, move |v: f32| {
let (r, g, b) = hsl_to_rgb(h, v / 100.0, l);
Message::FgColorChanged([r, g, b, 255])
}).step(1.0);
})
.step(1.0);
let l_slider = slider(0.0..=100.0, l * 100.0, move |v: f32| {
let (r, g, b) = hsl_to_rgb(h, s, v / 100.0);
Message::FgColorChanged([r, g, b, 255])
}).step(1.0);
})
.step(1.0);
column![
row![text("H").size(9), h_slider, text(format!("{:.0}\u{00B0}", h)).size(9)].spacing(2),
row![text("S").size(9), s_slider, text(format!("{:.0}%", s * 100.0)).size(9)].spacing(2),
row![text("L").size(9), l_slider, text(format!("{:.0}%", l * 100.0)).size(9)].spacing(2),
row![
text("H").size(9),
h_slider,
text(format!("{:.0}\u{00B0}", h)).size(9)
]
.spacing(2),
row![
text("S").size(9),
s_slider,
text(format!("{:.0}%", s * 100.0)).size(9)
]
.spacing(2),
row![
text("L").size(9),
l_slider,
text(format!("{:.0}%", l * 100.0)).size(9)
]
.spacing(2),
]
.spacing(2)
.into()
@@ -528,7 +979,11 @@ fn build_grid_view<'a>(fg_color: &'a [u8; 4]) -> Element<'a, Message> {
let c = palette_color(row_idx, col_idx);
let is_selected = [c[0], c[1], c[2], 255] == *fg_color;
let brightness = c[0] as f32 * 0.299 + c[1] as f32 * 0.587 + c[2] as f32 * 0.114;
let dot_color = if brightness > 128.0 { iced::Color::BLACK } else { iced::Color::WHITE };
let dot_color = if brightness > 128.0 {
iced::Color::BLACK
} else {
iced::Color::WHITE
};
let dot = text(if is_selected { "\u{2022}" } else { "" })
.size(10)
@@ -543,10 +998,15 @@ fn build_grid_view<'a>(fg_color: &'a [u8; 4]) -> Element<'a, Message> {
.center_y(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgb(
c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0,
c[0] as f32 / 255.0,
c[1] as f32 / 255.0,
c[2] as f32 / 255.0,
))),
border: if is_selected {
iced::Border::default().color(dot_color).width(1.0).rounded(1)
iced::Border::default()
.color(dot_color)
.width(1.0)
.rounded(1)
} else {
iced::Border::default().rounded(1)
},
@@ -560,10 +1020,44 @@ fn build_grid_view<'a>(fg_color: &'a [u8; 4]) -> Element<'a, Message> {
palette_grid = palette_grid.push(row_widgets);
}
column![
text("Palette").size(10),
palette_grid,
]
.spacing(2)
.into()
column![text("Palette").size(10), palette_grid,]
.spacing(2)
.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rgb_hsl_roundtrip_preserves_channels_with_rounding_tolerance() {
for color in [[0, 0, 0], [255, 255, 255], [231, 17, 93], [12, 190, 244]] {
let (h, s, l) = rgb_to_hsl(color[0], color[1], color[2]);
let actual = hsl_to_rgb(h, s, l);
for (expected, actual) in color.into_iter().zip([actual.0, actual.1, actual.2]) {
assert!((expected as i16 - actual as i16).abs() <= 1);
}
}
}
#[test]
fn rgb_hsv_roundtrip_preserves_channels_with_rounding_tolerance() {
for color in [[0, 0, 0], [255, 255, 255], [231, 17, 93], [12, 190, 244]] {
let hsv = rgb_to_hsv(color[0], color[1], color[2]);
let actual = hsv.to_rgb();
for (expected, actual) in color.into_iter().zip([actual.0, actual.1, actual.2]) {
assert!((expected as i16 - actual as i16).abs() <= 1);
}
}
}
#[test]
fn hex_parser_preserves_or_explicitly_updates_alpha() {
assert_eq!(parse_hex_color("#112233", 77), Some([0x11, 0x22, 0x33, 77]));
assert_eq!(
parse_hex_color("11223344", 77),
Some([0x11, 0x22, 0x33, 0x44])
);
assert_eq!(parse_hex_color("xyz", 77), None);
}
}
@@ -1,11 +1,12 @@
//! About dialog — displays application name, version, and credits.
use crate::app::Message;
use iced::widget::{button, column, container, horizontal_rule, text};
use iced::{Element, Length};
use crate::theme::ThemeColors;
use iced::widget::{button, column, horizontal_rule, text};
use iced::Element;
/// Build the About dialog.
pub fn view() -> Element<'static, Message> {
pub fn view(colors: ThemeColors) -> Element<'static, Message> {
let ok_btn = button(text("OK").size(12))
.on_press(Message::DialogClose)
.padding([8, 16]);
@@ -25,14 +26,5 @@ pub fn view() -> Element<'static, Message> {
.width(320)
.align_x(iced::Alignment::Center);
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()
super::modal(dialog, colors)
}
@@ -1,13 +1,15 @@
//! Adjustments dialog — brightness/contrast and HSL adjustments.
use crate::app::Message;
use iced::widget::{button, column, container, horizontal_rule, row, slider, text};
use crate::theme::ThemeColors;
use iced::widget::{button, column, horizontal_rule, row, slider, text};
use iced::{Element, Length};
/// Build the Brightness/Contrast adjustment dialog.
pub fn brightness_contrast_view(
brightness: f32,
contrast: f32,
colors: ThemeColors,
) -> Element<'static, Message> {
let brightness_slider = slider(-100.0..=100.0, brightness, |v| Message::AdjBrightness(v))
.step(1.0)
@@ -26,7 +28,9 @@ pub fn brightness_contrast_view(
.padding([6, 12]);
let dialog = column![
text("Brightness / Contrast").size(14).font(iced::Font::MONOSPACE),
text("Brightness / Contrast")
.size(14)
.font(iced::Font::MONOSPACE),
horizontal_rule(1),
text(format!("Brightness: {:.0}", brightness)).size(11),
brightness_slider,
@@ -39,16 +43,7 @@ pub fn brightness_contrast_view(
.padding(16)
.width(350);
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()
super::modal(dialog, colors)
}
/// Build the Hue/Saturation/Lightness adjustment dialog.
@@ -56,6 +51,7 @@ pub fn hsl_view(
hue: f32,
saturation: f32,
lightness: f32,
colors: ThemeColors,
) -> Element<'static, Message> {
let hue_slider = slider(-180.0..=180.0, hue, |v| Message::AdjHue(v))
.step(1.0)
@@ -78,7 +74,9 @@ pub fn hsl_view(
.padding([6, 12]);
let dialog = column![
text("Hue / Saturation / Lightness").size(14).font(iced::Font::MONOSPACE),
text("Hue / Saturation / Lightness")
.size(14)
.font(iced::Font::MONOSPACE),
horizontal_rule(1),
text(format!("Hue: {:.0}°", hue)).size(11),
hue_slider,
@@ -93,14 +91,5 @@ pub fn hsl_view(
.padding(16)
.width(350);
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()
super::modal(dialog, colors)
}
@@ -5,15 +5,12 @@
//! when the canvas is resized.
use crate::app::Message;
use iced::widget::{button, column, container, horizontal_rule, row, text, text_input};
use iced::{Element, Length};
use crate::theme::ThemeColors;
use iced::widget::{button, column, horizontal_rule, row, text, text_input};
use iced::Element;
/// Anchor position labels for the 9-point grid (row-major).
const ANCHOR_LABELS: [[&str; 3]; 3] = [
["TL", "TC", "TR"],
["ML", "MC", "MR"],
["BL", "BC", "BR"],
];
const ANCHOR_LABELS: [[&str; 3]; 3] = [["TL", "TC", "TR"], ["ML", "MC", "MR"], ["BL", "BC", "BR"]];
/// Build the Canvas Size dialog.
pub fn view(
@@ -21,6 +18,7 @@ pub fn view(
height: u32,
anchor_row: usize,
anchor_col: usize,
colors: ThemeColors,
) -> Element<'static, Message> {
let width_str = width.to_string();
let height_str = height.to_string();
@@ -86,14 +84,5 @@ pub fn view(
.padding(16)
.width(360);
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()
super::modal(dialog, colors)
}
@@ -1,11 +1,12 @@
//! Confirm dialog — save/discard/cancel confirmation.
use crate::app::Message;
use iced::widget::{button, column, container, horizontal_rule, row, slider, text};
use crate::theme::ThemeColors;
use iced::widget::{button, column, horizontal_rule, row, slider, text};
use iced::{Element, Length};
/// Build a confirmation dialog with save/discard/cancel.
pub fn close_confirm_view(doc_name: &str) -> Element<'static, Message> {
pub fn close_confirm_view(doc_name: &str, colors: ThemeColors) -> Element<'static, Message> {
let msg = format!("\"{}\" has unsaved changes.", doc_name);
let save_btn = button(text("Save").size(12))
@@ -31,16 +32,7 @@ pub fn close_confirm_view(doc_name: &str) -> Element<'static, Message> {
.padding(16)
.width(400);
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()
super::modal(dialog, colors)
}
/// Build a selection operation dialog (grow/shrink/feather).
@@ -49,6 +41,7 @@ pub fn selection_op_view(
value: f32,
min: f32,
max: f32,
colors: ThemeColors,
) -> Element<'static, Message> {
let op_owned = op_name.to_string();
let value_slider = slider(min..=max, value, |v| Message::SelectionOpValue(v))
@@ -75,14 +68,5 @@ pub fn selection_op_view(
.padding(16)
.width(350);
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()
super::modal(dialog, colors)
}
@@ -1,7 +1,8 @@
//! Dock Profile dialog — save, load, rename, and delete dock layout profiles.
use crate::app::Message;
use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, text_input};
use crate::theme::ThemeColors;
use iced::widget::{button, column, horizontal_rule, row, scrollable, text, text_input};
use iced::{Element, Length};
/// Build the dock profile management dialog.
@@ -17,6 +18,7 @@ pub fn view(
profiles: &[crate::settings::DockProfile],
active_profile: Option<&str>,
new_name: &str,
colors: ThemeColors,
) -> Element<'static, Message> {
let new_name_owned = new_name.to_string();
@@ -24,9 +26,7 @@ pub fn view(
let mut profile_list = column![].spacing(4);
if profiles.is_empty() {
profile_list = profile_list.push(
text("No saved profiles yet.").size(11),
);
profile_list = profile_list.push(text("No saved profiles yet.").size(11));
} else {
for profile in profiles {
let name = profile.name.clone();
@@ -45,9 +45,7 @@ pub fn view(
.on_press(Message::DockProfileDelete(name.clone()))
.padding([4, 8]);
let name_text = text(name.clone())
.size(12)
.width(Length::Fill);
let name_text = text(name.clone()).size(12).width(Length::Fill);
profile_list = profile_list.push(
row![apply_btn, name_text, rename_btn, delete_btn]
@@ -68,8 +66,7 @@ pub fn view(
.on_press(Message::DockProfileSave(new_name_owned.trim().to_string()))
.padding([6, 12])
} else {
button(text("Save Current").size(11))
.padding([6, 12])
button(text("Save Current").size(11)).padding([6, 12])
};
// Scrollable profile list with fixed max height
@@ -88,20 +85,11 @@ pub fn view(
row![button(text("Close").size(11))
.on_press(Message::DockProfileDialogHide)
.padding([6, 12])]
.width(Length::Fill),
.width(Length::Fill),
]
.spacing(8)
.padding(16)
.width(420);
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()
super::modal(dialog, colors)
}
@@ -5,14 +5,16 @@
//! updates the other to maintain the current aspect ratio.
use crate::app::Message;
use iced::widget::{button, checkbox, column, container, horizontal_rule, row, text, text_input};
use iced::{Element, Length};
use crate::theme::ThemeColors;
use iced::widget::{button, checkbox, column, horizontal_rule, row, text, text_input};
use iced::Element;
/// Build the Image Size dialog.
pub fn view(
width: u32,
height: u32,
constrain: bool,
colors: ThemeColors,
) -> Element<'static, Message> {
let width_str = width.to_string();
let height_str = height.to_string();
@@ -60,14 +62,5 @@ pub fn view(
.padding(16)
.width(360);
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()
super::modal(dialog, colors)
}
@@ -1,9 +1,57 @@
//! Dialog modules — modal dialogs for the Iced GUI.
pub mod new_image;
use crate::app::Message;
use crate::theme::ThemeColors;
use iced::widget::container;
use iced::{Element, Length};
pub mod about;
pub mod adjustments;
pub mod canvas_size;
pub mod confirm;
pub mod dock_profile;
pub mod image_size;
pub mod canvas_size;
pub mod about;
pub mod new_image;
/// Wraps dialog content in an opaque themed card over a translucent full-screen scrim.
///
/// **Purpose:** Prevents the canvas checkerboard from showing through dialog content while keeping
/// the obscured editor visible around the modal.
/// **Logic & Workflow:** The inner container supplies the active panel surface, readable inherited
/// text, border, and shadow. The outer fill container centers it and draws only the modal scrim.
/// **Arguments:** `content` is the complete dialog body and `colors` is the active application
/// palette.
/// **Returns:** A full-viewport modal element.
/// **Side Effects / Dependencies:** None; styling uses only the supplied theme tokens.
pub fn modal<'a>(
content: impl Into<Element<'a, Message>>,
colors: ThemeColors,
) -> Element<'a, Message> {
let card = container(content).style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_panel)),
text_color: Some(colors.text_primary),
border: iced::Border::default()
.rounded(3)
.color(colors.border_high)
.width(1),
shadow: iced::Shadow {
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.45),
offset: iced::Vector::new(0.0, 4.0),
blur_radius: 18.0,
},
..Default::default()
});
container(card)
.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.55,
))),
..Default::default()
})
.width(Length::Fill)
.height(Length::Fill)
.into()
}
@@ -1,7 +1,10 @@
//! New Image dialog — create a new document with preset sizes.
use crate::app::Message;
use iced::widget::{button, checkbox, column, container, horizontal_rule, row, scrollable, slider, text, text_input};
use crate::theme::ThemeColors;
use iced::widget::{
button, checkbox, column, horizontal_rule, row, scrollable, slider, text, text_input,
};
use iced::{Element, Length};
/// A canvas preset size.
@@ -12,14 +15,46 @@ struct CanvasPreset {
}
const PRESETS: &[CanvasPreset] = &[
CanvasPreset { name: "Custom", width: 0, height: 0 },
CanvasPreset { name: "HD (1280×720)", width: 1280, height: 720 },
CanvasPreset { name: "Full HD (1920×1080)", width: 1920, height: 1080 },
CanvasPreset { name: "4K (3840×2160)", width: 3840, height: 2160 },
CanvasPreset { name: "Square (1024×1024)", width: 1024, height: 1024 },
CanvasPreset { name: "A4 (2480×3508)", width: 2480, height: 3508 },
CanvasPreset { name: "800×600", width: 800, height: 600 },
CanvasPreset { name: "640×480", width: 640, height: 480 },
CanvasPreset {
name: "Custom",
width: 0,
height: 0,
},
CanvasPreset {
name: "HD (1280×720)",
width: 1280,
height: 720,
},
CanvasPreset {
name: "Full HD (1920×1080)",
width: 1920,
height: 1080,
},
CanvasPreset {
name: "4K (3840×2160)",
width: 3840,
height: 2160,
},
CanvasPreset {
name: "Square (1024×1024)",
width: 1024,
height: 1024,
},
CanvasPreset {
name: "A4 (2480×3508)",
width: 2480,
height: 3508,
},
CanvasPreset {
name: "800×600",
width: 800,
height: 600,
},
CanvasPreset {
name: "640×480",
width: 640,
height: 480,
},
];
/// Build the New Image dialog.
@@ -28,6 +63,7 @@ pub fn view(
width: u32,
height: u32,
transparent: bool,
colors: ThemeColors,
) -> Element<'static, Message> {
let name_owned = name.to_string();
let name_input = text_input("Document name", &name_owned)
@@ -88,24 +124,5 @@ pub fn view(
.padding(16)
.width(400);
// 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()
super::modal(dialog, colors)
}
@@ -67,6 +67,93 @@ impl PaneType {
PaneType::AiScript => "AI Script Generator",
}
}
/// Resolves an exact user-facing pane label to its dock type.
///
/// **Arguments:** `label` is the case-sensitive panel name accepted by automation commands.
/// **Returns:** The matching pane type, or `None` for unknown labels.
/// **Logic & Workflow:** Every supported variant is compared through its canonical label so
/// command-line capture and dock UI naming cannot drift independently.
/// **Side Effects / Dependencies:** None.
pub fn from_label(label: &str) -> Option<Self> {
[
Self::Canvas,
Self::Layers,
Self::History,
Self::Brushes,
Self::Filters,
Self::ColorPicker,
Self::Properties,
Self::Script,
Self::AiChat,
Self::LayerDetails,
Self::Geometry,
Self::ToolSettings,
Self::AiScript,
]
.into_iter()
.find(|pane| pane.label() == label)
}
}
#[cfg(test)]
mod tests {
use super::{DockState, PaneType};
use iced::widget::pane_grid::{Edge, Region, Target};
#[test]
fn pane_labels_round_trip() {
assert_eq!(PaneType::from_label("Geometry"), Some(PaneType::Geometry));
assert_eq!(PaneType::from_label("geometry"), None);
assert_eq!(PaneType::from_label("Unknown"), None);
}
/// Exercises Iced's complete drop target model while proving pane moves never duplicate Canvas.
#[test]
fn pane_drops_support_all_regions_and_preserve_one_canvas() {
let regions = [
Region::Center,
Region::Edge(Edge::Top),
Region::Edge(Edge::Right),
Region::Edge(Edge::Bottom),
Region::Edge(Edge::Left),
];
for region in regions {
let mut dock = DockState::new();
let source = pane(&dock, PaneType::Brushes);
let target = pane(&dock, PaneType::Layers);
dock.pane_grid.drop(source, Target::Pane(target, region));
assert_canvas_invariant(&dock);
}
for edge in [Edge::Top, Edge::Right, Edge::Bottom, Edge::Left] {
let mut dock = DockState::new();
let source = pane(&dock, PaneType::Brushes);
dock.pane_grid.drop(source, Target::Edge(edge));
assert_canvas_invariant(&dock);
}
}
/// Finds the pane identifier containing `pane_type` in a test dock.
fn pane(dock: &DockState, pane_type: PaneType) -> iced::widget::pane_grid::Pane {
dock.pane_grid
.iter()
.find(|(_, candidate)| **candidate == pane_type)
.map(|(pane, _)| *pane)
.expect("default dock pane must exist")
}
/// Asserts that a layout operation retained exactly one Canvas payload.
fn assert_canvas_invariant(dock: &DockState) {
assert_eq!(
dock.pane_grid
.iter()
.filter(|(_, pane_type)| **pane_type == PaneType::Canvas)
.count(),
1
);
}
}
/// The dock layout state, wrapping iced's PaneGrid state.
@@ -154,7 +241,8 @@ impl DockState {
let pane_grid = State::with_configuration(root_config);
// Find the Canvas pane (it's the center pane we want as root)
let root_pane = pane_grid.iter()
let root_pane = pane_grid
.iter()
.find(|(_, pane_type)| **pane_type == PaneType::Canvas)
.map(|(pane, _)| *pane)
.unwrap_or_else(|| pane_grid.iter().next().map(|(p, _)| *p).unwrap());
@@ -169,22 +257,18 @@ 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,
target,
pane_type,
)?;
let (new_pane, _) =
self.pane_grid
.split(iced::widget::pane_grid::Axis::Horizontal, target, pane_type)?;
Some(new_pane)
}
/// 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,
target,
pane_type,
)?;
let (new_pane, _) =
self.pane_grid
.split(iced::widget::pane_grid::Axis::Vertical, target, pane_type)?;
Some(new_pane)
}
@@ -226,7 +310,9 @@ impl DockState {
return;
}
let target_pane = self.pane_grid.iter()
let target_pane = self
.pane_grid
.iter()
.find(|(_, t)| **t == PaneType::Canvas)
.map(|(p, _)| *p)
.or_else(|| self.focused_pane)
@@ -234,9 +320,11 @@ impl DockState {
if let Some(target) = target_pane {
let axis = match pane_type {
PaneType::Layers | PaneType::Properties | PaneType::History | PaneType::LayerDetails | PaneType::AiScript => {
iced::widget::pane_grid::Axis::Horizontal
}
PaneType::Layers
| PaneType::Properties
| PaneType::History
| PaneType::LayerDetails
| PaneType::AiScript => iced::widget::pane_grid::Axis::Horizontal,
_ => iced::widget::pane_grid::Axis::Vertical,
};
if let Some((new_pane, _)) = self.pane_grid.split(axis, target, pane_type) {
@@ -253,7 +341,11 @@ impl DockState {
/// Toggle a pane: close it if open, reopen it if closed.
pub fn toggle_pane(&mut self, pane_type: PaneType) {
let is_open = self.pane_grid.iter().any(|(_, t)| *t == pane_type);
let pane = self.pane_grid.iter().find(|(_, t)| **t == pane_type).map(|(p, _)| *p);
let pane = self
.pane_grid
.iter()
.find(|(_, t)| **t == pane_type)
.map(|(p, _)| *p);
if is_open {
// Don't close the canvas pane
@@ -8,9 +8,10 @@
use crate::app::Message;
use crate::dock::state::{DockState, PaneType};
use crate::panels::styles;
use iced::widget::svg;
use crate::panels::typography::TITLE;
use crate::theme::ThemeColors;
use iced::widget::pane_grid::{Content, TitleBar};
use iced::widget::svg;
use iced::widget::{button, column, container, pane_grid, row, text};
use iced::{Element, Length};
@@ -40,9 +41,21 @@ pub fn dock_view<'a>(
let doc_tab_bar = document_tab_bar(app, colors);
let canvas = {
let doc = &app.documents[app.active_doc];
let pressure = app.tablet_state.lock().map(|s| s.current_pressure()).unwrap_or(1.0);
let pressure = app
.tablet_state
.lock()
.map(|s| s.current_pressure())
.unwrap_or(1.0);
let ts = &app.settings.tool_settings;
crate::canvas::view(doc, &app.tool_state, app.marching_ants_offset, pressure, ts.vector_points, ts.vector_sides, ts.vector_radius)
crate::canvas::view(
doc,
&app.tool_state,
app.marching_ants_offset,
pressure,
ts.vector_points,
ts.vector_sides,
ts.vector_radius,
)
};
column![doc_tab_bar, canvas]
.width(Length::Fill)
@@ -52,27 +65,39 @@ pub fn dock_view<'a>(
// 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(), &doc.engine, colors)
crate::panels::layers::view(
&doc.cached_layers,
doc.engine.active_layer_id(),
&doc.engine,
colors,
)
}
PaneType::History => {
let doc = &app.documents[app.active_doc];
crate::panels::history::view(&doc.cached_history, doc.history_current, colors)
}
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,
colors,
)
}
PaneType::Filters => {
crate::panels::filters::view(app.selected_filter, &app.filter_params, app.filter_preview_active, colors)
}
PaneType::ColorPicker => {
crate::color_picker::view(&app.fg_color, &app.bg_color, &app.recent_colors, app.color_tab)
}
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,
colors,
),
PaneType::Filters => crate::panels::filters::view(
app.selected_filter,
&app.filter_params,
app.filter_preview_active,
app.documents[app.active_doc]
.engine
.active_layer_is_editable(),
colors,
),
PaneType::ColorPicker => crate::color_picker::view(
&app.fg_color,
&app.bg_color,
&app.recent_colors,
app.color_tab,
),
PaneType::Properties => {
let doc = &app.documents[app.active_doc];
let active_id = doc.engine.active_layer_id();
@@ -80,8 +105,12 @@ pub fn dock_view<'a>(
let layer_name = layer_info.map(|l| l.name.as_str()).unwrap_or("");
let layer_opacity = layer_info.map(|l| l.opacity).unwrap_or(1.0);
let layer_visible = layer_info.map(|l| l.visible).unwrap_or(true);
let layer_blend_mode = layer_info.map(|l| l.blend_mode).unwrap_or(hcie_engine_api::BlendMode::Normal);
let layer_type = layer_info.map(|l| l.layer_type).unwrap_or(hcie_engine_api::LayerType::Raster);
let layer_blend_mode = layer_info
.map(|l| l.blend_mode)
.unwrap_or(hcie_engine_api::BlendMode::Normal);
let layer_type = layer_info
.map(|l| l.layer_type)
.unwrap_or(hcie_engine_api::LayerType::Raster);
let layer_width = layer_info.map(|l| l.width).unwrap_or(0);
let layer_height = layer_info.map(|l| l.height).unwrap_or(0);
let vector_shapes = doc.engine.active_vector_shapes().unwrap_or_default();
@@ -110,29 +139,32 @@ pub fn dock_view<'a>(
ts2.vector_points,
ts2.vector_sides,
active_id,
app.fg_color,
app.bg_color,
)
}
PaneType::Script => {
crate::panels::script_panel::view(
&app.script_content,
app.script_error.as_deref(),
app.script_error_line,
app.script_is_running,
PaneType::Script => crate::panels::script_panel::view(
&app.script_content,
app.script_error.as_deref(),
app.script_error_line,
app.script_is_running,
colors,
),
PaneType::AiChat => crate::panels::ai_chat_panel::view(&app.ai_chat_state, colors),
PaneType::AiScript => crate::panels::ai_script_panel::view(app),
PaneType::LayerDetails => {
let doc = &app.documents[app.active_doc];
crate::panels::layer_details::view(
&doc.cached_layers,
doc.engine.active_layer_id(),
colors,
)
}
PaneType::AiChat => {
crate::panels::ai_chat_panel::view(&app.ai_chat_state, colors)
}
PaneType::AiScript => {
crate::panels::ai_script_panel::view(app)
}
PaneType::LayerDetails => {
let doc = &app.documents[app.active_doc];
crate::panels::layer_details::view(&doc.cached_layers, doc.engine.active_layer_id(), colors)
}
PaneType::Geometry => {
let shapes = app.documents[app.active_doc].engine.active_vector_shapes().unwrap_or_default();
let shapes = app.documents[app.active_doc]
.engine
.active_vector_shapes()
.unwrap_or_default();
crate::panels::geometry::view(
shapes,
colors,
@@ -144,7 +176,13 @@ pub fn dock_view<'a>(
PaneType::ToolSettings => {
let fg = app.fg_color;
let draft = app.documents[app.active_doc].text_draft.as_ref();
crate::panels::tool_settings::view(&app.tool_state, &app.settings, fg, draft, colors)
crate::panels::tool_settings::view(
&app.tool_state,
&app.settings,
fg,
draft,
colors,
)
}
};
@@ -173,7 +211,10 @@ pub fn dock_view<'a>(
.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,
colors.accent.r,
colors.accent.g,
colors.accent.b,
0.15,
)),
border: iced::Border::default().color(colors.accent).width(1),
},
@@ -194,7 +235,10 @@ 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 (*). Tabs have hover feedback.
fn document_tab_bar<'a>(app: &'a crate::app::HcieIcedApp, colors: ThemeColors) -> Element<'a, Message> {
fn document_tab_bar<'a>(
app: &'a crate::app::HcieIcedApp,
colors: ThemeColors,
) -> Element<'a, Message> {
let mut tabs = row![].spacing(0);
for (idx, doc) in app.documents.iter().enumerate() {
@@ -219,33 +263,43 @@ fn document_tab_bar<'a>(app: &'a crate::app::HcieIcedApp, colors: ThemeColors) -
let tab = button(
row![
tab_text,
button(text("×").size(10))
.on_press(Message::NoOp)
.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()
},
}
}),
iced::widget::tooltip(
button(text("×").size(10))
.on_press(Message::DocClose(idx))
.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()
},
}
}
),
text(format!("Close {}", doc.name)).size(11),
iced::widget::tooltip::Position::Bottom,
),
]
.spacing(4)
.align_y(iced::Alignment::Center)
.align_y(iced::Alignment::Center),
)
.on_press(Message::DocSwitch(idx))
.padding([4, 8])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
match status {
.style(
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
iced::widget::button::Status::Active => {
if active {
iced::widget::button::Style {
@@ -286,8 +340,8 @@ fn document_tab_bar<'a>(app: &'a crate::app::HcieIcedApp, colors: ThemeColors) -
text_color: c.accent,
..Default::default()
},
}
});
},
);
tabs = tabs.push(tab);
}
@@ -296,8 +350,8 @@ fn document_tab_bar<'a>(app: &'a crate::app::HcieIcedApp, colors: ThemeColors) -
let new_btn = button(text("+").size(12))
.on_press(Message::DialogOpen(crate::app::ActiveDialog::NewImage))
.padding([4, 8])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
match status {
.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,
@@ -305,13 +359,15 @@ fn document_tab_bar<'a>(app: &'a crate::app::HcieIcedApp, colors: ThemeColors) -
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.0,
))),
text_color: colors.text_secondary,
border: iced::Border::default(),
..Default::default()
},
}
});
},
);
tabs = tabs.push(new_btn);
@@ -345,7 +401,11 @@ fn pane_type_icon(pane_type: PaneType) -> Option<&'static str> {
/// 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> {
fn make_title_bar<'a>(
pane: pane_grid::Pane,
pane_type: PaneType,
colors: ThemeColors,
) -> TitleBar<'a, Message> {
// Panel type icon
let icon_paths = [
std::path::PathBuf::from("hcie-iced-app/assets"),
@@ -355,29 +415,39 @@ fn make_title_bar<'a>(pane: pane_grid::Pane, pane_type: PaneType, colors: ThemeC
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 {
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()
},
);
row![
icon,
text(pane_type.label())
.size(TITLE)
.color(colors.text_secondary)
]
.spacing(4)
.align_y(iced::Alignment::Center)
.into()
} else {
text(pane_type.label()).size(11).color(colors.text_secondary).into()
text(pane_type.label())
.size(TITLE)
.color(colors.text_secondary)
.into()
}
} else {
text(pane_type.label()).size(11).color(colors.text_secondary).into()
text(pane_type.label())
.size(TITLE)
.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: &iced::Theme, status: iced::widget::button::Status| {
match status {
.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,
@@ -391,20 +461,22 @@ fn make_title_bar<'a>(pane: pane_grid::Pane, pane_type: PaneType, colors: ThemeC
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.0,
))),
text_color: colors.text_secondary,
border: iced::Border::default(),
..Default::default()
},
}
});
},
);
// Maximize button with hover state
let maximize_btn = button(text("").size(11))
.on_press(Message::PaneMaximize(pane))
.padding([2, 6])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
match status {
.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,
@@ -418,18 +490,25 @@ fn make_title_bar<'a>(pane: pane_grid::Pane, pane_type: PaneType, colors: ThemeC
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.0,
))),
text_color: colors.text_secondary,
border: iced::Border::default(),
..Default::default()
},
}
});
},
);
TitleBar::new(
row![title_content, iced::widget::Space::with_width(Length::Fill), maximize_btn, close_btn]
.spacing(2)
.align_y(iced::Alignment::Center)
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))
}
@@ -148,7 +148,12 @@ pub fn start_evdev_listener(state: Arc<Mutex<TabletState>>) {
let pr = state[abs_pressure.0 as usize];
log::info!(
"[tablet-evdev] ABS_X=[{},{}], ABS_Y=[{},{}], ABS_PRESSURE=[{},{}]",
xr.minimum, xr.maximum, yr.minimum, yr.maximum, pr.minimum, pr.maximum
xr.minimum,
xr.maximum,
yr.minimum,
yr.maximum,
pr.minimum,
pr.maximum
);
(
(xr.minimum, xr.maximum),
@@ -1,3 +1,6 @@
//! HCIE Iced GUI library — re-exports selection types for integration tests.
//! HCIE Iced GUI library - exposes testable, UI-independent application components.
pub mod cli;
pub mod raster;
pub mod selection;
pub mod vector_edit;
+33 -39
View File
@@ -7,18 +7,22 @@ mod ai_script;
mod app;
mod brush_import;
mod canvas;
mod cli;
mod color_picker;
mod dialogs;
mod dock;
mod i18n;
mod io;
mod panels;
mod raster;
mod screenshot;
mod script;
mod selection;
mod settings;
mod shape_sync;
mod sidebar;
mod theme;
mod vector_edit;
mod viewer;
use app::HcieIcedApp;
@@ -29,49 +33,39 @@ fn main() {
log::info!("Starting HCIE Iced GUI");
let args: Vec<String> = std::env::args().collect();
let mut load_path: Option<std::path::PathBuf> = None;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--help" | "-h" => {
println!("Usage: hcie-iced [OPTIONS] [FILE]");
println!();
println!("HCIE — Iced Edition");
println!();
println!("Options:");
println!(" -h, --help Print this help message");
println!();
println!("Arguments:");
println!(" FILE Image file to open on startup");
std::process::exit(0);
}
other if !other.starts_with('-') => {
load_path = Some(std::path::PathBuf::from(other));
}
unknown => {
eprintln!("Unknown argument: {}", unknown);
std::process::exit(1);
}
let args = match cli::parse_args(std::env::args().skip(1)) {
Ok(args) => args,
Err(error) => {
eprintln!("{error}\n\n{}", cli::help_text());
std::process::exit(2);
}
i += 1;
};
if args.help {
println!("{}", cli::help_text());
return;
}
// Start tablet evdev listener
let tablet_state = std::sync::Arc::new(std::sync::Mutex::new(
io::tablet::TabletState::new(),
));
let tablet_state = std::sync::Arc::new(std::sync::Mutex::new(io::tablet::TabletState::new()));
io::tablet::start_evdev_listener(tablet_state.clone());
let _ = iced::application("HCIE — Iced Edition", HcieIcedApp::update, HcieIcedApp::view)
.subscription(HcieIcedApp::subscription)
.theme(HcieIcedApp::theme)
.window_size((1280.0, 800.0))
.decorations(false)
.run_with(move || {
let (mut app, init_task) = HcieIcedApp::new(load_path);
app.tablet_state = tablet_state;
(app, init_task)
});
let load_path = args.load_path;
let screenshot = args.screenshot;
if let Err(error) = iced::application(
"HCIE — Iced Edition",
HcieIcedApp::update,
HcieIcedApp::view,
)
.subscription(HcieIcedApp::subscription)
.theme(HcieIcedApp::theme)
.window_size((1280.0, 800.0))
.decorations(false)
.run_with(move || {
let (mut app, init_task) = HcieIcedApp::new(load_path, screenshot);
app.tablet_state = tablet_state;
(app, init_task)
}) {
eprintln!("failed to run HCIE Iced GUI: {error}");
std::process::exit(1);
}
}
@@ -15,9 +15,21 @@ use iced::{Element, Length};
pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Message> {
// ── Provider selector row ──
let provider_label = text("Provider:").size(10).color(colors.text_secondary);
let ollama_btn = make_provider_btn("Ollama", state.config.provider == crate::ai_chat::AiProvider::Ollama, colors);
let claude_btn = make_provider_btn("Claude", state.config.provider == crate::ai_chat::AiProvider::Claude, colors);
let openai_btn = make_provider_btn("OpenAI", state.config.provider == crate::ai_chat::AiProvider::OpenAI, colors);
let ollama_btn = make_provider_btn(
"Ollama",
state.config.provider == crate::ai_chat::AiProvider::Ollama,
colors,
);
let claude_btn = make_provider_btn(
"Claude",
state.config.provider == crate::ai_chat::AiProvider::Claude,
colors,
);
let openai_btn = make_provider_btn(
"OpenAI",
state.config.provider == crate::ai_chat::AiProvider::OpenAI,
colors,
);
let provider_row = row![provider_label, ollama_btn, claude_btn, openai_btn]
.spacing(4)
.align_y(iced::Alignment::Center);
@@ -35,28 +47,37 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
.size(10)
.width(Length::Fill);
let config_row = row![
url_label,
url_input,
model_label,
model_input,
]
.spacing(4)
.align_y(iced::Alignment::Center);
let config_row = row![url_label, url_input, model_label, model_input,]
.spacing(4)
.align_y(iced::Alignment::Center);
// ── Toggles row ──
let reasoning_label = text("Reasoning").size(9).color(colors.text_secondary);
let reasoning_btn = button(
text(if state.config.reasoning_enabled { "ON" } else { "OFF" }).size(9)
text(if state.config.reasoning_enabled {
"ON"
} else {
"OFF"
})
.size(9),
)
.on_press(Message::AiChatReasoningToggled(!state.config.reasoning_enabled))
.on_press(Message::AiChatReasoningToggled(
!state.config.reasoning_enabled,
))
.padding([2, 6]);
let canvas_label = text("Canvas ctx").size(9).color(colors.text_secondary);
let canvas_btn = button(
text(if state.config.canvas_context { "ON" } else { "OFF" }).size(9)
text(if state.config.canvas_context {
"ON"
} else {
"OFF"
})
.size(9),
)
.on_press(Message::AiChatCanvasContextToggled(!state.config.canvas_context))
.on_press(Message::AiChatCanvasContextToggled(
!state.config.canvas_context,
))
.padding([2, 6]);
let toggles_row = row![
@@ -78,12 +99,18 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
column![
text("AI Creative Assistant").size(13).color(colors.accent),
text("").size(4),
text("Hello! I am your AI Drawing Partner.").size(11).color(colors.text_secondary),
text("Hello! I am your AI Drawing Partner.")
.size(11)
.color(colors.text_secondary),
text("").size(4),
text("Ask me to paint landscapes, add details,").size(10).color(colors.text_disabled),
text("apply blur filters, or manipulate layers!").size(10).color(colors.text_disabled),
text("Ask me to paint landscapes, add details,")
.size(10)
.color(colors.text_disabled),
text("apply blur filters, or manipulate layers!")
.size(10)
.color(colors.text_disabled),
]
.spacing(2)
.spacing(2),
)
.padding(12)
.width(Length::Fill)
@@ -105,16 +132,24 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
if state.config.reasoning_enabled && !state.current_reasoning.is_empty() {
let reasoning_bubble = container(
column![
text("Deep Thoughts:").size(10).color(iced::Color::from_rgb(0.85, 0.47, 0.02)),
text(state.current_reasoning.clone()).size(10).color(colors.text_secondary),
text("Deep Thoughts:")
.size(10)
.color(iced::Color::from_rgb(0.85, 0.47, 0.02)),
text(state.current_reasoning.clone())
.size(10)
.color(colors.text_secondary),
]
.spacing(2)
.spacing(2),
)
.padding(8)
.width(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(0.15, 0.12, 0.08, 1.0))),
border: iced::Border::default().rounded(6).color(iced::Color::from_rgb(0.85, 0.47, 0.02)),
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.15, 0.12, 0.08, 1.0,
))),
border: iced::Border::default()
.rounded(6)
.color(iced::Color::from_rgb(0.85, 0.47, 0.02)),
..Default::default()
});
@@ -124,13 +159,19 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
// Render streaming response if in progress
if state.is_streaming && !state.current_response.is_empty() {
let streaming_bubble = container(
text(state.current_response.clone()).size(11).color(colors.text_primary)
text(state.current_response.clone())
.size(11)
.color(colors.text_primary),
)
.padding(8)
.width(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(0.18, 0.22, 0.28, 1.0))),
border: iced::Border::default().rounded(6).color(iced::Color::from_rgba(0.29, 0.62, 1.0, 0.5)),
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.18, 0.22, 0.28, 1.0,
))),
border: iced::Border::default()
.rounded(6)
.color(iced::Color::from_rgba(0.29, 0.62, 1.0, 0.5)),
..Default::default()
});
@@ -141,7 +182,9 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
if state.is_streaming {
let indicator = row![
text("...").size(10).color(colors.text_disabled),
text("AI is thinking...").size(10).color(colors.text_disabled),
text("AI is thinking...")
.size(10)
.color(colors.text_disabled),
]
.spacing(4);
@@ -155,15 +198,24 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
.size(11)
.width(Length::Fill);
let send_btn = button(text("Send").size(10))
.on_press(Message::AiChatSend)
.padding([6, 10]);
let send_btn = action_button(
"Send",
(!state.is_streaming && !state.input.trim().is_empty()).then_some(Message::AiChatSend),
colors,
);
let stream_btn = action_button(
"Cancel",
state.is_streaming.then_some(Message::AiChatCancel),
colors,
);
let clear_btn = action_button("Clear", Some(Message::AiChatClear), colors);
let copy_btn = action_button(
"Copy all",
(!state.messages.is_empty() || state.is_streaming).then_some(Message::AiChatCopyTranscript),
colors,
);
let clear_btn = button(text("Clear").size(10))
.on_press(Message::AiChatClear)
.padding([6, 10]);
let input_row = row![input_field, send_btn, clear_btn]
let input_row = row![input_field, send_btn, stream_btn, copy_btn, clear_btn]
.spacing(4)
.align_y(iced::Alignment::Center);
@@ -172,6 +224,9 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
text("AI Assistant").size(13).color(colors.text_primary),
provider_row,
config_row,
text("Model discovery is unavailable; enter the exact provider model ID.")
.size(9)
.color(colors.text_disabled),
toggles_row,
horizontal_rule(1),
scrollable(msg_list).height(Length::Fill),
@@ -188,26 +243,82 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
.into()
}
/// Build an action button whose disabled, hover, pressed, and focus-border states are explicit.
fn action_button(
label: &'static str,
message: Option<Message>,
colors: ThemeColors,
) -> Element<'static, Message> {
let mut control = button(text(label).size(10)).padding([6, 10]);
if let Some(message) = message {
control = control.on_press(message);
}
control
.style(move |_theme, status| {
let (background, text_color, border) = match status {
iced::widget::button::Status::Active => {
(colors.bg_active, colors.text_primary, colors.border_high)
}
iced::widget::button::Status::Hovered => {
(colors.accent_hover, iced::Color::WHITE, colors.accent)
}
iced::widget::button::Status::Pressed => {
(colors.accent, iced::Color::WHITE, colors.text_primary)
}
iced::widget::button::Status::Disabled => {
(colors.bg_app, colors.text_disabled, colors.border_low)
}
};
iced::widget::button::Style {
background: Some(iced::Background::Color(background)),
text_color,
border: iced::Border::default().rounded(3).color(border).width(1),
..Default::default()
}
})
.into()
}
/// Render a single chat message as a styled bubble.
fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'static, Message> {
if msg.is_reasoning {
return container(
column![
text("Reasoning").size(9).color(colors.text_secondary),
text(msg.content.clone())
.size(10)
.color(colors.text_secondary),
]
.spacing(2),
)
.padding(8)
.width(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_app)),
border: iced::Border::default().rounded(6).color(colors.border_high),
..Default::default()
})
.into();
}
match msg.role.as_str() {
"user" => {
// User bubble — right-aligned, blue tint
let label = text("You").size(9).color(colors.accent);
let content = text(msg.content.clone()).size(11).color(colors.text_primary);
let bubble = container(
column![label, content].spacing(2)
)
.padding(8)
.max_width(400.0)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(0.16, 0.33, 0.58, 1.0))),
border: iced::Border::default().rounded(6),
..Default::default()
});
let content = text(msg.content.clone())
.size(11)
.color(colors.text_primary);
let bubble = container(column![label, content].spacing(2))
.padding(8)
.max_width(400.0)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.16, 0.33, 0.58, 1.0,
))),
border: iced::Border::default().rounded(6),
..Default::default()
});
row![iced::widget::Space::with_width(Length::Fill), bubble]
.into()
row![iced::widget::Space::with_width(Length::Fill), bubble].into()
}
"tool" => {
// Tool result — monospace pill
@@ -224,40 +335,45 @@ fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'sta
_ => {
// Assistant bubble — left-aligned, slate tint
let label = text("AI").size(9).color(colors.accent_green);
let content = text(msg.content.clone()).size(11).color(colors.text_primary);
let bubble = container(
column![label, content].spacing(2)
)
.padding(8)
.max_width(400.0)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(0.18, 0.22, 0.28, 1.0))),
border: iced::Border::default().rounded(6),
..Default::default()
});
let content = text(msg.content.clone())
.size(11)
.color(colors.text_primary);
let bubble = container(column![label, content].spacing(2))
.padding(8)
.max_width(400.0)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.18, 0.22, 0.28, 1.0,
))),
border: iced::Border::default().rounded(6),
..Default::default()
});
row![bubble, iced::widget::Space::with_width(Length::Fill)]
.into()
row![bubble, iced::widget::Space::with_width(Length::Fill)].into()
}
}
}
/// Create a provider selection button with active state highlighting.
fn make_provider_btn(label: &str, is_active: bool, colors: ThemeColors) -> Element<'static, Message> {
fn make_provider_btn(
label: &str,
is_active: bool,
colors: ThemeColors,
) -> Element<'static, Message> {
let label_owned = label.to_string();
let msg = match label {
"Ollama" => Message::AiChatProviderChanged("Ollama".to_string()),
"Claude" => Message::AiChatProviderChanged("Claude".to_string()),
"OpenAI" => Message::AiChatProviderChanged("OpenAI".to_string()),
_ => Message::NoOp,
_ => unreachable!("provider buttons are built from known labels"),
};
button(text(label_owned).size(10))
.on_press(msg)
.padding([4, 8])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
match status {
iced::widget::button::Status::Active | iced::widget::button::Status::Hovered => {
.style(
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
iced::widget::button::Status::Active => {
if is_active {
iced::widget::button::Style {
background: Some(iced::Background::Color(colors.accent)),
@@ -274,13 +390,33 @@ fn make_provider_btn(label: &str, is_active: bool, colors: ThemeColors) -> Eleme
}
}
}
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(colors.bg_hover)),
text_color: colors.text_secondary,
iced::widget::button::Status::Hovered => iced::widget::button::Style {
background: Some(iced::Background::Color(if is_active {
colors.accent_hover
} else {
colors.bg_active
})),
text_color: if is_active {
iced::Color::WHITE
} else {
colors.text_primary
},
border: iced::Border::default().rounded(4).color(colors.border_high),
..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().rounded(4).color(colors.accent),
..Default::default()
},
iced::widget::button::Status::Disabled => iced::widget::button::Style {
background: Some(iced::Background::Color(colors.bg_app)),
text_color: colors.text_disabled,
border: iced::Border::default().rounded(4),
..Default::default()
},
}
})
},
)
.into()
}
@@ -14,6 +14,7 @@ use iced::{Element, Length};
/// action buttons (Generate, Clear, Run, Copy), and the generated script
/// output with error display.
pub fn view(app: &HcieIcedApp) -> Element<'static, Message> {
let colors = app.theme_state.colors();
// ── Provider selector row ────────────────────────────
let provider_label = text("Provider").size(10);
let ollama_btn = button(text("Ollama").size(10))
@@ -50,13 +51,28 @@ pub fn view(app: &HcieIcedApp) -> Element<'static, Message> {
.padding([6, 12])
};
let run_btn = button(text("Run Script").size(11))
.on_press(Message::AiScriptRun)
.padding([6, 12]);
let run_btn = if app.ai_script_output.is_empty() || app.ai_script_is_running {
button(text("Run Script").size(11)).padding([6, 12])
} else {
button(text("Run Script").size(11))
.on_press(Message::AiScriptRun)
.padding([6, 12])
};
let copy_btn = button(text("Copy to Editor").size(11))
.on_press(Message::AiScriptCopy)
.padding([6, 12]);
let copy_btn = if app.ai_script_output.is_empty() {
button(text("To Editor").size(11)).padding([6, 12])
} else {
button(text("To Editor").size(11))
.on_press(Message::AiScriptCopy)
.padding([6, 12])
};
let copy_output_btn = if app.ai_script_output.is_empty() {
button(text("Copy Output").size(11)).padding([6, 12])
} else {
button(text("Copy Output").size(11))
.on_press(Message::AiScriptCopyOutput)
.padding([6, 12])
};
let clear_btn = button(text("Clear").size(11))
.on_press(Message::AiScriptClear)
@@ -69,24 +85,30 @@ pub fn view(app: &HcieIcedApp) -> Element<'static, Message> {
text(app.ai_script_output.clone()).size(10)
};
let script_area = container(
scrollable(script_content).height(Length::Fill)
)
.padding(8)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgb(0.12, 0.12, 0.12))),
border: iced::Border::default().rounded(4),
..Default::default()
});
let script_area = container(scrollable(script_content).height(Length::Fill))
.padding(8)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_app)),
border: iced::Border::default().rounded(4).color(colors.border_high),
..Default::default()
});
// ── Error display ────────────────────────────────────
let error_section: Element<'static, Message> = if let Some(ref err) = app.ai_script_error {
text(format!("Error: {}", err))
.size(10)
.color(iced::Color::from_rgb(0.9, 0.3, 0.3))
.color(colors.danger)
.into()
} else {
column![].into()
app.ai_script_notice.as_ref().map_or_else(
|| column![].into(),
|notice| {
text(notice.clone())
.size(10)
.color(colors.accent_green)
.into()
},
)
};
// ── Assemble panel ───────────────────────────────────
@@ -97,6 +119,9 @@ pub fn view(app: &HcieIcedApp) -> Element<'static, Message> {
provider_selector,
url_input,
model_input,
text("Model discovery is unavailable; enter the exact provider model ID.")
.size(9)
.color(colors.text_disabled),
horizontal_rule(1),
prompt_input,
row![generate_btn, clear_btn].spacing(4),
@@ -104,7 +129,7 @@ pub fn view(app: &HcieIcedApp) -> Element<'static, Message> {
script_area,
error_section,
horizontal_rule(1),
row![run_btn, copy_btn].spacing(4),
row![run_btn, copy_btn, copy_output_btn].spacing(4),
]
.spacing(4)
.padding(8);
@@ -113,8 +138,8 @@ pub fn view(app: &HcieIcedApp) -> Element<'static, Message> {
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgb(0.18, 0.18, 0.18))),
border: iced::Border::default().color(iced::Color::from_rgb(0.3, 0.3, 0.3)).width(1),
background: Some(iced::Background::Color(colors.bg_panel)),
border: iced::Border::default().color(colors.border_high).width(1),
..Default::default()
})
.into()
@@ -12,7 +12,9 @@ use crate::app::Message;
use crate::panels::styles;
use crate::theme::ThemeColors;
use hcie_engine_api::BrushStyle;
use iced::widget::{button, column, container, horizontal_rule, row, scrollable, slider, text};
use iced::widget::{
button, column, container, horizontal_rule, row, scrollable, slider, svg, text,
};
use iced::{Element, Length};
use std::sync::OnceLock;
@@ -35,30 +37,118 @@ enum BrushCategory {
const BRUSH_PRESETS: &[BrushPreset] = &[
// 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 },
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 },
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 },
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,
},
];
/// Cached brush preview images: (style_index, pixels).
@@ -109,12 +199,20 @@ fn generate_brush_preview(style: BrushStyle) -> Vec<u8> {
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::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::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::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(),
};
@@ -146,9 +244,15 @@ fn generate_brush_preview(style: BrushStyle) -> Vec<u8> {
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] = ((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;
}
}
@@ -195,37 +299,65 @@ pub fn view(
) -> Element<'static, Message> {
// Category tabs
let categories = [
(BrushCategory::All, "All"),
(BrushCategory::Drawing, "Drawing"),
(BrushCategory::Painting, "Painting"),
(BrushCategory::Effects, "Effects"),
(BrushCategory::All, "All brushes", "icons/panel-brush.svg"),
(BrushCategory::Drawing, "Drawing", "icons/pen.svg"),
(BrushCategory::Painting, "Painting", "icons/brush.svg"),
(BrushCategory::Effects, "Effects", "icons/glow.svg"),
];
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()
for (cat, label, icon_path) in categories {
let active = cat == BrushCategory::All;
let icon = svg(svg::Handle::from_path(
std::path::Path::new("hcie-iced-app/assets").join(icon_path),
))
.width(16)
.height(16)
.style(move |_theme, _status| svg::Style {
color: Some(if active {
colors.accent
} else {
colors.text_secondary
}),
});
let btn = button(icon).padding([3, 6]).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(if active {
colors.bg_active
} else {
iced::Color::TRANSPARENT
})),
text_color: if active {
colors.accent
} else {
colors.text_secondary
},
_ => 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);
border: iced::Border::default()
.color(if active {
colors.accent
} else {
colors.border_low
})
.width(if active { 1 } else { 0 }),
..Default::default()
},
},
);
tabs = tabs.push(iced::widget::tooltip(
btn,
text(format!("{label} (category filtering is not available yet)")).size(10),
iced::widget::tooltip::Position::Bottom,
));
}
// Brush preset grid — 3 columns
// Brush preset grid — two columns remain usable in narrow dock panes.
let mut grid_rows = column![].spacing(4);
let mut current_row = row![].spacing(4);
@@ -235,42 +367,40 @@ pub fn view(
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()
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()
}
}
} 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 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 {
.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,
@@ -278,18 +408,19 @@ pub fn view(
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.0,
))),
text_color: 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 {
if (idx + 1) % 2 == 0 || idx == BRUSH_PRESETS.len() - 1 {
grid_rows = grid_rows.push(current_row);
current_row = row![].spacing(4);
}
@@ -300,30 +431,30 @@ pub fn view(
.step(1.0)
.width(Length::Fill);
let opacity_slider = slider(0.0..=1.0, current_opacity, |v| Message::BrushOpacityChanged(v))
.step(0.01)
.width(Length::Fill);
let opacity_slider = slider(0.0..=1.0, current_opacity, |v| {
Message::BrushOpacityChanged(v)
})
.step(0.01)
.width(Length::Fill);
let hardness_slider = slider(0.0..=1.0, current_hardness, |v| Message::BrushHardnessChanged(v))
.step(0.01)
.width(Length::Fill);
let hardness_slider = slider(0.0..=1.0, current_hardness, |v| {
Message::BrushHardnessChanged(v)
})
.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 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()
});
// Panel title is in the dock tab — no duplicate inside the panel.
let panel = column![
@@ -331,11 +462,11 @@ pub fn view(
horizontal_rule(1),
scrollable(grid_rows).height(Length::Fill),
horizontal_rule(1),
row![
button(text("Import ABR").size(10))
.on_press(Message::BrushImportAbr)
.padding([4, 8])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
row![button(text("Import ABR").size(10))
.on_press(Message::BrushImportAbr)
.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.accent)),
@@ -350,8 +481,9 @@ pub fn view(
..Default::default()
},
}
})
].spacing(4),
}
)]
.spacing(4),
text("Preview").size(10),
live_preview,
text(format!("Size: {:.0}", current_size)).size(10),
@@ -17,34 +17,22 @@ use serde_json::json;
/// Read a float parameter from the JSON params, falling back to a default.
fn param_f64(params: &serde_json::Value, key: &str, default: f64) -> f64 {
params
.get(key)
.and_then(|v| v.as_f64())
.unwrap_or(default)
params.get(key).and_then(|v| v.as_f64()).unwrap_or(default)
}
/// Read an integer parameter from the JSON params, falling back to a default.
fn param_u64(params: &serde_json::Value, key: &str, default: u64) -> u64 {
params
.get(key)
.and_then(|v| v.as_u64())
.unwrap_or(default)
params.get(key).and_then(|v| v.as_u64()).unwrap_or(default)
}
/// Read a boolean parameter from the JSON params, falling back to a default.
fn param_bool(params: &serde_json::Value, key: &str, default: bool) -> bool {
params
.get(key)
.and_then(|v| v.as_bool())
.unwrap_or(default)
params.get(key).and_then(|v| v.as_bool()).unwrap_or(default)
}
/// Read a string parameter from the JSON params, falling back to a default.
fn param_str<'a>(params: &'a serde_json::Value, key: &str, default: &'a str) -> &'a str {
params
.get(key)
.and_then(|v| v.as_str())
.unwrap_or(default)
params.get(key).and_then(|v| v.as_str()).unwrap_or(default)
}
/// Build a labeled float slider row.
@@ -71,14 +59,13 @@ fn float_slider(
let label_owned = label.to_string();
let lbl = text(label_owned).size(11).width(Length::Fixed(80.0));
let display = text(format!("{:.1}", value)).size(11).width(Length::Fixed(40.0));
let display = text(format!("{:.1}", value))
.size(11)
.width(Length::Fixed(40.0));
let s = slider(min_f..=max_f, val, move |v| {
let rounded = (v as f64 / step).round() * step;
Message::FilterParamChanged(
filter_owned.clone(),
json!({ key_owned.clone(): rounded }),
)
Message::FilterParamChanged(filter_owned.clone(), json!({ key_owned.clone(): rounded }))
})
.step(step_f)
.width(Length::Fill);
@@ -104,7 +91,9 @@ fn int_slider(
let label_owned = label.to_string();
let lbl = text(label_owned).size(11).width(Length::Fixed(80.0));
let display = text(format!("{}", value)).size(11).width(Length::Fixed(40.0));
let display = text(format!("{}", value))
.size(11)
.width(Length::Fixed(40.0));
let s = slider(min_f..=max_f, val, move |v| {
Message::FilterParamChanged(
@@ -121,9 +110,13 @@ fn int_slider(
/// Build a section header for a group of parameters.
fn section_header(label: &str, colors: ThemeColors) -> Element<'static, Message> {
let label_owned = label.to_string();
container(text(label_owned).size(11).style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(colors.text_secondary),
}))
container(
text(label_owned)
.size(11)
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(colors.text_secondary),
}),
)
.padding(iced::Padding::default().top(4.0).bottom(2.0))
.into()
}
@@ -173,8 +166,25 @@ pub fn view(
let distance = param_u64(params, "distance", 10);
column![
section_header("Motion Blur", colors),
float_slider("Angle", "angle", "motion_blur", angle, 0.0, 360.0, 1.0, colors),
int_slider("Distance", "distance", "motion_blur", distance, 1, 500, colors),
float_slider(
"Angle",
"angle",
"motion_blur",
angle,
0.0,
360.0,
1.0,
colors
),
int_slider(
"Distance",
"distance",
"motion_blur",
distance,
1,
500,
colors
),
]
.spacing(2)
.into()
@@ -264,9 +274,36 @@ pub fn view(
section_header("Levels", colors),
float_slider("Shadows", "shadows", "levels", shadows, 0.0, 255.0, 1.0, colors),
float_slider("Midtones", "midtones", "levels", midtones, 0.0, 255.0, 1.0, colors),
float_slider("Highlights", "highlights", "levels", highlights, 0.0, 255.0, 1.0, colors),
float_slider("Out Min", "output_min", "levels", out_min, 0.0, 255.0, 1.0, colors),
float_slider("Out Max", "output_max", "levels", out_max, 0.0, 255.0, 1.0, colors),
float_slider(
"Highlights",
"highlights",
"levels",
highlights,
0.0,
255.0,
1.0,
colors
),
float_slider(
"Out Min",
"output_min",
"levels",
out_min,
0.0,
255.0,
1.0,
colors
),
float_slider(
"Out Max",
"output_max",
"levels",
out_max,
0.0,
255.0,
1.0,
colors
),
]
.spacing(2)
.into()
@@ -275,7 +312,9 @@ pub fn view(
let vibrance = param_f64(params, "vibrance", 0.0);
column![
section_header("Vibrance", colors),
float_slider("Vibrance", "vibrance", "vibrance", vibrance, -100.0, 100.0, 1.0, colors),
float_slider(
"Vibrance", "vibrance", "vibrance", vibrance, -100.0, 100.0, 1.0, colors
),
]
.spacing(2)
.into()
@@ -288,7 +327,16 @@ pub fn view(
section_header("Exposure", colors),
float_slider("Exposure", "exposure", "exposure", exposure, -5.0, 5.0, 0.01, colors),
float_slider("Offset", "offset", "exposure", offset, -1.0, 1.0, 0.01, colors),
float_slider("Gamma", "gamma_correction", "exposure", gamma, 0.1, 5.0, 0.01, colors),
float_slider(
"Gamma",
"gamma_correction",
"exposure",
gamma,
0.1,
5.0,
0.01,
colors
),
]
.spacing(2)
.into()
@@ -307,8 +355,26 @@ pub fn view(
let max_lum = param_f64(params, "max_luminance", 100.0);
column![
section_header("Threshold", colors),
float_slider("Min Luminance", "min_luminance", "threshold", min_lum, 0.0, 100.0, 1.0, colors),
float_slider("Max Luminance", "max_luminance", "threshold", max_lum, 0.0, 100.0, 1.0, colors),
float_slider(
"Min Luminance",
"min_luminance",
"threshold",
min_lum,
0.0,
100.0,
1.0,
colors
),
float_slider(
"Max Luminance",
"max_luminance",
"threshold",
max_lum,
0.0,
100.0,
1.0,
colors
),
]
.spacing(2)
.into()
@@ -319,10 +385,7 @@ pub fn view(
let mono_g = param_f64(params, "monochrome_g", 40.0);
let mono_b = param_f64(params, "monochrome_b", 20.0);
let mono_c = param_f64(params, "monochrome_constant", 0.0);
let mut col = column![
section_header("Channel Mixer", colors),
]
.spacing(2);
let mut col = column![section_header("Channel Mixer", colors),].spacing(2);
// Monochrome toggle
let filter_id = "channel_mixer";
@@ -340,10 +403,46 @@ pub fn view(
}
if mono {
col = col.push(float_slider("Monochrome R", "monochrome_r", filter_id, mono_r, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Monochrome G", "monochrome_g", filter_id, mono_g, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Monochrome B", "monochrome_b", filter_id, mono_b, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Constant", "monochrome_constant", filter_id, mono_c, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider(
"Monochrome R",
"monochrome_r",
filter_id,
mono_r,
-200.0,
200.0,
1.0,
colors,
));
col = col.push(float_slider(
"Monochrome G",
"monochrome_g",
filter_id,
mono_g,
-200.0,
200.0,
1.0,
colors,
));
col = col.push(float_slider(
"Monochrome B",
"monochrome_b",
filter_id,
mono_b,
-200.0,
200.0,
1.0,
colors,
));
col = col.push(float_slider(
"Constant",
"monochrome_constant",
filter_id,
mono_c,
-200.0,
200.0,
1.0,
colors,
));
} else {
// RGB channels
let red_r = param_f64(params, "red_r", 100.0);
@@ -360,22 +459,88 @@ pub fn view(
let blue_c = param_f64(params, "blue_constant", 0.0);
col = col.push(section_header("Red Channel", colors));
col = col.push(float_slider("Red→R", "red_r", filter_id, red_r, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Red→G", "red_g", filter_id, red_g, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Red→B", "red_b", filter_id, red_b, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Red Const", "red_constant", filter_id, red_c, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider(
"Red→R", "red_r", filter_id, red_r, -200.0, 200.0, 1.0, colors,
));
col = col.push(float_slider(
"Red→G", "red_g", filter_id, red_g, -200.0, 200.0, 1.0, colors,
));
col = col.push(float_slider(
"Red→B", "red_b", filter_id, red_b, -200.0, 200.0, 1.0, colors,
));
col = col.push(float_slider(
"Red Const",
"red_constant",
filter_id,
red_c,
-200.0,
200.0,
1.0,
colors,
));
col = col.push(section_header("Green Channel", colors));
col = col.push(float_slider("Green→R", "green_r", filter_id, green_r, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Green→G", "green_g", filter_id, green_g, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Green→B", "green_b", filter_id, green_b, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Green Const", "green_constant", filter_id, green_c, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider(
"Green→R",
"green_r",
filter_id,
green_r,
-200.0,
200.0,
1.0,
colors,
));
col = col.push(float_slider(
"Green→G",
"green_g",
filter_id,
green_g,
-200.0,
200.0,
1.0,
colors,
));
col = col.push(float_slider(
"Green→B",
"green_b",
filter_id,
green_b,
-200.0,
200.0,
1.0,
colors,
));
col = col.push(float_slider(
"Green Const",
"green_constant",
filter_id,
green_c,
-200.0,
200.0,
1.0,
colors,
));
col = col.push(section_header("Blue Channel", colors));
col = col.push(float_slider("Blue→R", "blue_r", filter_id, blue_r, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Blue→G", "blue_g", filter_id, blue_g, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Blue→B", "blue_b", filter_id, blue_b, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider("Blue Const", "blue_constant", filter_id, blue_c, -200.0, 200.0, 1.0, colors));
col = col.push(float_slider(
"Blue→R", "blue_r", filter_id, blue_r, -200.0, 200.0, 1.0, colors,
));
col = col.push(float_slider(
"Blue→G", "blue_g", filter_id, blue_g, -200.0, 200.0, 1.0, colors,
));
col = col.push(float_slider(
"Blue→B", "blue_b", filter_id, blue_b, -200.0, 200.0, 1.0, colors,
));
col = col.push(float_slider(
"Blue Const",
"blue_constant",
filter_id,
blue_c,
-200.0,
200.0,
1.0,
colors,
));
}
col.into()
@@ -392,16 +557,97 @@ pub fn view(
let tint_b = param_f64(params, "tint_b", 255.0);
column![
section_header("Black & White", colors),
float_slider("Reds", "reds", "black_and_white", reds, 0.0, 100.0, 1.0, colors),
float_slider("Yellows", "yellows", "black_and_white", yellows, 0.0, 100.0, 1.0, colors),
float_slider("Greens", "greens", "black_and_white", greens, 0.0, 100.0, 1.0, colors),
float_slider("Cyans", "cyans", "black_and_white", cyans, 0.0, 100.0, 1.0, colors),
float_slider("Blues", "blues", "black_and_white", blues, 0.0, 100.0, 1.0, colors),
float_slider("Magentas", "magentas", "black_and_white", magentas, 0.0, 100.0, 1.0, colors),
float_slider(
"Reds",
"reds",
"black_and_white",
reds,
0.0,
100.0,
1.0,
colors
),
float_slider(
"Yellows",
"yellows",
"black_and_white",
yellows,
0.0,
100.0,
1.0,
colors
),
float_slider(
"Greens",
"greens",
"black_and_white",
greens,
0.0,
100.0,
1.0,
colors
),
float_slider(
"Cyans",
"cyans",
"black_and_white",
cyans,
0.0,
100.0,
1.0,
colors
),
float_slider(
"Blues",
"blues",
"black_and_white",
blues,
0.0,
100.0,
1.0,
colors
),
float_slider(
"Magentas",
"magentas",
"black_and_white",
magentas,
0.0,
100.0,
1.0,
colors
),
section_header("Tint Color", colors),
float_slider("Tint R", "tint_r", "black_and_white", tint_r, 0.0, 255.0, 1.0, colors),
float_slider("Tint G", "tint_g", "black_and_white", tint_g, 0.0, 255.0, 1.0, colors),
float_slider("Tint B", "tint_b", "black_and_white", tint_b, 0.0, 255.0, 1.0, colors),
float_slider(
"Tint R",
"tint_r",
"black_and_white",
tint_r,
0.0,
255.0,
1.0,
colors
),
float_slider(
"Tint G",
"tint_g",
"black_and_white",
tint_g,
0.0,
255.0,
1.0,
colors
),
float_slider(
"Tint B",
"tint_b",
"black_and_white",
tint_b,
0.0,
255.0,
1.0,
colors
),
]
.spacing(2)
.into()
@@ -412,10 +658,7 @@ pub fn view(
let mode = param_str(params, "mode", "relative");
let is_absolute = mode == "absolute";
let mut col = column![
section_header("Selective Color", colors),
]
.spacing(2);
let mut col = column![section_header("Selective Color", colors),].spacing(2);
// Mode toggle
{
@@ -441,7 +684,12 @@ pub fn view(
("Neutrals", "neutrals"),
("Blacks", "blacks"),
];
let cmyk = [("Cyan", "cyan"), ("Magenta", "magenta"), ("Yellow", "yellow"), ("Black", "black")];
let cmyk = [
("Cyan", "cyan"),
("Magenta", "magenta"),
("Yellow", "yellow"),
("Black", "black"),
];
for (range_name, range_key) in &ranges {
col = col.push(section_header(range_name, colors));
@@ -449,14 +697,7 @@ pub fn view(
let param_key = format!("{}_{}", range_key, cmyk_key);
let val = param_f64(params, &param_key, 0.0);
col = col.push(float_slider(
cmyk_name,
&param_key,
filter_id,
val,
-100.0,
100.0,
1.0,
colors,
cmyk_name, &param_key, filter_id, val, -100.0, 100.0, 1.0, colors,
));
}
}
@@ -468,7 +709,16 @@ pub fn view(
let inverse = param_bool(params, "inverse", false);
let mut col = column![
section_header("Gamma Correction", colors),
float_slider("Gamma", "gamma", "gamma_correction", gamma, 0.1, 5.0, 0.1, colors),
float_slider(
"Gamma",
"gamma",
"gamma_correction",
gamma,
0.1,
5.0,
0.1,
colors
),
]
.spacing(2);
@@ -489,21 +739,25 @@ pub fn view(
FilterType::ExtractChannel => {
let channel = param_str(params, "channel", "red");
let channels = ["red", "green", "blue", "alpha"];
let mut col = column![
section_header("Extract Channel", colors),
]
.spacing(2);
let mut col = column![section_header("Extract Channel", colors),].spacing(2);
for ch in &channels {
let is_active = channel == *ch;
let filter_id = "extract_channel";
let ch_label = text(ch.to_string()).size(11).width(Length::Fixed(80.0));
let btn = iced::widget::button(text(if is_active { format!(">> {}", ch) } else { ch.to_string() }).size(11))
.on_press(Message::FilterParamChanged(
filter_id.to_string(),
json!({ "channel": ch }),
))
.padding([2, 8]);
let btn = iced::widget::button(
text(if is_active {
format!(">> {}", ch)
} else {
ch.to_string()
})
.size(11),
)
.on_press(Message::FilterParamChanged(
filter_id.to_string(),
json!({ "channel": ch }),
))
.padding([2, 8]);
col = col.push(row![ch_label, btn].spacing(4));
}
@@ -524,9 +778,7 @@ pub fn view(
.padding([2, 8]);
row![lbl, toggle_btn].spacing(4)
},
{
text("Gradient stops configured via color picker").size(10)
},
{ text("Gradient stops configured via color picker").size(10) },
]
.spacing(2)
.into()
@@ -585,7 +837,12 @@ pub fn view(
for nt in &noise_types {
let is_active = noise_type == *nt;
let btn = iced::widget::button(
text(if is_active { format!(">> {}", nt) } else { nt.to_string() }).size(11),
text(if is_active {
format!(">> {}", nt)
} else {
nt.to_string()
})
.size(11),
)
.on_press(Message::FilterParamChanged(
filter_id.to_string(),
@@ -603,7 +860,12 @@ pub fn view(
for ft in &fractal_types {
let is_active = fractal_type == *ft;
let btn = iced::widget::button(
text(if is_active { format!(">> {}", ft) } else { ft.to_string() }).size(11),
text(if is_active {
format!(">> {}", ft)
} else {
ft.to_string()
})
.size(11),
)
.on_press(Message::FilterParamChanged(
filter_id.to_string(),
@@ -615,9 +877,35 @@ pub fn view(
}
if fractal_type != "none" {
col = col.push(int_slider("Octaves", "fractal_octaves", filter_id, fractal_octaves, 1, 10, colors));
col = col.push(float_slider("Lacunarity", "fractal_lacunarity", filter_id, fractal_lacunarity, 1.0, 4.0, 0.1, colors));
col = col.push(float_slider("Gain", "fractal_gain", filter_id, fractal_gain, 0.0, 2.0, 0.01, colors));
col = col.push(int_slider(
"Octaves",
"fractal_octaves",
filter_id,
fractal_octaves,
1,
10,
colors,
));
col = col.push(float_slider(
"Lacunarity",
"fractal_lacunarity",
filter_id,
fractal_lacunarity,
1.0,
4.0,
0.1,
colors,
));
col = col.push(float_slider(
"Gain",
"fractal_gain",
filter_id,
fractal_gain,
0.0,
2.0,
0.01,
colors,
));
}
// Domain warp selector
@@ -627,7 +915,12 @@ pub fn view(
for wt in &warp_types {
let is_active = domain_warp_type == *wt;
let btn = iced::widget::button(
text(if is_active { format!(">> {}", wt) } else { wt.to_string() }).size(11),
text(if is_active {
format!(">> {}", wt)
} else {
wt.to_string()
})
.size(11),
)
.on_press(Message::FilterParamChanged(
filter_id.to_string(),
@@ -639,7 +932,16 @@ pub fn view(
}
if domain_warp_type != "none" {
col = col.push(float_slider("Warp Amp", "domain_warp_amplitude", filter_id, domain_warp_amp, 0.0, 500.0, 1.0, colors));
col = col.push(float_slider(
"Warp Amp",
"domain_warp_amplitude",
filter_id,
domain_warp_amp,
0.0,
500.0,
1.0,
colors,
));
}
// Cellular settings (shown when noise_type is "cellular")
@@ -650,7 +952,12 @@ pub fn view(
for df in &dist_funcs {
let is_active = cellular_distance == *df;
let btn = iced::widget::button(
text(if is_active { format!(">> {}", df) } else { df.to_string() }).size(11),
text(if is_active {
format!(">> {}", df)
} else {
df.to_string()
})
.size(11),
)
.on_press(Message::FilterParamChanged(
filter_id.to_string(),
@@ -665,7 +972,12 @@ pub fn view(
for rt in &ret_types {
let is_active = cellular_return == *rt;
let btn = iced::widget::button(
text(if is_active { format!(">> {}", rt) } else { rt.to_string() }).size(11),
text(if is_active {
format!(">> {}", rt)
} else {
rt.to_string()
})
.size(11),
)
.on_press(Message::FilterParamChanged(
filter_id.to_string(),
@@ -674,15 +986,21 @@ pub fn view(
.padding([2, 8]);
col = col.push(btn);
}
col = col.push(float_slider("Jitter", "cellular_jitter", filter_id, cellular_jitter, 0.0, 1.0, 0.01, colors));
col = col.push(float_slider(
"Jitter",
"cellular_jitter",
filter_id,
cellular_jitter,
0.0,
1.0,
0.01,
colors,
));
}
col.into()
}
};
container(content)
.width(Length::Fill)
.padding(4)
.into()
container(content).width(Length::Fill).padding(4).into()
}
@@ -23,65 +23,134 @@ const FILTER_CATEGORIES: &[FilterCategory] = &[
FilterCategory {
name: "Blur",
filters: &[
FilterEntry { name: "Box Blur", filter_type: FilterType::BoxBlur },
FilterEntry { name: "Gaussian Blur", filter_type: FilterType::GaussianBlur },
FilterEntry { name: "Motion Blur", filter_type: FilterType::MotionBlur },
FilterEntry { name: "Median Filter", filter_type: FilterType::MedianFilter },
FilterEntry {
name: "Box Blur",
filter_type: FilterType::BoxBlur,
},
FilterEntry {
name: "Gaussian Blur",
filter_type: FilterType::GaussianBlur,
},
FilterEntry {
name: "Motion Blur",
filter_type: FilterType::MotionBlur,
},
FilterEntry {
name: "Median Filter",
filter_type: FilterType::MedianFilter,
},
FilterEntry {
name: "Gamma-aware Box Blur",
filter_type: FilterType::BoxBlurGamma,
},
FilterEntry {
name: "Gamma-aware Gaussian Blur",
filter_type: FilterType::GaussianBlurGamma,
},
],
},
FilterCategory {
name: "Sharpen",
filters: &[
FilterEntry { name: "Unsharp Mask", filter_type: FilterType::UnsharpMask },
],
filters: &[FilterEntry {
name: "Unsharp Mask",
filter_type: FilterType::UnsharpMask,
}],
},
FilterCategory {
name: "Pixelate",
filters: &[
FilterEntry { name: "Mosaic", filter_type: FilterType::Mosaic },
FilterEntry { name: "Crystallize", filter_type: FilterType::Crystallize },
FilterEntry {
name: "Mosaic",
filter_type: FilterType::Mosaic,
},
FilterEntry {
name: "Crystallize",
filter_type: FilterType::Crystallize,
},
],
},
FilterCategory {
name: "Distort",
filters: &[
FilterEntry { name: "Pinch", filter_type: FilterType::Pinch },
FilterEntry { name: "Twirl", filter_type: FilterType::Twirl },
FilterEntry {
name: "Pinch",
filter_type: FilterType::Pinch,
},
FilterEntry {
name: "Twirl",
filter_type: FilterType::Twirl,
},
],
},
FilterCategory {
name: "Stylize",
filters: &[
FilterEntry { name: "Oil Paint", filter_type: FilterType::OilPaint },
],
filters: &[FilterEntry {
name: "Oil Paint",
filter_type: FilterType::OilPaint,
}],
},
FilterCategory {
name: "Color & Light",
filters: &[
FilterEntry { name: "Levels", filter_type: FilterType::Levels },
FilterEntry { name: "Vibrance", filter_type: FilterType::Vibrance },
FilterEntry { name: "Exposure", filter_type: FilterType::Exposure },
FilterEntry { name: "Posterize", filter_type: FilterType::Posterize },
FilterEntry { name: "Threshold", filter_type: FilterType::Threshold },
FilterEntry { name: "Channel Mixer", filter_type: FilterType::ChannelMixer },
FilterEntry { name: "Black & White", filter_type: FilterType::BlackAndWhite },
FilterEntry { name: "Selective Color", filter_type: FilterType::SelectiveColor },
FilterEntry { name: "Gamma Correction", filter_type: FilterType::GammaCorrection },
FilterEntry { name: "Extract Channel", filter_type: FilterType::ExtractChannel },
FilterEntry { name: "Gradient Map", filter_type: FilterType::GradientMap },
FilterEntry {
name: "Levels",
filter_type: FilterType::Levels,
},
FilterEntry {
name: "Vibrance",
filter_type: FilterType::Vibrance,
},
FilterEntry {
name: "Exposure",
filter_type: FilterType::Exposure,
},
FilterEntry {
name: "Posterize",
filter_type: FilterType::Posterize,
},
FilterEntry {
name: "Threshold",
filter_type: FilterType::Threshold,
},
FilterEntry {
name: "Channel Mixer",
filter_type: FilterType::ChannelMixer,
},
FilterEntry {
name: "Black & White",
filter_type: FilterType::BlackAndWhite,
},
FilterEntry {
name: "Selective Color",
filter_type: FilterType::SelectiveColor,
},
FilterEntry {
name: "Gamma Correction",
filter_type: FilterType::GammaCorrection,
},
FilterEntry {
name: "Extract Channel",
filter_type: FilterType::ExtractChannel,
},
FilterEntry {
name: "Gradient Map",
filter_type: FilterType::GradientMap,
},
],
},
FilterCategory {
name: "Restore",
filters: &[
FilterEntry { name: "Dehaze", filter_type: FilterType::Dehaze },
],
filters: &[FilterEntry {
name: "Dehaze",
filter_type: FilterType::Dehaze,
}],
},
FilterCategory {
name: "Noise & Pattern",
filters: &[
FilterEntry { name: "Noise Pattern", filter_type: FilterType::NoisePattern },
],
filters: &[FilterEntry {
name: "Noise Pattern",
filter_type: FilterType::NoisePattern,
}],
},
];
@@ -94,14 +163,13 @@ pub fn view(
selected_filter: Option<FilterType>,
filter_params: &serde_json::Value,
filter_preview_active: bool,
layer_editable: bool,
colors: ThemeColors,
) -> Element<'static, Message> {
let mut filter_list = column![].spacing(2);
for category in FILTER_CATEGORIES {
let cat_name = text(category.name)
.size(12)
;
let cat_name = text(category.name).size(12);
let mut cat_filters = column![].spacing(1).padding(iced::Padding::ZERO.left(12));
@@ -128,8 +196,8 @@ pub fn view(
.style(move |_theme| item_style.clone());
let filter_type = entry.filter_type;
let clickable = iced::widget::mouse_area(item)
.on_press(Message::FilterSelect(filter_type));
let clickable =
iced::widget::mouse_area(item).on_press(Message::FilterSelect(filter_type));
cat_filters = cat_filters.push(clickable);
}
@@ -138,9 +206,13 @@ pub fn view(
filter_list = filter_list.push(cat_filters);
}
let apply_btn = button(text("Apply").size(11))
.on_press(Message::FilterApply)
.padding([6, 12]);
let apply_btn = if layer_editable {
button(text("Apply").size(11))
.on_press(Message::FilterApply)
.padding([6, 12])
} else {
button(text("Apply (layer locked/type unsupported)").size(10)).padding([6, 8])
};
let reset_btn = button(text("Reset").size(11))
.on_press(Message::FilterReset)
@@ -150,14 +222,17 @@ pub fn view(
.on_toggle(Message::FilterPreviewToggle)
.size(11);
let buttons = row![preview_check, apply_btn, reset_btn].spacing(8);
let buttons = column![preview_check, row![apply_btn, reset_btn].spacing(6)].spacing(3);
// When a filter is selected, show its parameter panel below the list.
let param_section = match selected_filter {
Some(filter) => {
let params_panel = super::filter_params::view(filter, filter_params, colors);
column![horizontal_rule(1), scrollable(params_panel).height(Length::Fill)]
.spacing(0)
column![
horizontal_rule(1),
scrollable(params_panel).height(Length::Fill)
]
.spacing(0)
}
None => column![],
};
@@ -6,11 +6,13 @@
use crate::app::Message;
use crate::color_picker;
use crate::color_picker::ColorPickerTarget;
use crate::panels::styles;
use crate::panels::typography::{BODY, SECTION};
use crate::theme::ThemeColors;
use hcie_engine_api::{LineCap, VectorBooleanOp, VectorShape};
use iced::widget::{
button, checkbox, column, container, row, scrollable, slider, text, Space,
button, checkbox, column, container, pick_list, row, scrollable, slider, text, Space,
};
use iced::{Element, Length};
@@ -38,18 +40,15 @@ fn shape_label(shapes: &[VectorShape], i: usize) -> String {
/// Build a section header bar with the given label.
fn section_header(label: String, colors: ThemeColors) -> Element<'static, Message> {
container(
row![text(label).size(11)]
.spacing(4),
)
.width(Length::Fill)
.padding([3, 6])
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_hover)),
border: iced::Border::default().color(colors.border_high).width(1),
..Default::default()
})
.into()
container(row![text(label).size(SECTION)].spacing(4))
.width(Length::Fill)
.padding([3, 6])
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_hover)),
border: iced::Border::default().color(colors.border_high).width(1),
..Default::default()
})
.into()
}
/// Build a labeled row with a slider.
@@ -60,9 +59,11 @@ fn labeled_slider<'a>(
on_change: fn(f32) -> Message,
) -> Element<'a, Message> {
row![
text(label).size(10).width(30),
slider(range, value, on_change).step(0.5).width(Length::Fill),
text(format!("{:.0}", value)).size(9).width(32),
text(label).size(BODY).width(42),
slider(range, value, on_change)
.step(0.5)
.width(Length::Fill),
text(format!("{:.0}", value)).size(BODY).width(38),
]
.spacing(4)
.align_y(iced::Alignment::Center)
@@ -94,7 +95,7 @@ pub fn view(
container(
row![
Space::with_width(Length::Fill),
button(text("X").size(10))
button(text("X").size(BODY))
.on_press(Message::VectorShapeDelete)
.padding([2, 6]),
]
@@ -111,12 +112,16 @@ pub fn view(
let is_selected = selected_shape == Some(i);
let c = colors;
let item = button(text(label).size(11))
.on_press(Message::VectorShapeSelect(if is_selected { None } else { Some(i) }))
let item = button(text(label).size(BODY))
.on_press(Message::VectorShapeSelect(if is_selected {
None
} else {
Some(i)
}))
.width(Length::Fill)
.padding([4, 6])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
match status {
.style(
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
iced::widget::button::Status::Active => {
if is_selected {
iced::widget::button::Style {
@@ -132,32 +137,36 @@ pub fn view(
}
}
}
iced::widget::button::Status::Hovered => {
iced::widget::button::Style {
background: Some(iced::Background::Color(if is_selected { c.accent } else { c.bg_active })),
text_color: iced::Color::WHITE,
..Default::default()
}
}
iced::widget::button::Status::Hovered => iced::widget::button::Style {
background: Some(iced::Background::Color(if is_selected {
c.accent
} else {
c.bg_active
})),
text_color: iced::Color::WHITE,
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(c.bg_hover)),
text_color: c.text_primary,
..Default::default()
},
}
});
},
);
shape_list = shape_list.push(item);
}
content = content.push(
scrollable(shape_list).height(Length::Fill).width(Length::Fill),
scrollable(shape_list)
.height(Length::Fixed(132.0))
.width(Length::Fill),
);
} else {
content = content.push(
column![
text("No vector shapes").size(11),
text("Use vector tools to create shapes").size(10),
text("No vector shapes").size(BODY),
text("Use vector tools to create shapes").size(BODY),
]
.spacing(2),
);
@@ -169,68 +178,52 @@ pub fn view(
content = content.push(section_header("PATH BOOLEAN".to_string(), colors));
content = content.push(Space::with_height(4));
// Shape A selector
let a_label = match bool_shape_a {
Some(i) if i < shape_count => shape_label(&shapes, i),
_ => "Shape A".to_string(),
};
let mut a_menu = column![].spacing(1);
for i in 0..shape_count {
let lbl = shape_label(&shapes, i);
let idx = i;
a_menu = a_menu.push(
button(text(lbl).size(10))
.on_press(Message::GeometryBoolShapeA(Some(idx)))
.width(Length::Fill)
.padding([3, 6]),
);
}
// Shape B selector
let b_label = match bool_shape_b {
Some(i) if i < shape_count => shape_label(&shapes, i),
_ => "Shape B".to_string(),
};
let mut b_menu = column![].spacing(1);
for i in 0..shape_count {
let lbl = shape_label(&shapes, i);
let idx = i;
b_menu = b_menu.push(
button(text(lbl).size(10))
.on_press(Message::GeometryBoolShapeB(Some(idx)))
.width(Length::Fill)
.padding([3, 6]),
);
}
let labels: Vec<String> = (0..shape_count).map(|i| shape_label(&shapes, i)).collect();
let a_selected = bool_shape_a.and_then(|i| labels.get(i).cloned());
let b_selected = bool_shape_b.and_then(|i| labels.get(i).cloned());
let a_labels = labels.clone();
let b_labels = labels.clone();
// Show selectors inline
content = content.push(
row![
container(
column![
button(text(a_label).size(10))
.width(Length::Fill)
.padding([4, 6]),
a_menu,
]
.spacing(2)
).width(Length::Fill).style(move |_theme| iced::widget::container::Style {
pick_list(labels.clone(), a_selected, move |selected| {
Message::GeometryBoolShapeA(
a_labels.iter().position(|label| *label == selected),
)
})
.placeholder("Shape A")
.text_size(BODY)
.width(Length::Fill)
)
.width(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_active)),
border: iced::Border::default().color(colors.border_high).width(1).rounded(2),
border: iced::Border::default()
.color(colors.border_high)
.width(1)
.rounded(2),
..Default::default()
}),
text(" + ").size(10),
text(" + ").size(BODY),
container(
column![
button(text(b_label).size(10))
.width(Length::Fill)
.padding([4, 6]),
b_menu,
]
.spacing(2)
).width(Length::Fill).style(move |_theme| iced::widget::container::Style {
pick_list(labels, b_selected, move |selected| {
Message::GeometryBoolShapeB(
b_labels.iter().position(|label| *label == selected),
)
})
.placeholder("Shape B")
.text_size(BODY)
.width(Length::Fill)
)
.width(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_active)),
border: iced::Border::default().color(colors.border_high).width(1).rounded(2),
border: iced::Border::default()
.color(colors.border_high)
.width(1)
.rounded(2),
..Default::default()
}),
]
@@ -242,10 +235,18 @@ pub fn view(
content = content.push(Space::with_height(4));
content = content.push(
row![
button(text("Union").size(10)).on_press(Message::GeometryBooleanOp(VectorBooleanOp::Union)).padding([3, 8]),
button(text("Intersect").size(10)).on_press(Message::GeometryBooleanOp(VectorBooleanOp::Intersect)).padding([3, 8]),
button(text("Subtract").size(10)).on_press(Message::GeometryBooleanOp(VectorBooleanOp::Subtract)).padding([3, 8]),
button(text("Xor").size(10)).on_press(Message::GeometryBooleanOp(VectorBooleanOp::Xor)).padding([3, 8]),
button(text("Union").size(BODY))
.on_press(Message::GeometryBooleanOp(VectorBooleanOp::Union))
.padding([3, 8]),
button(text("Intersect").size(BODY))
.on_press(Message::GeometryBooleanOp(VectorBooleanOp::Intersect))
.padding([3, 8]),
button(text("Subtract").size(BODY))
.on_press(Message::GeometryBooleanOp(VectorBooleanOp::Subtract))
.padding([3, 8]),
button(text("Xor").size(BODY))
.on_press(Message::GeometryBooleanOp(VectorBooleanOp::Xor))
.padding([3, 8]),
]
.spacing(4)
.width(Length::Fill),
@@ -264,20 +265,38 @@ pub fn view(
let h = (y2 - y1).abs();
// Bounds sliders
content = content.push(labeled_slider("X1", x1, -2000.0..=5000.0, Message::GeometryBoundsX1));
content = content.push(labeled_slider("Y1", y1, -2000.0..=5000.0, Message::GeometryBoundsY1));
content = content.push(labeled_slider("X2", x2, -2000.0..=5000.0, Message::GeometryBoundsX2));
content = content.push(labeled_slider("Y2", y2, -2000.0..=5000.0, Message::GeometryBoundsY2));
content = content.push(labeled_slider(
"X1",
x1,
-2000.0..=5000.0,
Message::GeometryBoundsX1,
));
content = content.push(labeled_slider(
"Y1",
y1,
-2000.0..=5000.0,
Message::GeometryBoundsY1,
));
content = content.push(labeled_slider(
"X2",
x2,
-2000.0..=5000.0,
Message::GeometryBoundsX2,
));
content = content.push(labeled_slider(
"Y2",
y2,
-2000.0..=5000.0,
Message::GeometryBoundsY2,
));
// Size display
content = content.push(
text(format!("Size: {:.0} x {:.0}", w, h))
.size(10)
.style(move |_theme| iced::widget::text::Style {
color: Some(colors.text_secondary),
..Default::default()
}),
);
content = content.push(text(format!("Size: {:.0} x {:.0}", w, h)).size(BODY).style(
move |_theme| iced::widget::text::Style {
color: Some(colors.text_secondary),
..Default::default()
},
));
// Fill controls (not for Line shapes)
let is_line = matches!(&shapes[idx], VectorShape::Line { .. });
@@ -288,7 +307,7 @@ pub fn view(
content = content.push(
row![
checkbox("", cur_fill).on_toggle(Message::GeometryFillToggled),
text("Fill").size(10),
text("Fill").size(BODY),
]
.spacing(4)
.align_y(iced::Alignment::Center),
@@ -297,10 +316,10 @@ pub fn view(
if cur_fill {
// Fill color: HSV color picker
content = content.push(Space::with_height(2));
content = content.push(color_picker::color_popup(
content = content.push(color_picker::color_swatch(
fc,
"Fill",
Message::GeometryFillColorChanged,
ColorPickerTarget::GeometryFill,
));
}
}
@@ -309,25 +328,37 @@ pub fn view(
// Stroke color
let stroke_color = shapes[idx].color();
content = content.push(Space::with_height(4));
content = content.push(color_picker::color_popup(
content = content.push(color_picker::color_swatch(
stroke_color,
"Stroke",
Message::GeometryStrokeColorChanged,
ColorPickerTarget::GeometryStroke,
));
// Opacity slider
let cur_opacity = shapes[idx].shape_opacity();
content = content.push(Space::with_height(4));
content = content.push(labeled_slider("Opacity", cur_opacity, 0.0..=1.0, Message::GeometryOpacityChanged));
content = content.push(labeled_slider(
"Opacity",
cur_opacity,
0.0..=1.0,
Message::GeometryOpacityChanged,
));
// Hardness slider
let cur_hardness = shapes[idx].hardness();
content = content.push(labeled_slider("Hardness", cur_hardness, 0.0..=1.0, Message::GeometryHardnessChanged));
content = content.push(labeled_slider(
"Hardness",
cur_hardness,
0.0..=1.0,
Message::GeometryHardnessChanged,
));
// Line caps (for Line shapes only)
if is_line {
let (cs, ce) = match &shapes[idx] {
VectorShape::Line { cap_start, cap_end, .. } => (*cap_start, *cap_end),
VectorShape::Line {
cap_start, cap_end, ..
} => (*cap_start, *cap_end),
_ => (LineCap::Round, LineCap::Round),
};
@@ -335,71 +366,59 @@ pub fn view(
content = content.push(section_header("LINE CAPS".to_string(), colors));
content = content.push(Space::with_height(2));
// Start cap combo
let cap_labels: Vec<String> = LineCap::ALL
.iter()
.map(|cap| cap.label().to_string())
.collect();
let start_labels = cap_labels.clone();
let end_labels = cap_labels.clone();
// Start cap selector
content = content.push(
row![
text("Start").size(10).width(40),
button(text(cs.label()).size(10)).padding([3, 6]).width(Length::Fill),
text("Start").size(BODY).width(48),
pick_list(
cap_labels.clone(),
Some(cs.label().to_string()),
move |selected| {
let index = start_labels
.iter()
.position(|label| *label == selected)
.unwrap_or(0);
Message::GeometryLineCapStart(LineCap::ALL[index])
}
)
.text_size(BODY)
.width(Length::Fill),
]
.spacing(4)
.align_y(iced::Alignment::Center),
);
// Show cap options when clicked (inline list)
let mut cap_start_list = column![].spacing(1);
for &cap in LineCap::ALL {
let cap_val = cap;
let is_active = cs == cap;
cap_start_list = cap_start_list.push(
button(
row![
if is_active { text(">").size(10) } else { text(" ").size(10) },
text(cap.label()).size(10),
]
.spacing(4)
)
.on_press(Message::GeometryLineCapStart(cap_val))
.width(Length::Fill)
.padding([2, 6]),
);
}
content = content.push(cap_start_list);
content = content.push(Space::with_height(2));
// End cap combo
// End cap selector
content = content.push(
row![
text("End").size(10).width(40),
button(text(ce.label()).size(10)).padding([3, 6]).width(Length::Fill),
text("End").size(BODY).width(48),
pick_list(cap_labels, Some(ce.label().to_string()), move |selected| {
let index = end_labels
.iter()
.position(|label| *label == selected)
.unwrap_or(0);
Message::GeometryLineCapEnd(LineCap::ALL[index])
})
.text_size(BODY)
.width(Length::Fill),
]
.spacing(4)
.align_y(iced::Alignment::Center),
);
let mut cap_end_list = column![].spacing(1);
for &cap in LineCap::ALL {
let cap_val = cap;
let is_active = ce == cap;
cap_end_list = cap_end_list.push(
button(
row![
if is_active { text(">").size(10) } else { text(" ").size(10) },
text(cap.label()).size(10),
]
.spacing(4)
)
.on_press(Message::GeometryLineCapEnd(cap_val))
.width(Length::Fill)
.padding([2, 6]),
);
}
content = content.push(cap_end_list);
}
}
}
container(content.spacing(0))
container(scrollable(content.spacing(0)))
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| styles::panel_background(colors))
@@ -11,15 +11,18 @@ use iced::widget::{column, container, horizontal_rule, scrollable, text};
use iced::{Element, Length};
/// Build the history panel element.
pub fn view<'a>(descriptions: &[(usize, String)], current_idx: i32, colors: ThemeColors) -> Element<'a, Message> {
pub fn view<'a>(
descriptions: &[(usize, String)],
current_idx: i32,
colors: ThemeColors,
) -> Element<'a, Message> {
let mut history_list = column![].spacing(1);
for (idx, desc) in descriptions {
let is_current = *idx as i32 == current_idx;
let is_future = *idx as i32 > current_idx;
let label = text(format!("{}: {}", idx, desc))
.size(11);
let label = text(format!("{}: {}", idx, desc)).size(11);
let label = if is_current {
label.style(move |_theme: &iced::Theme| iced::widget::text::Style {
@@ -44,18 +47,16 @@ pub fn view<'a>(descriptions: &[(usize, String)], current_idx: i32, colors: Them
.padding([3, 6])
.style(move |_theme| item_style.clone());
let clickable = iced::widget::mouse_area(item)
.on_press(Message::HistoryJumpTo(*idx as i32));
let clickable =
iced::widget::mouse_area(item).on_press(Message::HistoryJumpTo(*idx as i32));
history_list = history_list.push(clickable);
}
// Panel title is in the dock tab — no duplicate inside the panel.
let panel = column![
scrollable(history_list).height(Length::Fill),
]
.spacing(0)
.padding(4);
let panel = column![scrollable(history_list).height(Length::Fill),]
.spacing(0)
.padding(4);
container(panel)
.width(Length::Fill)
@@ -12,7 +12,11 @@ use iced::widget::{column, container, horizontal_rule, row, scrollable, text};
use iced::{Element, Length};
/// Build the layer details panel.
pub fn view<'a>(layers: &'a [LayerInfo], active_layer_id: u64, colors: ThemeColors) -> Element<'a, Message> {
pub fn view<'a>(
layers: &'a [LayerInfo],
active_layer_id: u64,
colors: ThemeColors,
) -> Element<'a, Message> {
let active_layer = layers.iter().find(|l| l.id == active_layer_id);
let content: Element<'a, Message> = match active_layer {
@@ -24,20 +28,59 @@ pub fn view<'a>(layers: &'a [LayerInfo], active_layer_id: u64, colors: ThemeColo
row![text("Name:").size(10), text(&layer.name).size(11)].spacing(4),
row![text("ID:").size(10), text(format!("{}", layer.id)).size(10)].spacing(4),
row![text("Type:").size(10), text(layer_type).size(10)].spacing(4),
row![text("Size:").size(10), text(format!("{}x{}", layer.width, layer.height)).size(10)].spacing(4),
row![
text("Size:").size(10),
text(format!("{}x{}", layer.width, layer.height)).size(10)
]
.spacing(4),
horizontal_rule(1),
row![text("Blend:").size(10), text(blend_mode).size(10)].spacing(4),
row![text("Opacity:").size(10), text(format!("{:.0}%", layer.opacity * 100.0)).size(10)].spacing(4),
row![text("Visible:").size(10), text(if layer.visible { "Yes" } else { "No" }).size(10)].spacing(4),
row![text("Locked:").size(10), text(if layer.locked { "Yes" } else { "No" }).size(10)].spacing(4),
row![text("Clip Mask:").size(10), text(if layer.clipping_mask { "Yes" } else { "No" }).size(10)].spacing(4),
row![text("Parent:").size(10), text(format!("{:?}", layer.parent_id)).size(10)].spacing(4),
row![
text("Opacity:").size(10),
text(format!("{:.0}%", layer.opacity * 100.0)).size(10)
]
.spacing(4),
row![
text("Visible:").size(10),
text(if layer.visible { "Yes" } else { "No" }).size(10)
]
.spacing(4),
row![
text("Locked:").size(10),
text(if layer.locked { "Yes" } else { "No" }).size(10)
]
.spacing(4),
row![
text("Clip Mask:").size(10),
text(if layer.clipping_mask { "Yes" } else { "No" }).size(10)
]
.spacing(4),
row![
text("Parent:").size(10),
text(format!("{:?}", layer.parent_id)).size(10)
]
.spacing(4),
horizontal_rule(1),
row![text("Effects:").size(10), text(if layer.has_effects { "Yes" } else { "No" }).size(10)].spacing(4),
row![text("Vector:").size(10), text(if layer.has_vector_shapes {
format!("{} shapes", layer.shape_count)
} else { "None".to_string() }).size(10)].spacing(4),
row![text("Collapsed:").size(10), text(if layer.collapsed { "Yes" } else { "No" }).size(10)].spacing(4),
row![
text("Effects:").size(10),
text(if layer.has_effects { "Yes" } else { "No" }).size(10)
]
.spacing(4),
row![
text("Vector:").size(10),
text(if layer.has_vector_shapes {
format!("{} shapes", layer.shape_count)
} else {
"None".to_string()
})
.size(10)
]
.spacing(4),
row![
text("Collapsed:").size(10),
text(if layer.collapsed { "Yes" } else { "No" }).size(10)
]
.spacing(4),
]
.spacing(3)
.into()
@@ -46,11 +89,9 @@ pub fn view<'a>(layers: &'a [LayerInfo], active_layer_id: u64, colors: ThemeColo
};
// Panel title is in the dock tab — no duplicate inside the panel.
let panel = column![
scrollable(content).height(Length::Fill),
]
.spacing(0)
.padding(4);
let panel = column![scrollable(content).height(Length::Fill),]
.spacing(0)
.padding(4);
container(panel)
.width(Length::Fill)
File diff suppressed because it is too large Load Diff
@@ -10,9 +10,12 @@
use crate::app::Message;
use crate::panels::styles;
use crate::panels::thumbnails;
use crate::panels::typography::BODY;
use crate::theme::ThemeColors;
use hcie_engine_api::{BlendMode, LayerInfo, LayerType};
use iced::widget::{button, column, container, horizontal_rule, pick_list, row, scrollable, slider, text, Space};
use iced::widget::{
button, column, container, horizontal_rule, pick_list, row, scrollable, slider, text, Space,
};
use iced::{Element, Length};
/// Wrapper for BlendMode to provide Display for pick_list.
@@ -26,29 +29,45 @@ impl std::fmt::Display for BlendModeItem {
}
impl From<BlendMode> for BlendModeItem {
fn from(m: BlendMode) -> Self { Self(m) }
fn from(m: BlendMode) -> Self {
Self(m)
}
}
impl From<BlendModeItem> for BlendMode {
fn from(m: BlendModeItem) -> Self { m.0 }
fn from(m: BlendModeItem) -> Self {
m.0
}
}
const BLEND_MODE_ITEMS: &[BlendModeItem] = &[
BlendModeItem(BlendMode::Normal), BlendModeItem(BlendMode::Dissolve),
BlendModeItem(BlendMode::Darken), BlendModeItem(BlendMode::Multiply),
BlendModeItem(BlendMode::ColorBurn), BlendModeItem(BlendMode::LinearBurn),
BlendModeItem(BlendMode::Normal),
BlendModeItem(BlendMode::Dissolve),
BlendModeItem(BlendMode::Darken),
BlendModeItem(BlendMode::Multiply),
BlendModeItem(BlendMode::ColorBurn),
BlendModeItem(BlendMode::LinearBurn),
BlendModeItem(BlendMode::DarkerColor),
BlendModeItem(BlendMode::Lighten), BlendModeItem(BlendMode::Screen),
BlendModeItem(BlendMode::ColorDodge), BlendModeItem(BlendMode::LinearDodge),
BlendModeItem(BlendMode::Lighten),
BlendModeItem(BlendMode::Screen),
BlendModeItem(BlendMode::ColorDodge),
BlendModeItem(BlendMode::LinearDodge),
BlendModeItem(BlendMode::LighterColor),
BlendModeItem(BlendMode::Overlay), BlendModeItem(BlendMode::SoftLight),
BlendModeItem(BlendMode::HardLight), BlendModeItem(BlendMode::VividLight),
BlendModeItem(BlendMode::LinearLight), BlendModeItem(BlendMode::PinLight),
BlendModeItem(BlendMode::Overlay),
BlendModeItem(BlendMode::SoftLight),
BlendModeItem(BlendMode::HardLight),
BlendModeItem(BlendMode::VividLight),
BlendModeItem(BlendMode::LinearLight),
BlendModeItem(BlendMode::PinLight),
BlendModeItem(BlendMode::HardMix),
BlendModeItem(BlendMode::Difference), BlendModeItem(BlendMode::Exclusion),
BlendModeItem(BlendMode::Subtract), BlendModeItem(BlendMode::Divide),
BlendModeItem(BlendMode::Hue), BlendModeItem(BlendMode::Saturation),
BlendModeItem(BlendMode::Color), BlendModeItem(BlendMode::Luminosity),
BlendModeItem(BlendMode::Difference),
BlendModeItem(BlendMode::Exclusion),
BlendModeItem(BlendMode::Subtract),
BlendModeItem(BlendMode::Divide),
BlendModeItem(BlendMode::Hue),
BlendModeItem(BlendMode::Saturation),
BlendModeItem(BlendMode::Color),
BlendModeItem(BlendMode::Luminosity),
BlendModeItem(BlendMode::PassThrough),
];
@@ -77,7 +96,11 @@ fn build_tree<'a>(layers: &'a [LayerInfo]) -> Vec<LayerEntry<'a>> {
if let Some(children) = children_of.get(&parent_id) {
for info in children {
let is_group = info.layer_type == LayerType::Group;
result.push(LayerEntry { info, depth, is_group });
result.push(LayerEntry {
info,
depth,
is_group,
});
if is_group && !info.collapsed {
walk(Some(info.id), depth + 1, children_of, result);
}
@@ -114,7 +137,8 @@ fn layer_thumbnail<'a>(
}
let info = entry.info;
let thumb = engine.get_layer_pixels(info.id)
let thumb = engine
.get_layer_pixels(info.id)
.filter(|px| !px.is_empty() && info.width > 0 && info.height > 0)
.map(|px| thumbnails::generate_thumbnail(&px, info.width, info.height, 28));
@@ -141,17 +165,19 @@ fn layer_thumbnail<'a>(
_ => ("\u{25A3}", colors.text_secondary),
};
container(
text(icon_char).size(10).style(move |_theme: &iced::Theme| iced::widget::text::Style {
container(text(icon_char).size(10).style(move |_theme: &iced::Theme| {
iced::widget::text::Style {
color: Some(icon_color),
})
)
}
}))
.width(30)
.height(22)
.center_x(Length::Fixed(30.0))
.center_y(Length::Fixed(22.0))
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(1.0, 1.0, 1.0, 0.05))),
background: Some(iced::Background::Color(iced::Color::from_rgba(
1.0, 1.0, 1.0, 0.05,
))),
border: iced::Border::default().color(colors.border_high).width(1),
..Default::default()
})
@@ -169,18 +195,35 @@ pub fn view<'a>(
// ── Active layer controls ──────────────────────────────
let active_info = layers.iter().find(|l| l.id == active_layer_id);
let active_blend = active_info.map(|l| l.blend_mode).unwrap_or(BlendMode::Normal);
let active_blend = active_info
.map(|l| l.blend_mode)
.unwrap_or(BlendMode::Normal);
let active_opacity = active_info.map(|l| l.opacity).unwrap_or(1.0);
let active_locked = active_info.map(|l| l.locked).unwrap_or(false);
let active_metadata = active_info
.map(|layer| {
format!(
"{:?} {}x{} parent {}",
layer.layer_type,
layer.width,
layer.height,
layer
.parent_id
.map_or_else(|| "root".to_string(), |id| id.to_string())
)
})
.unwrap_or_else(|| "No active layer".to_string());
let blend_list = pick_list(BLEND_MODE_ITEMS, Some(BlendModeItem(active_blend)), move |item| {
Message::LayerSetBlendMode(active_layer_id, item.into())
})
let blend_list = pick_list(
BLEND_MODE_ITEMS,
Some(BlendModeItem(active_blend)),
move |item| Message::LayerSetBlendMode(active_layer_id, item.into()),
)
.width(Length::Fill)
.text_size(10);
.text_size(BODY);
let opacity_label = text(format!("{}%", (active_opacity * 100.0) as u32))
.size(10)
.size(BODY)
.width(Length::Fixed(32.0));
let opacity_slider = slider(0.0..=1.0, active_opacity, move |v| {
Message::LayerSetOpacity(active_layer_id, v)
@@ -188,11 +231,15 @@ pub fn view<'a>(
.step(0.01)
.width(Length::Fill);
let opacity_row = row![text("Op").size(9), opacity_slider, opacity_label]
let opacity_row = row![text("Op").size(BODY), opacity_slider, opacity_label]
.spacing(3)
.align_y(iced::Alignment::Center);
let lock_icon = if active_locked { "\u{1F512}" } else { "\u{1F513}" };
let lock_icon = if active_locked {
"\u{1F512}"
} else {
"\u{1F513}"
};
let lock_btn = button(text(lock_icon).size(11))
.on_press(Message::LayerToggleLock(active_layer_id))
.padding([1, 4])
@@ -206,7 +253,15 @@ pub fn view<'a>(
let controls = column![
row![blend_list].spacing(2),
opacity_row,
row![lock_btn, fx_btn].spacing(8).align_y(iced::Alignment::Center),
text("Fill opacity: unavailable in hcie-engine-api")
.size(BODY)
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(colors.text_secondary),
}),
row![lock_btn, fx_btn]
.spacing(8)
.align_y(iced::Alignment::Center),
text(active_metadata).size(BODY),
]
.spacing(2);
@@ -219,13 +274,16 @@ pub fn view<'a>(
let depth = entry.depth;
// Indent spacer
let indent: Element<'a, Message> = Space::with_width(
Length::Fixed((depth as f32) * 14.0)
).into();
let indent: Element<'a, Message> =
Space::with_width(Length::Fixed((depth as f32) * 14.0)).into();
// Collapse arrow (groups only)
let collapse: Element<'a, Message> = if entry.is_group {
let arrow = if info.collapsed { "\u{25B6}" } else { "\u{25BC}" };
let arrow = if info.collapsed {
"\u{25B6}"
} else {
"\u{25BC}"
};
button(text(arrow).size(8))
.on_press(Message::LayerToggleCollapse(info.id))
.padding([0, 1])
@@ -237,31 +295,46 @@ pub fn view<'a>(
// Visibility toggle
let vis_char = if info.visible { "\u{25CF}" } else { "\u{25CB}" };
let vis_color = if info.visible { colors.text_primary } else { colors.text_secondary };
let vis_btn = button(text(vis_char).size(10).style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(vis_color),
let vis_color = if info.visible {
colors.text_primary
} else {
colors.text_secondary
};
let vis_btn = button(text(vis_char).size(10).style(move |_theme: &iced::Theme| {
iced::widget::text::Style {
color: Some(vis_color),
}
}))
.on_press(Message::LayerToggleVisibility(info.id))
.padding([0, 2])
.style(move |_theme, _status| flat_btn_style(colors));
let row_lock = button(text(if info.locked { "L" } else { "-" }).size(9))
.on_press(Message::LayerToggleLock(info.id))
.padding([0, 2])
.style(move |_theme, _status| flat_btn_style(colors));
// Thumbnail
let thumb_elem = layer_thumbnail(entry, engine, colors);
// Layer name
let c = if is_active { colors.accent } else { colors.text_primary };
let c = if is_active {
colors.accent
} else {
colors.text_primary
};
let name_text = text(&info.name)
.size(10)
.size(BODY)
.width(Length::Fill)
.style(move |_theme: &iced::Theme| iced::widget::text::Style { color: Some(c) });
// "fx" icon for layers with effects (Photoshop-style)
let fx_icon: Element<'a, Message> = if info.has_effects {
button(
text("fx").size(8).style(move |_theme: &iced::Theme| iced::widget::text::Style {
button(text("fx").size(8).style(move |_theme: &iced::Theme| {
iced::widget::text::Style {
color: Some(colors.accent),
})
)
}
}))
.on_press(Message::LayerStyleToggle(0)) // Opens styles panel
.padding([0, 2])
.style(move |_theme, _status| flat_btn_style(colors))
@@ -272,36 +345,38 @@ pub fn view<'a>(
// Type badge
let type_badge: Element<'a, Message> = match info.layer_type {
LayerType::Vector => text("V").size(8)
LayerType::Vector => text("V")
.size(8)
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(iced::Color::from_rgb(0.4, 0.8, 0.4)),
}).into(),
LayerType::Text => text("T").size(8)
})
.into(),
LayerType::Text => text("T")
.size(8)
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(iced::Color::from_rgb(0.4, 0.6, 1.0)),
}).into(),
LayerType::Group => text("G").size(8)
})
.into(),
LayerType::Group => text("G")
.size(8)
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(iced::Color::from_rgb(0.85, 0.7, 0.3)),
}).into(),
LayerType::Mask => text("M").size(8)
})
.into(),
LayerType::Mask => text("M")
.size(8)
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(iced::Color::from_rgb(0.8, 0.4, 0.8)),
}).into(),
})
.into(),
_ => text("").size(8).into(),
};
// Main layer row
let name_row = row![
collapse,
vis_btn,
thumb_elem,
name_text,
fx_icon,
type_badge,
]
.spacing(2)
.align_y(iced::Alignment::Center);
let name_row =
row![collapse, vis_btn, row_lock, thumb_elem, name_text, fx_icon, type_badge,]
.spacing(2)
.align_y(iced::Alignment::Center);
let layer_item = if is_active {
container(name_row)
@@ -309,13 +384,21 @@ pub fn view<'a>(
.padding([2, 3])
.style(move |_theme| {
let accent_bg = iced::Color::from_rgba(
colors.accent.r, colors.accent.g, colors.accent.b, 0.18,
colors.accent.r,
colors.accent.g,
colors.accent.b,
0.18,
);
iced::widget::container::Style {
background: Some(iced::Background::Color(accent_bg)),
border: iced::Border::default().color(
iced::Color::from_rgba(colors.accent.r, colors.accent.g, colors.accent.b, 0.4)
).width(1),
border: iced::Border::default()
.color(iced::Color::from_rgba(
colors.accent.r,
colors.accent.g,
colors.accent.b,
0.4,
))
.width(1),
..Default::default()
}
})
@@ -326,8 +409,8 @@ pub fn view<'a>(
.style(move |_theme| styles::inactive_item_bg(colors))
};
let clickable = iced::widget::mouse_area(layer_item)
.on_press(Message::LayerSelect(info.id));
let clickable =
iced::widget::mouse_area(layer_item).on_press(Message::LayerSelect(info.id));
let row_with_indent = row![indent, clickable].align_y(iced::Alignment::Center);
layer_list = layer_list.push(row_with_indent);
@@ -341,17 +424,20 @@ pub fn view<'a>(
if *enabled {
let effect_name = effect_full_name(code);
let effect_color = fx_color(code);
let effect_indent = Space::with_width(
Length::Fixed((effects_depth as f32) * 14.0)
);
let effect_indent =
Space::with_width(Length::Fixed((effects_depth as f32) * 14.0));
let effect_row = row![
effect_indent,
text("\u{25B8}").size(8).style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(colors.text_secondary),
}),
text(effect_name).size(9).style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(effect_color),
text("\u{25B8}").size(8).style(move |_theme: &iced::Theme| {
iced::widget::text::Style {
color: Some(colors.text_secondary),
}
}),
text(effect_name)
.size(BODY)
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(effect_color),
}),
]
.spacing(2)
.align_y(iced::Alignment::Center);
@@ -371,6 +457,9 @@ pub fn view<'a>(
.on_press(Message::LayerAddGroup)
.padding([3, 6])
.style(move |_theme, _status| flat_btn_style(colors));
let duplicate_btn = button(text("Dup").size(9)).padding([3, 6]);
let mask_btn = button(text("Mask").size(9)).padding([3, 6]);
let rasterize_btn = button(text("Raster").size(9)).padding([3, 6]);
let flatten_btn = button(text("Flatten").size(10))
.on_press(Message::LayerFlatten)
.padding([3, 6])
@@ -392,17 +481,19 @@ pub fn view<'a>(
.on_press(Message::OpenLayerStyleDialog)
.padding([3, 6])
.style(move |_theme, _status| flat_btn_style(colors));
let toolbar = row![
add_btn, group_btn, flatten_btn,
iced::widget::horizontal_rule(1),
fx_btn,
iced::widget::horizontal_rule(1),
move_up_btn, move_down_btn,
iced::widget::horizontal_rule(1),
del_btn,
let toolbar = column![
text("Disabled (engine API): duplicate / mask / rasterize").size(8),
row![duplicate_btn, mask_btn, rasterize_btn]
.spacing(2)
.align_y(iced::Alignment::Center),
row![add_btn, group_btn, flatten_btn, fx_btn]
.spacing(2)
.align_y(iced::Alignment::Center),
row![move_up_btn, move_down_btn, del_btn]
.spacing(2)
.align_y(iced::Alignment::Center),
]
.spacing(2)
.align_y(iced::Alignment::Center);
.spacing(2);
// ── Panel layout (no duplicate title — dock tab provides it) ──
let panel = column![
File diff suppressed because it is too large Load Diff
@@ -22,3 +22,4 @@ pub mod title_bar;
pub mod tool_options;
pub mod tool_settings;
pub mod toolbar;
pub mod typography;
@@ -6,10 +6,14 @@
use crate::app::Message;
use crate::color_picker;
use crate::color_picker::ColorPickerTarget;
use crate::panels::styles;
use crate::panels::typography::{BODY, SECTION};
use crate::theme::ThemeColors;
use hcie_engine_api::{BlendMode, LayerType, Tool, VectorShape};
use iced::widget::{button, checkbox, column, container, horizontal_rule, row, slider, text};
use iced::widget::{
button, checkbox, column, container, horizontal_rule, row, scrollable, slider, text,
};
use iced::{Element, Length};
/// Human-readable label for a shape at a given index.
@@ -104,6 +108,8 @@ pub fn view<'a>(
vector_points: u32,
vector_sides: u32,
active_layer_id: u64,
foreground_color: [u8; 4],
background_color: [u8; 4],
) -> Element<'a, Message> {
// Build layer info section at the top
let layer_section = layer_info_section(
@@ -141,40 +147,40 @@ pub fn view<'a>(
| Tool::VectorCross
| Tool::VectorCrescent
| Tool::VectorBolt
| Tool::VectorArrow4 => {
vector_tool_properties(
active_tool,
vector_stroke,
vector_opacity,
vector_fill,
vector_radius,
vector_points,
vector_sides,
vector_draw,
colors,
)
}
| Tool::VectorArrow4 => vector_tool_properties(
active_tool,
vector_stroke,
vector_opacity,
vector_fill,
vector_radius,
vector_points,
vector_sides,
vector_draw,
colors,
foreground_color,
background_color,
),
Tool::Text => text_properties(colors),
Tool::Eyedropper => column![
text("Eyedropper").size(11),
text("Click on canvas to pick color").size(10),
text("Eyedropper").size(SECTION),
text("Click on canvas to pick color").size(BODY),
]
.spacing(4)
.into(),
Tool::FloodFill => column![
text("Flood Fill").size(11),
text("Click on canvas to fill area").size(10),
text("Flood Fill").size(SECTION),
text("Click on canvas to fill area").size(BODY),
]
.spacing(4)
.into(),
_ => text(format!("{:?}", active_tool)).size(11).into(),
_ => text(format!("{:?}", active_tool)).size(SECTION).into(),
};
let panel = column![layer_section, horizontal_rule(1), tool_section,]
.spacing(4)
.padding(4);
container(panel)
container(scrollable(panel))
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| styles::panel_background(colors))
@@ -193,25 +199,29 @@ fn layer_info_section<'a>(
layer_id: u64,
_colors: ThemeColors,
) -> Element<'a, Message> {
let opacity_slider = slider(0.0..=1.0, opacity, move |v| Message::LayerSetOpacity(layer_id, v))
.step(0.01)
.width(Length::Fill);
let opacity_slider = slider(0.0..=1.0, opacity, move |v| {
Message::LayerSetOpacity(layer_id, v)
})
.step(0.01)
.width(Length::Fill);
column![
text("Layer Info").size(11),
text("Layer Info").size(SECTION),
row![
text("Name").size(10).width(Length::Fixed(50.0)),
text(name).size(10),
text("Name").size(BODY).width(Length::Fixed(58.0)),
text(name).size(BODY),
]
.spacing(4),
row![
text("Opacity").size(10).width(Length::Fixed(50.0)),
text(format!("{:.0}%", opacity * 100.0)).size(10).width(Length::Fixed(36.0)),
text("Opacity").size(BODY).width(Length::Fixed(58.0)),
text(format!("{:.0}%", opacity * 100.0))
.size(BODY)
.width(Length::Fixed(36.0)),
]
.spacing(4),
opacity_slider,
row![
text("Visible").size(10).width(Length::Fixed(50.0)),
text("Visible").size(BODY).width(Length::Fixed(58.0)),
checkbox("", visible)
.on_toggle(move |v| Message::LayerToggleVisibility(layer_id))
.size(11),
@@ -219,18 +229,18 @@ fn layer_info_section<'a>(
.spacing(4)
.align_y(iced::Alignment::Center),
row![
text("Blend").size(10).width(Length::Fixed(50.0)),
text(blend_mode_label(blend_mode)).size(10),
text("Blend").size(BODY).width(Length::Fixed(58.0)),
text(blend_mode_label(blend_mode)).size(BODY),
]
.spacing(4),
row![
text("Type").size(10).width(Length::Fixed(50.0)),
text(layer_type_label(layer_type)).size(10),
text("Type").size(BODY).width(Length::Fixed(58.0)),
text(layer_type_label(layer_type)).size(BODY),
]
.spacing(4),
row![
text("Size").size(10).width(Length::Fixed(50.0)),
text(format!("{}×{}", width, height)).size(10),
text("Size").size(BODY).width(Length::Fixed(58.0)),
text(format!("{}×{}", width, height)).size(BODY),
]
.spacing(4),
]
@@ -274,23 +284,39 @@ fn vector_select_properties<'a>(
};
column![
text("Shape Properties").size(11),
row![text("Shape").size(10), text(label).size(10)].spacing(4),
row![text("Type").size(10), text(shape_type).size(10)].spacing(4),
row![text("Stroke").size(10), text(format!("{:.1}", stroke)).size(10)].spacing(4),
text("Shape Properties").size(SECTION),
row![text("Shape").size(BODY), text(label).size(BODY)].spacing(4),
row![text("Type").size(BODY), text(shape_type).size(BODY)].spacing(4),
row![
text("Stroke").size(BODY),
text(format!("{:.1}", stroke)).size(BODY)
]
.spacing(4),
slider(0.0..=50.0, stroke, move |v| Message::VectorStrokeChanged(v))
.step(0.5)
.width(Length::Fill),
row![text("Opacity").size(10), text(format!("{:.0}%", opacity * 100.0)).size(10)].spacing(4),
slider(0.0..=1.0, opacity, move |v| Message::VectorOpacityChanged(v))
.step(0.01)
.width(Length::Fill),
row![text("Hardness").size(10), text(format!("{:.0}%", hardness * 100.0)).size(10)].spacing(4),
slider(0.0..=1.0, hardness, move |v| Message::GeometryHardnessChanged(v))
.step(0.01)
.width(Length::Fill),
row![
text("Fill").size(10),
text("Opacity").size(BODY),
text(format!("{:.0}%", opacity * 100.0)).size(BODY)
]
.spacing(4),
slider(0.0..=1.0, opacity, move |v| Message::VectorOpacityChanged(
v
))
.step(0.01)
.width(Length::Fill),
row![
text("Hardness").size(BODY),
text(format!("{:.0}%", hardness * 100.0)).size(BODY)
]
.spacing(4),
slider(0.0..=1.0, hardness, move |v| {
Message::GeometryHardnessChanged(v)
})
.step(0.01)
.width(Length::Fill),
row![
text("Fill").size(BODY),
checkbox("", filled)
.on_toggle(Message::VectorFillToggled)
.size(11),
@@ -299,27 +325,27 @@ fn vector_select_properties<'a>(
.align_y(iced::Alignment::Center),
// Fill color picker (only if fill is enabled)
if filled {
color_picker::color_popup(
color_picker::color_swatch(
fill_color,
"Fill Color",
Message::GeometryFillColorChanged,
ColorPickerTarget::GeometryFill,
)
} else {
text("").into()
},
// Stroke color picker
color_picker::color_popup(
color_picker::color_swatch(
stroke_color,
"Stroke Color",
Message::GeometryStrokeColorChanged,
ColorPickerTarget::GeometryStroke,
),
]
.spacing(4)
.into()
}
None => column![
text("Vector Select").size(11),
text("Click a shape on canvas").size(10),
text("Vector Select").size(SECTION),
text("Click a shape on canvas").size(BODY),
]
.spacing(4)
.into(),
@@ -337,19 +363,24 @@ fn vector_tool_properties<'a>(
sides: u32,
vector_draw: Option<((f32, f32), (f32, f32))>,
_colors: ThemeColors,
foreground_color: [u8; 4],
background_color: [u8; 4],
) -> Element<'a, Message> {
// Get current fg/bg colors from app state (we need to pass them through)
// For now, use default colors - the actual colors will come from the app state
let default_color = [0, 0, 0, 255];
let default_fill_color = [255, 255, 255, 255];
let mut col = column![
text("Vector Shape").size(11),
row![text("Stroke").size(10), text(format!("{:.1}", stroke)).size(10)].spacing(4),
row![
text("Stroke").size(10),
text(format!("{:.1}", stroke)).size(10)
]
.spacing(4),
slider(0.0..=50.0, stroke, |v| Message::VectorStrokeChanged(v))
.step(0.5)
.width(Length::Fill),
row![text("Opacity").size(10), text(format!("{:.0}%", opacity * 100.0)).size(10)].spacing(4),
row![
text("Opacity").size(10),
text(format!("{:.0}%", opacity * 100.0)).size(10)
]
.spacing(4),
slider(0.0..=1.0, opacity, |v| Message::VectorOpacityChanged(v))
.step(0.01)
.width(Length::Fill),
@@ -363,19 +394,19 @@ fn vector_tool_properties<'a>(
.align_y(iced::Alignment::Center),
// Fill color picker (only if fill is enabled)
if fill {
color_picker::color_popup(
default_fill_color,
color_picker::color_swatch(
background_color,
"Fill Color",
Message::GeometryFillColorChanged,
ColorPickerTarget::Background,
)
} else {
text("").into()
},
// Stroke color picker
color_picker::color_popup(
default_color,
color_picker::color_swatch(
foreground_color,
"Stroke Color",
Message::GeometryStrokeColorChanged,
ColorPickerTarget::Foreground,
),
]
.spacing(4);
@@ -384,7 +415,11 @@ fn vector_tool_properties<'a>(
match tool {
Tool::VectorRect => {
col = col.push(
row![text("Radius").size(10), text(format!("{:.1}", radius)).size(10)].spacing(4),
row![
text("Radius").size(10),
text(format!("{:.1}", radius)).size(10)
]
.spacing(4),
);
col = col.push(
slider(0.0..=100.0, radius, |v| Message::VectorRadiusChanged(v))
@@ -394,22 +429,29 @@ fn vector_tool_properties<'a>(
}
Tool::VectorStar => {
col = col.push(
row![text("Points").size(10), text(format!("{}", points)).size(10)].spacing(4),
row![
text("Points").size(10),
text(format!("{}", points)).size(10)
]
.spacing(4),
);
col = col.push(
slider(3.0..=20.0, points as f32, |v| Message::VectorPointsChanged(v as u32))
.step(1.0)
.width(Length::Fill),
slider(3.0..=20.0, points as f32, |v| {
Message::VectorPointsChanged(v as u32)
})
.step(1.0)
.width(Length::Fill),
);
}
Tool::VectorPolygon => {
col = col
.push(row![text("Sides").size(10), text(format!("{}", sides)).size(10)].spacing(4));
col = col.push(
row![text("Sides").size(10), text(format!("{}", sides)).size(10)].spacing(4),
);
col = col.push(
slider(3.0..=12.0, sides as f32, |v| Message::VectorSidesChanged(v as u32))
.step(1.0)
.width(Length::Fill),
slider(3.0..=12.0, sides as f32, |v| {
Message::VectorSidesChanged(v as u32)
})
.step(1.0)
.width(Length::Fill),
);
}
_ => {}
@@ -420,16 +462,20 @@ fn vector_tool_properties<'a>(
let w = (x1 - x0).abs() as u32;
let h = (y1 - y0).abs() as u32;
col = col.push(horizontal_rule(1));
col = col.push(
row![text("Size").size(10), text(format!("{}×{}", w, h)).size(10)].spacing(4),
);
col =
col.push(row![text("Size").size(10), text(format!("{}×{}", w, h)).size(10)].spacing(4));
}
col.into()
}
/// Brush tool properties (Pen, Brush, Eraser, Spray).
fn brush_properties<'a>(size: f32, opacity: f32, hardness: f32, _colors: ThemeColors) -> Element<'a, Message> {
fn brush_properties<'a>(
size: f32,
opacity: f32,
hardness: f32,
_colors: ThemeColors,
) -> Element<'a, Message> {
let size_slider = slider(1.0..=200.0, size, |v| Message::BrushSizeChanged(v))
.step(1.0)
.width(Length::Fill);
@@ -444,9 +490,17 @@ fn brush_properties<'a>(size: f32, opacity: f32, hardness: f32, _colors: ThemeCo
text("Brush Properties").size(11),
row![text("Size").size(10), text(format!("{:.0}", size)).size(10)].spacing(4),
size_slider,
row![text("Opacity").size(10), text(format!("{:.0}%", opacity * 100.0)).size(10)].spacing(4),
row![
text("Opacity").size(10),
text(format!("{:.0}%", opacity * 100.0)).size(10)
]
.spacing(4),
opacity_slider,
row![text("Hardness").size(10), text(format!("{:.0}%", hardness * 100.0)).size(10)].spacing(4),
row![
text("Hardness").size(10),
text(format!("{:.0}%", hardness * 100.0)).size(10)
]
.spacing(4),
hardness_slider,
]
.spacing(4)
@@ -454,7 +508,10 @@ fn brush_properties<'a>(size: f32, opacity: f32, hardness: f32, _colors: ThemeCo
}
/// Selection tool properties.
fn selection_properties<'a>(rect: Option<(f32, f32, f32, f32)>, _colors: ThemeColors) -> Element<'a, Message> {
fn selection_properties<'a>(
rect: Option<(f32, f32, f32, f32)>,
_colors: ThemeColors,
) -> Element<'a, Message> {
match rect {
Some((x0, y0, x1, y1)) => {
let w = (x1 - x0).abs() as u32;
@@ -466,7 +523,9 @@ fn selection_properties<'a>(rect: Option<(f32, f32, f32, f32)>, _colors: ThemeCo
.spacing(4)
.into()
}
None => column![text("Selection").size(11), text("Drag on canvas").size(10)].spacing(4).into(),
None => column![text("Selection").size(11), text("Drag on canvas").size(10)]
.spacing(4)
.into(),
}
}
@@ -477,10 +536,17 @@ fn text_properties<'a>(_colors: ThemeColors) -> Element<'a, Message> {
text("Click on canvas to add text").size(10),
horizontal_rule(1),
row![
button(text("Left").size(10)).on_press(Message::TextAlignChanged("Left".to_string())).padding([4, 8]),
button(text("Center").size(10)).on_press(Message::TextAlignChanged("Center".to_string())).padding([4, 8]),
button(text("Right").size(10)).on_press(Message::TextAlignChanged("Right".to_string())).padding([4, 8]),
].spacing(4),
button(text("Left").size(10))
.on_press(Message::TextAlignChanged("Left".to_string()))
.padding([4, 8]),
button(text("Center").size(10))
.on_press(Message::TextAlignChanged("Center".to_string()))
.padding([4, 8]),
button(text("Right").size(10))
.on_press(Message::TextAlignChanged("Right".to_string()))
.padding([4, 8]),
]
.spacing(4),
]
.spacing(4)
.into()
@@ -7,7 +7,9 @@
use crate::app::Message;
use crate::panels::styles;
use crate::theme::ThemeColors;
use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, text_editor};
use iced::widget::{
button, column, container, horizontal_rule, row, scrollable, text, text_editor,
};
use iced::{Element, Length};
/// Build the full script editor panel.
@@ -43,7 +45,12 @@ pub fn view<'a>(
.fold(column![].spacing(0), |col, t| col.push(t));
let gutter = container(scrollable(gutter_lines).height(Length::Fill))
.padding(iced::Padding { top: 8.0, right: 4.0, bottom: 8.0, left: 8.0 })
.padding(iced::Padding {
top: 8.0,
right: 4.0,
bottom: 8.0,
left: 8.0,
})
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_app)),
border: iced::Border::default().rounded(4),
@@ -69,9 +76,13 @@ pub fn view<'a>(
.padding(iced::Padding::from([6.0, 12.0]));
let status_text = if script_is_running {
text("⟳ Executing...").size(11).color(iced::Color::from_rgb(0.9, 0.7, 0.2))
text("⟳ Executing...")
.size(11)
.color(iced::Color::from_rgb(0.9, 0.7, 0.2))
} else {
text("Ready").size(11).color(iced::Color::from_rgb(0.4, 0.7, 0.4))
text("Ready")
.size(11)
.color(iced::Color::from_rgb(0.4, 0.7, 0.4))
};
let buttons = row![run_btn, clear_btn, status_text].spacing(6);
@@ -148,7 +159,7 @@ pub fn view<'a>(
// ── Command Reference ───────────────────────────────────
let help_text = [
("Layer:", "layer Name — create raster layer"),
("", "vector_layer Name — create vector layer"),
("", "vector_layer — unavailable (public engine API)"),
("", "select_layer Name — switch active layer"),
("Brush & Color:", "brush round — set brush style"),
("", "color #FF0000 — set foreground color"),
@@ -162,7 +173,10 @@ pub fn view<'a>(
("", "circle cx,cy — single dab"),
("", "triangle x1,y1 x2,y2 x3,y3 — vector triangle"),
("", "bezier x1,y1 cx1,cy1 cx2,cy2 x2,y2 [steps]"),
("Organic:", "scatter N x,y w,h [size] — golden-angle scatter"),
(
"Organic:",
"scatter N x,y w,h [size] — golden-angle scatter",
),
("", "gradient x1,y1 x2,y2 #hex1 #hex2 ... N"),
("", "wiggle N — per-point jitter"),
("", "pressure / pressure_start / pressure_end"),
@@ -172,7 +186,9 @@ pub fn view<'a>(
];
let mut reference = column![
text("Command Reference").size(11).font(iced::Font::MONOSPACE),
text("Command Reference")
.size(11)
.font(iced::Font::MONOSPACE),
horizontal_rule(1),
]
.spacing(2);
@@ -193,16 +209,20 @@ pub fn view<'a>(
);
}
let reference_container = container(scrollable(reference).height(Length::Fill))
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_app)),
border: iced::Border::default().rounded(4),
..Default::default()
let reference_container =
container(scrollable(reference).height(Length::Fill)).style(move |_theme| {
iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_app)),
border: iced::Border::default().rounded(4),
..Default::default()
}
});
// ── Assemble the panel ──────────────────────────────────
let panel = column![
text("HCIE Script — Organic DSL").size(13).font(iced::Font::MONOSPACE),
text("HCIE Script — Organic DSL")
.size(13)
.font(iced::Font::MONOSPACE),
horizontal_rule(1),
editor_row,
buttons,
@@ -42,9 +42,9 @@ pub fn active_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)
)),
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.0,
))),
border: iced::Border::default(),
..Default::default()
}
@@ -20,9 +20,15 @@ pub fn view(
let g = font_color[1];
let b = font_color[2];
let color_r = slider(0..=255, r, move |v| Message::TextColorChanged([v, g, b, 255]));
let color_g = slider(0..=255, g, move |v| Message::TextColorChanged([r, v, b, 255]));
let color_b = slider(0..=255, b, move |v| Message::TextColorChanged([r, g, v, 255]));
let color_r = slider(0..=255, r, move |v| {
Message::TextColorChanged([v, g, b, 255])
});
let color_g = slider(0..=255, g, move |v| {
Message::TextColorChanged([r, v, b, 255])
});
let color_b = slider(0..=255, b, move |v| {
Message::TextColorChanged([r, g, v, 255])
});
let align_left = button(text("Left").size(10))
.on_press(Message::TextAlignChanged("Left".to_string()))
@@ -64,8 +70,12 @@ pub fn view(
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgb(0.18, 0.18, 0.18))),
border: iced::Border::default().color(iced::Color::from_rgb(0.3, 0.3, 0.3)).width(1),
background: Some(iced::Background::Color(iced::Color::from_rgb(
0.18, 0.18, 0.18,
))),
border: iced::Border::default()
.color(iced::Color::from_rgb(0.3, 0.3, 0.3))
.width(1),
..Default::default()
})
.into()
@@ -34,7 +34,11 @@ pub fn generate_thumbnail(pixels: &[u8], width: u32, height: u32, max_dim: u32)
let a = pixels[si + 3] as f32 / 255.0;
// Checkerboard: light (#f0f0f0) and dark (#c0c0c0)
let cb = if (tx / 4 + ty / 4) % 2 == 0 { 240.0 } else { 192.0 };
let cb = if (tx / 4 + ty / 4) % 2 == 0 {
240.0
} else {
192.0
};
let bg = cb / 255.0;
out[di] = ((r * a + bg * (1.0 - a)) * 255.0).round() as u8;
@@ -53,7 +57,11 @@ fn checkerboard(w: u32, h: u32) -> Vec<u8> {
let mut out = vec![0u8; (w * h * 4) as usize];
for y in 0..h {
for x in 0..w {
let cb = if (x / 4 + y / 4) % 2 == 0 { 240u8 } else { 192u8 };
let cb = if (x / 4 + y / 4) % 2 == 0 {
240u8
} else {
192u8
};
let i = ((y * w + x) * 4) as usize;
out[i] = cb;
out[i + 1] = cb;
@@ -14,17 +14,44 @@ use iced::widget::{button, container, mouse_area, row, text};
use iced::{Element, Length};
/// Photopea-style menu labels matching the reference screenshots.
const MENU_LABELS: &[&str] = &[
"File", "Edit", "Image", "Layer", "Select",
"Filter", "View", "Window", "More",
pub(crate) const MENU_LABELS: &[&str] = &[
"File", "Edit", "Image", "Layer", "Select", "Filter", "View", "Window", "More",
];
/// Horizontal title-menu metrics shared by button layout and dropdown placement.
///
/// **Purpose:** Keeps menu buttons and dropdown anchors in one deterministic layout model.
/// **Logic & Workflow:** Widths include the measured 13px semibold label advance plus the
/// title button's 16px horizontal padding. [`menu_anchor_x`] sums these exact rendered widths.
/// **Side Effects / Dependencies:** Values depend on the title bar font and padding below.
pub(crate) const MENU_BUTTON_WIDTHS: &[f32] =
&[42.0, 42.0, 52.0, 56.0, 60.0, 51.0, 47.0, 67.0, 50.0];
pub(crate) const MENU_LEFT_INSET: f32 = 4.0;
/// Unified menu/title bar height shared with dropdown placement.
pub(crate) const MENU_BAR_HEIGHT: f32 = 28.0;
/// Returns the clamped left edge for a menu dropdown.
///
/// **Arguments:** `menu_index` selects a title button, `viewport_width` is the available width,
/// and `dropdown_width` is the popup width. **Returns:** A viewport-safe X coordinate.
/// **Logic & Workflow:** Sums the same explicit widths assigned to preceding buttons, then clamps
/// the result so the dropdown remains visible on narrow windows. **Side Effects:** None.
pub(crate) fn menu_anchor_x(menu_index: usize, viewport_width: f32, dropdown_width: f32) -> f32 {
let natural = MENU_LEFT_INSET
+ MENU_BUTTON_WIDTHS
.iter()
.take(menu_index.min(MENU_BUTTON_WIDTHS.len()))
.sum::<f32>();
natural.min((viewport_width - dropdown_width).max(0.0))
}
/// Build the Photopea-style unified title/menu bar element.
///
/// Layout: [File Edit Image ... More] [doc info] [─ □ ×]
/// The left portion (before window controls) is draggable.
pub fn view<'a>(
doc_name: &'a str,
modified: bool,
zoom: f32,
canvas_w: u32,
canvas_h: u32,
@@ -36,10 +63,15 @@ pub fn view<'a>(
for (idx, &label) in MENU_LABELS.iter().enumerate() {
let is_open = active_menu == Some(idx);
let c = colors;
let btn = button(text(label).size(13).font(iced::Font { weight: iced::font::Weight::Semibold, ..iced::Font::default() }))
.on_press(Message::MenuOpen(idx))
.padding([3, 8])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
let btn = button(text(label).size(13).font(iced::Font {
weight: iced::font::Weight::Semibold,
..iced::Font::default()
}))
.on_press(Message::MenuOpen(idx))
.padding([3, 8])
.width(MENU_BUTTON_WIDTHS[idx])
.style(
move |_theme: &iced::Theme, status: iced::widget::button::Status| {
if is_open {
// Active menu: light background with dark text (Photopea style)
iced::widget::button::Style {
@@ -57,23 +89,30 @@ pub fn view<'a>(
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.0,
))),
text_color: c.text_primary,
border: iced::Border::default(),
..Default::default()
},
}
}
});
},
);
menu_items = menu_items.push(btn);
}
// ── Document info — Photopea style: "New Project.psd *" ──
let doc_info = text(format!("{} *", doc_name))
.size(11)
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(colors.text_secondary),
});
let doc_info = text(if modified {
format!("{} *", doc_name)
} else {
doc_name.to_string()
})
.size(11)
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(colors.text_secondary),
});
// ── Window controls — Photopea style: ─ □ × ──
let c = colors;
@@ -81,8 +120,8 @@ pub fn view<'a>(
let minimize_btn = button(text("").size(12))
.on_press(Message::WindowMinimize)
.padding([2, 8])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
match status {
.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,
@@ -90,19 +129,21 @@ pub fn view<'a>(
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.0,
))),
text_color: c.text_secondary,
border: iced::Border::default(),
..Default::default()
},
}
});
},
);
let maximize_btn = button(text("").size(12))
.on_press(Message::WindowMaximize)
.padding([2, 8])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
match status {
.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,
@@ -110,19 +151,21 @@ pub fn view<'a>(
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.0,
))),
text_color: c.text_secondary,
border: iced::Border::default(),
..Default::default()
},
}
});
},
);
let close_btn = button(text("×").size(13))
.on_press(Message::WindowClose)
.padding([2, 10])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
match status {
.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,
@@ -130,37 +173,41 @@ pub fn view<'a>(
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.0,
))),
text_color: c.text_secondary,
border: iced::Border::default(),
..Default::default()
},
}
});
},
);
let window_controls = row![minimize_btn, maximize_btn, close_btn].spacing(0);
// ── Left side (draggable): menus + doc info ──
let left_side = row![
menu_items,
iced::widget::Space::with_width(Length::Fill),
doc_info,
]
.spacing(0)
.align_y(iced::Alignment::Center);
// Only the empty title region is draggable. Interactive menus are siblings,
// preventing their presses from also initiating a native window drag.
let draggable_left = mouse_area(
container(left_side)
.width(Length::Fill)
.height(24)
.align_y(iced::Alignment::Center)
.padding([0, 4])
container(row![
iced::widget::Space::with_width(Length::Fill),
doc_info
])
.width(Length::Fill)
.height(MENU_BAR_HEIGHT)
.align_y(iced::Alignment::Center)
.padding([0, 4]),
)
.on_press(Message::WindowDrag)
.interaction(iced::mouse::Interaction::Grab);
// ── Full bar ──
let bar = row![
container(menu_items).padding(iced::Padding {
left: MENU_LEFT_INSET,
right: 0.0,
top: 0.0,
bottom: 0.0,
}),
draggable_left,
window_controls,
]
@@ -168,7 +215,7 @@ pub fn view<'a>(
container(bar)
.width(Length::Fill)
.height(24)
.height(MENU_BAR_HEIGHT)
.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),
@@ -176,3 +223,18 @@ pub fn view<'a>(
})
.into()
}
#[cfg(test)]
mod tests {
use super::menu_anchor_x;
/// Verifies natural anchors and narrow-viewport clamping use rendered button metrics.
#[test]
fn menu_anchors_are_deterministic_across_viewport_widths() {
assert_eq!(menu_anchor_x(0, 1280.0, 260.0), 4.0);
assert_eq!(menu_anchor_x(3, 1280.0, 260.0), 140.0);
assert_eq!(menu_anchor_x(8, 1280.0, 260.0), 421.0);
assert_eq!(menu_anchor_x(8, 500.0, 260.0), 240.0);
assert_eq!(menu_anchor_x(8, 180.0, 260.0), 0.0);
}
}
@@ -28,7 +28,6 @@ use iced::{Element, Length};
pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, Message> {
let tool_name = text(tool_name_label(tool_state.active_tool))
.size(11)
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(colors.text_primary),
});
@@ -58,9 +57,12 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
.step(0.01)
.width(Length::Fixed(100.0));
let hardness_label = text(format!("Hardness: {:.0}%", tool_state.brush_hardness * 100.0))
.size(10)
.width(Length::Fixed(75.0));
let hardness_label = text(format!(
"Hardness: {:.0}%",
tool_state.brush_hardness * 100.0
))
.size(10)
.width(Length::Fixed(75.0));
let hardness_slider = slider(
0.0..=1.0,
tool_state.brush_hardness,
@@ -70,11 +72,14 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
.width(Length::Fixed(80.0));
row![
size_label, size_slider,
size_label,
size_slider,
text(" ").size(10),
opacity_label, opacity_slider,
opacity_label,
opacity_slider,
text(" ").size(10),
hardness_label, hardness_slider,
hardness_label,
hardness_slider,
]
.spacing(2)
.align_y(iced::Alignment::Center)
@@ -82,15 +87,12 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
// ── Magic Wand ──
Tool::MagicWand => {
let tolerance_label = text("Tolerance:")
.size(10)
.width(Length::Fixed(55.0));
let tolerance_input = text_input("16", "16")
.width(Length::Fixed(40.0))
.size(10);
let tolerance_label = text("Tolerance:").size(10).width(Length::Fixed(55.0));
let tolerance_input = text_input("16", "16").width(Length::Fixed(40.0)).size(10);
row![
tolerance_label, tolerance_input,
tolerance_label,
tolerance_input,
text(" ").size(10),
checkbox_label("Anti-alias", true),
text(" ").size(10),
@@ -102,15 +104,12 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
// ── Select tools ──
Tool::Select | Tool::Lasso => {
let feather_label = text("Feather:")
.size(10)
.width(Length::Fixed(50.0));
let feather_input = text_input("0 px", "0")
.width(Length::Fixed(50.0))
.size(10);
let feather_label = text("Feather:").size(10).width(Length::Fixed(50.0));
let feather_input = text_input("0 px", "0").width(Length::Fixed(50.0)).size(10);
row![
feather_label, feather_input,
feather_label,
feather_input,
text(" ").size(10),
small_button("Refine Edge", colors),
text(" ").size(10),
@@ -121,82 +120,68 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
}
// ── Move tool ──
Tool::Move => {
row![
checkbox_label("Auto-Select", true),
text(" ").size(10),
text("Layer").size(10),
]
.spacing(2)
.align_y(iced::Alignment::Center)
}
Tool::Move => row![
checkbox_label("Auto-Select", true),
text(" ").size(10),
text("Layer").size(10),
]
.spacing(2)
.align_y(iced::Alignment::Center),
// ── Eyedropper ──
Tool::Eyedropper => {
row![
text("Sample Size: Point Sample").size(10),
text(" ").size(10),
checkbox_label("Sample All Layers", false),
]
.spacing(2)
.align_y(iced::Alignment::Center)
}
Tool::Eyedropper => row![
text("Sample Size: Point Sample").size(10),
text(" ").size(10),
checkbox_label("Sample All Layers", false),
]
.spacing(2)
.align_y(iced::Alignment::Center),
// ── Gradient / FloodFill ──
Tool::Gradient | Tool::FloodFill => {
row![
text("Mode:").size(10),
text("Normal").size(10),
text(" ").size(10),
text("Opacity:").size(10),
text("100%").size(10),
]
.spacing(2)
.align_y(iced::Alignment::Center)
}
Tool::Gradient | Tool::FloodFill => row![
text("Mode:").size(10),
text("Normal").size(10),
text(" ").size(10),
text("Opacity:").size(10),
text("100%").size(10),
]
.spacing(2)
.align_y(iced::Alignment::Center),
// ── Text ──
Tool::Text => {
row![
text("Font:").size(10),
text("Arial").size(10),
text(" ").size(10),
text("Size:").size(10),
text("24px").size(10),
]
.spacing(2)
.align_y(iced::Alignment::Center)
}
Tool::Text => row![
text("Font:").size(10),
text("Arial").size(10),
text(" ").size(10),
text("Size:").size(10),
text("24px").size(10),
]
.spacing(2)
.align_y(iced::Alignment::Center),
// ── Crop ──
Tool::Crop => {
row![
text("Ratio:").size(10),
text("Original").size(10),
text(" ").size(10),
small_button("Straighten", colors),
]
.spacing(2)
.align_y(iced::Alignment::Center)
}
Tool::Crop => row![
text("Ratio:").size(10),
text("Original").size(10),
text(" ").size(10),
small_button("Straighten", colors),
]
.spacing(2)
.align_y(iced::Alignment::Center),
// ── Vector tools ──
Tool::VectorLine | Tool::VectorRect | Tool::VectorCircle => {
row![
text("Fill:").size(10),
text("None").size(10),
text(" ").size(10),
text("Stroke:").size(10),
text("1px").size(10),
]
.spacing(2)
.align_y(iced::Alignment::Center)
}
Tool::VectorLine | Tool::VectorRect | Tool::VectorCircle => row![
text("Fill:").size(10),
text("None").size(10),
text(" ").size(10),
text("Stroke:").size(10),
text("1px").size(10),
]
.spacing(2)
.align_y(iced::Alignment::Center),
// ── Default (no specific options) ──
_ => {
row![].spacing(0)
}
_ => row![].spacing(0),
};
let bar = row![
@@ -244,13 +229,10 @@ fn tool_name_label(tool: Tool) -> &'static str {
fn checkbox_label(label: &str, checked: bool) -> Element<'static, Message> {
let mark = if checked { "" } else { "" };
let label_owned = label.to_string();
row![
text(mark).size(10),
text(label_owned).size(10),
]
.spacing(2)
.align_y(iced::Alignment::Center)
.into()
row![text(mark).size(10), text(label_owned).size(10),]
.spacing(2)
.align_y(iced::Alignment::Center)
.into()
}
/// A small flat button for tool options.
@@ -265,8 +247,8 @@ fn small_button(label: &str, colors: ThemeColors) -> Element<'static, Message> {
button(text(label_owned).size(10))
.on_press(Message::NoOp)
.padding([2, 6])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
match status {
.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(bg_hover)),
text_color: text_primary,
@@ -279,7 +261,7 @@ fn small_button(label: &str, colors: ThemeColors) -> Element<'static, Message> {
border: iced::Border::default().color(border_low).width(1),
..Default::default()
},
}
})
},
)
.into()
}
@@ -16,7 +16,9 @@ use crate::panels::styles;
use crate::settings::AppSettings;
use crate::theme::ThemeColors;
use hcie_engine_api::Tool;
use iced::widget::{button, checkbox, column, container, horizontal_rule, row, scrollable, slider, text, text_input};
use iced::widget::{
button, checkbox, column, container, horizontal_rule, row, scrollable, slider, text, text_input,
};
use iced::{Element, Length};
/// Build the tool settings panel for the active tool.
@@ -74,38 +76,134 @@ pub fn view<'a>(
.into()
}
fn brush_settings(ts: &ToolState, settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
fn brush_settings(
ts: &ToolState,
settings: &AppSettings,
c: ThemeColors,
) -> iced::widget::Column<'static, Message> {
let ts2 = settings.tool_settings.clone();
column![
section_header("Brush Settings", c),
prop_slider("Size", ts.brush_size, 1.0, 500.0, Message::BrushSizeChanged, c),
prop_slider("Opacity", ts.brush_opacity * 100.0, 0.0, 100.0, |v| Message::BrushOpacityChanged(v / 100.0), c),
prop_slider("Hardness", ts.brush_hardness * 100.0, 0.0, 100.0, |v| Message::BrushHardnessChanged(v / 100.0), c),
prop_slider("Flow", ts2.brush_flow * 100.0, 0.0, 100.0, |v| Message::BrushFlowChanged(v / 100.0), c),
prop_slider("Spacing", ts.brush_spacing * 100.0, 1.0, 100.0, |v| Message::BrushSpacingChanged(v / 100.0), c),
prop_slider(
"Size",
ts.brush_size,
1.0,
500.0,
Message::BrushSizeChanged,
c
),
prop_slider(
"Opacity",
ts.brush_opacity * 100.0,
0.0,
100.0,
|v| Message::BrushOpacityChanged(v / 100.0),
c
),
prop_slider(
"Hardness",
ts.brush_hardness * 100.0,
0.0,
100.0,
|v| Message::BrushHardnessChanged(v / 100.0),
c
),
prop_slider(
"Flow",
ts2.brush_flow * 100.0,
0.0,
100.0,
|v| Message::BrushFlowChanged(v / 100.0),
c
),
prop_slider(
"Spacing",
ts.brush_spacing * 100.0,
1.0,
100.0,
|v| Message::BrushSpacingChanged(v / 100.0),
c
),
]
.spacing(4)
}
fn spray_settings(ts: &ToolState, settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
fn spray_settings(
ts: &ToolState,
settings: &AppSettings,
c: ThemeColors,
) -> iced::widget::Column<'static, Message> {
let ts2 = settings.tool_settings.clone();
column![
section_header("Spray Settings", c),
prop_slider("Size", ts.brush_size, 1.0, 500.0, Message::BrushSizeChanged, c),
prop_slider("Particle Size", ts2.spray_particle_size, 1.0, 20.0, Message::SprayParticleSizeChanged, c),
prop_slider("Density", ts2.spray_density as f32, 10.0, 1000.0, |v| Message::SprayDensityChanged(v as u32), c),
prop_slider("Opacity", ts.brush_opacity * 100.0, 0.0, 100.0, |v| Message::BrushOpacityChanged(v / 100.0), c),
prop_slider("Hardness", ts.brush_hardness * 100.0, 0.0, 100.0, |v| Message::BrushHardnessChanged(v / 100.0), c),
prop_slider(
"Size",
ts.brush_size,
1.0,
500.0,
Message::BrushSizeChanged,
c
),
prop_slider(
"Particle Size",
ts2.spray_particle_size,
1.0,
20.0,
Message::SprayParticleSizeChanged,
c
),
prop_slider(
"Density",
ts2.spray_density as f32,
10.0,
1000.0,
|v| Message::SprayDensityChanged(v as u32),
c
),
prop_slider(
"Opacity",
ts.brush_opacity * 100.0,
0.0,
100.0,
|v| Message::BrushOpacityChanged(v / 100.0),
c
),
prop_slider(
"Hardness",
ts.brush_hardness * 100.0,
0.0,
100.0,
|v| Message::BrushHardnessChanged(v / 100.0),
c
),
]
.spacing(4)
}
fn vector_settings(ts: &ToolState, settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
fn vector_settings(
ts: &ToolState,
settings: &AppSettings,
c: ThemeColors,
) -> iced::widget::Column<'static, Message> {
let ts2 = settings.tool_settings.clone();
let mut col = column![
section_header("Vector Settings", c),
prop_slider("Stroke Width", ts2.vector_stroke_width, 0.0, 50.0, Message::VectorStrokeChanged, c),
prop_slider("Opacity", ts2.vector_opacity * 100.0, 0.0, 100.0, |v| Message::VectorOpacityChanged(v / 100.0), c),
prop_slider(
"Stroke Width",
ts2.vector_stroke_width,
0.0,
50.0,
Message::VectorStrokeChanged,
c
),
prop_slider(
"Opacity",
ts2.vector_opacity * 100.0,
0.0,
100.0,
|v| Message::VectorOpacityChanged(v / 100.0),
c
),
iced::widget::checkbox("Fill", ts2.vector_fill)
.on_toggle(Message::VectorFillToggled)
.size(11),
@@ -115,13 +213,34 @@ fn vector_settings(ts: &ToolState, settings: &AppSettings, c: ThemeColors) -> ic
// Shape-specific controls.
match ts.active_tool {
Tool::VectorRect => {
col = col.push(prop_slider("Corner Radius", ts2.vector_radius, 0.0, 100.0, Message::VectorRadiusChanged, c));
col = col.push(prop_slider(
"Corner Radius",
ts2.vector_radius,
0.0,
100.0,
Message::VectorRadiusChanged,
c,
));
}
Tool::VectorStar => {
col = col.push(prop_slider("Points", ts2.vector_points as f32, 3.0, 20.0, |v| Message::VectorPointsChanged(v as u32), c));
col = col.push(prop_slider(
"Points",
ts2.vector_points as f32,
3.0,
20.0,
|v| Message::VectorPointsChanged(v as u32),
c,
));
}
Tool::VectorPolygon => {
col = col.push(prop_slider("Sides", ts2.vector_sides as f32, 3.0, 12.0, |v| Message::VectorSidesChanged(v as u32), c));
col = col.push(prop_slider(
"Sides",
ts2.vector_sides as f32,
3.0,
12.0,
|v| Message::VectorSidesChanged(v as u32),
c,
));
}
_ => {}
}
@@ -129,28 +248,48 @@ fn vector_settings(ts: &ToolState, settings: &AppSettings, c: ThemeColors) -> ic
col
}
fn select_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
fn select_settings(
settings: &AppSettings,
c: ThemeColors,
) -> iced::widget::Column<'static, Message> {
let ts2 = settings.tool_settings.clone();
column![
section_header("Selection Settings", c),
prop_slider("Feather", ts2.select_feather, 0.0, 250.0, Message::SelectionFeatherChanged, c),
prop_slider(
"Feather",
ts2.select_feather,
0.0,
250.0,
Message::SelectionFeatherChanged,
c
),
iced::widget::checkbox("Anti-alias", ts2.selection_anti_alias)
.on_toggle(|_v| Message::NoOp)
.on_toggle(Message::SelectionAntiAliasToggled)
.size(11),
]
.spacing(4)
}
fn magic_wand_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
fn magic_wand_settings(
settings: &AppSettings,
c: ThemeColors,
) -> iced::widget::Column<'static, Message> {
let ts2 = settings.tool_settings.clone();
column![
section_header("Magic Wand Settings", c),
prop_slider("Tolerance", ts2.magic_wand_tolerance, 0.0, 255.0, |v| Message::SelectionToleranceChanged(v as u8), c),
prop_slider(
"Tolerance",
ts2.magic_wand_tolerance,
0.0,
255.0,
|v| Message::SelectionToleranceChanged(v as u8),
c
),
iced::widget::checkbox("Contiguous", ts2.selection_contiguous)
.on_toggle(Message::SelectionContiguousToggled)
.size(11),
iced::widget::checkbox("Anti-alias", ts2.selection_anti_alias)
.on_toggle(|_v| Message::NoOp)
.on_toggle(Message::SelectionAntiAliasToggled)
.size(11),
]
.spacing(4)
@@ -163,7 +302,9 @@ fn text_settings(
c: ThemeColors,
) -> iced::widget::Column<'static, Message> {
let ts2 = settings.tool_settings.clone();
let draft_font = text_draft.map(|d| d.font.clone()).unwrap_or_else(|| ts2.text_font.clone());
let draft_font = text_draft
.map(|d| d.font.clone())
.unwrap_or_else(|| ts2.text_font.clone());
let draft_size = text_draft.map(|d| d.size).unwrap_or(ts2.text_size);
let draft_angle = text_draft.map(|d| d.angle).unwrap_or(ts2.text_angle);
let draft_color = text_draft.map(|d| d.color).unwrap_or(fg_color);
@@ -187,7 +328,14 @@ fn text_settings(
.spacing(4)
.align_y(iced::Alignment::Center),
prop_slider("Size", draft_size, 4.0, 200.0, Message::TextSizeChanged, c),
prop_slider("Angle", draft_angle, -180.0, 180.0, Message::TextAngleChanged, c),
prop_slider(
"Angle",
draft_angle,
-180.0,
180.0,
Message::TextAngleChanged,
c
),
color_row("Color:", draft_color, c),
row![
text("Content:").size(10).width(Length::Fixed(50.0)),
@@ -200,20 +348,34 @@ fn text_settings(
.align_y(iced::Alignment::Center),
row![
text("Align:").size(10).width(Length::Fixed(50.0)),
button(text("Left").size(9)).on_press(Message::TextAlignChanged("Left".to_string())).padding([2, 4]),
button(text("Center").size(9)).on_press(Message::TextAlignChanged("Center".to_string())).padding([2, 4]),
button(text("Right").size(9)).on_press(Message::TextAlignChanged("Right".to_string())).padding([2, 4]),
button(text("Left").size(9))
.on_press(Message::TextAlignChanged("Left".to_string()))
.padding([2, 4]),
button(text("Center").size(9))
.on_press(Message::TextAlignChanged("Center".to_string()))
.padding([2, 4]),
button(text("Right").size(9))
.on_press(Message::TextAlignChanged("Right".to_string()))
.padding([2, 4]),
]
.spacing(3),
row![
text("Orient:").size(10).width(Length::Fixed(50.0)),
button(text("H").size(9)).on_press(Message::TextOrientationChanged("Horizontal".to_string())).padding([2, 6]),
button(text("V").size(9)).on_press(Message::TextOrientationChanged("Vertical".to_string())).padding([2, 6]),
button(text("H").size(9))
.on_press(Message::TextOrientationChanged("Horizontal".to_string()))
.padding([2, 6]),
button(text("V").size(9))
.on_press(Message::TextOrientationChanged("Vertical".to_string()))
.padding([2, 6]),
]
.spacing(3),
row![
button(text("Commit").size(10)).on_press(Message::TextCommit).padding([4, 10]),
button(text("Cancel").size(10)).on_press(Message::TextCancel).padding([4, 10]),
button(text("Commit").size(10))
.on_press(Message::TextCommit)
.padding([4, 10]),
button(text("Cancel").size(10))
.on_press(Message::TextCancel)
.padding([4, 10]),
]
.spacing(6),
]
@@ -231,7 +393,10 @@ fn move_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column
.spacing(4)
}
fn gradient_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
fn gradient_settings(
settings: &AppSettings,
c: ThemeColors,
) -> iced::widget::Column<'static, Message> {
let ts2 = settings.tool_settings.clone();
let gtype = ts2.gradient_type;
column![
@@ -246,17 +411,34 @@ fn gradient_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Co
.padding([2, 6]),
]
.spacing(3),
prop_slider("Opacity", ts2.gradient_opacity * 100.0, 0.0, 100.0, |v| Message::BrushOpacityChanged(v / 100.0), c),
prop_slider(
"Opacity",
ts2.gradient_opacity * 100.0,
0.0,
100.0,
|v| Message::BrushOpacityChanged(v / 100.0),
c
),
text(if gtype == 1 { "Radial" } else { "Linear" }).size(9),
]
.spacing(4)
}
fn flood_fill_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
fn flood_fill_settings(
settings: &AppSettings,
c: ThemeColors,
) -> iced::widget::Column<'static, Message> {
let ts2 = settings.tool_settings.clone();
column![
section_header("Flood Fill Settings", c),
prop_slider("Tolerance", ts2.flood_fill_tolerance, 0.0, 255.0, |v| Message::SelectionToleranceChanged(v as u8), c),
prop_slider(
"Tolerance",
ts2.flood_fill_tolerance,
0.0,
255.0,
|v| Message::SelectionToleranceChanged(v as u8),
c
),
iced::widget::checkbox("Contiguous", ts2.selection_contiguous)
.on_toggle(Message::SelectionContiguousToggled)
.size(11),
@@ -269,14 +451,25 @@ fn section_header(label: &str, _c: ThemeColors) -> Element<'static, Message> {
row![text(l).size(11)].spacing(4).into()
}
fn prop_slider<F>(label: &str, value: f32, min: f32, max: f32, msg_fn: F, _c: ThemeColors) -> Element<'static, Message>
fn prop_slider<F>(
label: &str,
value: f32,
min: f32,
max: f32,
msg_fn: F,
_c: ThemeColors,
) -> Element<'static, Message>
where
F: Fn(f32) -> Message + 'static,
{
let l = label.to_string();
let label_text = text(l).size(10).width(Length::Fixed(70.0));
let val_text = text(format!("{:.1}", value)).size(10).width(Length::Fixed(40.0));
let sl = slider(min..=max, value, msg_fn).step(0.1).width(Length::Fill);
let val_text = text(format!("{:.1}", value))
.size(10)
.width(Length::Fixed(40.0));
let sl = slider(min..=max, value, msg_fn)
.step(0.1)
.width(Length::Fill);
row![label_text, sl, val_text]
.spacing(4)
@@ -296,15 +489,15 @@ fn color_row(label: &str, color: [u8; 4], _c: ThemeColors) -> Element<'static, M
color[2] as f32 / 255.0,
color[3] as f32 / 255.0,
))),
border: iced::Border::default().rounded(2).color(iced::Color::from_rgb(0.3, 0.3, 0.3)).width(1),
border: iced::Border::default()
.rounded(2)
.color(iced::Color::from_rgb(0.3, 0.3, 0.3))
.width(1),
..Default::default()
});
row![
text(l).size(10).width(Length::Fixed(50.0)),
swatch,
]
.spacing(4)
.align_y(iced::Alignment::Center)
.into()
}
row![text(l).size(10).width(Length::Fixed(50.0)), swatch,]
.spacing(4)
.align_y(iced::Alignment::Center)
.into()
}
@@ -5,6 +5,7 @@
//! Icons are 16px SVGs loaded from assets/icons/.
use crate::app::Message;
use crate::panels::menus::MenuCommand;
use crate::panels::styles;
use crate::theme::ThemeColors;
use hcie_engine_api::Tool;
@@ -12,13 +13,32 @@ use iced::widget::{button, container, row, slider, svg, text};
use iced::{Element, Length};
/// Build the Photopea-style toolbar: SVG icon buttons + blend mode/opacity/flow in one row.
pub fn view<'a>(tool_state: &'a crate::app::ToolState, settings: &'a crate::settings::AppSettings, colors: ThemeColors) -> Element<'a, Message> {
pub fn view<'a>(
tool_state: &'a crate::app::ToolState,
settings: &'a crate::settings::AppSettings,
colors: ThemeColors,
) -> Element<'a, Message> {
let c = colors;
// ── SVG icon buttons (no text) ──
let new_btn = svg_btn("icons/new_file.svg", "New", Message::MenuAction(0, 0), c);
let open_btn = svg_btn("icons/open_file.svg", "Open", Message::MenuAction(0, 1), c);
let save_btn = svg_btn("icons/save_file.svg", "Save", Message::MenuAction(0, 7), c);
let new_btn = svg_btn(
"icons/new_file.svg",
"New",
Message::MenuCommand(MenuCommand::NewDocument),
c,
);
let open_btn = svg_btn(
"icons/open_file.svg",
"Open",
Message::MenuCommand(MenuCommand::OpenFile),
c,
);
let save_btn = svg_btn(
"icons/save_file.svg",
"Save",
Message::MenuCommand(MenuCommand::Save),
c,
);
let undo_btn = svg_btn("icons/undo.svg", "Undo", Message::Undo, c);
let redo_btn = svg_btn("icons/redo.svg", "Redo", Message::Redo, c);
@@ -27,32 +47,45 @@ pub fn view<'a>(tool_state: &'a crate::app::ToolState, settings: &'a crate::sett
.align_y(iced::Alignment::Center);
// ── Blend Mode, Opacity, Flow, Smooth (Photopea-style) ──
let blend_label = text("Blend Mode:").size(10).color(c.text_secondary);
let blend_value = text("Normal").size(10).color(c.text_primary);
let blend_value = compact_value("Normal".to_string(), "Blend mode: Normal", c);
let opacity_value = compact_value(
format!("O {:.0}%", tool_state.brush_opacity * 100.0),
"Brush opacity",
c,
);
let opacity_sl = slider(
0.0..=1.0,
tool_state.brush_opacity,
Message::BrushOpacityChanged,
)
.step(0.01)
.width(Length::Fixed(52.0));
let opacity_label = text("Opacity:").size(10).color(c.text_secondary);
let opacity_value = text(format!("{:.0}%", tool_state.brush_opacity * 100.0)).size(10).color(c.text_primary);
let opacity_sl = slider(0.0..=1.0, tool_state.brush_opacity, Message::BrushOpacityChanged)
.step(0.01)
.width(Length::Fixed(60.0));
let flow_value = compact_value(
format!("F {:.0}%", settings.tool_settings.brush_flow * 100.0),
"Brush flow",
c,
);
let flow_sl = slider(
0.0..=1.0,
settings.tool_settings.brush_flow,
Message::BrushFlowChanged,
)
.step(0.01)
.width(Length::Fixed(52.0));
let flow_label = text("Flow:").size(10).color(c.text_secondary);
let flow_value = text(format!("{:.0}%", settings.tool_settings.brush_flow * 100.0)).size(10).color(c.text_primary);
let flow_sl = slider(0.0..=1.0, settings.tool_settings.brush_flow, Message::BrushFlowChanged)
.step(0.01)
.width(Length::Fixed(60.0));
let smooth_label = text("Smooth:").size(10).color(c.text_secondary);
let smooth_value = text("0%").size(10).color(c.text_primary);
let smooth_value = compact_value("S 0%".to_string(), "Stroke smoothing", c);
let tool_options = row![
blend_label, blend_value,
blend_value,
sep(c),
opacity_label, opacity_value, opacity_sl,
opacity_value,
opacity_sl,
sep(c),
flow_label, flow_value, flow_sl,
flow_value,
flow_sl,
sep(c),
smooth_label, smooth_value,
smooth_value,
]
.spacing(4)
.align_y(iced::Alignment::Center);
@@ -60,52 +93,67 @@ pub fn view<'a>(tool_state: &'a crate::app::ToolState, settings: &'a crate::sett
// ── Tool-specific options (right side) ──
let tool_options_extra = match tool_state.active_tool {
Tool::Brush | Tool::Eraser | Tool::Pen | Tool::Spray => {
let sz = text(format!("Size: {:.0}", tool_state.brush_size))
.size(10)
.width(Length::Fixed(55.0));
let sz_sl = slider(1.0..=200.0, tool_state.brush_size, Message::BrushSizeChanged)
.step(1.0)
.width(Length::Fixed(80.0));
let ha = text(format!("Hardness: {:.0}%", tool_state.brush_hardness * 100.0))
.size(10)
.width(Length::Fixed(70.0));
let ha_sl = slider(0.0..=1.0, tool_state.brush_hardness, Message::BrushHardnessChanged)
.step(0.01)
.width(Length::Fixed(80.0));
row![sz, sz_sl, ha, ha_sl].spacing(4).align_y(iced::Alignment::Center)
let sz = compact_value(format!("{:.0}px", tool_state.brush_size), "Brush size", c);
let sz_sl = slider(
1.0..=200.0,
tool_state.brush_size,
Message::BrushSizeChanged,
)
.step(1.0)
.width(Length::Fixed(58.0));
let ha = compact_value(
format!("H {:.0}%", tool_state.brush_hardness * 100.0),
"Brush hardness",
c,
);
let ha_sl = slider(
0.0..=1.0,
tool_state.brush_hardness,
Message::BrushHardnessChanged,
)
.step(0.01)
.width(Length::Fixed(58.0));
row![sz, sz_sl, ha, ha_sl]
.spacing(4)
.align_y(iced::Alignment::Center)
}
Tool::MagicWand => {
let tol = settings.tool_settings.magic_wand_tolerance;
row![
text("Tolerance:").size(10),
slider(0.0..=255.0, tol, |v| Message::SelectionToleranceChanged(v as u8))
.step(1.0)
.width(Length::Fixed(80.0)),
text(format!("{:.0}", tol)).size(10).width(Length::Fixed(30.0)),
].spacing(6).align_y(iced::Alignment::Center)
compact_value(format!("T {:.0}", tol), "Selection tolerance", c),
slider(0.0..=255.0, tol, |v| Message::SelectionToleranceChanged(
v as u8
))
.step(1.0)
.width(Length::Fixed(64.0)),
]
.spacing(6)
.align_y(iced::Alignment::Center)
}
Tool::Select | Tool::Lasso => {
let feather = settings.tool_settings.select_feather;
row![
text("Feather:").size(10),
compact_value(format!("F {:.0}px", feather), "Selection feather", c),
slider(0.0..=250.0, feather, Message::SelectionFeatherChanged)
.step(1.0)
.width(Length::Fixed(80.0)),
text(format!("{:.0}px", feather)).size(10).width(Length::Fixed(40.0)),
].spacing(6).align_y(iced::Alignment::Center)
}
Tool::Move => {
row![text("Auto-Select").size(10)].spacing(4).align_y(iced::Alignment::Center)
.width(Length::Fixed(64.0)),
]
.spacing(6)
.align_y(iced::Alignment::Center)
}
Tool::Move => row![compact_value("Auto".to_string(), "Auto-select layer", c)]
.spacing(4)
.align_y(iced::Alignment::Center),
Tool::VectorLine | Tool::VectorRect | Tool::VectorCircle => {
let stroke = settings.tool_settings.vector_stroke_width;
row![
text("Stroke:").size(10),
compact_value(format!("{:.1}px", stroke), "Vector stroke width", c),
slider(0.0..=50.0, stroke, Message::VectorStrokeChanged)
.step(0.5)
.width(Length::Fixed(60.0)),
text(format!("{:.1}px", stroke)).size(10).width(Length::Fixed(40.0)),
].spacing(4).align_y(iced::Alignment::Center)
.width(Length::Fixed(58.0)),
]
.spacing(4)
.align_y(iced::Alignment::Center)
}
_ => row![].spacing(0),
};
@@ -129,7 +177,12 @@ pub fn view<'a>(tool_state: &'a crate::app::ToolState, settings: &'a crate::sett
}
/// SVG icon button (16px icon, no text).
fn svg_btn(icon_path: &str, tip: &str, msg: Message, colors: ThemeColors) -> Element<'static, Message> {
fn svg_btn(
icon_path: &str,
tip: &str,
msg: Message,
colors: ThemeColors,
) -> Element<'static, Message> {
let c = colors;
let tip_owned = tip.to_string();
@@ -144,9 +197,11 @@ fn svg_btn(icon_path: &str, tip: &str, msg: Message, colors: ThemeColors) -> Ele
svg(handle)
.width(16)
.height(16)
.style(move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style {
color: Some(c.text_primary),
})
.style(
move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style {
color: Some(c.text_primary),
},
)
.into()
} else {
// Fallback to text
@@ -154,25 +209,22 @@ fn svg_btn(icon_path: &str, tip: &str, msg: Message, colors: ThemeColors) -> Ele
};
iced::widget::tooltip(
button(icon)
.on_press(msg)
.padding([2, 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_hover)),
text_color: c.text_primary,
border: iced::Border::default().color(c.border_low).width(1),
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::TRANSPARENT)),
text_color: c.text_primary,
border: iced::Border::default(),
..Default::default()
},
}
}),
button(icon).on_press(msg).padding([2, 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_hover)),
text_color: c.text_primary,
border: iced::Border::default().color(c.border_low).width(1),
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::TRANSPARENT)),
text_color: c.text_primary,
border: iced::Border::default(),
..Default::default()
},
},
),
text(tip_owned).size(11),
iced::widget::tooltip::Position::Bottom,
)
@@ -185,8 +237,8 @@ fn small_btn(label: &str, colors: ThemeColors) -> Element<'static, Message> {
button(text(l).size(10))
.on_press(Message::NoOp)
.padding([2, 6])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
match status {
.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,
@@ -199,8 +251,8 @@ fn small_btn(label: &str, colors: ThemeColors) -> Element<'static, Message> {
border: iced::Border::default().color(c.border_low).width(1),
..Default::default()
},
}
})
},
)
.into()
}
@@ -213,3 +265,13 @@ fn sep(colors: ThemeColors) -> Element<'static, Message> {
})
.into()
}
/// Displays a compact toolbar value while moving its full meaning into a tooltip.
fn compact_value(value: String, tip: &str, colors: ThemeColors) -> Element<'static, Message> {
iced::widget::tooltip(
container(text(value).size(10).color(colors.text_primary)).padding([1, 3]),
text(tip.to_string()).size(11),
iced::widget::tooltip::Position::Bottom,
)
.into()
}
@@ -0,0 +1,12 @@
//! Shared typography metrics for docked panels.
//!
//! Purpose: Keep panel body, section, and dock-title text readable and consistent.
//! Logic & Workflow: Panel views use these constants instead of local pixel sizes so narrow
//! layouts can increase readability without diverging. Side Effects / Dependencies: None.
/// Standard readable body text size used by panel labels and values.
pub const BODY: u16 = 12;
/// Emphasized section heading size used inside panel content.
pub const SECTION: u16 = 13;
/// Dock title size used by pane title bars.
pub const TITLE: u16 = 13;
@@ -0,0 +1,62 @@
//! Pure raster operations owned by the Iced GUI layer.
//!
//! **Purpose:** Implements GUI-side pixel workflows that can be submitted through the public
//! engine pixel API without importing engine internals. **Logic & Workflow:** Functions transform
//! caller-owned RGBA buffers deterministically and leave history/commit policy to the application.
//! **Side Effects / Dependencies:** None beyond mutation of explicitly supplied buffers.
/// Applies a linear or radial two-color gradient to an RGBA canvas.
///
/// **Arguments:** `pixels` is canvas-sized RGBA data; dimensions and endpoints are canvas-space;
/// `radial` selects distance-from-start instead of projection; `start_color`/`end_color` are RGBA;
/// `opacity` blends the gradient over existing pixels; `mask` optionally limits affected pixels.
/// **Returns:** Nothing. **Side Effects:** Mutates `pixels` only.
#[allow(clippy::too_many_arguments)]
pub fn apply_gradient(
pixels: &mut [u8],
width: u32,
height: u32,
start: (f32, f32),
end: (f32, f32),
radial: bool,
start_color: [u8; 4],
end_color: [u8; 4],
opacity: f32,
mask: Option<&[u8]>,
) {
if pixels.len() != (width * height * 4) as usize {
return;
}
let dx = end.0 - start.0;
let dy = end.1 - start.1;
let length_sq = dx * dx + dy * dy;
if length_sq <= 0.000_1 {
return;
}
let length = length_sq.sqrt();
let opacity = opacity.clamp(0.0, 1.0);
for y in 0..height {
for x in 0..width {
let index = (y * width + x) as usize;
if mask.is_some_and(|mask| mask.get(index).copied().unwrap_or(0) == 0) {
continue;
}
let px = x as f32 + 0.5 - start.0;
let py = y as f32 + 0.5 - start.1;
let t = if radial {
px.hypot(py) / length
} else {
(px * dx + py * dy) / length_sq
}
.clamp(0.0, 1.0);
let destination = index * 4;
for channel in 0..4 {
let gradient =
start_color[channel] as f32 * (1.0 - t) + end_color[channel] as f32 * t;
pixels[destination + channel] =
(pixels[destination + channel] as f32 * (1.0 - opacity) + gradient * opacity)
.round() as u8;
}
}
}
}
@@ -0,0 +1,148 @@
//! Native Iced screenshot persistence helpers.
//!
//! **Purpose:** Saves renderer-provided RGBA screenshots as PNG files without interacting with
//! document pixels or the protected canvas texture pipeline.
//! **Logic & Workflow:** The output directory is created when needed, byte dimensions are
//! validated by the image crate, and the RGBA buffer is encoded directly to PNG.
//! **Side Effects / Dependencies:** Creates parent directories and writes one output file.
use iced::window::Screenshot;
use iced::{Rectangle, Size};
use std::path::Path;
const TITLE_BAR_HEIGHT: f32 = crate::panels::title_bar::MENU_BAR_HEIGHT;
const TOOLBAR_HEIGHT: f32 = 28.0;
const STATUS_BAR_HEIGHT: f32 = 20.0;
const TOOLBOX_DOCK_SPACING: f32 = 2.0;
const PANE_SPACING: f32 = 4.0;
/// Saves a native Iced window screenshot as an RGBA PNG.
///
/// **Arguments:** `screenshot` contains physical-pixel RGBA bytes; `output` is the destination.
/// **Returns:** `Ok(())` after a complete write, or a descriptive filesystem/encoding error.
/// **Side Effects / Dependencies:** May create `output`'s parent directory and overwrite the PNG.
pub fn save_png(screenshot: &Screenshot, output: &Path) -> Result<(), String> {
if let Some(parent) = output
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
std::fs::create_dir_all(parent)
.map_err(|error| format!("failed to create screenshot directory: {error}"))?;
}
image::save_buffer(
output,
screenshot.as_ref(),
screenshot.size.width,
screenshot.size.height,
image::ColorType::Rgba8,
)
.map_err(|error| format!("failed to save screenshot: {error}"))
}
/// Crops a screenshot to the current region of an exact named dock pane.
///
/// **Arguments:** `screenshot` is the complete physical viewport, `dock` contains the live split
/// tree, `panel` is a case-sensitive pane label, and `sidebar_expanded` selects toolbox width.
/// **Returns:** A physical-pixel screenshot containing the complete pane, or a descriptive error.
/// **Logic & Workflow:** PaneGrid computes logical regions from its live ratios; shell offsets are
/// added before converting outward to physical pixels with the screenshot scale factor.
/// **Side Effects / Dependencies:** None; this does not mutate UI or renderer state.
pub fn crop_panel(
screenshot: &Screenshot,
dock: &crate::dock::state::DockState,
panel: &str,
sidebar_expanded: bool,
) -> Result<Screenshot, String> {
let pane_type = crate::dock::state::PaneType::from_label(panel)
.ok_or_else(|| format!("unknown screenshot panel: {panel}"))?;
let pane = dock
.pane_grid
.iter()
.find_map(|(pane, current)| (*current == pane_type).then_some(*pane))
.ok_or_else(|| format!("screenshot panel is not open: {panel}"))?;
let scale = screenshot.scale_factor as f32;
let viewport = Size::new(
screenshot.size.width as f32 / scale,
screenshot.size.height as f32 / scale,
);
let dock_x = (if sidebar_expanded { 72.0 } else { 36.0 }) + TOOLBOX_DOCK_SPACING;
let dock_y = TITLE_BAR_HEIGHT + TOOLBAR_HEIGHT;
let dock_size = Size::new(
(viewport.width - dock_x).max(0.0),
(viewport.height - dock_y - STATUS_BAR_HEIGHT).max(0.0),
);
let logical = dock
.pane_grid
.layout()
.pane_regions(PANE_SPACING, dock_size)
.remove(&pane)
.ok_or_else(|| format!("no layout region found for screenshot panel: {panel}"))?;
let physical = physical_crop_rect(
Rectangle {
x: logical.x + dock_x,
y: logical.y + dock_y,
..logical
},
scale,
screenshot.size,
)?;
screenshot
.crop(physical)
.map_err(|error| format!("failed to crop screenshot panel {panel}: {error}"))
}
/// Converts logical widget bounds to a clamped physical-pixel rectangle.
fn physical_crop_rect(
logical: Rectangle,
scale: f32,
viewport: Size<u32>,
) -> Result<Rectangle<u32>, String> {
let x = (logical.x * scale).floor().max(0.0) as u32;
let y = (logical.y * scale).floor().max(0.0) as u32;
let right = ((logical.x + logical.width) * scale)
.ceil()
.clamp(0.0, viewport.width as f32) as u32;
let bottom = ((logical.y + logical.height) * scale)
.ceil()
.clamp(0.0, viewport.height as f32) as u32;
if right <= x || bottom <= y {
return Err("screenshot panel has an empty visible region".to_string());
}
Ok(Rectangle {
x,
y,
width: right - x,
height: bottom - y,
})
}
#[cfg(test)]
mod tests {
use super::physical_crop_rect;
use iced::{Rectangle, Size};
/// Confirms that scaling rounds outward and clamps to the viewport.
#[test]
fn physical_crop_scales_and_clamps() {
let crop = physical_crop_rect(
Rectangle {
x: 10.25,
y: 5.5,
width: 100.5,
height: 50.25,
},
2.0,
Size::new(200, 100),
)
.unwrap();
assert_eq!(
crop,
Rectangle {
x: 20,
y: 11,
width: 180,
height: 89
}
);
}
}
@@ -98,7 +98,10 @@ fn find_layer_id(engine: &mut Engine, name_or_index: &str) -> Option<u64> {
pub fn execute_actions(engine: &mut Engine, actions: &[ScriptAction]) {
for action in actions {
match action {
ScriptAction::CreateLayer { name, layer_type: _ } => {
ScriptAction::CreateLayer {
name,
layer_type: _,
} => {
engine.add_layer(name);
}
ScriptAction::SelectLayer { name_or_index } => {
@@ -106,12 +109,18 @@ pub fn execute_actions(engine: &mut Engine, actions: &[ScriptAction]) {
engine.set_active_layer(id);
}
}
ScriptAction::SetLayerOpacity { name_or_index, opacity } => {
ScriptAction::SetLayerOpacity {
name_or_index,
opacity,
} => {
if let Some(id) = find_layer_id(engine, name_or_index) {
engine.set_layer_opacity(id, *opacity);
}
}
ScriptAction::SetLayerBlendMode { name_or_index, blend_mode } => {
ScriptAction::SetLayerBlendMode {
name_or_index,
blend_mode,
} => {
if let Some(id) = find_layer_id(engine, name_or_index) {
let bm = parse_blend_mode(blend_mode);
engine.set_layer_blend_mode(id, bm);
@@ -166,10 +175,8 @@ pub fn execute_actions(engine: &mut Engine, actions: &[ScriptAction]) {
tip.hardness = *hardness;
engine.set_brush_tip(tip);
let stroke_points: Vec<(f32, f32, f32)> = points
.iter()
.map(|p| (p[0], p[1], 1.0))
.collect();
let stroke_points: Vec<(f32, f32, f32)> =
points.iter().map(|p| (p[0], p[1], 1.0)).collect();
engine.draw_stroke(&stroke_points);
}
ScriptAction::FillRasterRect {
@@ -224,7 +231,10 @@ pub fn execute_actions(engine: &mut Engine, actions: &[ScriptAction]) {
engine.delete_layer(id);
}
}
ScriptAction::RenameLayer { name_or_index, new_name } => {
ScriptAction::RenameLayer {
name_or_index,
new_name,
} => {
if let Some(id) = find_layer_id(engine, name_or_index) {
engine.rename_layer(id, new_name);
}
@@ -83,7 +83,11 @@ fn tokenize(s: &str) -> Result<Vec<Token>, String> {
Ok(tokens)
}
fn parse_expression(tokens: &[Token], pos: usize, vars: &std::collections::HashMap<String, f32>) -> Result<(f32, usize), String> {
fn parse_expression(
tokens: &[Token],
pos: usize,
vars: &std::collections::HashMap<String, f32>,
) -> Result<(f32, usize), String> {
let (mut left, mut pos) = parse_term(tokens, pos, vars)?;
while pos < tokens.len() {
match &tokens[pos] {
@@ -103,7 +107,11 @@ fn parse_expression(tokens: &[Token], pos: usize, vars: &std::collections::HashM
Ok((left, pos))
}
fn parse_term(tokens: &[Token], pos: usize, vars: &std::collections::HashMap<String, f32>) -> Result<(f32, usize), String> {
fn parse_term(
tokens: &[Token],
pos: usize,
vars: &std::collections::HashMap<String, f32>,
) -> Result<(f32, usize), String> {
let (mut left, mut pos) = parse_factor(tokens, pos, vars)?;
while pos < tokens.len() {
match &tokens[pos] {
@@ -126,7 +134,11 @@ fn parse_term(tokens: &[Token], pos: usize, vars: &std::collections::HashMap<Str
Ok((left, pos))
}
fn parse_factor(tokens: &[Token], pos: usize, vars: &std::collections::HashMap<String, f32>) -> Result<(f32, usize), String> {
fn parse_factor(
tokens: &[Token],
pos: usize,
vars: &std::collections::HashMap<String, f32>,
) -> Result<(f32, usize), String> {
if pos >= tokens.len() {
return Err("unexpected end of expression".to_string());
}
@@ -188,12 +200,28 @@ fn eval_expr(expr: &str, vars: &std::collections::HashMap<String, f32>) -> Resul
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum ScriptAction {
CreateLayer { name: String, layer_type: String },
DeleteLayer { name_or_index: String },
SelectLayer { name_or_index: String },
RenameLayer { name_or_index: String, new_name: String },
SetLayerOpacity { name_or_index: String, opacity: f32 },
SetLayerBlendMode { name_or_index: String, blend_mode: String },
CreateLayer {
name: String,
layer_type: String,
},
DeleteLayer {
name_or_index: String,
},
SelectLayer {
name_or_index: String,
},
RenameLayer {
name_or_index: String,
new_name: String,
},
SetLayerOpacity {
name_or_index: String,
opacity: f32,
},
SetLayerBlendMode {
name_or_index: String,
blend_mode: String,
},
PaintStroke {
points: Vec<[f32; 2]>,
color: String,
@@ -210,7 +238,9 @@ pub enum ScriptAction {
spacing: Option<f32>,
hardness: Option<f32>,
},
SetForegroundColor { hex: String },
SetForegroundColor {
hex: String,
},
FillRasterRect {
x1: f32,
y1: f32,
@@ -225,7 +255,9 @@ pub enum ScriptAction {
stroke_color: String,
stroke_width: f32,
},
Finish { summary: String },
Finish {
summary: String,
},
}
// ── Helper functions ────────────────────────────────────────────────────
@@ -234,7 +266,11 @@ fn to_norm(coord: f32, canvas_dim: f32) -> f32 {
(coord / canvas_dim).clamp(0.0, 1.0)
}
fn parse_coord(s: &str, vars: &std::collections::HashMap<String, f32>, canvas: f32) -> Result<f32, String> {
fn parse_coord(
s: &str,
vars: &std::collections::HashMap<String, f32>,
canvas: f32,
) -> Result<f32, String> {
let val = eval_expr(s, vars)?;
Ok(to_norm(val, canvas))
}
@@ -263,13 +299,25 @@ fn interpolate_hex(hex1: &str, hex2: &str, t: f32) -> Result<String, String> {
Ok(format!("#{:02X}{:02X}{:02X}", r, g, b))
}
fn bezier_sample(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], p3: [f32; 2], steps: usize) -> Vec<[f32; 2]> {
fn bezier_sample(
p0: [f32; 2],
p1: [f32; 2],
p2: [f32; 2],
p3: [f32; 2],
steps: usize,
) -> Vec<[f32; 2]> {
let mut pts = Vec::with_capacity(steps + 1);
for i in 0..=steps {
let t = i as f32 / steps as f32;
let u = 1.0 - t;
let x = u * u * u * p0[0] + 3.0 * u * u * t * p1[0] + 3.0 * u * t * t * p2[0] + t * t * t * p3[0];
let y = u * u * u * p0[1] + 3.0 * u * u * t * p1[1] + 3.0 * u * t * t * p2[1] + t * t * t * p3[1];
let x = u * u * u * p0[0]
+ 3.0 * u * u * t * p1[0]
+ 3.0 * u * t * t * p2[0]
+ t * t * t * p3[0];
let y = u * u * u * p0[1]
+ 3.0 * u * u * t * p1[1]
+ 3.0 * u * t * t * p2[1]
+ t * t * t * p3[1];
pts.push([x, y]);
}
pts
@@ -370,7 +418,11 @@ fn strip_brackets(s: &str) -> &str {
// ── Line Parser ──────────────────────────────────────────────────────────
fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -> Result<Vec<ScriptAction>, String> {
fn parse_single_line(
state: &mut ScriptParserState,
line: &str,
line_num: u64,
) -> Result<Vec<ScriptAction>, String> {
let err = |msg: &str| format!("Line {}: {}", line_num, msg);
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.is_empty() {
@@ -387,15 +439,9 @@ fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -
layer_type: "raster".to_string(),
}])
}
"vector_layer" => {
if parts.len() < 2 {
return Err(err("vector layer name missing"));
}
Ok(vec![ScriptAction::CreateLayer {
name: parts[1..].join(" "),
layer_type: "vector".to_string(),
}])
}
"vector_layer" => Err(err(
"vector_layer is unavailable in the public engine API; use layer",
)),
"select_layer" => {
if parts.len() < 2 {
return Err(err("layer name missing"));
@@ -405,7 +451,8 @@ fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -
}])
}
"layer_opacity" => {
let val = eval_expr(parts.get(1).unwrap_or(&"1.0"), &state.vars).map_err(|e| err(&e))?;
let val =
eval_expr(parts.get(1).unwrap_or(&"1.0"), &state.vars).map_err(|e| err(&e))?;
state.opacity = val.clamp(0.0, 1.0);
Ok(vec![ScriptAction::SetLayerOpacity {
name_or_index: "0".to_string(),
@@ -439,8 +486,11 @@ fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -
if parts.len() < 2 {
return Err(err("color hex missing"));
}
hex_to_rgba_components(parts[1]).map_err(|error| err(&error))?;
state.color = parts[1].to_string();
Ok(vec![ScriptAction::SetForegroundColor { hex: state.color.clone() }])
Ok(vec![ScriptAction::SetForegroundColor {
hex: state.color.clone(),
}])
}
"size" => {
state.size = eval_expr(parts.get(1).unwrap_or(&"20"), &state.vars)
@@ -530,12 +580,16 @@ fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -
"set" => {
let mut actions = Vec::new();
for kv in &parts[1..] {
let (key, val) =
kv.split_once('=').ok_or_else(|| err("set syntax: key=value"))?;
let (key, val) = kv
.split_once('=')
.ok_or_else(|| err("set syntax: key=value"))?;
match key.to_lowercase().as_str() {
"color" => {
hex_to_rgba_components(val).map_err(|error| err(&error))?;
state.color = val.to_string();
actions.push(ScriptAction::SetForegroundColor { hex: state.color.clone() });
actions.push(ScriptAction::SetForegroundColor {
hex: state.color.clone(),
});
}
"brush" | "style" => {
state.style = val.to_string();
@@ -548,8 +602,7 @@ fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -
});
}
"size" => {
state.size =
eval_expr(val, &state.vars).map_err(|e| err(&e))?.max(1.0);
state.size = eval_expr(val, &state.vars).map_err(|e| err(&e))?.max(1.0);
actions.push(ScriptAction::SetBrush {
size: Some(state.size),
style: None,
@@ -726,7 +779,7 @@ fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -
}])
}
"bezier" | "curve" => {
if parts.len() < 6 {
if parts.len() < 5 {
return Err(err("bezier expects x1,y1 cx1,cy1 cx2,cy2 x2,y2 [steps]"));
}
let parsed: Result<Vec<[f32; 2]>, String> = parts[1..5]
@@ -797,7 +850,10 @@ fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -
}
let points = pseudo_scatter(count, bx, by, bw, bh);
let mut npts: Vec<[f32; 2]> = points.iter().map(|pt| [to_norm(pt[0], 800.0), to_norm(pt[1], 600.0)]).collect();
let mut npts: Vec<[f32; 2]> = points
.iter()
.map(|pt| [to_norm(pt[0], 800.0), to_norm(pt[1], 600.0)])
.collect();
apply_wiggle(&mut npts, state.wiggle);
Ok(vec![ScriptAction::PaintStroke {
@@ -825,7 +881,8 @@ fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -
let y2 = parse_coord_abs(p2[1], &state.vars)?;
let steps = eval_expr(strip_brackets(parts[parts.len() - 1]), &state.vars)
.map_err(|e| err(&format!("gradient step count: {}", e)))? as usize;
.map_err(|e| err(&format!("gradient step count: {}", e)))?
as usize;
let color_stops: Vec<&str> = parts[3..parts.len() - 1].to_vec();
if color_stops.len() < 2 {
@@ -930,7 +987,8 @@ pub fn parse_dsl_script(script_text: &str) -> Result<Vec<ScriptAction>, String>
rest
};
let count = eval_expr(count_str.trim(), &state.vars)
.map_err(|e| format!("Line {}: repeat count: {}", line_num, e))? as usize;
.map_err(|e| format!("Line {}: repeat count: {}", line_num, e))?
as usize;
if count == 0 {
continue;
}
@@ -1010,7 +1068,9 @@ pub fn suggest_fix(err: &str) -> Option<&'static str> {
Some("bezier x1,y1 cx1,cy1 cx2,cy2 x2,y2 [steps] — 4 control points")
} else if err.contains("draw needs at least 2") {
Some("draw x1,y1 x2,y2 ... — provide at least 2 coordinate pairs")
} else if err.contains("invalid point") || (err.contains("invalid") && err.contains("coordinates")) {
} else if err.contains("invalid point")
|| (err.contains("invalid") && err.contains("coordinates"))
{
Some("Coordinates must be comma-separated without spaces: 100,200 not '100, 200'")
} else if err.contains("set syntax") {
Some("set key=value key=value — e.g. set brush=oil size=30 opacity=0.8")
@@ -124,11 +124,46 @@ pub fn composite_floating_to_layer(
let src_a = floating.pixels[src_idx + 3] as f32 / 255.0;
let inv_a = 1.0 - src_a;
layer_pixels[dst_idx] = (floating.pixels[src_idx] as f32 * src_a + layer_pixels[dst_idx] as f32 * inv_a) as u8;
layer_pixels[dst_idx + 1] = (floating.pixels[src_idx + 1] as f32 * src_a + layer_pixels[dst_idx + 1] as f32 * inv_a) as u8;
layer_pixels[dst_idx + 2] = (floating.pixels[src_idx + 2] as f32 * src_a + layer_pixels[dst_idx + 2] as f32 * inv_a) as u8;
layer_pixels[dst_idx + 3] = ((src_a * 255.0 + layer_pixels[dst_idx + 3] as f32 * inv_a).min(255.0)) as u8;
layer_pixels[dst_idx] = (floating.pixels[src_idx] as f32 * src_a
+ layer_pixels[dst_idx] as f32 * inv_a)
as u8;
layer_pixels[dst_idx + 1] = (floating.pixels[src_idx + 1] as f32 * src_a
+ layer_pixels[dst_idx + 1] as f32 * inv_a)
as u8;
layer_pixels[dst_idx + 2] = (floating.pixels[src_idx + 2] as f32 * src_a
+ layer_pixels[dst_idx + 2] as f32 * inv_a)
as u8;
layer_pixels[dst_idx + 3] =
((src_a * 255.0 + layer_pixels[dst_idx + 3] as f32 * inv_a).min(255.0)) as u8;
}
}
}
}
/// Places clipboard pixels as a floating transform centered in the current canvas.
///
/// **Arguments:** `pixels`, `width`, and `height` describe RGBA clipboard content; `canvas_w` and
/// `canvas_h` describe the destination. **Returns:** A transform clipped only during final apply,
/// allowing oversized clipboard images to remain movable. **Side Effects / Dependencies:** None.
pub fn centered_transform(
pixels: Vec<u8>,
width: u32,
height: u32,
canvas_w: u32,
canvas_h: u32,
) -> Option<SelectionTransform> {
if width == 0 || height == 0 || pixels.len() != (width * height * 4) as usize {
return None;
}
Some(SelectionTransform {
pixels,
width,
height,
pos: iced::Vector::new(
(canvas_w as f32 - width as f32) * 0.5,
(canvas_h as f32 - height as f32) * 0.5,
),
size: iced::Vector::new(width as f32, height as f32),
rotation: 0.0,
})
}
@@ -14,9 +14,17 @@ pub struct CropState {
pub drag_start: Option<(f32, f32)>,
/// Whether crop overlay is active
pub active: bool,
/// Canvas dimensions used to clamp the crop and restore a valid lifecycle.
pub canvas_size: Option<(u32, u32)>,
}
impl CropState {
/// Starts a crop constrained to the supplied canvas dimensions.
pub fn start_drag_clamped(&mut self, x: f32, y: f32, width: u32, height: u32) {
self.canvas_size = Some((width, height));
self.start_drag(x.clamp(0.0, width as f32), y.clamp(0.0, height as f32));
}
/// Begin a new crop drag at the given canvas position.
pub fn start_drag(&mut self, x: f32, y: f32) {
self.is_dragging = true;
@@ -28,6 +36,9 @@ impl CropState {
/// Update the crop rectangle during a drag.
pub fn update_drag(&mut self, x: f32, y: f32) {
if let Some((start_x, start_y)) = self.drag_start {
let (x, y) = self.canvas_size.map_or((x, y), |(width, height)| {
(x.clamp(0.0, width as f32), y.clamp(0.0, height as f32))
});
let min_x = start_x.min(x);
let min_y = start_y.min(y);
let w = (x - start_x).abs();
@@ -62,5 +73,6 @@ impl CropState {
self.rect = None;
self.is_dragging = false;
self.drag_start = None;
self.canvas_size = None;
}
}
@@ -8,7 +8,9 @@
pub mod clipboard;
pub mod crop;
pub mod state;
pub mod transform;
pub use crop::CropState;
pub use state::SelectionMode;
pub use transform::{SelectionTransform, TransformHandle};
@@ -0,0 +1,97 @@
//! Pure selection-mask state operations used by the Iced GUI.
//!
//! **Purpose:** Keeps selection combination, bounds, and irregular fill extraction independent
//! from widgets and the engine implementation. **Logic & Workflow:** New masks are combined with
//! the previous mask according to the active mode, then scanned for exact non-zero bounds and
//! horizontal spans. **Side Effects / Dependencies:** None; all functions are deterministic and
//! operate only on caller-owned slices.
/// Controls how a newly created selection modifies the existing selection mask.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SelectionMode {
/// Replace the old selection with the new mask.
#[default]
Replace,
/// Add newly selected coverage to existing coverage.
Add,
/// Remove newly selected coverage from existing coverage.
Subtract,
/// Keep only coverage shared by both masks.
Intersect,
}
/// Combines a newly generated mask with an optional previous mask.
///
/// **Arguments:** `previous` is the mask active before the operation, `new_mask` is the engine
/// result, and `mode` selects replace/add/subtract/intersect semantics. **Returns:** A mask with
/// the same length as `new_mask`. **Side Effects / Dependencies:** None.
pub fn combine_masks(previous: Option<&[u8]>, new_mask: &[u8], mode: SelectionMode) -> Vec<u8> {
let Some(previous) = previous.filter(|mask| mask.len() == new_mask.len()) else {
return new_mask.to_vec();
};
new_mask
.iter()
.zip(previous)
.map(|(&new, &old)| match mode {
SelectionMode::Replace => new,
SelectionMode::Add => old.max(new),
SelectionMode::Subtract => old.saturating_sub(new),
SelectionMode::Intersect => old.min(new),
})
.collect()
}
/// Finds the exact non-zero bounding rectangle of a selection mask.
///
/// **Arguments:** `mask` is one alpha byte per pixel and `width`/`height` are its dimensions.
/// **Returns:** `(x, y, width, height)`, or `None` for invalid/empty masks. **Side Effects:** None.
pub fn mask_bounds(mask: &[u8], width: u32, height: u32) -> Option<(u32, u32, u32, u32)> {
if width == 0 || height == 0 || mask.len() != (width * height) as usize {
return None;
}
let mut min_x = width;
let mut min_y = height;
let mut max_x = 0;
let mut max_y = 0;
let mut found = false;
for y in 0..height {
for x in 0..width {
if mask[(y * width + x) as usize] != 0 {
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
found = true;
}
}
}
found.then_some((min_x, min_y, max_x - min_x + 1, max_y - min_y + 1))
}
/// Compresses selected pixels into horizontal spans for efficient irregular overlay filling.
///
/// **Arguments:** `mask` is one alpha byte per pixel and `width`/`height` are its dimensions.
/// **Returns:** Inclusive-exclusive `(x_start, y, x_end)` spans for alpha values above 127.
/// **Side Effects / Dependencies:** None.
pub fn selected_spans(mask: &[u8], width: u32, height: u32) -> Vec<(u32, u32, u32)> {
if width == 0 || height == 0 || mask.len() != (width * height) as usize {
return Vec::new();
}
let mut spans = Vec::new();
for y in 0..height {
let mut x = 0;
while x < width {
while x < width && mask[(y * width + x) as usize] <= 127 {
x += 1;
}
let start = x;
while x < width && mask[(y * width + x) as usize] > 127 {
x += 1;
}
if start < x {
spans.push((start, y, x));
}
}
}
spans
}
@@ -70,7 +70,11 @@ impl SelectionTransform {
if src_idx + 3 < comp.len() && dst_idx + 3 < pixels.len() {
let mask_idx = ((src_y * canvas_w + src_x) * 4 + 3) as usize;
let alpha = if mask_idx < mask.len() { mask[mask_idx] } else { 0 };
let alpha = if mask_idx < mask.len() {
mask[mask_idx]
} else {
0
};
pixels[dst_idx] = comp[src_idx];
pixels[dst_idx + 1] = comp[src_idx + 1];
@@ -106,6 +110,102 @@ impl SelectionTransform {
pub fn is_empty(&self) -> bool {
self.pixels.is_empty() || self.width == 0 || self.height == 0
}
/// Hit-tests transform handles in canvas coordinates with screen-constant targets.
///
/// **Arguments:** `x`/`y` are canvas coordinates and `zoom` is the viewport scale.
/// **Returns:** The nearest resize/rotate/move handle or `None`. **Side Effects:** None.
pub fn hit_test(&self, x: f32, y: f32, zoom: f32) -> TransformHandle {
let half = TransformHandle::HANDLE_SIZE / zoom.max(0.01) * 0.5;
let points = [
(TransformHandle::TopLeft, self.pos.x, self.pos.y),
(
TransformHandle::TopRight,
self.pos.x + self.size.x,
self.pos.y,
),
(
TransformHandle::BottomLeft,
self.pos.x,
self.pos.y + self.size.y,
),
(
TransformHandle::BottomRight,
self.pos.x + self.size.x,
self.pos.y + self.size.y,
),
(
TransformHandle::Top,
self.pos.x + self.size.x * 0.5,
self.pos.y,
),
(
TransformHandle::Bottom,
self.pos.x + self.size.x * 0.5,
self.pos.y + self.size.y,
),
(
TransformHandle::Left,
self.pos.x,
self.pos.y + self.size.y * 0.5,
),
(
TransformHandle::Right,
self.pos.x + self.size.x,
self.pos.y + self.size.y * 0.5,
),
];
let rotate_x = self.pos.x + self.size.x * 0.5;
let rotate_y = self.pos.y - TransformHandle::ROTATE_OFFSET / zoom.max(0.01);
if (x - rotate_x).hypot(y - rotate_y) <= half {
return TransformHandle::Rotate;
}
for (handle, hx, hy) in points {
if (x - hx).abs() <= half && (y - hy).abs() <= half {
return handle;
}
}
if x >= self.pos.x
&& x <= self.pos.x + self.size.x
&& y >= self.pos.y
&& y <= self.pos.y + self.size.y
{
TransformHandle::Move
} else {
TransformHandle::None
}
}
/// Enforces positive finite geometry and optionally preserves source aspect ratio.
///
/// **Arguments:** `lock_aspect` applies the source pixel ratio when true. **Returns:** Nothing.
/// **Side Effects:** Mutates only transform position/size and prevents inverted/NaN geometry.
pub fn sanitize_geometry(&mut self, lock_aspect: bool) {
if !self.pos.x.is_finite() || !self.pos.y.is_finite() {
self.pos = Vector::ZERO;
}
if !self.size.x.is_finite() || !self.size.y.is_finite() {
self.size = Vector::new(self.width.max(1) as f32, self.height.max(1) as f32);
}
if self.size.x < 0.0 {
self.pos.x += self.size.x;
self.size.x = -self.size.x;
}
if self.size.y < 0.0 {
self.pos.y += self.size.y;
self.size.y = -self.size.y;
}
self.size.x = self.size.x.max(1.0);
self.size.y = self.size.y.max(1.0);
if lock_aspect && self.width > 0 && self.height > 0 {
let ratio = self.width as f32 / self.height as f32;
if self.size.x / self.size.y > ratio {
self.size.y = self.size.x / ratio;
} else {
self.size.x = self.size.y * ratio;
}
}
}
}
/// Handle for interacting with a selection transform.
@@ -36,6 +36,9 @@ pub struct AppSettings {
/// Saved dock layout profiles.
#[serde(default)]
pub dock_profiles: Vec<DockProfile>,
/// Selected application color preset.
#[serde(default)]
pub theme_preset: crate::theme::ThemePreset,
}
/// Tool-specific settings that persist between sessions.
@@ -98,21 +101,42 @@ pub struct ToolSettings {
pub last_active_tool: String,
}
fn default_vector_fill() -> bool { true }
fn default_vector_points() -> u32 { 5 }
fn default_vector_sides() -> u32 { 6 }
fn default_spray_particle_size() -> f32 { 4.0 }
fn default_spray_density() -> u32 { 80 }
fn default_contiguous() -> bool { true }
fn default_anti_alias() -> bool { true }
fn default_text_font() -> String { "Arial".to_string() }
fn default_apply_to_new_layer() -> bool { true }
fn default_vector_fill() -> bool {
true
}
fn default_vector_points() -> u32 {
5
}
fn default_vector_sides() -> u32 {
6
}
fn default_spray_particle_size() -> f32 {
4.0
}
fn default_spray_density() -> u32 {
80
}
fn default_contiguous() -> bool {
true
}
fn default_anti_alias() -> bool {
true
}
fn default_text_font() -> String {
"Arial".to_string()
}
fn default_apply_to_new_layer() -> bool {
true
}
/// Panel layout positions and visibility.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PanelLayout {
/// Left sidebar width in pixels.
pub sidebar_width: f32,
/// Whether the fixed toolbox sidebar shows labels as well as icons.
#[serde(default)]
pub sidebar_expanded: bool,
/// Right tool settings panel width in pixels.
pub tool_settings_width: f32,
/// Which panels are visible.
@@ -136,6 +160,7 @@ impl Default for AppSettings {
panel_layout: PanelLayout::default(),
window: WindowSettings::default(),
dock_profiles: Vec::new(),
theme_preset: crate::theme::ThemePreset::default(),
}
}
}
@@ -182,6 +207,7 @@ impl Default for PanelLayout {
fn default() -> Self {
Self {
sidebar_width: 36.0,
sidebar_expanded: false,
tool_settings_width: 200.0,
visible_panels: vec![
"Brushes & Tips".to_string(),
@@ -223,15 +249,13 @@ impl AppSettings {
let path = Self::file_path();
if path.exists() {
match std::fs::read_to_string(&path) {
Ok(content) => {
match serde_json::from_str(&content) {
Ok(settings) => settings,
Err(e) => {
log::warn!("Failed to parse settings: {}, using defaults", e);
Self::default()
}
Ok(content) => match serde_json::from_str(&content) {
Ok(settings) => settings,
Err(e) => {
log::warn!("Failed to parse settings: {}, using defaults", e);
Self::default()
}
}
},
Err(e) => {
log::warn!("Failed to read settings: {}, using defaults", e);
Self::default()
@@ -245,7 +269,7 @@ impl AppSettings {
/// Save settings to disk.
pub fn save(&self) -> Result<(), String> {
let path = Self::file_path();
// Create directory if it doesn't exist
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
@@ -255,8 +279,7 @@ impl AppSettings {
let content = serde_json::to_string_pretty(self)
.map_err(|e| format!("Failed to serialize settings: {}", e))?;
std::fs::write(&path, content)
.map_err(|e| format!("Failed to write settings: {}", e))?;
std::fs::write(&path, content).map_err(|e| format!("Failed to write settings: {}", e))?;
log::info!("Settings saved to {}", path.display());
Ok(())
@@ -282,3 +305,27 @@ impl AppSettings {
tool_state.brush_hardness = self.tool_settings.brush_hardness;
}
}
#[cfg(test)]
mod tests {
use super::AppSettings;
use crate::theme::ThemePreset;
/// Proves settings written before Cycle 1 acquire safe theme and sidebar defaults.
#[test]
fn legacy_settings_default_new_cycle_one_fields() {
let mut legacy = serde_json::to_value(AppSettings::default()).expect("serialize defaults");
let object = legacy.as_object_mut().expect("settings object");
object.remove("theme_preset");
object
.get_mut("panel_layout")
.and_then(serde_json::Value::as_object_mut)
.expect("panel layout object")
.remove("sidebar_expanded");
let settings: AppSettings =
serde_json::from_value(legacy).expect("deserialize legacy settings");
assert_eq!(settings.theme_preset, ThemePreset::Photopea);
assert!(!settings.panel_layout.sidebar_expanded);
}
}
@@ -30,11 +30,23 @@ pub fn sync_shape_from_tool(app: &mut HcieIcedApp) {
Some(i) => i,
None => return,
};
if !app.documents[app.active_doc]
.engine
.active_vector_shapes()
.is_some_and(|shapes| shape_idx < shapes.len())
{
return;
}
// Snapshot for undo (before first modification in this edit session)
if app.vector_shapes_snapshot.is_none() {
let snapshot = app.documents[app.active_doc].engine.active_vector_shapes();
app.vector_shapes_snapshot = snapshot;
let doc = &app.documents[app.active_doc];
if let Some(shapes) = doc.engine.active_vector_shapes() {
app.vector_shapes_snapshot = Some(crate::vector_edit::VectorEditSnapshot {
layer_id: doc.engine.active_layer_id(),
shapes,
});
}
}
let layer_id = app.documents[app.active_doc].engine.active_layer_id();
@@ -49,12 +61,16 @@ pub fn sync_shape_from_tool(app: &mut HcieIcedApp) {
if let Some(shapes) = app.documents[app.active_doc].engine.active_vector_shapes() {
if let Some(shape) = shapes.get(shape_idx) {
if shape.fill_color().is_some() || shape.is_filled() != ts.vector_fill {
app.documents[app.active_doc]
.engine
.set_vector_shape_fill(layer_id, shape_idx, ts.vector_fill);
app.documents[app.active_doc].engine.set_vector_shape_fill(
layer_id,
shape_idx,
ts.vector_fill,
);
}
}
}
app.documents[app.active_doc].modified = true;
app.documents[app.active_doc].engine.mark_composite_dirty();
// Opacity
app.documents[app.active_doc]
@@ -107,9 +123,6 @@ pub fn sync_shape_from_tool(app: &mut HcieIcedApp) {
/// Called when the user clicks/selects a shape in VectorSelect mode
/// so the properties panel and tool state reflect the shape's actual values.
pub fn sync_tool_from_shape(app: &mut HcieIcedApp) {
if app.tool_state.active_tool != Tool::VectorSelect {
return;
}
let shape_idx = match app.documents[app.active_doc].selected_vector_shape {
Some(i) => i,
None => return,
@@ -156,6 +169,7 @@ pub fn sync_tool_from_shape(app: &mut HcieIcedApp) {
if let Some(fc) = shape.fill_color() {
app.bg_color = fc;
}
app.colors_dirty = true;
}
}
}
@@ -15,7 +15,7 @@ use crate::app::{Message, ToolState};
use crate::panels::styles;
use crate::theme::ThemeColors;
use hcie_engine_api::Tool;
use iced::widget::{button, column, container, row, svg, text};
use iced::widget::{button, column, container, row, scrollable, svg, text};
use iced::{Element, Length};
/// Tool slot with sub-tools — matches egui's TOOL_SLOTS structure.
@@ -28,36 +28,121 @@ pub struct ToolSlot {
/// All tool slots matching egui's TOOL_SLOTS structure exactly.
/// Each slot can have multiple tools (sub-tools shown on click).
pub const TOOL_SLOTS: &[ToolSlot] = &[
ToolSlot { tools: &[Tool::Move], label: "Move", icon: "icons/move.svg" },
ToolSlot { tools: &[Tool::VectorSelect], label: "Vector Select", icon: "icons/vector_select.svg" },
ToolSlot { tools: &[Tool::Select], label: "Rectangle Select", icon: "icons/rect_select.svg" },
ToolSlot { tools: &[Tool::Lasso, Tool::PolygonSelect], label: "Lasso", icon: "icons/lasso.svg" },
ToolSlot { tools: &[Tool::MagicWand], label: "Magic Wand", icon: "icons/magic_wand.svg" },
ToolSlot { tools: &[Tool::SmartSelect, Tool::VisionSelect], label: "Smart Select", icon: "icons/smart_select.svg" },
ToolSlot { tools: &[Tool::Crop], label: "Crop", icon: "icons/crop.svg" },
ToolSlot { tools: &[Tool::Eyedropper], label: "Eyedropper", icon: "icons/eyedropper.svg" },
ToolSlot { tools: &[Tool::Brush], label: "Brush", icon: "icons/brush.svg" },
ToolSlot { tools: &[Tool::Pen], label: "Pen", icon: "icons/pen.svg" },
ToolSlot { tools: &[Tool::Spray], label: "Spray", icon: "icons/spray.svg" },
ToolSlot { tools: &[Tool::Eraser], label: "Eraser", icon: "icons/eraser.svg" },
ToolSlot { tools: &[Tool::FloodFill], label: "Flood Fill", icon: "icons/bucket.svg" },
ToolSlot { tools: &[Tool::Gradient], label: "Gradient", icon: "icons/gradient.svg" },
ToolSlot { tools: &[Tool::Text], label: "Text", icon: "icons/text.svg" },
ToolSlot { tools: &[Tool::VectorLine], label: "Vector Line", icon: "icons/line.svg" },
ToolSlot { tools: &[Tool::VectorRect], label: "Vector Rectangle", icon: "icons/vector_rect.svg" },
ToolSlot { tools: &[Tool::VectorCircle], label: "Vector Circle", icon: "icons/circle.svg" },
ToolSlot {
tools: &[Tool::Move],
label: "Move",
icon: "icons/move.svg",
},
ToolSlot {
tools: &[Tool::VectorSelect],
label: "Vector Select",
icon: "icons/vector_select.svg",
},
ToolSlot {
tools: &[Tool::Select],
label: "Rectangle Select",
icon: "icons/rect_select.svg",
},
ToolSlot {
tools: &[Tool::Lasso, Tool::PolygonSelect],
label: "Lasso",
icon: "icons/lasso.svg",
},
ToolSlot {
tools: &[Tool::MagicWand],
label: "Magic Wand",
icon: "icons/magic_wand.svg",
},
ToolSlot {
tools: &[Tool::SmartSelect, Tool::VisionSelect],
label: "Smart Select",
icon: "icons/smart_select.svg",
},
ToolSlot {
tools: &[Tool::Crop],
label: "Crop",
icon: "icons/crop.svg",
},
ToolSlot {
tools: &[Tool::Eyedropper],
label: "Eyedropper",
icon: "icons/eyedropper.svg",
},
ToolSlot {
tools: &[Tool::Brush],
label: "Brush",
icon: "icons/brush.svg",
},
ToolSlot {
tools: &[Tool::Pen],
label: "Pen",
icon: "icons/pen.svg",
},
ToolSlot {
tools: &[Tool::Spray],
label: "Spray",
icon: "icons/spray.svg",
},
ToolSlot {
tools: &[Tool::Eraser],
label: "Eraser",
icon: "icons/eraser.svg",
},
ToolSlot {
tools: &[Tool::FloodFill],
label: "Flood Fill",
icon: "icons/bucket.svg",
},
ToolSlot {
tools: &[Tool::Gradient],
label: "Gradient",
icon: "icons/gradient.svg",
},
ToolSlot {
tools: &[Tool::Text],
label: "Text",
icon: "icons/text.svg",
},
ToolSlot {
tools: &[Tool::VectorLine],
label: "Vector Line",
icon: "icons/line.svg",
},
ToolSlot {
tools: &[Tool::VectorRect],
label: "Vector Rectangle",
icon: "icons/vector_rect.svg",
},
ToolSlot {
tools: &[Tool::VectorCircle],
label: "Vector Circle",
icon: "icons/circle.svg",
},
ToolSlot {
tools: &[
Tool::VectorArrow, Tool::VectorStar, Tool::VectorPolygon,
Tool::VectorRhombus, Tool::VectorCylinder, Tool::VectorHeart,
Tool::VectorBubble, Tool::VectorGear, Tool::VectorCross,
Tool::VectorCrescent, Tool::VectorBolt, Tool::VectorArrow4,
Tool::VectorArrow,
Tool::VectorStar,
Tool::VectorPolygon,
Tool::VectorRhombus,
Tool::VectorCylinder,
Tool::VectorHeart,
Tool::VectorBubble,
Tool::VectorGear,
Tool::VectorCross,
Tool::VectorCrescent,
Tool::VectorBolt,
Tool::VectorArrow4,
],
label: "Vector Shapes",
icon: "icons/star.svg",
},
ToolSlot {
tools: &[Tool::SpotRemoval, Tool::RedEyeRemoval, Tool::SmartPatch, Tool::AiObjectRemoval],
tools: &[
Tool::SpotRemoval,
Tool::RedEyeRemoval,
Tool::SmartPatch,
Tool::AiObjectRemoval,
],
label: "Retouch",
icon: "icons/spot.svg",
},
@@ -82,21 +167,55 @@ pub fn view<'a>(
// Tool buttons — single column or 2-column grid depending on expanded state
let mut tool_list = column![].spacing(1);
if expanded {
// 2-column layout: show tools in pairs
// The open subtool slot owns a full row: 34px parent plus 34px popup.
// Other slots continue to use the regular two-column grid.
let mut row_children = iced::widget::Row::new().spacing(1);
let mut row_items = 0;
for (idx, slot) in TOOL_SLOTS.iter().enumerate() {
let btn = tool_button(slot, tool_state, colors, idx, active_slot, sub_tools_open, slot_last_used);
let btn = tool_button(
slot,
tool_state,
colors,
idx,
active_slot,
sub_tools_open,
slot_last_used,
);
let popup_row = sub_tools_open && active_slot == idx && slot.tools.len() > 1;
if popup_row {
if row_items > 0 {
tool_list = tool_list.push(row_children);
row_children = iced::widget::Row::new().spacing(1);
row_items = 0;
}
tool_list = tool_list.push(btn);
continue;
}
row_children = row_children.push(btn);
// Every 2 tools, start a new row
if idx % 2 == 1 || idx == TOOL_SLOTS.len() - 1 {
row_items += 1;
if row_items == 2 {
tool_list = tool_list.push(row_children);
row_children = iced::widget::Row::new().spacing(1);
row_items = 0;
}
}
if row_items > 0 {
tool_list = tool_list.push(row_children);
}
} else {
// Single column layout (default)
for (idx, slot) in TOOL_SLOTS.iter().enumerate() {
let btn = tool_button(slot, tool_state, colors, idx, active_slot, sub_tools_open, slot_last_used);
let btn = tool_button(
slot,
tool_state,
colors,
idx,
active_slot,
sub_tools_open,
slot_last_used,
);
tool_list = tool_list.push(btn);
}
}
@@ -107,95 +226,142 @@ pub fn view<'a>(
let bg = bg_color;
let fg_swatch = container(text(""))
.width(18)
.height(18)
.width(24)
.height(24)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
fg[0] as f32 / 255.0, fg[1] as f32 / 255.0, fg[2] as f32 / 255.0, fg[3] as f32 / 255.0,
fg[0] as f32 / 255.0,
fg[1] as f32 / 255.0,
fg[2] as f32 / 255.0,
fg[3] as f32 / 255.0,
))),
border: iced::Border::default().rounded(2).color(colors.border_high).width(1),
border: iced::Border::default()
.rounded(2)
.color(colors.border_high)
.width(2),
..Default::default()
});
let bg_swatch = container(text(""))
.width(18)
.height(18)
.width(24)
.height(24)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
bg[0] as f32 / 255.0, bg[1] as f32 / 255.0, bg[2] as f32 / 255.0, bg[3] as f32 / 255.0,
bg[0] as f32 / 255.0,
bg[1] as f32 / 255.0,
bg[2] as f32 / 255.0,
bg[3] as f32 / 255.0,
))),
border: iced::Border::default().rounded(2).color(colors.border_high).width(1),
border: iced::Border::default()
.rounded(2)
.color(colors.border_high)
.width(2),
..Default::default()
});
let fg_color_val = *fg_color;
let fg_clickable = iced::widget::tooltip(
iced::widget::mouse_area(fg_swatch).on_press(Message::FgColorChanged(fg_color_val)),
text("Foreground color - click to apply").size(11),
iced::widget::tooltip::Position::Right,
);
// Make the background swatch clickable so a click promotes the background
// color to the foreground (egui: toolbox.rs:355-358).
let bg_color_val = *bg_color;
let bg_clickable = iced::widget::mouse_area(bg_swatch)
.on_press(Message::FgColorChanged(bg_color_val));
let bg_clickable = iced::widget::tooltip(
iced::widget::mouse_area(bg_swatch).on_press(Message::FgColorChanged(bg_color_val)),
text("Background color - click to promote to foreground").size(11),
iced::widget::tooltip::Position::Right,
);
// Small reset-to-defaults button (black/white) below the swatches
// (egui: toolbox.rs:351-354 resets fg=black/bg=white on a bottom-left click).
let reset_btn = button(text("D").size(7).color(colors.text_secondary))
.on_press(Message::ResetColors)
.padding(1)
.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()
let reset_btn = iced::widget::tooltip(
button(text("D").size(10).color(colors.text_secondary))
.on_press(Message::ResetColors)
.padding([1, 3])
.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()
},
},
_ => 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()
},
}
});
),
text("Reset foreground/background colors").size(11),
iced::widget::tooltip::Position::Right,
);
// Photopea-style: fg at top-left, bg at bottom-right (diagonal overlap)
// with a swap arrow button between them
let color_section = container(
iced::widget::Stack::new()
.push(container(bg_clickable).padding(iced::Padding {
top: 10.0,
bottom: 0.0,
left: 8.0,
right: 0.0,
}))
.push(container(fg_clickable).padding(iced::Padding {
top: 0.0,
bottom: 0.0,
left: 0.0,
right: 0.0,
}))
.push(
container(fg_swatch)
.padding(iced::Padding { top: 0.0, bottom: 0.0, left: 4.0, right: 0.0 })
)
.push(
container(bg_clickable)
.padding(iced::Padding { top: 10.0, bottom: 0.0, left: 16.0, right: 0.0 })
)
.push(
container(
button(text("").size(8).color(colors.text_primary))
container(iced::widget::tooltip(
button(text("").size(10).color(colors.text_primary))
.on_press(Message::SwapColors)
.padding(1)
.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_primary,
border: iced::Border::default(),
..Default::default()
},
}
})
)
.padding(iced::Padding { top: 6.0, bottom: 0.0, left: 10.0, right: 0.0 })
)
.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_primary,
border: iced::Border::default(),
..Default::default()
},
}
},
),
text("Swap foreground/background colors").size(11),
iced::widget::tooltip::Position::Right,
))
.padding(iced::Padding {
top: 0.0,
bottom: 0.0,
left: 21.0,
right: 0.0,
}),
),
)
.padding([4, 2])
.width(sidebar_width)
.height(32);
.height(40);
// Separator line
let sep_style = move |_theme: &iced::Theme| iced::widget::container::Style {
@@ -209,8 +375,8 @@ pub fn view<'a>(
.on_press(Message::SidebarToggleExpanded)
.padding(2)
.width(sidebar_width)
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
match status {
.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,
@@ -218,17 +384,21 @@ pub fn view<'a>(
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.0,
))),
text_color: colors.text_secondary,
border: iced::Border::default(),
..Default::default()
},
}
});
},
);
let sidebar = column![
container(tool_list).padding([2, 0]),
container(text("").height(1)).width(Length::Fill).style(sep_style),
scrollable(container(tool_list).padding([2, 0])).height(Length::Fill),
container(text("").height(1))
.width(Length::Fill)
.style(sep_style),
color_section,
container(reset_btn).padding([2, 4]),
container(toggle_btn).padding([2, 0]),
@@ -266,30 +436,41 @@ fn tool_button<'a>(
let represented_tool = slot.tools.get(last_idx).copied().unwrap_or(slot.tools[0]);
// Try to load SVG icon - search multiple paths
let represented_icon = tool_icon(represented_tool);
let icon_paths = [
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)),
std::path::PathBuf::from(represented_icon),
std::path::Path::new("hcie-iced-app/assets").join(represented_icon),
std::path::PathBuf::from(format!(
"/mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-iced-app/assets/{}",
represented_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);
svg(handle)
.width(20)
.height(20)
.style(move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style {
color: Some(icon_color),
})
.into()
let icon_color = if is_active {
colors.accent
} else {
text(slot.label.chars().next().unwrap_or('?').to_string())
.size(14)
.color(icon_color)
.into()
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);
svg(handle)
.width(20)
.height(20)
.style(
move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style {
color: Some(icon_color),
},
)
.into()
} else {
text(slot.label.chars().next().unwrap_or('?').to_string())
.size(14)
.color(icon_color)
.into()
};
// If slot has sub-tools and is active, show sub-tools popup
let has_sub_tools = slot.tools.len() > 1;
let show_popup = has_sub_tools && sub_tools_open && active_slot == slot_idx;
@@ -300,15 +481,15 @@ fn tool_button<'a>(
let btn = iced::widget::button(
container(icon_element)
.width(28)
.width(34)
.height(28)
.center_y(20)
.center_x(20)
.center_x(20),
)
.on_press(Message::ToolSlotClicked(slot_idx, primary_tool))
.padding(0)
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
match status {
.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 {
@@ -319,7 +500,9 @@ fn tool_button<'a>(
}
} else {
iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.0,
))),
border: iced::Border::default(),
text_color: c.text_primary,
..Default::default()
@@ -343,96 +526,236 @@ fn tool_button<'a>(
}
}
}
iced::widget::button::Status::Pressed => {
iced::widget::button::Style {
background: Some(iced::Background::Color(c.accent_green)),
iced::widget::button::Status::Pressed => iced::widget::button::Style {
background: Some(iced::Background::Color(c.accent_green)),
border: iced::Border::default(),
text_color: iced::Color::WHITE,
..Default::default()
},
},
);
let popup_affordance: Element<'a, Message> = if has_sub_tools {
iced::widget::tooltip(
button(text("").size(12).color(colors.text_primary))
.on_press(Message::SubtoolsToggle(slot_idx))
.padding(0)
.width(12)
.height(14)
.style(move |_theme, _status| iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
colors.bg_app.r,
colors.bg_app.g,
colors.bg_app.b,
0.82,
))),
text_color: colors.text_primary,
border: iced::Border::default(),
text_color: iced::Color::WHITE,
..Default::default()
}
}
}
});
}),
text(format!("Open {} sub-tools", slot.label)).size(11),
iced::widget::tooltip::Position::Right,
)
.into()
} else {
iced::widget::Space::with_width(0).into()
};
let btn_element: Element<'a, Message> = iced::widget::Stack::new()
.push(btn)
.push(container(popup_affordance).padding(iced::Padding {
top: 14.0,
bottom: 0.0,
left: 22.0,
right: 0.0,
}))
.width(34)
.height(28)
.into();
// Build the tool button with optional sub-tools popup
if show_popup {
let mut popup_items = column![].spacing(0);
for &tool in slot.tools {
let mut popup_grid = column![].spacing(1);
let mut popup_row = row![].spacing(1);
for (tool_index, &tool) in slot.tools.iter().enumerate() {
let tool_name = tool.label();
let is_tool_active = tool == tool_state.active_tool;
let c = colors;
let item = button(
container(
row![
text(tool_name).size(11).width(Length::Fill),
text(tool_shortcut(tool)).size(10),
]
.spacing(8)
)
.width(150)
.padding([4, 8])
)
.on_press(Message::ToolSelected(tool))
.padding(0)
.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.menu_hover)),
text_color: c.menu_text,
border: iced::Border::default(),
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(c.bg_hover)),
text_color: if is_tool_active { c.accent } else { c.text_primary },
border: iced::Border::default(),
..Default::default()
},
}
});
let popup_icon: Element<'_, Message> =
svg(svg::Handle::from_path(resolve_icon_path(tool_icon(tool))))
.width(18)
.height(18)
.style(move |_theme, _status| svg::Style {
color: Some(if is_tool_active {
iced::Color::WHITE
} else {
c.text_primary
}),
})
.into();
popup_items = popup_items.push(item);
let item = button(container(popup_icon).width(32).height(28).center(18))
.on_press(Message::ToolSelected(tool))
.padding(0)
.width(32)
.height(28)
.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(if is_tool_active {
c.accent_green
} else {
c.bg_hover
})),
text_color: c.text_primary,
border: iced::Border::default()
.color(if is_tool_active {
c.accent
} else {
c.border_high
})
.width(if is_tool_active { 1 } else { 0 }),
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(if is_tool_active {
c.accent_green
} else {
c.bg_active
})),
text_color: c.text_primary,
border: iced::Border::default()
.color(if is_tool_active {
c.accent
} else {
c.border_high
})
.width(if is_tool_active { 1 } else { 0 }),
..Default::default()
},
},
);
let shortcut = tool_shortcut(tool);
let tooltip_text = if shortcut.is_empty() {
tool_name.to_string()
} else {
format!("{tool_name} ({shortcut})")
};
popup_row = popup_row.push(iced::widget::tooltip(
item,
text(tooltip_text).size(11),
iced::widget::tooltip::Position::Right,
));
if (tool_index + 1) % 2 == 0 || tool_index + 1 == slot.tools.len() {
popup_grid = popup_grid.push(popup_row);
popup_row = row![].spacing(1);
}
}
let popup = container(popup_items)
.padding(2)
let popup = container(popup_grid)
.padding(1)
.width(68)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_active)),
border: iced::Border::default().color(colors.border_high).width(1),
shadow: iced::Shadow {
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.4),
offset: iced::Vector::new(2.0, 0.0),
blur_radius: 8.0,
offset: iced::Vector::new(1.0, 0.0),
blur_radius: 5.0,
},
..Default::default()
});
// Position popup to the right of the button
let btn_element: Element<'a, Message> = container(
row![btn, popup].spacing(0).align_y(iced::Alignment::Center)
).into();
let parent = container(iced::widget::tooltip(
btn_element,
iced::widget::text(format!("{} (sub-tools open)", slot.label)).size(11),
iced::widget::tooltip::Position::Right,
))
.width(68);
container(column![parent, popup].spacing(1))
.width(68)
.into()
} else {
// Tooltip on hover
let tooltip_text = format!("{} (click for sub-tools)", slot.label);
let tooltip_text = slot.label.to_string();
iced::widget::tooltip(
btn_element,
iced::widget::text(tooltip_text).size(11),
iced::widget::tooltip::Position::Right,
)
.into()
} else {
// Tooltip on hover
let tooltip_text = slot.label.to_string();
iced::widget::tooltip(
btn,
iced::widget::text(tooltip_text).size(11),
iced::widget::tooltip::Position::Right,
)
.into()
}
}
/// Returns the SVG asset associated with an individual tool, including subtools.
///
/// **Arguments:** `tool` is the concrete tool represented by a toolbox slot or popup row.
/// **Returns:** The crate-relative SVG path for the concrete tool.
/// **Side Effects / Dependencies:** None; callers resolve the returned path against assets.
fn tool_icon(tool: Tool) -> &'static str {
match tool {
Tool::Move => "icons/move.svg",
Tool::VectorSelect => "icons/vector_select.svg",
Tool::Select => "icons/rect_select.svg",
Tool::Lasso | Tool::PolygonSelect => "icons/lasso.svg",
Tool::MagicWand => "icons/magic_wand.svg",
Tool::SmartSelect | Tool::VisionSelect => "icons/smart_select.svg",
Tool::Crop => "icons/crop.svg",
Tool::Eyedropper => "icons/eyedropper.svg",
Tool::Brush => "icons/brush.svg",
Tool::Pen => "icons/pen.svg",
Tool::Spray => "icons/spray.svg",
Tool::Eraser => "icons/eraser.svg",
Tool::FloodFill => "icons/bucket.svg",
Tool::Gradient => "icons/gradient.svg",
Tool::Text => "icons/text.svg",
Tool::VectorLine => "icons/line.svg",
Tool::VectorRect => "icons/vector_rect.svg",
Tool::VectorCircle => "icons/circle.svg",
Tool::VectorArrow => "icons/vector_arrow.svg",
Tool::VectorStar => "icons/star.svg",
Tool::VectorPolygon => "icons/vector_polygon.svg",
Tool::VectorRhombus => "icons/vector_rhombus.svg",
Tool::VectorCylinder => "icons/vector_cylinder.svg",
Tool::VectorHeart => "icons/vector_heart.svg",
Tool::VectorBubble => "icons/vector_bubble.svg",
Tool::VectorGear => "icons/vector_gear.svg",
Tool::VectorCross => "icons/vector_cross.svg",
Tool::VectorCrescent => "icons/vector_crescent.svg",
Tool::VectorBolt => "icons/vector_bolt.svg",
Tool::VectorArrow4 => "icons/vector_arrow4.svg",
Tool::SpotRemoval => "icons/spot.svg",
Tool::RedEyeRemoval => "icons/redeye.svg",
Tool::SmartPatch | Tool::AiObjectRemoval => "icons/patch.svg",
}
}
/// Resolves an SVG path across supported repository and application working directories.
///
/// **Arguments:** `icon` is the asset-relative SVG path.
/// **Returns:** The first existing path, or the application-relative fallback.
/// **Side Effects / Dependencies:** Reads filesystem metadata without modifying it.
fn resolve_icon_path(icon: &str) -> std::path::PathBuf {
let candidates = [
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../assets")
.join(icon),
std::path::PathBuf::from(icon),
std::path::Path::new("assets").join(icon),
std::path::Path::new("hcie-iced-app/assets").join(icon),
];
candidates
.into_iter()
.find(|path| path.exists())
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../assets")
.join(icon)
})
}
/// Get keyboard shortcut display for a tool.
fn tool_shortcut(tool: Tool) -> &'static str {
match tool {
@@ -480,3 +803,24 @@ pub fn last_used_tool(slot_idx: usize, last_used: &[usize]) -> Tool {
.and_then(|s| s.tools.get(tidx).copied())
.unwrap_or(Tool::Brush)
}
#[cfg(test)]
mod tests {
use super::{resolve_icon_path, tool_icon, TOOL_SLOTS};
/// Verifies every concrete popup choice resolves to an existing SVG asset.
#[test]
fn every_subtool_has_an_svg_icon() {
for tool in TOOL_SLOTS
.iter()
.flat_map(|slot| slot.tools.iter().copied())
{
let path = resolve_icon_path(tool_icon(tool));
assert!(
path.exists(),
"missing icon for {tool:?}: {}",
path.display()
);
}
}
}
@@ -13,31 +13,59 @@ use std::sync::{Arc, Mutex};
/// Shared theme state for the application.
#[derive(Clone)]
pub struct ThemeState {
colors: Arc<Mutex<ThemeColors>>,
state: Arc<Mutex<(ThemePreset, ThemeColors)>>,
}
impl ThemeState {
/// Create a new theme state with the default preset.
pub fn new() -> Self {
/// Create theme state from a selected preset.
///
/// **Arguments:** `preset` identifies the persisted palette to resolve.
/// **Returns:** Shared state containing both the preset and its color tokens.
/// **Logic & Workflow:** Resolves colors once and stores them atomically with the preset.
/// **Side Effects / Dependencies:** Allocates synchronized shared state; performs no I/O.
pub fn new(preset: ThemePreset) -> Self {
Self {
colors: Arc::new(Mutex::new(ThemeColors::get(ThemePreset::default()))),
state: Arc::new(Mutex::new((preset, ThemeColors::get(preset)))),
}
}
/// Get the current theme colors.
pub fn colors(&self) -> ThemeColors {
*self.colors.lock().unwrap()
self.state.lock().unwrap().1
}
/// Report the currently selected preset.
///
/// **Returns:** The preset used to produce the current color tokens.
/// **Logic & Workflow:** Reads the preset from the same lock as its resolved colors.
/// **Side Effects / Dependencies:** Briefly locks shared theme state.
pub fn preset(&self) -> ThemePreset {
self.state.lock().unwrap().0
}
/// Switch to a different theme preset.
#[allow(dead_code)]
pub fn set_preset(&self, preset: ThemePreset) {
*self.colors.lock().unwrap() = ThemeColors::get(preset);
*self.state.lock().unwrap() = (preset, ThemeColors::get(preset));
}
}
impl Default for ThemeState {
fn default() -> Self {
Self::new()
Self::new(ThemePreset::default())
}
}
#[cfg(test)]
mod tests {
use super::{ThemePreset, ThemeState};
/// Verifies preset mutation updates only the synchronized theme payload.
#[test]
fn theme_state_retains_and_reports_selected_preset() {
let state = ThemeState::new(ThemePreset::Photopea);
state.set_preset(ThemePreset::ProLight);
assert_eq!(state.preset(), ThemePreset::ProLight);
assert!(state.colors().is_light);
}
}
@@ -0,0 +1,493 @@
//! Pure vector-selection and transform calculations for the Iced GUI.
//!
//! Purpose: Centralize overlap cycling, screen-constant rotated handle hit testing, and safe
//! move/resize/rotate calculations. Logic & Workflow: Drag calculations always start from an
//! immutable shape snapshot, convert pointer movement into the shape's local axes, apply optional
//! aspect/center constraints, and reject non-finite or inverted bounds. Side Effects / Dependencies:
//! This module has no I/O or engine side effects; callers commit returned shapes through
//! `hcie-engine-api`.
use hcie_engine_api::{VectorEditHandle, VectorShape};
/// Smallest permitted vector dimension in canvas pixels.
pub const MIN_VECTOR_SIZE: f32 = 1.0;
/// Resize handle hit-box half extent in screen pixels.
pub const HANDLE_HIT_HALF_PX: f32 = 7.0;
/// Rotation handle distance above the shape in screen pixels.
pub const ROTATE_OFFSET_PX: f32 = 24.0;
/// Rotation handle hit radius in screen pixels.
pub const ROTATE_HIT_RADIUS_PX: f32 = 7.0;
/// Immutable edit-session snapshot tied to the layer from which it was captured.
///
/// Purpose: Prevents Cancel from restoring shapes into a different active layer. Logic & Workflow:
/// the app captures this once on selection and restores `shapes` by `layer_id`. Side Effects: None.
#[derive(Debug, Clone, PartialEq)]
pub struct VectorEditSnapshot {
pub layer_id: u64,
pub shapes: Vec<VectorShape>,
}
/// Immutable basis for one pointer drag.
///
/// Purpose: Makes every move event deterministic and prevents cumulative floating-point drift.
/// Logic & Workflow: `transform_shape` derives the current shape from `original` and the total
/// pointer displacement since `pointer_start`. Side Effects: None.
#[derive(Debug, Clone, PartialEq)]
pub struct VectorDragSession {
pub layer_id: u64,
pub shape_index: usize,
pub handle: VectorEditHandle,
pub pointer_start: (f32, f32),
pub original: VectorShape,
}
/// Chooses the next selected index for a point containing `hits` in bottom-to-top order.
///
/// Arguments: `hits` contains shape indices under the pointer and `current` is the prior selection.
/// Returns: The topmost hit initially, then the next lower overlapping shape on repeated clicks.
/// Side Effects: None.
pub fn cycle_selection(hits: &[usize], current: Option<usize>) -> Option<usize> {
if hits.is_empty() {
return None;
}
match current.and_then(|index| hits.iter().position(|hit| *hit == index)) {
Some(position) => Some(hits[(position + hits.len() - 1) % hits.len()]),
None => hits.last().copied(),
}
}
/// Hit-tests rotated vector handles using screen-constant target sizes.
///
/// Arguments: `bounds` are normalized canvas bounds, `angle` is radians, `point` is canvas-space,
/// and `zoom` maps canvas pixels to screen pixels. Returns: The matching handle, preferring rotate
/// and resize targets over the move interior. Side Effects: None.
pub fn hit_test_handle(
bounds: (f32, f32, f32, f32),
angle: f32,
point: (f32, f32),
zoom: f32,
) -> VectorEditHandle {
use VectorEditHandle as H;
if !finite4(bounds) || !angle.is_finite() || !point.0.is_finite() || !point.1.is_finite() {
return H::None;
}
let (left, top, right, bottom) = bounds;
if right - left < MIN_VECTOR_SIZE || bottom - top < MIN_VECTOR_SIZE {
return H::None;
}
let zoom = zoom.max(0.000_1);
let center = ((left + right) * 0.5, (top + bottom) * 0.5);
let local = inverse_rotate(point, center, angle);
let rotate_offset = ROTATE_OFFSET_PX / zoom;
let rotate_radius = ROTATE_HIT_RADIUS_PX / zoom;
let rotate_delta = (local.0 - center.0, local.1 - (top - rotate_offset));
if rotate_delta.0 * rotate_delta.0 + rotate_delta.1 * rotate_delta.1
<= rotate_radius * rotate_radius
{
return H::Rotate;
}
let half = HANDLE_HIT_HALF_PX / zoom;
let points = [
(H::TopLeft, left, top),
(H::Top, center.0, top),
(H::TopRight, right, top),
(H::Left, left, center.1),
(H::Right, right, center.1),
(H::BottomLeft, left, bottom),
(H::Bottom, center.0, bottom),
(H::BottomRight, right, bottom),
];
for (handle, x, y) in points {
if (local.0 - x).abs() <= half && (local.1 - y).abs() <= half {
return handle;
}
}
if local.0 >= left && local.0 <= right && local.1 >= top && local.1 <= bottom {
H::Move
} else {
H::None
}
}
/// Derives a safe shape for the current pointer position in a drag.
///
/// Arguments: `session` is the immutable drag basis, `pointer` is canvas-space, `lock_aspect`
/// corresponds to Shift, and `from_center` corresponds to Alt. Returns: A finite shape with
/// dimensions at least `MIN_VECTOR_SIZE`; invalid pointer input returns the original shape.
/// Side Effects: None.
pub fn transform_shape(
session: &VectorDragSession,
pointer: (f32, f32),
lock_aspect: bool,
from_center: bool,
) -> VectorShape {
use VectorEditHandle as H;
if !pointer.0.is_finite() || !pointer.1.is_finite() {
return session.original.clone();
}
let mut result = session.original.clone();
let delta = (
pointer.0 - session.pointer_start.0,
pointer.1 - session.pointer_start.1,
);
if !delta.0.is_finite() || !delta.1.is_finite() {
return result;
}
if session.handle == H::Move {
result.apply_handle(H::Move, delta.0, delta.1, None);
return finite_shape_or_original(result, &session.original);
}
let bounds = session.original.normalized_bounds();
let center = ((bounds.0 + bounds.2) * 0.5, (bounds.1 + bounds.3) * 0.5);
if session.handle == H::Rotate {
if matches!(session.original, VectorShape::FreePath { .. }) {
return result;
}
let start_angle =
(session.pointer_start.1 - center.1).atan2(session.pointer_start.0 - center.0);
let current_angle = (pointer.1 - center.1).atan2(pointer.0 - center.0);
let angle = session.original.angle() + normalize_angle(current_angle - start_angle);
if angle.is_finite() {
result.set_angle(normalize_angle(angle));
}
return result;
}
if session.handle == H::None {
return result;
}
let local_delta = inverse_rotate_delta(delta, session.original.angle());
let resized = resize_bounds(
bounds,
session.handle,
local_delta,
lock_aspect,
from_center,
);
result.set_bounds(resized.0, resized.1, resized.2, resized.3);
finite_shape_or_original(result, &session.original)
}
/// Calculates normalized, minimum-sized resize bounds.
///
/// Arguments: Original normalized `bounds`, active resize `handle`, pointer `delta` in local shape
/// axes, and Shift/Alt constraints. Returns: Normalized finite bounds without edge inversion.
/// Side Effects: None.
pub fn resize_bounds(
bounds: (f32, f32, f32, f32),
handle: VectorEditHandle,
delta: (f32, f32),
lock_aspect: bool,
from_center: bool,
) -> (f32, f32, f32, f32) {
use VectorEditHandle as H;
if !finite4(bounds) || !delta.0.is_finite() || !delta.1.is_finite() {
return bounds;
}
let (left, top, right, bottom) = bounds;
let center = ((left + right) * 0.5, (top + bottom) * 0.5);
let affects_left = matches!(handle, H::TopLeft | H::Left | H::BottomLeft);
let affects_right = matches!(handle, H::TopRight | H::Right | H::BottomRight);
let affects_top = matches!(handle, H::TopLeft | H::Top | H::TopRight);
let affects_bottom = matches!(handle, H::BottomLeft | H::Bottom | H::BottomRight);
if !(affects_left || affects_right || affects_top || affects_bottom) {
return bounds;
}
let mut new_left = left;
let mut new_right = right;
let mut new_top = top;
let mut new_bottom = bottom;
let scale = if from_center { 2.0 } else { 1.0 };
if affects_left {
new_left = (left + delta.0 * scale).min(right - MIN_VECTOR_SIZE);
if from_center {
new_right = 2.0 * center.0 - new_left;
}
}
if affects_right {
new_right = (right + delta.0 * scale).max(left + MIN_VECTOR_SIZE);
if from_center {
new_left = 2.0 * center.0 - new_right;
}
}
if affects_top {
new_top = (top + delta.1 * scale).min(bottom - MIN_VECTOR_SIZE);
if from_center {
new_bottom = 2.0 * center.1 - new_top;
}
}
if affects_bottom {
new_bottom = (bottom + delta.1 * scale).max(top + MIN_VECTOR_SIZE);
if from_center {
new_top = 2.0 * center.1 - new_bottom;
}
}
if lock_aspect {
let ratio = ((right - left) / (bottom - top).max(MIN_VECTOR_SIZE)).max(0.000_1);
let mut width = (new_right - new_left).max(MIN_VECTOR_SIZE);
let mut height = (new_bottom - new_top).max(MIN_VECTOR_SIZE);
if affects_left || affects_right {
height = (width / ratio).max(MIN_VECTOR_SIZE);
} else {
width = (height * ratio).max(MIN_VECTOR_SIZE);
}
let anchor_x = if from_center {
center.0
} else if affects_left {
right
} else if affects_right {
left
} else {
center.0
};
let anchor_y = if from_center {
center.1
} else if affects_top {
bottom
} else if affects_bottom {
top
} else {
center.1
};
if from_center || !(affects_left || affects_right) {
new_left = anchor_x - width * 0.5;
new_right = anchor_x + width * 0.5;
} else if affects_left {
new_left = anchor_x - width;
new_right = anchor_x;
} else {
new_left = anchor_x;
new_right = anchor_x + width;
}
if from_center || !(affects_top || affects_bottom) {
new_top = anchor_y - height * 0.5;
new_bottom = anchor_y + height * 0.5;
} else if affects_top {
new_top = anchor_y - height;
new_bottom = anchor_y;
} else {
new_top = anchor_y;
new_bottom = anchor_y + height;
}
}
let result = (new_left, new_top, new_right, new_bottom);
if finite4(result)
&& result.2 - result.0 >= MIN_VECTOR_SIZE
&& result.3 - result.1 >= MIN_VECTOR_SIZE
{
result
} else {
bounds
}
}
/// Normalizes an angle to `[-PI, PI]` while preserving finite values.
///
/// Arguments: `angle` in radians. Returns: A wrapped angle, or zero for non-finite input.
/// Side Effects: None.
pub fn normalize_angle(angle: f32) -> f32 {
if !angle.is_finite() {
return 0.0;
}
let two_pi = std::f32::consts::TAU;
(angle + std::f32::consts::PI).rem_euclid(two_pi) - std::f32::consts::PI
}
/// Inverse-rotates a point around a center. Arguments and return are canvas-space coordinates.
/// Side Effects: None.
fn inverse_rotate(point: (f32, f32), center: (f32, f32), angle: f32) -> (f32, f32) {
let delta = (point.0 - center.0, point.1 - center.1);
let local = inverse_rotate_delta(delta, angle);
(center.0 + local.0, center.1 + local.1)
}
/// Inverse-rotates a displacement by `angle`. Returns the shape-local displacement.
/// Side Effects: None.
fn inverse_rotate_delta(delta: (f32, f32), angle: f32) -> (f32, f32) {
let (sin, cos) = angle.sin_cos();
(
delta.0 * cos + delta.1 * sin,
-delta.0 * sin + delta.1 * cos,
)
}
/// Checks four floats for finiteness. Returns true only when all values are finite.
/// Side Effects: None.
fn finite4(values: (f32, f32, f32, f32)) -> bool {
values.0.is_finite() && values.1.is_finite() && values.2.is_finite() && values.3.is_finite()
}
/// Rejects shapes whose resulting bounds contain NaN/infinity or violate minimum size.
/// Returns: `candidate` when valid, otherwise `original`. Side Effects: None.
fn finite_shape_or_original(candidate: VectorShape, original: &VectorShape) -> VectorShape {
let bounds = candidate.normalized_bounds();
if finite4(bounds)
&& bounds.2 - bounds.0 >= MIN_VECTOR_SIZE
&& bounds.3 - bounds.1 >= MIN_VECTOR_SIZE
{
candidate
} else {
original.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Creates a stable rectangle fixture for transform invariant tests.
fn rect() -> VectorShape {
VectorShape::Rect {
x1: 10.0,
y1: 20.0,
x2: 110.0,
y2: 70.0,
stroke: 2.0,
color: [1, 2, 3, 255],
fill_color: [4, 5, 6, 255],
fill: true,
radius: 0.0,
angle: 0.0,
opacity: 1.0,
hardness: 1.0,
}
}
/// Verifies topmost-first overlap selection and deterministic repeated-click cycling.
#[test]
fn overlap_selection_cycles_without_external_counter() {
let hits = [1, 3, 8];
assert_eq!(cycle_selection(&hits, None), Some(8));
assert_eq!(cycle_selection(&hits, Some(8)), Some(3));
assert_eq!(cycle_selection(&hits, Some(3)), Some(1));
assert_eq!(cycle_selection(&hits, Some(1)), Some(8));
assert_eq!(cycle_selection(&[], Some(1)), None);
}
/// Verifies handle targets occupy the same screen radius at low and high zoom.
#[test]
fn handle_hit_area_is_screen_constant_across_zoom() {
let bounds = (10.0, 20.0, 110.0, 70.0);
assert_eq!(
hit_test_handle(bounds, 0.0, (16.0, 20.0), 1.0),
VectorEditHandle::TopLeft
);
assert_eq!(
hit_test_handle(bounds, 0.0, (10.6, 20.0), 10.0),
VectorEditHandle::TopLeft
);
assert_eq!(
hit_test_handle(bounds, 0.0, (10.8, 20.0), 10.0),
VectorEditHandle::Move
);
}
/// Verifies the visible rotation handle and its hit target remain 24 screen pixels above the
/// selected shape at low zoom, where the previous drawing path incorrectly moved it closer.
#[test]
fn rotation_handle_offset_is_screen_constant_at_low_zoom() {
let bounds = (10.0, 20.0, 110.0, 70.0);
let zoom = 0.25;
let rotation_point = (60.0, 20.0 - ROTATE_OFFSET_PX / zoom);
assert_eq!(
hit_test_handle(bounds, 0.0, rotation_point, zoom),
VectorEditHandle::Rotate
);
assert_eq!((20.0 - rotation_point.1) * zoom, ROTATE_OFFSET_PX);
}
/// Verifies all eight handles preserve normalized, minimum-sized bounds under extreme drags.
#[test]
fn every_resize_handle_prevents_inversion_and_nan() {
let handles = [
VectorEditHandle::TopLeft,
VectorEditHandle::Top,
VectorEditHandle::TopRight,
VectorEditHandle::Left,
VectorEditHandle::Right,
VectorEditHandle::BottomLeft,
VectorEditHandle::Bottom,
VectorEditHandle::BottomRight,
];
for handle in handles {
for delta in [
(10_000.0, 10_000.0),
(-10_000.0, -10_000.0),
(f32::NAN, 1.0),
] {
let bounds = resize_bounds((10.0, 20.0, 110.0, 70.0), handle, delta, false, false);
assert!(finite4(bounds), "{handle:?}: {bounds:?}");
assert!(
bounds.2 - bounds.0 >= MIN_VECTOR_SIZE,
"{handle:?}: {bounds:?}"
);
assert!(
bounds.3 - bounds.1 >= MIN_VECTOR_SIZE,
"{handle:?}: {bounds:?}"
);
}
}
}
/// Verifies Shift preserves aspect and Alt keeps the original center fixed.
#[test]
fn shift_aspect_and_alt_center_constraints_hold() {
let original = (10.0, 20.0, 110.0, 70.0);
let resized = resize_bounds(
original,
VectorEditHandle::BottomRight,
(50.0, 10.0),
true,
true,
);
let ratio = (resized.2 - resized.0) / (resized.3 - resized.1);
assert!((ratio - 2.0).abs() < 0.0001);
assert!(((resized.0 + resized.2) * 0.5 - 60.0).abs() < 0.0001);
assert!(((resized.1 + resized.3) * 0.5 - 45.0).abs() < 0.0001);
}
/// Verifies drag output is based on the original snapshot and invalid pointers are harmless.
#[test]
fn drag_is_deterministic_and_rejects_non_finite_input() {
let session = VectorDragSession {
layer_id: 7,
shape_index: 0,
handle: VectorEditHandle::Move,
pointer_start: (5.0, 5.0),
original: rect(),
};
let moved = transform_shape(&session, (15.0, 25.0), false, false);
assert_eq!(moved.normalized_bounds(), (20.0, 40.0, 120.0, 90.0));
assert_eq!(
transform_shape(&session, (f32::NAN, 0.0), false, false),
rect()
);
}
/// Verifies rotated handle hit testing operates in shape-local coordinates.
#[test]
fn rotated_handle_hit_test_uses_rotated_position() {
let bounds = (10.0, 20.0, 110.0, 70.0);
let center = (60.0, 45.0);
let angle = std::f32::consts::FRAC_PI_2;
let local = (10.0, 20.0);
let rotated = (
center.0 - (local.1 - center.1),
center.1 + (local.0 - center.0),
);
assert_eq!(
hit_test_handle(bounds, angle, rotated, 2.0),
VectorEditHandle::TopLeft
);
}
}
@@ -129,8 +129,7 @@ fn draw_tree_node(
let mut children_col = column![].spacing(1);
for child in &subdirs {
if let Some(child_name) = child.file_name().and_then(|n| n.to_str()) {
let child_node =
draw_tree_node(child, child_name, ">", state, colors, depth + 1);
let child_node = draw_tree_node(child, child_name, ">", state, colors, depth + 1);
children_col = children_col.push(child_node);
}
}
@@ -138,36 +137,40 @@ fn draw_tree_node(
let btn = button(label_text)
.on_press(Message::ViewerNavigate(path.to_path_buf()))
.width(Length::Fill)
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(if is_selected {
color_alpha(colors.accent, 0.15)
} else {
iced::Color::TRANSPARENT
})),
text_color: label_color,
border: iced::Border::default(),
..Default::default()
}
});
.style(
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(if is_selected {
color_alpha(colors.accent, 0.15)
} else {
iced::Color::TRANSPARENT
})),
text_color: label_color,
border: iced::Border::default(),
..Default::default()
}
},
);
column![btn, children_col].spacing(1).into()
} else {
button(label_text)
.on_press(Message::ViewerNavigate(path.to_path_buf()))
.width(Length::Fill)
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(if is_selected {
color_alpha(colors.accent, 0.15)
} else {
iced::Color::TRANSPARENT
})),
text_color: label_color,
border: iced::Border::default(),
..Default::default()
}
})
.style(
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(if is_selected {
color_alpha(colors.accent, 0.15)
} else {
iced::Color::TRANSPARENT
})),
text_color: label_color,
border: iced::Border::default(),
..Default::default()
}
},
)
.into()
};
@@ -14,7 +14,7 @@ pub mod thumbnail_grid;
use crate::app::Message;
use crate::theme::ThemeColors;
use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, Space};
use iced::widget::{button, column, container, horizontal_rule, row, text, Space};
use iced::{Element, Length};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
@@ -56,6 +56,8 @@ pub struct ViewerState {
pub preview_pixels: Option<(Vec<u8>, u32, u32)>,
/// Preview load error message.
pub load_error: Option<String>,
/// Directory enumeration error shown without pretending the folder is empty.
pub directory_error: Option<String>,
/// Zoom level for the viewer panel.
pub zoom: f32,
/// Pan offset for the viewer panel.
@@ -75,6 +77,7 @@ impl Default for ViewerState {
failed_thumbnails: HashSet::new(),
preview_pixels: None,
load_error: None,
directory_error: None,
zoom: 1.0,
pan_offset: (0.0, 0.0),
}
@@ -99,17 +102,32 @@ impl ViewerState {
/// Refresh the images list in the current directory.
pub fn refresh_images(&mut self) {
log::debug!("Refreshing image list for: {}", self.current_dir.display());
let selected = self.images.get(self.active_image_idx).cloned();
self.images.clear();
if let Ok(entries) = std::fs::read_dir(&self.current_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && is_supported_image(&path) {
self.images.push(path);
self.directory_error = None;
match std::fs::read_dir(&self.current_dir) {
Ok(entries) => {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && is_supported_image(&path) {
self.images.push(path);
}
}
}
Err(error) => {
self.directory_error = Some(format!(
"Cannot read {}: {error}",
self.current_dir.display()
))
}
}
self.images.sort();
self.active_image_idx = 0;
self.images.sort_by_cached_key(|path| {
path.file_name()
.map(|name| name.to_string_lossy().to_lowercase())
});
self.active_image_idx = selected
.and_then(|path| self.images.iter().position(|item| item == &path))
.unwrap_or(0);
self.preview_pixels = None;
self.load_error = None;
log::debug!("Found {} supported images", self.images.len());
@@ -220,7 +238,8 @@ impl ViewerState {
let w = rgba.width();
let h = rgba.height();
let pixels = rgba.into_raw();
self.thumbnail_cache.insert(path.to_path_buf(), (pixels, w, h));
self.thumbnail_cache
.insert(path.to_path_buf(), (pixels, w, h));
}
Err(_) => {
self.failed_thumbnails.insert(path.to_path_buf());
@@ -247,10 +266,7 @@ fn _fit_size(texture_w: f32, texture_h: f32, avail_w: f32, avail_h: f32) -> (f32
}
/// Build the viewer panel UI element.
pub fn view(
state: &ViewerState,
colors: ThemeColors,
) -> Element<'static, Message> {
pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> {
let header = view_header(state, colors);
let body = match state.view_mode {
ViewerMode::Browser => view_browser(state, colors),
@@ -266,77 +282,91 @@ pub fn view(
fn view_header(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> {
let exit_btn = button(text(" Exit Viewer "))
.on_press(Message::ViewerToggle)
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(colors.accent)),
text_color: iced::Color::WHITE,
border: iced::Border::default().color(colors.accent).width(1),
..Default::default()
}
});
.style(
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(colors.accent)),
text_color: iced::Color::WHITE,
border: iced::Border::default().color(colors.accent).width(1),
..Default::default()
}
},
);
let fs_label = if state.fullscreen { "Windowed" } else { "Fullscreen" };
let fs_label = if state.fullscreen {
"Windowed"
} else {
"Fullscreen"
};
let fs_btn = button(text(format!(" {} ", fs_label)))
.on_press(Message::ViewerFullscreen(!state.fullscreen))
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(colors.bg_panel)),
text_color: colors.text_primary,
border: iced::Border::default().color(colors.border_low).width(1),
..Default::default()
}
});
.style(
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(colors.bg_panel)),
text_color: colors.text_primary,
border: iced::Border::default().color(colors.border_low).width(1),
..Default::default()
}
},
);
let is_browser = state.view_mode == ViewerMode::Browser;
let browser_btn = button(text(" Browse "))
.on_press(Message::ViewerNavigate(state.current_dir.clone()))
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(if is_browser {
colors.accent
} else {
colors.bg_panel
})),
text_color: if is_browser {
iced::Color::WHITE
} else {
colors.text_primary
},
border: iced::Border::default().color(colors.border_low).width(1),
..Default::default()
}
});
.on_press(Message::ViewerSetMode(ViewerMode::Browser))
.style(
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(if is_browser {
colors.accent
} else {
colors.bg_panel
})),
text_color: if is_browser {
iced::Color::WHITE
} else {
colors.text_primary
},
border: iced::Border::default().color(colors.border_low).width(1),
..Default::default()
}
},
);
let is_viewer = state.view_mode == ViewerMode::Viewer;
let viewer_btn = button(text(" Viewer "))
.on_press(Message::ViewerNavigate(state.current_dir.clone()))
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(if is_viewer {
colors.accent
} else {
colors.bg_panel
})),
text_color: if is_viewer {
iced::Color::WHITE
} else {
colors.text_primary
},
border: iced::Border::default().color(colors.border_low).width(1),
..Default::default()
}
});
.on_press(Message::ViewerSetMode(ViewerMode::Viewer))
.style(
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(if is_viewer {
colors.accent
} else {
colors.bg_panel
})),
text_color: if is_viewer {
iced::Color::WHITE
} else {
colors.text_primary
},
border: iced::Border::default().color(colors.border_low).width(1),
..Default::default()
}
},
);
let refresh_btn = button(text(" Refresh "))
.on_press(Message::ViewerRefresh)
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(colors.bg_panel)),
text_color: colors.text_primary,
border: iced::Border::default().color(colors.border_low).width(1),
..Default::default()
}
});
.style(
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(colors.bg_panel)),
text_color: colors.text_primary,
border: iced::Border::default().color(colors.border_low).width(1),
..Default::default()
}
},
);
row![
exit_btn,
@@ -407,7 +437,9 @@ fn view_left_sidebar(state: &ViewerState, colors: ThemeColors) -> Element<'stati
fn view_preview(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> {
let content = if let Some((pixels, w, h)) = &state.preview_pixels {
let img = iced::widget::Image::new(iced::widget::image::Handle::from_rgba(
*w, *h, pixels.clone(),
*w,
*h,
pixels.clone(),
))
.width(Length::Fill)
.height(Length::Shrink);
@@ -438,15 +470,19 @@ fn view_preview(state: &ViewerState, colors: ThemeColors) -> Element<'static, Me
.align_x(iced::Alignment::Center)
.width(Length::Fill)
} else if let Some(err) = &state.load_error {
column![text(format!("Error: {}", err)).style(move |_theme| iced::widget::text::Style {
color: Some(colors.danger),
})]
column![
text(format!("Error: {}", err)).style(move |_theme| iced::widget::text::Style {
color: Some(colors.danger),
})
]
.align_x(iced::Alignment::Center)
.width(Length::Fill)
} else {
column![text("No image selected").style(move |_theme| iced::widget::text::Style {
color: Some(colors.text_secondary),
})]
column![
text("No image selected").style(move |_theme| iced::widget::text::Style {
color: Some(colors.text_secondary),
})
]
.align_x(iced::Alignment::Center)
.width(Length::Fill)
};
@@ -481,23 +517,23 @@ fn view_right_panel(state: &ViewerState, colors: ThemeColors) -> Element<'static
let has_parent = state.current_dir.parent().is_some();
let parent_btn = if has_parent {
button(text(" Parent "))
.on_press(Message::ViewerNavigate(
state.current_dir.parent().unwrap().to_path_buf(),
))
button(text(" Parent ")).on_press(Message::ViewerNavigate(
state.current_dir.parent().unwrap().to_path_buf(),
))
} else {
button(text(" Parent "))
};
let parent_btn = parent_btn
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
let parent_btn = parent_btn.style(
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(colors.bg_panel)),
text_color: colors.text_primary,
border: iced::Border::default().color(colors.border_low).width(1),
..Default::default()
}
});
},
);
let dir_str = state.current_dir.to_string_lossy().to_string();
let total_files = state.images.len();
@@ -506,7 +542,10 @@ fn view_right_panel(state: &ViewerState, colors: ThemeColors) -> Element<'static
} else {
0
};
let stats_str = format!("{} Files | Selected: {} / {}", total_files, selected_num, total_files);
let stats_str = format!(
"{} Files | Selected: {} / {}",
total_files, selected_num, total_files
);
let toolbar = row![
parent_btn,
@@ -526,19 +565,20 @@ fn view_right_panel(state: &ViewerState, colors: ThemeColors) -> Element<'static
color: Some(colors.text_secondary),
});
let grid = thumbnail_grid::view(state, colors);
let grid: Element<'static, Message> = if let Some(error) = &state.directory_error {
container(text(error.clone()).color(colors.danger))
.center_x(Length::Fill)
.center_y(Length::Fill)
.into()
} else {
thumbnail_grid::view(state, colors)
};
container(
column![
toolbar,
horizontal_rule(1),
stats,
horizontal_rule(1),
grid,
]
.spacing(4)
.width(Length::Fill)
.height(Length::Fill),
column![toolbar, horizontal_rule(1), stats, horizontal_rule(1), grid,]
.spacing(4)
.width(Length::Fill)
.height(Length::Fill),
)
.width(Length::Fill)
.height(Length::Fill)
@@ -557,12 +597,13 @@ fn view_right_panel(state: &ViewerState, colors: ThemeColors) -> Element<'static
fn view_viewer(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> {
if state.images.is_empty() {
return column![
text("No images found in this folder.").style(move |_theme| iced::widget::text::Style {
color: Some(colors.text_secondary),
text("No images found in this folder.").style(move |_theme| {
iced::widget::text::Style {
color: Some(colors.text_secondary),
}
}),
button(text("Browse Mode")).on_press(Message::ViewerNavigate(
state.current_dir.clone(),
)),
button(text("Browse Mode"))
.on_press(Message::ViewerNavigate(state.current_dir.clone(),)),
]
.align_x(iced::Alignment::Center)
.width(Length::Fill)
@@ -585,35 +626,38 @@ fn view_viewer(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes
color: Some(colors.text_primary),
}),
Space::with_width(Length::Fill),
text(format!("{} / {}", current_idx + 1, total)).style(move |_theme| iced::widget::text::Style {
color: Some(colors.text_secondary),
text(format!("{} / {}", current_idx + 1, total)).style(move |_theme| {
iced::widget::text::Style {
color: Some(colors.text_secondary),
}
}),
text(" "),
button(text("Edit")).on_press(Message::ViewerOpenFile(edit_path))
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(colors.accent)),
text_color: iced::Color::WHITE,
border: iced::Border::default().color(colors.accent).width(1),
..Default::default()
button(text("Edit"))
.on_press(Message::ViewerOpenFile(edit_path))
.style(
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(colors.accent)),
text_color: iced::Color::WHITE,
border: iced::Border::default().color(colors.accent).width(1),
..Default::default()
}
}
}),
),
]
.align_y(iced::Alignment::Center)
.width(Length::Fill);
let image_area: Element<'static, Message> = if let Some((pixels, w, h)) = &state.preview_pixels {
let handle: iced::widget::image::Handle = iced::widget::image::Handle::from_rgba(
*w, *h, pixels.clone(),
);
let image_area: Element<'static, Message> = if let Some((pixels, w, h)) = &state.preview_pixels
{
let handle: iced::widget::image::Handle =
iced::widget::image::Handle::from_rgba(*w, *h, pixels.clone());
let img = iced::widget::Image::new(handle)
.width(Length::Fill)
.height(Length::Fill);
let prev_path = state.images[current_idx].clone();
let prev_btn = button(text(" < "))
.on_press(Message::ViewerNavigate(prev_path))
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
let prev_btn = button(text(" < ")).on_press(Message::ViewerPrev).style(
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.5,
@@ -622,17 +666,11 @@ fn view_viewer(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes
border: iced::Border::default().rounded(8),
..Default::default()
}
});
},
);
let next_idx = if current_idx + 1 < total {
current_idx + 1
} else {
0
};
let next_path = state.images[next_idx].clone();
let next_btn = button(text(" > "))
.on_press(Message::ViewerNavigate(next_path))
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
let next_btn = button(text(" > ")).on_press(Message::ViewerNext).style(
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.5,
@@ -641,26 +679,31 @@ fn view_viewer(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes
border: iced::Border::default().rounded(8),
..Default::default()
}
});
},
);
row![prev_btn, img, next_btn]
.width(Length::Fill)
.height(Length::Fill)
.into()
} else if let Some(err) = &state.load_error {
column![text(format!("Failed to load: {}", err)).style(move |_theme| {
iced::widget::text::Style {
color: Some(colors.danger),
}
})]
column![
text(format!("Failed to load: {}", err)).style(move |_theme| {
iced::widget::text::Style {
color: Some(colors.danger),
}
})
]
.align_x(iced::Alignment::Center)
.width(Length::Fill)
.height(Length::Fill)
.into()
} else {
column![text("Loading...").style(move |_theme| iced::widget::text::Style {
color: Some(colors.text_secondary),
})]
column![
text("Loading...").style(move |_theme| iced::widget::text::Style {
color: Some(colors.text_secondary),
})
]
.align_x(iced::Alignment::Center)
.width(Length::Fill)
.height(Length::Fill)
@@ -672,3 +715,24 @@ fn view_viewer(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes
.height(Length::Fill)
.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn navigation_wraps_deterministically() {
let mut state = ViewerState::default();
state.images = vec![PathBuf::from("a.png"), PathBuf::from("b.png")];
state.prev_image();
assert_eq!(state.active_image_idx, 1);
state.next_image();
assert_eq!(state.active_image_idx, 0);
}
#[test]
fn supported_extensions_are_case_insensitive() {
assert!(is_supported_image(Path::new("IMAGE.PNG")));
assert!(!is_supported_image(Path::new("notes.txt")));
}
}
@@ -42,8 +42,9 @@ pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Messag
.into();
}
let avail_w = 600.0_f32;
let cols = ((avail_w / (CARD_W + CARD_SPACING)).floor().max(1.0)) as usize;
// Four compact columns remain usable at the minimum editor width while the
// surrounding scrollable preserves access on narrower windows.
let cols = 4;
let mut grid_rows = column![].spacing(CARD_SPACING);
let total = state.images.len();
@@ -68,7 +69,11 @@ pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Messag
}
/// Render a single thumbnail card.
fn thumbnail_card(state: &ViewerState, index: usize, colors: ThemeColors) -> Element<'static, Message> {
fn thumbnail_card(
state: &ViewerState,
index: usize,
colors: ThemeColors,
) -> Element<'static, Message> {
let img_path = &state.images[index];
let is_selected = state.active_image_idx == index;
let filename = img_path
@@ -82,19 +87,17 @@ fn thumbnail_card(state: &ViewerState, index: usize, colors: ThemeColors) -> Ele
filename.clone()
};
let thumb_content: Element<'static, Message> = if let Some((pixels, w, h)) = state.thumbnail_cache.get(img_path) {
let handle: iced::widget::image::Handle = iced::widget::image::Handle::from_rgba(
*w, *h, pixels.clone(),
);
let img = iced::widget::Image::new(handle)
.width(110)
.height(100);
img.into()
} else if state.failed_thumbnails.contains(img_path) {
text("Error").size(10).into()
} else {
text("...").size(10).into()
};
let thumb_content: Element<'static, Message> =
if let Some((pixels, w, h)) = state.thumbnail_cache.get(img_path) {
let handle: iced::widget::image::Handle =
iced::widget::image::Handle::from_rgba(*w, *h, pixels.clone());
let img = iced::widget::Image::new(handle).width(110).height(100);
img.into()
} else if state.failed_thumbnails.contains(img_path) {
text("Error").size(10).into()
} else {
text("...").size(10).into()
};
let card_content = column![
Space::with_height(4),
@@ -124,17 +127,29 @@ fn thumbnail_card(state: &ViewerState, index: usize, colors: ThemeColors) -> Ele
button(card_content)
.on_press(Message::ViewerSelectFile(img_path.clone()))
.width(CARD_W)
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style {
background: Some(iced::Background::Color(frame_color)),
text_color: colors.text_primary,
border: iced::Border::default()
.color(border_color)
.width(if is_selected { 2.0 } else { 1.0 })
.rounded(6),
..Default::default()
}
})
.style(
move |_theme: &iced::Theme, status: iced::widget::button::Status| {
let background = match status {
iced::widget::button::Status::Hovered => colors.bg_hover,
iced::widget::button::Status::Pressed => colors.bg_active,
iced::widget::button::Status::Disabled => colors.bg_app,
iced::widget::button::Status::Active => frame_color,
};
iced::widget::button::Style {
background: Some(iced::Background::Color(background)),
text_color: if status == iced::widget::button::Status::Disabled {
colors.text_disabled
} else {
colors.text_primary
},
border: iced::Border::default()
.color(border_color)
.width(if is_selected { 2.0 } else { 1.0 })
.rounded(6),
..Default::default()
}
},
)
.into()
}
@@ -143,7 +158,9 @@ pub fn count_unloaded(state: &ViewerState) -> usize {
state
.images
.iter()
.filter(|p| !state.thumbnail_cache.contains_key(*p) && !state.failed_thumbnails.contains(*p))
.filter(|p| {
!state.thumbnail_cache.contains_key(*p) && !state.failed_thumbnails.contains(*p)
})
.count()
}
@@ -152,7 +169,9 @@ pub fn next_load_batch(state: &ViewerState) -> Vec<std::path::PathBuf> {
state
.images
.iter()
.filter(|p| !state.thumbnail_cache.contains_key(*p) && !state.failed_thumbnails.contains(*p))
.filter(|p| {
!state.thumbnail_cache.contains_key(*p) && !state.failed_thumbnails.contains(*p)
})
.take(LAZY_LOAD_BUDGET)
.cloned()
.collect()
@@ -0,0 +1,343 @@
//! Stable Cycle 0 feature identifiers and source-level regression gates.
//!
//! **Purpose:** Prevents the highest-risk placeholder behaviors from returning while broader GUI
//! parity work proceeds. **Logic & Workflow:** Each test owns a stable feature ID and inspects the
//! production source for architectural invariants that complement module-level behavior tests.
//! **Side Effects / Dependencies:** Reads source files from the crate at test time; performs no
//! writes and does not launch the GUI.
use std::path::PathBuf;
const MENU_SEMANTIC_DISPATCH: &str = "shell.menu.semantic_dispatch";
const MENU_NO_CUT_COPY_ALIAS: &str = "clipboard.cut.no_copy_alias";
const SCREENSHOT_NATIVE_CAPTURE: &str = "audit.screenshot.native_capture";
const SCREENSHOT_PANEL_CROP: &str = "audit.screenshot.panel_crop";
const PANE_FULL_DROP_TARGETS: &str = "cycle1.pane.full_drop_targets";
const THEME_SYNC_ISOLATION: &str = "cycle1.theme.sync_isolation";
const SIDEBAR_MODE_PERSISTENCE: &str = "cycle1.sidebar.mode_persistence";
const VECTOR_SCREEN_CONSTANT_HANDLES: &str = "cycle2.vector.screen_constant_handles";
const VECTOR_CONSTRAINED_DRAG: &str = "cycle2.vector.constrained_drag";
const VECTOR_LAYER_BOUND_CANCEL: &str = "cycle2.vector.layer_bound_cancel";
const VECTOR_BOOLEAN_RESULT: &str = "cycle2.vector.boolean_result_selection";
const VECTOR_CLAMPED_CONTROLS: &str = "cycle2.vector.clamped_controls";
const SELECTION_IRREGULAR_EDGES: &str = "cycle3.selection.irregular_edges";
const CLIPBOARD_DISTINCT_CUT: &str = "cycle3.clipboard.distinct_cut";
const RASTER_TRANSFORM_PLACEMENT: &str = "cycle3.raster.transform_placement";
const CROP_LIFECYCLE: &str = "cycle3.crop.lifecycle";
const TEXT_MULTILINE: &str = "cycle3.text.multiline";
const TRUE_GRADIENT: &str = "cycle3.gradient.linear_radial";
const CANVAS_INPUT_PARITY: &str = "cycle3.canvas.input_parity";
const PEN_SPRAY_DISTINCT: &str = "cycle3.tools.pen_spray_distinct";
const LAYERS_HIERARCHY_CONTROLS: &str = "cycle4.layers.hierarchy_controls";
const LAYER_STYLES_CANCEL_RESTORE: &str = "cycle4.layer_styles.cancel_restore";
const IMMUTABLE_PIXEL_PREVIEW: &str = "cycle4.preview.immutable_baseline";
const FILTER_SCHEMA_COVERAGE: &str = "cycle4.filters.schema_coverage";
const DOCUMENT_PATH_IDENTITY: &str = "cycle4.documents.path_identity";
const CLOSE_SAVE_CONTINUATION: &str = "cycle4.documents.close_save_continuation";
const AI_REAL_STREAMING: &str = "cycle5.ai.real_streaming";
const AI_REASONING_TRANSCRIPT: &str = "cycle5.ai.reasoning_transcript";
const SCRIPT_VALIDATED_OUTPUT: &str = "cycle5.script.validated_output";
const VIEWER_DETERMINISTIC_NAV: &str = "cycle5.viewer.deterministic_navigation";
const VIEWER_SAFE_EDIT: &str = "cycle5.viewer.safe_edit_document";
/// Reads a crate-relative source file for structural audit assertions.
///
/// **Arguments:** `relative` is a path beneath the package manifest directory.
/// **Returns:** The complete UTF-8 source text.
/// **Side Effects / Dependencies:** Reads one local source file and panics on missing test input.
fn source(relative: &str) -> String {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative);
std::fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()))
}
#[test]
fn semantic_menu_dispatch_has_no_positional_message() {
let app = source("src/app.rs");
let menus = source("src/panels/menus.rs");
assert!(
app.contains("Message::MenuCommand(command)"),
"{MENU_SEMANTIC_DISPATCH}"
);
assert!(
!app.contains("MenuAction(usize, usize)"),
"{MENU_SEMANTIC_DISPATCH}"
);
assert!(
menus.contains("command: Option<MenuCommand>"),
"{MENU_SEMANTIC_DISPATCH}"
);
}
#[test]
fn cut_does_not_dispatch_copy() {
let app = source("src/app.rs");
let menus = source("src/panels/menus.rs");
assert!(
app.contains("MenuCommand::Cut => return Task::perform(async {}, |_| Message::CutImage)"),
"{MENU_NO_CUT_COPY_ALIAS}"
);
assert!(
menus.contains("MenuCommand::Cut"),
"{MENU_NO_CUT_COPY_ALIAS}"
);
assert!(
!menus.contains("MenuItem::with_shortcut(\"Cut\", \"Ctrl+X\")"),
"{MENU_NO_CUT_COPY_ALIAS}"
);
}
#[test]
fn cycle_three_canvas_selection_clipboard_crop_text_paths_are_connected() {
let app = source("src/app.rs");
let canvas = source("src/canvas/mod.rs");
let shader = source("src/canvas/shader_canvas.rs");
let state = source("src/selection/state.rs");
let clipboard = source("src/selection/clipboard.rs");
let crop = source("src/selection/crop.rs");
let raster = source("src/raster.rs");
assert!(
state.contains("pub fn selected_spans")
&& canvas.contains("selection_fill_spans")
&& canvas.contains("Fill exact mask runs"),
"{SELECTION_IRREGULAR_EDGES}"
);
assert!(
app.contains("Message::CutImage")
&& app.contains("Message::ClearPixels")
&& app.contains("Some(Message::CutImage)"),
"{CLIPBOARD_DISTINCT_CUT}"
);
assert!(
clipboard.contains("pub fn centered_transform")
&& app.contains("placed.pos.x += 10.0")
&& app.contains("tr.sanitize_geometry(self.modifiers.shift())"),
"{RASTER_TRANSFORM_PLACEMENT}"
);
assert!(
crop.contains("start_drag_clamped") && app.contains("Message::CropConfirm"),
"{CROP_LIFECYCLE}"
);
assert!(
canvas.contains("iced::widget::text_editor(&draft.editor)")
&& app.contains("Message::TextEditorAction(action)"),
"{TEXT_MULTILINE}"
);
assert!(
raster.contains("pub fn apply_gradient")
&& app.contains("self.settings.tool_settings.gradient_type == 1"),
"{TRUE_GRADIENT}"
);
assert!(
app.contains("paint_tool && (self.modifiers.control()")
&& app.contains("paint_tool && self.modifiers.shift()")
&& app.contains("Message::FitToArea")
&& shader.contains("state.space_left_pan = true"),
"{CANVAS_INPUT_PARITY}"
);
assert!(
app.contains(".draw_pen_segment(")
&& app.contains("tip.spray_particle_size")
&& app.contains("tip.spray_density"),
"{PEN_SPRAY_DISTINCT}"
);
}
#[test]
fn screenshot_cli_uses_native_capture_and_panel_crop() {
let app = source("src/app.rs");
let screenshot = source("src/screenshot.rs");
assert!(
app.contains("iced::window::screenshot(id)"),
"{SCREENSHOT_NATIVE_CAPTURE}"
);
assert!(
app.contains("crate::screenshot::crop_panel("),
"{SCREENSHOT_PANEL_CROP}"
);
assert!(
screenshot.contains(".layout()\n .pane_regions"),
"{SCREENSHOT_PANEL_CROP}"
);
}
#[test]
fn cycle_one_state_paths_are_complete_and_persisted() {
let app = source("src/app.rs");
let settings = source("src/settings.rs");
assert!(
app.contains("self.dock.pane_grid.drop(pane, target)"),
"{PANE_FULL_DROP_TARGETS}"
);
assert!(
!app.contains("Task::perform(async move { theme }, Message::ThemeChanged)"),
"{THEME_SYNC_ISOLATION}"
);
assert!(
app.contains("self.settings.theme_preset = theme"),
"{THEME_SYNC_ISOLATION}"
);
assert!(
settings.contains("pub theme_preset: crate::theme::ThemePreset"),
"{THEME_SYNC_ISOLATION}"
);
assert!(
app.contains("settings.panel_layout.sidebar_expanded"),
"{SIDEBAR_MODE_PERSISTENCE}"
);
assert!(
app.contains("self.settings.panel_layout.sidebar_expanded = self.sidebar_expanded"),
"{SIDEBAR_MODE_PERSISTENCE}"
);
}
/// Verifies the Cycle 2 vector completion paths remain connected to pure calculations and UI.
///
/// Purpose: Provides stable source-level scorecard evidence in addition to calculation unit tests.
/// Logic & Workflow: Checks semantic state/session types and reachable handlers rather than fragile
/// line positions. Side Effects / Dependencies: Reads local production sources only.
#[test]
fn cycle_two_vector_completion_paths_are_connected() {
let app = source("src/app.rs");
let canvas = source("src/canvas/mod.rs");
let edit = source("src/vector_edit.rs");
assert!(
edit.contains("HANDLE_HIT_HALF_PX / zoom") && edit.contains("ROTATE_HIT_RADIUS_PX / zoom"),
"{VECTOR_SCREEN_CONSTANT_HANDLES}"
);
assert!(
app.contains("crate::vector_edit::transform_shape(")
&& app.contains("self.modifiers.shift()")
&& app.contains("self.modifiers.alt()")
&& app.contains("engine.set_vector_shape_bounds(")
&& app.contains("engine.set_vector_shape_angle(")
&& edit.contains("MIN_VECTOR_SIZE"),
"{VECTOR_CONSTRAINED_DRAG}"
);
assert!(
app.contains("get_layer_shapes_direct(snapshot.layer_id)")
&& app.contains("self.vector_drag_session = None"),
"{VECTOR_LAYER_BOUND_CANCEL}"
);
assert!(
app.contains("let succeeded = valid")
&& app.contains("selected_vector_shape = Some(result_index)"),
"{VECTOR_BOOLEAN_RESULT}"
);
assert!(
canvas.contains("pane_w - controls_width") && canvas.contains("Message::VectorShapeDelete"),
"{VECTOR_CLAMPED_CONTROLS}"
);
}
/// Verifies Cycle 4 panel and document-lifecycle paths remain semantic and non-destructive.
///
/// Purpose: Records stable evidence for immutable previews, style cancellation, layer hierarchy,
/// filter schema coverage, and successful-save identity. Logic & Workflow: Inspects the GUI-only
/// implementations for their explicit state and engine-API routing. Side Effects / Dependencies:
/// Reads production source files only.
#[test]
fn cycle_four_panel_and_document_paths_are_connected() {
let app = source("src/app.rs");
let layers = source("src/panels/layers.rs");
let filters = source("src/panels/filters.rs");
let filter_params = source("src/panels/filter_params.rs");
let title_bar = source("src/panels/title_bar.rs");
assert!(
layers.contains("Message::LayerToggleCollapse(info.id)")
&& layers.contains("Message::LayerToggleVisibility(info.id)")
&& layers.contains("Message::LayerToggleLock(info.id)")
&& app.contains("set_layer_parent(active_id, Some(group_id))")
&& app.contains("move_layer(id, target_idx)"),
"{LAYERS_HIERARCHY_CONTROLS}"
);
assert!(
layers.contains("Fill opacity: unavailable in hcie-engine-api")
&& layers.contains("Disabled (engine API): duplicate / mask / rasterize")
&& layers.contains("button(text(\"Dup\")")
&& layers.contains("button(text(\"Mask\")")
&& layers.contains("button(text(\"Raster\")"),
"{LAYERS_HIERARCHY_CONTROLS}: unsupported operations must be visibly disabled"
);
assert!(
app.contains("layer_style_baseline: Option<LayerStyleBaseline>")
&& app.contains("remove_layer_style_by_index(baseline.layer_id, index)")
&& app.contains("doc.engine.update_layer_style(baseline.layer_id, style)"),
"{LAYER_STYLES_CANCEL_RESTORE}"
);
assert!(
app.contains("preview_baseline: Option<PreviewBaseline>")
&& app.contains("reset_preview_to_baseline()")
&& app.contains("set_layer_pixels(baseline.layer_id, baseline.pixels.clone())"),
"{IMMUTABLE_PIXEL_PREVIEW}"
);
for variant in [
"BoxBlurGamma",
"GaussianBlurGamma",
"MedianFilter",
"SelectiveColor",
"NoisePattern",
] {
assert!(
filters.contains(variant) && filter_params.contains(variant),
"{FILTER_SCHEMA_COVERAGE}: missing {variant}"
);
}
assert!(
app.contains("fn save_document_to(")
&& app.contains("\"hcie\" => engine.save_native(&path_str)")
&& app.contains("finish_successful_save")
&& title_bar.contains("if modified"),
"{DOCUMENT_PATH_IDENTITY}"
);
assert!(
app.contains("PendingFileOperation::SaveAs { close_after: true }")
&& app.contains("continue_close_after_save()")
&& app.contains("Some(PendingFileOperation::Open)"),
"{CLOSE_SAVE_CONTINUATION}"
);
}
/// Verifies Cycle 5 AI, script, and viewer workflows have semantic handlers and safety gates.
#[test]
fn cycle_five_practical_paths_are_connected() {
let app = source("src/app.rs");
let chat = source("src/ai_chat.rs");
let ai_script = source("src/ai_script.rs");
let viewer = source("src/viewer/mod.rs");
assert!(
chat.contains("iced::stream::channel")
&& app.contains("Task::run(")
&& app.contains("task.abortable()")
&& app.contains("Message::AiChatStreamChunk(chunk)"),
"{AI_REAL_STREAMING}"
);
assert!(
app.contains("Message::AiChatReasoningChunk")
&& app.contains("Message::AiChatCopyTranscript")
&& chat.contains("pub fn transcript_text"),
"{AI_REASONING_TRANSCRIPT}"
);
assert!(
ai_script.contains("pub fn validate_script_output")
&& ai_script.contains("ANTHROPIC_API_KEY")
&& ai_script.contains("OPENAI_API_KEY"),
"{SCRIPT_VALIDATED_OUTPUT}"
);
assert!(
viewer.contains("Message::ViewerPrev")
&& viewer.contains("Message::ViewerNext")
&& app.contains("Message::ViewerSetMode(mode)"),
"{VIEWER_DETERMINISTIC_NAV}"
);
assert!(
app.contains("Open into a new editor document")
&& app.contains("self.documents.remove(document_index)"),
"{VIEWER_SAFE_EDIT}"
);
}
@@ -0,0 +1,44 @@
//! Pure Cycle 3 raster behavior tests.
use hcie_iced_gui::raster::apply_gradient;
#[test]
fn linear_gradient_interpolates_and_respects_mask() {
let mut pixels = vec![0; 3 * 4];
let mask = [255, 255, 0];
apply_gradient(
&mut pixels,
3,
1,
(0.5, 0.5),
(2.5, 0.5),
false,
[0, 0, 0, 255],
[200, 100, 50, 255],
1.0,
Some(&mask),
);
assert_eq!(&pixels[0..4], &[0, 0, 0, 255]);
assert_eq!(&pixels[4..8], &[100, 50, 25, 255]);
assert_eq!(&pixels[8..12], &[0, 0, 0, 0]);
}
#[test]
fn radial_gradient_uses_distance_from_origin() {
let mut pixels = vec![0; 3 * 4];
apply_gradient(
&mut pixels,
3,
1,
(0.5, 0.5),
(2.5, 0.5),
true,
[0, 0, 0, 255],
[200, 0, 0, 255],
1.0,
None,
);
assert_eq!(pixels[0], 0);
assert_eq!(pixels[4], 100);
assert_eq!(pixels[8], 200);
}
@@ -1,6 +1,8 @@
//! Tests for selection types: SelectionTransform, TransformHandle, CropState.
use hcie_iced_gui::selection::{CropState, SelectionTransform, TransformHandle};
use hcie_iced_gui::selection::clipboard::centered_transform;
use hcie_iced_gui::selection::state::{combine_masks, mask_bounds, selected_spans};
use hcie_iced_gui::selection::{CropState, SelectionMode, SelectionTransform, TransformHandle};
#[test]
fn test_selection_transform_is_empty() {
@@ -70,3 +72,53 @@ fn test_transform_handle_cursor_hints() {
assert_eq!(TransformHandle::TopLeft.cursor_hint(), "nwse-resize");
assert_eq!(TransformHandle::Rotate.cursor_hint(), "crosshair");
}
#[test]
fn selection_modes_combine_alpha_without_losing_soft_edges() {
let old = [0, 64, 255, 255];
let new = [255, 128, 64, 0];
assert_eq!(combine_masks(Some(&old), &new, SelectionMode::Replace), new);
assert_eq!(
combine_masks(Some(&old), &new, SelectionMode::Add),
[255, 128, 255, 255]
);
assert_eq!(
combine_masks(Some(&old), &new, SelectionMode::Subtract),
[0, 0, 191, 255]
);
assert_eq!(
combine_masks(Some(&old), &new, SelectionMode::Intersect),
[0, 64, 64, 0]
);
}
#[test]
fn irregular_mask_bounds_and_fill_spans_are_exact() {
let mask = [0, 255, 0, 0, 255, 255, 0, 0, 0, 255, 0, 0];
assert_eq!(mask_bounds(&mask, 4, 3), Some((0, 0, 2, 3)));
assert_eq!(
selected_spans(&mask, 4, 3),
vec![(1, 0, 2), (0, 1, 2), (1, 2, 2)]
);
}
#[test]
fn crop_clamps_drag_and_cancel_restores_idle_state() {
let mut crop = CropState::default();
crop.start_drag_clamped(-20.0, 5.0, 100, 80);
crop.update_drag(140.0, 120.0);
assert_eq!(crop.rect, Some((0.0, 5.0, 100.0, 75.0)));
crop.cancel();
assert_eq!(crop.canvas_size, None);
assert!(!crop.active);
}
#[test]
fn clipboard_transform_is_centered_and_transform_geometry_is_safe() {
let mut transform = centered_transform(vec![255; 8 * 4 * 4], 8, 4, 100, 50).unwrap();
assert_eq!(transform.pos, iced::Vector::new(46.0, 23.0));
transform.size = iced::Vector::new(-20.0, f32::NAN);
transform.sanitize_geometry(true);
assert!(transform.size.x > 0.0 && transform.size.y > 0.0);
assert!((transform.size.x / transform.size.y - 2.0).abs() < 0.001);
}
@@ -10,3 +10,4 @@ crate-type = ["rlib"]
hcie-engine-api = { path = "../../../hcie-engine-api" }
iced = { workspace = true }
serde_json = { workspace = true }
serde = { workspace = true }
@@ -35,9 +35,9 @@ pub fn render_module_panel(panel: &ModulePanel) -> Element<'_, PanelMessage> {
/// Renders a single `WidgetDescription` as an Iced element.
pub fn render_widget(widget: &WidgetDescription) -> Element<'_, PanelMessage> {
match widget {
WidgetDescription::Label { text: label_text, .. } => {
text(label_text.as_str()).into()
}
WidgetDescription::Label {
text: label_text, ..
} => text(label_text.as_str()).into(),
WidgetDescription::Button { id, label } => {
let id = id.clone();
let label = label.clone();
@@ -45,7 +45,14 @@ pub fn render_widget(widget: &WidgetDescription) -> Element<'_, PanelMessage> {
.on_press(PanelMessage::ValueChanged(id, serde_json::Value::Null))
.into()
}
WidgetDescription::Slider { id, label, min, max, value, .. } => {
WidgetDescription::Slider {
id,
label,
min,
max,
value,
..
} => {
let id = id.clone();
let current = *value;
@@ -91,7 +98,12 @@ pub fn render_widget(widget: &WidgetDescription) -> Element<'_, PanelMessage> {
})
.into()
}
WidgetDescription::Dropdown { id, label, options, selected } => {
WidgetDescription::Dropdown {
id,
label,
options,
selected,
} => {
let id = id.clone();
let selected_text = options.get(*selected).cloned().unwrap_or_default();
@@ -102,14 +114,12 @@ pub fn render_widget(widget: &WidgetDescription) -> Element<'_, PanelMessage> {
let idx = options.iter().position(|o| *o == choice).unwrap_or(0);
PanelMessage::ValueChanged(id.clone(), serde_json::Value::from(idx))
})
.width(Length::Fill)
.width(Length::Fill),
)
.spacing(4)
.into()
}
WidgetDescription::Separator => {
horizontal_rule(1).into()
}
WidgetDescription::Separator => horizontal_rule(1).into(),
WidgetDescription::Group { label, children } => {
let mut inner = iced::widget::Column::new()
.spacing(4)
@@ -16,7 +16,7 @@ use iced::Color;
///
/// `Photopea` is the default — matches the Photopea image editor's dark UI.
/// Other presets are kept as alternatives.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum ThemePreset {
#[default]
Photopea,
@@ -82,127 +82,127 @@ impl ThemeColors {
// ── Photopea (Default) ─────────────────────────────────────
// Exact colors from 93 Photopea screenshots.
ThemePreset::Photopea => Self {
bg_app: Color::from_rgb(0.118, 0.118, 0.118), // #1e1e1e - canvas
bg_panel: Color::from_rgb(0.165, 0.165, 0.165), // #2a2a2a - panels
bg_hover: Color::from_rgb(0.227, 0.227, 0.227), // #3a3a3a - hover
bg_active: Color::from_rgb(0.200, 0.200, 0.200), // #333333 - active tab
accent: Color::from_rgb(0.290, 0.620, 1.000), // #4a9eff - selections
accent_hover: Color::from_rgb(0.350, 0.680, 1.000),
accent_green: Color::from_rgb(0.298, 0.686, 0.314), // #4caf50 - tool active
danger: Color::from_rgb(0.906, 0.298, 0.235), // #e74c3c - account btn
border_low: Color::from_rgb(0.200, 0.200, 0.200), // #333333 - dividers
border_high: Color::from_rgb(0.267, 0.267, 0.267), // #444444 - borders
text_primary: Color::from_rgb(0.878, 0.878, 0.878), // #e0e0e0 - primary
text_secondary:Color::from_rgb(0.600, 0.600, 0.600), // #999999 - muted
text_disabled: Color::from_rgb(0.400, 0.400, 0.400), // #666666 - disabled
menu_bg: Color::from_rgb(0.910, 0.910, 0.910), // #e8e8e8 - menu bg
menu_hover: Color::from_rgb(0.702, 0.851, 1.000), // #b3d9ff - menu hover
menu_text: Color::from_rgb(0.000, 0.000, 0.000), // #000000 - menu text
menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400), // #666666 - shortcuts
bg_app: Color::from_rgb(0.118, 0.118, 0.118), // #1e1e1e - canvas
bg_panel: Color::from_rgb(0.165, 0.165, 0.165), // #2a2a2a - panels
bg_hover: Color::from_rgb(0.227, 0.227, 0.227), // #3a3a3a - hover
bg_active: Color::from_rgb(0.200, 0.200, 0.200), // #333333 - active tab
accent: Color::from_rgb(0.290, 0.620, 1.000), // #4a9eff - selections
accent_hover: Color::from_rgb(0.350, 0.680, 1.000),
accent_green: Color::from_rgb(0.298, 0.686, 0.314), // #4caf50 - tool active
danger: Color::from_rgb(0.906, 0.298, 0.235), // #e74c3c - account btn
border_low: Color::from_rgb(0.200, 0.200, 0.200), // #333333 - dividers
border_high: Color::from_rgb(0.267, 0.267, 0.267), // #444444 - borders
text_primary: Color::from_rgb(0.878, 0.878, 0.878), // #e0e0e0 - primary
text_secondary: Color::from_rgb(0.600, 0.600, 0.600), // #999999 - muted
text_disabled: Color::from_rgb(0.400, 0.400, 0.400), // #666666 - disabled
menu_bg: Color::from_rgb(0.910, 0.910, 0.910), // #e8e8e8 - menu bg
menu_hover: Color::from_rgb(0.702, 0.851, 1.000), // #b3d9ff - menu hover
menu_text: Color::from_rgb(0.000, 0.000, 0.000), // #000000 - menu text
menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400), // #666666 - shortcuts
is_light: false,
},
// ── Photoshop (Legacy) ─────────────────────────────────────
ThemePreset::Photoshop => Self {
bg_app: Color::from_rgb(0.110, 0.110, 0.118),
bg_panel: Color::from_rgb(0.196, 0.196, 0.212),
bg_hover: Color::from_rgb(0.243, 0.243, 0.259),
bg_active: Color::from_rgb(0.227, 0.227, 0.243),
accent: Color::from_rgb(0.220, 0.471, 0.863),
accent_hover: Color::from_rgb(0.260, 0.520, 0.900),
accent_green: Color::from_rgb(0.298, 0.686, 0.314),
danger: Color::from_rgb(0.800, 0.200, 0.200),
border_low: Color::from_rgb(0.157, 0.157, 0.165),
border_high: Color::from_rgb(0.251, 0.251, 0.267),
text_primary: Color::from_rgb(0.878, 0.878, 0.878),
text_secondary:Color::from_rgb(0.580, 0.580, 0.596),
bg_app: Color::from_rgb(0.110, 0.110, 0.118),
bg_panel: Color::from_rgb(0.196, 0.196, 0.212),
bg_hover: Color::from_rgb(0.243, 0.243, 0.259),
bg_active: Color::from_rgb(0.227, 0.227, 0.243),
accent: Color::from_rgb(0.220, 0.471, 0.863),
accent_hover: Color::from_rgb(0.260, 0.520, 0.900),
accent_green: Color::from_rgb(0.298, 0.686, 0.314),
danger: Color::from_rgb(0.800, 0.200, 0.200),
border_low: Color::from_rgb(0.157, 0.157, 0.165),
border_high: Color::from_rgb(0.251, 0.251, 0.267),
text_primary: Color::from_rgb(0.878, 0.878, 0.878),
text_secondary: Color::from_rgb(0.580, 0.580, 0.596),
text_disabled: Color::from_rgb(0.350, 0.350, 0.360),
menu_bg: Color::from_rgb(0.910, 0.910, 0.910),
menu_hover: Color::from_rgb(0.702, 0.851, 1.000),
menu_text: Color::from_rgb(0.000, 0.000, 0.000),
menu_bg: Color::from_rgb(0.910, 0.910, 0.910),
menu_hover: Color::from_rgb(0.702, 0.851, 1.000),
menu_text: Color::from_rgb(0.000, 0.000, 0.000),
menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400),
is_light: false,
},
// ── Monokai ────────────────────────────────────────────────
ThemePreset::ProDark => Self {
bg_app: Color::from_rgb(0.086, 0.082, 0.071),
bg_panel: Color::from_rgb(0.153, 0.157, 0.133),
bg_hover: Color::from_rgb(0.212, 0.220, 0.188),
bg_active: Color::from_rgb(0.180, 0.184, 0.160),
accent: Color::from_rgb(0.902, 0.588, 0.196),
accent_hover: Color::from_rgb(0.940, 0.640, 0.260),
accent_green: Color::from_rgb(0.298, 0.686, 0.314),
danger: Color::from_rgb(0.780, 0.220, 0.267),
border_low: Color::from_rgb(0.110, 0.106, 0.086),
border_high: Color::from_rgb(0.243, 0.251, 0.212),
text_primary: Color::from_rgb(0.973, 0.973, 0.941),
text_secondary:Color::from_rgb(0.580, 0.588, 0.533),
bg_app: Color::from_rgb(0.086, 0.082, 0.071),
bg_panel: Color::from_rgb(0.153, 0.157, 0.133),
bg_hover: Color::from_rgb(0.212, 0.220, 0.188),
bg_active: Color::from_rgb(0.180, 0.184, 0.160),
accent: Color::from_rgb(0.902, 0.588, 0.196),
accent_hover: Color::from_rgb(0.940, 0.640, 0.260),
accent_green: Color::from_rgb(0.298, 0.686, 0.314),
danger: Color::from_rgb(0.780, 0.220, 0.267),
border_low: Color::from_rgb(0.110, 0.106, 0.086),
border_high: Color::from_rgb(0.243, 0.251, 0.212),
text_primary: Color::from_rgb(0.973, 0.973, 0.941),
text_secondary: Color::from_rgb(0.580, 0.588, 0.533),
text_disabled: Color::from_rgb(0.350, 0.350, 0.330),
menu_bg: Color::from_rgb(0.910, 0.910, 0.910),
menu_hover: Color::from_rgb(0.702, 0.851, 1.000),
menu_text: Color::from_rgb(0.000, 0.000, 0.000),
menu_bg: Color::from_rgb(0.910, 0.910, 0.910),
menu_hover: Color::from_rgb(0.702, 0.851, 1.000),
menu_text: Color::from_rgb(0.000, 0.000, 0.000),
menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400),
is_light: false,
},
// ── Dracula ────────────────────────────────────────────────
ThemePreset::Amoled => Self {
bg_app: Color::from_rgb(0.094, 0.086, 0.133),
bg_panel: Color::from_rgb(0.157, 0.165, 0.212),
bg_hover: Color::from_rgb(0.220, 0.227, 0.290),
bg_active: Color::from_rgb(0.184, 0.192, 0.243),
accent: Color::from_rgb(1.000, 0.475, 0.776),
accent_hover: Color::from_rgb(1.000, 0.530, 0.820),
accent_green: Color::from_rgb(0.298, 0.686, 0.314),
danger: Color::from_rgb(0.753, 0.220, 0.294),
border_low: Color::from_rgb(0.118, 0.110, 0.165),
border_high: Color::from_rgb(0.267, 0.275, 0.345),
text_primary: Color::from_rgb(0.973, 0.973, 0.949),
text_secondary:Color::from_rgb(0.588, 0.596, 0.667),
bg_app: Color::from_rgb(0.094, 0.086, 0.133),
bg_panel: Color::from_rgb(0.157, 0.165, 0.212),
bg_hover: Color::from_rgb(0.220, 0.227, 0.290),
bg_active: Color::from_rgb(0.184, 0.192, 0.243),
accent: Color::from_rgb(1.000, 0.475, 0.776),
accent_hover: Color::from_rgb(1.000, 0.530, 0.820),
accent_green: Color::from_rgb(0.298, 0.686, 0.314),
danger: Color::from_rgb(0.753, 0.220, 0.294),
border_low: Color::from_rgb(0.118, 0.110, 0.165),
border_high: Color::from_rgb(0.267, 0.275, 0.345),
text_primary: Color::from_rgb(0.973, 0.973, 0.949),
text_secondary: Color::from_rgb(0.588, 0.596, 0.667),
text_disabled: Color::from_rgb(0.350, 0.350, 0.380),
menu_bg: Color::from_rgb(0.910, 0.910, 0.910),
menu_hover: Color::from_rgb(0.702, 0.851, 1.000),
menu_text: Color::from_rgb(0.000, 0.000, 0.000),
menu_bg: Color::from_rgb(0.910, 0.910, 0.910),
menu_hover: Color::from_rgb(0.702, 0.851, 1.000),
menu_text: Color::from_rgb(0.000, 0.000, 0.000),
menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400),
is_light: false,
},
// ── Solarized Light ────────────────────────────────────────
ThemePreset::PhotoshopLight => Self {
bg_app: Color::from_rgb(0.992, 0.965, 0.890),
bg_panel: Color::from_rgb(1.000, 0.980, 0.922),
bg_hover: Color::from_rgb(0.933, 0.910, 0.835),
bg_active: Color::from_rgb(0.878, 0.855, 0.780),
accent: Color::from_rgb(0.796, 0.294, 0.086),
accent_hover: Color::from_rgb(0.840, 0.340, 0.140),
accent_green: Color::from_rgb(0.298, 0.686, 0.314),
danger: Color::from_rgb(0.800, 0.200, 0.200),
border_low: Color::from_rgb(0.871, 0.847, 0.769),
border_high: Color::from_rgb(0.784, 0.753, 0.667),
text_primary: Color::from_rgb(0.196, 0.196, 0.157),
text_secondary:Color::from_rgb(0.471, 0.463, 0.392),
bg_app: Color::from_rgb(0.992, 0.965, 0.890),
bg_panel: Color::from_rgb(1.000, 0.980, 0.922),
bg_hover: Color::from_rgb(0.933, 0.910, 0.835),
bg_active: Color::from_rgb(0.878, 0.855, 0.780),
accent: Color::from_rgb(0.796, 0.294, 0.086),
accent_hover: Color::from_rgb(0.840, 0.340, 0.140),
accent_green: Color::from_rgb(0.298, 0.686, 0.314),
danger: Color::from_rgb(0.800, 0.200, 0.200),
border_low: Color::from_rgb(0.871, 0.847, 0.769),
border_high: Color::from_rgb(0.784, 0.753, 0.667),
text_primary: Color::from_rgb(0.196, 0.196, 0.157),
text_secondary: Color::from_rgb(0.471, 0.463, 0.392),
text_disabled: Color::from_rgb(0.650, 0.640, 0.580),
menu_bg: Color::from_rgb(0.910, 0.910, 0.910),
menu_hover: Color::from_rgb(0.702, 0.851, 1.000),
menu_text: Color::from_rgb(0.000, 0.000, 0.000),
menu_bg: Color::from_rgb(0.910, 0.910, 0.910),
menu_hover: Color::from_rgb(0.702, 0.851, 1.000),
menu_text: Color::from_rgb(0.000, 0.000, 0.000),
menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400),
is_light: true,
},
// ── Nord ───────────────────────────────────────────────────
ThemePreset::ProLight => Self {
bg_app: Color::from_rgb(0.894, 0.910, 0.933),
bg_panel: Color::from_rgb(0.925, 0.937, 0.957),
bg_hover: Color::from_rgb(0.847, 0.871, 0.902),
bg_active: Color::from_rgb(0.816, 0.839, 0.871),
accent: Color::from_rgb(0.369, 0.506, 0.675),
accent_hover: Color::from_rgb(0.410, 0.540, 0.710),
accent_green: Color::from_rgb(0.298, 0.686, 0.314),
danger: Color::from_rgb(0.820, 0.220, 0.220),
border_low: Color::from_rgb(0.831, 0.855, 0.886),
border_high: Color::from_rgb(0.706, 0.737, 0.784),
text_primary: Color::from_rgb(0.180, 0.204, 0.251),
text_secondary:Color::from_rgb(0.392, 0.431, 0.502),
bg_app: Color::from_rgb(0.894, 0.910, 0.933),
bg_panel: Color::from_rgb(0.925, 0.937, 0.957),
bg_hover: Color::from_rgb(0.847, 0.871, 0.902),
bg_active: Color::from_rgb(0.816, 0.839, 0.871),
accent: Color::from_rgb(0.369, 0.506, 0.675),
accent_hover: Color::from_rgb(0.410, 0.540, 0.710),
accent_green: Color::from_rgb(0.298, 0.686, 0.314),
danger: Color::from_rgb(0.820, 0.220, 0.220),
border_low: Color::from_rgb(0.831, 0.855, 0.886),
border_high: Color::from_rgb(0.706, 0.737, 0.784),
text_primary: Color::from_rgb(0.180, 0.204, 0.251),
text_secondary: Color::from_rgb(0.392, 0.431, 0.502),
text_disabled: Color::from_rgb(0.580, 0.610, 0.650),
menu_bg: Color::from_rgb(0.910, 0.910, 0.910),
menu_hover: Color::from_rgb(0.702, 0.851, 1.000),
menu_text: Color::from_rgb(0.000, 0.000, 0.000),
menu_bg: Color::from_rgb(0.910, 0.910, 0.910),
menu_hover: Color::from_rgb(0.702, 0.851, 1.000),
menu_text: Color::from_rgb(0.000, 0.000, 0.000),
menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400),
is_light: true,
},