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
+214 -66
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('/'))
) -> 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 response = client.post(&url_endpoint).json(&body).send().await
.map_err(|e| format!("Network Failure: {}", e))?;
.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 err_msg = response.text().await.unwrap_or_else(|_| "Unknown HTTP error".to_string());
return Err(format!("HTTP Error {}: {}", status, err_msg));
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();
use futures_util::StreamExt;
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
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();
}
// Handle regular content
if let Some(content) = delta["content"].as_str() {
if !content.is_empty() {
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();
})
}
Ok(full_accumulated)
/// 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]");
}
#[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 {
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 {
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 {
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 {
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)]
@@ -48,11 +65,7 @@ pub fn rgb_to_hsv(r: u8, g: u8, b: u8) -> Hsv {
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,85 +88,38 @@ 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)
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()
}
@@ -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 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))
let q = if l < 0.5 {
l * (1.0 + s)
} else {
l + s - l * s
};
((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8)
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),
)
};
(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,
]
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
@@ -94,14 +91,5 @@ pub fn view(
.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(
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::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(
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::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)
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,
iced::widget::tooltip(
button(text("×").size(10))
.on_press(Message::NoOp)
.on_press(Message::DocClose(idx))
.padding([0, 4])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
.style(
move |_theme: &iced::Theme, status: iced::widget::button::Status| {
match status {
iced::widget::button::Status::Hovered => iced::widget::button::Style {
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))),
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)]
},
);
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]
row![
title_content,
iced::widget::Space::with_width(Length::Fill),
maximize_btn,
close_btn
]
.spacing(2)
.align_y(iced::Alignment::Center)
.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;
+26 -32
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);
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);
}
other if !other.starts_with('-') => {
load_path = Some(std::path::PathBuf::from(other));
}
unknown => {
eprintln!("Unknown argument: {}", unknown);
std::process::exit(1);
}
}
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)
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);
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,
]
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)
)
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))),
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)
)
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))),
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))
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]);
.padding([6, 12])
};
let copy_btn = button(text("Copy to Editor").size(11))
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]);
.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,13 +85,11 @@ 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)
)
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),
background: Some(iced::Background::Color(colors.bg_app)),
border: iced::Border::default().rounded(4).color(colors.border_high),
..Default::default()
});
@@ -83,10 +97,18 @@ pub fn view(app: &HcieIcedApp) -> Element<'static, Message> {
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,19 +299,29 @@ 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 {
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,
@@ -215,17 +329,35 @@ 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))),
text_color: colors.text_secondary,
border: iced::Border::default(),
background: Some(iced::Background::Color(if active {
colors.bg_active
} else {
iced::Color::TRANSPARENT
})),
text_color: if active {
colors.accent
} else {
colors.text_secondary
},
border: iced::Border::default()
.color(if active {
colors.accent
} else {
colors.border_low
})
.width(if active { 1 } else { 0 }),
..Default::default()
},
}
});
tabs = tabs.push(btn);
},
);
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,11 +367,7 @@ pub fn view(
let c = colors;
let style = preset.style;
let thumb = container(
iced::widget::image(preview)
.width(48)
.height(48)
)
let thumb = container(iced::widget::image(preview).width(48).height(48))
.width(56)
.height(56)
.center_x(48)
@@ -264,13 +392,15 @@ pub fn view(
.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,21 +431,21 @@ pub fn view(
.step(1.0)
.width(Length::Fill);
let opacity_slider = slider(0.0..=1.0, current_opacity, |v| Message::BrushOpacityChanged(v))
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))
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)
)
let live_preview = container(iced::widget::image(preview_handle).width(64).height(64))
.width(Length::Fill)
.height(80)
.center_x(Length::Fill)
@@ -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))
row![button(text("Import ABR").size(10))
.on_press(Message::BrushImportAbr)
.padding([4, 8])
.style(move |_theme: &iced::Theme, status: iced::widget::button::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.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 {
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,16 +739,20 @@ 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))
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 }),
@@ -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))
let apply_btn = if layer_editable {
button(text("Apply").size(11))
.on_press(Message::FilterApply)
.padding([6, 12]);
.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,13 +222,16 @@ 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)]
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,10 +40,7 @@ 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),
)
container(row![text(label).size(SECTION)].spacing(4))
.width(Length::Fill)
.padding([3, 6])
.style(move |_theme| iced::widget::container::Style {
@@ -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 })),
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))
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)
.padding([4, 6]),
a_menu,
]
.spacing(2)
).width(Length::Fill).style(move |_theme| iced::widget::container::Style {
)
.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))
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)
.padding([4, 6]),
b_menu,
]
.spacing(2)
).width(Length::Fill).style(move |_theme| iced::widget::container::Style {
)
.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 {
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,16 +47,14 @@ 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),
]
let panel = column![scrollable(history_list).height(Length::Fill),]
.spacing(0)
.padding(4);
@@ -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 {
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),
} 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,9 +89,7 @@ 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),
]
let panel = column![scrollable(content).height(Length::Fill),]
.spacing(0)
.padding(4);
@@ -6,10 +6,13 @@
//! Includes a clickable color swatch with HSL color picker popup.
use crate::app::Message;
use crate::color_picker::{self, ColorPickerTarget};
use crate::panels::styles;
use crate::theme::ThemeColors;
use hcie_engine_api::LayerStyle;
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};
/// All 10 standard Photoshop layer style types.
@@ -28,33 +31,67 @@ const STYLE_DEFS: &[(&str, &str, [f32; 3])] = &[
/// Blend mode options for the dropdown.
const BLEND_MODES: &[&str] = &[
"Normal", "Dissolve", "Darken", "Multiply", "Color Burn", "Linear Burn",
"Darker Color", "Lighten", "Screen", "Color Dodge", "Linear Dodge",
"Lighter Color", "Overlay", "Soft Light", "Hard Light", "Vivid Light",
"Linear Light", "Pin Light", "Hard Mix", "Difference", "Exclusion",
"Subtract", "Divide", "Hue", "Saturation", "Color", "Luminosity",
"Normal",
"Dissolve",
"Darken",
"Multiply",
"Color Burn",
"Linear Burn",
"Darker Color",
"Lighten",
"Screen",
"Color Dodge",
"Linear Dodge",
"Lighter Color",
"Overlay",
"Soft Light",
"Hard Light",
"Vivid Light",
"Linear Light",
"Pin Light",
"Hard Mix",
"Difference",
"Exclusion",
"Subtract",
"Divide",
"Hue",
"Saturation",
"Color",
"Luminosity",
];
/// Bevel & Emboss style options.
const BEVEL_STYLES: &[&str] = &[
"Inner Bevel", "Outer Bevel", "Emboss", "Pillow Emboss", "Stroke Emboss",
"Inner Bevel",
"Outer Bevel",
"Emboss",
"Pillow Emboss",
"Stroke Emboss",
];
/// Bevel & Emboss technique options.
const BEVEL_TECHNIQUES: &[&str] = &[
"Smooth", "Chisel Hard", "Chisel Soft",
];
const BEVEL_TECHNIQUES: &[&str] = &["Smooth", "Chisel Hard", "Chisel Soft"];
/// Bevel & Emboss direction options.
const BEVEL_DIRECTIONS: &[&str] = &[
"Up", "Down",
];
const BEVEL_DIRECTIONS: &[&str] = &["Up", "Down"];
/// Bevel & Emboss contour options.
const BEVEL_CONTOURS: &[&str] = &[
"Linear", "Cone", "Cone-Inverted", "Gaussian", "Half Round",
"Round", "Ring", "Ring-Double", "Sawtooth", "Square", "Valley",
"Shallow-Slope", "Wave", "Cove", "Washboard",
"Linear",
"Cone",
"Cone-Inverted",
"Gaussian",
"Half Round",
"Round",
"Ring",
"Ring-Double",
"Sawtooth",
"Square",
"Valley",
"Shallow-Slope",
"Wave",
"Cove",
"Washboard",
];
/// Mapping: human-readable BevelEmboss style name -> internal enum string.
@@ -120,7 +157,9 @@ fn style_matches(discriminant: &str, style: &LayerStyle) -> bool {
/// Get enabled state for a style.
fn style_enabled(discriminant: &str, styles: &[LayerStyle]) -> bool {
styles.iter().find(|s| style_matches(discriminant, s))
styles
.iter()
.find(|s| style_matches(discriminant, s))
.map(|s| match s {
LayerStyle::DropShadow { enabled, .. }
| LayerStyle::InnerShadow { enabled, .. }
@@ -138,7 +177,9 @@ fn style_enabled(discriminant: &str, styles: &[LayerStyle]) -> bool {
/// Get the blend mode string for a style.
fn get_blend_mode(discriminant: &str, styles: &[LayerStyle]) -> String {
styles.iter().find(|s| style_matches(discriminant, s))
styles
.iter()
.find(|s| style_matches(discriminant, s))
.map(|s| match s {
LayerStyle::DropShadow { blend_mode, .. }
| LayerStyle::InnerShadow { blend_mode, .. }
@@ -155,7 +196,9 @@ fn get_blend_mode(discriminant: &str, styles: &[LayerStyle]) -> String {
/// Get the color for a style (first color parameter).
fn get_style_color(discriminant: &str, styles: &[LayerStyle]) -> [u8; 4] {
styles.iter().find(|s| style_matches(discriminant, s))
styles
.iter()
.find(|s| style_matches(discriminant, s))
.map(|s| match s {
LayerStyle::DropShadow { color, .. }
| LayerStyle::InnerShadow { color, .. }
@@ -164,41 +207,22 @@ fn get_style_color(discriminant: &str, styles: &[LayerStyle]) -> [u8; 4] {
| LayerStyle::ColorOverlay { color, .. }
| LayerStyle::Satin { color, .. }
| LayerStyle::Stroke { color, .. } => *color,
LayerStyle::BevelEmboss { highlight_color, .. } => *highlight_color,
LayerStyle::BevelEmboss {
highlight_color, ..
} => *highlight_color,
_ => [0, 0, 0, 255],
})
.unwrap_or([0, 0, 0, 255])
}
/// Convert HSL (h: 0..360, s: 0..1, l: 0..1) to RGB u8.
fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [u8; 3] {
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 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))
};
[(r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8]
}
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 }
}
/// Build the floating Layer Style dialog.
pub fn view<'a>(
styles_list: &'a [LayerStyle],
selected_style: usize,
colors: ThemeColors,
show_color_picker: bool,
color_picker_idx: usize,
color_hsl: (f32, f32, f32),
_show_color_picker: bool,
_color_picker_idx: usize,
_color_hsl: (f32, f32, f32),
drag_offset: (f32, f32),
) -> Element<'a, Message> {
// ── Left sidebar: style list ──
@@ -214,27 +238,39 @@ pub fn view<'a>(
.on_press(Message::LayerStyleToggle(i as u32))
.padding([1, 4]);
let label_text = text(label).size(10).style(move |_theme: &iced::Theme| iced::widget::text::Style {
let label_text =
text(label)
.size(10)
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(badge_color),
});
// We wrap only the label in mouse_area(Message::LayerStyleSelect(i)) so it doesn't intercept toggle button clicks.
let label_container = container(label_text)
.width(Length::Fill)
.padding([3, 4]);
let label_container = container(label_text).width(Length::Fill).padding([3, 4]);
let clickable_label = iced::widget::mouse_area(label_container)
.on_press(Message::LayerStyleSelect(i))
.interaction(iced::mouse::Interaction::Pointer);
let item_row = row![toggle_btn, clickable_label].spacing(3).align_y(iced::Alignment::Center);
let item_row = row![toggle_btn, clickable_label]
.spacing(3)
.align_y(iced::Alignment::Center);
let item_bg = if is_selected {
iced::widget::container::Style {
background: Some(iced::Background::Color(
iced::Color::from_rgba(color_arr[0], color_arr[1], color_arr[2], 0.2),
)),
border: iced::Border::default().color(iced::Color::from_rgb(color_arr[0], color_arr[1], color_arr[2])).width(1),
background: Some(iced::Background::Color(iced::Color::from_rgba(
color_arr[0],
color_arr[1],
color_arr[2],
0.2,
))),
border: iced::Border::default()
.color(iced::Color::from_rgb(
color_arr[0],
color_arr[1],
color_arr[2],
))
.width(1),
..Default::default()
}
} else if enabled {
@@ -261,7 +297,7 @@ pub fn view<'a>(
.width(Length::Fixed(180.0));
// ── Right side: parameters ──
let params = build_style_params(selected_style, styles_list, colors, show_color_picker, color_picker_idx, color_hsl);
let params = build_style_params(selected_style, styles_list, colors);
// ── Buttons ──
let ok_btn = button(text("OK").size(11))
@@ -270,18 +306,19 @@ pub fn view<'a>(
let cancel_btn = button(text("Cancel").size(11))
.on_press(Message::LayerStyleCancel)
.padding([6, 20]);
let buttons = row![ok_btn, cancel_btn].spacing(8).align_y(iced::Alignment::Center);
let buttons = row![ok_btn, cancel_btn]
.spacing(8)
.align_y(iced::Alignment::Center);
// ── Dialog content ──
let content = column![
row![
let content = column![row![
sidebar,
column![params, horizontal_rule(1), buttons]
.width(Length::Fill)
.spacing(4)
.padding(8),
].spacing(4),
]
.spacing(4),]
.spacing(4)
.padding(8);
@@ -300,15 +337,10 @@ pub fn view<'a>(
// Make the title bar draggable — on_press starts the drag.
// Global mouse move is handled by the subscription in app.rs.
let drag_title = iced::widget::mouse_area(title_bar)
.on_press(Message::LayerStyleDialogDragStart(0.0, 0.0));
let drag_title =
iced::widget::mouse_area(title_bar).on_press(Message::LayerStyleDialogDragStart(0.0, 0.0));
let dialog = column![
drag_title,
horizontal_rule(1),
content,
]
.spacing(0);
let dialog = column![drag_title, horizontal_rule(1), content,].spacing(0);
// ── Floating panel with offset from center + backdrop ──
let ox = drag_offset.0;
@@ -319,7 +351,10 @@ pub fn view<'a>(
.height(Length::Fixed(420.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(2).rounded(8),
border: iced::Border::default()
.color(colors.border_high)
.width(2)
.rounded(8),
shadow: iced::Shadow {
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.6),
offset: iced::Vector::new(4.0, 4.0),
@@ -330,10 +365,7 @@ pub fn view<'a>(
// Apply horizontal offset using Space and doubling technique
let dialog_row = if ox >= 0.0 {
row![
Space::with_width(Length::Fixed(ox * 2.0)),
dialog_container,
]
row![Space::with_width(Length::Fixed(ox * 2.0)), dialog_container,]
} else {
row![
dialog_container,
@@ -343,15 +375,9 @@ pub fn view<'a>(
// Apply vertical offset using Space and doubling technique
let dialog_positioned = if oy >= 0.0 {
column![
Space::with_height(Length::Fixed(oy * 2.0)),
dialog_row,
]
column![Space::with_height(Length::Fixed(oy * 2.0)), dialog_row,]
} else {
column![
dialog_row,
Space::with_height(Length::Fixed(-oy * 2.0)),
]
column![dialog_row, Space::with_height(Length::Fixed(-oy * 2.0)),]
};
let backdrop = container(dialog_positioned)
@@ -368,9 +394,6 @@ fn build_style_params<'a>(
selected: usize,
styles_list: &[LayerStyle],
colors: ThemeColors,
show_color_picker: bool,
color_picker_idx: usize,
color_hsl: (f32, f32, f32),
) -> Element<'a, Message> {
if selected >= STYLE_DEFS.len() {
return text("Select a style from the list").size(11).into();
@@ -396,7 +419,11 @@ fn build_style_params<'a>(
.width(Length::Fill)
.text_size(10);
params = params.push(row![text("Blend Mode:").size(10), blend_list].spacing(4).align_y(iced::Alignment::Center));
params = params.push(
row![text("Blend Mode:").size(10), blend_list]
.spacing(4)
.align_y(iced::Alignment::Center),
);
// Opacity slider (common to most styles)
let opacity = match style {
@@ -409,102 +436,210 @@ fn build_style_params<'a>(
| Some(LayerStyle::GradientOverlay { opacity, .. })
| Some(LayerStyle::PatternOverlay { opacity, .. })
| Some(LayerStyle::Stroke { opacity, .. }) => *opacity,
Some(LayerStyle::BevelEmboss { highlight_opacity, .. }) => *highlight_opacity,
Some(LayerStyle::BevelEmboss {
highlight_opacity, ..
}) => *highlight_opacity,
_ => 1.0,
};
params = params.push(slider_row("Opacity", opacity, 0.0..=1.0, move |v| Message::LayerStyleUpdateParam(selected, "opacity".to_string(), v), colors));
params = params.push(slider_row(
"Opacity",
opacity,
0.0..=1.0,
move |v| Message::LayerStyleUpdateParam(selected, "opacity".to_string(), v),
colors,
));
// Color swatch — clickable to open color picker
let sc = style_color;
let swatch_bg = container(text(""))
.width(32)
.height(24)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
sc[0] as f32 / 255.0, sc[1] as f32 / 255.0, sc[2] as f32 / 255.0, 1.0,
))),
border: iced::Border::default()
.color(iced::Color::from_rgba(1.0, 1.0, 1.0, 0.15))
.width(1)
.rounded(4),
shadow: iced::Shadow {
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.6),
offset: iced::Vector::new(0.0, 1.0),
blur_radius: 4.0,
},
..Default::default()
});
let swatch_clickable = iced::widget::mouse_area(swatch_bg)
.on_press(Message::ShowStyleColorPicker(selected))
.interaction(iced::mouse::Interaction::Pointer);
let color_label = row![text("Color:").size(10), swatch_clickable].spacing(4).align_y(iced::Alignment::Center);
params = params.push(color_label);
params = params.push(color_picker::color_swatch(
style_color,
"Color",
ColorPickerTarget::StyleMain(selected),
));
// Style-specific sliders
match disc {
"DropShadow" | "InnerShadow" => {
let (angle, distance, spread, size) = match style {
Some(LayerStyle::DropShadow { angle, distance, spread, size, .. }) |
Some(LayerStyle::InnerShadow { angle, distance, spread, size, .. }) =>
(*angle, *distance, *spread, *size),
Some(LayerStyle::DropShadow {
angle,
distance,
spread,
size,
..
})
| Some(LayerStyle::InnerShadow {
angle,
distance,
spread,
size,
..
}) => (*angle, *distance, *spread, *size),
_ => (120.0, 5.0, 0.0, 5.0),
};
params = params.push(slider_row("Angle", angle, 0.0..=360.0, move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v), colors));
params = params.push(slider_row("Distance", distance, 0.0..=100.0, move |v| Message::LayerStyleUpdateParam(selected, "distance".to_string(), v), colors));
params = params.push(slider_row("Spread", spread, 0.0..=100.0, move |v| Message::LayerStyleUpdateParam(selected, "spread".to_string(), v), colors));
params = params.push(slider_row("Size", size, 0.0..=250.0, move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v), colors));
params = params.push(slider_row(
"Angle",
angle,
0.0..=360.0,
move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v),
colors,
));
params = params.push(slider_row(
"Distance",
distance,
0.0..=100.0,
move |v| Message::LayerStyleUpdateParam(selected, "distance".to_string(), v),
colors,
));
params = params.push(slider_row(
"Spread",
spread,
0.0..=100.0,
move |v| Message::LayerStyleUpdateParam(selected, "spread".to_string(), v),
colors,
));
params = params.push(slider_row(
"Size",
size,
0.0..=250.0,
move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v),
colors,
));
}
"OuterGlow" | "InnerGlow" => {
let (spread, size) = match style {
Some(LayerStyle::OuterGlow { spread, size, .. }) |
Some(LayerStyle::InnerGlow { spread, size, .. }) =>
(*spread, *size),
Some(LayerStyle::OuterGlow { spread, size, .. })
| Some(LayerStyle::InnerGlow { spread, size, .. }) => (*spread, *size),
_ => (0.0, 5.0),
};
params = params.push(slider_row("Spread", spread, 0.0..=100.0, move |v| Message::LayerStyleUpdateParam(selected, "spread".to_string(), v), colors));
params = params.push(slider_row("Size", size, 0.0..=250.0, move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v), colors));
params = params.push(slider_row(
"Spread",
spread,
0.0..=100.0,
move |v| Message::LayerStyleUpdateParam(selected, "spread".to_string(), v),
colors,
));
params = params.push(slider_row(
"Size",
size,
0.0..=250.0,
move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v),
colors,
));
}
"BevelEmboss" => {
let (depth, size, angle, altitude, soften, style_str, technique_str, direction_str,
highlight_opacity, shadow_opacity, hl_color, sh_color, hl_blend, sh_blend,
contour_str) = match style {
let (
depth,
size,
angle,
altitude,
soften,
style_str,
technique_str,
direction_str,
highlight_opacity,
shadow_opacity,
hl_color,
sh_color,
hl_blend,
sh_blend,
contour_str,
) = match style {
Some(LayerStyle::BevelEmboss {
depth, size, angle, altitude, soften, style, technique, direction,
highlight_opacity, shadow_opacity, highlight_color, shadow_color,
highlight_blend_mode, shadow_blend_mode, contour, ..
}) => (*depth, *size, *angle, *altitude, *soften,
style.clone(), technique.clone(), direction.clone(),
*highlight_opacity, *shadow_opacity, *highlight_color, *shadow_color,
highlight_blend_mode.clone(), shadow_blend_mode.clone(),
contour.clone()),
_ => (100.0, 5.0, 120.0, 30.0, 0.0,
"InnerBevel".to_string(), "Smooth".to_string(), "Up".to_string(),
0.75, 0.75, [255, 255, 255, 255], [0, 0, 0, 255],
"Screen".to_string(), "Multiply".to_string(),
"Linear".to_string()),
depth,
size,
angle,
altitude,
soften,
style,
technique,
direction,
highlight_opacity,
shadow_opacity,
highlight_color,
shadow_color,
highlight_blend_mode,
shadow_blend_mode,
contour,
..
}) => (
*depth,
*size,
*angle,
*altitude,
*soften,
style.clone(),
technique.clone(),
direction.clone(),
*highlight_opacity,
*shadow_opacity,
*highlight_color,
*shadow_color,
highlight_blend_mode.clone(),
shadow_blend_mode.clone(),
contour.clone(),
),
_ => (
100.0,
5.0,
120.0,
30.0,
0.0,
"InnerBevel".to_string(),
"Smooth".to_string(),
"Up".to_string(),
0.75,
0.75,
[255, 255, 255, 255],
[0, 0, 0, 255],
"Screen".to_string(),
"Multiply".to_string(),
"Linear".to_string(),
),
};
// ── Structure section: Style, Technique, Direction ──
let style_label_str = bevel_style_to_label(&style_str);
let style_static: &'static str = Box::leak(style_label_str.to_string().into_boxed_str());
let style_static: &'static str =
Box::leak(style_label_str.to_string().into_boxed_str());
let style_list = pick_list(BEVEL_STYLES, Some(style_static), move |v: &str| {
Message::LayerStyleUpdateString(selected, "style".to_string(), bevel_style_to_internal(v).to_string())
}).text_size(10);
Message::LayerStyleUpdateString(
selected,
"style".to_string(),
bevel_style_to_internal(v).to_string(),
)
})
.text_size(10);
let tech_label_str = bevel_technique_to_label(&technique_str);
let tech_static: &'static str = Box::leak(tech_label_str.to_string().into_boxed_str());
let tech_list = pick_list(BEVEL_TECHNIQUES, Some(tech_static), move |v: &str| {
Message::LayerStyleUpdateString(selected, "technique".to_string(), bevel_technique_to_internal(v).to_string())
}).text_size(10);
Message::LayerStyleUpdateString(
selected,
"technique".to_string(),
bevel_technique_to_internal(v).to_string(),
)
})
.text_size(10);
let dir_static: &'static str = Box::leak(direction_str.clone().into_boxed_str());
let dir_list = pick_list(BEVEL_DIRECTIONS, Some(dir_static), move |v: &str| {
Message::LayerStyleUpdateString(selected, "direction".to_string(), v.to_string())
}).text_size(10);
})
.text_size(10);
params = params.push(row![text("Style:").size(10), style_list, Space::with_width(Length::Fixed(10.0)),
text("Tech:").size(10), tech_list, Space::with_width(Length::Fixed(10.0)),
text("Dir:").size(10), dir_list].spacing(3).align_y(iced::Alignment::Center));
params = params.push(
row![
text("Style:").size(10),
style_list,
Space::with_width(Length::Fixed(10.0)),
text("Tech:").size(10),
tech_list,
Space::with_width(Length::Fixed(10.0)),
text("Dir:").size(10),
dir_list
]
.spacing(3)
.align_y(iced::Alignment::Center),
);
params = params.push(horizontal_rule(1));
@@ -514,59 +649,67 @@ fn build_style_params<'a>(
// Highlight blend mode dropdown
let hl_blend_static: &'static str = Box::leak(hl_blend.clone().into_boxed_str());
let hl_blend_list = pick_list(BLEND_MODES, Some(hl_blend_static), move |mode: &str| {
Message::LayerStyleUpdateString(selected, "highlight_blend".to_string(), mode.to_string())
}).width(Length::Fixed(110.0)).text_size(10);
Message::LayerStyleUpdateString(
selected,
"highlight_blend".to_string(),
mode.to_string(),
)
})
.width(Length::Fixed(110.0))
.text_size(10);
params = params.push(row![text("Hl Mode:").size(10), hl_blend_list].spacing(3).align_y(iced::Alignment::Center));
params = params.push(
row![text("Hl Mode:").size(10), hl_blend_list]
.spacing(3)
.align_y(iced::Alignment::Center),
);
// Highlight opacity slider
params = params.push(slider_row("Hl Opacity", highlight_opacity, 0.0..=1.0, move |v| Message::LayerStyleUpdateParam(selected, "highlight_opacity".to_string(), v), colors));
params = params.push(slider_row(
"Hl Opacity",
highlight_opacity,
0.0..=1.0,
move |v| {
Message::LayerStyleUpdateParam(selected, "highlight_opacity".to_string(), v)
},
colors,
));
// Highlight color swatch
let hc = hl_color;
let hl_swatch_bg = container(text(""))
.width(32).height(24)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
hc[0] as f32 / 255.0, hc[1] as f32 / 255.0, hc[2] as f32 / 255.0, 1.0,
))),
border: iced::Border::default().color(iced::Color::from_rgba(1.0, 1.0, 1.0, 0.15)).width(1).rounded(4),
shadow: iced::Shadow { color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.6), offset: iced::Vector::new(0.0, 1.0), blur_radius: 4.0 },
..Default::default()
});
let hl_swatch_clickable = iced::widget::mouse_area(hl_swatch_bg)
.on_press(Message::ShowStyleColorPicker(selected))
.interaction(iced::mouse::Interaction::Pointer);
params = params.push(row![text("Hl Color:").size(10), hl_swatch_clickable].spacing(4).align_y(iced::Alignment::Center));
params = params.push(color_picker::color_swatch(
hl_color,
"Highlight Color",
ColorPickerTarget::StyleHighlight(selected),
));
// ── Shadow ──
// Shadow blend mode dropdown
let sh_blend_static: &'static str = Box::leak(sh_blend.clone().into_boxed_str());
let sh_blend_list = pick_list(BLEND_MODES, Some(sh_blend_static), move |mode: &str| {
Message::LayerStyleUpdateShadowBlend(selected, mode.to_string())
}).width(Length::Fixed(110.0)).text_size(10);
})
.width(Length::Fixed(110.0))
.text_size(10);
params = params.push(row![text("Sh Mode:").size(10), sh_blend_list].spacing(3).align_y(iced::Alignment::Center));
params = params.push(
row![text("Sh Mode:").size(10), sh_blend_list]
.spacing(3)
.align_y(iced::Alignment::Center),
);
// Shadow opacity slider
params = params.push(slider_row("Sh Opacity", shadow_opacity, 0.0..=1.0, move |v| Message::LayerStyleUpdateShadowOpacity(selected, v), colors));
params = params.push(slider_row(
"Sh Opacity",
shadow_opacity,
0.0..=1.0,
move |v| Message::LayerStyleUpdateShadowOpacity(selected, v),
colors,
));
// Shadow color swatch
let sc = sh_color;
let sh_swatch_bg = container(text(""))
.width(32).height(24)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
sc[0] as f32 / 255.0, sc[1] as f32 / 255.0, sc[2] as f32 / 255.0, 1.0,
))),
border: iced::Border::default().color(iced::Color::from_rgba(1.0, 1.0, 1.0, 0.15)).width(1).rounded(4),
shadow: iced::Shadow { color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.6), offset: iced::Vector::new(0.0, 1.0), blur_radius: 4.0 },
..Default::default()
});
let sh_swatch_clickable = iced::widget::mouse_area(sh_swatch_bg)
.on_press(Message::ShowStyleShadowColorPicker(selected))
.interaction(iced::mouse::Interaction::Pointer);
params = params.push(row![text("Sh Color:").size(10), sh_swatch_clickable].spacing(4).align_y(iced::Alignment::Center));
params = params.push(color_picker::color_swatch(
sh_color,
"Shadow Color",
ColorPickerTarget::StyleShadow(selected),
));
params = params.push(horizontal_rule(1));
@@ -575,134 +718,137 @@ fn build_style_params<'a>(
// Contour dropdown
let contour_static: &'static str = Box::leak(contour_str.clone().into_boxed_str());
let contour_list: iced::widget::PickList<&str, &[&str], &str, Message> = pick_list(BEVEL_CONTOURS, Some(contour_static), move |v: &str| {
let contour_list: iced::widget::PickList<&str, &[&str], &str, Message> =
pick_list(BEVEL_CONTOURS, Some(contour_static), move |v: &str| {
Message::LayerStyleUpdateString(selected, "contour".to_string(), v.to_string())
}).width(Length::Fixed(120.0)).text_size(10);
})
.width(Length::Fixed(120.0))
.text_size(10);
params = params.push(row![text("Contour:").size(10), contour_list].spacing(3));
params = params.push(slider_row("Depth", depth, 0.0..=500.0, move |v| Message::LayerStyleUpdateParam(selected, "depth".to_string(), v), colors));
params = params.push(slider_row("Size", size, 0.0..=250.0, move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v), colors));
params = params.push(slider_row("Angle", angle, 0.0..=360.0, move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v), colors));
params = params.push(slider_row("Altitude", altitude, 0.0..=90.0, move |v| Message::LayerStyleUpdateParam(selected, "altitude".to_string(), v), colors));
params = params.push(slider_row("Soften", soften, 0.0..=16.0, move |v| Message::LayerStyleUpdateParam(selected, "soften".to_string(), v), colors));
params = params.push(slider_row(
"Depth",
depth,
0.0..=500.0,
move |v| Message::LayerStyleUpdateParam(selected, "depth".to_string(), v),
colors,
));
params = params.push(slider_row(
"Size",
size,
0.0..=250.0,
move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v),
colors,
));
params = params.push(slider_row(
"Angle",
angle,
0.0..=360.0,
move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v),
colors,
));
params = params.push(slider_row(
"Altitude",
altitude,
0.0..=90.0,
move |v| Message::LayerStyleUpdateParam(selected, "altitude".to_string(), v),
colors,
));
params = params.push(slider_row(
"Soften",
soften,
0.0..=16.0,
move |v| Message::LayerStyleUpdateParam(selected, "soften".to_string(), v),
colors,
));
}
"Satin" => {
let (angle, distance, size) = match style {
Some(LayerStyle::Satin { angle, distance, size, .. }) =>
(*angle, *distance, *size),
Some(LayerStyle::Satin {
angle,
distance,
size,
..
}) => (*angle, *distance, *size),
_ => (120.0, 11.0, 14.0),
};
params = params.push(slider_row("Angle", angle, 0.0..=360.0, move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v), colors));
params = params.push(slider_row("Distance", distance, 0.0..=100.0, move |v| Message::LayerStyleUpdateParam(selected, "distance".to_string(), v), colors));
params = params.push(slider_row("Size", size, 0.0..=250.0, move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v), colors));
params = params.push(slider_row(
"Angle",
angle,
0.0..=360.0,
move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v),
colors,
));
params = params.push(slider_row(
"Distance",
distance,
0.0..=100.0,
move |v| Message::LayerStyleUpdateParam(selected, "distance".to_string(), v),
colors,
));
params = params.push(slider_row(
"Size",
size,
0.0..=250.0,
move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v),
colors,
));
}
"ColorOverlay" => {
// Only opacity — color is via the swatch above
}
"GradientOverlay" => {
let (angle, scale) = match style {
Some(LayerStyle::GradientOverlay { angle, scale, .. }) =>
(*angle, *scale),
Some(LayerStyle::GradientOverlay { angle, scale, .. }) => (*angle, *scale),
_ => (90.0, 1.0),
};
params = params.push(slider_row("Angle", angle, 0.0..=360.0, move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v), colors));
params = params.push(slider_row("Scale", scale, 0.0..=10.0, move |v| Message::LayerStyleUpdateParam(selected, "scale".to_string(), v), colors));
params = params.push(slider_row(
"Angle",
angle,
0.0..=360.0,
move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v),
colors,
));
params = params.push(slider_row(
"Scale",
scale,
0.0..=10.0,
move |v| Message::LayerStyleUpdateParam(selected, "scale".to_string(), v),
colors,
));
}
"PatternOverlay" => {
let scale = match style {
Some(LayerStyle::PatternOverlay { scale, .. }) => *scale,
_ => 1.0,
};
params = params.push(slider_row("Scale", scale, 0.0..=10.0, move |v| Message::LayerStyleUpdateParam(selected, "scale".to_string(), v), colors));
params = params.push(slider_row(
"Scale",
scale,
0.0..=10.0,
move |v| Message::LayerStyleUpdateParam(selected, "scale".to_string(), v),
colors,
));
}
"Stroke" => {
let size = match style {
Some(LayerStyle::Stroke { size, .. }) => *size,
_ => 3.0,
};
params = params.push(slider_row("Size", size, 0.0..=100.0, move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v), colors));
params = params.push(slider_row(
"Size",
size,
0.0..=100.0,
move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v),
colors,
));
}
_ => {}
}
// Show color picker popup if active
if show_color_picker && color_picker_idx == selected {
params = params.push(build_color_picker_popup(color_hsl, selected, colors));
}
scrollable(params).height(Length::Fill).into()
}
/// Build a compact HSL color picker popup for a layer style.
fn build_color_picker_popup<'a>(
color_hsl: (f32, f32, f32),
style_idx: usize,
colors: ThemeColors,
) -> Element<'a, Message> {
let (h, s, l) = color_hsl;
let rgb = hsl_to_rgb(h, s, l);
// Hue slider
let h_slider = slider(0.0..=360.0, h, move |v: f32| {
let rgb = hsl_to_rgb(v, s, l);
Message::LayerStyleUpdateColor(style_idx, [rgb[0], rgb[1], rgb[2], 255])
}).step(1.0);
// Saturation slider
let s_slider = slider(0.0..=100.0, s * 100.0, move |v: f32| {
let rgb = hsl_to_rgb(h, v / 100.0, l);
Message::LayerStyleUpdateColor(style_idx, [rgb[0], rgb[1], rgb[2], 255])
}).step(1.0);
// Lightness slider
let l_slider = slider(0.0..=100.0, l * 100.0, move |v: f32| {
let rgb = hsl_to_rgb(h, s, v / 100.0);
Message::LayerStyleUpdateColor(style_idx, [rgb[0], rgb[1], rgb[2], 255])
}).step(1.0);
// Color preview swatch
let preview = container(text(""))
.width(Length::Fill)
.height(20)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgb(
rgb[0] as f32 / 255.0, rgb[1] as f32 / 255.0, rgb[2] as f32 / 255.0,
))),
border: iced::Border::default().rounded(2).color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1),
..Default::default()
});
// Hex display
let hex = format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]);
let popup = column![
text("Color Picker").size(10),
preview,
row![text("H").size(9), h_slider, text(format!("{:.0}\u{00B0}", h)).size(9)].spacing(3),
row![text("S").size(9), s_slider, text(format!("{:.0}%", s * 100.0)).size(9)].spacing(3),
row![text("L").size(9), l_slider, text(format!("{:.0}%", l * 100.0)).size(9)].spacing(3),
row![text(hex).size(9),
Space::with_width(Length::Fill),
button(text("Close").size(9)).on_press(Message::HideStyleColorPicker).padding([2, 8])].spacing(4),
]
.spacing(3)
.padding(6);
container(popup)
.width(Length::Fixed(260.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(4),
shadow: iced::Shadow {
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.4),
offset: iced::Vector::new(0.0, 2.0),
blur_radius: 8.0,
},
..Default::default()
})
.into()
}
/// Build a compact slider row.
fn slider_row<'a>(
label: &'a str,
@@ -711,14 +857,18 @@ fn slider_row<'a>(
on_change: impl Fn(f32) -> Message + 'static,
_colors: ThemeColors,
) -> Element<'a, Message> {
let value_label = text(format!("{:.1}", value)).size(9).width(Length::Fixed(40.0));
let value_label = text(format!("{:.1}", value))
.size(9)
.width(Length::Fixed(40.0));
let label_text = text(label).size(10).width(Length::Fixed(70.0));
let step = if *range.end() <= 1.0 { 0.01 } else { 1.0 };
row![
label_text,
slider(range, value, on_change).step(step).width(Length::Fill),
slider(range, value, on_change)
.step(step)
.width(Length::Fill),
value_label,
]
.spacing(3)
@@ -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 blend_list = pick_list(BLEND_MODE_ITEMS, Some(BlendModeItem(active_blend)), move |item| {
Message::LayerSetBlendMode(active_layer_id, item.into())
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()),
)
.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 {
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,34 +345,36 @@ 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,
]
let name_row =
row![collapse, vis_btn, row_lock, thumb_elem, name_text, fx_icon, type_badge,]
.spacing(2)
.align_y(iced::Alignment::Center);
@@ -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,15 +424,18 @@ 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 {
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 {
text(effect_name)
.size(BODY)
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(effect_color),
}),
]
@@ -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);
.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);
// ── Panel layout (no duplicate title — dock tab provides it) ──
let panel = column![
@@ -15,38 +15,145 @@
use crate::app::Message;
use crate::dock::state::{DockState, PaneType};
use crate::theme::ThemeColors;
use hcie_engine_api::Tool;
use iced::widget::{column, container, horizontal_rule, mouse_area, row, text};
use iced::{Element, Length};
/// Menu item definition.
/// Identifies one implemented menu operation independently of menu position or label.
///
/// The menu builder stores this value on enabled leaves, and `HcieApp::update`
/// exhaustively translates it into application behavior. Dynamic recent-file entries
/// carry their path so reordering the recent list cannot change the selected file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MenuCommand {
NewDocument,
OpenFile,
Save,
SaveAs,
OpenRecent(String),
ClearRecent,
Undo,
Redo,
Copy,
Cut,
ClearPixels,
Paste,
CanvasSize,
ImageSize,
AddLayer,
DeleteLayer,
LayerStyles,
MergeDown,
Flatten,
SelectAll,
Deselect,
InvertSelection,
GrowSelection,
ShrinkSelection,
FeatherSelection,
BorderSelection,
SmoothSelection,
SelectionMode(crate::selection::SelectionMode),
SaveSelection,
LoadSelection,
ToggleQuickMask,
ZoomIn,
ZoomOut,
FitArea,
ActualPixels,
SelectCropTool,
GaussianBlur,
Mosaic,
UnsharpMask,
TogglePane(PaneType),
SetTheme(crate::theme::ThemePreset),
}
/// Describes one rendered menu row and its optional semantic operation.
#[derive(Clone)]
struct MenuItem {
label: String,
shortcut: String,
enabled: bool,
has_submenu: bool,
command: Option<MenuCommand>,
}
impl MenuItem {
/// Creates a disabled placeholder leaf for an operation that is not implemented.
fn new(label: &str) -> Self {
Self { label: label.to_string(), shortcut: String::new(), enabled: true, has_submenu: false }
Self {
label: label.to_string(),
shortcut: String::new(),
enabled: false,
has_submenu: false,
command: None,
}
}
/// Creates a disabled placeholder leaf while retaining its documented shortcut.
fn with_shortcut(label: &str, shortcut: &str) -> Self {
Self { label: label.to_string(), shortcut: shortcut.to_string(), enabled: true, has_submenu: false }
Self {
label: label.to_string(),
shortcut: shortcut.to_string(),
enabled: false,
has_submenu: false,
command: None,
}
}
/// Creates an enabled leaf bound to a stable semantic command.
fn command(label: &str, command: MenuCommand) -> Self {
Self {
label: label.to_string(),
shortcut: String::new(),
enabled: true,
has_submenu: false,
command: Some(command),
}
}
/// Creates an enabled leaf with a shortcut and a stable semantic command.
fn command_with_shortcut(label: &str, shortcut: &str, command: MenuCommand) -> Self {
Self {
label: label.to_string(),
shortcut: shortcut.to_string(),
enabled: true,
has_submenu: false,
command: Some(command),
}
}
/// Creates a non-dispatching submenu placeholder.
fn submenu(label: &str) -> Self {
Self { label: label.to_string(), shortcut: String::new(), enabled: true, has_submenu: true }
Self {
label: label.to_string(),
shortcut: String::new(),
enabled: false,
has_submenu: true,
command: None,
}
}
/// Creates a visual separator with no interaction.
fn separator() -> Self {
Self { label: "".to_string(), shortcut: String::new(), enabled: false, has_submenu: false }
Self {
label: "".to_string(),
shortcut: String::new(),
enabled: false,
has_submenu: false,
command: None,
}
}
/// Creates a disabled informational row.
fn disabled(label: &str) -> Self {
Self { label: label.to_string(), shortcut: String::new(), enabled: false, has_submenu: false }
Self {
label: label.to_string(),
shortcut: String::new(),
enabled: false,
has_submenu: false,
command: None,
}
}
}
@@ -60,15 +167,15 @@ struct MenuDef {
fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
// ── File menu (Photopea-style) ──
let mut file_items = vec![
MenuItem::with_shortcut("New...", "Alt+Ctrl+N"),
MenuItem::with_shortcut("Open...", "Alt+Ctrl+O"),
MenuItem::command_with_shortcut("New...", "Alt+Ctrl+N", MenuCommand::NewDocument),
MenuItem::command_with_shortcut("Open...", "Alt+Ctrl+O", MenuCommand::OpenFile),
MenuItem::new("Open & Place..."),
MenuItem::submenu("Open More"),
MenuItem::separator(),
MenuItem::new("Share"),
MenuItem::separator(),
MenuItem::with_shortcut("Save", "Ctrl+S"),
MenuItem::new("Save as PSD"),
MenuItem::command_with_shortcut("Save", "Ctrl+S", MenuCommand::Save),
MenuItem::command("Save as PSD", MenuCommand::SaveAs),
MenuItem::submenu("Save More"),
MenuItem::submenu("Export as"),
MenuItem::with_shortcut("Print...", "Ctrl+P"),
@@ -85,29 +192,35 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
file_items.push(MenuItem::separator());
file_items.push(MenuItem::disabled("Open Recent"));
for entry in recent_files.iter().take(10) {
file_items.push(MenuItem::new(&entry.name));
file_items.push(MenuItem::command(
&entry.name,
MenuCommand::OpenRecent(entry.path.clone()),
));
}
file_items.push(MenuItem::separator());
file_items.push(MenuItem::new("Clear Recent"));
file_items.push(MenuItem::command("Clear Recent", MenuCommand::ClearRecent));
}
vec![
MenuDef { label: "File".to_string(), items: file_items },
MenuDef {
label: "File".to_string(),
items: file_items,
},
// ── Edit menu (Photopea-style) ──
MenuDef {
label: "Edit".to_string(),
items: vec![
MenuItem::new("Undo / Redo"),
MenuItem::with_shortcut("Step Forward", "Shift+Ctrl+Z"),
MenuItem::with_shortcut("Step Backward", "Ctrl+Z"),
MenuItem::command("Undo / Redo", MenuCommand::Undo),
MenuItem::command_with_shortcut("Step Forward", "Shift+Ctrl+Z", MenuCommand::Redo),
MenuItem::command_with_shortcut("Step Backward", "Ctrl+Z", MenuCommand::Undo),
MenuItem::separator(),
MenuItem::with_shortcut("Fade...", "Shift+Ctrl+F"),
MenuItem::separator(),
MenuItem::with_shortcut("Cut", "Ctrl+X"),
MenuItem::with_shortcut("Copy", "Ctrl+C"),
MenuItem::command_with_shortcut("Cut", "Ctrl+X", MenuCommand::Cut),
MenuItem::command_with_shortcut("Copy", "Ctrl+C", MenuCommand::Copy),
MenuItem::with_shortcut("Copy Merged", "Shift+Ctrl+C"),
MenuItem::with_shortcut("Paste", "Ctrl+V"),
MenuItem::new("Clear"),
MenuItem::command_with_shortcut("Paste", "Ctrl+V", MenuCommand::Paste),
MenuItem::command("Clear", MenuCommand::ClearPixels),
MenuItem::separator(),
MenuItem::with_shortcut("Fill...", "Shift+F5"),
MenuItem::new("Stroke..."),
@@ -143,8 +256,16 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::new("Vectorize Bitmap..."),
MenuItem::new("Wavelet Decompose..."),
MenuItem::separator(),
MenuItem::with_shortcut("Canvas Size...", "Alt+Ctrl+C"),
MenuItem::with_shortcut("Image Size...", "Alt+Ctrl+I"),
MenuItem::command_with_shortcut(
"Canvas Size...",
"Alt+Ctrl+C",
MenuCommand::CanvasSize,
),
MenuItem::command_with_shortcut(
"Image Size...",
"Alt+Ctrl+I",
MenuCommand::ImageSize,
),
MenuItem::submenu("Transform"),
MenuItem::new("Crop"),
MenuItem::with_shortcut("Trim...", "Ctrl+."),
@@ -160,13 +281,13 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuDef {
label: "Layer".to_string(),
items: vec![
MenuItem::submenu("New"),
MenuItem::command("New", MenuCommand::AddLayer),
MenuItem::new("Duplicate Layer"),
MenuItem::new("Duplicate Into ..."),
MenuItem::new("Delete"),
MenuItem::command("Delete", MenuCommand::DeleteLayer),
MenuItem::separator(),
MenuItem::submenu("Text"),
MenuItem::submenu("Layer Style"),
MenuItem::command("Layer Style", MenuCommand::LayerStyles),
MenuItem::separator(),
MenuItem::submenu("New Fill Layer"),
MenuItem::submenu("New Adjustment Layer"),
@@ -184,8 +305,8 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::submenu("Combine Shapes"),
MenuItem::submenu("Animation"),
MenuItem::separator(),
MenuItem::with_shortcut("Merge Down", "Ctrl+E"),
MenuItem::new("Flatten Image"),
MenuItem::command_with_shortcut("Merge Down", "Ctrl+E", MenuCommand::MergeDown),
MenuItem::command("Flatten Image", MenuCommand::Flatten),
MenuItem::new("Defringe"),
],
},
@@ -193,9 +314,13 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuDef {
label: "Select".to_string(),
items: vec![
MenuItem::with_shortcut("All", "Ctrl+A"),
MenuItem::with_shortcut("Deselect", "Ctrl+D"),
MenuItem::with_shortcut("Inverse", "Shift+Ctrl+I"),
MenuItem::command_with_shortcut("All", "Ctrl+A", MenuCommand::SelectAll),
MenuItem::command_with_shortcut("Deselect", "Ctrl+D", MenuCommand::Deselect),
MenuItem::command_with_shortcut(
"Inverse",
"Shift+Ctrl+I",
MenuCommand::InvertSelection,
),
MenuItem::separator(),
MenuItem::new("Remove BG"),
MenuItem::new("Color Range..."),
@@ -204,19 +329,52 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::separator(),
MenuItem::new("Refine Edge..."),
MenuItem::submenu("Modify"),
MenuItem::with_shortcut("Grow...", "Alt+Ctrl+G"),
MenuItem::with_shortcut("Shrink...", "Alt+Ctrl+S"),
MenuItem::with_shortcut("Feather...", "Alt+Ctrl+F"),
MenuItem::new("Border..."),
MenuItem::new("Smooth..."),
MenuItem::command_with_shortcut(
"Grow...",
"Alt+Ctrl+G",
MenuCommand::GrowSelection,
),
MenuItem::command_with_shortcut(
"Shrink...",
"Alt+Ctrl+S",
MenuCommand::ShrinkSelection,
),
MenuItem::command_with_shortcut(
"Feather...",
"Alt+Ctrl+F",
MenuCommand::FeatherSelection,
),
MenuItem::command("Border...", MenuCommand::BorderSelection),
MenuItem::command("Smooth...", MenuCommand::SmoothSelection),
MenuItem::separator(),
MenuItem::command(
"New Selection",
MenuCommand::SelectionMode(crate::selection::SelectionMode::Replace),
),
MenuItem::command(
"Add to Selection",
MenuCommand::SelectionMode(crate::selection::SelectionMode::Add),
),
MenuItem::command(
"Subtract from Selection",
MenuCommand::SelectionMode(crate::selection::SelectionMode::Subtract),
),
MenuItem::command(
"Intersect Selection",
MenuCommand::SelectionMode(crate::selection::SelectionMode::Intersect),
),
MenuItem::separator(),
MenuItem::new("Similar"),
MenuItem::separator(),
MenuItem::new("Transform Selection"),
MenuItem::with_shortcut("Quick Mask Mode", "Q"),
MenuItem::command_with_shortcut(
"Quick Mask Mode",
"Q",
MenuCommand::ToggleQuickMask,
),
MenuItem::separator(),
MenuItem::new("Load Selection"),
MenuItem::new("Save Selection"),
MenuItem::command("Load Selection", MenuCommand::LoadSelection),
MenuItem::command("Save Selection", MenuCommand::SaveSelection),
],
},
// ── Filter menu (Photopea-style) ──
@@ -248,10 +406,14 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuDef {
label: "View".to_string(),
items: vec![
MenuItem::with_shortcut("Zoom In", "Ctrl++"),
MenuItem::with_shortcut("Zoom Out", "Ctrl+-"),
MenuItem::with_shortcut("Fit The Area", "Ctrl+0"),
MenuItem::with_shortcut("Pixel to Pixel", "Ctrl+1"),
MenuItem::command_with_shortcut("Zoom In", "Ctrl++", MenuCommand::ZoomIn),
MenuItem::command_with_shortcut("Zoom Out", "Ctrl+-", MenuCommand::ZoomOut),
MenuItem::command_with_shortcut("Fit The Area", "Ctrl+0", MenuCommand::FitArea),
MenuItem::command_with_shortcut(
"Pixel to Pixel",
"Ctrl+1",
MenuCommand::ActualPixels,
),
MenuItem::new("Pattern Preview"),
MenuItem::separator(),
MenuItem::submenu("Mode"),
@@ -275,24 +437,51 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuDef {
label: "Window".to_string(),
items: vec![
MenuItem::new("Layers"),
MenuItem::new("History"),
MenuItem::new("Brushes & Tips"),
MenuItem::new("Filters"),
MenuItem::new("Color Palette"),
MenuItem::new("Properties"),
MenuItem::new("AI Assistant"),
MenuItem::new("Geometry"),
MenuItem::new("Script"),
MenuItem::new("Layer Details"),
MenuItem::new("AI Script Generator"),
MenuItem::command("Layers", MenuCommand::TogglePane(PaneType::Layers)),
MenuItem::command("History", MenuCommand::TogglePane(PaneType::History)),
MenuItem::command("Brushes & Tips", MenuCommand::TogglePane(PaneType::Brushes)),
MenuItem::command("Filters", MenuCommand::TogglePane(PaneType::Filters)),
MenuItem::command(
"Color Palette",
MenuCommand::TogglePane(PaneType::ColorPicker),
),
MenuItem::command("Properties", MenuCommand::TogglePane(PaneType::Properties)),
MenuItem::command("AI Assistant", MenuCommand::TogglePane(PaneType::AiChat)),
MenuItem::command("Geometry", MenuCommand::TogglePane(PaneType::Geometry)),
MenuItem::command("Script", MenuCommand::TogglePane(PaneType::Script)),
MenuItem::command(
"Layer Details",
MenuCommand::TogglePane(PaneType::LayerDetails),
),
MenuItem::command(
"AI Script Generator",
MenuCommand::TogglePane(PaneType::AiScript),
),
MenuItem::separator(),
MenuItem::new("Theme: Photopea"),
MenuItem::new("Theme: Photoshop"),
MenuItem::new("Theme: ProDark (Monokai)"),
MenuItem::new("Theme: Amoled (Dracula)"),
MenuItem::new("Theme: PhotoshopLight (Solarized)"),
MenuItem::new("Theme: ProLight (Nord)"),
MenuItem::command(
"Theme: Photopea",
MenuCommand::SetTheme(crate::theme::ThemePreset::Photopea),
),
MenuItem::command(
"Theme: Photoshop",
MenuCommand::SetTheme(crate::theme::ThemePreset::Photoshop),
),
MenuItem::command(
"Theme: ProDark (Monokai)",
MenuCommand::SetTheme(crate::theme::ThemePreset::ProDark),
),
MenuItem::command(
"Theme: Amoled (Dracula)",
MenuCommand::SetTheme(crate::theme::ThemePreset::Amoled),
),
MenuItem::command(
"Theme: PhotoshopLight (Solarized)",
MenuCommand::SetTheme(crate::theme::ThemePreset::PhotoshopLight),
),
MenuItem::command(
"Theme: ProLight (Nord)",
MenuCommand::SetTheme(crate::theme::ThemePreset::ProLight),
),
],
},
// ── More menu (Photopea-style) ──
@@ -335,6 +524,7 @@ pub fn dropdown_overlay(
recent_files: &[crate::app::RecentFileEntry],
colors: ThemeColors,
dock: Option<&DockState>,
viewport_width: f32,
) -> Option<Element<'static, Message>> {
let menu_idx = active_menu?;
let menus = all_menus(recent_files);
@@ -350,75 +540,18 @@ pub fn dropdown_overlay(
container(horizontal_rule(1))
.width(Length::Fill)
.padding([2, 8])
.into()
.into(),
);
} else if item.label == "Open Recent" {
let lbl = item.label.clone();
let disabled_item = container(
text(lbl).size(13)
)
.width(Length::Fill)
.padding([4, 16])
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.menu_bg)),
..Default::default()
});
items.push(disabled_item.into());
} else if item_idx >= 12 && item_idx < 12 + recent_files.len().min(10) {
let file_idx = item_idx - 12;
let lbl = item.label.clone();
let c = colors;
let btn = iced::widget::button(
container(text(lbl).size(13)).width(Length::Fill).padding([4, 16])
)
.on_press(Message::OpenRecentFile(file_idx))
.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.menu_bg)),
text_color: c.menu_text,
border: iced::Border::default(),
..Default::default()
},
}
});
items.push(btn.into());
} else if item.label == "Clear Recent" {
let lbl = item.label.clone();
let c = colors;
let btn = iced::widget::button(
container(text(lbl).size(13)).width(Length::Fill).padding([4, 16])
)
.on_press(Message::ClearRecentFiles)
.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.menu_bg)),
text_color: c.menu_text,
border: iced::Border::default(),
..Default::default()
},
}
});
items.push(btn.into());
} else {
let lbl = item.label.clone();
let sht = item.shortcut.clone();
let has_sub = item.has_submenu;
let enabled = item.enabled;
let label_color = if enabled {
colors.menu_text
} else {
colors.menu_shortcut
};
// Check if this is a Window menu item that should show a checkmark
let is_window_panel = menu_idx == 7 && item_idx <= 10;
@@ -437,7 +570,9 @@ pub fn dropdown_overlay(
10 => Some(PaneType::AiScript),
_ => None,
};
pane_type.and_then(|pt| dock.map(|d| d.is_pane_open(pt))).unwrap_or(false)
pane_type
.and_then(|pt| dock.map(|d| d.is_pane_open(pt)))
.unwrap_or(false)
} else {
false
};
@@ -447,38 +582,65 @@ pub fn dropdown_overlay(
let label_row: Element<'static, Message> = {
let mut row_items = vec![];
if is_checked {
row_items.push(text("").size(13).color(iced::Color::from_rgba(0.2, 0.6, 0.2, 1.0)).into());
row_items.push(
text("")
.size(13)
.color(iced::Color::from_rgba(0.2, 0.6, 0.2, 1.0))
.into(),
);
}
row_items.push(text(lbl).size(13).width(Length::Fill).into());
row_items.push(
text(lbl)
.size(13)
.font(iced::Font {
weight: iced::font::Weight::Semibold,
..iced::Font::default()
})
.color(label_color)
.width(Length::Fill)
.into(),
);
if !sht.is_empty() {
row_items.push(text(sht).size(12).color(iced::Color::from_rgba(0.4, 0.4, 0.4, 1.0)).into());
row_items.push(
text(sht)
.size(12)
.font(iced::Font {
weight: iced::font::Weight::Medium,
..iced::Font::default()
})
.color(colors.menu_shortcut)
.into(),
);
}
if has_sub {
row_items.push(text(">").size(12).color(iced::Color::from_rgba(0.4, 0.4, 0.4, 1.0)).into());
row_items.push(text(">").size(12).color(colors.menu_shortcut).into());
}
row(row_items).spacing(16).into()
};
let enabled = item.enabled;
let m_idx = menu_idx;
let i_idx = item_idx;
let c = colors;
if enabled {
let btn = iced::widget::button(
container(label_row).width(Length::Fill).padding([4, 16])
)
.on_press(Message::MenuAction(m_idx, i_idx))
let command = item
.command
.clone()
.expect("enabled menu leaves must have commands");
let btn =
iced::widget::button(container(label_row).width(Length::Fill).padding([6, 16]))
.on_press(Message::MenuCommand(command))
.padding(0)
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
.style(
move |_theme: &iced::Theme, status: iced::widget::button::Status| {
match status {
// Photopea-style hover: light blue highlight
iced::widget::button::Status::Hovered => iced::widget::button::Style {
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.menu_bg)),
text_color: c.menu_text,
@@ -486,16 +648,17 @@ pub fn dropdown_overlay(
..Default::default()
},
}
});
},
);
items.push(btn.into());
} else {
// Photopea-style disabled: lighter text
let disabled_item = container(label_row)
.width(Length::Fill)
.padding([4, 16])
.padding([6, 16])
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.menu_bg)),
text_color: Some(iced::Color::from_rgba(0.6, 0.6, 0.6, 1.0)),
text_color: Some(colors.menu_shortcut),
..Default::default()
});
items.push(disabled_item.into());
@@ -511,9 +674,7 @@ pub fn dropdown_overlay(
.width(260)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.menu_bg)),
border: iced::Border::default()
.color(colors.border_high)
.width(1),
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, 4.0),
@@ -522,25 +683,20 @@ pub fn dropdown_overlay(
..Default::default()
});
// Position dropdown below the clicked menu item.
// Compute the x-offset by summing the approximate widths of preceding menu
// bar items. Each button = label_chars * 7px (at 13px font) + 16px padding,
// plus the 4px initial left padding from the title bar.
const MENU_LABELS: &[&str] = &["File", "Edit", "Image", "Layer", "Select", "Filter", "View", "Window", "More"];
let mut left_offset: f32 = 4.0;
for i in 0..menu_idx.min(MENU_LABELS.len()) {
left_offset += MENU_LABELS[i].len() as f32 * 7.0 + 16.0;
}
let overlay = mouse_area(
container(dropdown)
.padding(iced::Padding {
top: 24.0,
let left_offset = crate::panels::title_bar::menu_anchor_x(menu_idx, viewport_width, 260.0);
let backdrop = mouse_area(container(text("")).width(Length::Fill).height(Length::Fill))
.on_press(Message::MenuClose);
let positioned = container(dropdown).padding(iced::Padding {
top: crate::panels::title_bar::MENU_BAR_HEIGHT,
bottom: 0.0,
left: left_offset,
right: 0.0,
})
)
.on_press(Message::MenuClose);
});
let overlay = iced::widget::Stack::new()
.push(backdrop)
.push(positioned)
.width(Length::Fill)
.height(Length::Fill);
Some(overlay.into())
}
@@ -554,91 +710,112 @@ pub fn canvas_context_menu<'a>(
) -> Element<'a, Message> {
let mut items = vec![];
items.push(MenuItem::with_shortcut("Copy", "Ctrl+C"));
items.push(MenuItem::command_with_shortcut(
"Copy",
"Ctrl+C",
MenuCommand::Copy,
));
if doc.selection_bounds.is_some() {
items.push(MenuItem::with_shortcut("Cut", "Ctrl+X"));
items.push(MenuItem::command_with_shortcut(
"Cut",
"Ctrl+X",
MenuCommand::Cut,
));
}
items.push(MenuItem::with_shortcut("Paste", "Ctrl+V"));
items.push(MenuItem::command_with_shortcut(
"Paste",
"Ctrl+V",
MenuCommand::Paste,
));
items.push(MenuItem::separator());
items.push(MenuItem::new("New Layer"));
items.push(MenuItem::command("New Layer", MenuCommand::AddLayer));
if doc.selection_bounds.is_some() {
items.push(MenuItem::with_shortcut("Deselect", "Ctrl+D"));
items.push(MenuItem::new("Crop Image"));
items.push(MenuItem::command_with_shortcut(
"Deselect",
"Ctrl+D",
MenuCommand::Deselect,
));
items.push(MenuItem::command("Crop Image", MenuCommand::SelectCropTool));
}
items.push(MenuItem::separator());
items.push(MenuItem::new("Gaussian Blur (5px)"));
items.push(MenuItem::new("Mosaic / Pixelate (8px)"));
items.push(MenuItem::new("Unsharp Mask"));
items.push(MenuItem::command(
"Gaussian Blur (5px)",
MenuCommand::GaussianBlur,
));
items.push(MenuItem::command(
"Mosaic / Pixelate (8px)",
MenuCommand::Mosaic,
));
items.push(MenuItem::command("Unsharp Mask", MenuCommand::UnsharpMask));
let mut col_items = vec![];
for item in items {
if item.label == "" {
col_items.push(
container(horizontal_rule(1).style(move |_theme| iced::widget::rule::Style {
container(
horizontal_rule(1).style(move |_theme| iced::widget::rule::Style {
color: colors.border_high,
width: 1,
radius: 0.0.into(),
fill_mode: iced::widget::rule::FillMode::Full,
}))
}),
)
.padding([4, 0])
.into(),
);
} else {
let label = text(item.label.clone()).size(14).style(move |_theme| iced::widget::text::Style {
color: Some(colors.text_primary),
let label = text(item.label.clone())
.size(13)
.font(iced::Font {
weight: iced::font::Weight::Semibold,
..iced::Font::default()
})
.style(move |_theme| iced::widget::text::Style {
color: Some(colors.menu_text),
});
let mut label_row = row![label].align_y(iced::Alignment::Center);
if !item.shortcut.is_empty() {
label_row = label_row.push(iced::widget::Space::with_width(Length::Fill));
label_row = label_row.push(
text(item.shortcut)
.size(13)
.style(move |_theme| iced::widget::text::Style {
color: Some(colors.text_secondary),
}),
);
label_row = label_row.push(text(item.shortcut).size(12).style(move |_theme| {
iced::widget::text::Style {
color: Some(colors.menu_shortcut),
}
}));
}
let msg = match item.label.as_str() {
"Copy" => Message::CopyImage,
"Cut" => Message::CopyImage, // TODO: CutImage
"Paste" => Message::PasteImage,
"New Layer" => Message::LayerAdd,
"Deselect" => Message::Deselect,
"Crop Image" => Message::ToolSelected(Tool::Crop),
"Gaussian Blur (5px)" => Message::FilterSelect(hcie_engine_api::FilterType::GaussianBlur),
"Mosaic / Pixelate (8px)" => Message::FilterSelect(hcie_engine_api::FilterType::Mosaic),
"Unsharp Mask" => Message::FilterSelect(hcie_engine_api::FilterType::UnsharpMask),
_ => Message::CanvasContextMenuClose,
};
let c = colors;
let menu_item = iced::widget::button(
container(label_row)
.width(Length::Fill)
.padding([6, 16])
)
let menu_item =
iced::widget::button(container(label_row).width(Length::Fill).padding([7, 16]))
.padding(0)
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
.style(
move |_theme: &iced::Theme, status: iced::widget::button::Status| {
match status {
iced::widget::button::Status::Hovered => iced::widget::button::Style {
iced::widget::button::Status::Hovered => {
iced::widget::button::Style {
background: Some(iced::Background::Color(c.menu_hover)),
text_color: c.text_primary,
text_color: c.menu_text,
border: iced::Border::default(),
..Default::default()
},
}
}
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(c.menu_bg)),
text_color: c.text_primary,
text_color: c.menu_text,
border: iced::Border::default(),
..Default::default()
},
}
})
.on_press(msg);
},
);
let menu_item = if let Some(command) = item.command {
menu_item.on_press(Message::MenuCommand(command))
} else {
menu_item
};
col_items.push(menu_item.into());
}
@@ -659,16 +836,148 @@ pub fn canvas_context_menu<'a>(
..Default::default()
});
let overlay = mouse_area(
container(dropdown)
.padding(iced::Padding {
let overlay = mouse_area(container(dropdown).padding(iced::Padding {
top: y,
bottom: 0.0,
left: x,
right: 0.0,
})
)
}))
.on_press(Message::CanvasContextMenuClose);
overlay.into()
}
#[cfg(test)]
mod tests {
use super::*;
/// Audits every static and dynamic menu row so enabled leaves cannot drift away
/// from semantic dispatch, and non-leaf rows can never become dispatchable.
#[test]
fn enabled_menu_leaves_have_commands() {
let recent = vec![crate::app::RecentFileEntry {
path: "/tmp/recent.png".to_string(),
name: "recent.png".to_string(),
}];
for menu in all_menus(&recent) {
for item in menu.items {
assert_eq!(
item.enabled,
item.command.is_some(),
"menu item: {} / {}",
menu.label,
item.label
);
assert!(
!item.has_submenu || item.command.is_none(),
"submenu dispatched: {} / {}",
menu.label,
item.label
);
}
}
}
/// Verifies recent-file commands own stable paths instead of positional indices.
#[test]
fn recent_file_commands_are_path_based() {
let recent = vec![crate::app::RecentFileEntry {
path: "/tmp/stable.png".to_string(),
name: "stable.png".to_string(),
}];
let file = &all_menus(&recent)[0];
let item = file
.items
.iter()
.find(|item| item.label == "stable.png")
.unwrap();
assert_eq!(
item.command,
Some(MenuCommand::OpenRecent("/tmp/stable.png".to_string()))
);
}
/// Locks the implemented static leaf set so menu-definition edits require an
/// explicit audit update rather than silently changing application behavior.
#[test]
fn implemented_static_menu_leaves_match_audited_set() {
let actual: Vec<String> = all_menus(&[])
.into_iter()
.flat_map(|menu| {
menu.items.into_iter().filter_map(move |item| {
item.enabled
.then(|| format!("{}/{}", menu.label, item.label))
})
})
.collect();
let expected = vec![
"File/New...",
"File/Open...",
"File/Save",
"File/Save as PSD",
"Edit/Undo / Redo",
"Edit/Step Forward",
"Edit/Step Backward",
"Edit/Cut",
"Edit/Copy",
"Edit/Paste",
"Edit/Clear",
"Image/Canvas Size...",
"Image/Image Size...",
"Layer/New",
"Layer/Delete",
"Layer/Layer Style",
"Layer/Merge Down",
"Layer/Flatten Image",
"Select/All",
"Select/Deselect",
"Select/Inverse",
"Select/Grow...",
"Select/Shrink...",
"Select/Feather...",
"Select/Border...",
"Select/Smooth...",
"Select/New Selection",
"Select/Add to Selection",
"Select/Subtract from Selection",
"Select/Intersect Selection",
"Select/Quick Mask Mode",
"Select/Load Selection",
"Select/Save Selection",
"View/Zoom In",
"View/Zoom Out",
"View/Fit The Area",
"View/Pixel to Pixel",
"Window/Layers",
"Window/History",
"Window/Brushes & Tips",
"Window/Filters",
"Window/Color Palette",
"Window/Properties",
"Window/AI Assistant",
"Window/Geometry",
"Window/Script",
"Window/Layer Details",
"Window/AI Script Generator",
"Window/Theme: Photopea",
"Window/Theme: Photoshop",
"Window/Theme: ProDark (Monokai)",
"Window/Theme: Amoled (Dracula)",
"Window/Theme: PhotoshopLight (Solarized)",
"Window/Theme: ProLight (Nord)",
];
assert_eq!(actual, expected);
}
/// Confirms context-menu Cut uses its distinct semantic command.
#[test]
fn context_cut_is_enabled_and_distinct_from_copy() {
let cut = MenuItem::command_with_shortcut("Cut", "Ctrl+X", MenuCommand::Cut);
assert!(cut.enabled);
assert_eq!(cut.command, Some(MenuCommand::Cut));
}
}
@@ -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,8 +147,7 @@ pub fn view<'a>(
| Tool::VectorCross
| Tool::VectorCrescent
| Tool::VectorBolt
| Tool::VectorArrow4 => {
vector_tool_properties(
| Tool::VectorArrow4 => vector_tool_properties(
active_tool,
vector_stroke,
vector_opacity,
@@ -152,29 +157,30 @@ pub fn view<'a>(
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))
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))
row![
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("Fill").size(10),
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,20 +429,27 @@ 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))
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))
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 {
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() }))
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| {
.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,19 +89,26 @@ 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))
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),
@@ -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)
container(row![
iced::widget::Space::with_width(Length::Fill),
doc_info
])
.width(Length::Fill)
.height(24)
.height(MENU_BAR_HEIGHT)
.align_y(iced::Alignment::Center)
.padding([0, 4])
.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,7 +57,10 @@ 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))
let hardness_label = text(format!(
"Hardness: {:.0}%",
tool_state.brush_hardness * 100.0
))
.size(10)
.width(Length::Fixed(75.0));
let hardness_slider = slider(
@@ -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,30 +120,25 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
}
// ── Move tool ──
Tool::Move => {
row![
Tool::Move => row![
checkbox_label("Auto-Select", true),
text(" ").size(10),
text("Layer").size(10),
]
.spacing(2)
.align_y(iced::Alignment::Center)
}
.align_y(iced::Alignment::Center),
// ── Eyedropper ──
Tool::Eyedropper => {
row![
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)
}
.align_y(iced::Alignment::Center),
// ── Gradient / FloodFill ──
Tool::Gradient | Tool::FloodFill => {
row![
Tool::Gradient | Tool::FloodFill => row![
text("Mode:").size(10),
text("Normal").size(10),
text(" ").size(10),
@@ -152,12 +146,10 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
text("100%").size(10),
]
.spacing(2)
.align_y(iced::Alignment::Center)
}
.align_y(iced::Alignment::Center),
// ── Text ──
Tool::Text => {
row![
Tool::Text => row![
text("Font:").size(10),
text("Arial").size(10),
text(" ").size(10),
@@ -165,24 +157,20 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
text("24px").size(10),
]
.spacing(2)
.align_y(iced::Alignment::Center)
}
.align_y(iced::Alignment::Center),
// ── Crop ──
Tool::Crop => {
row![
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)
}
.align_y(iced::Alignment::Center),
// ── Vector tools ──
Tool::VectorLine | Tool::VectorRect | Tool::VectorCircle => {
row![
Tool::VectorLine | Tool::VectorRect | Tool::VectorCircle => row![
text("Fill:").size(10),
text("None").size(10),
text(" ").size(10),
@@ -190,13 +178,10 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
text("1px").size(10),
]
.spacing(2)
.align_y(iced::Alignment::Center)
}
.align_y(iced::Alignment::Center),
// ── Default (no specific options) ──
_ => {
row![].spacing(0)
}
_ => row![].spacing(0),
};
let bar = row![
@@ -244,10 +229,7 @@ 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),
]
row![text(mark).size(10), text(label_owned).size(10),]
.spacing(2)
.align_y(iced::Alignment::Center)
.into()
@@ -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,14 +489,14 @@ 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,
]
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 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)
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(60.0));
.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)
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(60.0));
.width(Length::Fixed(52.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)
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(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)
.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(80.0));
row![sz, sz_sl, ha, ha_sl].spacing(4).align_y(iced::Alignment::Center)
.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))
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(80.0)),
text(format!("{:.0}", tol)).size(10).width(Length::Fixed(30.0)),
].spacing(6).align_y(iced::Alignment::Center)
.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 {
.style(
move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style {
color: Some(c.text_primary),
})
},
)
.into()
} else {
// Fallback to text
@@ -154,11 +209,8 @@ 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 {
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,
@@ -171,8 +223,8 @@ fn svg_btn(icon_path: &str, tip: &str, msg: Message, colors: ThemeColors) -> Ele
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(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()
@@ -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);
row_children = row_children.push(btn);
// Every 2 tools, start a new row
if idx % 2 == 1 || idx == TOOL_SLOTS.len() - 1 {
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);
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,40 +226,63 @@ 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))
let reset_btn = iced::widget::tooltip(
button(text("D").size(10).color(colors.text_secondary))
.on_press(Message::ResetColors)
.padding(1)
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
match status {
.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,
@@ -148,54 +290,78 @@ 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()
},
}
});
},
),
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| {
.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)),
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))),
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 })
)
},
),
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,22 +436,33 @@ 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_color = if is_active {
colors.accent
} else {
colors.text_primary
};
let icon_element: Element<'_, Message> = if let Some(icon_path) = icon_paths.iter().find(|p| p.exists()) {
let icon_element: Element<'_, Message> =
if let Some(icon_path) = icon_paths.iter().find(|p| p.exists()) {
let handle = svg::Handle::from_path(icon_path);
svg(handle)
.width(20)
.height(20)
.style(move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style {
.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())
@@ -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 {
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(),
..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])
)
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();
let item = button(container(popup_icon).width(32).height(28).center(18))
.on_press(Message::ToolSelected(tool))
.padding(0)
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
match status {
.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(c.menu_hover)),
text_color: c.menu_text,
border: iced::Border::default(),
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(c.bg_hover)),
text_color: if is_tool_active { c.accent } else { c.text_primary },
border: iced::Border::default(),
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()
},
}
});
},
);
popup_items = popup_items.push(item);
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,7 +137,8 @@ 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| {
.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)
@@ -149,14 +149,16 @@ fn draw_tree_node(
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| {
.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)
@@ -167,7 +169,8 @@ fn draw_tree_node(
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,8 +102,11 @@ 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) {
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) {
@@ -108,8 +114,20 @@ impl ViewerState {
}
}
}
self.images.sort();
self.active_image_idx = 0;
Err(error) => {
self.directory_error = Some(format!(
"Cannot read {}: {error}",
self.current_dir.display()
))
}
}
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,31 +282,40 @@ 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| {
.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| {
.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| {
.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
@@ -305,12 +330,14 @@ fn view_header(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes
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| {
.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
@@ -325,18 +352,21 @@ fn view_header(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes
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| {
.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 {
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 {
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(
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,16 +565,17 @@ 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,
]
column![toolbar, horizontal_rule(1), stats, horizontal_rule(1), grid,]
.spacing(4)
.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 {
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 {
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| {
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| {
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 {
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,13 +87,11 @@ 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);
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()
@@ -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| {
.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(frame_color)),
text_color: colors.text_primary,
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,