diff --git a/.kilo/plans/1784128529941-iced-gui-parity-vector-plan.md b/.kilo/plans/1784128529941-iced-gui-parity-vector-plan.md new file mode 100644 index 0000000..f8e73d3 --- /dev/null +++ b/.kilo/plans/1784128529941-iced-gui-parity-vector-plan.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index a514f17..817cf88 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -3054,6 +3054,7 @@ version = "0.1.0" dependencies = [ "hcie-engine-api", "iced", + "serde", "serde_json", ] diff --git a/docs/compose/plans/2025-07-15-vector-tool-migration.md b/docs/compose/plans/2025-07-15-vector-tool-migration.md new file mode 100644 index 0000000..825be5e --- /dev/null +++ b/docs/compose/plans/2025-07-15-vector-tool-migration.md @@ -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` to app state +- Add `vector_shapes_snapshot: Option>` 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 diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/ai_chat.rs b/hcie-iced-app/crates/hcie-iced-gui/src/ai_chat.rs index 864b4b7..faf4d4c 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/ai_chat.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/ai_chat.rs @@ -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 { +pub fn execute_tool_call( + engine: &mut Engine, + name: &str, + args: &serde_json::Value, +) -> Result { 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 { "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 { } 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 { } 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 { let filter_id = parts[1].to_string(); let params = if parts.len() >= 3 { let json_str = parts[2..].join(" "); - serde_json::from_str::(&json_str).unwrap_or(serde_json::Value::Null) + serde_json::from_str::(&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 { /// 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 { 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 { 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, -) -> Result { - let body = json!({ - "model": model, - "messages": messages, - "stream": true, - "temperature": 0.7 - }); - - let url_endpoint = if url.ends_with("/v1/chat/completions") || url.ends_with("/api/chat") { - url.clone() - } else { - format!("{}/v1/chat/completions", url.trim_end_matches('/')) - }; - - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(120)) - .build() - .map_err(|e| format!("Failed to create HTTP client: {}", e))?; - - let response = client.post(&url_endpoint).json(&body).send().await - .map_err(|e| format!("Network Failure: {}", e))?; - - let status = response.status(); - if !status.is_success() { - let err_msg = response.text().await.unwrap_or_else(|_| "Unknown HTTP error".to_string()); - return Err(format!("HTTP Error {}: {}", status, err_msg)); - } - - let mut full_accumulated = String::new(); - let mut stream = response.bytes_stream(); - - use futures_util::StreamExt; - let mut buffer = String::new(); - - while let Some(chunk_result) = stream.next().await { +) -> impl futures_util::Stream { + 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::>(), "max_tokens": 4096, "stream": true}), + ), + AiProvider::OpenAI => ( + format!("{}/chat/completions", url.trim_end_matches('/')), + json!({"model": model, "messages": messages, "stream": true, "temperature": 0.7}), + ), + }; + let client = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(15)) + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {e}"))?; + let mut request = client.post(&url_endpoint).json(&body); + match provider { + AiProvider::Claude => { + let key = std::env::var("ANTHROPIC_API_KEY").map_err(|_| "ANTHROPIC_API_KEY is not set".to_string())?; + request = request.header("x-api-key", key).header("anthropic-version", "2023-06-01"); + } + AiProvider::OpenAI => { + let key = std::env::var("OPENAI_API_KEY").map_err(|_| "OPENAI_API_KEY is not set".to_string())?; + request = request.bearer_auth(key); + } + AiProvider::Ollama => {} + } + let response = request.send().await.map_err(|e| format!("Network failure: {e}"))?; + let status = response.status(); + if !status.is_success() { + let detail = response.text().await.unwrap_or_else(|_| "response body unavailable".into()); + return Err(format!("HTTP {status}: {}", detail.chars().take(1000).collect::())); + } + let mut full_accumulated = String::new(); + let mut stream = response.bytes_stream(); + let mut buffer = String::new(); + while let Some(chunk_result) = stream.next().await { match chunk_result { Ok(chunk) => { buffer.push_str(&String::from_utf8_lossy(&chunk)); - - // Process complete lines while let Some(newline_pos) = buffer.find('\n') { let line = buffer[..newline_pos].trim().to_string(); - buffer = buffer[newline_pos + 1..].to_string(); - + buffer.drain(..=newline_pos); if line.is_empty() { continue; } - + let json_part = line.strip_prefix("data: ").unwrap_or(&line); if line.starts_with("data: ") { - let json_part = &line[6..]; if json_part.trim() == "[DONE]" { - break; + continue; } if let Ok(v) = serde_json::from_str::(json_part) { - if let Some(choices) = v["choices"].as_array() { - if !choices.is_empty() { - let delta = &choices[0]["delta"]; - // Handle reasoning content (deep thinking models) - if let Some(_reasoning) = delta["reasoning_content"].as_str() { - // Reasoning chunks are logged but not accumulated in this path - } - // Handle regular content - if let Some(content) = delta["content"].as_str() { - if !content.is_empty() { - full_accumulated.push_str(content); - } - } - } + let delta = &v["choices"][0]["delta"]; + if let Some(reasoning) = delta["reasoning_content"].as_str().or_else(|| delta["reasoning"].as_str()) { + output.send(ChatUpdate::ReasoningChunk(reasoning.to_string())).await.ok(); + } + let content = delta["content"].as_str().or_else(|| v["delta"]["text"].as_str()); + if let Some(content) = content.filter(|text| !text.is_empty()) { + full_accumulated.push_str(content); + output.send(ChatUpdate::Chunk(content.to_string())).await.ok(); } } } else { - // Fallback to Ollama native chat ND-JSON format if let Ok(v) = serde_json::from_str::(&line) { + if let Some(reasoning) = v["message"]["thinking"].as_str() { + output.send(ChatUpdate::ReasoningChunk(reasoning.to_string())).await.ok(); + } if let Some(content) = v["message"]["content"].as_str() { if !content.is_empty() { full_accumulated.push_str(content); + output.send(ChatUpdate::Chunk(content.to_string())).await.ok(); } } } } + if full_accumulated.len() > MAX_RESPONSE_BYTES { + return Err("AI response exceeded the 4 MiB safety limit".to_string()); + } } } - Err(e) => { - return Err(format!("Stream read error: {}", e)); - } + Err(e) => return Err(format!("Stream read error: {e}")), } + } + Ok(full_accumulated) + }.await; + output.send(ChatUpdate::Finished(result)).await.ok(); + }) +} + +/// Format every visible message as a complete plain-text transcript. +pub fn transcript_text(messages: &[ChatMessage]) -> String { + messages + .iter() + .map(|message| { + let role = if message.is_reasoning { + "Reasoning" + } else { + message.role.as_str() + }; + format!("{}:\n{}", role, message.content) + }) + .collect::>() + .join("\n\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canvas_context_replaces_only_latest_user_payload() { + let history = vec![ + ChatMessage { + role: "user".into(), + content: "old".into(), + is_reasoning: false, + }, + ChatMessage { + role: "assistant".into(), + content: "reply".into(), + is_reasoning: false, + }, + ChatMessage { + role: "user".into(), + content: "new".into(), + is_reasoning: false, + }, + ]; + let payload = build_messages_json(&history, "new\n[canvas]", "system"); + assert_eq!(payload[1]["content"], "old"); + assert_eq!(payload[3]["content"], "new\n[canvas]"); } - Ok(full_accumulated) + #[test] + fn transcript_includes_reasoning_and_tool_results() { + let messages = vec![ + ChatMessage { + role: "assistant".into(), + content: "answer".into(), + is_reasoning: false, + }, + ChatMessage { + role: "assistant".into(), + content: "thought".into(), + is_reasoning: true, + }, + ChatMessage { + role: "tool".into(), + content: "result".into(), + is_reasoning: false, + }, + ]; + let transcript = transcript_text(&messages); + assert!(transcript.contains("assistant:\nanswer")); + assert!(transcript.contains("Reasoning:\nthought")); + assert!(transcript.contains("tool:\nresult")); + } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/ai_script.rs b/hcie-iced-app/crates/hcie-iced-gui/src/ai_script.rs index 9ecd5e9..b168105 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/ai_script.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/ai_script.rs @@ -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 Result Result Result Result { - 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 Result String { text.trim().to_string() } +/// Validate a generated payload before exposing Run as an executable action. +pub fn validate_script_output(text: &str) -> Result { + 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 { +pub async fn generate_script( + provider_type: &str, + provider: &Provider, + prompt: &str, +) -> Result { 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")); } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs index b1172f8..cf0c074 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -14,14 +14,16 @@ use crate::ai_chat::{self, AiChatState, ChatMessage, SYSTEM_PROMPT}; use crate::dialogs; -use crate::i18n; use crate::dock::state::{DockState, PaneType}; +use crate::i18n; use crate::io::tablet::TabletState; use crate::panels; use crate::selection; use crate::selection::{CropState, SelectionTransform, TransformHandle}; use crate::theme::ThemeState; -use hcie_engine_api::{BlendMode, BrushTip, BrushStyle, Engine, FilterType, LayerStyle, Tool, ZOOM_MAX, ZOOM_MIN}; +use hcie_engine_api::{ + BlendMode, BrushStyle, BrushTip, Engine, FilterType, LayerStyle, Tool, ZOOM_MAX, ZOOM_MIN, +}; use iced::widget::{column, container, text}; use iced::{Element, Length, Task, Theme, Vector}; use std::path::PathBuf; @@ -141,6 +143,8 @@ pub struct HcieIcedApp { pub settings: crate::settings::AppSettings, /// Window ID for window control operations (drag, minimize, maximize, close). pub window_id: Option, + /// Pending automated or keyboard-triggered full-viewport screenshot. + pub screenshot_request: Option, /// New Image dialog state. pub dialog_input: String, pub dialog_input2: String, @@ -174,6 +178,14 @@ pub struct HcieIcedApp { pub filter_params: serde_json::Value, /// Filter preview state. pub filter_preview_active: bool, + /// Immutable active-layer pixels used by filter and adjustment previews. + preview_baseline: Option, + /// Original styles restored when the Layer Style dialog is cancelled. + layer_style_baseline: Option, + /// File operation associated with the currently open native file dialog. + pending_file_operation: Option, + /// Document tab awaiting Save/Discard confirmation before removal. + pending_document_close: Option, /// Dock layout state. pub dock: DockState, /// Shared tablet state for pressure input. @@ -188,6 +200,14 @@ pub struct HcieIcedApp { pub recent_files: Vec, /// Recent colors used (most recent first, max 40). pub recent_colors: Vec<[u8; 4]>, + /// Target currently edited by the shared popup color selector. + pub color_picker_target: Option, + /// Live RGBA value displayed by the shared popup color selector. + pub popup_color: [u8; 4], + /// Editable hexadecimal value retained while the shared selector is open. + pub popup_color_hex: String, + /// Shared selector tab: RGB, wheel, or HSL. + pub popup_color_tab: usize, /// Selected style index in Layer Styles panel. pub selected_style: usize, /// Whether the color picker popup is open for a layer style. @@ -211,6 +231,8 @@ pub struct HcieIcedApp { pub marching_ants_offset: f32, /// AI chat state (messages, config, streaming). pub ai_chat_state: AiChatState, + /// Abort handle for the currently active AI response stream. + ai_chat_abort: Option, /// Script editor text content (raw string for error display/reference). pub script_text: String, /// Script editor content for the Iced text_editor widget. @@ -227,6 +249,8 @@ pub struct HcieIcedApp { pub ai_script_output: String, /// AI script generator — last error message (if any). pub ai_script_error: Option, + /// AI script generator — last successful generation/copy/execution notice. + pub ai_script_notice: Option, /// AI script generator — whether a generation request is in progress. pub ai_script_is_running: bool, /// AI script generator — selected provider type ("ollama", "claude", "openai"). @@ -256,10 +280,12 @@ pub struct HcieIcedApp { /// Dirty flag set whenever fg/bg/recent colors change, so colors can be /// persisted to disk (egui persists these under the `hcie_colors` key). pub colors_dirty: bool, - /// Snapshot of vector shapes before editing (for undo/Cancel). - pub vector_shapes_snapshot: Option>, + /// Layer-bound snapshot of vector shapes before editing (for exact Cancel restoration). + pub vector_shapes_snapshot: Option, /// Last drag position during vector shape editing (canvas coordinates). pub vector_drag_last: Option<(f32, f32)>, + /// Immutable basis for the active vector drag. + pub vector_drag_session: Option, /// Boolean operation shape A selector index (geometry panel). pub bool_shape_a: Option, /// Boolean operation shape B selector index (geometry panel). @@ -275,6 +301,31 @@ pub struct RecentFileEntry { pub name: String, } +/// Immutable pixels captured before a non-destructive GUI preview begins. +#[derive(Debug, Clone)] +struct PreviewBaseline { + document_index: usize, + layer_id: u64, + pixels: Vec, +} + +/// Layer-style state captured when the modal editor opens. +#[derive(Debug, Clone)] +struct LayerStyleBaseline { + document_index: usize, + layer_id: u64, + styles: Vec, + was_modified: bool, + changed: bool, +} + +/// Distinguishes open and save callbacks and carries close-after-save continuation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PendingFileOperation { + Open, + SaveAs { close_after: bool }, +} + /// Per-document state wrapping an engine instance. pub struct IcedDocument { pub engine: Engine, @@ -324,6 +375,8 @@ pub struct IcedDocument { pub transform_drag_start: Option<(f32, f32)>, /// Original transform state before drag (for undo) pub transform_original: Option, + /// Source pixels and placement removed when a move transform began, for exact Cancel restore. + pub transform_source: Option, /// Internal clipboard for copy/paste operations pub internal_clipboard: Option, /// Selection mask (alpha channel) for the current selection @@ -346,6 +399,14 @@ pub struct IcedDocument { pub selection_edge_cache: crate::canvas::edge_cache::SelectionEdgeCache, /// Extracted selection edges for overlay rendering. pub marching_ants_edges: Option>>, + /// Horizontal runs of selected pixels used to fill irregular masks exactly. + pub selection_fill_spans: Option>>, + /// How newly generated selection masks modify the existing mask. + pub selection_mode: selection::SelectionMode, + /// In-memory saved selection for the document's Load/Save Selection commands. + pub saved_selection_mask: Option>, + /// Whether quick-mask visualization is enabled. + pub quick_mask: bool, /// Index of the currently selected vector shape (None = no selection). pub selected_vector_shape: Option, /// Index into the list of shapes at the current click position for cycling @@ -360,7 +421,7 @@ pub struct IcedDocument { /// Holds the editable content plus font/size/color/alignment/orientation so the /// properties panel and canvas overlay can render a live preview before the /// text is committed to the engine as a Text layer. -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct TextDraft { pub content: String, pub font: String, @@ -371,6 +432,8 @@ pub struct TextDraft { pub angle: f32, pub alignment: hcie_engine_api::TextAlignment, pub orientation: hcie_engine_api::TextOrientation, + /// Stateful multiline editor content kept in sync with `content`. + pub editor: iced::widget::text_editor::Content, } impl Default for TextDraft { @@ -385,6 +448,7 @@ impl Default for TextDraft { angle: 0.0, alignment: hcie_engine_api::TextAlignment::Left, orientation: hcie_engine_api::TextOrientation::Horizontal, + editor: iced::widget::text_editor::Content::new(), } } } @@ -425,6 +489,8 @@ pub struct ToolState { pub last_click_time: std::time::Instant, /// Last click pos for double-click detection pub last_click_pos: Option<(f32, f32)>, + /// Whether Space is held, temporarily routing left drag to canvas panning. + pub space_pan: bool, } impl Default for ToolState { @@ -447,6 +513,7 @@ impl Default for ToolState { brush_resize_start_pos: None, last_click_time: std::time::Instant::now(), last_click_pos: None, + space_pan: false, } } } @@ -458,6 +525,10 @@ pub enum Message { // ── Tool selection ────────────────────────────────── ToolSelected(Tool), ToolSlotClicked(usize, Tool), + /// Opens or closes a slot's subtool chooser without changing the active tool. + SubtoolsToggle(usize), + /// Dismisses a transient toolbox or menu popup after an uncaptured pointer press. + OverlayOutsideClick, // ── Color ─────────────────────────────────────────── FgColorChanged([u8; 4]), @@ -467,17 +538,36 @@ pub enum Message { ColorTabChanged(usize), // ── Canvas interaction ────────────────────────────── - CanvasPointerPressed { x: f32, y: f32 }, - CanvasPointerMoved { x: f32, y: f32 }, + CanvasPointerPressed { + x: f32, + y: f32, + }, + CanvasPointerMoved { + x: f32, + y: f32, + }, CanvasPointerReleased, - CanvasPointerRightClicked { x: f32, y: f32, screen_x: f32, screen_y: f32 }, + CanvasPointerRightClicked { + x: f32, + y: f32, + screen_x: f32, + screen_y: f32, + }, CanvasContextMenuClose, - CanvasPanZoom { zoom: f32, pan_offset: Vector }, + CanvasPanZoom { + zoom: f32, + pan_offset: Vector, + }, CanvasZoomSet(f32), CanvasZoomRelative(f32), + FitToArea, CanvasSize((f32, f32)), - CanvasCursorPos { x: u32, y: u32 }, + CanvasCursorPos { + x: u32, + y: u32, + }, ModifiersChanged(iced::keyboard::Modifiers), + SpacePanChanged(bool), // ── Engine operations ─────────────────────────────── CompositeRefresh, @@ -525,6 +615,11 @@ pub enum Message { ShowStyleColorPicker(usize), ShowStyleShadowColorPicker(usize), HideStyleColorPicker, + OpenColorPicker(crate::color_picker::ColorPickerTarget, [u8; 4]), + CloseColorPicker, + ColorPickerTabChanged(usize), + ColorPickerHexChanged(String), + PopupColorChanged([u8; 4]), OpenLayerStyleDialog, LayerStyleDialogDragStart(f32, f32), LayerStyleDialogDragMove(f32, f32), @@ -546,6 +641,7 @@ pub enum Message { // ── Per-tool options (selection / spray / vector / text / gradient) ─ SelectionToleranceChanged(u8), SelectionContiguousToggled(bool), + SelectionAntiAliasToggled(bool), SelectionFeatherChanged(f32), SprayParticleSizeChanged(f32), SprayDensityChanged(u32), @@ -560,6 +656,7 @@ pub enum Message { TextAngleChanged(f32), TextOrientationChanged(String), TextContentChanged(String), + TextEditorAction(iced::widget::text_editor::Action), TextCommit, ToolApplyToNewLayerToggled(bool), @@ -617,6 +714,11 @@ pub enum Message { SelectAll, Deselect, SelectInverse, + SelectionModeChanged(selection::SelectionMode), + SelectionSave, + SelectionLoad, + QuickMaskToggle, + ClearPixels, // ── Selection transform ───────────────────────────── TransformDragStart(f32, f32), @@ -631,9 +733,18 @@ pub enum Message { VectorDrawEnd, // ── Vector select / edit ──────────────────────────── - VectorSelectClick { x: f32, y: f32 }, - VectorSelectDragStart { x: f32, y: f32 }, - VectorSelectDragMove { x: f32, y: f32 }, + VectorSelectClick { + x: f32, + y: f32, + }, + VectorSelectDragStart { + x: f32, + y: f32, + }, + VectorSelectDragMove { + x: f32, + y: f32, + }, VectorSelectDragEnd, VectorShapeDelete, VectorShapeSelect(Option), @@ -692,7 +803,10 @@ pub enum Message { AiChatReasoningToggled(bool), AiChatCanvasContextToggled(bool), AiChatStreamChunk(String), + AiChatReasoningChunk(String), AiChatStreamFinished(Result), + AiChatCancel, + AiChatCopyTranscript, // ── AI Script Generator ───────────────────────────── AiScriptPromptChanged(String), @@ -702,11 +816,13 @@ pub enum Message { AiScriptGenerate, AiScriptRun, AiScriptCopy, + AiScriptCopyOutput, AiScriptClear, AiScriptResult(Result), // ── Clipboard ─────────────────────────────────────── CopyImage, + CutImage, PasteImage, CopyText(String), PasteText, @@ -729,6 +845,8 @@ pub enum Message { // ── File I/O ──────────────────────────────────────── NewDocument(u32, u32), DocSwitch(usize), + /// Requests closure of one document tab without closing the application window. + DocClose(usize), OpenFile, FileOpened(Result), SaveFile, @@ -736,9 +854,8 @@ pub enum Message { // ── Menu ───────────────────────────────────────────── MenuOpen(usize), MenuClose, - MenuAction(usize, usize), // (menu_index, item_index) - OpenRecentFile(usize), // index into recent_files - ClearRecentFiles, + /// Executes a semantic command supplied by an enabled menu leaf. + MenuCommand(crate::panels::menus::MenuCommand), // ── Window ─────────────────────────────────────────── WindowDrag, @@ -746,6 +863,8 @@ pub enum Message { WindowMaximize, WindowClose, WindowId(iced::window::Id), + ScreenshotCapture, + ScreenshotReady(iced::window::Screenshot), ThemeChanged(crate::theme::ThemePreset), // ── Dock Profiles ─────────────────────────────────── @@ -765,7 +884,11 @@ pub enum Message { NoOp, // ── Tablet / touch feed ───────────────────────────── - TabletTouch { x: f32, y: f32, pressed: bool }, + TabletTouch { + x: f32, + y: f32, + pressed: bool, + }, // ── Contextual key actions (resolved in update with self) ── EscapePressed, @@ -773,6 +896,7 @@ pub enum Message { // ── Image Viewer ──────────────────────────────────── ViewerToggle, + ViewerSetMode(crate::viewer::ViewerMode), ViewerNavigate(std::path::PathBuf), ViewerSelectFile(std::path::PathBuf), ViewerOpenFile(std::path::PathBuf), @@ -933,9 +1057,7 @@ fn apply_transform_to_layer( for dx in 0..dst_w { let dst_x = px + dx as f32; let dst_y = py + dy as f32; - if dst_x < 0.0 || dst_x >= canvas_w as f32 - || dst_y < 0.0 || dst_y >= canvas_h as f32 - { + if dst_x < 0.0 || dst_x >= canvas_w as f32 || dst_y < 0.0 || dst_y >= canvas_h as f32 { continue; } @@ -948,7 +1070,10 @@ fn apply_transform_to_layer( let inv_y = -rel_x * sin + rel_y * cos; let center_src_x = src_w / 2.0; let center_src_y = src_h / 2.0; - ((inv_x / scale_x + center_src_x), (inv_y / scale_y + center_src_y)) + ( + (inv_x / scale_x + center_src_x), + (inv_y / scale_y + center_src_y), + ) } else { let nx = dx as f32 / scale_x.max(1e-6); let ny = dy as f32 / scale_y.max(1e-6); @@ -966,8 +1091,7 @@ fn apply_transform_to_layer( } let dst_idx = (dst_y as u32 * canvas_w + dst_x as u32) as usize * 4; if dst_idx + 3 < dest.len() { - dest[dst_idx..dst_idx + 4] - .copy_from_slice(&tr.pixels[src_idx..src_idx + 4]); + dest[dst_idx..dst_idx + 4].copy_from_slice(&tr.pixels[src_idx..src_idx + 4]); } } } @@ -976,11 +1100,29 @@ fn apply_transform_to_layer( } impl HcieIcedApp { + /// Combines the engine's newly generated selection with the previous mask. + /// + /// **Arguments:** `previous` is the selection mask captured before an engine selection call. + /// **Returns:** Nothing. **Side Effects / Dependencies:** Replaces the engine selection mask + /// through `hcie-engine-api` when add/subtract/intersect mode is active. + fn combine_current_selection(&mut self, previous: Option>) { + let doc = &mut self.documents[self.active_doc]; + let Some(new_mask) = doc.engine.get_selection_mask().map(ToOwned::to_owned) else { + return; + }; + let combined = + selection::state::combine_masks(previous.as_deref(), &new_mask, doc.selection_mode); + doc.engine.set_selection_mask(combined); + } + /// Create the initial application state. /// /// If `load_path` is provided, the file will be opened after initialization. /// Returns a Task to capture the window ID on startup. - pub fn new(load_path: Option) -> (Self, Task) { + pub fn new( + load_path: Option, + screenshot_request: Option, + ) -> (Self, Task) { let mut engine = Engine::new(800, 600); engine.pre_tile_all_layers(); let composite_raw = engine.get_composite_pixels(); @@ -1014,6 +1156,7 @@ impl HcieIcedApp { transform_drag_handle: TransformHandle::None, transform_drag_start: None, transform_original: None, + transform_source: None, internal_clipboard: None, selection_mask: None, selection_bounds: None, @@ -1025,6 +1168,10 @@ impl HcieIcedApp { text_draft: None, selection_edge_cache: Default::default(), marching_ants_edges: None, + selection_fill_spans: None, + selection_mode: selection::SelectionMode::Replace, + saved_selection_mask: None, + quick_mask: false, selected_vector_shape: None, vector_cycle_index: 0, vector_edit_handle: hcie_engine_api::VectorEditHandle::None, @@ -1053,6 +1200,7 @@ impl HcieIcedApp { canvas_context_menu: None, settings: settings.clone(), window_id: None, + screenshot_request, dialog_new_name: "Untitled".to_string(), dialog_new_width: 800, dialog_new_height: 600, @@ -1073,13 +1221,21 @@ impl HcieIcedApp { selected_filter: None, filter_params: serde_json::json!({}), filter_preview_active: false, + preview_baseline: None, + layer_style_baseline: None, + pending_file_operation: None, + pending_document_close: None, dock: DockState::new(), tablet_state: Arc::new(Mutex::new(TabletState::new())), initialized: false, - theme_state: ThemeState::new(), + theme_state: ThemeState::new(settings.theme_preset), active_menu: None, recent_files: load_recent_files(), recent_colors: Vec::new(), + color_picker_target: None, + popup_color: [0, 0, 0, 255], + popup_color_hex: "000000FF".to_string(), + popup_color_tab: 1, selected_style: 0, show_style_color_picker: false, style_color_picker_idx: 0, @@ -1091,6 +1247,7 @@ impl HcieIcedApp { layer_style_drag_start: None, marching_ants_offset: 0.0, ai_chat_state: AiChatState::default(), + ai_chat_abort: None, script_text: crate::script::parser::DEFAULT_SCRIPT.to_string(), script_content: iced::widget::text_editor::Content::with_text( crate::script::parser::DEFAULT_SCRIPT, @@ -1101,6 +1258,7 @@ impl HcieIcedApp { ai_script_prompt: String::new(), ai_script_output: String::new(), ai_script_error: None, + ai_script_notice: None, ai_script_is_running: false, ai_script_provider: "ollama".to_string(), ai_script_model: "qwen2.5:7b".to_string(), @@ -1116,11 +1274,21 @@ impl HcieIcedApp { colors_dirty: false, vector_shapes_snapshot: None, vector_drag_last: None, + vector_drag_session: None, bool_shape_a: None, bool_shape_b: None, - sidebar_expanded: false, + sidebar_expanded: settings.panel_layout.sidebar_expanded, }; + if let Some(panel) = app + .screenshot_request + .as_ref() + .and_then(|request| request.panel.as_deref()) + .and_then(crate::dock::state::PaneType::from_label) + { + app.dock.reopen_pane(panel); + } + // Apply saved settings to tool state settings.apply_to_tool_state(&mut app.tool_state); @@ -1143,7 +1311,8 @@ impl HcieIcedApp { // If a file path was provided, open it (route multi-layer formats to dedicated importers) if let Some(path) = load_path { let path_str = path.to_string_lossy().to_string(); - let ext = path.extension() + let ext = path + .extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_lowercase(); @@ -1156,23 +1325,35 @@ impl HcieIcedApp { match result { Ok(()) => { app.documents[0].engine.pre_tile_all_layers(); - app.documents[0].name = path.file_name() + app.documents[0].name = path + .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "Untitled".to_string()); app.documents[0].source_path = Some(path.clone()); app.add_recent_file(&path); let composite = app.documents[0].engine.get_composite_pixels(); let size = composite.len(); - log::info!("[startup] composite pixels: {} bytes ({}x{})", size, - app.documents[0].engine.canvas_width(), app.documents[0].engine.canvas_height()); + log::info!( + "[startup] composite pixels: {} bytes ({}x{})", + size, + app.documents[0].engine.canvas_width(), + app.documents[0].engine.canvas_height() + ); app.documents[0].composite_raw = composite; - app.documents[0].composite_pixels = std::sync::Arc::new(app.documents[0].composite_raw.clone()); + app.documents[0].composite_pixels = + std::sync::Arc::new(app.documents[0].composite_raw.clone()); app.documents[0].full_upload.replace(true); - app.documents[0].render_generation = app.documents[0].render_generation.wrapping_add(1); + app.documents[0].render_generation = + app.documents[0].render_generation.wrapping_add(1); app.documents[0].cached_layers = app.documents[0].engine.layer_infos(); let history_len = app.documents[0].engine.history_len(); app.documents[0].cached_history = (0..history_len) - .filter_map(|i| app.documents[0].engine.history_description(i).map(|d| (i, d))) + .filter_map(|i| { + app.documents[0] + .engine + .history_description(i) + .map(|d| (i, d)) + }) .collect(); app.documents[0].history_current = app.documents[0].engine.history_current(); } @@ -1195,7 +1376,9 @@ impl HcieIcedApp { /// Return a mutable reference to the active document. fn active_document_mut(&mut self) -> &mut IcedDocument { - self.documents.get_mut(self.active_doc).expect("no active document") + self.documents + .get_mut(self.active_doc) + .expect("no active document") } /// Convert pane-relative coordinates to canvas-space coordinates. @@ -1219,9 +1402,7 @@ impl HcieIcedApp { let canvas_x = (sx - canvas_origin_x) / zoom; let canvas_y = (sy - canvas_origin_y) / zoom; - if canvas_x >= 0.0 && canvas_x < engine_w - && canvas_y >= 0.0 && canvas_y < engine_h - { + if canvas_x >= 0.0 && canvas_x < engine_w && canvas_y >= 0.0 && canvas_y < engine_h { Some((canvas_x, canvas_y)) } else { None @@ -1250,82 +1431,150 @@ impl HcieIcedApp { None => return VEH::None, }; - let (x1, y1, x2, y2) = shape.normalized_bounds(); + let bounds = shape.normalized_bounds(); let angle = doc.engine.vector_shape_angle(layer_id, idx); - let w = (x2 - x1).abs(); - let h = (y2 - y1).abs(); - if w < 0.5 || h < 0.5 { return VEH::None; } - - let bcx = (x1 + x2) / 2.0; - let bcy = (y1 + y2) / 2.0; - let cos_a = angle.cos(); - let sin_a = angle.sin(); - - // Inverse-rotate a canvas-space point around the bounding-box center. - let unrotate = |px: f32, py: f32| -> (f32, f32) { - let dx = px - bcx; - let dy = py - bcy; - (bcx + dx * cos_a + dy * sin_a, bcy - dx * sin_a + dy * cos_a) - }; - - let (lx, ly) = unrotate(cx, cy); - - // Check rotation handle first (outside the box). - let rotate_offset = 24.0; - let rot_circ_x = bcx; - let rot_circ_y = y1 - rotate_offset; - let rot_radius = 6.0; - let drx = lx - rot_circ_x; - let dry = ly - rot_circ_y; - if drx * drx + dry * dry <= rot_radius * rot_radius { - return VEH::Rotate; - } - - // Check 8 resize handles. - let handle_half = 7.0; - let handle_points: [(VEH, f32, f32); 8] = [ - (VEH::TopLeft, x1, y1), - (VEH::TopRight, x2, y1), - (VEH::BottomLeft, x1, y2), - (VEH::BottomRight, x2, y2), - (VEH::Top, bcx, y1), - (VEH::Bottom, bcx, y2), - (VEH::Left, x1, bcy), - (VEH::Right, x2, bcy), - ]; - - for &(handle, hx, hy) in &handle_points { - if (lx - hx).abs() <= handle_half && (ly - hy).abs() <= handle_half { - return handle; - } - } - - // Check move handle (inside bounding box). - if lx >= x1 && lx <= x2 && ly >= y1 && ly <= y2 { - return VEH::Move; - } - - VEH::None + crate::vector_edit::hit_test_handle(bounds, angle, (cx, cy), doc.zoom) } - - /// Add a file to the recent files list (most recent first, max 20) and persist to disk. fn add_recent_file(&mut self, path: &std::path::Path) { let path_str = path.to_string_lossy().to_string(); - let name = path.file_name() + let name = path + .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| path_str.clone()); // Remove duplicates self.recent_files.retain(|e| e.path != path_str); // Insert at front - self.recent_files.insert(0, RecentFileEntry { path: path_str, name }); + self.recent_files.insert( + 0, + RecentFileEntry { + path: path_str, + name, + }, + ); // Cap at 20 self.recent_files.truncate(20); // Persist to disk save_recent_files(&self.recent_files); } + /// Captures the active layer once so repeated previews always start from identical pixels. + fn begin_preview(&mut self) -> bool { + self.restore_preview(); + let document_index = self.active_doc; + let layer_id = self.documents[document_index].engine.active_layer_id(); + let Some(pixels) = self.documents[document_index] + .engine + .get_layer_pixels(layer_id) + else { + return false; + }; + self.preview_baseline = Some(PreviewBaseline { + document_index, + layer_id, + pixels, + }); + true + } + + /// Restores preview pixels without consuming the immutable baseline. + fn reset_preview_to_baseline(&mut self) -> bool { + let Some(baseline) = self.preview_baseline.as_ref() else { + return false; + }; + if baseline.document_index >= self.documents.len() { + return false; + } + self.documents[baseline.document_index] + .engine + .set_layer_pixels(baseline.layer_id, baseline.pixels.clone()); + true + } + + /// Restores preview pixels and ends the current preview session. + fn restore_preview(&mut self) -> bool { + let restored = self.reset_preview_to_baseline(); + self.preview_baseline = None; + restored + } + + /// Saves a document using extension-based engine API routing. + fn save_document_to( + &self, + document_index: usize, + path: &std::path::Path, + ) -> Result<(), String> { + let path_str = path.to_string_lossy(); + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if extension.is_empty() { + return Err("Save As requires a supported file extension".to_string()); + } + let engine = &self.documents[document_index].engine; + match extension.as_str() { + "hcie" => engine.save_native(&path_str), + "psd" => engine.export_psd(&path_str), + "kra" => engine.export_kra(&path_str), + "png" | "jpg" | "jpeg" | "webp" | "avif" | "bmp" | "gif" | "tiff" | "tif" | "tga" + | "exr" => engine.save_as(&path_str, &extension), + _ => Err(format!("Unsupported save extension: {extension}")), + } + } + + /// Applies successful-save identity updates and records the path as recent. + fn finish_successful_save(&mut self, document_index: usize, path: std::path::PathBuf) { + let name = path + .file_name() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Untitled".to_string()); + let doc = &mut self.documents[document_index]; + doc.source_path = Some(path.clone()); + doc.name = name; + doc.modified = false; + self.add_recent_file(&path); + } + + /// Continues window-close processing after one document has saved successfully. + fn continue_close_after_save(&mut self) -> Task { + if let Some(document_index) = self.pending_document_close.take() { + self.active_dialog = ActiveDialog::None; + self.close_document_tab(document_index); + return Task::none(); + } + if let Some(next) = self.documents.iter().position(|document| document.modified) { + self.active_doc = next; + self.active_dialog = ActiveDialog::CloseConfirm; + Task::none() + } else { + self.active_dialog = ActiveDialog::None; + self.window_id.map_or_else(Task::none, iced::window::close) + } + } + + /// Removes one non-final document tab and keeps the active index valid. + /// + /// Arguments: `document_index` is the tab to remove. Returns: Nothing. Logic & Workflow: + /// indices before the active tab shift it left; closing the active tab selects the tab now at + /// that position or the preceding final tab. Side Effects: Drops the document and edit state. + fn close_document_tab(&mut self, document_index: usize) { + if self.documents.len() <= 1 || document_index >= self.documents.len() { + return; + } + self.documents.remove(document_index); + self.active_doc = active_index_after_document_close( + self.active_doc, + document_index, + self.documents.len(), + ); + self.vector_shapes_snapshot = None; + self.vector_drag_session = None; + self.vector_drag_last = None; + } + /// Add a color to the recent colors list (most recent first, max 40). fn add_recent_color(&mut self, color: [u8; 4]) { self.recent_colors.retain(|c| *c != color); @@ -1366,11 +1615,29 @@ impl HcieIcedApp { // Update selection edge cache if mask changed if let Some(mask) = doc.engine.get_selection_mask() { + doc.selection_mask = Some(mask.to_vec()); + doc.selection_bounds = selection::state::mask_bounds( + mask, + doc.engine.canvas_width(), + doc.engine.canvas_height(), + ); + doc.selection_fill_spans = Some(Arc::new(selection::state::selected_spans( + mask, + doc.engine.canvas_width(), + doc.engine.canvas_height(), + ))); let edges = doc.selection_edge_cache.get(mask).unwrap_or_else(|| { - doc.selection_edge_cache.rebuild(mask, doc.engine.canvas_width(), doc.engine.canvas_height()) + doc.selection_edge_cache.rebuild( + mask, + doc.engine.canvas_width(), + doc.engine.canvas_height(), + ) }); doc.marching_ants_edges = Some(edges); } else { + doc.selection_mask = None; + doc.selection_bounds = None; + doc.selection_fill_spans = None; doc.marching_ants_edges = None; // Clear cache if selection is dropped so next time it won't mistakenly hit doc.selection_edge_cache = Default::default(); @@ -1390,7 +1657,22 @@ impl HcieIcedApp { /// Called before starting a stroke to ensure the engine uses /// the current brush size, opacity, hardness, and style. fn apply_brush_params(&mut self) { - let tip = build_brush_tip(&self.tool_state); + let mut tip = build_brush_tip(&self.tool_state); + match self.tool_state.active_tool { + Tool::Pen => { + tip.style = BrushStyle::Round; + tip.size = self.settings.tool_settings.pen_size; + tip.spacing = 0.05; + } + Tool::Spray => { + tip.style = BrushStyle::Spray; + tip.size = self.settings.tool_settings.spray_radius; + tip.spray_particle_size = self.settings.tool_settings.spray_particle_size; + tip.spray_density = self.settings.tool_settings.spray_density; + tip.spacing = 0.5; + } + _ => {} + } self.documents[self.active_doc].engine.set_brush_tip(tip); } @@ -1404,8 +1686,13 @@ impl HcieIcedApp { // Update marching ants animation offset (cycles at ~60fps for smooth animation) // The offset cycles from 0.0 to 1.0, completing one cycle every ~1 second - if self.documents.iter().any(|doc| doc.selection_bounds.is_some()) { - self.marching_ants_offset = (self.marching_ants_offset + since_last.as_secs_f32() * 0.5) % 1.0; + if self + .documents + .iter() + .any(|doc| doc.selection_bounds.is_some()) + { + self.marching_ants_offset = + (self.marching_ants_offset + since_last.as_secs_f32() * 0.5) % 1.0; } match message { @@ -1431,27 +1718,37 @@ impl HcieIcedApp { } Message::ToolSlotClicked(slot_idx, primary_tool) => { - // If clicking the same slot that's already active, toggle sub-tools popup - if self.active_tool_slot == slot_idx && self.sub_tools_open { - self.sub_tools_open = false; - } else if self.active_tool_slot == slot_idx { - // Same slot, but popup not open — open it if slot has sub-tools - self.sub_tools_open = true; - } else { - // Different slot — select the last-used sub-tool (or the - // primary tool if none recorded) and close the popup. + self.active_tool_slot = slot_idx; + let tool = crate::sidebar::last_used_tool(slot_idx, &self.slot_last_used); + self.tool_state.active_tool = + if crate::sidebar::tool_slots().get(slot_idx).is_some() { + tool + } else { + primary_tool + }; + self.sub_tools_open = false; + } + Message::SubtoolsToggle(slot_idx) => { + let supports_popup = crate::sidebar::tool_slots() + .get(slot_idx) + .is_some_and(|slot| slot.tools.len() > 1); + if supports_popup { + let opening = !(self.sub_tools_open && self.active_tool_slot == slot_idx); + self.sub_tools_open = opening; self.active_tool_slot = slot_idx; - let last = self - .slot_last_used - .get(slot_idx) - .copied() - .unwrap_or(0); - let tool = crate::sidebar::tool_slots() - .get(slot_idx) - .and_then(|s| s.tools.get(last).copied()) - .unwrap_or(primary_tool); - self.tool_state.active_tool = tool; + if opening && !self.sidebar_expanded { + self.sidebar_expanded = true; + self.settings.panel_layout.sidebar_expanded = true; + self.settings.panel_layout.sidebar_width = 72.0; + let _ = self.settings.save(); + } + } + } + Message::OverlayOutsideClick => { + if self.sub_tools_open { self.sub_tools_open = false; + } else { + self.active_menu = None; } } @@ -1461,11 +1758,13 @@ impl HcieIcedApp { self.add_recent_color(color); self.colors_dirty = true; crate::shape_sync::sync_shape_from_tool(self); + self.refresh_composite_if_needed(); } Message::BgColorChanged(color) => { self.bg_color = color; crate::shape_sync::sync_shape_from_tool(self); + self.refresh_composite_if_needed(); } Message::SwapColors => { @@ -1475,6 +1774,7 @@ impl HcieIcedApp { self.add_recent_color(color); self.colors_dirty = true; crate::shape_sync::sync_shape_from_tool(self); + self.refresh_composite_if_needed(); } Message::ResetColors => { // Reset fg=black, bg=white (egui: toolbox.rs:351-354). @@ -1483,10 +1783,66 @@ impl HcieIcedApp { let fg = self.fg_color; self.active_document_mut().engine.set_color(fg); self.colors_dirty = true; + crate::shape_sync::sync_shape_from_tool(self); + self.refresh_composite_if_needed(); } Message::ColorTabChanged(tab) => { self.color_tab = tab; } + Message::OpenColorPicker(target, color) => { + self.color_picker_target = Some(target); + self.popup_color = color; + self.popup_color_hex = format!( + "{:02X}{:02X}{:02X}{:02X}", + color[0], color[1], color[2], color[3] + ); + } + Message::CloseColorPicker => { + self.color_picker_target = None; + } + Message::ColorPickerTabChanged(tab) => { + self.popup_color_tab = tab.min(2); + } + Message::ColorPickerHexChanged(value) => { + self.popup_color_hex = value.clone(); + if let Some(color) = + crate::color_picker::parse_hex_color(&value, self.popup_color[3]) + { + return self.update(Message::PopupColorChanged(color)); + } + } + Message::PopupColorChanged(color) => { + self.popup_color = color; + self.popup_color_hex = format!( + "{:02X}{:02X}{:02X}{:02X}", + color[0], color[1], color[2], color[3] + ); + let routed = match self.color_picker_target { + Some(crate::color_picker::ColorPickerTarget::Foreground) => { + Some(Message::FgColorChanged(color)) + } + Some(crate::color_picker::ColorPickerTarget::Background) => { + Some(Message::BgColorChanged(color)) + } + Some(crate::color_picker::ColorPickerTarget::GeometryFill) => { + Some(Message::GeometryFillColorChanged(color)) + } + Some(crate::color_picker::ColorPickerTarget::GeometryStroke) => { + Some(Message::GeometryStrokeColorChanged(color)) + } + Some(crate::color_picker::ColorPickerTarget::StyleMain(index)) + | Some(crate::color_picker::ColorPickerTarget::StyleHighlight(index)) => { + Some(Message::LayerStyleUpdateColor(index, color)) + } + Some(crate::color_picker::ColorPickerTarget::StyleShadow(index)) => { + Some(Message::LayerStyleUpdateShadowColor(index, color)) + } + None => None, + }; + if let Some(message) = routed { + return self.update(message); + } + } Message::CanvasPointerPressed { x, y } => { // Coordinates are already in canvas-space from the canvas widget @@ -1495,22 +1851,68 @@ impl HcieIcedApp { self.canvas_cursor_pos = Some((canvas_x as u32, canvas_y as u32)); let tool = self.tool_state.active_tool; + if let Some(transform) = + self.documents[self.active_doc].selection_transform.as_ref() + { + let handle = transform.hit_test(x, y, self.documents[self.active_doc].zoom); + if handle != TransformHandle::None { + self.documents[self.active_doc].transform_drag_handle = handle; + return self.update(Message::TransformDragStart(x, y)); + } + } + let now = std::time::Instant::now(); let _is_double_click = if let Some((lx, ly)) = self.tool_state.last_click_pos { let dx = canvas_x - lx; let dy = canvas_y - ly; let dist_sq = dx * dx + dy * dy; - now.duration_since(self.tool_state.last_click_time).as_millis() < 300 && dist_sq < 25.0 + now.duration_since(self.tool_state.last_click_time) + .as_millis() + < 300 + && dist_sq < 25.0 } else { false }; self.tool_state.last_click_time = now; self.tool_state.last_click_pos = Some((canvas_x, canvas_y)); + let paint_tool = + matches!(tool, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray); + if paint_tool && (self.modifiers.control() || self.modifiers.logo()) { + let cx = canvas_x as u32; + let cy = canvas_y as u32; + let width = self.documents[self.active_doc].engine.canvas_width(); + if cx < width && cy < self.documents[self.active_doc].engine.canvas_height() { + let index = ((cy * width + cx) * 4) as usize; + if index + 3 < self.documents[self.active_doc].composite_raw.len() { + let pixels = &self.documents[self.active_doc].composite_raw; + let color = [ + pixels[index], + pixels[index + 1], + pixels[index + 2], + pixels[index + 3], + ]; + self.fg_color = color; + self.documents[self.active_doc].engine.set_color(color); + } + } + return Task::none(); + } + + if paint_tool && self.modifiers.shift() { + self.tool_state.is_resizing_brush = true; + self.tool_state.is_drawing = true; + self.tool_state.brush_resize_start_size = self.tool_state.brush_size; + self.tool_state.brush_resize_start_pos = Some((canvas_x, canvas_y)); + return Task::none(); + } + if let Some(req_type) = tool.allowed_layer_type() { // Reject raster edits on non-editable layers if req_type == hcie_engine_api::LayerType::Raster - && !self.documents[self.active_doc].engine.active_layer_is_editable() + && !self.documents[self.active_doc] + .engine + .active_layer_is_editable() { // In the future: emit a status message here return Task::none(); @@ -1522,7 +1924,8 @@ impl HcieIcedApp { if req_type != hcie_engine_api::LayerType::Text && req_type != hcie_engine_api::LayerType::Vector { - if let Some(active) = self.documents[self.active_doc].engine.active_layer() { + if let Some(active) = self.documents[self.active_doc].engine.active_layer() + { if active.layer_type != req_type { let id = self.documents[self.active_doc].engine.add_layer("Layer"); self.documents[self.active_doc].engine.set_active_layer(id); @@ -1539,9 +1942,17 @@ impl HcieIcedApp { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); self.documents[self.active_doc].engine.set_tool(tool); - self.documents[self.active_doc].engine.set_eraser(tool == Tool::Eraser); - self.documents[self.active_doc].engine.begin_stroke(layer_id, canvas_x, canvas_y); - self.documents[self.active_doc].engine.stroke_to(layer_id, canvas_x, canvas_y, 1.0); + self.documents[self.active_doc] + .engine + .set_eraser(tool == Tool::Eraser); + if tool != Tool::Pen { + self.documents[self.active_doc] + .engine + .begin_stroke(layer_id, canvas_x, canvas_y); + self.documents[self.active_doc] + .engine + .stroke_to(layer_id, canvas_x, canvas_y, 1.0); + } self.tool_state.is_drawing = true; self.tool_state.last_stroke_pos = Some((canvas_x, canvas_y)); self.tool_state.brush_accumulated_dist = 0.0; @@ -1557,225 +1968,240 @@ impl HcieIcedApp { let pixels = &self.documents[self.active_doc].composite_raw; let idx = ((cy * w + cx) * 4) as usize; if idx + 3 < pixels.len() { - let color = [pixels[idx], pixels[idx + 1], pixels[idx + 2], pixels[idx + 3]]; + let color = [ + pixels[idx], + pixels[idx + 1], + pixels[idx + 2], + pixels[idx + 3], + ]; self.fg_color = color; self.documents[self.active_doc].engine.set_color(color); } } } - Tool::FloodFill => { - let cx = canvas_x as u32; - let cy = canvas_y as u32; - let color = self.fg_color; - let tol = self.settings.tool_settings.flood_fill_tolerance as u8; - self.documents[self.active_doc].engine.flood_fill(cx, cy, color, tol); - self.refresh_composite_if_needed(); - } - Tool::Select => { - // Start selection rectangle drag - let sx = canvas_x; - let sy = canvas_y; - return Task::perform(async move {}, move |_| { - Message::SelectionDragStart(sx, sy) - }); - } - Tool::MagicWand => { - let cx = canvas_x as u32; - let cy = canvas_y as u32; - let tol = self.settings.tool_settings.magic_wand_tolerance as u8; - self.documents[self.active_doc] - .engine - .create_selection_magic_wand(cx, cy, tol); - self.documents[self.active_doc].selection_bounds = { - let w = self.documents[self.active_doc].engine.canvas_width(); - let h = self.documents[self.active_doc].engine.canvas_height(); - Some((0, 0, w, h)) - }; - self.refresh_composite_if_needed(); - } - Tool::SmartSelect => { - // No dedicated AI selection API exposed by the engine; - // fall back to a magic-wand pick at the click point so the - // tool remains usable without a configured SAM backend. - let cx = canvas_x as u32; - let cy = canvas_y as u32; - let tol = self.settings.tool_settings.magic_wand_tolerance as u8; - self.documents[self.active_doc] - .engine - .create_selection_magic_wand(cx, cy, tol); - self.documents[self.active_doc].selection_bounds = { - let w = self.documents[self.active_doc].engine.canvas_width(); - let h = self.documents[self.active_doc].engine.canvas_height(); - Some((0, 0, w, h)) - }; - self.refresh_composite_if_needed(); - } - Tool::Lasso => { - // Start a freehand lasso path. - self.documents[self.active_doc].lasso_points.clear(); - let cx = canvas_x as u32; - let cy = canvas_y as u32; - self.documents[self.active_doc].lasso_points.push((cx, cy)); - self.tool_state.is_drawing = true; - } - Tool::PolygonSelect => { - // Polygon lasso: accumulate vertices on each click. Close when - // clicking near the first vertex (≥3 points) or double-clicking. - let cx = canvas_x as u32; - let cy = canvas_y as u32; - let pts = &self.documents[self.active_doc].polygon_points; - let close_dist: i32 = 10; - let near_start = pts.len() >= 3 - && (pts[0].0 as i32 - cx as i32).abs() <= close_dist - && (pts[0].1 as i32 - cy as i32).abs() <= close_dist; - let near_last = pts - .last() - .map(|l| { - (l.0 as i32 - cx as i32).abs() <= 4 - && (l.1 as i32 - cy as i32).abs() <= 4 - }) - .unwrap_or(false); - if near_start || near_last { - // Finalize polygon selection. - let pts = - std::mem::take(&mut self.documents[self.active_doc].polygon_points); - if pts.len() >= 3 { - let pts_ref: Vec<(u32, u32)> = pts; - self.documents[self.active_doc] - .engine - .create_selection_polygon(&pts_ref); - self.refresh_composite_if_needed(); - } - } else { - self.documents[self.active_doc].polygon_points.push((cx, cy)); + Tool::FloodFill => { + let cx = canvas_x as u32; + let cy = canvas_y as u32; + let color = self.fg_color; + let tol = self.settings.tool_settings.flood_fill_tolerance as u8; + self.documents[self.active_doc] + .engine + .flood_fill(cx, cy, color, tol); + self.refresh_composite_if_needed(); + } + Tool::Select => { + // Start selection rectangle drag + let sx = canvas_x; + let sy = canvas_y; + return Task::perform(async move {}, move |_| { + Message::SelectionDragStart(sx, sy) + }); + } + Tool::MagicWand => { + let cx = canvas_x as u32; + let cy = canvas_y as u32; + let tol = self.settings.tool_settings.magic_wand_tolerance as u8; + let previous = self.documents[self.active_doc] + .engine + .get_selection_mask() + .map(ToOwned::to_owned); + self.documents[self.active_doc] + .engine + .create_selection_magic_wand(cx, cy, tol); + self.combine_current_selection(previous); + self.refresh_composite_if_needed(); + } + Tool::SmartSelect => { + // No dedicated AI selection API exposed by the engine; + // fall back to a magic-wand pick at the click point so the + // tool remains usable without a configured SAM backend. + let cx = canvas_x as u32; + let cy = canvas_y as u32; + let tol = self.settings.tool_settings.magic_wand_tolerance as u8; + self.documents[self.active_doc] + .engine + .create_selection_magic_wand(cx, cy, tol); + self.documents[self.active_doc].selection_bounds = { + let w = self.documents[self.active_doc].engine.canvas_width(); + let h = self.documents[self.active_doc].engine.canvas_height(); + Some((0, 0, w, h)) + }; + self.refresh_composite_if_needed(); + } + Tool::Lasso => { + // Start a freehand lasso path. + self.documents[self.active_doc].lasso_points.clear(); + let cx = canvas_x as u32; + let cy = canvas_y as u32; + self.documents[self.active_doc].lasso_points.push((cx, cy)); + self.tool_state.is_drawing = true; + } + Tool::PolygonSelect => { + // Polygon lasso: accumulate vertices on each click. Close when + // clicking near the first vertex (≥3 points) or double-clicking. + let cx = canvas_x as u32; + let cy = canvas_y as u32; + let pts = &self.documents[self.active_doc].polygon_points; + let close_dist: i32 = 10; + let near_start = pts.len() >= 3 + && (pts[0].0 as i32 - cx as i32).abs() <= close_dist + && (pts[0].1 as i32 - cy as i32).abs() <= close_dist; + let near_last = pts + .last() + .map(|l| { + (l.0 as i32 - cx as i32).abs() <= 4 + && (l.1 as i32 - cy as i32).abs() <= 4 + }) + .unwrap_or(false); + if near_start || near_last { + // Finalize polygon selection. + let pts = + std::mem::take(&mut self.documents[self.active_doc].polygon_points); + if pts.len() >= 3 { + let pts_ref: Vec<(u32, u32)> = pts; + self.documents[self.active_doc] + .engine + .create_selection_polygon(&pts_ref); + self.refresh_composite_if_needed(); } + } else { + self.documents[self.active_doc] + .polygon_points + .push((cx, cy)); } - Tool::VisionSelect => { - // Start a bounding-box drag for vision segmentation. - self.tool_state.is_drawing = true; - self.documents[self.active_doc].vision_rect = - Some((canvas_x, canvas_y, canvas_x, canvas_y)); - } - Tool::Move => { - // Begin a floating-pixel transform from the active - // selection mask, clearing the source area first. - let doc = &mut self.documents[self.active_doc]; - let mask = doc.engine.get_selection_mask().map(|m| m.to_vec()); - let w = doc.engine.canvas_width(); - let h = doc.engine.canvas_height(); - if let Some(mask_data) = mask { - let cx = canvas_x as u32; - let cy = canvas_y as u32; - let in_selection = (cx < w && cy < h) - && mask_data[(cy * w + cx) as usize] > 0; - if in_selection { - let tr = selection::SelectionTransform::from_engine( - &mut doc.engine, - &mask_data, - w, - h, - ); - if !tr.is_empty() { - // Clear pixels within the selection area (not the - // whole layer), then drop the selection mask. - if let Some(mut layer_pixels) = - doc.engine.get_active_layer_pixels() - { - let cw = w as usize; - for y in 0..h as usize { - for x in 0..cw { - let midx = y * cw + x; - if midx < mask_data.len() - && mask_data[midx] > 0 - { - let pidx = midx * 4; - if pidx + 3 < layer_pixels.len() { - layer_pixels[pidx..pidx + 4] - .fill(0); - } + } + Tool::VisionSelect => { + // Start a bounding-box drag for vision segmentation. + self.tool_state.is_drawing = true; + self.documents[self.active_doc].vision_rect = + Some((canvas_x, canvas_y, canvas_x, canvas_y)); + } + Tool::Move => { + // Begin a floating-pixel transform from the active + // selection mask, clearing the source area first. + let doc = &mut self.documents[self.active_doc]; + let mask = doc.engine.get_selection_mask().map(|m| m.to_vec()); + let w = doc.engine.canvas_width(); + let h = doc.engine.canvas_height(); + if let Some(mask_data) = mask { + let cx = canvas_x as u32; + let cy = canvas_y as u32; + let in_selection = + (cx < w && cy < h) && mask_data[(cy * w + cx) as usize] > 0; + if in_selection { + let tr = selection::SelectionTransform::from_engine( + &mut doc.engine, + &mask_data, + w, + h, + ); + if !tr.is_empty() { + // Clear pixels within the selection area (not the + // whole layer), then drop the selection mask. + if let Some(mut layer_pixels) = + doc.engine.get_active_layer_pixels() + { + let cw = w as usize; + for y in 0..h as usize { + for x in 0..cw { + let midx = y * cw + x; + if midx < mask_data.len() && mask_data[midx] > 0 { + let pidx = midx * 4; + if pidx + 3 < layer_pixels.len() { + layer_pixels[pidx..pidx + 4].fill(0); } } } - doc.engine.set_active_layer_pixels(layer_pixels); } - doc.engine.selection_clear(); - doc.selection_mask = Some(mask_data); - doc.selection_transform = Some(tr); + doc.engine.set_active_layer_pixels(layer_pixels); } + doc.engine.selection_clear(); + doc.selection_mask = Some(mask_data); + doc.transform_source = Some(tr.clone()); + doc.selection_transform = Some(tr); } } } - Tool::Gradient => { - // Start a gradient endpoint drag. - let sx = canvas_x; - let sy = canvas_y; - return Task::perform(async move {}, move |_| { - Message::GradientDragStart(sx, sy) - }); - } - Tool::Text => { - // Begin (or continue) an on-canvas text draft at the - // click point. A second click elsewhere moves the draft. - let cx = canvas_x; - let cy = canvas_y; - let doc = &mut self.documents[self.active_doc]; - if doc.text_draft.is_none() { - let mut draft = TextDraft::default(); - draft.color = self.fg_color; - draft.x = cx; - draft.y = cy; - doc.text_draft = Some(draft); - } else { - doc.text_draft.as_mut().unwrap().x = cx; - doc.text_draft.as_mut().unwrap().y = cy; - } - } - Tool::VectorRect - | Tool::VectorCircle - | Tool::VectorLine - | Tool::VectorArrow - | Tool::VectorStar - | Tool::VectorPolygon - | Tool::VectorRhombus - | Tool::VectorCylinder - | Tool::VectorHeart - | Tool::VectorBubble - | Tool::VectorGear - | Tool::VectorCross - | Tool::VectorCrescent - | Tool::VectorBolt - | Tool::VectorArrow4 => { - // Start vector shape drawing (all shape tools share one drag). - let sx = canvas_x; - let sy = canvas_y; - return Task::perform(async move {}, move |_| { - Message::VectorDrawStart(sx, sy) - }); - } - Tool::Crop => { - // Start crop drag - let sx = canvas_x; - let sy = canvas_y; - return Task::perform(async move {}, move |_| { - Message::CropDragStart(sx, sy) - }); - } - Tool::VectorSelect => { - // Click to select vector shapes under cursor. - let cx = canvas_x; - let cy = canvas_y; - return Task::perform(async move {}, move |_| { - Message::VectorSelectClick { x: cx, y: cy } - }); - } - _ => {} } + Tool::Gradient => { + // Start a gradient endpoint drag. + let sx = canvas_x; + let sy = canvas_y; + return Task::perform(async move {}, move |_| { + Message::GradientDragStart(sx, sy) + }); + } + Tool::Text => { + // Begin (or continue) an on-canvas text draft at the + // click point. A second click elsewhere moves the draft. + let cx = canvas_x; + let cy = canvas_y; + let doc = &mut self.documents[self.active_doc]; + if doc.text_draft.is_none() { + let mut draft = TextDraft::default(); + draft.color = self.fg_color; + draft.x = cx; + draft.y = cy; + doc.text_draft = Some(draft); + } else { + doc.text_draft.as_mut().unwrap().x = cx; + doc.text_draft.as_mut().unwrap().y = cy; + } + } + Tool::VectorRect + | Tool::VectorCircle + | Tool::VectorLine + | Tool::VectorArrow + | Tool::VectorStar + | Tool::VectorPolygon + | Tool::VectorRhombus + | Tool::VectorCylinder + | Tool::VectorHeart + | Tool::VectorBubble + | Tool::VectorGear + | Tool::VectorCross + | Tool::VectorCrescent + | Tool::VectorBolt + | Tool::VectorArrow4 => { + // Start vector shape drawing (all shape tools share one drag). + let sx = canvas_x; + let sy = canvas_y; + return Task::perform(async move {}, move |_| { + Message::VectorDrawStart(sx, sy) + }); + } + Tool::Crop => { + // Start crop drag + let sx = canvas_x; + let sy = canvas_y; + return Task::perform(async move {}, move |_| { + Message::CropDragStart(sx, sy) + }); + } + Tool::VectorSelect => { + // Click to select vector shapes under cursor. + let cx = canvas_x; + let cy = canvas_y; + return Task::perform(async move {}, move |_| Message::VectorSelectClick { + x: cx, + y: cy, + }); + } + _ => {} } + } - Message::CanvasPointerMoved { x, y } => { + Message::CanvasPointerMoved { x, y } => { // Coordinates are already in canvas-space from the canvas widget self.canvas_cursor_pos = Some((x as u32, y as u32)); + if self.documents[self.active_doc] + .transform_drag_start + .is_some() + { + return self.update(Message::TransformDragMove(x, y)); + } + if self.tool_state.is_drawing { let is_ctrl = self.modifiers.control() || self.modifiers.logo(); if is_ctrl { @@ -1788,7 +2214,12 @@ impl HcieIcedApp { let pixels = &self.documents[self.active_doc].composite_raw; let idx = ((cy * w + cx) * 4) as usize; if idx + 3 < pixels.len() { - let color = [pixels[idx], pixels[idx + 1], pixels[idx + 2], pixels[idx + 3]]; + let color = [ + pixels[idx], + pixels[idx + 1], + pixels[idx + 2], + pixels[idx + 3], + ]; self.fg_color = color; self.documents[self.active_doc].engine.set_color(color); } @@ -1796,7 +2227,9 @@ impl HcieIcedApp { } else if self.tool_state.is_resizing_brush { if let Some((start_x, _)) = self.tool_state.brush_resize_start_pos { let dx = x - start_x; - let new_size = (self.tool_state.brush_resize_start_size + dx).clamp(1.0, 500.0); + let new_size = + (self.tool_state.brush_resize_start_size + dx).clamp(1.0, 500.0); + self.tool_state.brush_size = new_size; match self.tool_state.active_tool { Tool::Pen => self.settings.tool_settings.pen_size = new_size, Tool::Brush => self.settings.tool_settings.brush_size = new_size, @@ -1808,10 +2241,32 @@ impl HcieIcedApp { } else { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); + if self.tool_state.active_tool == Tool::Pen { + if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos { + if !self.documents[self.active_doc].engine.is_stroke_active() { + self.documents[self.active_doc] + .engine + .begin_stroke(layer_id, last_x, last_y); + } + let pressure = self + .tablet_state + .lock() + .map(|state| state.current_pressure()) + .unwrap_or(1.0); + self.documents[self.active_doc] + .engine + .draw_pen_segment(layer_id, last_x, last_y, x, y, pressure); + self.tool_state.last_stroke_pos = Some((x, y)); + self.refresh_composite_if_needed(); + } + return Task::none(); + } + let spacing: f32 = match self.tool_state.active_tool { Tool::Spray => 2.0_f32, _ => 1.0_f32, - }.max(1.0); + } + .max(1.0); if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos { let dx = x - last_x; @@ -1820,22 +2275,31 @@ impl HcieIcedApp { // Use constant pressure for mouse input // Real tablet pressure comes from tablet_state - let pressure = self.tablet_state.lock().map(|s| s.current_pressure()).unwrap_or(1.0); + let pressure = self + .tablet_state + .lock() + .map(|s| s.current_pressure()) + .unwrap_or(1.0); if dist >= spacing { - self.documents[self.active_doc].engine.stroke_to(layer_id, x, y, pressure); + self.documents[self.active_doc] + .engine + .stroke_to(layer_id, x, y, pressure); self.tool_state.last_stroke_pos = Some((x, y)); // Throttle composite refresh to ~60 FPS during strokes. let now = std::time::Instant::now(); - let elapsed = now.duration_since(self.tool_state.last_composite_refresh); + let elapsed = + now.duration_since(self.tool_state.last_composite_refresh); if elapsed.as_secs_f32() >= 0.016 { self.tool_state.last_composite_refresh = now; self.refresh_composite_if_needed(); } } } else { - self.documents[self.active_doc].engine.stroke_to(layer_id, x, y, 1.0); + self.documents[self.active_doc] + .engine + .stroke_to(layer_id, x, y, 1.0); self.tool_state.last_stroke_pos = Some((x, y)); } } @@ -1854,9 +2318,7 @@ impl HcieIcedApp { if self.tool_state.active_tool == Tool::Lasso && self.tool_state.is_drawing { let mx = x; let my = y; - return Task::perform(async move {}, move |_| { - Message::LassoDragMove(mx, my) - }); + return Task::perform(async move {}, move |_| Message::LassoDragMove(mx, my)); } // Handle vision-select bounding-box drag. @@ -1881,32 +2343,45 @@ impl HcieIcedApp { if is_vector_shape_tool(self.tool_state.active_tool) { let mx = x; let my = y; - return Task::perform(async move {}, move |_| { - Message::VectorDrawMove(mx, my) - }); + return Task::perform(async move {}, move |_| Message::VectorDrawMove(mx, my)); } // Handle crop drag if self.tool_state.active_tool == Tool::Crop { let mx = x; let my = y; - return Task::perform(async move {}, move |_| { - Message::CropDragMove(mx, my) - }); + return Task::perform(async move {}, move |_| Message::CropDragMove(mx, my)); } // Handle vector select drag - if self.tool_state.active_tool == Tool::VectorSelect && self.vector_drag_last.is_some() { + if self.tool_state.active_tool == Tool::VectorSelect + && self.vector_drag_last.is_some() + { let mx = x; let my = y; - return Task::perform(async move {}, move |_| { - Message::VectorSelectDragMove { x: mx, y: my } + return Task::perform(async move {}, move |_| Message::VectorSelectDragMove { + x: mx, + y: my, }); } } Message::CanvasPointerReleased => { - self.tool_state.is_resizing_brush = false; + if self.tool_state.is_resizing_brush { + self.tool_state.is_resizing_brush = false; + self.tool_state.is_drawing = false; + self.tool_state.brush_resize_start_pos = None; + self.settings.update_from_tool_state(&self.tool_state); + let _ = self.settings.save(); + return Task::none(); + } + + if self.documents[self.active_doc] + .transform_drag_start + .is_some() + { + return self.update(Message::TransformDragEnd); + } // Lasso release finalizes a freehand selection path. if self.tool_state.active_tool == Tool::Lasso && self.tool_state.is_drawing { @@ -1922,11 +2397,12 @@ impl HcieIcedApp { let sx = x0.min(x1) as u32; let sy = y0.min(y1) as u32; let ex = x0.max(x1) as u32; - let ey = y1.max(y1) as u32; + let ey = y0.max(y1) as u32; if ex > sx && ey > sy { self.documents[self.active_doc] .engine .create_selection_rect(sx, sy, ex, ey); + self.combine_current_selection(None); self.documents[self.active_doc].selection_bounds = Some((sx, sy, ex - sx, ey - sy)); self.refresh_composite_if_needed(); @@ -1944,7 +2420,9 @@ impl HcieIcedApp { if self.tool_state.is_drawing { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); self.documents[self.active_doc].engine.end_stroke(layer_id); - self.documents[self.active_doc].engine.commit_pending_history(); + self.documents[self.active_doc] + .engine + .commit_pending_history(); self.tool_state.is_drawing = false; self.tool_state.last_stroke_pos = None; @@ -1969,12 +2447,19 @@ impl HcieIcedApp { } // End vector select drag - if self.tool_state.active_tool == Tool::VectorSelect && self.vector_drag_last.is_some() { + if self.tool_state.active_tool == Tool::VectorSelect + && self.vector_drag_last.is_some() + { return Task::perform(async {}, |_| Message::VectorSelectDragEnd); } } - Message::CanvasPointerRightClicked { x: _, y: _, screen_x, screen_y } => { + Message::CanvasPointerRightClicked { + x: _, + y: _, + screen_x, + screen_y, + } => { // Ignore canvas_x, canvas_y for now, just pop the menu self.canvas_context_menu = Some((screen_x, screen_y)); } @@ -2007,6 +2492,13 @@ impl HcieIcedApp { doc.zoom = (doc.zoom * factor).clamp(ZOOM_MIN, ZOOM_MAX); // Keep pan_offset — zooming relative to center via keyboard. } + Message::FitToArea => { + let doc = &mut self.documents[self.active_doc]; + let fit_x = (doc.pane_size.0 - 24.0) / doc.engine.canvas_width() as f32; + let fit_y = (doc.pane_size.1 - 24.0) / doc.engine.canvas_height() as f32; + doc.zoom = fit_x.min(fit_y).clamp(ZOOM_MIN, ZOOM_MAX); + doc.pan_offset = Vector::ZERO; + } Message::CanvasSize((w, h)) => { self.documents[self.active_doc].pane_size = (w, h); @@ -2021,8 +2513,12 @@ impl HcieIcedApp { Message::CompositeRefresh | Message::Undo | Message::Redo => { match message { - Message::Undo => { self.documents[self.active_doc].engine.undo(); } - Message::Redo => { self.documents[self.active_doc].engine.redo(); } + Message::Undo => { + self.documents[self.active_doc].engine.undo(); + } + Message::Redo => { + self.documents[self.active_doc].engine.redo(); + } _ => {} } self.refresh_composite_if_needed(); @@ -2030,52 +2526,89 @@ impl HcieIcedApp { // ── Layer operations ────────────────────────── Message::LayerSelect(id) => { - self.documents[self.active_doc].engine.set_active_layer(id); + if self.preview_baseline.is_some() { + self.restore_preview(); + self.filter_preview_active = false; + self.selected_filter = None; + self.filter_params = serde_json::json!({}); + self.refresh_composite_if_needed(); + } + let doc = &mut self.documents[self.active_doc]; + if doc.engine.active_layer_id() != id { + doc.selected_vector_shape = None; + doc.vector_cycle_index = 0; + doc.vector_edit_handle = hcie_engine_api::VectorEditHandle::None; + self.vector_shapes_snapshot = None; + self.vector_drag_last = None; + self.vector_drag_session = None; + self.bool_shape_a = None; + self.bool_shape_b = None; + doc.engine.set_active_layer(id); + } } Message::LayerAdd => { let count = self.documents[self.active_doc].engine.get_layer_count(); - self.documents[self.active_doc].engine.add_layer(&format!("Layer {}", count + 1)); + self.documents[self.active_doc] + .engine + .add_layer(&format!("Layer {}", count + 1)); + self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerDelete(id) => { self.documents[self.active_doc].engine.delete_layer(id); + self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerToggleVisibility(id) => { - let visible = self.documents[self.active_doc].engine.get_layer_info(id) + let visible = self.documents[self.active_doc] + .engine + .get_layer_info(id) .map(|l| !l.visible) .unwrap_or(true); - self.documents[self.active_doc].engine.set_layer_visible(id, visible); + self.documents[self.active_doc] + .engine + .set_layer_visible(id, visible); + self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerToggleLock(id) => { - let locked = self.documents[self.active_doc].engine.get_layer_info(id) + let locked = self.documents[self.active_doc] + .engine + .get_layer_info(id) .map(|l| !l.locked) .unwrap_or(false); - self.documents[self.active_doc].engine.set_layer_locked(id, locked); + self.documents[self.active_doc] + .engine + .set_layer_locked(id, locked); + self.documents[self.active_doc].modified = true; } Message::LayerSetOpacity(id, opacity) => { - self.documents[self.active_doc].engine.set_layer_opacity(id, opacity); + self.documents[self.active_doc] + .engine + .set_layer_opacity(id, opacity); + self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerMergeDown => { self.documents[self.active_doc].engine.merge_down(); + self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerFlatten => { self.documents[self.active_doc].engine.flatten_image(); + self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } @@ -2085,7 +2618,10 @@ impl HcieIcedApp { if let Some(pos) = infos.iter().position(|l| l.id == id) { if pos > 0 { let target_idx = pos - 1; - self.documents[self.active_doc].engine.move_layer(id, target_idx); + self.documents[self.active_doc] + .engine + .move_layer(id, target_idx); + self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } @@ -2097,7 +2633,10 @@ impl HcieIcedApp { if let Some(pos) = infos.iter().position(|l| l.id == id) { if pos + 1 < infos.len() { let target_idx = pos + 1; - self.documents[self.active_doc].engine.move_layer(id, target_idx); + self.documents[self.active_doc] + .engine + .move_layer(id, target_idx); + self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } @@ -2105,7 +2644,10 @@ impl HcieIcedApp { } Message::LayerSetBlendMode(id, mode) => { - self.documents[self.active_doc].engine.set_layer_blend_mode(id, mode); + self.documents[self.active_doc] + .engine + .set_layer_blend_mode(id, mode); + self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } @@ -2116,13 +2658,32 @@ impl HcieIcedApp { Message::LayerAddGroup => { let count = self.documents[self.active_doc].engine.get_layer_count(); - self.documents[self.active_doc].engine.add_group(&format!("Group {}", count + 1)); + let active_id = self.documents[self.active_doc].engine.active_layer_id(); + let active_is_group = self.documents[self.active_doc] + .engine + .get_layer_info(active_id) + .is_some_and(|layer| layer.layer_type == hcie_engine_api::LayerType::Group); + let group_id = self.documents[self.active_doc] + .engine + .add_group(&format!("Group {}", count + 1)); + if active_id != 0 && active_id != group_id && !active_is_group { + self.documents[self.active_doc] + .engine + .set_layer_parent(active_id, Some(group_id)); + } + self.documents[self.active_doc] + .engine + .set_active_layer(group_id); + self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerToggleCollapse(id) => { - self.documents[self.active_doc].engine.toggle_group_collapsed(id); + self.documents[self.active_doc] + .engine + .toggle_group_collapsed(id); + self.documents[self.active_doc].modified = true; } // ── History ─────────────────────────────────── @@ -2139,29 +2700,45 @@ impl HcieIcedApp { // ── Layer styles ────────────────────────────── Message::LayerStyleToggle(index) => { + if let Some(baseline) = self.layer_style_baseline.as_mut() { + baseline.changed = true; + } // Toggle layer style enabled state. // BUG FIX: When style doesn't exist, first click creates it ENABLED (not disabled). // Previous logic used map_or(false, ...) which created disabled styles on first click. let layer_id = self.documents[self.active_doc].engine.active_layer_id(); if layer_id != 0 { - let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id); - let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow", - "BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"]; + let styles = self.documents[self.active_doc] + .engine + .get_layer_styles(layer_id); + let disc_names = [ + "DropShadow", + "InnerShadow", + "OuterGlow", + "InnerGlow", + "BevelEmboss", + "Satin", + "ColorOverlay", + "GradientOverlay", + "PatternOverlay", + "Stroke", + ]; if (index as usize) < disc_names.len() { let disc = disc_names[index as usize]; // Find existing style let existing = styles.iter().find(|s| { - matches!((disc, s), + matches!( + (disc, s), ("DropShadow", LayerStyle::DropShadow { .. }) - | ("InnerShadow", LayerStyle::InnerShadow { .. }) - | ("OuterGlow", LayerStyle::OuterGlow { .. }) - | ("InnerGlow", LayerStyle::InnerGlow { .. }) - | ("BevelEmboss", LayerStyle::BevelEmboss { .. }) - | ("Satin", LayerStyle::Satin { .. }) - | ("ColorOverlay", LayerStyle::ColorOverlay { .. }) - | ("GradientOverlay", LayerStyle::GradientOverlay { .. }) - | ("PatternOverlay", LayerStyle::PatternOverlay { .. }) - | ("Stroke", LayerStyle::Stroke { .. }) + | ("InnerShadow", LayerStyle::InnerShadow { .. }) + | ("OuterGlow", LayerStyle::OuterGlow { .. }) + | ("InnerGlow", LayerStyle::InnerGlow { .. }) + | ("BevelEmboss", LayerStyle::BevelEmboss { .. }) + | ("Satin", LayerStyle::Satin { .. }) + | ("ColorOverlay", LayerStyle::ColorOverlay { .. }) + | ("GradientOverlay", LayerStyle::GradientOverlay { .. }) + | ("PatternOverlay", LayerStyle::PatternOverlay { .. }) + | ("Stroke", LayerStyle::Stroke { .. }) ) }); @@ -2170,34 +2747,59 @@ impl HcieIcedApp { // - Style exists → clone and toggle enabled flag let new_style = match existing { None => { - println!("LayerStyleToggle: Style {} not found, creating new", disc); + println!( + "LayerStyleToggle: Style {} not found, creating new", + disc + ); match disc { "DropShadow" => LayerStyle::DropShadow { enabled: true, - opacity: 0.75, angle: 120.0, distance: 5.0, spread: 0.0, size: 5.0, - color: [0, 0, 0, 255], blend_mode: "Multiply".to_string(), + opacity: 0.75, + angle: 120.0, + distance: 5.0, + spread: 0.0, + size: 5.0, + color: [0, 0, 0, 255], + blend_mode: "Multiply".to_string(), }, "InnerShadow" => LayerStyle::InnerShadow { enabled: true, - opacity: 0.75, angle: 120.0, distance: 5.0, spread: 0.0, size: 5.0, - color: [0, 0, 0, 255], blend_mode: "Multiply".to_string(), + opacity: 0.75, + angle: 120.0, + distance: 5.0, + spread: 0.0, + size: 5.0, + color: [0, 0, 0, 255], + blend_mode: "Multiply".to_string(), }, "OuterGlow" => LayerStyle::OuterGlow { enabled: true, - opacity: 0.75, spread: 0.0, size: 5.0, - color: [255, 255, 190, 255], blend_mode: "Screen".to_string(), + opacity: 0.75, + spread: 0.0, + size: 5.0, + color: [255, 255, 190, 255], + blend_mode: "Screen".to_string(), }, "InnerGlow" => LayerStyle::InnerGlow { enabled: true, - opacity: 0.75, spread: 0.0, size: 5.0, - color: [255, 255, 190, 255], blend_mode: "Screen".to_string(), + opacity: 0.75, + spread: 0.0, + size: 5.0, + color: [255, 255, 190, 255], + blend_mode: "Screen".to_string(), }, "BevelEmboss" => LayerStyle::BevelEmboss { enabled: true, - depth: 1.0, size: 5.0, angle: 120.0, altitude: 30.0, - highlight_opacity: 0.75, shadow_opacity: 0.75, - direction: "Up".to_string(), style: "InnerBevel".to_string(), - technique: "Smooth".to_string(), soften: 0.0, + depth: 1.0, + size: 5.0, + angle: 120.0, + altitude: 30.0, + highlight_opacity: 0.75, + shadow_opacity: 0.75, + direction: "Up".to_string(), + style: "InnerBevel".to_string(), + technique: "Smooth".to_string(), + soften: 0.0, highlight_blend_mode: "Screen".to_string(), highlight_color: [255, 255, 255, 255], shadow_blend_mode: "Multiply".to_string(), @@ -2206,27 +2808,41 @@ impl HcieIcedApp { }, "Satin" => LayerStyle::Satin { enabled: true, - opacity: 0.5, angle: 120.0, distance: 11.0, size: 14.0, - color: [0, 0, 0, 255], invert: true, + opacity: 0.5, + angle: 120.0, + distance: 11.0, + size: 14.0, + color: [0, 0, 0, 255], + invert: true, }, "ColorOverlay" => LayerStyle::ColorOverlay { enabled: true, - opacity: 1.0, color: [255, 0, 0, 255], blend_mode: "Normal".to_string(), + opacity: 1.0, + color: [255, 0, 0, 255], + blend_mode: "Normal".to_string(), }, "GradientOverlay" => LayerStyle::GradientOverlay { enabled: true, - opacity: 1.0, blend_mode: "Normal".to_string(), - angle: 90.0, scale: 1.0, gradient_type: 0, + opacity: 1.0, + blend_mode: "Normal".to_string(), + angle: 90.0, + scale: 1.0, + gradient_type: 0, }, "PatternOverlay" => LayerStyle::PatternOverlay { enabled: true, - opacity: 1.0, blend_mode: "Normal".to_string(), - scale: 1.0, pattern_name: "".to_string(), + opacity: 1.0, + blend_mode: "Normal".to_string(), + scale: 1.0, + pattern_name: "".to_string(), }, "Stroke" => LayerStyle::Stroke { enabled: true, - size: 3.0, position: "Outside".to_string(), - opacity: 1.0, color: [255, 0, 0, 255], blend_mode: "Normal".to_string(), + size: 3.0, + position: "Outside".to_string(), + opacity: 1.0, + color: [255, 0, 0, 255], + blend_mode: "Normal".to_string(), }, _ => unreachable!(), } @@ -2245,14 +2861,22 @@ impl HcieIcedApp { | LayerStyle::PatternOverlay { enabled, .. } | LayerStyle::Stroke { enabled, .. } => { *enabled = !*enabled; - println!("LayerStyleToggle: Style {} found, toggling to {}", disc, *enabled); + println!( + "LayerStyleToggle: Style {} found, toggling to {}", + disc, *enabled + ); } } cloned } }; - println!("LayerStyleToggle: Calling update_layer_style with new_style: {:?}", new_style); - self.documents[self.active_doc].engine.update_layer_style(layer_id, new_style); + println!( + "LayerStyleToggle: Calling update_layer_style with new_style: {:?}", + new_style + ); + self.documents[self.active_doc] + .engine + .update_layer_style(layer_id, new_style); } } self.refresh_composite_if_needed(); @@ -2262,36 +2886,69 @@ impl HcieIcedApp { self.selected_style = index; } Message::LayerStyleApply => { + self.documents[self.active_doc].modified = true; + self.layer_style_baseline = None; self.active_dialog = ActiveDialog::None; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerStyleCancel => { + if let Some(baseline) = self.layer_style_baseline.take() { + if baseline.changed && baseline.document_index < self.documents.len() { + let doc = &mut self.documents[baseline.document_index]; + for index in 0..10 { + doc.engine + .remove_layer_style_by_index(baseline.layer_id, index); + } + for style in baseline.styles { + doc.engine.update_layer_style(baseline.layer_id, style); + } + doc.modified = baseline.was_modified; + self.active_doc = baseline.document_index; + self.refresh_composite_if_needed(); + } + } self.active_dialog = ActiveDialog::None; } Message::LayerStyleAdd => { - // Open layer style add dialog (placeholder) + log::warn!("LayerStyleAdd is unavailable; select one of the ten explicit styles"); } Message::LayerStyleUpdateParam(index, param, value) => { + if let Some(baseline) = self.layer_style_baseline.as_mut() { + baseline.changed = true; + } let layer_id = self.documents[self.active_doc].engine.active_layer_id(); if layer_id != 0 { - let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id); - let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow", - "BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"]; + let styles = self.documents[self.active_doc] + .engine + .get_layer_styles(layer_id); + let disc_names = [ + "DropShadow", + "InnerShadow", + "OuterGlow", + "InnerGlow", + "BevelEmboss", + "Satin", + "ColorOverlay", + "GradientOverlay", + "PatternOverlay", + "Stroke", + ]; if index < disc_names.len() { let disc = disc_names[index]; let existing = styles.iter().find(|s| { - matches!((disc, s), + matches!( + (disc, s), ("DropShadow", LayerStyle::DropShadow { .. }) - | ("InnerShadow", LayerStyle::InnerShadow { .. }) - | ("OuterGlow", LayerStyle::OuterGlow { .. }) - | ("InnerGlow", LayerStyle::InnerGlow { .. }) - | ("BevelEmboss", LayerStyle::BevelEmboss { .. }) - | ("Satin", LayerStyle::Satin { .. }) - | ("ColorOverlay", LayerStyle::ColorOverlay { .. }) - | ("GradientOverlay", LayerStyle::GradientOverlay { .. }) - | ("PatternOverlay", LayerStyle::PatternOverlay { .. }) - | ("Stroke", LayerStyle::Stroke { .. }) + | ("InnerShadow", LayerStyle::InnerShadow { .. }) + | ("OuterGlow", LayerStyle::OuterGlow { .. }) + | ("InnerGlow", LayerStyle::InnerGlow { .. }) + | ("BevelEmboss", LayerStyle::BevelEmboss { .. }) + | ("Satin", LayerStyle::Satin { .. }) + | ("ColorOverlay", LayerStyle::ColorOverlay { .. }) + | ("GradientOverlay", LayerStyle::GradientOverlay { .. }) + | ("PatternOverlay", LayerStyle::PatternOverlay { .. }) + | ("Stroke", LayerStyle::Stroke { .. }) ) }); @@ -2299,52 +2956,103 @@ impl HcieIcedApp { let mut new_style = style.clone(); // Update the specific parameter match (&mut new_style, param.as_str()) { - (LayerStyle::DropShadow { opacity, .. }, "opacity") => *opacity = value, + (LayerStyle::DropShadow { opacity, .. }, "opacity") => { + *opacity = value + } (LayerStyle::DropShadow { angle, .. }, "angle") => *angle = value, - (LayerStyle::DropShadow { distance, .. }, "distance") => *distance = value, - (LayerStyle::DropShadow { spread, .. }, "spread") => *spread = value, + (LayerStyle::DropShadow { distance, .. }, "distance") => { + *distance = value + } + (LayerStyle::DropShadow { spread, .. }, "spread") => { + *spread = value + } (LayerStyle::DropShadow { size, .. }, "size") => *size = value, - (LayerStyle::InnerShadow { opacity, .. }, "opacity") => *opacity = value, + (LayerStyle::InnerShadow { opacity, .. }, "opacity") => { + *opacity = value + } (LayerStyle::InnerShadow { angle, .. }, "angle") => *angle = value, - (LayerStyle::InnerShadow { distance, .. }, "distance") => *distance = value, - (LayerStyle::InnerShadow { spread, .. }, "spread") => *spread = value, + (LayerStyle::InnerShadow { distance, .. }, "distance") => { + *distance = value + } + (LayerStyle::InnerShadow { spread, .. }, "spread") => { + *spread = value + } (LayerStyle::InnerShadow { size, .. }, "size") => *size = value, - (LayerStyle::OuterGlow { opacity, .. }, "opacity") => *opacity = value, + (LayerStyle::OuterGlow { opacity, .. }, "opacity") => { + *opacity = value + } (LayerStyle::OuterGlow { spread, .. }, "spread") => *spread = value, (LayerStyle::OuterGlow { size, .. }, "size") => *size = value, - (LayerStyle::InnerGlow { opacity, .. }, "opacity") => *opacity = value, + (LayerStyle::InnerGlow { opacity, .. }, "opacity") => { + *opacity = value + } (LayerStyle::InnerGlow { spread, .. }, "spread") => *spread = value, (LayerStyle::InnerGlow { size, .. }, "size") => *size = value, (LayerStyle::BevelEmboss { depth, .. }, "depth") => *depth = value, (LayerStyle::BevelEmboss { size, .. }, "size") => *size = value, (LayerStyle::BevelEmboss { angle, .. }, "angle") => *angle = value, - (LayerStyle::BevelEmboss { altitude, .. }, "altitude") => *altitude = value, - (LayerStyle::BevelEmboss { soften, .. }, "soften") => *soften = value, - (LayerStyle::BevelEmboss { highlight_opacity, shadow_opacity, .. }, "opacity") => { + (LayerStyle::BevelEmboss { altitude, .. }, "altitude") => { + *altitude = value + } + (LayerStyle::BevelEmboss { soften, .. }, "soften") => { + *soften = value + } + ( + LayerStyle::BevelEmboss { + highlight_opacity, + shadow_opacity, + .. + }, + "opacity", + ) => { *highlight_opacity = value; *shadow_opacity = value; - }, - (LayerStyle::BevelEmboss { highlight_opacity, .. }, "highlight_opacity") => { + } + ( + LayerStyle::BevelEmboss { + highlight_opacity, .. + }, + "highlight_opacity", + ) => { *highlight_opacity = value; - }, - (LayerStyle::BevelEmboss { shadow_opacity, .. }, "shadow_opacity") => { + } + ( + LayerStyle::BevelEmboss { shadow_opacity, .. }, + "shadow_opacity", + ) => { *shadow_opacity = value; - }, + } (LayerStyle::Satin { opacity, .. }, "opacity") => *opacity = value, (LayerStyle::Satin { angle, .. }, "angle") => *angle = value, - (LayerStyle::Satin { distance, .. }, "distance") => *distance = value, + (LayerStyle::Satin { distance, .. }, "distance") => { + *distance = value + } (LayerStyle::Satin { size, .. }, "size") => *size = value, - (LayerStyle::ColorOverlay { opacity, .. }, "opacity") => *opacity = value, - (LayerStyle::GradientOverlay { opacity, .. }, "opacity") => *opacity = value, - (LayerStyle::GradientOverlay { angle, .. }, "angle") => *angle = value, - (LayerStyle::GradientOverlay { scale, .. }, "scale") => *scale = value, - (LayerStyle::PatternOverlay { opacity, .. }, "opacity") => *opacity = value, - (LayerStyle::PatternOverlay { scale, .. }, "scale") => *scale = value, + (LayerStyle::ColorOverlay { opacity, .. }, "opacity") => { + *opacity = value + } + (LayerStyle::GradientOverlay { opacity, .. }, "opacity") => { + *opacity = value + } + (LayerStyle::GradientOverlay { angle, .. }, "angle") => { + *angle = value + } + (LayerStyle::GradientOverlay { scale, .. }, "scale") => { + *scale = value + } + (LayerStyle::PatternOverlay { opacity, .. }, "opacity") => { + *opacity = value + } + (LayerStyle::PatternOverlay { scale, .. }, "scale") => { + *scale = value + } (LayerStyle::Stroke { size, .. }, "size") => *size = value, (LayerStyle::Stroke { opacity, .. }, "opacity") => *opacity = value, _ => {} } - self.documents[self.active_doc].engine.update_layer_style(layer_id, new_style); + self.documents[self.active_doc] + .engine + .update_layer_style(layer_id, new_style); } } } @@ -2352,25 +3060,41 @@ impl HcieIcedApp { return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerStyleUpdateBlendMode(index, mode) => { + if let Some(baseline) = self.layer_style_baseline.as_mut() { + baseline.changed = true; + } let layer_id = self.documents[self.active_doc].engine.active_layer_id(); if layer_id != 0 { - let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id); - let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow", - "BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"]; + let styles = self.documents[self.active_doc] + .engine + .get_layer_styles(layer_id); + let disc_names = [ + "DropShadow", + "InnerShadow", + "OuterGlow", + "InnerGlow", + "BevelEmboss", + "Satin", + "ColorOverlay", + "GradientOverlay", + "PatternOverlay", + "Stroke", + ]; if index < disc_names.len() { let disc = disc_names[index]; let existing = styles.iter().find(|s| { - matches!((disc, s), + matches!( + (disc, s), ("DropShadow", LayerStyle::DropShadow { .. }) - | ("InnerShadow", LayerStyle::InnerShadow { .. }) - | ("OuterGlow", LayerStyle::OuterGlow { .. }) - | ("InnerGlow", LayerStyle::InnerGlow { .. }) - | ("BevelEmboss", LayerStyle::BevelEmboss { .. }) - | ("Satin", LayerStyle::Satin { .. }) - | ("ColorOverlay", LayerStyle::ColorOverlay { .. }) - | ("GradientOverlay", LayerStyle::GradientOverlay { .. }) - | ("PatternOverlay", LayerStyle::PatternOverlay { .. }) - | ("Stroke", LayerStyle::Stroke { .. }) + | ("InnerShadow", LayerStyle::InnerShadow { .. }) + | ("OuterGlow", LayerStyle::OuterGlow { .. }) + | ("InnerGlow", LayerStyle::InnerGlow { .. }) + | ("BevelEmboss", LayerStyle::BevelEmboss { .. }) + | ("Satin", LayerStyle::Satin { .. }) + | ("ColorOverlay", LayerStyle::ColorOverlay { .. }) + | ("GradientOverlay", LayerStyle::GradientOverlay { .. }) + | ("PatternOverlay", LayerStyle::PatternOverlay { .. }) + | ("Stroke", LayerStyle::Stroke { .. }) ) }); if let Some(style) = existing { @@ -2384,10 +3108,15 @@ impl HcieIcedApp { | LayerStyle::GradientOverlay { blend_mode, .. } | LayerStyle::PatternOverlay { blend_mode, .. } | LayerStyle::Stroke { blend_mode, .. } => *blend_mode = mode, - LayerStyle::BevelEmboss { highlight_blend_mode, .. } => *highlight_blend_mode = mode, + LayerStyle::BevelEmboss { + highlight_blend_mode, + .. + } => *highlight_blend_mode = mode, _ => {} } - self.documents[self.active_doc].engine.update_layer_style(layer_id, new_style); + self.documents[self.active_doc] + .engine + .update_layer_style(layer_id, new_style); } } } @@ -2395,25 +3124,41 @@ impl HcieIcedApp { return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerStyleUpdateColor(index, new_color) => { + if let Some(baseline) = self.layer_style_baseline.as_mut() { + baseline.changed = true; + } let layer_id = self.documents[self.active_doc].engine.active_layer_id(); if layer_id != 0 { - let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id); - let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow", - "BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"]; + let styles = self.documents[self.active_doc] + .engine + .get_layer_styles(layer_id); + let disc_names = [ + "DropShadow", + "InnerShadow", + "OuterGlow", + "InnerGlow", + "BevelEmboss", + "Satin", + "ColorOverlay", + "GradientOverlay", + "PatternOverlay", + "Stroke", + ]; if index < disc_names.len() { let disc = disc_names[index]; let existing = styles.iter().find(|s| { - matches!((disc, s), + matches!( + (disc, s), ("DropShadow", LayerStyle::DropShadow { .. }) - | ("InnerShadow", LayerStyle::InnerShadow { .. }) - | ("OuterGlow", LayerStyle::OuterGlow { .. }) - | ("InnerGlow", LayerStyle::InnerGlow { .. }) - | ("BevelEmboss", LayerStyle::BevelEmboss { .. }) - | ("Satin", LayerStyle::Satin { .. }) - | ("ColorOverlay", LayerStyle::ColorOverlay { .. }) - | ("GradientOverlay", LayerStyle::GradientOverlay { .. }) - | ("PatternOverlay", LayerStyle::PatternOverlay { .. }) - | ("Stroke", LayerStyle::Stroke { .. }) + | ("InnerShadow", LayerStyle::InnerShadow { .. }) + | ("OuterGlow", LayerStyle::OuterGlow { .. }) + | ("InnerGlow", LayerStyle::InnerGlow { .. }) + | ("BevelEmboss", LayerStyle::BevelEmboss { .. }) + | ("Satin", LayerStyle::Satin { .. }) + | ("ColorOverlay", LayerStyle::ColorOverlay { .. }) + | ("GradientOverlay", LayerStyle::GradientOverlay { .. }) + | ("PatternOverlay", LayerStyle::PatternOverlay { .. }) + | ("Stroke", LayerStyle::Stroke { .. }) ) }); if let Some(style) = existing { @@ -2426,7 +3171,11 @@ impl HcieIcedApp { | LayerStyle::ColorOverlay { color, .. } | LayerStyle::Satin { color, .. } | LayerStyle::Stroke { color, .. } => *color = new_color, - LayerStyle::BevelEmboss { highlight_color, shadow_color, .. } => { + LayerStyle::BevelEmboss { + highlight_color, + shadow_color, + .. + } => { if self.style_color_is_shadow { *shadow_color = new_color; } else { @@ -2435,7 +3184,9 @@ impl HcieIcedApp { } _ => {} } - self.documents[self.active_doc].engine.update_layer_style(layer_id, updated_style); + self.documents[self.active_doc] + .engine + .update_layer_style(layer_id, updated_style); } } } @@ -2446,39 +3197,88 @@ impl HcieIcedApp { let max = r.max(g).max(b); let min = r.min(g).min(b); let l = (max + min) / 2.0; - let s = if max == min { 0.0 } else if l > 0.5 { (max - min) / (2.0 - max - min) } else { (max - min) / (max + min) }; - let h = if max == min { 0.0 } else if max == r { ((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0 } else if max == g { ((b - r) / (max - min) + 2.0) / 6.0 * 360.0 } else { ((r - g) / (max - min) + 4.0) / 6.0 * 360.0 }; + let s = if max == min { + 0.0 + } else if l > 0.5 { + (max - min) / (2.0 - max - min) + } else { + (max - min) / (max + min) + }; + let h = if max == min { + 0.0 + } else if max == r { + ((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0 + } else if max == g { + ((b - r) / (max - min) + 2.0) / 6.0 * 360.0 + } else { + ((r - g) / (max - min) + 4.0) / 6.0 * 360.0 + }; self.style_color_hsl = (h, s, l); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerStyleUpdateString(index, param, value) => { + if let Some(baseline) = self.layer_style_baseline.as_mut() { + baseline.changed = true; + } let layer_id = self.documents[self.active_doc].engine.active_layer_id(); if layer_id != 0 { - let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id); - let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow", - "BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"]; + let styles = self.documents[self.active_doc] + .engine + .get_layer_styles(layer_id); + let disc_names = [ + "DropShadow", + "InnerShadow", + "OuterGlow", + "InnerGlow", + "BevelEmboss", + "Satin", + "ColorOverlay", + "GradientOverlay", + "PatternOverlay", + "Stroke", + ]; if index < disc_names.len() { let disc = disc_names[index]; let existing = styles.iter().find(|s| { - matches!((disc, s), + matches!( + (disc, s), ("BevelEmboss", LayerStyle::BevelEmboss { .. }) - | ("DropShadow", LayerStyle::DropShadow { .. }) - | ("InnerShadow", LayerStyle::InnerShadow { .. }) + | ("DropShadow", LayerStyle::DropShadow { .. }) + | ("InnerShadow", LayerStyle::InnerShadow { .. }) ) }); if let Some(style) = existing { let mut new_style = style.clone(); match (&mut new_style, param.as_str()) { (LayerStyle::BevelEmboss { style, .. }, "style") => *style = value, - (LayerStyle::BevelEmboss { technique, .. }, "technique") => *technique = value, - (LayerStyle::BevelEmboss { direction, .. }, "direction") => *direction = value, - (LayerStyle::BevelEmboss { highlight_blend_mode, .. }, "highlight_blend") => *highlight_blend_mode = value, - (LayerStyle::BevelEmboss { shadow_blend_mode, .. }, "shadow_blend") => *shadow_blend_mode = value, - (LayerStyle::BevelEmboss { contour, .. }, "contour") => *contour = value, + (LayerStyle::BevelEmboss { technique, .. }, "technique") => { + *technique = value + } + (LayerStyle::BevelEmboss { direction, .. }, "direction") => { + *direction = value + } + ( + LayerStyle::BevelEmboss { + highlight_blend_mode, + .. + }, + "highlight_blend", + ) => *highlight_blend_mode = value, + ( + LayerStyle::BevelEmboss { + shadow_blend_mode, .. + }, + "shadow_blend", + ) => *shadow_blend_mode = value, + (LayerStyle::BevelEmboss { contour, .. }, "contour") => { + *contour = value + } _ => {} } - self.documents[self.active_doc].engine.update_layer_style(layer_id, new_style); + self.documents[self.active_doc] + .engine + .update_layer_style(layer_id, new_style); } } } @@ -2486,11 +3286,26 @@ impl HcieIcedApp { return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerStyleUpdateShadowColor(index, new_color) => { + if let Some(baseline) = self.layer_style_baseline.as_mut() { + baseline.changed = true; + } let layer_id = self.documents[self.active_doc].engine.active_layer_id(); if layer_id != 0 { - let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id); - let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow", - "BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"]; + let styles = self.documents[self.active_doc] + .engine + .get_layer_styles(layer_id); + let disc_names = [ + "DropShadow", + "InnerShadow", + "OuterGlow", + "InnerGlow", + "BevelEmboss", + "Satin", + "ColorOverlay", + "GradientOverlay", + "PatternOverlay", + "Stroke", + ]; if index < disc_names.len() { let disc = disc_names[index]; let existing = styles.iter().find(|s| { @@ -2498,10 +3313,13 @@ impl HcieIcedApp { }); if let Some(style) = existing { let mut updated_style = style.clone(); - if let LayerStyle::BevelEmboss { shadow_color, .. } = &mut updated_style { + if let LayerStyle::BevelEmboss { shadow_color, .. } = &mut updated_style + { *shadow_color = new_color; } - self.documents[self.active_doc].engine.update_layer_style(layer_id, updated_style); + self.documents[self.active_doc] + .engine + .update_layer_style(layer_id, updated_style); } } } @@ -2511,18 +3329,47 @@ impl HcieIcedApp { let max = r.max(g).max(b); let min = r.min(g).min(b); let l = (max + min) / 2.0; - let s = if max == min { 0.0 } else if l > 0.5 { (max - min) / (2.0 - max - min) } else { (max - min) / (max + min) }; - let h = if max == min { 0.0 } else if max == r { ((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0 } else if max == g { ((b - r) / (max - min) + 2.0) / 6.0 * 360.0 } else { ((r - g) / (max - min) + 4.0) / 6.0 * 360.0 }; + let s = if max == min { + 0.0 + } else if l > 0.5 { + (max - min) / (2.0 - max - min) + } else { + (max - min) / (max + min) + }; + let h = if max == min { + 0.0 + } else if max == r { + ((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0 + } else if max == g { + ((b - r) / (max - min) + 2.0) / 6.0 * 360.0 + } else { + ((r - g) / (max - min) + 4.0) / 6.0 * 360.0 + }; self.style_color_hsl = (h, s, l); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerStyleUpdateShadowBlend(index, mode) => { + if let Some(baseline) = self.layer_style_baseline.as_mut() { + baseline.changed = true; + } let layer_id = self.documents[self.active_doc].engine.active_layer_id(); if layer_id != 0 { - let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id); - let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow", - "BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"]; + let styles = self.documents[self.active_doc] + .engine + .get_layer_styles(layer_id); + let disc_names = [ + "DropShadow", + "InnerShadow", + "OuterGlow", + "InnerGlow", + "BevelEmboss", + "Satin", + "ColorOverlay", + "GradientOverlay", + "PatternOverlay", + "Stroke", + ]; if index < disc_names.len() { let disc = disc_names[index]; let existing = styles.iter().find(|s| { @@ -2530,10 +3377,15 @@ impl HcieIcedApp { }); if let Some(style) = existing { let mut updated_style = style.clone(); - if let LayerStyle::BevelEmboss { shadow_blend_mode, .. } = &mut updated_style { + if let LayerStyle::BevelEmboss { + shadow_blend_mode, .. + } = &mut updated_style + { *shadow_blend_mode = mode; } - self.documents[self.active_doc].engine.update_layer_style(layer_id, updated_style); + self.documents[self.active_doc] + .engine + .update_layer_style(layer_id, updated_style); } } } @@ -2541,11 +3393,26 @@ impl HcieIcedApp { return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::LayerStyleUpdateShadowOpacity(index, value) => { + if let Some(baseline) = self.layer_style_baseline.as_mut() { + baseline.changed = true; + } let layer_id = self.documents[self.active_doc].engine.active_layer_id(); if layer_id != 0 { - let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id); - let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow", - "BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"]; + let styles = self.documents[self.active_doc] + .engine + .get_layer_styles(layer_id); + let disc_names = [ + "DropShadow", + "InnerShadow", + "OuterGlow", + "InnerGlow", + "BevelEmboss", + "Satin", + "ColorOverlay", + "GradientOverlay", + "PatternOverlay", + "Stroke", + ]; if index < disc_names.len() { let disc = disc_names[index]; let existing = styles.iter().find(|s| { @@ -2553,10 +3420,14 @@ impl HcieIcedApp { }); if let Some(style) = existing { let mut updated_style = style.clone(); - if let LayerStyle::BevelEmboss { shadow_opacity, .. } = &mut updated_style { + if let LayerStyle::BevelEmboss { shadow_opacity, .. } = + &mut updated_style + { *shadow_opacity = value; } - self.documents[self.active_doc].engine.update_layer_style(layer_id, updated_style); + self.documents[self.active_doc] + .engine + .update_layer_style(layer_id, updated_style); } } } @@ -2567,12 +3438,24 @@ impl HcieIcedApp { self.show_style_color_picker = true; self.style_color_picker_idx = idx; self.style_color_is_shadow = true; - let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow", - "BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"]; + let disc_names = [ + "DropShadow", + "InnerShadow", + "OuterGlow", + "InnerGlow", + "BevelEmboss", + "Satin", + "ColorOverlay", + "GradientOverlay", + "PatternOverlay", + "Stroke", + ]; let layer_id = self.documents[self.active_doc].engine.active_layer_id(); if layer_id != 0 && idx < disc_names.len() { let disc = disc_names[idx]; - let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id); + let styles = self.documents[self.active_doc] + .engine + .get_layer_styles(layer_id); if let Some(style) = styles.iter().find(|s| { matches!((disc, s), ("BevelEmboss", LayerStyle::BevelEmboss { .. })) }) { @@ -2587,8 +3470,22 @@ impl HcieIcedApp { let max = r.max(g).max(b); let min = r.min(g).min(b); let l = (max + min) / 2.0; - let s = if max == min { 0.0 } else if l > 0.5 { (max - min) / (2.0 - max - min) } else { (max - min) / (max + min) }; - let h = if max == min { 0.0 } else if max == r { ((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0 } else if max == g { ((b - r) / (max - min) + 2.0) / 6.0 * 360.0 } else { ((r - g) / (max - min) + 4.0) / 6.0 * 360.0 }; + let s = if max == min { + 0.0 + } else if l > 0.5 { + (max - min) / (2.0 - max - min) + } else { + (max - min) / (max + min) + }; + let h = if max == min { + 0.0 + } else if max == r { + ((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0 + } else if max == g { + ((b - r) / (max - min) + 2.0) / 6.0 * 360.0 + } else { + ((r - g) / (max - min) + 4.0) / 6.0 * 360.0 + }; self.style_color_hsl = (h, s, l); } } @@ -2599,44 +3496,77 @@ impl HcieIcedApp { self.style_color_picker_idx = idx; self.style_color_is_shadow = false; // Initialize HSL from current color - let disc_names = ["DropShadow", "InnerShadow", "OuterGlow", "InnerGlow", - "BevelEmboss", "Satin", "ColorOverlay", "GradientOverlay", "PatternOverlay", "Stroke"]; + let disc_names = [ + "DropShadow", + "InnerShadow", + "OuterGlow", + "InnerGlow", + "BevelEmboss", + "Satin", + "ColorOverlay", + "GradientOverlay", + "PatternOverlay", + "Stroke", + ]; let layer_id = self.documents[self.active_doc].engine.active_layer_id(); if layer_id != 0 && idx < disc_names.len() { - let styles = self.documents[self.active_doc].engine.get_layer_styles(layer_id); + let styles = self.documents[self.active_doc] + .engine + .get_layer_styles(layer_id); let disc = disc_names[idx]; - let color = styles.iter().find(|s| { - matches!((disc, s), - ("DropShadow", LayerStyle::DropShadow { .. }) - | ("InnerShadow", LayerStyle::InnerShadow { .. }) - | ("OuterGlow", LayerStyle::OuterGlow { .. }) - | ("InnerGlow", LayerStyle::InnerGlow { .. }) - | ("BevelEmboss", LayerStyle::BevelEmboss { .. }) - | ("Satin", LayerStyle::Satin { .. }) - | ("ColorOverlay", LayerStyle::ColorOverlay { .. }) - | ("GradientOverlay", LayerStyle::GradientOverlay { .. }) - | ("PatternOverlay", LayerStyle::PatternOverlay { .. }) - | ("Stroke", LayerStyle::Stroke { .. }) - ) - }).map(|s| match s { - LayerStyle::DropShadow { color, .. } - | LayerStyle::InnerShadow { color, .. } - | LayerStyle::OuterGlow { color, .. } - | LayerStyle::InnerGlow { color, .. } - | LayerStyle::ColorOverlay { color, .. } - | LayerStyle::Satin { color, .. } - | LayerStyle::Stroke { color, .. } => *color, - LayerStyle::BevelEmboss { highlight_color, .. } => *highlight_color, - _ => [0, 0, 0, 255], - }).unwrap_or([0, 0, 0, 255]); + let color = styles + .iter() + .find(|s| { + matches!( + (disc, s), + ("DropShadow", LayerStyle::DropShadow { .. }) + | ("InnerShadow", LayerStyle::InnerShadow { .. }) + | ("OuterGlow", LayerStyle::OuterGlow { .. }) + | ("InnerGlow", LayerStyle::InnerGlow { .. }) + | ("BevelEmboss", LayerStyle::BevelEmboss { .. }) + | ("Satin", LayerStyle::Satin { .. }) + | ("ColorOverlay", LayerStyle::ColorOverlay { .. }) + | ("GradientOverlay", LayerStyle::GradientOverlay { .. }) + | ("PatternOverlay", LayerStyle::PatternOverlay { .. }) + | ("Stroke", LayerStyle::Stroke { .. }) + ) + }) + .map(|s| match s { + LayerStyle::DropShadow { color, .. } + | LayerStyle::InnerShadow { color, .. } + | LayerStyle::OuterGlow { color, .. } + | LayerStyle::InnerGlow { color, .. } + | LayerStyle::ColorOverlay { color, .. } + | LayerStyle::Satin { color, .. } + | LayerStyle::Stroke { color, .. } => *color, + LayerStyle::BevelEmboss { + highlight_color, .. + } => *highlight_color, + _ => [0, 0, 0, 255], + }) + .unwrap_or([0, 0, 0, 255]); let r = color[0] as f32 / 255.0; let g = color[1] as f32 / 255.0; let b = color[2] as f32 / 255.0; let max = r.max(g).max(b); let min = r.min(g).min(b); let l = (max + min) / 2.0; - let s = if max == min { 0.0 } else if l > 0.5 { (max - min) / (2.0 - max - min) } else { (max - min) / (max + min) }; - let h = if max == min { 0.0 } else if max == r { ((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0 } else if max == g { ((b - r) / (max - min) + 2.0) / 6.0 * 360.0 } else { ((r - g) / (max - min) + 4.0) / 6.0 * 360.0 }; + let s = if max == min { + 0.0 + } else if l > 0.5 { + (max - min) / (2.0 - max - min) + } else { + (max - min) / (max + min) + }; + let h = if max == min { + 0.0 + } else if max == r { + ((g - b) / (max - min) + if g < b { 6.0 } else { 0.0 }) / 6.0 * 360.0 + } else if max == g { + ((b - r) / (max - min) + 2.0) / 6.0 * 360.0 + } else { + ((r - g) / (max - min) + 4.0) / 6.0 * 360.0 + }; self.style_color_hsl = (h, s, l); } } @@ -2644,6 +3574,17 @@ impl HcieIcedApp { self.show_style_color_picker = false; } Message::OpenLayerStyleDialog => { + let document_index = self.active_doc; + let layer_id = self.documents[document_index].engine.active_layer_id(); + self.layer_style_baseline = Some(LayerStyleBaseline { + document_index, + layer_id, + styles: self.documents[document_index] + .engine + .get_layer_styles(layer_id), + was_modified: self.documents[document_index].modified, + changed: false, + }); self.active_dialog = ActiveDialog::LayerStyleDialog; self.layer_style_offset = (0.0, 0.0); self.layer_style_dragging = false; @@ -2714,6 +3655,10 @@ impl HcieIcedApp { self.settings.tool_settings.selection_contiguous = v; let _ = self.settings.save(); } + Message::SelectionAntiAliasToggled(v) => { + self.settings.tool_settings.selection_anti_alias = v; + let _ = self.settings.save(); + } Message::SelectionFeatherChanged(r) => { self.settings.tool_settings.select_feather = r; let _ = self.settings.save(); @@ -2806,18 +3751,21 @@ impl HcieIcedApp { } Message::TextContentChanged(content) => { if let Some(draft) = self.active_document_mut().text_draft.as_mut() { + draft.editor = iced::widget::text_editor::Content::with_text(&content); draft.content = content; } } + Message::TextEditorAction(action) => { + if let Some(draft) = self.active_document_mut().text_draft.as_mut() { + draft.editor.perform(action); + draft.content = draft.editor.text(); + } + } Message::TextCommit => { let draft = self.active_document_mut().text_draft.take(); if let Some(draft) = draft { if !draft.content.is_empty() { - let make_new_layer = self.settings.tool_settings.tool_apply_to_new_layer; let engine = &mut self.active_document_mut().engine; - if make_new_layer { - let _id = engine.add_layer("Text"); - } engine.add_text( &draft.content, &draft.font, @@ -2865,12 +3813,15 @@ impl HcieIcedApp { .map(|handle| handle.path().to_path_buf()); match result { Some(path) => { - let file_name = path.file_name() + let file_name = path + .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); match std::fs::read(&path) { Ok(data) => { - let presets = crate::brush_import::import_abr_with_prefix(&data, &file_name); + let presets = crate::brush_import::import_abr_with_prefix( + &data, &file_name, + ); Ok(presets) } Err(e) => Err(format!("Failed to read file: {}", e)), @@ -2898,28 +3849,50 @@ impl HcieIcedApp { // ── Filters ─────────────────────────────────── Message::FilterSelect(filter) => { + self.restore_preview(); self.selected_filter = Some(filter); - self.filter_preview_active = true; + self.filter_preview_active = self.documents[self.active_doc] + .engine + .active_layer_is_editable() + && self.begin_preview(); self.filter_params = serde_json::json!({}); - self.documents[self.active_doc].engine.filter_preview_begin(); } Message::FilterParamChanged(key, value) => { - // Update the specific parameter in filter_params if let serde_json::Value::Object(ref mut map) = self.filter_params { - map.insert(key, value); + if let serde_json::Value::Object(values) = value { + map.extend(values); + } else { + map.insert(key, value); + } } - // Apply preview with updated params - if let Some(filter) = self.selected_filter { + if self.filter_preview_active && self.reset_preview_to_baseline() { + let Some(filter) = self.selected_filter else { + return Task::none(); + }; let filter_id = filter_type_to_id(filter); - self.documents[self.active_doc].engine.apply_filter_preview(filter_id, self.filter_params.clone()); + self.documents[self.active_doc] + .engine + .apply_filter_preview(filter_id, self.filter_params.clone()); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } } Message::FilterApply => { if let Some(filter) = self.selected_filter { + if !self.documents[self.active_doc] + .engine + .active_layer_is_editable() + { + log::warn!("filter apply blocked for locked, group, or mask layer"); + return Task::none(); + } + self.reset_preview_to_baseline(); let filter_id = filter_type_to_id(filter); - self.documents[self.active_doc].engine.apply_filter(filter_id, self.filter_params.clone()); + self.documents[self.active_doc] + .engine + .apply_filter(filter_id, self.filter_params.clone()); + self.preview_baseline = None; + self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); self.filter_preview_active = false; self.selected_filter = None; @@ -2928,39 +3901,47 @@ impl HcieIcedApp { } } Message::FilterReset => { - if self.filter_preview_active { - self.documents[self.active_doc].engine.filter_preview_restore(); - self.refresh_composite_if_needed(); - self.filter_preview_active = false; - } - self.selected_filter = None; self.filter_params = serde_json::json!({}); + if self.filter_preview_active && self.reset_preview_to_baseline() { + if let Some(filter) = self.selected_filter { + self.documents[self.active_doc].engine.apply_filter_preview( + filter_type_to_id(filter), + self.filter_params.clone(), + ); + } + self.refresh_composite_if_needed(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } } Message::FilterPreviewToggle(enabled) => { self.filter_preview_active = enabled; if enabled { - // Start a new preview snapshot so restore can undo it - self.documents[self.active_doc].engine.filter_preview_begin(); - // Apply the current filter with current params to show live preview - if let Some(filter) = self.selected_filter { + if self.begin_preview() { + let Some(filter) = self.selected_filter else { + return Task::none(); + }; let filter_id = filter_type_to_id(filter); - self.documents[self.active_doc].engine.apply_filter_preview(filter_id, self.filter_params.clone()); + self.documents[self.active_doc] + .engine + .apply_filter_preview(filter_id, self.filter_params.clone()); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } } else { - // Restore original pixels - self.documents[self.active_doc].engine.filter_preview_restore(); + self.restore_preview(); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } } Message::FilterPreviewApply => { - if let Some(filter) = self.selected_filter { - let filter_id = filter_type_to_id(filter); - self.documents[self.active_doc].engine.apply_filter(filter_id, self.filter_params.clone()); + return self.update(Message::FilterApply); + } + + Message::FilterPreviewRestore => { + if self.filter_preview_active { + self.restore_preview(); self.refresh_composite_if_needed(); self.filter_preview_active = false; self.selected_filter = None; @@ -2969,52 +3950,53 @@ impl HcieIcedApp { } } - Message::FilterPreviewRestore => { - if self.filter_preview_active { - self.documents[self.active_doc].engine.filter_preview_restore(); - self.refresh_composite_if_needed(); - return Task::perform(async {}, |_| Message::CompositeRefresh); - } - } - // ── Dialogs ─────────────────────────────────── Message::DialogOpen(dialog) => { + if matches!( + dialog, + ActiveDialog::BrightnessContrast | ActiveDialog::HueSaturation + ) { + self.dialog_brightness = 0.0; + self.dialog_contrast = 0.0; + self.dialog_hue = 0.0; + self.dialog_saturation = 0.0; + self.dialog_lightness = 0.0; + self.begin_preview(); + } self.active_dialog = dialog; } Message::DialogClose => { + if matches!( + self.active_dialog, + ActiveDialog::BrightnessContrast | ActiveDialog::HueSaturation + ) && self.restore_preview() + { + self.refresh_composite_if_needed(); + } self.active_dialog = ActiveDialog::None; + self.pending_document_close = None; } Message::DialogCloseSave => { - // Save the current document then close - let doc = &self.documents[self.active_doc]; - if let Some(path) = &doc.source_path { - let path_str = path.to_string_lossy().to_string(); - let ext = path.extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - let result = match ext.as_str() { - "psd" => self.documents[self.active_doc].engine.export_psd(&path_str), - "kra" => self.documents[self.active_doc].engine.export_kra(&path_str), - "hcie" => self.documents[self.active_doc].engine.save_native(&path_str), - _ => self.documents[self.active_doc].engine.save_as(&path_str, &ext), - }; - if result.is_ok() { - self.documents[self.active_doc].modified = false; + if let Some(path) = self.documents[self.active_doc].source_path.clone() { + match self.save_document_to(self.active_doc, &path) { + Ok(()) => { + self.finish_successful_save(self.active_doc, path); + return self.continue_close_after_save(); + } + Err(error) => { + log::error!("Failed to save before close: {error}"); + return Task::none(); + } } } + self.pending_file_operation = + Some(PendingFileOperation::SaveAs { close_after: true }); self.active_dialog = ActiveDialog::None; - if let Some(id) = self.window_id { - return iced::window::close(id); - } + return self.update(Message::SaveFileAs); } Message::DialogCloseDiscard => { - // Discard changes and close self.documents[self.active_doc].modified = false; - self.active_dialog = ActiveDialog::None; - if let Some(id) = self.window_id { - return iced::window::close(id); - } + return self.continue_close_after_save(); } // ── New Image Dialog ────────────────────────── @@ -3070,6 +4052,7 @@ impl HcieIcedApp { transform_drag_handle: TransformHandle::None, transform_drag_start: None, transform_original: None, + transform_source: None, internal_clipboard: None, selection_mask: None, selection_bounds: None, @@ -3081,6 +4064,10 @@ impl HcieIcedApp { text_draft: None, selection_edge_cache: Default::default(), marching_ants_edges: None, + selection_fill_spans: None, + selection_mode: selection::SelectionMode::Replace, + saved_selection_mask: None, + quick_mask: false, selected_vector_shape: None, vector_cycle_index: 0, vector_edit_handle: hcie_engine_api::VectorEditHandle::None, @@ -3093,35 +4080,51 @@ impl HcieIcedApp { Message::AdjBrightness(v) => { self.dialog_brightness = v; let params = serde_json::json!({"brightness": v, "contrast": self.dialog_contrast}); - self.documents[self.active_doc].engine.apply_filter_preview("brightness_contrast", params); + self.reset_preview_to_baseline(); + self.documents[self.active_doc] + .engine + .apply_filter_preview("brightness_contrast", params); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::AdjContrast(v) => { self.dialog_contrast = v; - let params = serde_json::json!({"brightness": self.dialog_brightness, "contrast": v}); - self.documents[self.active_doc].engine.apply_filter_preview("brightness_contrast", params); + let params = + serde_json::json!({"brightness": self.dialog_brightness, "contrast": v}); + self.reset_preview_to_baseline(); + self.documents[self.active_doc] + .engine + .apply_filter_preview("brightness_contrast", params); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::AdjHue(v) => { self.dialog_hue = v; - let params = serde_json::json!({"hue": v, "saturation": self.dialog_saturation, "lightness": self.dialog_lightness}); - self.documents[self.active_doc].engine.apply_filter_preview("hue_saturation", params); + let params = serde_json::json!({"hue": v, "saturation": 1.0 + self.dialog_saturation / 100.0, "lightness": self.dialog_lightness}); + self.reset_preview_to_baseline(); + self.documents[self.active_doc] + .engine + .apply_filter_preview("hue_saturation", params); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::AdjSaturation(v) => { self.dialog_saturation = v; - let params = serde_json::json!({"hue": self.dialog_hue, "saturation": v, "lightness": self.dialog_lightness}); - self.documents[self.active_doc].engine.apply_filter_preview("hue_saturation", params); + let params = serde_json::json!({"hue": self.dialog_hue, "saturation": 1.0 + v / 100.0, "lightness": self.dialog_lightness}); + self.reset_preview_to_baseline(); + self.documents[self.active_doc] + .engine + .apply_filter_preview("hue_saturation", params); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Message::AdjLightness(v) => { self.dialog_lightness = v; - let params = serde_json::json!({"hue": self.dialog_hue, "saturation": self.dialog_saturation, "lightness": v}); - self.documents[self.active_doc].engine.apply_filter_preview("hue_saturation", params); + let params = serde_json::json!({"hue": self.dialog_hue, "saturation": 1.0 + self.dialog_saturation / 100.0, "lightness": v}); + self.reset_preview_to_baseline(); + self.documents[self.active_doc] + .engine + .apply_filter_preview("hue_saturation", params); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } @@ -3132,13 +4135,12 @@ impl HcieIcedApp { serde_json::json!({"brightness": self.dialog_brightness, "contrast": self.dialog_contrast}) } ActiveDialog::HueSaturation => { - serde_json::json!({"hue": self.dialog_hue, "saturation": self.dialog_saturation, "lightness": self.dialog_lightness}) + serde_json::json!({"hue": self.dialog_hue, "saturation": 1.0 + self.dialog_saturation / 100.0, "lightness": self.dialog_lightness}) } _ => serde_json::json!({}), }; - // First restore the preview, then apply permanently - self.documents[self.active_doc].engine.filter_preview_restore(); + self.reset_preview_to_baseline(); let filter_id = match &self.active_dialog { ActiveDialog::BrightnessContrast => "brightness_contrast", @@ -3147,7 +4149,11 @@ impl HcieIcedApp { }; if !filter_id.is_empty() { - self.documents[self.active_doc].engine.apply_filter(filter_id, params); + self.documents[self.active_doc] + .engine + .apply_filter(filter_id, params); + self.preview_baseline = None; + self.documents[self.active_doc].modified = true; } self.refresh_composite_if_needed(); @@ -3162,18 +4168,20 @@ impl HcieIcedApp { Message::SelectionOpApply => { let v = self.dialog_selection_value as u32; match &self.active_dialog { - ActiveDialog::SelectionOp(op) => { - match *op { - "Grow" => self.documents[self.active_doc].engine.selection_grow(v), - "Shrink" => self.documents[self.active_doc].engine.selection_shrink(v), - "Feather" => self.documents[self.active_doc].engine.selection_feather(v as f32), - "Border" => self.documents[self.active_doc].engine.selection_border(v), - "Smooth" => self.documents[self.active_doc].engine.selection_smooth(v), - _ => {} - } - } + ActiveDialog::SelectionOp(op) => match *op { + "Grow" => self.documents[self.active_doc].engine.selection_grow(v), + "Shrink" => self.documents[self.active_doc].engine.selection_shrink(v), + "Feather" => self.documents[self.active_doc] + .engine + .selection_feather(v as f32), + "Border" => self.documents[self.active_doc].engine.selection_border(v), + "Smooth" => self.documents[self.active_doc].engine.selection_smooth(v), + _ => {} + }, _ => {} } + // Selection operations mutate the existing mask in place, so force overlay cache + // synchronization before the next marching-ants frame. self.refresh_composite_if_needed(); self.active_dialog = ActiveDialog::None; return Task::perform(async {}, |_| Message::CompositeRefresh); @@ -3183,13 +4191,15 @@ impl HcieIcedApp { Message::DialogImageSizeWidth(w) => { self.dialog_image_size_width = w; if self.dialog_image_size_constrain && self.dialog_image_size_original_ratio > 0.0 { - self.dialog_image_size_height = (w as f32 / self.dialog_image_size_original_ratio) as u32; + self.dialog_image_size_height = + (w as f32 / self.dialog_image_size_original_ratio) as u32; } } Message::DialogImageSizeHeight(h) => { self.dialog_image_size_height = h; if self.dialog_image_size_constrain && self.dialog_image_size_original_ratio > 0.0 { - self.dialog_image_size_width = (h as f32 * self.dialog_image_size_original_ratio) as u32; + self.dialog_image_size_width = + (h as f32 * self.dialog_image_size_original_ratio) as u32; } } Message::DialogImageSizeConstrain(c) => { @@ -3219,7 +4229,9 @@ impl HcieIcedApp { Message::DialogCanvasSizeApply => { let new_w = self.dialog_canvas_size_width.max(1); let new_h = self.dialog_canvas_size_height.max(1); - self.documents[self.active_doc].engine.resize_canvas(new_w, new_h); + self.documents[self.active_doc] + .engine + .resize_canvas(new_w, new_h); self.documents[self.active_doc].engine.pre_tile_all_layers(); self.refresh_composite_if_needed(); self.documents[self.active_doc].full_upload.replace(true); @@ -3243,9 +4255,17 @@ impl HcieIcedApp { let ex = x0.max(x1) as u32; let ey = y0.max(y1) as u32; if ex > sx && ey > sy { - self.documents[self.active_doc].engine.create_selection_rect(sx, sy, ex, ey); + let previous = self.documents[self.active_doc] + .engine + .get_selection_mask() + .map(ToOwned::to_owned); + self.documents[self.active_doc] + .engine + .create_selection_rect(sx, sy, ex, ey); + self.combine_current_selection(previous); // Store selection bounds for marching ants display - self.documents[self.active_doc].selection_bounds = Some((sx, sy, ex - sx, ey - sy)); + self.documents[self.active_doc].selection_bounds = + Some((sx, sy, ex - sx, ey - sy)); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } @@ -3265,12 +4285,63 @@ impl HcieIcedApp { self.documents[self.active_doc].selection_transform = None; self.documents[self.active_doc].transform_drag_handle = TransformHandle::None; self.documents[self.active_doc].engine.selection_clear(); + self.documents[self.active_doc].selection_fill_spans = None; + self.documents[self.active_doc].marching_ants_edges = None; } Message::SelectInverse => { self.documents[self.active_doc].engine.selection_invert(); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } + Message::SelectionModeChanged(mode) => { + self.documents[self.active_doc].selection_mode = mode; + } + Message::SelectionSave => { + self.documents[self.active_doc].saved_selection_mask = self.documents + [self.active_doc] + .engine + .get_selection_mask() + .map(ToOwned::to_owned); + } + Message::SelectionLoad => { + if let Some(mask) = self.documents[self.active_doc].saved_selection_mask.clone() { + self.documents[self.active_doc] + .engine + .set_selection_mask(mask); + self.refresh_composite_if_needed(); + } + } + Message::QuickMaskToggle => { + let doc = &mut self.documents[self.active_doc]; + doc.quick_mask = !doc.quick_mask; + } + Message::ClearPixels => { + let mask = self.documents[self.active_doc] + .engine + .get_selection_mask() + .map(ToOwned::to_owned); + if let (Some(mask), Some(mut pixels)) = ( + mask, + self.documents[self.active_doc] + .engine + .get_active_layer_pixels(), + ) { + for (index, alpha) in mask.into_iter().enumerate() { + if alpha != 0 && index * 4 + 3 < pixels.len() { + pixels[index * 4..index * 4 + 4].fill(0); + } + } + self.documents[self.active_doc] + .engine + .set_active_layer_pixels(pixels); + } else { + self.documents[self.active_doc] + .engine + .clear_active_layer_pixels(); + } + self.documents[self.active_doc].modified = true; + self.refresh_composite_if_needed(); + } // ── Selection transform ──────────────────────── Message::TransformDragStart(x, y) => { @@ -3283,7 +4354,9 @@ impl HcieIcedApp { } Message::TransformDragMove(x, y) => { let doc = &mut self.documents[self.active_doc]; - if let (Some(start), Some(ref mut tr)) = (doc.transform_drag_start, &mut doc.selection_transform) { + if let (Some(start), Some(ref mut tr)) = + (doc.transform_drag_start, &mut doc.selection_transform) + { let dx = x - start.0; let dy = y - start.1; @@ -3338,6 +4411,7 @@ impl HcieIcedApp { } TransformHandle::None => {} } + tr.sanitize_geometry(self.modifiers.shift()); } } Message::TransformDragEnd => { @@ -3362,6 +4436,7 @@ impl HcieIcedApp { self.documents[self.active_doc].transform_drag_handle = TransformHandle::None; self.documents[self.active_doc].transform_drag_start = None; self.documents[self.active_doc].transform_original = None; + self.documents[self.active_doc].transform_source = None; self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); @@ -3369,14 +4444,16 @@ impl HcieIcedApp { } Message::TransformCancel => { let doc = &mut self.documents[self.active_doc]; - // Restore original transform if we have it - if let Some(original) = doc.transform_original.take() { - doc.selection_transform = Some(original); - } else { - doc.selection_transform = None; + if let Some(source) = doc.transform_source.take() { + let width = doc.engine.canvas_width(); + let height = doc.engine.canvas_height(); + apply_transform_to_layer(&mut doc.engine, &source, width, height); } + doc.selection_transform = None; + doc.transform_original = None; doc.transform_drag_start = None; doc.transform_drag_handle = TransformHandle::None; + self.refresh_composite_if_needed(); } // ── Vector tools ────────────────────────────── @@ -3389,7 +4466,41 @@ impl HcieIcedApp { } } Message::VectorDrawEnd => { - if let Some(((x0, y0), (x1, y1))) = self.documents[self.active_doc].vector_draw.take() { + if let Some(((x0, y0), (x1, y1))) = + self.documents[self.active_doc].vector_draw.take() + { + // `add_vector_shape` safely auto-creates a vector layer from a non-vector + // layer, but would otherwise append to a locked active vector layer. + let active_id = self.documents[self.active_doc].engine.active_layer_id(); + let active_is_locked_vector = self.documents[self.active_doc] + .engine + .get_layer_info(active_id) + .map(|info| { + info.locked && info.layer_type == hcie_engine_api::LayerType::Vector + }) + .unwrap_or(false); + if active_is_locked_vector { + let safe_source = self.documents[self.active_doc] + .engine + .layer_infos() + .into_iter() + .find(|info| { + !info.locked + && !matches!( + info.layer_type, + hcie_engine_api::LayerType::Vector + | hcie_engine_api::LayerType::Group + | hcie_engine_api::LayerType::Mask + ) + }) + .map(|info| info.id); + let Some(safe_source) = safe_source else { + return Task::none(); + }; + self.documents[self.active_doc] + .engine + .set_active_layer(safe_source); + } let engine = &mut self.documents[self.active_doc].engine; let color = self.fg_color; let stroke = self.settings.tool_settings.vector_stroke_width; @@ -3402,68 +4513,204 @@ impl HcieIcedApp { let shape = match self.tool_state.active_tool { Tool::VectorRect => Some(hcie_engine_api::VectorShape::Rect { - x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), - stroke, color, fill, fill_color, radius, angle: 0.0, opacity, hardness: 0.5, + x1: x0.min(x1), + y1: y0.min(y1), + x2: x0.max(x1), + y2: y0.max(y1), + stroke, + color, + fill, + fill_color, + radius, + angle: 0.0, + opacity, + hardness: 0.5, }), Tool::VectorCircle => Some(hcie_engine_api::VectorShape::Circle { - x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), - stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, + x1: x0.min(x1), + y1: y0.min(y1), + x2: x0.max(x1), + y2: y0.max(y1), + stroke, + color, + fill, + fill_color, + angle: 0.0, + opacity, + hardness: 0.5, }), Tool::VectorLine => Some(hcie_engine_api::VectorShape::Line { - x1: x0, y1: y0, x2: x1, y2: y1, stroke, color, angle: 0.0, + x1: x0, + y1: y0, + x2: x1, + y2: y1, + stroke, + color, + angle: 0.0, cap_start: hcie_engine_api::LineCap::Round, cap_end: hcie_engine_api::LineCap::Round, - opacity, hardness: 0.5, + opacity, + hardness: 0.5, }), Tool::VectorArrow => Some(hcie_engine_api::VectorShape::Arrow { - x1: x0, y1: y0, x2: x1, y2: y1, stroke, color, fill, fill_color, - angle: 0.0, opacity, hardness: 0.5, thick: false, + x1: x0, + y1: y0, + x2: x1, + y2: y1, + stroke, + color, + fill, + fill_color, + angle: 0.0, + opacity, + hardness: 0.5, + thick: false, }), Tool::VectorStar => Some(hcie_engine_api::VectorShape::Star { - x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), - stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, - points, inner_radius: 0.5, + x1: x0.min(x1), + y1: y0.min(y1), + x2: x0.max(x1), + y2: y0.max(y1), + stroke, + color, + fill, + fill_color, + angle: 0.0, + opacity, + hardness: 0.5, + points, + inner_radius: 0.5, }), Tool::VectorPolygon => Some(hcie_engine_api::VectorShape::Polygon { - x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), - stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, + x1: x0.min(x1), + y1: y0.min(y1), + x2: x0.max(x1), + y2: y0.max(y1), + stroke, + color, + fill, + fill_color, + angle: 0.0, + opacity, + hardness: 0.5, sides, }), Tool::VectorRhombus => Some(hcie_engine_api::VectorShape::Rhombus { - x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), - stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, + x1: x0.min(x1), + y1: y0.min(y1), + x2: x0.max(x1), + y2: y0.max(y1), + stroke, + color, + fill, + fill_color, + angle: 0.0, + opacity, + hardness: 0.5, }), Tool::VectorCylinder => Some(hcie_engine_api::VectorShape::Cylinder { - x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), - stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, + x1: x0.min(x1), + y1: y0.min(y1), + x2: x0.max(x1), + y2: y0.max(y1), + stroke, + color, + fill, + fill_color, + angle: 0.0, + opacity, + hardness: 0.5, }), Tool::VectorHeart => Some(hcie_engine_api::VectorShape::Heart { - x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), - stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, + x1: x0.min(x1), + y1: y0.min(y1), + x2: x0.max(x1), + y2: y0.max(y1), + stroke, + color, + fill, + fill_color, + angle: 0.0, + opacity, + hardness: 0.5, }), Tool::VectorBubble => Some(hcie_engine_api::VectorShape::Bubble { - x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), - stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, + x1: x0.min(x1), + y1: y0.min(y1), + x2: x0.max(x1), + y2: y0.max(y1), + stroke, + color, + fill, + fill_color, + angle: 0.0, + opacity, + hardness: 0.5, }), Tool::VectorGear => Some(hcie_engine_api::VectorShape::Gear { - x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), - stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, + x1: x0.min(x1), + y1: y0.min(y1), + x2: x0.max(x1), + y2: y0.max(y1), + stroke, + color, + fill, + fill_color, + angle: 0.0, + opacity, + hardness: 0.5, }), Tool::VectorCross => Some(hcie_engine_api::VectorShape::Cross { - x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), - stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, + x1: x0.min(x1), + y1: y0.min(y1), + x2: x0.max(x1), + y2: y0.max(y1), + stroke, + color, + fill, + fill_color, + angle: 0.0, + opacity, + hardness: 0.5, }), Tool::VectorCrescent => Some(hcie_engine_api::VectorShape::Crescent { - x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), - stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, + x1: x0.min(x1), + y1: y0.min(y1), + x2: x0.max(x1), + y2: y0.max(y1), + stroke, + color, + fill, + fill_color, + angle: 0.0, + opacity, + hardness: 0.5, }), Tool::VectorBolt => Some(hcie_engine_api::VectorShape::Bolt { - x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), - stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, + x1: x0.min(x1), + y1: y0.min(y1), + x2: x0.max(x1), + y2: y0.max(y1), + stroke, + color, + fill, + fill_color, + angle: 0.0, + opacity, + hardness: 0.5, }), Tool::VectorArrow4 => Some(hcie_engine_api::VectorShape::Arrow4 { - x1: x0.min(x1), y1: y0.min(y1), x2: x0.max(x1), y2: y0.max(y1), - stroke, color, fill, fill_color, angle: 0.0, opacity, hardness: 0.5, + x1: x0.min(x1), + y1: y0.min(y1), + x2: x0.max(x1), + y2: y0.max(y1), + stroke, + color, + fill, + fill_color, + angle: 0.0, + opacity, + hardness: 0.5, }), _ => None, }; @@ -3481,12 +4728,32 @@ impl HcieIcedApp { use hcie_engine_api::VectorEditHandle as VEH; // If a shape is already selected, hit-test its edit handles first. - if self.documents[self.active_doc].selected_vector_shape.is_some() { + if self.documents[self.active_doc] + .selected_vector_shape + .is_some() + { let handle = self.vector_hit_test_handle(x, y); if handle != VEH::None && handle != VEH::Move { // Clicked on a resize/rotate handle — start drag with that handle. - self.documents[self.active_doc].vector_edit_handle = handle; - self.vector_drag_last = Some((x, y)); + let doc = &mut self.documents[self.active_doc]; + let layer_id = doc.engine.active_layer_id(); + let shape_index = doc.selected_vector_shape.unwrap(); + if let Some(original) = doc + .engine + .active_vector_shapes() + .and_then(|shapes| shapes.get(shape_index).cloned()) + { + doc.vector_edit_handle = handle; + self.vector_drag_last = Some((x, y)); + self.vector_drag_session = + Some(crate::vector_edit::VectorDragSession { + layer_id, + shape_index, + handle, + pointer_start: (x, y), + original, + }); + } return Task::none(); } } @@ -3494,8 +4761,13 @@ impl HcieIcedApp { // Find ALL shapes at the click position for cycling support. // Collect indices of shapes whose bounding box contains (x, y), // iterating forward so index 0 is the bottom-most shape. - if let Some(shapes) = self.documents[self.active_doc].engine.active_vector_shapes() { - let hits: Vec = shapes.iter().enumerate() + if let Some(shapes) = self.documents[self.active_doc] + .engine + .active_vector_shapes() + { + let hits: Vec = shapes + .iter() + .enumerate() .filter(|(_, s)| { let (l, t, r, b) = s.normalized_bounds(); x >= l && x <= r && y >= t && y <= b @@ -3505,34 +4777,40 @@ impl HcieIcedApp { if !hits.is_empty() { // Save snapshot before editing (for undo). - let snapshot = self.documents[self.active_doc].engine.active_vector_shapes(); - self.vector_shapes_snapshot = snapshot; + let layer_id = self.documents[self.active_doc].engine.active_layer_id(); + if self.vector_shapes_snapshot.as_ref().map(|s| s.layer_id) + != Some(layer_id) + { + self.vector_shapes_snapshot = + Some(crate::vector_edit::VectorEditSnapshot { + layer_id, + shapes: shapes.clone(), + }); + } let doc = &mut self.documents[self.active_doc]; let currently_selected = doc.selected_vector_shape; - // If clicking on a position where a shape is already selected, - // cycle through overlapping shapes. - if let Some(cur_idx) = currently_selected { - if hits.contains(&cur_idx) { - // The current selection is among the hits — cycle to the next. - let next_pos = (doc.vector_cycle_index + 1) % hits.len(); - doc.vector_cycle_index = next_pos; - doc.selected_vector_shape = Some(hits[next_pos]); - } else { - // Clicked on a different region — select topmost and reset cycle. - doc.vector_cycle_index = 0; - doc.selected_vector_shape = Some(*hits.last().unwrap()); - } - } else { - // No shape was selected — pick the topmost (last in hits) and reset cycle. - doc.vector_cycle_index = 0; - doc.selected_vector_shape = Some(*hits.last().unwrap()); - } + doc.selected_vector_shape = + crate::vector_edit::cycle_selection(&hits, currently_selected); + doc.vector_cycle_index = doc + .selected_vector_shape + .and_then(|selected| hits.iter().position(|hit| *hit == selected)) + .unwrap_or(0); doc.vector_draw = None; // clear any preview doc.vector_edit_handle = VEH::Move; self.vector_drag_last = Some((x, y)); + if let Some(shape_index) = doc.selected_vector_shape { + self.vector_drag_session = + Some(crate::vector_edit::VectorDragSession { + layer_id, + shape_index, + handle: VEH::Move, + pointer_start: (x, y), + original: shapes[shape_index].clone(), + }); + } crate::shape_sync::sync_tool_from_shape(self); } else { // No shape under cursor — deselect. @@ -3542,6 +4820,7 @@ impl HcieIcedApp { doc.vector_edit_handle = VEH::None; self.vector_shapes_snapshot = None; self.vector_drag_last = None; + self.vector_drag_session = None; } } else { // No vector shapes on this layer — deselect. @@ -3551,44 +4830,66 @@ impl HcieIcedApp { doc.vector_edit_handle = VEH::None; self.vector_shapes_snapshot = None; self.vector_drag_last = None; + self.vector_drag_session = None; } } Message::VectorSelectDragStart { x, y } => { - if self.documents[self.active_doc].selected_vector_shape.is_some() { + if self.documents[self.active_doc] + .selected_vector_shape + .is_some() + { // Hit-test vector edit handles to determine drag mode. let handle = self.vector_hit_test_handle(x, y); - self.documents[self.active_doc].vector_edit_handle = handle; - self.vector_drag_last = Some((x, y)); + let doc = &mut self.documents[self.active_doc]; + let layer_id = doc.engine.active_layer_id(); + let shape_index = doc.selected_vector_shape.unwrap(); + if let Some(original) = doc + .engine + .active_vector_shapes() + .and_then(|shapes| shapes.get(shape_index).cloned()) + { + doc.vector_edit_handle = handle; + self.vector_drag_last = Some((x, y)); + self.vector_drag_session = Some(crate::vector_edit::VectorDragSession { + layer_id, + shape_index, + handle, + pointer_start: (x, y), + original, + }); + } } } Message::VectorSelectDragMove { x, y } => { - let idx = self.documents[self.active_doc].selected_vector_shape; - if let (Some(idx), Some((last_x, last_y))) = (idx, self.vector_drag_last) { - let dx = x - last_x; - let dy = y - last_y; - let layer_id = self.documents[self.active_doc].engine.active_layer_id(); - let handle = self.documents[self.active_doc].vector_edit_handle; - match handle { - hcie_engine_api::VectorEditHandle::Rotate => { - // Compute rotation delta from mouse movement. - if let Some((cx, cy)) = self.documents[self.active_doc].engine.get_vector_shape_center(layer_id, idx) { - let angle_start = (last_y - cy).atan2(last_x - cx); - let angle_end = (y - cy).atan2(x - cx); - let delta = angle_end - angle_start; - let current = self.documents[self.active_doc].engine.vector_shape_angle(layer_id, idx); - self.documents[self.active_doc].engine.set_vector_shape_angle(layer_id, idx, current + delta); - } - } - _ => { - self.documents[self.active_doc].engine.modify_vector_shape( - layer_id, - idx, - handle, - dx, - dy, - ); - } + if let Some(session) = self.vector_drag_session.clone() { + let active_layer = self.documents[self.active_doc].engine.active_layer_id(); + if active_layer != session.layer_id + || self.documents[self.active_doc].selected_vector_shape + != Some(session.shape_index) + { + self.vector_drag_last = None; + self.vector_drag_session = None; + return Task::none(); } + let shape = crate::vector_edit::transform_shape( + &session, + (x, y), + self.modifiers.shift(), + self.modifiers.alt(), + ); + let (x1, y1, x2, y2) = shape.normalized_bounds(); + let angle = shape.angle(); + let engine = &mut self.documents[self.active_doc].engine; + engine.set_vector_shape_bounds( + session.layer_id, + session.shape_index, + x1, + y1, + x2, + y2, + ); + engine.set_vector_shape_angle(session.layer_id, session.shape_index, angle); + self.documents[self.active_doc].modified = true; self.vector_drag_last = Some((x, y)); self.refresh_composite_if_needed(); } @@ -3597,17 +4898,25 @@ impl HcieIcedApp { if self.vector_drag_last.is_some() { // Commit the move/resize/rotate: if shapes changed, push history snapshot. self.vector_drag_last = None; - self.documents[self.active_doc].vector_edit_handle = hcie_engine_api::VectorEditHandle::None; + self.vector_drag_session = None; + self.documents[self.active_doc].vector_edit_handle = + hcie_engine_api::VectorEditHandle::None; } } Message::VectorShapeDelete => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); - self.documents[self.active_doc].engine.delete_vector_shape(layer_id, idx); + self.documents[self.active_doc] + .engine + .delete_vector_shape(layer_id, idx); self.documents[self.active_doc].selected_vector_shape = None; + self.documents[self.active_doc].modified = true; self.documents[self.active_doc].vector_cycle_index = 0; self.vector_shapes_snapshot = None; - self.documents[self.active_doc].engine.mark_composite_dirty(); + self.vector_drag_session = None; + self.documents[self.active_doc] + .engine + .mark_composite_dirty(); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } else { @@ -3618,13 +4927,20 @@ impl HcieIcedApp { self.documents[self.active_doc].selected_vector_shape = idx; if idx.is_some() { self.documents[self.active_doc].vector_cycle_index = 0; - let snapshot = self.documents[self.active_doc].engine.active_vector_shapes(); - self.vector_shapes_snapshot = snapshot; + let doc = &self.documents[self.active_doc]; + if let Some(shapes) = doc.engine.active_vector_shapes() { + self.vector_shapes_snapshot = + Some(crate::vector_edit::VectorEditSnapshot { + layer_id: doc.engine.active_layer_id(), + shapes, + }); + } crate::shape_sync::sync_tool_from_shape(self); } else { self.documents[self.active_doc].vector_cycle_index = 0; self.vector_shapes_snapshot = None; } + self.vector_drag_session = None; } // ── Vector shape apply / cancel / flip ────── Message::VectorShapeApply => { @@ -3635,19 +4951,20 @@ impl HcieIcedApp { doc.vector_edit_handle = hcie_engine_api::VectorEditHandle::None; self.vector_shapes_snapshot = None; self.vector_drag_last = None; + self.vector_drag_session = None; } Message::VectorShapeCancel => { // Restore snapshot: put original shapes back, then deselect. if let Some(snapshot) = self.vector_shapes_snapshot.take() { let doc = &mut self.documents[self.active_doc]; - let layer_id = doc.engine.active_layer_id(); - if let Some(shapes) = doc.engine.get_layer_shapes_direct(layer_id) { - *shapes = snapshot; + if let Some(shapes) = doc.engine.get_layer_shapes_direct(snapshot.layer_id) { + *shapes = snapshot.shapes; } doc.selected_vector_shape = None; doc.vector_cycle_index = 0; doc.vector_edit_handle = hcie_engine_api::VectorEditHandle::None; self.vector_drag_last = None; + self.vector_drag_session = None; doc.engine.mark_composite_dirty(); self.refresh_composite_if_needed(); } @@ -3655,11 +4972,18 @@ impl HcieIcedApp { Message::VectorShapeFlipH => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); - if let Some(shapes) = self.documents[self.active_doc].engine.get_layer_shapes_direct(layer_id) { + if let Some(shapes) = self.documents[self.active_doc] + .engine + .get_layer_shapes_direct(layer_id) + { if let Some(shape) = shapes.get_mut(idx) { match shape { hcie_engine_api::VectorShape::FreePath { pts, .. } => { - let cx = pts.iter().map(|p| p[0]).sum::() / pts.len() as f32; + if pts.is_empty() { + return Task::none(); + } + let cx = + pts.iter().map(|p| p[0]).sum::() / pts.len() as f32; for p in pts.iter_mut() { p[0] = 2.0 * cx - p[0]; } @@ -3670,7 +4994,10 @@ impl HcieIcedApp { shape.set_bounds(x2, y1, x1, y2); } } - self.documents[self.active_doc].engine.mark_composite_dirty(); + self.documents[self.active_doc] + .engine + .mark_composite_dirty(); + self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); } } @@ -3679,11 +5006,18 @@ impl HcieIcedApp { Message::VectorShapeFlipV => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); - if let Some(shapes) = self.documents[self.active_doc].engine.get_layer_shapes_direct(layer_id) { + if let Some(shapes) = self.documents[self.active_doc] + .engine + .get_layer_shapes_direct(layer_id) + { if let Some(shape) = shapes.get_mut(idx) { match shape { hcie_engine_api::VectorShape::FreePath { pts, .. } => { - let cy = pts.iter().map(|p| p[1]).sum::() / pts.len() as f32; + if pts.is_empty() { + return Task::none(); + } + let cy = + pts.iter().map(|p| p[1]).sum::() / pts.len() as f32; for p in pts.iter_mut() { p[1] = 2.0 * cy - p[1]; } @@ -3694,7 +5028,10 @@ impl HcieIcedApp { shape.set_bounds(x1, y2, x2, y1); } } - self.documents[self.active_doc].engine.mark_composite_dirty(); + self.documents[self.active_doc] + .engine + .mark_composite_dirty(); + self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); } } @@ -3705,11 +5042,20 @@ impl HcieIcedApp { Message::GeometryBoundsX1(v) => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); - let shapes = self.documents[self.active_doc].engine.active_vector_shapes().unwrap_or_default(); + let shapes = self.documents[self.active_doc] + .engine + .active_vector_shapes() + .unwrap_or_default(); if let Some(s) = shapes.get(idx) { let (_, y1, x2, y2) = s.bounds(); - self.documents[self.active_doc].engine.set_vector_shape_bounds(layer_id, idx, v, y1, x2, y2); - self.documents[self.active_doc].engine.mark_composite_dirty(); + self.documents[self.active_doc] + .engine + .set_vector_shape_bounds(layer_id, idx, v, y1, x2, y2); + self.documents[self.active_doc].modified = true; + crate::shape_sync::sync_tool_from_shape(self); + self.documents[self.active_doc] + .engine + .mark_composite_dirty(); self.refresh_composite_if_needed(); } } @@ -3717,11 +5063,20 @@ impl HcieIcedApp { Message::GeometryBoundsY1(v) => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); - let shapes = self.documents[self.active_doc].engine.active_vector_shapes().unwrap_or_default(); + let shapes = self.documents[self.active_doc] + .engine + .active_vector_shapes() + .unwrap_or_default(); if let Some(s) = shapes.get(idx) { let (x1, _, x2, y2) = s.bounds(); - self.documents[self.active_doc].engine.set_vector_shape_bounds(layer_id, idx, x1, v, x2, y2); - self.documents[self.active_doc].engine.mark_composite_dirty(); + self.documents[self.active_doc] + .engine + .set_vector_shape_bounds(layer_id, idx, x1, v, x2, y2); + self.documents[self.active_doc].modified = true; + crate::shape_sync::sync_tool_from_shape(self); + self.documents[self.active_doc] + .engine + .mark_composite_dirty(); self.refresh_composite_if_needed(); } } @@ -3729,11 +5084,20 @@ impl HcieIcedApp { Message::GeometryBoundsX2(v) => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); - let shapes = self.documents[self.active_doc].engine.active_vector_shapes().unwrap_or_default(); + let shapes = self.documents[self.active_doc] + .engine + .active_vector_shapes() + .unwrap_or_default(); if let Some(s) = shapes.get(idx) { let (x1, y1, _, y2) = s.bounds(); - self.documents[self.active_doc].engine.set_vector_shape_bounds(layer_id, idx, x1, y1, v, y2); - self.documents[self.active_doc].engine.mark_composite_dirty(); + self.documents[self.active_doc] + .engine + .set_vector_shape_bounds(layer_id, idx, x1, y1, v, y2); + self.documents[self.active_doc].modified = true; + crate::shape_sync::sync_tool_from_shape(self); + self.documents[self.active_doc] + .engine + .mark_composite_dirty(); self.refresh_composite_if_needed(); } } @@ -3741,11 +5105,20 @@ impl HcieIcedApp { Message::GeometryBoundsY2(v) => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); - let shapes = self.documents[self.active_doc].engine.active_vector_shapes().unwrap_or_default(); + let shapes = self.documents[self.active_doc] + .engine + .active_vector_shapes() + .unwrap_or_default(); if let Some(s) = shapes.get(idx) { let (x1, y1, x2, _) = s.bounds(); - self.documents[self.active_doc].engine.set_vector_shape_bounds(layer_id, idx, x1, y1, x2, v); - self.documents[self.active_doc].engine.mark_composite_dirty(); + self.documents[self.active_doc] + .engine + .set_vector_shape_bounds(layer_id, idx, x1, y1, x2, v); + self.documents[self.active_doc].modified = true; + crate::shape_sync::sync_tool_from_shape(self); + self.documents[self.active_doc] + .engine + .mark_composite_dirty(); self.refresh_composite_if_needed(); } } @@ -3753,40 +5126,70 @@ impl HcieIcedApp { Message::GeometryFillToggled(v) => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); - self.documents[self.active_doc].engine.set_vector_shape_fill(layer_id, idx, v); - self.documents[self.active_doc].engine.mark_composite_dirty(); + self.documents[self.active_doc] + .engine + .set_vector_shape_fill(layer_id, idx, v); + self.documents[self.active_doc].modified = true; + crate::shape_sync::sync_tool_from_shape(self); + self.documents[self.active_doc] + .engine + .mark_composite_dirty(); self.refresh_composite_if_needed(); } } Message::GeometryFillColorChanged(c) => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); - self.documents[self.active_doc].engine.set_vector_shape_fill_color(layer_id, idx, c); - self.documents[self.active_doc].engine.mark_composite_dirty(); + self.documents[self.active_doc] + .engine + .set_vector_shape_fill_color(layer_id, idx, c); + self.documents[self.active_doc].modified = true; + crate::shape_sync::sync_tool_from_shape(self); + self.documents[self.active_doc] + .engine + .mark_composite_dirty(); self.refresh_composite_if_needed(); } } Message::GeometryStrokeColorChanged(c) => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); - self.documents[self.active_doc].engine.set_vector_shape_color(layer_id, idx, c); - self.documents[self.active_doc].engine.mark_composite_dirty(); + self.documents[self.active_doc] + .engine + .set_vector_shape_color(layer_id, idx, c); + self.documents[self.active_doc].modified = true; + crate::shape_sync::sync_tool_from_shape(self); + self.documents[self.active_doc] + .engine + .mark_composite_dirty(); self.refresh_composite_if_needed(); } } Message::GeometryOpacityChanged(v) => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); - self.documents[self.active_doc].engine.set_vector_shape_opacity(layer_id, idx, v); - self.documents[self.active_doc].engine.mark_composite_dirty(); + self.documents[self.active_doc] + .engine + .set_vector_shape_opacity(layer_id, idx, v); + self.documents[self.active_doc].modified = true; + crate::shape_sync::sync_tool_from_shape(self); + self.documents[self.active_doc] + .engine + .mark_composite_dirty(); self.refresh_composite_if_needed(); } } Message::GeometryHardnessChanged(v) => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); - self.documents[self.active_doc].engine.set_vector_shape_hardness(layer_id, idx, v); - self.documents[self.active_doc].engine.mark_composite_dirty(); + self.documents[self.active_doc] + .engine + .set_vector_shape_hardness(layer_id, idx, v); + self.documents[self.active_doc].modified = true; + crate::shape_sync::sync_tool_from_shape(self); + self.documents[self.active_doc] + .engine + .mark_composite_dirty(); self.refresh_composite_if_needed(); } } @@ -3799,22 +5202,51 @@ impl HcieIcedApp { Message::GeometryBooleanOp(op) => { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); if let (Some(a), Some(b)) = (self.bool_shape_a, self.bool_shape_b) { - self.documents[self.active_doc].engine.boolean_vector_shapes(layer_id, op, a, b); - self.documents[self.active_doc].engine.mark_composite_dirty(); - self.refresh_composite_if_needed(); + let shape_count = self.documents[self.active_doc] + .engine + .active_vector_shapes() + .map_or(0, |shapes| shapes.len()); + let valid = a < shape_count && b < shape_count && a != b; + let succeeded = valid + && self.documents[self.active_doc] + .engine + .boolean_vector_shapes(layer_id, op, a, b); + if succeeded { + let result_index = shape_count - 2; + self.documents[self.active_doc].selected_vector_shape = Some(result_index); + self.documents[self.active_doc].vector_cycle_index = 0; + self.bool_shape_a = None; + self.bool_shape_b = None; + self.vector_shapes_snapshot = None; + self.documents[self.active_doc].modified = true; + self.documents[self.active_doc] + .engine + .mark_composite_dirty(); + crate::shape_sync::sync_tool_from_shape(self); + self.refresh_composite_if_needed(); + } } } Message::GeometryLineCapStart(cap) => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); - let shapes = self.documents[self.active_doc].engine.active_vector_shapes().unwrap_or_default(); + let shapes = self.documents[self.active_doc] + .engine + .active_vector_shapes() + .unwrap_or_default(); if let Some(s) = shapes.get(idx) { let ce = match s { hcie_engine_api::VectorShape::Line { cap_end, .. } => *cap_end, _ => hcie_engine_api::LineCap::Round, }; - self.documents[self.active_doc].engine.set_vector_shape_line_caps(layer_id, idx, cap, ce); - self.documents[self.active_doc].engine.mark_composite_dirty(); + self.documents[self.active_doc] + .engine + .set_vector_shape_line_caps(layer_id, idx, cap, ce); + self.documents[self.active_doc].modified = true; + crate::shape_sync::sync_tool_from_shape(self); + self.documents[self.active_doc] + .engine + .mark_composite_dirty(); self.refresh_composite_if_needed(); } } @@ -3822,14 +5254,23 @@ impl HcieIcedApp { Message::GeometryLineCapEnd(cap) => { if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); - let shapes = self.documents[self.active_doc].engine.active_vector_shapes().unwrap_or_default(); + let shapes = self.documents[self.active_doc] + .engine + .active_vector_shapes() + .unwrap_or_default(); if let Some(s) = shapes.get(idx) { let cs = match s { hcie_engine_api::VectorShape::Line { cap_start, .. } => *cap_start, _ => hcie_engine_api::LineCap::Round, }; - self.documents[self.active_doc].engine.set_vector_shape_line_caps(layer_id, idx, cs, cap); - self.documents[self.active_doc].engine.mark_composite_dirty(); + self.documents[self.active_doc] + .engine + .set_vector_shape_line_caps(layer_id, idx, cs, cap); + self.documents[self.active_doc].modified = true; + crate::shape_sync::sync_tool_from_shape(self); + self.documents[self.active_doc] + .engine + .mark_composite_dirty(); self.refresh_composite_if_needed(); } } @@ -3848,8 +5289,15 @@ impl HcieIcedApp { Message::LassoDragEnd => { let pts = std::mem::take(&mut self.documents[self.active_doc].lasso_points); if pts.len() >= 3 { + let previous = self.documents[self.active_doc] + .engine + .get_selection_mask() + .map(ToOwned::to_owned); let pts_ref: Vec<(u32, u32)> = pts; - self.documents[self.active_doc].engine.create_selection_lasso(&pts_ref); + self.documents[self.active_doc] + .engine + .create_selection_lasso(&pts_ref); + self.combine_current_selection(previous); self.documents[self.active_doc].selection_bounds = { let w = self.documents[self.active_doc].engine.canvas_width(); let h = self.documents[self.active_doc].engine.canvas_height(); @@ -3861,13 +5309,22 @@ impl HcieIcedApp { Message::PolygonAddPoint(x, y) => { let cx = x.round().max(0.0) as u32; let cy = y.round().max(0.0) as u32; - self.documents[self.active_doc].polygon_points.push((cx, cy)); + self.documents[self.active_doc] + .polygon_points + .push((cx, cy)); } Message::PolygonClose => { let pts = std::mem::take(&mut self.documents[self.active_doc].polygon_points); if pts.len() >= 3 { + let previous = self.documents[self.active_doc] + .engine + .get_selection_mask() + .map(ToOwned::to_owned); let pts_ref: Vec<(u32, u32)> = pts; - self.documents[self.active_doc].engine.create_selection_polygon(&pts_ref); + self.documents[self.active_doc] + .engine + .create_selection_polygon(&pts_ref); + self.combine_current_selection(previous); self.documents[self.active_doc].selection_bounds = { let w = self.documents[self.active_doc].engine.canvas_width(); let h = self.documents[self.active_doc].engine.canvas_height(); @@ -3894,28 +5351,28 @@ impl HcieIcedApp { } } Message::GradientDragEnd => { - if let Some(((x0, y0), (x1, y1))) = self.documents[self.active_doc].gradient_drag.take() { - let engine = &mut self.documents[self.active_doc].engine; - let color = self.fg_color; - // Render the gradient into the active layer pixels by - // walking the endpoint line and stamping opaque color - // dabs. This is a simple, engine-API-only approximation - // of a linear gradient fill. - let dx = x1 - x0; - let dy = y1 - y0; - let len = (dx * dx + dy * dy).sqrt(); - if len > 1.0 { - let steps = (len as i32).max(2); - let layer_id = engine.active_layer_id(); - engine.set_color(color); - let mut pts: Vec<(f32, f32, f32)> = Vec::with_capacity(steps as usize); - for i in 0..steps { - let t = i as f32 / (steps - 1) as f32; - pts.push((x0 + dx * t, y0 + dy * t, 1.0)); - } - engine.draw_stroke(&pts); - let _ = layer_id; - self.documents[self.active_doc].modified = true; + if let Some(((x0, y0), (x1, y1))) = + self.documents[self.active_doc].gradient_drag.take() + { + let doc = &mut self.documents[self.active_doc]; + let width = doc.engine.canvas_width(); + let height = doc.engine.canvas_height(); + let mask = doc.engine.get_selection_mask().map(ToOwned::to_owned); + if let Some(mut pixels) = doc.engine.get_active_layer_pixels() { + crate::raster::apply_gradient( + &mut pixels, + width, + height, + (x0, y0), + (x1, y1), + self.settings.tool_settings.gradient_type == 1, + self.fg_color, + self.bg_color, + self.settings.tool_settings.gradient_opacity, + mask.as_deref(), + ); + doc.engine.set_active_layer_pixels(pixels); + doc.modified = true; } self.refresh_composite_if_needed(); } @@ -3923,7 +5380,11 @@ impl HcieIcedApp { // ── Crop tool ──────────────────────────────── Message::CropDragStart(x, y) => { - self.documents[self.active_doc].crop_state.start_drag(x, y); + let width = self.documents[self.active_doc].engine.canvas_width(); + let height = self.documents[self.active_doc].engine.canvas_height(); + self.documents[self.active_doc] + .crop_state + .start_drag_clamped(x, y, width, height); } Message::CropDragMove(x, y) => { self.documents[self.active_doc].crop_state.update_drag(x, y); @@ -4006,11 +5467,16 @@ impl HcieIcedApp { let w = doc.engine.canvas_width(); let h = doc.engine.canvas_height(); let layers = doc.engine.layer_infos(); - let mut meta = format!("\n\n[Active Canvas State: Size {}x{} px. Layers:", w, h); + let mut meta = + format!("\n\n[Active Canvas State: Size {}x{} px. Layers:", w, h); for l in &layers { meta.push_str(&format!( "\n- \"{}\" (Type: {:?}, ID: {}, Visible: {}, Opacity: {:.0}%)", - l.name, l.layer_type, l.id, l.visible, l.opacity * 100.0 + l.name, + l.layer_type, + l.id, + l.visible, + l.opacity * 100.0 )); } meta.push_str("]"); @@ -4028,35 +5494,47 @@ impl HcieIcedApp { let url = self.ai_chat_state.config.base_url.clone(); let model = self.ai_chat_state.config.model.clone(); - // Spawn async streaming task — runs on Iced's executor - return Task::perform( - ai_chat::stream_llm_response(url, model, messages_json), - Message::AiChatStreamFinished, + let provider = self.ai_chat_state.config.provider.clone(); + let task = Task::run( + ai_chat::stream_llm_response(provider, url, model, messages_json), + |update| match update { + ai_chat::ChatUpdate::Chunk(chunk) => Message::AiChatStreamChunk(chunk), + ai_chat::ChatUpdate::ReasoningChunk(chunk) => { + Message::AiChatReasoningChunk(chunk) + } + ai_chat::ChatUpdate::Finished(result) => { + Message::AiChatStreamFinished(result) + } + }, ); + let (task, handle) = task.abortable(); + self.ai_chat_abort = Some(handle); + return task; } Message::AiChatClear => { self.ai_chat_state.messages.clear(); self.ai_chat_state.current_response.clear(); self.ai_chat_state.current_reasoning.clear(); self.ai_chat_state.is_streaming = false; - } - Message::AiChatProviderChanged(provider) => { - match provider.as_str() { - "Ollama" => { - self.ai_chat_state.config.provider = ai_chat::AiProvider::Ollama; - self.ai_chat_state.config.base_url = "http://localhost:11434".to_string(); - } - "Claude" => { - self.ai_chat_state.config.provider = ai_chat::AiProvider::Claude; - self.ai_chat_state.config.base_url = "https://api.anthropic.com".to_string(); - } - "OpenAI" => { - self.ai_chat_state.config.provider = ai_chat::AiProvider::OpenAI; - self.ai_chat_state.config.base_url = "https://api.openai.com/v1".to_string(); - } - _ => {} + if let Some(handle) = self.ai_chat_abort.take() { + handle.abort(); } } + Message::AiChatProviderChanged(provider) => match provider.as_str() { + "Ollama" => { + self.ai_chat_state.config.provider = ai_chat::AiProvider::Ollama; + self.ai_chat_state.config.base_url = "http://localhost:11434".to_string(); + } + "Claude" => { + self.ai_chat_state.config.provider = ai_chat::AiProvider::Claude; + self.ai_chat_state.config.base_url = "https://api.anthropic.com".to_string(); + } + "OpenAI" => { + self.ai_chat_state.config.provider = ai_chat::AiProvider::OpenAI; + self.ai_chat_state.config.base_url = "https://api.openai.com/v1".to_string(); + } + _ => {} + }, Message::AiChatUrlChanged(url) => { self.ai_chat_state.config.base_url = url; } @@ -4069,10 +5547,14 @@ impl HcieIcedApp { Message::AiChatCanvasContextToggled(enabled) => { self.ai_chat_state.config.canvas_context = enabled; } - Message::AiChatStreamChunk(_chunk) => { - // Handled by the streaming task internally + Message::AiChatStreamChunk(chunk) => { + self.ai_chat_state.current_response.push_str(&chunk); + } + Message::AiChatReasoningChunk(chunk) => { + self.ai_chat_state.current_reasoning.push_str(&chunk); } Message::AiChatStreamFinished(result) => { + self.ai_chat_abort = None; self.ai_chat_state.is_streaming = false; match result { Ok(full_response) => { @@ -4103,7 +5585,8 @@ impl HcieIcedApp { } self.ai_chat_state.messages.push(ChatMessage { role: "tool".to_string(), - content: "▶ Automatically executed AI procedural drawing script.".to_string(), + content: "▶ Automatically executed AI procedural drawing script." + .to_string(), is_reasoning: false, }); self.refresh_composite_if_needed(); @@ -4115,6 +5598,14 @@ impl HcieIcedApp { content: full_response, is_reasoning: false, }); + if !self.ai_chat_state.current_reasoning.is_empty() { + self.ai_chat_state.messages.push(ChatMessage { + role: "assistant".to_string(), + content: std::mem::take(&mut self.ai_chat_state.current_reasoning), + is_reasoning: true, + }); + } + self.ai_chat_state.current_response.clear(); } Err(e) => { self.ai_chat_state.messages.push(ChatMessage { @@ -4125,6 +5616,37 @@ impl HcieIcedApp { } } } + Message::AiChatCancel => { + if let Some(handle) = self.ai_chat_abort.take() { + handle.abort(); + } + if !self.ai_chat_state.current_response.is_empty() { + self.ai_chat_state.messages.push(ChatMessage { + role: "assistant".to_string(), + content: std::mem::take(&mut self.ai_chat_state.current_response), + is_reasoning: false, + }); + } + self.ai_chat_state.is_streaming = false; + } + Message::AiChatCopyTranscript => { + let mut messages = self.ai_chat_state.messages.clone(); + if !self.ai_chat_state.current_response.is_empty() { + messages.push(ChatMessage { + role: "assistant".to_string(), + content: self.ai_chat_state.current_response.clone(), + is_reasoning: false, + }); + } + if !self.ai_chat_state.current_reasoning.is_empty() { + messages.push(ChatMessage { + role: "assistant".to_string(), + content: self.ai_chat_state.current_reasoning.clone(), + is_reasoning: true, + }); + } + return iced::clipboard::write(ai_chat::transcript_text(&messages)); + } // ── AI Script Generator ─────────────────────── Message::AiScriptPromptChanged(text) => { @@ -4161,6 +5683,7 @@ impl HcieIcedApp { } self.ai_script_is_running = true; self.ai_script_error = None; + self.ai_script_notice = None; self.ai_script_output.clear(); let prompt = self.ai_script_prompt.clone(); @@ -4184,6 +5707,10 @@ impl HcieIcedApp { match crate::script::parser::parse_dsl_script(&self.ai_script_output) { Ok(actions) => { self.ai_script_error = None; + self.ai_script_notice = Some(format!( + "Executed {} validated script actions.", + actions.len() + )); crate::script::executor::execute_actions( &mut self.documents[self.active_doc].engine, &actions, @@ -4201,15 +5728,24 @@ impl HcieIcedApp { if !self.ai_script_output.is_empty() { // Copy script to the script editor panel self.script_text = self.ai_script_output.clone(); - self.script_content = iced::widget::text_editor::Content::with_text( - &self.ai_script_output, - ); + self.script_content = + iced::widget::text_editor::Content::with_text(&self.ai_script_output); + self.ai_script_notice = + Some("Copied generated script to the Script panel.".to_string()); + } + } + Message::AiScriptCopyOutput => { + if !self.ai_script_output.is_empty() { + self.ai_script_notice = + Some("Copied generated script to the system clipboard.".to_string()); + return iced::clipboard::write(self.ai_script_output.clone()); } } Message::AiScriptClear => { self.ai_script_prompt.clear(); self.ai_script_output.clear(); self.ai_script_error = None; + self.ai_script_notice = None; self.ai_script_is_running = false; } Message::AiScriptResult(result) => { @@ -4217,6 +5753,8 @@ impl HcieIcedApp { match result { Ok(script) => { self.ai_script_output = script; + self.ai_script_notice = + Some("Generated script validated and is ready to run.".to_string()); } Err(e) => { self.ai_script_error = Some(e); @@ -4226,34 +5764,71 @@ impl HcieIcedApp { // ── Clipboard ───────────────────────────────── Message::CopyImage => { - let doc = &self.documents[self.active_doc]; - let w = doc.engine.canvas_width(); - let h = doc.engine.canvas_height(); - - // Check if there's a selection - if let Some(ref mask) = doc.selection_mask { - // Copy selected pixels only - let pixels = doc.composite_raw.clone(); - if let Some(transform) = selection::clipboard::extract_masked_pixels(&pixels, mask, w, h) { - self.documents[self.active_doc].internal_clipboard = Some(transform); - return Task::perform(async {}, |_| Message::NoOp); + let (pixels, w, h) = { + let doc = &self.documents[self.active_doc]; + let canvas_w = doc.engine.canvas_width(); + let canvas_h = doc.engine.canvas_height(); + if let Some(mask) = doc.engine.get_selection_mask() { + if let Some(transform) = selection::clipboard::extract_masked_pixels( + &doc.composite_raw, + mask, + canvas_w, + canvas_h, + ) { + let result = + (transform.pixels.clone(), transform.width, transform.height); + self.documents[self.active_doc].internal_clipboard = Some(transform); + result + } else { + (doc.composite_raw.clone(), canvas_w, canvas_h) + } + } else { + (doc.composite_raw.clone(), canvas_w, canvas_h) } - } - - // No selection - copy entire canvas - let pixels = doc.composite_raw.clone(); + }; return Task::perform( async move { crate::io::clipboard::copy_image_to_clipboard(&pixels, w, h) }, Message::ImageCopied, ); } + Message::CutImage => { + let doc = &mut self.documents[self.active_doc]; + let w = doc.engine.canvas_width(); + let h = doc.engine.canvas_height(); + let mask = doc.engine.get_selection_mask().map(ToOwned::to_owned); + if let Some(mask) = mask { + if let Some(transform) = + selection::clipboard::extract_masked_pixels(&doc.composite_raw, &mask, w, h) + { + let _ = crate::io::clipboard::copy_image_to_clipboard( + &transform.pixels, + transform.width, + transform.height, + ); + doc.internal_clipboard = Some(transform); + if let Some(mut pixels) = doc.engine.get_active_layer_pixels() { + for (index, alpha) in mask.into_iter().enumerate() { + if alpha != 0 && index * 4 + 3 < pixels.len() { + pixels[index * 4..index * 4 + 4].fill(0); + } + } + doc.engine.set_active_layer_pixels(pixels); + doc.modified = true; + } + } + } + self.refresh_composite_if_needed(); + } Message::PasteImage => { let doc = &self.documents[self.active_doc]; // Check internal clipboard first if let Some(ref transform) = doc.internal_clipboard { // Enter transform mode with the clipboard content - self.documents[self.active_doc].selection_transform = Some(transform.clone()); + let mut placed = transform.clone(); + placed.pos.x += 10.0; + placed.pos.y += 10.0; + self.documents[self.active_doc].selection_transform = Some(placed); self.documents[self.active_doc].transform_drag_handle = TransformHandle::None; return Task::perform(async {}, |_| Message::CompositeRefresh); } @@ -4278,7 +5853,10 @@ impl HcieIcedApp { ); } Message::ClipboardResult(Ok(Some(text))) => { - log::info!("Pasted text: {}", &text[..text.len().min(50)]); + let doc = &mut self.documents[self.active_doc]; + let draft = doc.text_draft.get_or_insert_with(TextDraft::default); + draft.content.push_str(&text); + draft.editor = iced::widget::text_editor::Content::with_text(&draft.content); } Message::ClipboardResult(Ok(None)) => { log::info!("Clipboard is empty"); @@ -4294,10 +5872,10 @@ impl HcieIcedApp { } Message::ImagePasted(Ok(Some((pixels, w, h)))) => { log::info!("Pasted image: {}x{}", w, h); - let engine = &mut self.documents[self.active_doc].engine; - engine.create_layer_from_data("Pasted", pixels, w, h); - self.refresh_composite_if_needed(); - return Task::perform(async {}, |_| Message::CompositeRefresh); + let canvas_w = self.documents[self.active_doc].engine.canvas_width(); + let canvas_h = self.documents[self.active_doc].engine.canvas_height(); + self.documents[self.active_doc].selection_transform = + selection::clipboard::centered_transform(pixels, w, h, canvas_w, canvas_h); } Message::ImagePasted(Ok(None)) => { log::info!("No image in clipboard"); @@ -4308,19 +5886,26 @@ impl HcieIcedApp { // ── File I/O (with rfd) ─────────────────────── Message::OpenFileRfd => { + self.pending_file_operation = Some(PendingFileOperation::Open); return Task::perform( async { rfd::AsyncFileDialog::new() .set_title("Open Image") - .add_filter("All Supported Types", &[ - "png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif", - "avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr", - "hcie", "psd", "kra", - ]) - .add_filter("Images", &[ - "png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif", - "avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr", - ]) + .add_filter( + "All Supported Types", + &[ + "png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif", + "avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr", + "hcie", "psd", "kra", + ], + ) + .add_filter( + "Images", + &[ + "png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif", + "avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr", + ], + ) .add_filter("HCIE Native", &["hcie"]) .add_filter("Photoshop", &["psd"]) .add_filter("Krita", &["kra"]) @@ -4332,6 +5917,13 @@ impl HcieIcedApp { ); } Message::SaveFileAs => { + if !matches!( + self.pending_file_operation, + Some(PendingFileOperation::SaveAs { close_after: true }) + ) { + self.pending_file_operation = + Some(PendingFileOperation::SaveAs { close_after: false }); + } return Task::perform( async { rfd::AsyncFileDialog::new() @@ -4348,10 +5940,13 @@ impl HcieIcedApp { .add_filter("TIFF", &["tiff", "tif"]) .add_filter("TGA", &["tga"]) .add_filter("OpenEXR", &["exr"]) - .add_filter("All Supported Types", &[ - "png", "jpg", "jpeg", "webp", "bmp", "gif", "tiff", "tif", - "avif", "tga", "exr", "psd", "kra", "hcie", - ]) + .add_filter( + "All Supported Types", + &[ + "png", "jpg", "jpeg", "webp", "bmp", "gif", "tiff", "tif", + "avif", "tga", "exr", "psd", "kra", "hcie", + ], + ) .save_file() .await .map(|handle| handle.path().to_path_buf()) @@ -4361,52 +5956,56 @@ impl HcieIcedApp { } Message::FileDialogClosed(Some(path)) => { let path_str = path.to_string_lossy().to_string(); - let ext = path.extension() + let ext = path + .extension() .and_then(|e| e.to_str()) .unwrap_or("png") .to_lowercase(); - if self.active_dialog == ActiveDialog::None { - // Open file — route multi-layer formats to dedicated importers - let engine = &mut self.documents[self.active_doc].engine; - let result = match ext.as_str() { - "psd" => engine.import_psd(&path_str), - "kra" => engine.import_kra(&path_str), - "hcie" => engine.load_native(&path_str), - _ => engine.open_image(&path_str), - }; - match result { - Ok(()) => { - self.documents[self.active_doc].engine.pre_tile_all_layers(); - self.documents[self.active_doc].name = path.file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "Untitled".to_string()); - self.documents[self.active_doc].source_path = Some(path.clone()); - self.add_recent_file(&path); - self.documents[self.active_doc].full_upload.replace(true); - self.refresh_composite_if_needed(); - return Task::perform(async {}, |_| Message::CompositeRefresh); + match self.pending_file_operation.take() { + Some(PendingFileOperation::Open) => { + let engine = &mut self.documents[self.active_doc].engine; + let result = match ext.as_str() { + "psd" => engine.import_psd(&path_str), + "kra" => engine.import_kra(&path_str), + "hcie" => engine.load_native(&path_str), + _ => engine.open_image(&path_str), + }; + match result { + Ok(()) => { + self.documents[self.active_doc].engine.pre_tile_all_layers(); + self.documents[self.active_doc].name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "Untitled".to_string()); + self.documents[self.active_doc].source_path = Some(path.clone()); + self.documents[self.active_doc].modified = false; + self.add_recent_file(&path); + self.documents[self.active_doc].full_upload.replace(true); + self.refresh_composite_if_needed(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } + Err(e) => log::error!("Failed to open: {}", e), } - Err(e) => log::error!("Failed to open: {}", e), } - } else { - // Save file — route multi-layer formats to dedicated exporters - let engine = &self.documents[self.active_doc].engine; - let result = match ext.as_str() { - "hcie" => engine.save_native(&path_str), - "psd" => engine.export_psd(&path_str), - "kra" => engine.export_kra(&path_str), - _ => engine.save_as(&path_str, &ext), - }; - match result { - Ok(()) => log::info!("Saved to {}", path_str), - Err(e) => log::error!("Failed to save: {}", e), + Some(PendingFileOperation::SaveAs { close_after }) => { + match self.save_document_to(self.active_doc, &path) { + Ok(()) => { + log::info!("Saved to {}", path_str); + self.finish_successful_save(self.active_doc, path); + if close_after { + return self.continue_close_after_save(); + } + } + Err(error) => log::error!("Failed to save: {error}"), + } } - self.active_dialog = ActiveDialog::None; + None => log::warn!("Ignoring file dialog result without a pending operation"), } } Message::FileDialogClosed(None) => { - // User cancelled + self.pending_file_operation = None; + self.pending_document_close = None; } // ── Dock Layout ─────────────────────────────── @@ -4415,13 +6014,13 @@ impl HcieIcedApp { } Message::PaneDragged(drag_event) => { if let iced::widget::pane_grid::DragEvent::Dropped { pane, target } = drag_event { - if let iced::widget::pane_grid::Target::Pane(target_pane, _) = target { - self.dock.pane_grid.swap(pane, target_pane); - } + self.dock.pane_grid.drop(pane, target); } } Message::PaneResized(resize_event) => { - self.dock.pane_grid.resize(resize_event.split, resize_event.ratio); + self.dock + .pane_grid + .resize(resize_event.split, resize_event.ratio); } Message::PaneClose(pane) => { // Don't close canvas panes @@ -4441,7 +6040,10 @@ impl HcieIcedApp { return Task::none(); } // Collect the list of panel types currently open in the dock - let visible_panels: Vec = self.dock.pane_grid.iter() + let visible_panels: Vec = self + .dock + .pane_grid + .iter() .map(|(_, pane_type)| pane_type.label().to_string()) .collect(); let profile = crate::settings::DockProfile { @@ -4462,11 +6064,7 @@ impl HcieIcedApp { self.active_dialog = ActiveDialog::None; } Message::DockProfileLoad(name) => { - if let Some(profile) = self - .settings - .dock_profiles - .iter() - .find(|p| p.name == *name) + if let Some(profile) = self.settings.dock_profiles.iter().find(|p| p.name == *name) { // Reopen all panels listed in the saved profile let panel_names = profile.visible_panels.clone(); @@ -4513,7 +6111,12 @@ impl HcieIcedApp { } } Message::DockProfileRenameStart(name) => { - if let Some(idx) = self.settings.dock_profiles.iter().position(|p| p.name == name) { + if let Some(idx) = self + .settings + .dock_profiles + .iter() + .position(|p| p.name == name) + { self.dock_profile_rename_idx = Some(idx); self.dock_profile_rename_buf = name; } @@ -4528,7 +6131,11 @@ impl HcieIcedApp { if self.active_dock_profile.as_deref() == Some(&old_name) { self.active_dock_profile = Some(new_name); } - log::info!("Dock profile renamed: {} -> {}", old_name, self.settings.dock_profiles[idx].name); + log::info!( + "Dock profile renamed: {} -> {}", + old_name, + self.settings.dock_profiles[idx].name + ); let _ = self.settings.save(); } } @@ -4588,6 +6195,7 @@ impl HcieIcedApp { transform_drag_handle: TransformHandle::None, transform_drag_start: None, transform_original: None, + transform_source: None, internal_clipboard: None, selection_mask: None, selection_bounds: None, @@ -4599,42 +6207,54 @@ impl HcieIcedApp { text_draft: None, selection_edge_cache: Default::default(), marching_ants_edges: None, + selection_fill_spans: None, + selection_mode: selection::SelectionMode::Replace, + saved_selection_mask: None, + quick_mask: false, selected_vector_shape: None, vector_cycle_index: 0, vector_edit_handle: hcie_engine_api::VectorEditHandle::None, }); } - Message::DocSwitch(idx) => { if idx < self.documents.len() { + if self.preview_baseline.is_some() { + self.restore_preview(); + self.filter_preview_active = false; + self.selected_filter = None; + self.filter_params = serde_json::json!({}); + } self.active_doc = idx; } } + Message::DocClose(idx) => { + let modified = self + .documents + .get(idx) + .is_some_and(|document| document.modified); + match document_close_disposition(self.documents.len(), idx, modified) { + DocumentCloseDisposition::Invalid => return Task::none(), + DocumentCloseDisposition::Window => return self.update(Message::WindowClose), + DocumentCloseDisposition::Confirm | DocumentCloseDisposition::Close => { + if self.preview_baseline.is_some() { + self.restore_preview(); + self.filter_preview_active = false; + } + } + } + if modified { + self.active_doc = idx; + self.pending_document_close = Some(idx); + self.active_dialog = ActiveDialog::CloseConfirm; + } else { + self.close_document_tab(idx); + } + } + Message::OpenFile => { - return Task::perform( - async { - rfd::AsyncFileDialog::new() - .set_title("Open Image") - .add_filter("All Supported Types", &[ - "png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif", - "avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr", - "hcie", "psd", "kra", - ]) - .add_filter("Images", &[ - "png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff", "tif", - "avif", "ico", "pnm", "qoi", "hdr", "dds", "tga", "exr", - ]) - .add_filter("HCIE Native", &["hcie"]) - .add_filter("Photoshop", &["psd"]) - .add_filter("Krita", &["kra"]) - .pick_file() - .await - .map(|handle| handle.path().to_path_buf()) - }, - Message::FileDialogClosed, - ); + return self.update(Message::OpenFileRfd); } Message::FileOpened(Ok(path)) => { @@ -4646,36 +6266,19 @@ impl HcieIcedApp { } Message::SaveFile => { - return Task::perform( - async { - rfd::AsyncFileDialog::new() - .set_title("Save As") - .add_filter("HCIE Native", &["hcie"]) - .add_filter("Photoshop", &["psd"]) - .add_filter("Krita", &["kra"]) - .add_filter("PNG", &["png"]) - .add_filter("JPEG", &["jpg", "jpeg"]) - .add_filter("WebP", &["webp"]) - .add_filter("AVIF", &["avif"]) - .add_filter("BMP", &["bmp"]) - .add_filter("GIF", &["gif"]) - .add_filter("TIFF", &["tiff", "tif"]) - .add_filter("TGA", &["tga"]) - .add_filter("OpenEXR", &["exr"]) - .add_filter("All Supported Types", &[ - "png", "jpg", "jpeg", "webp", "bmp", "gif", "tiff", "tif", - "avif", "tga", "exr", "psd", "kra", "hcie", - ]) - .save_file() - .await - .map(|handle| handle.path().to_path_buf()) - }, - Message::FileDialogClosed, - ); + if let Some(path) = self.documents[self.active_doc].source_path.clone() { + match self.save_document_to(self.active_doc, &path) { + Ok(()) => self.finish_successful_save(self.active_doc, path), + Err(error) => log::error!("Failed to save: {error}"), + } + } else { + return self.update(Message::SaveFileAs); + } } // ── Menu ───────────────────────────────────── Message::MenuOpen(idx) => { + self.sub_tools_open = false; if self.active_menu == Some(idx) { self.active_menu = None; } else { @@ -4688,150 +6291,100 @@ impl HcieIcedApp { && self.documents[self.active_doc].crop_state.active { self.documents[self.active_doc].crop_state.cancel(); - } else if self.documents[self.active_doc].selection_transform.is_some() { - // Cancel transform if active - let doc = &mut self.documents[self.active_doc]; - if let Some(original) = doc.transform_original.take() { - doc.selection_transform = Some(original); - } else { - doc.selection_transform = None; - } - doc.transform_drag_start = None; - doc.transform_drag_handle = TransformHandle::None; + } else if self.documents[self.active_doc] + .selection_transform + .is_some() + { + return self.update(Message::TransformCancel); } else if self.active_menu.is_some() { self.active_menu = None; } else if self.active_dialog != ActiveDialog::None { self.active_dialog = ActiveDialog::None; } } - Message::OpenRecentFile(idx) => { + Message::MenuCommand(crate::panels::menus::MenuCommand::OpenRecent(path_str)) => { self.active_menu = None; - // Clone the path to avoid borrow conflict - let path_str = self.recent_files.get(idx).map(|e| e.path.clone()); - if let Some(path_str) = path_str { - let path = std::path::PathBuf::from(&path_str); - if path.exists() { - let ext = path.extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - let result = match ext.as_str() { - "psd" => self.documents[self.active_doc].engine.import_psd(&path_str), - "kra" => self.documents[self.active_doc].engine.import_kra(&path_str), - "hcie" => self.documents[self.active_doc].engine.load_native(&path_str), - _ => self.documents[self.active_doc].engine.open_image(&path_str), - }; - match result { - Ok(()) => { - self.documents[self.active_doc].engine.pre_tile_all_layers(); - self.documents[self.active_doc].name = path.file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "Untitled".to_string()); - self.documents[self.active_doc].source_path = Some(path.clone()); - self.add_recent_file(&path); - let composite = self.documents[self.active_doc].engine.get_composite_pixels(); - self.documents[self.active_doc].composite_raw = composite; - self.documents[self.active_doc].composite_pixels = std::sync::Arc::new(self.documents[self.active_doc].composite_raw.clone()); - self.documents[self.active_doc].full_upload.replace(true); - self.documents[self.active_doc].render_generation = self.documents[self.active_doc].render_generation.wrapping_add(1); - self.documents[self.active_doc].cached_layers = self.documents[self.active_doc].engine.layer_infos(); - return Task::perform(async {}, |_| Message::CompositeRefresh); - } - Err(e) => log::error!("Failed to open recent file: {}", e), + let path = std::path::PathBuf::from(&path_str); + if path.exists() { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + let result = match ext.as_str() { + "psd" => self.documents[self.active_doc].engine.import_psd(&path_str), + "kra" => self.documents[self.active_doc].engine.import_kra(&path_str), + "hcie" => self.documents[self.active_doc] + .engine + .load_native(&path_str), + _ => self.documents[self.active_doc].engine.open_image(&path_str), + }; + match result { + Ok(()) => { + self.documents[self.active_doc].engine.pre_tile_all_layers(); + self.documents[self.active_doc].name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "Untitled".to_string()); + self.documents[self.active_doc].source_path = Some(path.clone()); + self.add_recent_file(&path); + let composite = self.documents[self.active_doc] + .engine + .get_composite_pixels(); + self.documents[self.active_doc].composite_raw = composite; + self.documents[self.active_doc].composite_pixels = std::sync::Arc::new( + self.documents[self.active_doc].composite_raw.clone(), + ); + self.documents[self.active_doc].full_upload.replace(true); + self.documents[self.active_doc].render_generation = self.documents + [self.active_doc] + .render_generation + .wrapping_add(1); + self.documents[self.active_doc].cached_layers = + self.documents[self.active_doc].engine.layer_infos(); + return Task::perform(async {}, |_| Message::CompositeRefresh); } - } else { - self.recent_files.retain(|e| e.path != path_str); + Err(e) => log::error!("Failed to open recent file: {}", e), } + } else { + log::error!("Recent file no longer exists: {}", path_str); } } - Message::ClearRecentFiles => { + Message::MenuCommand(crate::panels::menus::MenuCommand::ClearRecent) => { self.active_menu = None; self.recent_files.clear(); save_recent_files(&self.recent_files); } - Message::MenuAction(menu_idx, item_idx) => { + Message::MenuCommand(command) => { + use crate::panels::menus::MenuCommand; self.active_menu = None; - match (menu_idx, item_idx) { - // ══════════════════════════════════════════ - // File menu (0) — Photopea-style - // ══════════════════════════════════════════ - (0, 0) => return Task::perform(async {}, |_| Message::DialogOpen(ActiveDialog::NewImage)), // New... - (0, 1) => return Task::perform(async {}, |_| Message::OpenFileRfd), // Open... - // (0, 2) = Open & Place... - // (0, 3) = Open More (submenu) - // (0, 4) = separator - // (0, 5) = Share - // (0, 6) = separator - (0, 7) => return Task::perform(async {}, |_| Message::SaveFile), // Save - (0, 8) => return Task::perform(async {}, |_| Message::SaveFileAs), // Save as PSD - // (0, 9) = Save More (submenu) - // (0, 10) = Export as (submenu) - (0, 11) => return Task::perform(async {}, |_| Message::SaveFileAs), // Print... - // (0, 12) = separator - // (0, 13) = Export Layers... - // (0, 14) = Export Color Lookup... - // (0, 15) = File Info... - // (0, 16) = separator - // (0, 17) = Automate (submenu) - // (0, 18) = Script... - - // ══════════════════════════════════════════ - // Edit menu (1) — Photopea-style - // ══════════════════════════════════════════ - (1, 0) => return Task::perform(async {}, |_| Message::Undo), // Undo / Redo - (1, 1) => return Task::perform(async {}, |_| Message::Redo), // Step Forward - (1, 2) => return Task::perform(async {}, |_| Message::Undo), // Step Backward - // (1, 3) = separator - // (1, 4) = Fade... - // (1, 5) = separator - (1, 6) => return Task::perform(async {}, |_| Message::CopyImage), // Cut - (1, 7) => return Task::perform(async {}, |_| Message::CopyImage), // Copy - (1, 8) => return Task::perform(async {}, |_| Message::CopyImage), // Copy Merged - (1, 9) => return Task::perform(async {}, |_| Message::PasteImage), // Paste - // (1, 10) = Clear - // (1, 11) = separator - // (1, 12) = Fill... - // (1, 13) = Stroke... - // (1, 14) = separator - // (1, 15) = Content-Aware Scale - // (1, 16) = Puppet Warp - // (1, 17) = Perspective Warp - // (1, 18) = Free Transform - // (1, 19) = Transform (submenu) - // (1, 20) = Auto-Align - // (1, 21) = Auto-Blend - // (1, 22) = separator - // (1, 23) = Assign Profile (submenu) - // (1, 24) = Convert to Profile (submenu) - // (1, 25) = separator - // (1, 26) = Define New (submenu) - // (1, 27) = Preset Manager... - // (1, 28) = Preferences... - // (1, 29) = Local Storage... - - // ══════════════════════════════════════════ - // Image menu (2) — Photopea-style - // ══════════════════════════════════════════ - // (2, 0) = Mode (submenu) - // (2, 1) = Adjustments (submenu) - // (2, 2) = Auto Tone - // (2, 3) = Auto Contrast - // (2, 4) = Auto Color - // (2, 5) = separator - // (2, 6) = Reduce Colors... - // (2, 7) = Vectorize Bitmap... - // (2, 8) = Wavelet Decompose... - // (2, 9) = separator - (2, 10) => { - // Canvas Size... + match command { + MenuCommand::NewDocument => { + return Task::perform(async {}, |_| { + Message::DialogOpen(ActiveDialog::NewImage) + }) + } + MenuCommand::OpenFile => { + return Task::perform(async {}, |_| Message::OpenFileRfd) + } + MenuCommand::Save => return Task::perform(async {}, |_| Message::SaveFile), + MenuCommand::SaveAs => return Task::perform(async {}, |_| Message::SaveFileAs), + MenuCommand::Undo => return Task::perform(async {}, |_| Message::Undo), + MenuCommand::Redo => return Task::perform(async {}, |_| Message::Redo), + MenuCommand::Copy => return Task::perform(async {}, |_| Message::CopyImage), + MenuCommand::Cut => return Task::perform(async {}, |_| Message::CutImage), + MenuCommand::ClearPixels => { + return Task::perform(async {}, |_| Message::ClearPixels) + } + MenuCommand::Paste => return Task::perform(async {}, |_| Message::PasteImage), + MenuCommand::CanvasSize => { let (w, h) = self.documents[self.active_doc].engine.get_canvas_size(); self.dialog_canvas_size_width = w; self.dialog_canvas_size_height = h; self.dialog_canvas_size_anchor = (1, 1); self.active_dialog = ActiveDialog::CanvasSize; } - (2, 11) => { - // Image Size... + MenuCommand::ImageSize => { let (w, h) = self.documents[self.active_doc].engine.get_canvas_size(); self.dialog_image_size_width = w; self.dialog_image_size_height = h; @@ -4839,187 +6392,110 @@ impl HcieIcedApp { self.dialog_image_size_constrain = true; self.active_dialog = ActiveDialog::ImageSize; } - // (2, 12) = Transform (submenu) - // (2, 13) = Crop - // (2, 14) = Trim... - // (2, 15) = Reveal All - // (2, 16) = separator - // (2, 17) = Duplicate - // (2, 18) = Apply Image... - // (2, 19) = separator - // (2, 20) = Variables... - - // ══════════════════════════════════════════ - // Layer menu (3) — Photopea-style - // ══════════════════════════════════════════ - (3, 0) => return Task::perform(async {}, |_| Message::LayerAdd), // New (submenu) - // (3, 1) = Duplicate Layer — TODO: engine doesn't support yet - // (3, 2) = Duplicate Into... - (3, 3) => { // Delete + MenuCommand::AddLayer => return Task::perform(async {}, |_| Message::LayerAdd), + MenuCommand::DeleteLayer => { let id = self.documents[self.active_doc].engine.active_layer_id(); self.documents[self.active_doc].engine.delete_layer(id); } - // (3, 4) = separator - // (3, 5) = Text (submenu) - (3, 6) => return Task::perform(async {}, |_| Message::OpenLayerStyleDialog), // Layer Style (submenu) - // (3, 7) = separator - // (3, 8) = New Fill Layer (submenu) - // (3, 9) = New Adjustment Layer (submenu) - // (3, 10) = separator - // (3, 11) = Raster Mask (submenu) - // (3, 12) = Vector Mask (submenu) - // (3, 13) = Clipping Mask (disabled) - // (3, 14) = separator - // (3, 15) = Smart Object (submenu) - // (3, 16) = Rasterize - // (3, 17) = Rasterize Layer Style - // (3, 18) = separator - // (3, 19) = Group Layers - // (3, 20) = Arrange (submenu) - // (3, 21) = Combine Shapes (submenu) - // (3, 22) = Animation (submenu) - // (3, 23) = separator - (3, 24) => return Task::perform(async {}, |_| Message::LayerMergeDown), // Merge Down - (3, 25) => return Task::perform(async {}, |_| Message::LayerFlatten), // Flatten Image - // (3, 26) = Defringe - - // ══════════════════════════════════════════ - // Select menu (4) — Photopea-style - // ══════════════════════════════════════════ - (4, 0) => { // All + MenuCommand::LayerStyles => { + return Task::perform(async {}, |_| Message::OpenLayerStyleDialog) + } + MenuCommand::MergeDown => { + return Task::perform(async {}, |_| Message::LayerMergeDown) + } + MenuCommand::Flatten => { + return Task::perform(async {}, |_| Message::LayerFlatten) + } + MenuCommand::SelectAll => { let w = self.documents[self.active_doc].engine.canvas_width() as f32; let h = self.documents[self.active_doc].engine.canvas_height() as f32; self.documents[self.active_doc].selection_rect = Some((0.0, 0.0, w, h)); } - (4, 1) => { self.documents[self.active_doc].selection_rect = None; } // Deselect - (4, 2) => { // Inverse + MenuCommand::Deselect => { + self.documents[self.active_doc].selection_rect = None; + } + MenuCommand::InvertSelection => { self.documents[self.active_doc].engine.selection_invert(); self.refresh_composite_if_needed(); return Task::perform(async {}, |_| Message::CompositeRefresh); } - // (4, 3) = separator - // (4, 4) = Remove BG - // (4, 5) = Color Range... - // (4, 6) = Magic Cut... - // (4, 7) = Subject - // (4, 8) = separator - // (4, 9) = Refine Edge... - // (4, 10) = Modify (submenu) - (4, 11) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Grow"); } // Grow - (4, 12) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Shrink"); } // Shrink - (4, 13) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Feather"); } // Feather - (4, 14) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Border"); } // Border - (4, 15) => { self.dialog_selection_value = 5.0; self.active_dialog = ActiveDialog::SelectionOp("Smooth"); } // Smooth - // (4, 16) = separator - // (4, 17) = Similar - // (4, 18) = separator - // (4, 19) = Transform Selection - // (4, 20) = Quick Mask Mode - // (4, 21) = separator - // (4, 22) = Load Selection - // (4, 23) = Save Selection - - // ══════════════════════════════════════════ - // Filter menu (5) — Photopea-style - // ══════════════════════════════════════════ - // (5, 0) = Last Filter - // (5, 1) = separator - // (5, 2) = Filter Gallery... - // (5, 3) = Lens Correction... - // (5, 4) = Camera Raw... - // (5, 5) = Liquify... - // (5, 6) = Vanishing Point... - // (5, 7) = separator - // (5, 8) = 3D (submenu) - // (5, 9) = Blur (submenu) - // (5, 10) = Blur Gallery (submenu) - // (5, 11) = Distort (submenu) - // (5, 12) = Noise (submenu) - // (5, 13) = Pixelate (submenu) - // (5, 14) = Render (submenu) - // (5, 15) = Sharpen (submenu) - // (5, 16) = Stylize (submenu) - // (5, 17) = Other (submenu) - // (5, 18) = Fourier (submenu) - - // ══════════════════════════════════════════ - // View menu (6) — Photopea-style - // ══════════════════════════════════════════ - (6, 0) => return Task::perform(async {}, |_| Message::CanvasZoomRelative(1.1)), // Zoom In - (6, 1) => return Task::perform(async {}, |_| Message::CanvasZoomRelative(1.0 / 1.1)), // Zoom Out - (6, 2) => { // Fit The Area - self.documents[self.active_doc].zoom = 1.0; + MenuCommand::GrowSelection => { + self.dialog_selection_value = 5.0; + self.active_dialog = ActiveDialog::SelectionOp("Grow"); + } + MenuCommand::ShrinkSelection => { + self.dialog_selection_value = 5.0; + self.active_dialog = ActiveDialog::SelectionOp("Shrink"); + } + MenuCommand::FeatherSelection => { + self.dialog_selection_value = 5.0; + self.active_dialog = ActiveDialog::SelectionOp("Feather"); + } + MenuCommand::BorderSelection => { + self.dialog_selection_value = 5.0; + self.active_dialog = ActiveDialog::SelectionOp("Border"); + } + MenuCommand::SmoothSelection => { + self.dialog_selection_value = 5.0; + self.active_dialog = ActiveDialog::SelectionOp("Smooth"); + } + MenuCommand::SelectionMode(mode) => { + return Task::perform(async move {}, move |_| { + Message::SelectionModeChanged(mode) + }) + } + MenuCommand::SaveSelection => { + return Task::perform(async {}, |_| Message::SelectionSave) + } + MenuCommand::LoadSelection => { + return Task::perform(async {}, |_| Message::SelectionLoad) + } + MenuCommand::ToggleQuickMask => { + return Task::perform(async {}, |_| Message::QuickMaskToggle) + } + MenuCommand::ZoomIn => { + return Task::perform(async {}, |_| Message::CanvasZoomRelative(1.1)) + } + MenuCommand::ZoomOut => { + return Task::perform(async {}, |_| Message::CanvasZoomRelative(1.0 / 1.1)) + } + MenuCommand::FitArea => { + let doc = &mut self.documents[self.active_doc]; + let fit_x = (doc.pane_size.0 - 24.0) / doc.engine.canvas_width() as f32; + let fit_y = (doc.pane_size.1 - 24.0) / doc.engine.canvas_height() as f32; + doc.zoom = fit_x.min(fit_y).clamp(ZOOM_MIN, ZOOM_MAX); + doc.pan_offset = Vector::ZERO; + } + MenuCommand::ActualPixels => { + return Task::perform(async {}, |_| Message::CanvasZoomSet(1.0)) + } + MenuCommand::SelectCropTool => { + return Task::perform(async {}, |_| Message::ToolSelected(Tool::Crop)) + } + MenuCommand::GaussianBlur => { + return Task::perform(async {}, |_| { + Message::FilterSelect(hcie_engine_api::FilterType::GaussianBlur) + }) + } + MenuCommand::Mosaic => { + return Task::perform(async {}, |_| { + Message::FilterSelect(hcie_engine_api::FilterType::Mosaic) + }) + } + MenuCommand::UnsharpMask => { + return Task::perform(async {}, |_| { + Message::FilterSelect(hcie_engine_api::FilterType::UnsharpMask) + }) + } + MenuCommand::TogglePane(pane) => self.dock.toggle_pane(pane), + MenuCommand::SetTheme(theme) => { + self.theme_state.set_preset(theme); + self.settings.theme_preset = theme; + let _ = self.settings.save(); + } + MenuCommand::OpenRecent(_) | MenuCommand::ClearRecent => { + unreachable!("handled before general menu dispatch") } - (6, 3) => return Task::perform(async {}, |_| Message::CanvasZoomSet(1.0)), // Pixel to Pixel (100%) - // (6, 4) = Pattern Preview - // (6, 5) = separator - // (6, 6) = Mode (submenu) - // (6, 7) = Extras - // (6, 8) = Show (submenu) - // (6, 9) = separator - // (6, 10) = Rulers - // (6, 11) = separator - // (6, 12) = Snap - // (6, 13) = Snap To (submenu) - // (6, 14) = separator - // (6, 15) = Lock Guides - // (6, 16) = Clear Guides - // (6, 17) = Add Guides... - // (6, 18) = Guides from Layer - // (6, 19) = separator - // (6, 20) = Clear Slices - - // ══════════════════════════════════════════ - // Window menu (7) — Photopea-style - // ══════════════════════════════════════════ - (7, 0) => self.dock.toggle_pane(PaneType::Layers), - (7, 1) => self.dock.toggle_pane(PaneType::History), - (7, 2) => self.dock.toggle_pane(PaneType::Brushes), - (7, 3) => self.dock.toggle_pane(PaneType::Filters), - (7, 4) => self.dock.toggle_pane(PaneType::ColorPicker), - (7, 5) => self.dock.toggle_pane(PaneType::Properties), - (7, 6) => self.dock.toggle_pane(PaneType::AiChat), - (7, 7) => self.dock.toggle_pane(PaneType::Geometry), - (7, 8) => self.dock.toggle_pane(PaneType::Script), - (7, 9) => self.dock.toggle_pane(PaneType::LayerDetails), - (7, 10) => self.dock.toggle_pane(PaneType::AiScript), - // (7, 11) = separator - (7, 12) => return Task::perform(async {}, |_| Message::ThemeChanged(crate::theme::ThemePreset::Photopea)), - (7, 13) => return Task::perform(async {}, |_| Message::ThemeChanged(crate::theme::ThemePreset::Photoshop)), - (7, 14) => return Task::perform(async {}, |_| Message::ThemeChanged(crate::theme::ThemePreset::ProDark)), - (7, 15) => return Task::perform(async {}, |_| Message::ThemeChanged(crate::theme::ThemePreset::Amoled)), - (7, 16) => return Task::perform(async {}, |_| Message::ThemeChanged(crate::theme::ThemePreset::PhotoshopLight)), - (7, 17) => return Task::perform(async {}, |_| Message::ThemeChanged(crate::theme::ThemePreset::ProLight)), - - // ══════════════════════════════════════════ - // More menu (8) — Photopea-style - // ══════════════════════════════════════════ - // (8, 0) = More (submenu) - // (8, 1) = separator - // (8, 2) = Plugins - // (8, 3) = Actions - // (8, 4) = Adjustments - // (8, 5) = Brush - // (8, 6) = Channels - // (8, 7) = Character - // (8, 8) = Character Styles - // (8, 9) = Color - // (8, 10) = Glyphs - // (8, 11) = Histogram - // (8, 12) = History - // (8, 13) = Info - // (8, 14) = Navigator - // (8, 15) = Notes - // (8, 16) = Paragraph - // (8, 17) = Paths - // (8, 18) = Properties - // (8, 19) = Style - // (8, 20) = Swatches - // (8, 21) = Tool Presets - // (8, 22) = Vector Info - - // Ignore separators, submenus, and unimplemented items - _ => {} } } @@ -5040,9 +6516,11 @@ impl HcieIcedApp { } } Message::WindowClose => { - // Check if any document has unsaved changes - let has_unsaved = self.documents.iter().any(|d| d.modified); - if has_unsaved { + self.pending_document_close = None; + if let Some(document_index) = + self.documents.iter().position(|document| document.modified) + { + self.active_doc = document_index; self.active_dialog = ActiveDialog::CloseConfirm; } else if let Some(id) = self.window_id { return iced::window::close(id); @@ -5050,9 +6528,60 @@ impl HcieIcedApp { } Message::WindowId(id) => { self.window_id = Some(id); + if self.screenshot_request.is_some() { + return Task::perform( + async { + std::thread::sleep(std::time::Duration::from_millis(750)); + }, + |_| Message::ScreenshotCapture, + ); + } + } + Message::ScreenshotCapture => { + if self.screenshot_request.is_none() { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs()); + self.screenshot_request = Some(crate::cli::ScreenshotRequest { + panel: None, + output: std::path::PathBuf::from(format!( + "target/screenshots/iced_{timestamp}.png" + )), + }); + } + if let Some(id) = self.window_id { + return iced::window::screenshot(id).map(Message::ScreenshotReady); + } + log::error!("cannot capture screenshot before the window is initialized"); + } + Message::ScreenshotReady(screenshot) => { + if let Some(request) = self.screenshot_request.take() { + let captured = if let Some(panel) = request.panel.as_deref() { + crate::screenshot::crop_panel( + &screenshot, + &self.dock, + panel, + self.sidebar_expanded, + ) + } else { + Ok(screenshot) + }; + if let Err(error) = captured.and_then(|captured| { + crate::screenshot::save_png(&captured, &request.output) + }) { + log::error!("{error}"); + } else { + log::info!("screenshot saved to {}", request.output.display()); + } + if let Some(id) = self.window_id { + return iced::window::close(id); + } + } } Message::ThemeChanged(preset) => { self.theme_state.set_preset(preset); + self.settings.theme_preset = preset; + let _ = self.settings.save(); } // ── Image Viewer ───────────────────────────────── @@ -5060,14 +6589,18 @@ impl HcieIcedApp { self.viewer_active = !self.viewer_active; if self.viewer_active && self.viewer_state.current_dir.as_os_str().is_empty() { // Initialize to home directory on first open - let start_dir = dirs::home_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")); + let start_dir = + dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); self.viewer_state = crate::viewer::ViewerState::new(start_dir); } if self.viewer_active { self.viewer_state.load_preview(); } } + Message::ViewerSetMode(mode) => { + self.viewer_state.view_mode = mode; + self.viewer_state.load_preview(); + } Message::ViewerNavigate(path) => { self.viewer_state.set_dir(path); self.viewer_state.load_preview(); @@ -5081,42 +6614,63 @@ impl HcieIcedApp { } } Message::ViewerOpenFile(path) => { - // Open the file in the editor — exit viewer and open the file - self.viewer_active = false; + // Open into a new editor document so browsing never overwrites a dirty canvas. + let _ = self.update(Message::NewDocument(1, 1)); + let document_index = self.documents.len() - 1; let path_str = path.to_string_lossy().to_string(); - let ext = path.extension() + let ext = path + .extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_lowercase(); let result = match ext.as_str() { - "psd" => self.documents[0].engine.import_psd(&path_str), - "kra" => self.documents[0].engine.import_kra(&path_str), - "hcie" => self.documents[0].engine.load_native(&path_str), - _ => self.documents[0].engine.open_image(&path_str), + "psd" => self.documents[document_index].engine.import_psd(&path_str), + "kra" => self.documents[document_index].engine.import_kra(&path_str), + "hcie" => self.documents[document_index].engine.load_native(&path_str), + _ => self.documents[document_index].engine.open_image(&path_str), }; match result { Ok(()) => { - self.documents[0].engine.pre_tile_all_layers(); - self.documents[0].name = path.file_name() + self.viewer_active = false; + self.active_doc = document_index; + self.documents[document_index].engine.pre_tile_all_layers(); + self.documents[document_index].name = path + .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "Untitled".to_string()); - self.documents[0].source_path = Some(path.clone()); + self.documents[document_index].source_path = Some(path.clone()); self.add_recent_file(&path); - let composite = self.documents[0].engine.get_composite_pixels(); - self.documents[0].composite_raw = composite; - self.documents[0].composite_pixels = std::sync::Arc::new(self.documents[0].composite_raw.clone()); - self.documents[0].full_upload.replace(true); - self.documents[0].render_generation = self.documents[0].render_generation.wrapping_add(1); - self.documents[0].cached_layers = self.documents[0].engine.layer_infos(); - let history_len = self.documents[0].engine.history_len(); - self.documents[0].cached_history = (0..history_len) - .filter_map(|i| self.documents[0].engine.history_description(i).map(|d| (i, d))) + let composite = + self.documents[document_index].engine.get_composite_pixels(); + self.documents[document_index].composite_raw = composite; + self.documents[document_index].composite_pixels = std::sync::Arc::new( + self.documents[document_index].composite_raw.clone(), + ); + self.documents[document_index].full_upload.replace(true); + self.documents[document_index].render_generation = self.documents + [document_index] + .render_generation + .wrapping_add(1); + self.documents[document_index].cached_layers = + self.documents[document_index].engine.layer_infos(); + let history_len = self.documents[document_index].engine.history_len(); + self.documents[document_index].cached_history = (0..history_len) + .filter_map(|i| { + self.documents[document_index] + .engine + .history_description(i) + .map(|d| (i, d)) + }) .collect(); - self.documents[0].history_current = self.documents[0].engine.history_current(); + self.documents[document_index].history_current = + self.documents[document_index].engine.history_current(); return Task::perform(async {}, |_| Message::CompositeRefresh); } Err(e) => { + self.documents.remove(document_index); log::error!("Failed to open file from viewer: {}", e); + self.viewer_state.load_error = + Some(format!("Could not edit {}: {e}", path.display())); } } } @@ -5127,11 +6681,12 @@ impl HcieIcedApp { Message::ViewerFullscreen(fs) => { self.viewer_state.fullscreen = fs; if let Some(id) = self.window_id { - if fs { - return iced::window::change_level(id, iced::window::Level::AlwaysOnTop); + let mode = if fs { + iced::window::Mode::Fullscreen } else { - return iced::window::change_level(id, iced::window::Level::Normal); - } + iced::window::Mode::Windowed + }; + return iced::window::change_mode(id, mode); } } Message::ViewerPrev => { @@ -5149,12 +6704,18 @@ impl HcieIcedApp { Message::ViewerEnter => { if self.viewer_active && !self.viewer_state.images.is_empty() { let path = self.viewer_state.images[self.viewer_state.active_image_idx].clone(); - return Task::perform(async move {}, move |_| Message::ViewerOpenFile(path.clone())); + return Task::perform(async move {}, move |_| { + Message::ViewerOpenFile(path.clone()) + }); } } Message::SidebarToggleExpanded => { self.sidebar_expanded = !self.sidebar_expanded; + self.settings.panel_layout.sidebar_expanded = self.sidebar_expanded; + self.settings.panel_layout.sidebar_width = + if self.sidebar_expanded { 72.0 } else { 36.0 }; + let _ = self.settings.save(); } Message::NoOp => {} @@ -5177,13 +6738,32 @@ impl HcieIcedApp { Message::EscapePressed => { // Mirrors egui's Escape handling. Priority: viewer > crop > // transform > vector selection > selection > dialog > viewer fallback. + match overlay_escape_target(self.sub_tools_open, self.active_menu.is_some()) { + Some(OverlayEscapeTarget::Subtools) => { + self.sub_tools_open = false; + return Task::none(); + } + Some(OverlayEscapeTarget::Menu) => { + self.active_menu = None; + return Task::none(); + } + None => {} + } if self.viewer_active { return self.update(Message::ViewerToggle); + } else if self.sub_tools_open || self.active_menu.is_some() { + return Task::none(); } else if self.documents[self.active_doc].crop_state.active { return self.update(Message::CropCancel); - } else if self.documents[self.active_doc].selection_transform.is_some() { + } else if self.documents[self.active_doc] + .selection_transform + .is_some() + { return self.update(Message::TransformCancel); - } else if self.documents[self.active_doc].selected_vector_shape.is_some() { + } else if self.documents[self.active_doc] + .selected_vector_shape + .is_some() + { return self.update(Message::VectorShapeCancel); } else if self.documents[self.active_doc].selection_bounds.is_some() || self.documents[self.active_doc].selection_rect.is_some() @@ -5199,11 +6779,17 @@ impl HcieIcedApp { // Mirrors egui's Enter: crop > transform > text-commit > vector-apply > viewer. if self.documents[self.active_doc].crop_state.active { return self.update(Message::CropConfirm); - } else if self.documents[self.active_doc].selection_transform.is_some() { + } else if self.documents[self.active_doc] + .selection_transform + .is_some() + { return self.update(Message::TransformApply); } else if self.active_document_mut().text_draft.is_some() { return self.update(Message::TextCommit); - } else if self.documents[self.active_doc].selected_vector_shape.is_some() { + } else if self.documents[self.active_doc] + .selected_vector_shape + .is_some() + { return self.update(Message::VectorShapeApply); } else { return self.update(Message::ViewerEnter); @@ -5212,6 +6798,9 @@ impl HcieIcedApp { Message::ModifiersChanged(modifiers) => { self.modifiers = modifiers; } + Message::SpacePanChanged(held) => { + self.tool_state.space_pan = held; + } } // Persist colors to disk when they have changed (debounced via the @@ -5260,6 +6849,7 @@ impl HcieIcedApp { // Title bar (now includes menu items and window controls) let title_bar = panels::title_bar::view( &doc.name, + doc.modified, doc.zoom, doc.engine.canvas_width(), doc.engine.canvas_height(), @@ -5301,14 +6891,9 @@ impl HcieIcedApp { colors, ); - let content = column![ - title_bar, - toolbar, - main_area, - status_bar, - ] - .width(Length::Fill) - .height(Length::Fill); + let content = column![title_bar, toolbar, main_area, status_bar,] + .width(Length::Fill) + .height(Length::Fill); let content = container(content) .width(Length::Fill) @@ -5326,27 +6911,32 @@ impl HcieIcedApp { self.dialog_new_width, self.dialog_new_height, self.dialog_new_transparent, + colors, ), ActiveDialog::BrightnessContrast => dialogs::adjustments::brightness_contrast_view( self.dialog_brightness, self.dialog_contrast, + colors, ), ActiveDialog::HueSaturation => dialogs::adjustments::hsl_view( self.dialog_hue, self.dialog_saturation, self.dialog_lightness, + colors, ), - ActiveDialog::CloseConfirm => dialogs::confirm::close_confirm_view(&doc.name), + ActiveDialog::CloseConfirm => dialogs::confirm::close_confirm_view(&doc.name, colors), ActiveDialog::SelectionOp(op) => dialogs::confirm::selection_op_view( op, self.dialog_selection_value, 1.0, 100.0, + colors, ), ActiveDialog::LayerStyleDialog => { let doc = &self.documents[self.active_doc]; let styles = doc.engine.get_layer_styles(doc.engine.active_layer_id()); - let styles: &'static [hcie_engine_api::LayerStyle] = Box::leak(styles.into_boxed_slice()); + let styles: &'static [hcie_engine_api::LayerStyle] = + Box::leak(styles.into_boxed_slice()); crate::panels::layer_styles::view( styles, self.selected_style, @@ -5361,14 +6951,16 @@ impl HcieIcedApp { self.dialog_image_size_width, self.dialog_image_size_height, self.dialog_image_size_constrain, + colors, ), ActiveDialog::CanvasSize => dialogs::canvas_size::view( self.dialog_canvas_size_width, self.dialog_canvas_size_height, self.dialog_canvas_size_anchor.0, self.dialog_canvas_size_anchor.1, + colors, ), - ActiveDialog::About => dialogs::about::view(), + ActiveDialog::About => dialogs::about::view(colors), }; // Stack main content with overlays (dialog + menu dropdown + dock profile dialog). @@ -5378,8 +6970,10 @@ impl HcieIcedApp { let has_menu = self.active_menu.is_some(); let has_dock_profile_dialog = self.show_dock_profile_dialog; let has_context_menu = self.canvas_context_menu.is_some(); + let has_color_picker = self.color_picker_target.is_some(); - if has_dialog || has_menu || has_dock_profile_dialog || has_context_menu { + if has_dialog || has_menu || has_dock_profile_dialog || has_context_menu || has_color_picker + { let mut stack = iced::widget::Stack::new().push(content); if has_dialog { stack = stack.push(dialog_overlay); @@ -5389,9 +6983,16 @@ impl HcieIcedApp { &self.settings.dock_profiles, self.active_dock_profile.as_deref(), &self.dock_profile_new_name, + colors, )); } - if let Some(menu_overlay) = panels::menus::dropdown_overlay(self.active_menu, &self.recent_files, self.theme_state.colors(), Some(&self.dock)) { + if let Some(menu_overlay) = panels::menus::dropdown_overlay( + self.active_menu, + &self.recent_files, + self.theme_state.colors(), + Some(&self.dock), + self.settings.window.width, + ) { stack = stack.push(menu_overlay); } if let Some((cx, cy)) = self.canvas_context_menu { @@ -5402,6 +7003,14 @@ impl HcieIcedApp { self.theme_state.colors(), )); } + if has_color_picker { + stack = stack.push(crate::color_picker::popup_view( + self.popup_color, + &self.popup_color_hex, + self.popup_color_tab, + colors, + )); + } let _elapsed = view_start.elapsed(); // Performance log measuring Elm view reconstruction time. // useful to track widget layout allocation and view model reconstruction times. @@ -5419,7 +7028,11 @@ impl HcieIcedApp { /// Return the application theme. pub fn theme(&self) -> Theme { - Theme::Dark + if self.theme_state.colors().is_light { + Theme::Light + } else { + Theme::Dark + } } /// Subscriptions — keyboard events for shortcuts. @@ -5433,7 +7046,6 @@ impl HcieIcedApp { /// - Ctrl+O: Open /// - Ctrl+C: Copy /// - Ctrl+V: Paste - /// - Ctrl+X: Cut /// - Ctrl+A: Select All /// - Ctrl+D: Deselect /// - Ctrl+Shift+I: Inverse Selection @@ -5489,9 +7101,8 @@ impl HcieIcedApp { iced::keyboard::Key::Character(ref c) if c.as_str() == "v" && ctrl && !shift => { Some(Message::PasteImage) } - // Ctrl+X = Cut iced::keyboard::Key::Character(ref c) if c.as_str() == "x" && ctrl && !shift => { - Some(Message::CopyImage) // TODO: cut + Some(Message::CutImage) } // Ctrl+A = Select All iced::keyboard::Key::Character(ref c) if c.as_str() == "a" && ctrl && !shift => { @@ -5506,16 +7117,18 @@ impl HcieIcedApp { Some(Message::SelectInverse) } // Ctrl++ = Zoom In - iced::keyboard::Key::Character(ref c) if (c.as_str() == "=" || c.as_str() == "+") && ctrl && !shift => { + iced::keyboard::Key::Character(ref c) + if (c.as_str() == "=" || c.as_str() == "+") && ctrl && !shift => + { Some(Message::CanvasZoomRelative(1.1)) } // Ctrl+- = Zoom Out iced::keyboard::Key::Character(ref c) if c.as_str() == "-" && ctrl && !shift => { Some(Message::CanvasZoomRelative(1.0 / 1.1)) } - // Ctrl+0 = Fit to Screen (reset zoom to 100%) + // Ctrl+0 = true fit to the measured canvas pane. iced::keyboard::Key::Character(ref c) if c.as_str() == "0" && ctrl && !shift => { - Some(Message::CanvasZoomSet(1.0)) + Some(Message::FitToArea) } // Ctrl+1 = 100% Zoom iced::keyboard::Key::Character(ref c) if c.as_str() == "1" && ctrl && !shift => { @@ -5583,6 +7196,13 @@ impl HcieIcedApp { iced::keyboard::Key::Character(ref c) if c.as_str() == "x" && !ctrl && !shift => { Some(Message::SwapColors) } + iced::keyboard::Key::Character(ref c) if c.as_str() == "q" && !ctrl && !shift => { + Some(Message::QuickMaskToggle) + } + // Shift+Tab toggles the expanded, scrollable toolbox. + iced::keyboard::Key::Named(iced::keyboard::key::Named::Tab) if shift && !ctrl => { + Some(Message::SidebarToggleExpanded) + } // [ = Decrease brush size (egui: app/mod.rs:3001). The closure // must be a non-capturing fn, so we emit a relative delta and // apply it in update(). @@ -5597,19 +7217,17 @@ impl HcieIcedApp { // reserved — egui maps Enter to crop confirm). // ── Named keys ─────────────────────────────── - // Delete = Delete selected vector shape, or clear selection + // Delete clears selected raster pixels; vector deletion remains available on-canvas. iced::keyboard::Key::Named(iced::keyboard::key::Named::Delete) => { - Some(Message::VectorShapeDelete) + Some(Message::ClearPixels) } // F11 = Fullscreen toggle iced::keyboard::Key::Named(iced::keyboard::key::Named::F11) => { Some(Message::WindowMaximize) } - // F12 = Full screenshot (egui: app/mod.rs:3015). We re-paint - // by forcing a composite refresh; saving to disk is handled by - // the existing screenshot path if present. + // F12 captures the native Iced viewport through the same path used by CLI audits. iced::keyboard::Key::Named(iced::keyboard::key::Named::F12) => { - Some(Message::CompositeRefresh) + Some(Message::ScreenshotCapture) } // Tab = Toggle panel visibility (egui: app/mod.rs:3011). We // toggle the dock profile dialog open/closed as a stand-in. @@ -5641,13 +7259,12 @@ impl HcieIcedApp { // Mouse events for dialog dragging, plus touch/pen position feed to the // shared tablet state so evdev-less touchscreens still update position. - let mouse = iced::event::listen_with(|event, _status, _id| { + let mouse = iced::event::listen_with(|event, status, _id| { match event { iced::Event::Mouse(mouse_event) => match mouse_event { - iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left) => { - // Drag start is handled by mouse_area on_press - None - } + iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left) => (status + == iced::event::Status::Ignored) + .then_some(Message::OverlayOutsideClick), iced::mouse::Event::CursorMoved { position } => { Some(Message::LayerStyleDialogDragMove(position.x, position.y)) } @@ -5671,24 +7288,34 @@ impl HcieIcedApp { }) } iced::touch::Event::FingerLifted { .. } - | iced::touch::Event::FingerLost { .. } => { - Some(Message::TabletTouch { - x: 0.0, - y: 0.0, - pressed: false, - }) - } + | iced::touch::Event::FingerLost { .. } => Some(Message::TabletTouch { + x: 0.0, + y: 0.0, + pressed: false, + }), } } iced::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) => { Some(Message::ModifiersChanged(modifiers)) } + iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { + key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Space), + .. + }) => Some(Message::SpacePanChanged(true)), + iced::Event::Keyboard(iced::keyboard::Event::KeyReleased { + key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Space), + .. + }) => Some(Message::SpacePanChanged(false)), _ => None, } }); // Timer for marching ants animation - fires every 50ms when selection is active - let marching_ants = if self.documents.iter().any(|doc| doc.selection_bounds.is_some()) { + let marching_ants = if self + .documents + .iter() + .any(|doc| doc.selection_bounds.is_some()) + { iced::Subscription::run_with_id( std::time::Instant::now(), iced::futures::stream::unfold((), |_| async { @@ -5706,6 +7333,131 @@ impl HcieIcedApp { } } +/// Identifies which transient overlay consumes Escape before editor-level cancellation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OverlayEscapeTarget { + Subtools, + Menu, +} + +/// Resolves Escape priority for transient navigation surfaces. +/// +/// **Arguments:** `subtools_open` and `menu_open` describe the two transient overlays. +/// **Returns:** The single overlay to close, preferring the more specific subtool popup. +/// **Logic & Workflow:** Popup priority is stable even if inconsistent state briefly exposes both. +/// **Side Effects / Dependencies:** None. +fn overlay_escape_target(subtools_open: bool, menu_open: bool) -> Option { + if subtools_open { + Some(OverlayEscapeTarget::Subtools) + } else if menu_open { + Some(OverlayEscapeTarget::Menu) + } else { + None + } +} + +/// Semantic result of pressing one document tab's close control. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DocumentCloseDisposition { + Invalid, + Window, + Confirm, + Close, +} + +/// Resolves tab-close behavior without mutating application state. +/// +/// Arguments: open document count, requested index, and modified state. Returns: whether to ignore, +/// delegate to window close, confirm, or remove immediately. Side Effects: None. +fn document_close_disposition( + document_count: usize, + document_index: usize, + modified: bool, +) -> DocumentCloseDisposition { + if document_index >= document_count { + DocumentCloseDisposition::Invalid + } else if document_count == 1 { + DocumentCloseDisposition::Window + } else if modified { + DocumentCloseDisposition::Confirm + } else { + DocumentCloseDisposition::Close + } +} + +/// Computes a safe active index after one tab has been removed. +/// +/// Arguments: prior active index, removed index, and remaining document count. Returns: a valid +/// remaining index. Logic & Workflow: shifts indices after the removed tab and selects the nearest +/// surviving tab when the active one closes. Side Effects: None. +fn active_index_after_document_close( + active_index: usize, + removed_index: usize, + remaining_count: usize, +) -> usize { + debug_assert!(remaining_count > 0); + if active_index > removed_index { + active_index - 1 + } else if active_index == removed_index { + removed_index.min(remaining_count - 1) + } else { + active_index.min(remaining_count - 1) + } +} + +#[cfg(test)] +mod cycle_one_ux_tests { + use super::{ + active_index_after_document_close, document_close_disposition, overlay_escape_target, + DocumentCloseDisposition, OverlayEscapeTarget, + }; + + /// Confirms Escape dismisses subtools before menus and leaves editor cancellation untouched. + #[test] + fn popup_and_menu_escape_priority_is_stable() { + assert_eq!( + overlay_escape_target(true, true), + Some(OverlayEscapeTarget::Subtools) + ); + assert_eq!( + overlay_escape_target(false, true), + Some(OverlayEscapeTarget::Menu) + ); + assert_eq!(overlay_escape_target(false, false), None); + } + + /// Confirms dirty tabs require confirmation, clean tabs close directly, and the final tab uses + /// the existing whole-window confirmation path. + #[test] + fn document_tab_close_uses_semantic_continuations() { + assert_eq!( + document_close_disposition(3, 1, true), + DocumentCloseDisposition::Confirm + ); + assert_eq!( + document_close_disposition(3, 1, false), + DocumentCloseDisposition::Close + ); + assert_eq!( + document_close_disposition(1, 0, false), + DocumentCloseDisposition::Window + ); + assert_eq!( + document_close_disposition(2, 2, false), + DocumentCloseDisposition::Invalid + ); + } + + /// Confirms closing tabs before, at, and after the active tab never leaves a stale index. + #[test] + fn document_tab_close_keeps_active_index_safe() { + assert_eq!(active_index_after_document_close(2, 0, 2), 1); + assert_eq!(active_index_after_document_close(1, 1, 2), 1); + assert_eq!(active_index_after_document_close(2, 2, 2), 1); + assert_eq!(active_index_after_document_close(0, 2, 2), 0); + } +} + /// Find the topmost text layer whose rasterized pixels contain the point /// `(x, y)` in canvas coordinates. fn find_text_layer_at(engine: &hcie_engine_api::Engine, x: f32, y: f32) -> Option { diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/brush_import.rs b/hcie-iced-app/crates/hcie-iced-gui/src/brush_import.rs index fc42ab8..10d590b 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/brush_import.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/brush_import.rs @@ -265,7 +265,8 @@ fn harvest_brs2_sampled_brushes(data: &[u8], base_pos: usize) -> Vec>, } impl SelectionEdgeCache { + /// Returns cached edges only when mask content and dimensions still match. pub fn get(&self, mask: &[u8]) -> Option>> { - 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> { 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, diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs index f90c460..5637a55 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs @@ -19,19 +19,20 @@ //! - Only dirty sub-regions are uploaded — never the full 33MB buffer //! - The checkerboard is procedural (in the fragment shader) — no CPU geometry -pub mod shader_canvas; pub mod edge_cache; +pub mod shader_canvas; // pub mod viewport; // pub mod render; use crate::app::Message; use crate::selection::{CropState, SelectionTransform, TransformHandle}; -use iced::widget::{canvas, column, container, row, text, Stack}; +use iced::mouse::{self, Cursor}; use iced::widget::canvas::{Frame, Path, Stroke}; use iced::widget::Shader; +use iced::widget::{canvas, column, container, row, text, Stack}; use iced::{Element, Length, Point, Rectangle, Size, Vector}; -use iced::mouse::{self, Cursor}; +use hcie_engine_api::{ZOOM_MAX, ZOOM_MIN}; use shader_canvas::CanvasShaderProgram; // ─── Overlay (selection rect, vector preview, crosshair) ───────────────────── @@ -78,6 +79,12 @@ struct OverlayProgram { gradient_drag: Option<((f32, f32), (f32, f32))>, /// Extracted edges for marching ants rendering. marching_ants_edges: Option>>, + /// Exact horizontal selected-pixel spans for irregular selection fill. + selection_fill_spans: Option>>, + /// Whether selected pixels use quick-mask red instead of the normal blue tint. + quick_mask: bool, + /// Active brush diameter in canvas pixels for the footprint cursor. + brush_size: f32, /// Normalized bounds of the selected vector shape in canvas-space (x1, y1, x2, y2). selected_vector_bounds: Option<(f32, f32, f32, f32)>, /// Rotation angle (radians) of the selected vector shape. @@ -95,6 +102,8 @@ struct OverlayState { is_hovered: bool, /// Current handle being hovered (for cursor changes). hovered_handle: TransformHandle, + /// Vector handle currently under the pointer. + hovered_vector_handle: hcie_engine_api::VectorEditHandle, } impl OverlayProgram { @@ -108,17 +117,53 @@ impl OverlayProgram { let raw_y = (pane.height - display_h) / 2.0 + self.pan_offset.y; let min_vis_w = display_w * 0.25; let min_vis_h = display_h * 0.25; - let ox = raw_x.clamp(-(display_w - min_vis_w).max(0.0), (pane.width - min_vis_w).max(0.0)); - let oy = raw_y.clamp(-(display_h - min_vis_h).max(0.0), (pane.height - min_vis_h).max(0.0)); + let ox = raw_x.clamp( + -(display_w - min_vis_w).max(0.0), + (pane.width - min_vis_w).max(0.0), + ); + let oy = raw_y.clamp( + -(display_h - min_vis_h).max(0.0), + (pane.height - min_vis_h).max(0.0), + ); (ox, oy, display_w, display_h) } + /// Computes cursor-anchored wheel zoom using the same clamped viewport model as rendering. + /// + /// **Arguments:** `bounds` is the overlay viewport, `cursor` is viewport-local, and `factor` + /// is the multiplicative wheel step. **Returns:** The new zoom and pan offset preserving the + /// canvas point beneath the cursor. **Side Effects / Dependencies:** None. + fn wheel_zoom(&self, bounds: Rectangle, cursor: Point, factor: f32) -> (f32, Vector) { + let old_zoom = self.zoom.max(0.000_1); + let new_zoom = (old_zoom * factor).clamp(ZOOM_MIN, ZOOM_MAX); + let engine_w = self.engine_w as f32; + let engine_h = self.engine_h as f32; + let old_origin_x = (bounds.width - engine_w * old_zoom) / 2.0 + self.pan_offset.x; + let old_origin_y = (bounds.height - engine_h * old_zoom) / 2.0 + self.pan_offset.y; + let canvas_x = (cursor.x - old_origin_x) / old_zoom; + let canvas_y = (cursor.y - old_origin_y) / old_zoom; + let display_w = engine_w * new_zoom; + let display_h = engine_h * new_zoom; + let default_x = (bounds.width - display_w) / 2.0; + let default_y = (bounds.height - display_h) / 2.0; + let min_vis_w = display_w * 0.25; + let min_vis_h = display_h * 0.25; + let origin_x = (cursor.x - canvas_x * new_zoom).clamp( + -(display_w - min_vis_w).max(0.0), + (bounds.width - min_vis_w).max(0.0), + ); + let origin_y = (cursor.y - canvas_y * new_zoom).clamp( + -(display_h - min_vis_h).max(0.0), + (bounds.height - min_vis_h).max(0.0), + ); + ( + new_zoom, + Vector::new(origin_x - default_x, origin_y - default_y), + ) + } + /// Test which transform handle is at the given viewport position. - pub fn hit_test_handle( - &self, - viewport_x: f32, - viewport_y: f32, - ) -> TransformHandle { + pub fn hit_test_handle(&self, viewport_x: f32, viewport_y: f32) -> TransformHandle { let Some(ref tr) = self.selection_transform else { return TransformHandle::None; }; @@ -215,11 +260,14 @@ impl OverlayProgram { // White fill frame.fill(&rect, handle_color); // Black stroke - frame.stroke(&rect, Stroke { - style: canvas::stroke::Style::Solid(handle_stroke_color), - width: 1.0 / self.zoom.max(1.0), - ..Default::default() - }); + frame.stroke( + &rect, + Stroke { + style: canvas::stroke::Style::Solid(handle_stroke_color), + width: 1.0 / self.zoom.max(1.0), + ..Default::default() + }, + ); } // Draw rotation handle (circle above top center) @@ -228,32 +276,29 @@ impl OverlayProgram { let rotate_radius = 4.0 / self.zoom.max(1.0); let rotate_circle = Path::circle(Point::new(rotate_x, rotate_y), rotate_radius); frame.fill(&rotate_circle, handle_color); - frame.stroke(&rotate_circle, Stroke { - style: canvas::stroke::Style::Solid(handle_stroke_color), - width: 1.0 / self.zoom.max(1.0), - ..Default::default() - }); + frame.stroke( + &rotate_circle, + Stroke { + style: canvas::stroke::Style::Solid(handle_stroke_color), + width: 1.0 / self.zoom.max(1.0), + ..Default::default() + }, + ); // Draw line from top center to rotation handle - let line = Path::line( - Point::new(x + w / 2.0, y), - Point::new(rotate_x, rotate_y), + let line = Path::line(Point::new(x + w / 2.0, y), Point::new(rotate_x, rotate_y)); + frame.stroke( + &line, + Stroke { + style: canvas::stroke::Style::Solid(handle_stroke_color), + width: 1.0 / self.zoom.max(1.0), + ..Default::default() + }, ); - frame.stroke(&line, Stroke { - style: canvas::stroke::Style::Solid(handle_stroke_color), - width: 1.0 / self.zoom.max(1.0), - ..Default::default() - }); } /// Draw crop overlay with handles and rule-of-thirds. - fn draw_crop_overlay( - &self, - frame: &mut Frame, - crop: &CropState, - origin_x: f32, - origin_y: f32, - ) { + fn draw_crop_overlay(&self, frame: &mut Frame, crop: &CropState, origin_x: f32, origin_y: f32) { if let Some((x, y, w, h)) = crop.rect { if w < 1.0 || h < 1.0 { return; @@ -264,17 +309,36 @@ impl OverlayProgram { let cw = w * self.zoom; let ch = h * self.zoom; - // Draw darkened area outside crop - let crop_path = Path::rectangle(Point::new(cx, cy), Size::new(cw, ch)); - frame.fill(&crop_path, iced::Color::from_rgba(0.0, 0.0, 0.0, 0.5)); + // Darken the canvas outside the crop while preserving the chosen region. + let canvas_w = self.engine_w as f32 * self.zoom; + let canvas_h = self.engine_h as f32 * self.zoom; + let shade = iced::Color::from_rgba(0.0, 0.0, 0.0, 0.5); + for (x, y, w, h) in [ + (origin_x, origin_y, canvas_w, (cy - origin_y).max(0.0)), + ( + origin_x, + cy + ch, + canvas_w, + (origin_y + canvas_h - cy - ch).max(0.0), + ), + (origin_x, cy, (cx - origin_x).max(0.0), ch), + (cx + cw, cy, (origin_x + canvas_w - cx - cw).max(0.0), ch), + ] { + if w > 0.0 && h > 0.0 { + frame.fill(&Path::rectangle(Point::new(x, y), Size::new(w, h)), shade); + } + } // Draw crop border let border = Path::rectangle(Point::new(cx, cy), Size::new(cw, ch)); - frame.stroke(&border, Stroke { - style: canvas::stroke::Style::Solid(iced::Color::from_rgb(1.0, 1.0, 1.0)), - width: 2.0 / self.zoom.max(1.0), - ..Default::default() - }); + frame.stroke( + &border, + Stroke { + style: canvas::stroke::Style::Solid(iced::Color::from_rgb(1.0, 1.0, 1.0)), + width: 2.0 / self.zoom.max(1.0), + ..Default::default() + }, + ); // Draw rule-of-thirds lines let third_w = cw / 3.0; @@ -283,27 +347,31 @@ impl OverlayProgram { for i in 1..3 { // Vertical lines let vx = cx + third_w * i as f32; - let vline = Path::line( - Point::new(vx, cy), - Point::new(vx, cy + ch), + let vline = Path::line(Point::new(vx, cy), Point::new(vx, cy + ch)); + frame.stroke( + &vline, + Stroke { + style: canvas::stroke::Style::Solid(iced::Color::from_rgba( + 1.0, 1.0, 1.0, 0.5, + )), + width: 1.0 / self.zoom.max(1.0), + ..Default::default() + }, ); - frame.stroke(&vline, Stroke { - style: canvas::stroke::Style::Solid(iced::Color::from_rgba(1.0, 1.0, 1.0, 0.5)), - width: 1.0 / self.zoom.max(1.0), - ..Default::default() - }); // Horizontal lines let hy = cy + third_h * i as f32; - let hline = Path::line( - Point::new(cx, hy), - Point::new(cx + cw, hy), + let hline = Path::line(Point::new(cx, hy), Point::new(cx + cw, hy)); + frame.stroke( + &hline, + Stroke { + style: canvas::stroke::Style::Solid(iced::Color::from_rgba( + 1.0, 1.0, 1.0, 0.5, + )), + width: 1.0 / self.zoom.max(1.0), + ..Default::default() + }, ); - frame.stroke(&hline, Stroke { - style: canvas::stroke::Style::Solid(iced::Color::from_rgba(1.0, 1.0, 1.0, 0.5)), - width: 1.0 / self.zoom.max(1.0), - ..Default::default() - }); } // Draw handles @@ -313,14 +381,14 @@ impl OverlayProgram { let handle_stroke = iced::Color::from_rgb(0.0, 0.0, 0.0); let handles = [ - (cx, cy), // TopLeft - (cx + cw, cy), // TopRight - (cx, cy + ch), // BottomLeft - (cx + cw, cy + ch), // BottomRight - (cx + cw / 2.0, cy), // Top - (cx + cw / 2.0, cy + ch), // Bottom - (cx, cy + ch / 2.0), // Left - (cx + cw, cy + ch / 2.0), // Right + (cx, cy), // TopLeft + (cx + cw, cy), // TopRight + (cx, cy + ch), // BottomLeft + (cx + cw, cy + ch), // BottomRight + (cx + cw / 2.0, cy), // Top + (cx + cw / 2.0, cy + ch), // Bottom + (cx, cy + ch / 2.0), // Left + (cx + cw, cy + ch / 2.0), // Right ]; for (hx, hy) in &handles { @@ -329,11 +397,14 @@ impl OverlayProgram { Size::new(handle_size, handle_size), ); frame.fill(&rect, handle_color); - frame.stroke(&rect, Stroke { - style: canvas::stroke::Style::Solid(handle_stroke), - width: 1.0 / self.zoom.max(1.0), - ..Default::default() - }); + frame.stroke( + &rect, + Stroke { + style: canvas::stroke::Style::Solid(handle_stroke), + width: 1.0 / self.zoom.max(1.0), + ..Default::default() + }, + ); } } } @@ -346,16 +417,22 @@ impl OverlayProgram { fn draw_vector_edit_handles( &self, frame: &mut Frame, - x1: f32, y1: f32, x2: f32, y2: f32, + x1: f32, + y1: f32, + x2: f32, + y2: f32, angle: f32, origin_x: f32, origin_y: f32, + hovered: hcie_engine_api::VectorEditHandle, ) { let cx = (x1 + x2) / 2.0; let cy = (y1 + y2) / 2.0; let w = (x2 - x1).abs(); let h = (y2 - y1).abs(); - if w < 0.5 || h < 0.5 { return; } + if w < 0.5 || h < 0.5 { + return; + } let cos_a = angle.cos(); let sin_a = angle.sin(); @@ -367,45 +444,52 @@ impl OverlayProgram { (cx + dx * cos_a - dy * sin_a, cy + dx * sin_a + dy * cos_a) }; - let line_width = 1.5 / self.zoom.max(1.0); + let line_width = 1.5; // ── Blue selection outline ───────────────────────────────────────── let outline_color = iced::Color::from_rgb(0.2, 0.5, 1.0); let corners: [(f32, f32); 4] = [(x1, y1), (x2, y1), (x2, y2), (x1, y2)]; - let rotated_corners: Vec<(f32, f32)> = corners.iter().map(|&(px, py)| rotate(px, py)).collect(); + let rotated_corners: Vec<(f32, f32)> = + corners.iter().map(|&(px, py)| rotate(px, py)).collect(); let outline = Path::new(|b| { for (i, &(px, py)) in rotated_corners.iter().enumerate() { let sx = origin_x + px * self.zoom; let sy = origin_y + py * self.zoom; - if i == 0 { b.move_to(Point::new(sx, sy)); } - else { b.line_to(Point::new(sx, sy)); } + if i == 0 { + b.move_to(Point::new(sx, sy)); + } else { + b.line_to(Point::new(sx, sy)); + } } b.close(); }); - frame.stroke(&outline, Stroke { - style: canvas::stroke::Style::Solid(outline_color), - width: line_width, - ..Default::default() - }); + frame.stroke( + &outline, + Stroke { + style: canvas::stroke::Style::Solid(outline_color), + width: line_width, + ..Default::default() + }, + ); // ── 8 resize handles (white squares with black stroke) ───────────── - let handle_size = 8.0 / self.zoom.max(1.0); + let handle_size = 8.0; let half = handle_size / 2.0; let white = iced::Color::from_rgb(1.0, 1.0, 1.0); let black = iced::Color::from_rgb(0.0, 0.0, 0.0); - let handle_points: [(f32, f32); 8] = [ - (x1, y1), // TopLeft - (x2, y1), // TopRight - (x1, y2), // BottomLeft - (x2, y2), // BottomRight - (cx, y1), // Top - (cx, y2), // Bottom - (x1, cy), // Left - (x2, cy), // Right + let handle_points: [(hcie_engine_api::VectorEditHandle, f32, f32); 8] = [ + (hcie_engine_api::VectorEditHandle::TopLeft, x1, y1), + (hcie_engine_api::VectorEditHandle::TopRight, x2, y1), + (hcie_engine_api::VectorEditHandle::BottomLeft, x1, y2), + (hcie_engine_api::VectorEditHandle::BottomRight, x2, y2), + (hcie_engine_api::VectorEditHandle::Top, cx, y1), + (hcie_engine_api::VectorEditHandle::Bottom, cx, y2), + (hcie_engine_api::VectorEditHandle::Left, x1, cy), + (hcie_engine_api::VectorEditHandle::Right, x2, cy), ]; - for &(px, py) in &handle_points { + for &(handle, px, py) in &handle_points { let (rpx, rpy) = rotate(px, py); let sx = origin_x + rpx * self.zoom; let sy = origin_y + rpy * self.zoom; @@ -413,118 +497,64 @@ impl OverlayProgram { Point::new(sx - half, sy - half), Size::new(handle_size, handle_size), ); - frame.fill(&rect, white); - frame.stroke(&rect, Stroke { - style: canvas::stroke::Style::Solid(black), - width: line_width, - ..Default::default() - }); + let highlighted = handle == hovered || handle == self.vector_edit_handle; + frame.fill(&rect, if highlighted { outline_color } else { white }); + frame.stroke( + &rect, + Stroke { + style: canvas::stroke::Style::Solid(black), + width: line_width, + ..Default::default() + }, + ); } // ── Rotation handle (line from top-center upward + blue circle) ──── - let rotate_offset = 24.0 / self.zoom.max(1.0); + let rotate_offset = crate::vector_edit::ROTATE_OFFSET_PX / self.zoom.max(0.000_1); let (rot_line_start_x, rot_line_start_y) = rotate(cx, y1); let (rot_circ_x, rot_circ_y) = rotate(cx, y1 - rotate_offset); - let rot_circ_radius = 4.0 / self.zoom.max(1.0); + let rot_circ_radius = 4.0; // Line from top-center to rotation circle let rot_line = Path::line( - Point::new(origin_x + rot_line_start_x * self.zoom, origin_y + rot_line_start_y * self.zoom), - Point::new(origin_x + rot_circ_x * self.zoom, origin_y + rot_circ_y * self.zoom), + Point::new( + origin_x + rot_line_start_x * self.zoom, + origin_y + rot_line_start_y * self.zoom, + ), + Point::new( + origin_x + rot_circ_x * self.zoom, + origin_y + rot_circ_y * self.zoom, + ), + ); + frame.stroke( + &rot_line, + Stroke { + style: canvas::stroke::Style::Solid(black), + width: line_width, + ..Default::default() + }, ); - frame.stroke(&rot_line, Stroke { - style: canvas::stroke::Style::Solid(black), - width: line_width, - ..Default::default() - }); // Blue rotation circle let rot_circle = Path::circle( - Point::new(origin_x + rot_circ_x * self.zoom, origin_y + rot_circ_y * self.zoom), + Point::new( + origin_x + rot_circ_x * self.zoom, + origin_y + rot_circ_y * self.zoom, + ), rot_circ_radius, ); let blue = iced::Color::from_rgb(0.2, 0.5, 1.0); - frame.fill(&rot_circle, blue); - frame.stroke(&rot_circle, Stroke { - style: canvas::stroke::Style::Solid(black), - width: line_width, - ..Default::default() - }); - } - - /// Hit-test which vector edit handle is at the given viewport position. - /// - /// Checks rotation handle first (circle area), then resize handles (square - /// hit-box), then the move handle (expanded bounding box). Returns the - /// matching `VectorEditHandle` variant. - pub fn hit_test_vector_handle( - &self, - viewport_x: f32, - viewport_y: f32, - ) -> hcie_engine_api::VectorEditHandle { - use hcie_engine_api::VectorEditHandle as VEH; - - let Some((x1, y1, x2, y2)) = self.selected_vector_bounds else { - return VEH::None; - }; - - let (origin_x, origin_y, _, _) = self.canvas_origin(iced::Size::new(0.0, 0.0)); - - // Convert viewport to canvas coordinates - let canvas_x = (viewport_x - origin_x) / self.zoom; - let canvas_y = (viewport_y - origin_y) / self.zoom; - - let cx = (x1 + x2) / 2.0; - let cy = (y1 + y2) / 2.0; - let angle = self.selected_vector_angle; - let cos_a = angle.cos(); - let sin_a = angle.sin(); - - // Inverse-rotate a canvas-space point around (cx, cy) by -angle. - let unrotate = |px: f32, py: f32| -> (f32, f32) { - let dx = px - cx; - let dy = py - cy; - (cx + dx * cos_a + dy * sin_a, cy - dx * sin_a + dy * cos_a) - }; - - let (lx, ly) = unrotate(canvas_x, canvas_y); - - // ── Check rotation handle first (outside the box) ────────────────── - let rotate_offset = 24.0 / self.zoom.max(1.0); - let rot_circ_x = cx; - let rot_circ_y = y1 - rotate_offset; - let rot_radius = 6.0 / self.zoom.max(1.0); - let drx = lx - rot_circ_x; - let dry = ly - rot_circ_y; - if drx * drx + dry * dry <= rot_radius * rot_radius { - return VEH::Rotate; - } - - // ── Check 8 resize handles ──────────────────────────────────────── - let handle_half = 7.0 / self.zoom.max(1.0); - let handle_points: [(VEH, f32, f32); 8] = [ - (VEH::TopLeft, x1, y1), - (VEH::TopRight, x2, y1), - (VEH::BottomLeft, x1, y2), - (VEH::BottomRight, x2, y2), - (VEH::Top, cx, y1), - (VEH::Bottom, cx, y2), - (VEH::Left, x1, cy), - (VEH::Right, x2, cy), - ]; - - for &(handle, hx, hy) in &handle_points { - if (lx - hx).abs() <= handle_half && (ly - hy).abs() <= handle_half { - return handle; - } - } - - // ── Check move handle (inside bounding box) ──────────────────────── - if lx >= x1 && lx <= x2 && ly >= y1 && ly <= y2 { - return VEH::Move; - } - - VEH::None + let rotate_highlighted = hovered == hcie_engine_api::VectorEditHandle::Rotate + || self.vector_edit_handle == hcie_engine_api::VectorEditHandle::Rotate; + frame.fill(&rot_circle, if rotate_highlighted { white } else { blue }); + frame.stroke( + &rot_circle, + Stroke { + style: canvas::stroke::Style::Solid(black), + width: line_width, + ..Default::default() + }, + ); } } @@ -546,6 +576,7 @@ impl canvas::Program for OverlayProgram { let has_overlays = self.selection_rect.is_some() || self.selection_bounds.is_some() || self.vector_draw.is_some() + || self.selected_vector_bounds.is_some() || self.selection_transform.is_some() || self.crop_state.as_ref().map_or(false, |c| c.active); @@ -559,15 +590,18 @@ impl canvas::Program for OverlayProgram { let sel_y = origin_y + y0.min(y1) * self.zoom; let sel_w = (x1 - x0).abs() * self.zoom; let sel_h = (y1 - y0).abs() * self.zoom; - let sel_path = Path::rectangle( - Point::new(sel_x, sel_y), - Size::new(sel_w, sel_h), + let sel_path = + Path::rectangle(Point::new(sel_x, sel_y), Size::new(sel_w, sel_h)); + frame.stroke( + &sel_path, + Stroke { + style: canvas::stroke::Style::Solid(iced::Color::from_rgb( + 0.0, 0.6, 1.0, + )), + width: 2.0 / self.zoom.max(1.0), + ..Default::default() + }, ); - frame.stroke(&sel_path, Stroke { - style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.0, 0.6, 1.0)), - width: 2.0 / self.zoom.max(1.0), - ..Default::default() - }); } } @@ -579,14 +613,29 @@ impl canvas::Program for OverlayProgram { let sel_w = sw as f32 * self.zoom; let sel_h = sh as f32 * self.zoom; - // Semi-transparent fill over the selection bounds so the - // selected region is visually distinguished (egui draws a - // purple fill over selected pixels at mod.rs:1655). - let fill_path = Path::rectangle( - Point::new(sel_x, sel_y), - Size::new(sel_w, sel_h), - ); - frame.fill(&fill_path, iced::Color::from_rgba(0.4, 0.5, 1.0, 0.12)); + // Fill exact mask runs rather than the irregular selection's bounding box. + let fill_color = if self.quick_mask { + iced::Color::from_rgba(1.0, 0.1, 0.1, 0.35) + } else { + iced::Color::from_rgba(0.4, 0.5, 1.0, 0.12) + }; + if let Some(spans) = &self.selection_fill_spans { + for &(start_x, y, end_x) in spans.iter() { + frame.fill( + &Path::rectangle( + Point::new( + origin_x + start_x as f32 * self.zoom, + origin_y + y as f32 * self.zoom, + ), + Size::new( + (end_x - start_x) as f32 * self.zoom, + self.zoom.max(1.0), + ), + ), + fill_color, + ); + } + } // Calculate dash offset for marching ants animation // The offset moves the dash pattern to create the marching effect @@ -596,28 +645,42 @@ impl canvas::Program for OverlayProgram { let animated_offset = (self.marching_ants_offset * total_cycle) as usize; // Draw white dashes (foreground) - offset by half cycle for marching effect - let white_offset = (animated_offset + (total_cycle / 2.0) as usize) % (total_cycle as usize); - + let white_offset = + (animated_offset + (total_cycle / 2.0) as usize) % (total_cycle as usize); + if let Some(edges) = &self.marching_ants_edges { // Irregular selection bounds (marching ants along extracted edges) for &(x1, y1, x2, y2) in edges.iter() { - let s1 = Point::new(origin_x + x1 as f32 * self.zoom, origin_y + y1 as f32 * self.zoom); - let s2 = Point::new(origin_x + x2 as f32 * self.zoom, origin_y + y2 as f32 * self.zoom); - + let s1 = Point::new( + origin_x + x1 as f32 * self.zoom, + origin_y + y1 as f32 * self.zoom, + ); + let s2 = Point::new( + origin_x + x2 as f32 * self.zoom, + origin_y + y2 as f32 * self.zoom, + ); + // Optimization: we could clip, but `Path::line` handles it generally. let dist = ((s1.x - s2.x).powi(2) + (s1.y - s2.y).powi(2)).sqrt(); - if dist < 1e-6 { continue; } - + if dist < 1e-6 { + continue; + } + let line_path = Path::line(s1, s2); - frame.stroke(&line_path, Stroke { - style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.0, 0.0, 0.0)), - width: 1.5, - ..Default::default() - }); - + frame.stroke( + &line_path, + Stroke { + style: canvas::stroke::Style::Solid(iced::Color::from_rgb( + 0.0, 0.0, 0.0, + )), + width: 1.5, + ..Default::default() + }, + ); + let dir_x = (s2.x - s1.x) / dist; let dir_y = (s2.y - s1.y) / dist; - + let mut curr = -(self.marching_ants_offset * total_cycle); let mut iter = 0u32; while curr < dist && iter < 1000 { @@ -625,43 +688,58 @@ impl canvas::Program for OverlayProgram { let start_d = curr.max(0.0); let end_d = (curr + dash_length).min(dist); if end_d > start_d { - let p_start = Point::new(s1.x + dir_x * start_d, s1.y + dir_y * start_d); - let p_end = Point::new(s1.x + dir_x * end_d, s1.y + dir_y * end_d); + let p_start = + Point::new(s1.x + dir_x * start_d, s1.y + dir_y * start_d); + let p_end = + Point::new(s1.x + dir_x * end_d, s1.y + dir_y * end_d); let dash_path = Path::line(p_start, p_end); - frame.stroke(&dash_path, Stroke { - style: canvas::stroke::Style::Solid(iced::Color::from_rgb(1.0, 1.0, 1.0)), - width: 1.5, - ..Default::default() - }); + frame.stroke( + &dash_path, + Stroke { + style: canvas::stroke::Style::Solid( + iced::Color::from_rgb(1.0, 1.0, 1.0), + ), + width: 1.5, + ..Default::default() + }, + ); } curr += total_cycle; } } } else { // Fallback to bounding box marching ants - let sel_path = Path::rectangle( - Point::new(sel_x, sel_y), - Size::new(sel_w, sel_h), + let sel_path = + Path::rectangle(Point::new(sel_x, sel_y), Size::new(sel_w, sel_h)); + frame.stroke( + &sel_path, + Stroke { + style: canvas::stroke::Style::Solid(iced::Color::from_rgb( + 0.0, 0.0, 0.0, + )), + width: 2.0 / self.zoom.max(1.0), + line_dash: canvas::LineDash { + segments: &[dash_length, gap_length], + offset: animated_offset, + }, + ..Default::default() + }, ); - frame.stroke(&sel_path, Stroke { - style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.0, 0.0, 0.0)), - width: 2.0 / self.zoom.max(1.0), - line_dash: canvas::LineDash { - segments: &[dash_length, gap_length], - offset: animated_offset, - }, - ..Default::default() - }); - frame.stroke(&sel_path, Stroke { - style: canvas::stroke::Style::Solid(iced::Color::from_rgb(1.0, 1.0, 1.0)), - width: 2.0 / self.zoom.max(1.0), - line_dash: canvas::LineDash { - segments: &[dash_length, gap_length], - offset: white_offset, + frame.stroke( + &sel_path, + Stroke { + style: canvas::stroke::Style::Solid(iced::Color::from_rgb( + 1.0, 1.0, 1.0, + )), + width: 2.0 / self.zoom.max(1.0), + line_dash: canvas::LineDash { + segments: &[dash_length, gap_length], + offset: white_offset, + }, + ..Default::default() }, - ..Default::default() - }); + ); } } } @@ -720,7 +798,8 @@ impl canvas::Program for OverlayProgram { let points = self.star_points.max(3); let star_path = Path::new(|b| { for i in 0..(points * 2) { - let t = (i as f32) * std::f32::consts::PI / points as f32 - std::f32::consts::FRAC_PI_2; + let t = (i as f32) * std::f32::consts::PI / points as f32 + - std::f32::consts::FRAC_PI_2; let r = if i % 2 == 0 { r_out } else { r_in }; let px = cx + r * t.cos(); let py = cy + r * t.sin(); @@ -742,7 +821,8 @@ impl canvas::Program for OverlayProgram { let sides = self.polygon_sides.max(3); let poly_path = Path::new(|b| { for i in 0..sides { - let t = (i as f32) * std::f32::consts::TAU / sides as f32 - std::f32::consts::FRAC_PI_2; + let t = (i as f32) * std::f32::consts::TAU / sides as f32 + - std::f32::consts::FRAC_PI_2; let px = cx + r * t.cos(); let py = cy + r * t.sin(); if i == 0 { @@ -792,9 +872,9 @@ impl canvas::Program for OverlayProgram { let hh = ((y1 - y0).abs() / 2.0).max(1.0) * self.zoom; let rhombus_path = Path::new(|b| { b.move_to(Point::new(cx, cy - hh)); // top - b.line_to(Point::new(cx + hw, cy)); // right - b.line_to(Point::new(cx, cy + hh)); // bottom - b.line_to(Point::new(cx - hw, cy)); // left + b.line_to(Point::new(cx + hw, cy)); // right + b.line_to(Point::new(cx, cy + hh)); // bottom + b.line_to(Point::new(cx - hw, cy)); // left b.close(); }); frame.stroke(&rhombus_path, stroke_style); @@ -842,16 +922,12 @@ impl canvas::Program for OverlayProgram { }); frame.stroke(&bot_ellipse, stroke_style); // Left vertical line - let left_line = Path::line( - Point::new(left, top_cy), - Point::new(left, bot_cy), - ); + let left_line = + Path::line(Point::new(left, top_cy), Point::new(left, bot_cy)); frame.stroke(&left_line, stroke_style); // Right vertical line - let right_line = Path::line( - Point::new(right, top_cy), - Point::new(right, bot_cy), - ); + let right_line = + Path::line(Point::new(right, top_cy), Point::new(right, bot_cy)); frame.stroke(&right_line, stroke_style); } hcie_engine_api::Tool::VectorHeart => { @@ -1076,7 +1152,9 @@ impl canvas::Program for OverlayProgram { let v_y = origin_y + y0.min(y1) * self.zoom; let v_w = (x1 - x0).abs() * self.zoom; let v_h = (y1 - y0).abs() * self.zoom; - let v_path = if self.active_tool == hcie_engine_api::Tool::VectorRect && self.rect_radius > 0.0 { + let v_path = if self.active_tool == hcie_engine_api::Tool::VectorRect + && self.rect_radius > 0.0 + { let max_r = (v_w.min(v_h) / 2.0).min(self.rect_radius * self.zoom); let k = max_r * 0.5523; // cubic approximation of quarter circle Path::new(|b| { @@ -1108,10 +1186,7 @@ impl canvas::Program for OverlayProgram { b.close(); }) } else { - Path::rectangle( - Point::new(v_x, v_y), - Size::new(v_w, v_h), - ) + Path::rectangle(Point::new(v_x, v_y), Size::new(v_w, v_h)) }; frame.stroke(&v_path, stroke_style); } @@ -1120,7 +1195,17 @@ impl canvas::Program for OverlayProgram { // Draw vector edit handles for selected shape (blue outline, resize + rotation) if let Some((x1, y1, x2, y2)) = self.selected_vector_bounds { - self.draw_vector_edit_handles(&mut frame, x1, y1, x2, y2, self.selected_vector_angle, origin_x, origin_y); + self.draw_vector_edit_handles( + &mut frame, + x1, + y1, + x2, + y2, + self.selected_vector_angle, + origin_x, + origin_y, + state.hovered_vector_handle, + ); } // Draw gradient endpoint preview line. @@ -1128,11 +1213,14 @@ impl canvas::Program for OverlayProgram { let p0 = Point::new(origin_x + gx0 * self.zoom, origin_y + gy0 * self.zoom); let p1 = Point::new(origin_x + gx1 * self.zoom, origin_y + gy1 * self.zoom); let g_path = Path::line(p0, p1); - frame.stroke(&g_path, Stroke { - style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.2, 0.8, 1.0)), - width: 2.0 / self.zoom.max(1.0), - ..Default::default() - }); + frame.stroke( + &g_path, + Stroke { + style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.2, 0.8, 1.0)), + width: 2.0 / self.zoom.max(1.0), + ..Default::default() + }, + ); // Endpoint markers. frame.fill(&Path::circle(p0, 3.0), iced::Color::from_rgb(0.2, 0.8, 1.0)); frame.fill(&Path::circle(p1, 3.0), iced::Color::from_rgb(0.2, 0.8, 1.0)); @@ -1147,15 +1235,20 @@ impl canvas::Program for OverlayProgram { let w = tr.size.x * self.zoom; let h = tr.size.y * self.zoom; let bbox = Path::rectangle(Point::new(x, y), Size::new(w, h)); - frame.stroke(&bbox, Stroke { - style: canvas::stroke::Style::Solid(iced::Color::from_rgb(0.5, 0.5, 1.0)), - width: 1.0 / self.zoom.max(1.0), - line_dash: canvas::LineDash { - segments: &[4.0, 4.0], - offset: 0, + frame.stroke( + &bbox, + Stroke { + style: canvas::stroke::Style::Solid(iced::Color::from_rgb( + 0.5, 0.5, 1.0, + )), + width: 1.0 / self.zoom.max(1.0), + line_dash: canvas::LineDash { + segments: &[4.0, 4.0], + offset: 0, + }, + ..Default::default() }, - ..Default::default() - }); + ); // Draw handles self.draw_transform_handles(&mut frame, tr, origin_x, origin_y); @@ -1176,24 +1269,48 @@ impl canvas::Program for OverlayProgram { if state.is_hovered { if let Some(cursor) = state.cursor_pos { let mut crosshair_frame = Frame::new(renderer, bounds.size()); - let crosshair_size = 10.0 / self.zoom.max(1.0); + let crosshair_size = 10.0; let crosshair_color = iced::Color::from_rgb(1.0, 1.0, 1.0); - crosshair_frame.stroke(&Path::line( - Point::new(cursor.x - crosshair_size, cursor.y), - Point::new(cursor.x + crosshair_size, cursor.y), - ), Stroke { - style: canvas::stroke::Style::Solid(crosshair_color), - width: 1.0 / self.zoom.max(1.0), - ..Default::default() - }); - crosshair_frame.stroke(&Path::line( - Point::new(cursor.x, cursor.y - crosshair_size), - Point::new(cursor.x, cursor.y + crosshair_size), - ), Stroke { - style: canvas::stroke::Style::Solid(crosshair_color), - width: 1.0 / self.zoom.max(1.0), - ..Default::default() - }); + if matches!( + self.active_tool, + hcie_engine_api::Tool::Pen + | hcie_engine_api::Tool::Brush + | hcie_engine_api::Tool::Eraser + | hcie_engine_api::Tool::Spray + ) { + let footprint = + Path::circle(cursor, (self.brush_size * self.zoom * 0.5).max(1.0)); + crosshair_frame.stroke( + &footprint, + Stroke { + style: canvas::stroke::Style::Solid(crosshair_color), + width: 1.0, + ..Default::default() + }, + ); + } + crosshair_frame.stroke( + &Path::line( + Point::new(cursor.x - crosshair_size, cursor.y), + Point::new(cursor.x + crosshair_size, cursor.y), + ), + Stroke { + style: canvas::stroke::Style::Solid(crosshair_color), + width: 1.0 / self.zoom.max(1.0), + ..Default::default() + }, + ); + crosshair_frame.stroke( + &Path::line( + Point::new(cursor.x, cursor.y - crosshair_size), + Point::new(cursor.x, cursor.y + crosshair_size), + ), + Stroke { + style: canvas::stroke::Style::Solid(crosshair_color), + width: 1.0 / self.zoom.max(1.0), + ..Default::default() + }, + ); geometries.push(crosshair_frame.into_geometry()); } } @@ -1219,11 +1336,27 @@ impl canvas::Program for OverlayProgram { // All actual input handling (drawing, panning, zooming) is done // by the shader widget underneath. match event { + canvas::Event::Mouse(mouse::Event::WheelScrolled { delta }) + if cursor.is_over(bounds) => + { + let scroll_y = match delta { + mouse::ScrollDelta::Lines { y, .. } => y, + mouse::ScrollDelta::Pixels { y, .. } => y / 50.0, + }; + if scroll_y != 0.0 { + let local = cursor + .position_in(bounds) + .unwrap_or(Point::new(bounds.width / 2.0, bounds.height / 2.0)); + let factor = if scroll_y > 0.0 { 1.1 } else { 1.0 / 1.1 }; + let (zoom, pan_offset) = self.wheel_zoom(bounds, local, factor); + return ( + canvas::event::Status::Captured, + Some(Message::CanvasPanZoom { zoom, pan_offset }), + ); + } + } canvas::Event::Mouse(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); @@ -1233,18 +1366,60 @@ impl canvas::Program for OverlayProgram { } else { state.hovered_handle = TransformHandle::None; } + state.hovered_vector_handle = + if let Some(vector_bounds) = self.selected_vector_bounds { + let (origin_x, origin_y, _, _) = self.canvas_origin(bounds.size()); + let point = ( + (local_pos.x - origin_x) / self.zoom.max(0.000_1), + (local_pos.y - origin_y) / self.zoom.max(0.000_1), + ); + crate::vector_edit::hit_test_handle( + vector_bounds, + self.selected_vector_angle, + point, + self.zoom, + ) + } else { + hcie_engine_api::VectorEditHandle::None + }; } _ => { if state.is_hovered && !cursor.is_over(bounds) { state.is_hovered = false; state.cursor_pos = None; state.hovered_handle = TransformHandle::None; + state.hovered_vector_handle = hcie_engine_api::VectorEditHandle::None; } } } // Always pass events through to the shader widget below (canvas::event::Status::Ignored, None) } + + /// Selects a cursor matching the hovered vector transform handle. + /// + /// Arguments: Current overlay state, widget bounds, and pointer cursor. Returns: A resize, + /// grab, or crosshair interaction. Side Effects: None. + fn mouse_interaction( + &self, + state: &Self::State, + bounds: Rectangle, + cursor: Cursor, + ) -> mouse::Interaction { + use hcie_engine_api::VectorEditHandle as H; + if !cursor.is_over(bounds) { + return mouse::Interaction::default(); + } + match state.hovered_vector_handle { + H::Move => mouse::Interaction::Grab, + H::Top | H::Bottom => mouse::Interaction::ResizingVertically, + H::Left | H::Right => mouse::Interaction::ResizingHorizontally, + H::TopLeft | H::BottomRight => mouse::Interaction::ResizingDiagonallyDown, + H::TopRight | H::BottomLeft => mouse::Interaction::ResizingDiagonallyUp, + H::Rotate => mouse::Interaction::Pointer, + H::None => mouse::Interaction::Crosshair, + } + } } // ─── Public view function ──────────────────────────────────────────────────── @@ -1289,6 +1464,7 @@ pub fn view<'a>( composite_pixels: doc.composite_pixels.clone(), dirty_region: doc.dirty_region.take(), full_upload: doc.full_upload.replace(false), + space_pan: tool_state.space_pan, }; let shader_canvas = Shader::new(shader_program) @@ -1323,7 +1499,7 @@ pub fn view<'a>( selection_bounds: doc.selection_bounds, vector_draw: doc.vector_draw, selection_transform: doc.selection_transform.clone(), - active_handle: TransformHandle::None, // TODO: track hover state + active_handle: TransformHandle::None, // TODO: track hover state crop_state: Some(doc.crop_state.clone()), marching_ants_offset, pressure, @@ -1333,6 +1509,9 @@ pub fn view<'a>( rect_radius, gradient_drag: doc.gradient_drag, marching_ants_edges: doc.marching_ants_edges.clone(), + selection_fill_spans: doc.selection_fill_spans.clone(), + quick_mask: doc.quick_mask, + brush_size: tool_state.brush_size, selected_vector_bounds: sel_vec_bounds, selected_vector_angle: sel_vec_angle, vector_edit_handle: doc.vector_edit_handle, @@ -1364,17 +1543,21 @@ pub fn view<'a>( let text_screen_y = (origin_y + draft.y * zoom).max(4.0); let editor_bg = |_theme: &iced::Theme| iced::widget::container::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba(0.1, 0.1, 0.12, 0.92))), - border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.5, 1.0)).width(1).rounded(3), + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.1, 0.1, 0.12, 0.92, + ))), + border: iced::Border::default() + .color(iced::Color::from_rgb(0.4, 0.5, 1.0)) + .width(1) + .rounded(3), ..Default::default() }; let editor = container( column![ - iced::widget::text_input("Type text here...", &draft.content) - .on_input(Message::TextContentChanged) - .on_submit(Message::TextCommit) - .size(13) - .width(Length::Fixed(220.0)), + iced::widget::text_editor(&draft.editor) + .placeholder("Type text here...") + .on_action(Message::TextEditorAction) + .height(Length::Fixed(96.0)), row![ iced::widget::button(iced::widget::text("Commit").size(10)) .on_press(Message::TextCommit) @@ -1414,58 +1597,63 @@ pub fn view<'a>( let cx = (bx1 + bx2) / 2.0; let cy = (by2).max(by1) + 10.0; // below shape - let screen_x = (origin_x + cx * zoom).max(4.0); - let screen_y = (origin_y + cy * zoom).max(4.0); + let controls_width = 286.0; + let controls_height = 28.0; + let screen_x = (origin_x + cx * zoom - controls_width * 0.5) + .clamp(4.0, (pane_w - controls_width - 4.0).max(4.0)); + let screen_y = (origin_y + cy * zoom).clamp(4.0, (pane_h - controls_height - 4.0).max(4.0)); - let btn_style = |_theme: &iced::Theme, _status: iced::widget::button::Status| iced::widget::button::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba(0.15, 0.15, 0.18, 0.9))), - border: iced::Border::default() - .color(iced::Color::from_rgba(0.35, 0.35, 0.4, 1.0)) - .width(1) - .rounded(4), - text_color: iced::Color::from_rgb(0.9, 0.9, 0.9), - ..Default::default() + let btn_style = |_theme: &iced::Theme, _status: iced::widget::button::Status| { + iced::widget::button::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.15, 0.15, 0.18, 0.9, + ))), + border: iced::Border::default() + .color(iced::Color::from_rgba(0.35, 0.35, 0.4, 1.0)) + .width(1) + .rounded(4), + text_color: iced::Color::from_rgb(0.9, 0.9, 0.9), + ..Default::default() + } }; - let apply_btn = iced::widget::button( - iced::widget::text("Apply").size(10), - ) - .on_press(Message::VectorShapeApply) - .padding([3, 8]) - .style(btn_style); + let apply_btn = iced::widget::button(iced::widget::text("Apply").size(10)) + .on_press(Message::VectorShapeApply) + .padding([3, 8]) + .style(btn_style); - let cancel_btn = iced::widget::button( - iced::widget::text("Cancel").size(10), - ) - .on_press(Message::VectorShapeCancel) - .padding([3, 8]) - .style(btn_style); + let cancel_btn = iced::widget::button(iced::widget::text("Cancel").size(10)) + .on_press(Message::VectorShapeCancel) + .padding([3, 8]) + .style(btn_style); - let flip_h_btn = iced::widget::button( - iced::widget::text("Flip H").size(10), - ) - .on_press(Message::VectorShapeFlipH) - .padding([3, 8]) - .style(btn_style); + let flip_h_btn = iced::widget::button(iced::widget::text("Flip H").size(10)) + .on_press(Message::VectorShapeFlipH) + .padding([3, 8]) + .style(btn_style); - let flip_v_btn = iced::widget::button( - iced::widget::text("Flip V").size(10), - ) - .on_press(Message::VectorShapeFlipV) - .padding([3, 8]) - .style(btn_style); + let flip_v_btn = iced::widget::button(iced::widget::text("Flip V").size(10)) + .on_press(Message::VectorShapeFlipV) + .padding([3, 8]) + .style(btn_style); - let overlay_row = row![apply_btn, cancel_btn, flip_h_btn, flip_v_btn] - .spacing(4); + let delete_btn = iced::widget::button(iced::widget::text("Delete").size(10)) + .on_press(Message::VectorShapeDelete) + .padding([3, 8]) + .style(btn_style); - let overlay_container = container(overlay_row) - .width(Length::Shrink) - .padding(iced::Padding { - top: screen_y, - bottom: 0.0, - left: screen_x, - right: 0.0, - }); + let overlay_row = + row![apply_btn, cancel_btn, flip_h_btn, flip_v_btn, delete_btn].spacing(4); + + let overlay_container = + container(overlay_row) + .width(Length::Shrink) + .padding(iced::Padding { + top: screen_y, + bottom: 0.0, + left: screen_x, + right: 0.0, + }); stacked = stacked.push(overlay_container); } @@ -1488,8 +1676,12 @@ pub fn view<'a>( }; let mut overlay_lines = vec![]; - if let Some(sel) = overlay_info { overlay_lines.push(sel); } - if let Some(vec) = vector_info { overlay_lines.push(vec); } + if let Some(sel) = overlay_info { + overlay_lines.push(sel); + } + if let Some(vec) = vector_info { + overlay_lines.push(vec); + } let overlay_text = if overlay_lines.is_empty() { text("").size(10) @@ -1504,7 +1696,7 @@ pub fn view<'a>( overlay_text, ] .spacing(4) - .align_y(iced::Alignment::Center) + .align_y(iced::Alignment::Center), ) .padding([2, 8]); diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs index a1c20b3..4a3bb63 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs @@ -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 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 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 for CanvasShaderProgram { (bounds.height - min_vis_h).max(0.0), ) - default_oy; - return (iced::event::Status::Captured, Some(Message::CanvasPanZoom { - zoom: self.zoom, - pan_offset: new_pan, - })); + return ( + iced::event::Status::Captured, + Some(Message::CanvasPanZoom { + zoom: self.zoom, + pan_offset: new_pan, + }), + ); } // Cursor over canvas → status bar coords if let (Some(cx), Some(cy)) = (canvas_x, canvas_y) { - return (iced::event::Status::Captured, Some(Message::CanvasCursorPos { - x: cx as u32, - y: cy as u32, - })); + return ( + iced::event::Status::Captured, + Some(Message::CanvasCursorPos { + x: cx as u32, + y: cy as u32, + }), + ); } return (iced::event::Status::Captured, Some(pane_size_msg)); } mouse::Event::ButtonPressed(button) => { - if button == mouse::Button::Left && bounds.contains(cursor.position().unwrap_or(Point::ORIGIN)) { + if button == mouse::Button::Left + && bounds.contains(cursor.position().unwrap_or(Point::ORIGIN)) + { + if self.space_pan { + state.middle_pressed = true; + state.space_left_pan = true; + state.pan_start = cursor.position(); + return (iced::event::Status::Captured, None); + } let local_pos = { let p = cursor.position().unwrap_or(Point::ORIGIN); Point::new(p.x - bounds.x, p.y - bounds.y) @@ -888,10 +963,10 @@ impl shader::Program 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 for CanvasShaderProgram { if bounds.contains(pos) { let local_pos = Point::new(pos.x - bounds.x, pos.y - bounds.y); let (cx_opt, cy_opt) = screen_to_canvas_local( - local_pos, bounds.width, bounds.height, self.engine_w as f32, self.engine_h as f32, self.zoom, self.pan_offset + local_pos, + bounds.width, + bounds.height, + self.engine_w as f32, + self.engine_h as f32, + self.zoom, + self.pan_offset, ); if let (Some(cx), Some(cy)) = (cx_opt, cy_opt) { - return (iced::event::Status::Captured, Some(Message::CanvasPointerRightClicked { - x: cx, - y: cy, - screen_x: pos.x, - screen_y: pos.y, - })); + return ( + iced::event::Status::Captured, + Some(Message::CanvasPointerRightClicked { + x: cx, + y: cy, + screen_x: pos.x, + screen_y: pos.y, + }), + ); } } } @@ -921,8 +1005,17 @@ impl shader::Program 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 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 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 for CanvasShaderProgram { (bounds.height - new_min_vis_h).max(0.0), ) - new_default_oy; - return (iced::event::Status::Captured, Some(Message::CanvasPanZoom { - zoom: new_zoom, - pan_offset: new_pan, - })); + return ( + iced::event::Status::Captured, + Some(Message::CanvasPanZoom { + zoom: new_zoom, + pan_offset: new_pan, + }), + ); } } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/cli.rs b/hcie-iced-app/crates/hcie-iced-gui/src/cli.rs new file mode 100644 index 0000000..112a4a2 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/cli.rs @@ -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, + /// 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, + /// Optional automated screenshot operation. + pub screenshot: Option, + /// 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(args: I) -> Result +where + I: IntoIterator, + S: Into, +{ + let tokens: Vec = 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"))); + } +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs b/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs index c55d9cc..2cb963e 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs @@ -6,11 +6,28 @@ //! - HSL sliders //! - 10x10 palette grid //! - Recent colors -//! - HSV color picker popup for geometry and properties panels +//! - Shared targeted popup selector for vector and layer-style colors use crate::app::Message; -use iced::widget::{button, column, container, horizontal_rule, row, slider, text, text_input}; -use iced::{Element, Length, Padding}; +use crate::theme::ThemeColors; +use iced::mouse; +use iced::widget::canvas::{self, Canvas, Frame, Path, Stroke}; +use iced::widget::{ + button, column, container, horizontal_rule, row, scrollable, slider, text, text_input, +}; +use iced::{Element, Length, Padding, Point, Rectangle, Size}; + +/// Identifies the application value edited by the shared color selector. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColorPickerTarget { + Foreground, + Background, + GeometryFill, + GeometryStroke, + StyleMain(usize), + StyleHighlight(usize), + StyleShadow(usize), +} /// HSV color representation (Hue, Saturation, Value) #[derive(Debug, Clone, Copy)] @@ -34,7 +51,7 @@ pub fn rgb_to_hsv(r: u8, g: u8, b: u8) -> Hsv { let max = r.max(g).max(b); let min = r.min(g).min(b); let d = max - min; - + let h = if max == min { 0.0 } else if max == r { @@ -44,15 +61,11 @@ pub fn rgb_to_hsv(r: u8, g: u8, b: u8) -> Hsv { } else { ((r - g) / d + 4.0) / 6.0 }; - + let s = if max == 0.0 { 0.0 } else { d / max }; let v = max; - - Hsv { - h: h * 360.0, - s, - v, - } + + Hsv { h: h * 360.0, s, v } } /// Convert HSV to RGB. @@ -75,86 +88,39 @@ pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) { _ => (v, p, q), } }; - ((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8) + (to_byte(r), to_byte(g), to_byte(b)) } -/// Create a compact HSV color picker popup for inline use in panels. -/// Returns a color swatch that opens a popup when clicked. -pub fn color_popup<'a>( +/// Builds a compact swatch row that opens the shared application popup. +pub fn color_swatch<'a>( color: [u8; 4], label: &'a str, - on_change: fn([u8; 4]) -> Message, + target: ColorPickerTarget, ) -> Element<'a, Message> { - let r = color[0]; - let g = color[1]; - let b = color[2]; - let a = color[3]; - let hsv = rgb_to_hsv(r, g, b); - - // Color swatch button let swatch = container(text("")) - .width(20) - .height(20) + .width(30) + .height(22) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(iced::Color::from_rgba( - r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, a as f32 / 255.0, + color[0] as f32 / 255.0, + color[1] as f32 / 255.0, + color[2] as f32 / 255.0, + color[3] as f32 / 255.0, ))), - border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1).rounded(2), + border: iced::Border::default() + .color(iced::Color::from_rgb(0.55, 0.55, 0.55)) + .width(1) + .rounded(3), ..Default::default() }); - - // HSV sliders - let h_slider = slider(0.0..=360.0, hsv.h, move |v| { - let (r, g, b) = hsv_to_rgb(v, hsv.s, hsv.v); - on_change([r, g, b, a]) - }).step(1.0); - - let s_slider = slider(0.0..=100.0, hsv.s * 100.0, move |v| { - let (r, g, b) = hsv_to_rgb(hsv.h, v / 100.0, hsv.v); - on_change([r, g, b, a]) - }).step(1.0); - - let v_slider = slider(0.0..=100.0, hsv.v * 100.0, move |v| { - let (r, g, b) = hsv_to_rgb(hsv.h, hsv.s, v / 100.0); - on_change([r, g, b, a]) - }).step(1.0); - - // Alpha slider - let a_slider = slider(0..=255u8, a, move |v| { - on_change([r, g, b, v]) - }).width(Length::Fill); - - // Hex input - let hex_str = format!("{:02X}{:02X}{:02X}", r, g, b); - let alpha = a; - let hex_input = text_input("#", &hex_str) - .on_input(move |input| { - let clean = input.trim_start_matches('#').to_uppercase(); - if clean.len() == 6 { - if let (Ok(r), Ok(g), Ok(b)) = ( - u8::from_str_radix(&clean[0..2], 16), - u8::from_str_radix(&clean[2..4], 16), - u8::from_str_radix(&clean[4..6], 16), - ) { - return on_change([r, g, b, alpha]); - } - } - Message::NoOp - }) - .width(70) - .font(iced::Font::MONOSPACE) - .size(10); - - column![ - row![text(label).size(10), swatch].spacing(4).align_y(iced::Alignment::Center), - row![text("H").size(9), h_slider, text(format!("{:.0}°", hsv.h)).size(9)].spacing(2), - row![text("S").size(9), s_slider, text(format!("{:.0}%", hsv.s * 100.0)).size(9)].spacing(2), - row![text("V").size(9), v_slider, text(format!("{:.0}%", hsv.v * 100.0)).size(9)].spacing(2), - row![text("A").size(9), a_slider, text(format!("{:.0}%", a as f32 / 255.0 * 100.0)).size(9)].spacing(2), - row![text("Hex:").size(10), hex_input].spacing(4).align_y(iced::Alignment::Center), - ] - .spacing(2) - .into() + let trigger = iced::widget::mouse_area(swatch) + .on_press(Message::OpenColorPicker(target, color)) + .interaction(mouse::Interaction::Pointer); + row![text(label).size(10).width(Length::Fill), trigger] + .spacing(6) + .align_y(iced::Alignment::Center) + .width(Length::Fill) + .into() } /// A predefined color palette (10x10 grid) — generated algorithmically to match egui. @@ -173,16 +139,22 @@ fn palette_color(row: usize, col: usize) -> [u8; 3] { } /// Convert RGB to HSL. -fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) { +pub fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) { let r = r as f32 / 255.0; let g = g as f32 / 255.0; let b = b as f32 / 255.0; let max = r.max(g).max(b); let min = r.min(g).min(b); let l = (max + min) / 2.0; - if max == min { return (0.0, 0.0, l); } + if max == min { + return (0.0, 0.0, l); + } let d = max - min; - let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) }; + let s = if l > 0.5 { + d / (2.0 - max - min) + } else { + d / (max + min) + }; let h = if max == r { ((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0 } else if max == g { @@ -194,24 +166,437 @@ fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) { } /// Convert HSL to RGB. -fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) { +pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) { let h = h / 360.0; let (r, g, b) = if s == 0.0 { (l, l, l) } else { - let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s }; + let q = if l < 0.5 { + l * (1.0 + s) + } else { + l + s - l * s + }; let p = 2.0 * l - q; - (hue_to_rgb(p, q, h + 1.0 / 3.0), hue_to_rgb(p, q, h), hue_to_rgb(p, q, h - 1.0 / 3.0)) + ( + hue_to_rgb(p, q, h + 1.0 / 3.0), + hue_to_rgb(p, q, h), + hue_to_rgb(p, q, h - 1.0 / 3.0), + ) }; - ((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8) + (to_byte(r), to_byte(g), to_byte(b)) +} + +fn to_byte(value: f32) -> u8 { + (value.clamp(0.0, 1.0) * 255.0).round() as u8 +} + +/// Parses `RRGGBB` or `RRGGBBAA`, preserving the supplied alpha for six digits. +pub fn parse_hex_color(value: &str, current_alpha: u8) -> Option<[u8; 4]> { + let value = value.trim().trim_start_matches('#'); + if value.len() != 6 && value.len() != 8 { + return None; + } + let r = u8::from_str_radix(&value[0..2], 16).ok()?; + let g = u8::from_str_radix(&value[2..4], 16).ok()?; + let b = u8::from_str_radix(&value[4..6], 16).ok()?; + let a = if value.len() == 8 { + u8::from_str_radix(&value[6..8], 16).ok()? + } else { + current_alpha + }; + Some([r, g, b, a]) } fn hue_to_rgb(p: f32, q: f32, t: f32) -> f32 { - let t = if t < 0.0 { t + 1.0 } else if t > 1.0 { t - 1.0 } else { t }; - if t < 1.0 / 6.0 { p + (q - p) * 6.0 * t } - else if t < 1.0 / 2.0 { q } - else if t < 2.0 / 3.0 { p + (q - p) * (2.0 / 3.0 - t) * 6.0 } - else { p } + let t = if t < 0.0 { + t + 1.0 + } else if t > 1.0 { + t - 1.0 + } else { + t + }; + if t < 1.0 / 6.0 { + p + (q - p) * 6.0 * t + } else if t < 1.0 / 2.0 { + q + } else if t < 2.0 / 3.0 { + p + (q - p) * (2.0 / 3.0 - t) * 6.0 + } else { + p + } +} + +/// Builds the shared popup overlay for the active target. +/// +/// The popup keeps alpha and hexadecimal editing common to all three compact +/// tabs. RGB and HSL expose sliders plus numeric inputs, while Wheel uses a +/// custom Iced canvas with continuous hue-ring and saturation/lightness hit +/// testing. Changes are emitted immediately through `PopupColorChanged`. +pub fn popup_view( + color: [u8; 4], + hex_value: &str, + tab: usize, + colors: ThemeColors, +) -> Element<'static, Message> { + let (h, s, l) = rgb_to_hsl(color[0], color[1], color[2]); + let tab_button = |label: &'static str, index: usize| { + let active = tab == index; + button(text(label).size(10)) + .on_press(Message::ColorPickerTabChanged(index)) + .padding([4, 10]) + .style(move |_theme, status| iced::widget::button::Style { + background: Some(iced::Background::Color(if active { + colors.bg_active + } else if status == iced::widget::button::Status::Hovered { + colors.bg_hover + } else { + colors.bg_panel + })), + text_color: if active { + colors.accent + } else { + colors.text_primary + }, + border: iced::Border::default() + .color(if active { + colors.accent + } else { + colors.border_low + }) + .width(1) + .rounded(3), + ..Default::default() + }) + }; + + let body: Element<'static, Message> = match tab { + 0 => rgb_popup_section(color), + 1 => Canvas::new(ColorWheel { color }) + .width(Length::Fixed(220.0)) + .height(Length::Fixed(220.0)) + .into(), + _ => hsl_popup_section(color, h, s, l), + }; + + let alpha = color[3]; + let alpha_row = row![ + text("A").size(10).width(16), + slider(0..=255u8, alpha, move |value| { + Message::PopupColorChanged([color[0], color[1], color[2], value]) + }) + .width(Length::Fill), + text(format!("{alpha}")).size(10).width(28), + ] + .spacing(5) + .align_y(iced::Alignment::Center); + + let popup = column![ + row![ + text("Color Selector").size(12).width(Length::Fill), + button(text("X").size(10)) + .on_press(Message::CloseColorPicker) + .padding([2, 7]), + ] + .align_y(iced::Alignment::Center), + row![ + tab_button("RGB", 0), + tab_button("Wheel", 1), + tab_button("HSL", 2) + ] + .spacing(3), + body, + alpha_row, + row![ + text("Hex").size(10), + text_input("#RRGGBBAA", hex_value) + .on_input(Message::ColorPickerHexChanged) + .font(iced::Font::MONOSPACE) + .size(10) + .width(Length::Fill), + ] + .spacing(6) + .align_y(iced::Alignment::Center), + ] + .spacing(7) + .padding(10) + .width(Length::Fill); + + let panel = container(scrollable(popup).height(Length::Shrink)) + .width(Length::Fill) + .max_width(320.0) + .max_height(520.0) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(colors.bg_panel)), + border: iced::Border::default() + .color(colors.border_high) + .width(1) + .rounded(6), + shadow: iced::Shadow { + color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.65), + offset: iced::Vector::new(0.0, 3.0), + blur_radius: 14.0, + }, + ..Default::default() + }); + + container(panel) + .padding(8) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() +} + +/// Builds RGB sliders and directly editable byte values. +fn rgb_popup_section(color: [u8; 4]) -> Element<'static, Message> { + let channel = |label, index: usize| { + let slider_color = color; + let input_color = color; + row![ + text(label).size(10).width(16), + slider(0..=255u8, color[index], move |value| { + let mut next = slider_color; + next[index] = value; + Message::PopupColorChanged(next) + }) + .width(Length::Fill), + text_input("0", &color[index].to_string()) + .on_input(move |value| match value.parse::() { + 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 for ColorWheel { + type State = ColorWheelState; + + fn draw( + &self, + _state: &Self::State, + renderer: &iced::Renderer, + _theme: &iced::Theme, + bounds: Rectangle, + _cursor: mouse::Cursor, + ) -> Vec { + 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) { + let local = cursor.position_in(bounds); + match event { + canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) { + state.dragging = true; + return ( + canvas::event::Status::Captured, + Some(Message::PopupColorChanged(color)), + ); + } + } + canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => { + if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) { + return ( + canvas::event::Status::Captured, + Some(Message::PopupColorChanged(color)), + ); + } + } + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + state.dragging = false; + } + _ => {} + } + (canvas::event::Status::Ignored, None) + } + + fn mouse_interaction( + &self, + _state: &Self::State, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> mouse::Interaction { + if cursor.is_over(bounds) { + mouse::Interaction::Crosshair + } else { + mouse::Interaction::default() + } + } } /// Build the full color picker panel. @@ -234,9 +619,15 @@ pub fn view<'a>( .height(20) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(iced::Color::from_rgba( - fg[0] as f32 / 255.0, fg[1] as f32 / 255.0, fg[2] as f32 / 255.0, fg[3] as f32 / 255.0, + fg[0] as f32 / 255.0, + fg[1] as f32 / 255.0, + fg[2] as f32 / 255.0, + fg[3] as f32 / 255.0, ))), - border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1).rounded(2), + border: iced::Border::default() + .color(iced::Color::from_rgb(0.4, 0.4, 0.4)) + .width(1) + .rounded(2), ..Default::default() }); let secondary_swatch = container(text("")) @@ -244,15 +635,23 @@ pub fn view<'a>( .height(20) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(iced::Color::from_rgba( - bg[0] as f32 / 255.0, bg[1] as f32 / 255.0, bg[2] as f32 / 255.0, bg[3] as f32 / 255.0, + bg[0] as f32 / 255.0, + bg[1] as f32 / 255.0, + bg[2] as f32 / 255.0, + bg[3] as f32 / 255.0, ))), - border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1).rounded(2), + border: iced::Border::default() + .color(iced::Color::from_rgb(0.4, 0.4, 0.4)) + .width(1) + .rounded(2), ..Default::default() }); let swap_btn = button(text("\u{21C5}").size(11)) .on_press(Message::SwapColors) .padding([2, 4]); - let swatch_row = row![primary_swatch, secondary_swatch, swap_btn].spacing(4).align_y(iced::Alignment::Center); + let swatch_row = row![primary_swatch, secondary_swatch, swap_btn] + .spacing(4) + .align_y(iced::Alignment::Center); // ── 2. Hex input ── // Preserve the current alpha channel: the hex field only edits RGB, but the @@ -277,7 +676,9 @@ pub fn view<'a>( .width(70) .font(iced::Font::MONOSPACE) .size(10); - let hex_row = row![text("Hex:").size(10), hex_input].spacing(4).align_y(iced::Alignment::Center); + let hex_row = row![text("Hex:").size(10), hex_input] + .spacing(4) + .align_y(iced::Alignment::Center); // ── 2b. Alpha slider ── // Allows transparency editing, which egui supports in the layer-style panel @@ -300,13 +701,18 @@ pub fn view<'a>( // ── 3. Tab bar ── let tab_w = 30; let make_tab = |label: &'static str, idx: usize, color_tab: usize| { - button(text(label).size(10)).padding([2, tab_w / 2 - 4]) + button(text(label).size(10)) + .padding([2, tab_w / 2 - 4]) .on_press(Message::ColorTabChanged(idx)) .style(move |_theme, _status| iced::widget::button::Style { background: if color_tab == idx { - Some(iced::Background::Color(iced::Color::from_rgb(0.35, 0.35, 0.35))) + Some(iced::Background::Color(iced::Color::from_rgb( + 0.35, 0.35, 0.35, + ))) } else { - Some(iced::Background::Color(iced::Color::from_rgb(0.22, 0.22, 0.22))) + Some(iced::Background::Color(iced::Color::from_rgb( + 0.22, 0.22, 0.22, + ))) }, text_color: iced::Color::from_rgb(0.8, 0.8, 0.8), border: iced::Border::default().rounded(2), @@ -317,7 +723,8 @@ pub fn view<'a>( make_tab("W", 0, color_tab), make_tab("H", 1, color_tab), make_tab("G", 2, color_tab), - ].spacing(1); + ] + .spacing(1); // ── 4. Tab content ── let tab_content: Element<'_, Message> = match color_tab { @@ -332,7 +739,11 @@ pub fn view<'a>( let c = color; let is_selected = c == *fg_color; let brightness = c[0] as f32 * 0.299 + c[1] as f32 * 0.587 + c[2] as f32 * 0.114; - let dot_color = if brightness > 128.0 { iced::Color::BLACK } else { iced::Color::WHITE }; + let dot_color = if brightness > 128.0 { + iced::Color::BLACK + } else { + iced::Color::WHITE + }; let dot = text(if is_selected { "\u{2022}" } else { "" }) .size(10) @@ -347,10 +758,16 @@ pub fn view<'a>( .center_y(Length::Fill) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(iced::Color::from_rgba( - c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, c[3] as f32 / 255.0, + c[0] as f32 / 255.0, + c[1] as f32 / 255.0, + c[2] as f32 / 255.0, + c[3] as f32 / 255.0, ))), border: if is_selected { - iced::Border::default().color(dot_color).width(1.0).rounded(2) + iced::Border::default() + .color(dot_color) + .width(1.0) + .rounded(2) } else { iced::Border::default().rounded(2) }, @@ -394,11 +811,16 @@ fn build_wheel_view<'a>(h: f32, s: f32, l: f32, _fg_color: &'a [u8; 4]) -> Eleme for i in 0..36 { let hue = i as f32 * 10.0; let (r, g, b) = hsl_to_rgb(hue, 1.0, 0.5); - let cell_color = iced::Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0); + let cell_color = + iced::Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0); let is_current_h = (h - hue).abs() < 5.0 || (h - hue).abs() > 355.0; let is_selected = is_current_h && (s - 1.0).abs() < 0.1 && (l - 0.5).abs() < 0.1; let brightness = r as f32 * 0.299 + g as f32 * 0.587 + b as f32 * 0.114; - let dot_color = if brightness > 128.0 { iced::Color::BLACK } else { iced::Color::WHITE }; + let dot_color = if brightness > 128.0 { + iced::Color::BLACK + } else { + iced::Color::WHITE + }; let dot = text(if is_selected { "\u{2022}" } else { "" }) .size(8) @@ -442,9 +864,14 @@ fn build_wheel_view<'a>(h: f32, s: f32, l: f32, _fg_color: &'a [u8; 4]) -> Eleme let sat = col_idx as f32 / 11.0; let (r, g, b) = hsl_to_rgb(h, sat, lightness); let is_current = (s - sat).abs() < 0.05 && (l - lightness).abs() < 0.05; - let cell_color = iced::Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0); + let cell_color = + iced::Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0); let brightness = r as f32 * 0.299 + g as f32 * 0.587 + b as f32 * 0.114; - let dot_color = if brightness > 128.0 { iced::Color::BLACK } else { iced::Color::WHITE }; + let dot_color = if brightness > 128.0 { + iced::Color::BLACK + } else { + iced::Color::WHITE + }; let dot = text(if is_current { "\u{2022}" } else { "" }) .size(8) @@ -481,8 +908,14 @@ fn build_wheel_view<'a>(h: f32, s: f32, l: f32, _fg_color: &'a [u8; 4]) -> Eleme } // HSL readout - let readout = text(format!("H:{:.0}\u{00B0} S:{:.0}% L:{:.0}%", h, s * 100.0, l * 100.0)) - .size(9).font(iced::Font::MONOSPACE); + let readout = text(format!( + "H:{:.0}\u{00B0} S:{:.0}% L:{:.0}%", + h, + s * 100.0, + l * 100.0 + )) + .size(9) + .font(iced::Font::MONOSPACE); column![ text("Hue").size(9), @@ -500,20 +933,38 @@ fn build_slider_view<'a>(h: f32, s: f32, l: f32) -> Element<'a, Message> { let h_slider = slider(0.0..=360.0, h, move |v: f32| { let (r, g, b) = hsl_to_rgb(v, s, l); Message::FgColorChanged([r, g, b, 255]) - }).step(1.0); + }) + .step(1.0); let s_slider = slider(0.0..=100.0, s * 100.0, move |v: f32| { let (r, g, b) = hsl_to_rgb(h, v / 100.0, l); Message::FgColorChanged([r, g, b, 255]) - }).step(1.0); + }) + .step(1.0); let l_slider = slider(0.0..=100.0, l * 100.0, move |v: f32| { let (r, g, b) = hsl_to_rgb(h, s, v / 100.0); Message::FgColorChanged([r, g, b, 255]) - }).step(1.0); + }) + .step(1.0); column![ - row![text("H").size(9), h_slider, text(format!("{:.0}\u{00B0}", h)).size(9)].spacing(2), - row![text("S").size(9), s_slider, text(format!("{:.0}%", s * 100.0)).size(9)].spacing(2), - row![text("L").size(9), l_slider, text(format!("{:.0}%", l * 100.0)).size(9)].spacing(2), + row![ + text("H").size(9), + h_slider, + text(format!("{:.0}\u{00B0}", h)).size(9) + ] + .spacing(2), + row![ + text("S").size(9), + s_slider, + text(format!("{:.0}%", s * 100.0)).size(9) + ] + .spacing(2), + row![ + text("L").size(9), + l_slider, + text(format!("{:.0}%", l * 100.0)).size(9) + ] + .spacing(2), ] .spacing(2) .into() @@ -528,7 +979,11 @@ fn build_grid_view<'a>(fg_color: &'a [u8; 4]) -> Element<'a, Message> { let c = palette_color(row_idx, col_idx); let is_selected = [c[0], c[1], c[2], 255] == *fg_color; let brightness = c[0] as f32 * 0.299 + c[1] as f32 * 0.587 + c[2] as f32 * 0.114; - let dot_color = if brightness > 128.0 { iced::Color::BLACK } else { iced::Color::WHITE }; + let dot_color = if brightness > 128.0 { + iced::Color::BLACK + } else { + iced::Color::WHITE + }; let dot = text(if is_selected { "\u{2022}" } else { "" }) .size(10) @@ -543,10 +998,15 @@ fn build_grid_view<'a>(fg_color: &'a [u8; 4]) -> Element<'a, Message> { .center_y(Length::Fill) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(iced::Color::from_rgb( - c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, + c[0] as f32 / 255.0, + c[1] as f32 / 255.0, + c[2] as f32 / 255.0, ))), border: if is_selected { - iced::Border::default().color(dot_color).width(1.0).rounded(1) + iced::Border::default() + .color(dot_color) + .width(1.0) + .rounded(1) } else { iced::Border::default().rounded(1) }, @@ -560,10 +1020,44 @@ fn build_grid_view<'a>(fg_color: &'a [u8; 4]) -> Element<'a, Message> { palette_grid = palette_grid.push(row_widgets); } - column![ - text("Palette").size(10), - palette_grid, - ] - .spacing(2) - .into() + column![text("Palette").size(10), palette_grid,] + .spacing(2) + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rgb_hsl_roundtrip_preserves_channels_with_rounding_tolerance() { + for color in [[0, 0, 0], [255, 255, 255], [231, 17, 93], [12, 190, 244]] { + let (h, s, l) = rgb_to_hsl(color[0], color[1], color[2]); + let actual = hsl_to_rgb(h, s, l); + for (expected, actual) in color.into_iter().zip([actual.0, actual.1, actual.2]) { + assert!((expected as i16 - actual as i16).abs() <= 1); + } + } + } + + #[test] + fn rgb_hsv_roundtrip_preserves_channels_with_rounding_tolerance() { + for color in [[0, 0, 0], [255, 255, 255], [231, 17, 93], [12, 190, 244]] { + let hsv = rgb_to_hsv(color[0], color[1], color[2]); + let actual = hsv.to_rgb(); + for (expected, actual) in color.into_iter().zip([actual.0, actual.1, actual.2]) { + assert!((expected as i16 - actual as i16).abs() <= 1); + } + } + } + + #[test] + fn hex_parser_preserves_or_explicitly_updates_alpha() { + assert_eq!(parse_hex_color("#112233", 77), Some([0x11, 0x22, 0x33, 77])); + assert_eq!( + parse_hex_color("11223344", 77), + Some([0x11, 0x22, 0x33, 0x44]) + ); + assert_eq!(parse_hex_color("xyz", 77), None); + } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/about.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/about.rs index 7d5af2b..5a31d7e 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/about.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/about.rs @@ -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) } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/adjustments.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/adjustments.rs index c771d03..01d4d0f 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/adjustments.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/adjustments.rs @@ -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) } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/canvas_size.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/canvas_size.rs index f7133eb..dc941e3 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/canvas_size.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/canvas_size.rs @@ -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) } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/confirm.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/confirm.rs index f1baed2..53b4cbb 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/confirm.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/confirm.rs @@ -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) } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/dock_profile.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/dock_profile.rs index 6559bde..d11f44f 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/dock_profile.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/dock_profile.rs @@ -1,7 +1,8 @@ //! Dock Profile dialog — save, load, rename, and delete dock layout profiles. use crate::app::Message; -use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, text_input}; +use crate::theme::ThemeColors; +use iced::widget::{button, column, horizontal_rule, row, scrollable, text, text_input}; use iced::{Element, Length}; /// Build the dock profile management dialog. @@ -17,6 +18,7 @@ pub fn view( profiles: &[crate::settings::DockProfile], active_profile: Option<&str>, new_name: &str, + colors: ThemeColors, ) -> Element<'static, Message> { let new_name_owned = new_name.to_string(); @@ -24,9 +26,7 @@ pub fn view( let mut profile_list = column![].spacing(4); if profiles.is_empty() { - profile_list = profile_list.push( - text("No saved profiles yet.").size(11), - ); + profile_list = profile_list.push(text("No saved profiles yet.").size(11)); } else { for profile in profiles { let name = profile.name.clone(); @@ -45,9 +45,7 @@ pub fn view( .on_press(Message::DockProfileDelete(name.clone())) .padding([4, 8]); - let name_text = text(name.clone()) - .size(12) - .width(Length::Fill); + let name_text = text(name.clone()).size(12).width(Length::Fill); profile_list = profile_list.push( row![apply_btn, name_text, rename_btn, delete_btn] @@ -68,8 +66,7 @@ pub fn view( .on_press(Message::DockProfileSave(new_name_owned.trim().to_string())) .padding([6, 12]) } else { - button(text("Save Current").size(11)) - .padding([6, 12]) + button(text("Save Current").size(11)).padding([6, 12]) }; // Scrollable profile list with fixed max height @@ -88,20 +85,11 @@ pub fn view( row![button(text("Close").size(11)) .on_press(Message::DockProfileDialogHide) .padding([6, 12])] - .width(Length::Fill), + .width(Length::Fill), ] .spacing(8) .padding(16) .width(420); - container(dialog) - .center_x(Length::Fill) - .center_y(Length::Fill) - .style(|_theme| iced::widget::container::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.5))), - ..Default::default() - }) - .width(Length::Fill) - .height(Length::Fill) - .into() + super::modal(dialog, colors) } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/image_size.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/image_size.rs index a84d6dc..eb73f06 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/image_size.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/image_size.rs @@ -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) } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/mod.rs index f3de249..58d50f4 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/mod.rs @@ -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>, + 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() +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/new_image.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/new_image.rs index 2688531..f42d12c 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/new_image.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/new_image.rs @@ -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) } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs index f676e70..f28555f 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs @@ -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::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 { - 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 { - 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 diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs index 0cc1d26..8f152b6 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs @@ -8,9 +8,10 @@ use crate::app::Message; use crate::dock::state::{DockState, PaneType}; use crate::panels::styles; -use iced::widget::svg; +use crate::panels::typography::TITLE; use crate::theme::ThemeColors; use iced::widget::pane_grid::{Content, TitleBar}; +use iced::widget::svg; use iced::widget::{button, column, container, pane_grid, row, text}; use iced::{Element, Length}; @@ -40,9 +41,21 @@ pub fn dock_view<'a>( let doc_tab_bar = document_tab_bar(app, colors); let canvas = { let doc = &app.documents[app.active_doc]; - let pressure = app.tablet_state.lock().map(|s| s.current_pressure()).unwrap_or(1.0); + let pressure = app + .tablet_state + .lock() + .map(|s| s.current_pressure()) + .unwrap_or(1.0); let ts = &app.settings.tool_settings; - crate::canvas::view(doc, &app.tool_state, app.marching_ants_offset, pressure, ts.vector_points, ts.vector_sides, ts.vector_radius) + crate::canvas::view( + doc, + &app.tool_state, + app.marching_ants_offset, + pressure, + ts.vector_points, + ts.vector_sides, + ts.vector_radius, + ) }; column![doc_tab_bar, canvas] .width(Length::Fill) @@ -52,27 +65,39 @@ pub fn dock_view<'a>( // Tools is no longer in the dock — toolbox is a fixed strip on the left PaneType::Layers => { let doc = &app.documents[app.active_doc]; - crate::panels::layers::view(&doc.cached_layers, doc.engine.active_layer_id(), &doc.engine, colors) + crate::panels::layers::view( + &doc.cached_layers, + doc.engine.active_layer_id(), + &doc.engine, + colors, + ) } PaneType::History => { let doc = &app.documents[app.active_doc]; crate::panels::history::view(&doc.cached_history, doc.history_current, colors) } - PaneType::Brushes => { - crate::panels::brushes::view( - app.tool_state.brush_style, - app.tool_state.brush_size, - app.tool_state.brush_opacity, - app.tool_state.brush_hardness, - colors, - ) - } - PaneType::Filters => { - crate::panels::filters::view(app.selected_filter, &app.filter_params, app.filter_preview_active, colors) - } - PaneType::ColorPicker => { - crate::color_picker::view(&app.fg_color, &app.bg_color, &app.recent_colors, app.color_tab) - } + PaneType::Brushes => crate::panels::brushes::view( + app.tool_state.brush_style, + app.tool_state.brush_size, + app.tool_state.brush_opacity, + app.tool_state.brush_hardness, + colors, + ), + PaneType::Filters => crate::panels::filters::view( + app.selected_filter, + &app.filter_params, + app.filter_preview_active, + app.documents[app.active_doc] + .engine + .active_layer_is_editable(), + colors, + ), + PaneType::ColorPicker => crate::color_picker::view( + &app.fg_color, + &app.bg_color, + &app.recent_colors, + app.color_tab, + ), PaneType::Properties => { let doc = &app.documents[app.active_doc]; let active_id = doc.engine.active_layer_id(); @@ -80,8 +105,12 @@ pub fn dock_view<'a>( let layer_name = layer_info.map(|l| l.name.as_str()).unwrap_or("—"); let layer_opacity = layer_info.map(|l| l.opacity).unwrap_or(1.0); let layer_visible = layer_info.map(|l| l.visible).unwrap_or(true); - let layer_blend_mode = layer_info.map(|l| l.blend_mode).unwrap_or(hcie_engine_api::BlendMode::Normal); - let layer_type = layer_info.map(|l| l.layer_type).unwrap_or(hcie_engine_api::LayerType::Raster); + let layer_blend_mode = layer_info + .map(|l| l.blend_mode) + .unwrap_or(hcie_engine_api::BlendMode::Normal); + let layer_type = layer_info + .map(|l| l.layer_type) + .unwrap_or(hcie_engine_api::LayerType::Raster); let layer_width = layer_info.map(|l| l.width).unwrap_or(0); let layer_height = layer_info.map(|l| l.height).unwrap_or(0); let vector_shapes = doc.engine.active_vector_shapes().unwrap_or_default(); @@ -110,29 +139,32 @@ pub fn dock_view<'a>( ts2.vector_points, ts2.vector_sides, active_id, + app.fg_color, + app.bg_color, ) } - PaneType::Script => { - crate::panels::script_panel::view( - &app.script_content, - app.script_error.as_deref(), - app.script_error_line, - app.script_is_running, + PaneType::Script => crate::panels::script_panel::view( + &app.script_content, + app.script_error.as_deref(), + app.script_error_line, + app.script_is_running, + colors, + ), + PaneType::AiChat => crate::panels::ai_chat_panel::view(&app.ai_chat_state, colors), + PaneType::AiScript => crate::panels::ai_script_panel::view(app), + PaneType::LayerDetails => { + let doc = &app.documents[app.active_doc]; + crate::panels::layer_details::view( + &doc.cached_layers, + doc.engine.active_layer_id(), colors, ) } - PaneType::AiChat => { - crate::panels::ai_chat_panel::view(&app.ai_chat_state, colors) - } - PaneType::AiScript => { - crate::panels::ai_script_panel::view(app) - } - PaneType::LayerDetails => { - let doc = &app.documents[app.active_doc]; - crate::panels::layer_details::view(&doc.cached_layers, doc.engine.active_layer_id(), colors) - } PaneType::Geometry => { - let shapes = app.documents[app.active_doc].engine.active_vector_shapes().unwrap_or_default(); + let shapes = app.documents[app.active_doc] + .engine + .active_vector_shapes() + .unwrap_or_default(); crate::panels::geometry::view( shapes, colors, @@ -144,7 +176,13 @@ pub fn dock_view<'a>( PaneType::ToolSettings => { let fg = app.fg_color; let draft = app.documents[app.active_doc].text_draft.as_ref(); - crate::panels::tool_settings::view(&app.tool_state, &app.settings, fg, draft, colors) + crate::panels::tool_settings::view( + &app.tool_state, + &app.settings, + fg, + draft, + colors, + ) } }; @@ -173,7 +211,10 @@ pub fn dock_view<'a>( .style(move |_theme| pane_grid::Style { hovered_region: pane_grid::Highlight { background: iced::Background::Color(iced::Color::from_rgba( - colors.accent.r, colors.accent.g, colors.accent.b, 0.15, + colors.accent.r, + colors.accent.g, + colors.accent.b, + 0.15, )), border: iced::Border::default().color(colors.accent).width(1), }, @@ -194,7 +235,10 @@ pub fn dock_view<'a>( /// Shows a horizontal tab for each open document. The active document tab /// is highlighted. Each tab shows the document name and a close button. /// Modified documents show an asterisk (*). Tabs have hover feedback. -fn document_tab_bar<'a>(app: &'a crate::app::HcieIcedApp, colors: ThemeColors) -> Element<'a, Message> { +fn document_tab_bar<'a>( + app: &'a crate::app::HcieIcedApp, + colors: ThemeColors, +) -> Element<'a, Message> { let mut tabs = row![].spacing(0); for (idx, doc) in app.documents.iter().enumerate() { @@ -219,33 +263,43 @@ fn document_tab_bar<'a>(app: &'a crate::app::HcieIcedApp, colors: ThemeColors) - let tab = button( row![ tab_text, - button(text("×").size(10)) - .on_press(Message::NoOp) - .padding([0, 4]) - .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { - match status { - iced::widget::button::Status::Hovered => iced::widget::button::Style { - background: Some(iced::Background::Color(c.bg_active)), - text_color: c.text_primary, - border: iced::Border::default(), - ..Default::default() - }, - _ => iced::widget::button::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))), - text_color: c.text_secondary, - border: iced::Border::default(), - ..Default::default() - }, - } - }), + iced::widget::tooltip( + button(text("×").size(10)) + .on_press(Message::DocClose(idx)) + .padding([0, 4]) + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| { + match status { + iced::widget::button::Status::Hovered => { + iced::widget::button::Style { + background: Some(iced::Background::Color(c.bg_active)), + text_color: c.text_primary, + border: iced::Border::default(), + ..Default::default() + } + } + _ => iced::widget::button::Style { + background: Some(iced::Background::Color( + iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0), + )), + text_color: c.text_secondary, + border: iced::Border::default(), + ..Default::default() + }, + } + } + ), + text(format!("Close {}", doc.name)).size(11), + iced::widget::tooltip::Position::Bottom, + ), ] .spacing(4) - .align_y(iced::Alignment::Center) + .align_y(iced::Alignment::Center), ) .on_press(Message::DocSwitch(idx)) .padding([4, 8]) - .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { - match status { + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { iced::widget::button::Status::Active => { if active { iced::widget::button::Style { @@ -286,8 +340,8 @@ fn document_tab_bar<'a>(app: &'a crate::app::HcieIcedApp, colors: ThemeColors) - text_color: c.accent, ..Default::default() }, - } - }); + }, + ); tabs = tabs.push(tab); } @@ -296,8 +350,8 @@ fn document_tab_bar<'a>(app: &'a crate::app::HcieIcedApp, colors: ThemeColors) - let new_btn = button(text("+").size(12)) .on_press(Message::DialogOpen(crate::app::ActiveDialog::NewImage)) .padding([4, 8]) - .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { - match status { + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { iced::widget::button::Status::Hovered => iced::widget::button::Style { background: Some(iced::Background::Color(colors.bg_hover)), text_color: colors.text_primary, @@ -305,13 +359,15 @@ fn document_tab_bar<'a>(app: &'a crate::app::HcieIcedApp, colors: ThemeColors) - ..Default::default() }, _ => iced::widget::button::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))), + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.0, 0.0, 0.0, 0.0, + ))), text_color: colors.text_secondary, border: iced::Border::default(), ..Default::default() }, - } - }); + }, + ); tabs = tabs.push(new_btn); @@ -345,7 +401,11 @@ fn pane_type_icon(pane_type: PaneType) -> Option<&'static str> { /// Shows a panel type icon + label with close and maximize buttons. /// Uses `panel_header` style from the theme for consistent header appearance. /// Buttons have hover feedback: transparent by default, bg_hover on hover. -fn make_title_bar<'a>(pane: pane_grid::Pane, pane_type: PaneType, colors: ThemeColors) -> TitleBar<'a, Message> { +fn make_title_bar<'a>( + pane: pane_grid::Pane, + pane_type: PaneType, + colors: ThemeColors, +) -> TitleBar<'a, Message> { // Panel type icon let icon_paths = [ std::path::PathBuf::from("hcie-iced-app/assets"), @@ -355,29 +415,39 @@ fn make_title_bar<'a>(pane: pane_grid::Pane, pane_type: PaneType, colors: ThemeC let title_content: Element<'_, Message> = if let Some(icon_file) = pane_type_icon(pane_type) { if let Some(base) = icon_paths.iter().find(|p| p.join(icon_file).exists()) { let handle = svg::Handle::from_path(base.join(icon_file)); - let icon = svg(handle) - .width(14) - .height(14) - .style(move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style { + let icon = svg(handle).width(14).height(14).style( + move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style { color: Some(colors.text_secondary), - }); - row![icon, text(pane_type.label()).size(11).color(colors.text_secondary)] - .spacing(4) - .align_y(iced::Alignment::Center) - .into() + }, + ); + row![ + icon, + text(pane_type.label()) + .size(TITLE) + .color(colors.text_secondary) + ] + .spacing(4) + .align_y(iced::Alignment::Center) + .into() } else { - text(pane_type.label()).size(11).color(colors.text_secondary).into() + text(pane_type.label()) + .size(TITLE) + .color(colors.text_secondary) + .into() } } else { - text(pane_type.label()).size(11).color(colors.text_secondary).into() + text(pane_type.label()) + .size(TITLE) + .color(colors.text_secondary) + .into() }; // Close button with hover state let close_btn = button(text("×").size(11)) .on_press(Message::PaneClose(pane)) .padding([2, 6]) - .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { - match status { + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { iced::widget::button::Status::Hovered => iced::widget::button::Style { background: Some(iced::Background::Color(colors.bg_hover)), text_color: colors.text_primary, @@ -391,20 +461,22 @@ fn make_title_bar<'a>(pane: pane_grid::Pane, pane_type: PaneType, colors: ThemeC ..Default::default() }, _ => iced::widget::button::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))), + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.0, 0.0, 0.0, 0.0, + ))), text_color: colors.text_secondary, border: iced::Border::default(), ..Default::default() }, - } - }); + }, + ); // Maximize button with hover state let maximize_btn = button(text("□").size(11)) .on_press(Message::PaneMaximize(pane)) .padding([2, 6]) - .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { - match status { + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { iced::widget::button::Status::Hovered => iced::widget::button::Style { background: Some(iced::Background::Color(colors.bg_hover)), text_color: colors.text_primary, @@ -418,18 +490,25 @@ fn make_title_bar<'a>(pane: pane_grid::Pane, pane_type: PaneType, colors: ThemeC ..Default::default() }, _ => iced::widget::button::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))), + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.0, 0.0, 0.0, 0.0, + ))), text_color: colors.text_secondary, border: iced::Border::default(), ..Default::default() }, - } - }); + }, + ); TitleBar::new( - row![title_content, iced::widget::Space::with_width(Length::Fill), maximize_btn, close_btn] - .spacing(2) - .align_y(iced::Alignment::Center) + row![ + title_content, + iced::widget::Space::with_width(Length::Fill), + maximize_btn, + close_btn + ] + .spacing(2) + .align_y(iced::Alignment::Center), ) .style(move |_theme| styles::panel_header(colors)) } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/io/tablet.rs b/hcie-iced-app/crates/hcie-iced-gui/src/io/tablet.rs index c2b0ce6..acd8a48 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/io/tablet.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/io/tablet.rs @@ -148,7 +148,12 @@ pub fn start_evdev_listener(state: Arc>) { 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), diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/lib.rs b/hcie-iced-app/crates/hcie-iced-gui/src/lib.rs index 7fb2e91..ddb02fc 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/lib.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/lib.rs @@ -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; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/main.rs b/hcie-iced-app/crates/hcie-iced-gui/src/main.rs index 2c473a9..2381938 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/main.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/main.rs @@ -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 = std::env::args().collect(); - let mut load_path: Option = None; - - let mut i = 1; - while i < args.len() { - match args[i].as_str() { - "--help" | "-h" => { - println!("Usage: hcie-iced [OPTIONS] [FILE]"); - println!(); - println!("HCIE — Iced Edition"); - println!(); - println!("Options:"); - println!(" -h, --help Print this help message"); - println!(); - println!("Arguments:"); - println!(" FILE Image file to open on startup"); - std::process::exit(0); - } - other if !other.starts_with('-') => { - load_path = Some(std::path::PathBuf::from(other)); - } - unknown => { - eprintln!("Unknown argument: {}", unknown); - std::process::exit(1); - } + let args = match cli::parse_args(std::env::args().skip(1)) { + Ok(args) => args, + Err(error) => { + eprintln!("{error}\n\n{}", cli::help_text()); + std::process::exit(2); } - i += 1; + }; + if args.help { + println!("{}", cli::help_text()); + return; } // Start tablet evdev listener - let tablet_state = std::sync::Arc::new(std::sync::Mutex::new( - io::tablet::TabletState::new(), - )); + let tablet_state = std::sync::Arc::new(std::sync::Mutex::new(io::tablet::TabletState::new())); io::tablet::start_evdev_listener(tablet_state.clone()); - let _ = iced::application("HCIE — Iced Edition", HcieIcedApp::update, HcieIcedApp::view) - .subscription(HcieIcedApp::subscription) - .theme(HcieIcedApp::theme) - .window_size((1280.0, 800.0)) - .decorations(false) - .run_with(move || { - let (mut app, init_task) = HcieIcedApp::new(load_path); - app.tablet_state = tablet_state; - (app, init_task) - }); + let load_path = args.load_path; + let screenshot = args.screenshot; + if let Err(error) = iced::application( + "HCIE — Iced Edition", + HcieIcedApp::update, + HcieIcedApp::view, + ) + .subscription(HcieIcedApp::subscription) + .theme(HcieIcedApp::theme) + .window_size((1280.0, 800.0)) + .decorations(false) + .run_with(move || { + let (mut app, init_task) = HcieIcedApp::new(load_path, screenshot); + app.tablet_state = tablet_state; + (app, init_task) + }) { + eprintln!("failed to run HCIE Iced GUI: {error}"); + std::process::exit(1); + } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/ai_chat_panel.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/ai_chat_panel.rs index 1d3062c..b361da5 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/ai_chat_panel.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/ai_chat_panel.rs @@ -15,9 +15,21 @@ use iced::{Element, Length}; pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Message> { // ── Provider selector row ── let provider_label = text("Provider:").size(10).color(colors.text_secondary); - let ollama_btn = make_provider_btn("Ollama", state.config.provider == crate::ai_chat::AiProvider::Ollama, colors); - let claude_btn = make_provider_btn("Claude", state.config.provider == crate::ai_chat::AiProvider::Claude, colors); - let openai_btn = make_provider_btn("OpenAI", state.config.provider == crate::ai_chat::AiProvider::OpenAI, colors); + let ollama_btn = make_provider_btn( + "Ollama", + state.config.provider == crate::ai_chat::AiProvider::Ollama, + colors, + ); + let claude_btn = make_provider_btn( + "Claude", + state.config.provider == crate::ai_chat::AiProvider::Claude, + colors, + ); + let openai_btn = make_provider_btn( + "OpenAI", + state.config.provider == crate::ai_chat::AiProvider::OpenAI, + colors, + ); let provider_row = row![provider_label, ollama_btn, claude_btn, openai_btn] .spacing(4) .align_y(iced::Alignment::Center); @@ -35,28 +47,37 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag .size(10) .width(Length::Fill); - let config_row = row![ - url_label, - url_input, - model_label, - model_input, - ] - .spacing(4) - .align_y(iced::Alignment::Center); + let config_row = row![url_label, url_input, model_label, model_input,] + .spacing(4) + .align_y(iced::Alignment::Center); // ── Toggles row ── let reasoning_label = text("Reasoning").size(9).color(colors.text_secondary); let reasoning_btn = button( - text(if state.config.reasoning_enabled { "ON" } else { "OFF" }).size(9) + text(if state.config.reasoning_enabled { + "ON" + } else { + "OFF" + }) + .size(9), ) - .on_press(Message::AiChatReasoningToggled(!state.config.reasoning_enabled)) + .on_press(Message::AiChatReasoningToggled( + !state.config.reasoning_enabled, + )) .padding([2, 6]); let canvas_label = text("Canvas ctx").size(9).color(colors.text_secondary); let canvas_btn = button( - text(if state.config.canvas_context { "ON" } else { "OFF" }).size(9) + text(if state.config.canvas_context { + "ON" + } else { + "OFF" + }) + .size(9), ) - .on_press(Message::AiChatCanvasContextToggled(!state.config.canvas_context)) + .on_press(Message::AiChatCanvasContextToggled( + !state.config.canvas_context, + )) .padding([2, 6]); let toggles_row = row![ @@ -78,12 +99,18 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag column![ text("AI Creative Assistant").size(13).color(colors.accent), text("").size(4), - text("Hello! I am your AI Drawing Partner.").size(11).color(colors.text_secondary), + text("Hello! I am your AI Drawing Partner.") + .size(11) + .color(colors.text_secondary), text("").size(4), - text("Ask me to paint landscapes, add details,").size(10).color(colors.text_disabled), - text("apply blur filters, or manipulate layers!").size(10).color(colors.text_disabled), + text("Ask me to paint landscapes, add details,") + .size(10) + .color(colors.text_disabled), + text("apply blur filters, or manipulate layers!") + .size(10) + .color(colors.text_disabled), ] - .spacing(2) + .spacing(2), ) .padding(12) .width(Length::Fill) @@ -105,16 +132,24 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag if state.config.reasoning_enabled && !state.current_reasoning.is_empty() { let reasoning_bubble = container( column![ - text("Deep Thoughts:").size(10).color(iced::Color::from_rgb(0.85, 0.47, 0.02)), - text(state.current_reasoning.clone()).size(10).color(colors.text_secondary), + text("Deep Thoughts:") + .size(10) + .color(iced::Color::from_rgb(0.85, 0.47, 0.02)), + text(state.current_reasoning.clone()) + .size(10) + .color(colors.text_secondary), ] - .spacing(2) + .spacing(2), ) .padding(8) .width(Length::Fill) .style(move |_theme| iced::widget::container::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba(0.15, 0.12, 0.08, 1.0))), - border: iced::Border::default().rounded(6).color(iced::Color::from_rgb(0.85, 0.47, 0.02)), + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.15, 0.12, 0.08, 1.0, + ))), + border: iced::Border::default() + .rounded(6) + .color(iced::Color::from_rgb(0.85, 0.47, 0.02)), ..Default::default() }); @@ -124,13 +159,19 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag // Render streaming response if in progress if state.is_streaming && !state.current_response.is_empty() { let streaming_bubble = container( - text(state.current_response.clone()).size(11).color(colors.text_primary) + text(state.current_response.clone()) + .size(11) + .color(colors.text_primary), ) .padding(8) .width(Length::Fill) .style(move |_theme| iced::widget::container::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba(0.18, 0.22, 0.28, 1.0))), - border: iced::Border::default().rounded(6).color(iced::Color::from_rgba(0.29, 0.62, 1.0, 0.5)), + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.18, 0.22, 0.28, 1.0, + ))), + border: iced::Border::default() + .rounded(6) + .color(iced::Color::from_rgba(0.29, 0.62, 1.0, 0.5)), ..Default::default() }); @@ -141,7 +182,9 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag if state.is_streaming { let indicator = row![ text("...").size(10).color(colors.text_disabled), - text("AI is thinking...").size(10).color(colors.text_disabled), + text("AI is thinking...") + .size(10) + .color(colors.text_disabled), ] .spacing(4); @@ -155,15 +198,24 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag .size(11) .width(Length::Fill); - let send_btn = button(text("Send").size(10)) - .on_press(Message::AiChatSend) - .padding([6, 10]); + let send_btn = action_button( + "Send", + (!state.is_streaming && !state.input.trim().is_empty()).then_some(Message::AiChatSend), + colors, + ); + let stream_btn = action_button( + "Cancel", + state.is_streaming.then_some(Message::AiChatCancel), + colors, + ); + let clear_btn = action_button("Clear", Some(Message::AiChatClear), colors); + let copy_btn = action_button( + "Copy all", + (!state.messages.is_empty() || state.is_streaming).then_some(Message::AiChatCopyTranscript), + colors, + ); - let clear_btn = button(text("Clear").size(10)) - .on_press(Message::AiChatClear) - .padding([6, 10]); - - let input_row = row![input_field, send_btn, clear_btn] + let input_row = row![input_field, send_btn, stream_btn, copy_btn, clear_btn] .spacing(4) .align_y(iced::Alignment::Center); @@ -172,6 +224,9 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag text("AI Assistant").size(13).color(colors.text_primary), provider_row, config_row, + text("Model discovery is unavailable; enter the exact provider model ID.") + .size(9) + .color(colors.text_disabled), toggles_row, horizontal_rule(1), scrollable(msg_list).height(Length::Fill), @@ -188,26 +243,82 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag .into() } +/// Build an action button whose disabled, hover, pressed, and focus-border states are explicit. +fn action_button( + label: &'static str, + message: Option, + colors: ThemeColors, +) -> Element<'static, Message> { + let mut control = button(text(label).size(10)).padding([6, 10]); + if let Some(message) = message { + control = control.on_press(message); + } + control + .style(move |_theme, status| { + let (background, text_color, border) = match status { + iced::widget::button::Status::Active => { + (colors.bg_active, colors.text_primary, colors.border_high) + } + iced::widget::button::Status::Hovered => { + (colors.accent_hover, iced::Color::WHITE, colors.accent) + } + iced::widget::button::Status::Pressed => { + (colors.accent, iced::Color::WHITE, colors.text_primary) + } + iced::widget::button::Status::Disabled => { + (colors.bg_app, colors.text_disabled, colors.border_low) + } + }; + iced::widget::button::Style { + background: Some(iced::Background::Color(background)), + text_color, + border: iced::Border::default().rounded(3).color(border).width(1), + ..Default::default() + } + }) + .into() +} + /// Render a single chat message as a styled bubble. fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'static, Message> { + if msg.is_reasoning { + return container( + column![ + text("Reasoning").size(9).color(colors.text_secondary), + text(msg.content.clone()) + .size(10) + .color(colors.text_secondary), + ] + .spacing(2), + ) + .padding(8) + .width(Length::Fill) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(colors.bg_app)), + border: iced::Border::default().rounded(6).color(colors.border_high), + ..Default::default() + }) + .into(); + } match msg.role.as_str() { "user" => { // User bubble — right-aligned, blue tint let label = text("You").size(9).color(colors.accent); - let content = text(msg.content.clone()).size(11).color(colors.text_primary); - let bubble = container( - column![label, content].spacing(2) - ) - .padding(8) - .max_width(400.0) - .style(move |_theme| iced::widget::container::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba(0.16, 0.33, 0.58, 1.0))), - border: iced::Border::default().rounded(6), - ..Default::default() - }); + let content = text(msg.content.clone()) + .size(11) + .color(colors.text_primary); + let bubble = container(column![label, content].spacing(2)) + .padding(8) + .max_width(400.0) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.16, 0.33, 0.58, 1.0, + ))), + border: iced::Border::default().rounded(6), + ..Default::default() + }); - row![iced::widget::Space::with_width(Length::Fill), bubble] - .into() + row![iced::widget::Space::with_width(Length::Fill), bubble].into() } "tool" => { // Tool result — monospace pill @@ -224,40 +335,45 @@ fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'sta _ => { // Assistant bubble — left-aligned, slate tint let label = text("AI").size(9).color(colors.accent_green); - let content = text(msg.content.clone()).size(11).color(colors.text_primary); - let bubble = container( - column![label, content].spacing(2) - ) - .padding(8) - .max_width(400.0) - .style(move |_theme| iced::widget::container::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba(0.18, 0.22, 0.28, 1.0))), - border: iced::Border::default().rounded(6), - ..Default::default() - }); + let content = text(msg.content.clone()) + .size(11) + .color(colors.text_primary); + let bubble = container(column![label, content].spacing(2)) + .padding(8) + .max_width(400.0) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.18, 0.22, 0.28, 1.0, + ))), + border: iced::Border::default().rounded(6), + ..Default::default() + }); - row![bubble, iced::widget::Space::with_width(Length::Fill)] - .into() + row![bubble, iced::widget::Space::with_width(Length::Fill)].into() } } } /// Create a provider selection button with active state highlighting. -fn make_provider_btn(label: &str, is_active: bool, colors: ThemeColors) -> Element<'static, Message> { +fn make_provider_btn( + label: &str, + is_active: bool, + colors: ThemeColors, +) -> Element<'static, Message> { let label_owned = label.to_string(); let msg = match label { "Ollama" => Message::AiChatProviderChanged("Ollama".to_string()), "Claude" => Message::AiChatProviderChanged("Claude".to_string()), "OpenAI" => Message::AiChatProviderChanged("OpenAI".to_string()), - _ => Message::NoOp, + _ => unreachable!("provider buttons are built from known labels"), }; button(text(label_owned).size(10)) .on_press(msg) .padding([4, 8]) - .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { - match status { - iced::widget::button::Status::Active | iced::widget::button::Status::Hovered => { + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { + iced::widget::button::Status::Active => { if is_active { iced::widget::button::Style { background: Some(iced::Background::Color(colors.accent)), @@ -274,13 +390,33 @@ fn make_provider_btn(label: &str, is_active: bool, colors: ThemeColors) -> Eleme } } } - _ => iced::widget::button::Style { - background: Some(iced::Background::Color(colors.bg_hover)), - text_color: colors.text_secondary, + iced::widget::button::Status::Hovered => iced::widget::button::Style { + background: Some(iced::Background::Color(if is_active { + colors.accent_hover + } else { + colors.bg_active + })), + text_color: if is_active { + iced::Color::WHITE + } else { + colors.text_primary + }, + border: iced::Border::default().rounded(4).color(colors.border_high), + ..Default::default() + }, + iced::widget::button::Status::Pressed => iced::widget::button::Style { + background: Some(iced::Background::Color(colors.bg_active)), + text_color: colors.text_primary, + border: iced::Border::default().rounded(4).color(colors.accent), + ..Default::default() + }, + iced::widget::button::Status::Disabled => iced::widget::button::Style { + background: Some(iced::Background::Color(colors.bg_app)), + text_color: colors.text_disabled, border: iced::Border::default().rounded(4), ..Default::default() }, - } - }) + }, + ) .into() } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/ai_script_panel.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/ai_script_panel.rs index 405f614..40d7931 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/ai_script_panel.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/ai_script_panel.rs @@ -14,6 +14,7 @@ use iced::{Element, Length}; /// action buttons (Generate, Clear, Run, Copy), and the generated script /// output with error display. pub fn view(app: &HcieIcedApp) -> Element<'static, Message> { + let colors = app.theme_state.colors(); // ── Provider selector row ──────────────────────────── let provider_label = text("Provider").size(10); let ollama_btn = button(text("Ollama").size(10)) @@ -50,13 +51,28 @@ pub fn view(app: &HcieIcedApp) -> Element<'static, Message> { .padding([6, 12]) }; - let run_btn = button(text("Run Script").size(11)) - .on_press(Message::AiScriptRun) - .padding([6, 12]); + let run_btn = if app.ai_script_output.is_empty() || app.ai_script_is_running { + button(text("Run Script").size(11)).padding([6, 12]) + } else { + button(text("Run Script").size(11)) + .on_press(Message::AiScriptRun) + .padding([6, 12]) + }; - let copy_btn = button(text("Copy to Editor").size(11)) - .on_press(Message::AiScriptCopy) - .padding([6, 12]); + let copy_btn = if app.ai_script_output.is_empty() { + button(text("To Editor").size(11)).padding([6, 12]) + } else { + button(text("To Editor").size(11)) + .on_press(Message::AiScriptCopy) + .padding([6, 12]) + }; + let copy_output_btn = if app.ai_script_output.is_empty() { + button(text("Copy Output").size(11)).padding([6, 12]) + } else { + button(text("Copy Output").size(11)) + .on_press(Message::AiScriptCopyOutput) + .padding([6, 12]) + }; let clear_btn = button(text("Clear").size(11)) .on_press(Message::AiScriptClear) @@ -69,24 +85,30 @@ pub fn view(app: &HcieIcedApp) -> Element<'static, Message> { text(app.ai_script_output.clone()).size(10) }; - let script_area = container( - scrollable(script_content).height(Length::Fill) - ) - .padding(8) - .style(move |_theme| iced::widget::container::Style { - background: Some(iced::Background::Color(iced::Color::from_rgb(0.12, 0.12, 0.12))), - border: iced::Border::default().rounded(4), - ..Default::default() - }); + let script_area = container(scrollable(script_content).height(Length::Fill)) + .padding(8) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(colors.bg_app)), + border: iced::Border::default().rounded(4).color(colors.border_high), + ..Default::default() + }); // ── Error display ──────────────────────────────────── let error_section: Element<'static, Message> = if let Some(ref err) = app.ai_script_error { text(format!("Error: {}", err)) .size(10) - .color(iced::Color::from_rgb(0.9, 0.3, 0.3)) + .color(colors.danger) .into() } else { - column![].into() + app.ai_script_notice.as_ref().map_or_else( + || column![].into(), + |notice| { + text(notice.clone()) + .size(10) + .color(colors.accent_green) + .into() + }, + ) }; // ── Assemble panel ─────────────────────────────────── @@ -97,6 +119,9 @@ pub fn view(app: &HcieIcedApp) -> Element<'static, Message> { provider_selector, url_input, model_input, + text("Model discovery is unavailable; enter the exact provider model ID.") + .size(9) + .color(colors.text_disabled), horizontal_rule(1), prompt_input, row![generate_btn, clear_btn].spacing(4), @@ -104,7 +129,7 @@ pub fn view(app: &HcieIcedApp) -> Element<'static, Message> { script_area, error_section, horizontal_rule(1), - row![run_btn, copy_btn].spacing(4), + row![run_btn, copy_btn, copy_output_btn].spacing(4), ] .spacing(4) .padding(8); @@ -113,8 +138,8 @@ pub fn view(app: &HcieIcedApp) -> Element<'static, Message> { .width(Length::Fill) .height(Length::Fill) .style(move |_theme| iced::widget::container::Style { - background: Some(iced::Background::Color(iced::Color::from_rgb(0.18, 0.18, 0.18))), - border: iced::Border::default().color(iced::Color::from_rgb(0.3, 0.3, 0.3)).width(1), + background: Some(iced::Background::Color(colors.bg_panel)), + border: iced::Border::default().color(colors.border_high).width(1), ..Default::default() }) .into() diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs index 517f944..3987721 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs @@ -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 { 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 { let dst_a = pixels[idx + 3] as f32 / 255.0; let out_a = src_a + dst_a * (1.0 - src_a); if out_a > 0.0 { - pixels[idx] = ((color[0] as f32 * src_a + pixels[idx] as f32 * dst_a * (1.0 - src_a)) / out_a) as u8; - pixels[idx + 1] = ((color[1] as f32 * src_a + pixels[idx + 1] as f32 * dst_a * (1.0 - src_a)) / out_a) as u8; - pixels[idx + 2] = ((color[2] as f32 * src_a + pixels[idx + 2] as f32 * dst_a * (1.0 - src_a)) / out_a) as u8; + pixels[idx] = ((color[0] as f32 * src_a + + pixels[idx] as f32 * dst_a * (1.0 - src_a)) + / out_a) as u8; + pixels[idx + 1] = ((color[1] as f32 * src_a + + pixels[idx + 1] as f32 * dst_a * (1.0 - src_a)) + / out_a) as u8; + pixels[idx + 2] = ((color[2] as f32 * src_a + + pixels[idx + 2] as f32 * dst_a * (1.0 - src_a)) + / out_a) as u8; pixels[idx + 3] = (out_a * 255.0) as u8; } } @@ -195,37 +299,65 @@ pub fn view( ) -> Element<'static, Message> { // Category tabs let categories = [ - (BrushCategory::All, "All"), - (BrushCategory::Drawing, "Drawing"), - (BrushCategory::Painting, "Painting"), - (BrushCategory::Effects, "Effects"), + (BrushCategory::All, "All brushes", "icons/panel-brush.svg"), + (BrushCategory::Drawing, "Drawing", "icons/pen.svg"), + (BrushCategory::Painting, "Painting", "icons/brush.svg"), + (BrushCategory::Effects, "Effects", "icons/glow.svg"), ]; let mut tabs = row![].spacing(2); - for (_cat, label) in categories { - let btn = button(text(label).size(10)) - .on_press(Message::NoOp) // TODO: category filter - .padding([4, 8]) - .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { - match status { - iced::widget::button::Status::Hovered => iced::widget::button::Style { - background: Some(iced::Background::Color(colors.bg_hover)), - text_color: colors.text_primary, - border: iced::Border::default(), - ..Default::default() + for (cat, label, icon_path) in categories { + let active = cat == BrushCategory::All; + let icon = svg(svg::Handle::from_path( + std::path::Path::new("hcie-iced-app/assets").join(icon_path), + )) + .width(16) + .height(16) + .style(move |_theme, _status| svg::Style { + color: Some(if active { + colors.accent + } else { + colors.text_secondary + }), + }); + let btn = button(icon).padding([3, 6]).style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { + iced::widget::button::Status::Hovered => iced::widget::button::Style { + background: Some(iced::Background::Color(colors.bg_hover)), + text_color: colors.text_primary, + border: iced::Border::default(), + ..Default::default() + }, + _ => iced::widget::button::Style { + background: Some(iced::Background::Color(if active { + colors.bg_active + } else { + iced::Color::TRANSPARENT + })), + text_color: if active { + colors.accent + } else { + colors.text_secondary }, - _ => iced::widget::button::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))), - text_color: colors.text_secondary, - border: iced::Border::default(), - ..Default::default() - }, - } - }); - tabs = tabs.push(btn); + border: iced::Border::default() + .color(if active { + colors.accent + } else { + colors.border_low + }) + .width(if active { 1 } else { 0 }), + ..Default::default() + }, + }, + ); + tabs = tabs.push(iced::widget::tooltip( + btn, + text(format!("{label} (category filtering is not available yet)")).size(10), + iced::widget::tooltip::Position::Bottom, + )); } - // Brush preset grid — 3 columns + // Brush preset grid — two columns remain usable in narrow dock panes. let mut grid_rows = column![].spacing(4); let mut current_row = row![].spacing(4); @@ -235,42 +367,40 @@ pub fn view( let c = colors; let style = preset.style; - let thumb = container( - iced::widget::image(preview) - .width(48) - .height(48) - ) - .width(56) - .height(56) - .center_x(48) - .center_y(48) - .style(move |_theme| { - if is_selected { - iced::widget::container::Style { - background: Some(iced::Background::Color(c.bg_active)), - border: iced::Border::default().color(c.accent).width(2), - ..Default::default() + let thumb = container(iced::widget::image(preview).width(48).height(48)) + .width(56) + .height(56) + .center_x(48) + .center_y(48) + .style(move |_theme| { + if is_selected { + iced::widget::container::Style { + background: Some(iced::Background::Color(c.bg_active)), + border: iced::Border::default().color(c.accent).width(2), + ..Default::default() + } + } else { + iced::widget::container::Style { + background: Some(iced::Background::Color(c.bg_panel)), + border: iced::Border::default().color(c.border_low).width(1), + ..Default::default() + } } - } else { - iced::widget::container::Style { - background: Some(iced::Background::Color(c.bg_panel)), - border: iced::Border::default().color(c.border_low).width(1), - ..Default::default() - } - } - }); + }); let label = container(text(preset.name).size(9)) .width(Length::Fill) .center_x(Length::Fill); - let cell = column![thumb, label].spacing(2).align_x(iced::Alignment::Center); + let cell = column![thumb, label] + .spacing(2) + .align_x(iced::Alignment::Center); let clickable = button(cell) .on_press(Message::BrushStyleSelected(style)) .padding(2) - .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { - match status { + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { iced::widget::button::Status::Hovered => iced::widget::button::Style { background: Some(iced::Background::Color(c.bg_hover)), text_color: c.text_primary, @@ -278,18 +408,19 @@ pub fn view( ..Default::default() }, _ => iced::widget::button::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))), + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.0, 0.0, 0.0, 0.0, + ))), text_color: c.text_primary, border: iced::Border::default(), ..Default::default() }, - } - }); + }, + ); current_row = current_row.push(clickable); - // Wrap row every 3 items - if (idx + 1) % 3 == 0 || idx == BRUSH_PRESETS.len() - 1 { + if (idx + 1) % 2 == 0 || idx == BRUSH_PRESETS.len() - 1 { grid_rows = grid_rows.push(current_row); current_row = row![].spacing(4); } @@ -300,30 +431,30 @@ pub fn view( .step(1.0) .width(Length::Fill); - let opacity_slider = slider(0.0..=1.0, current_opacity, |v| Message::BrushOpacityChanged(v)) - .step(0.01) - .width(Length::Fill); + let opacity_slider = slider(0.0..=1.0, current_opacity, |v| { + Message::BrushOpacityChanged(v) + }) + .step(0.01) + .width(Length::Fill); - let hardness_slider = slider(0.0..=1.0, current_hardness, |v| Message::BrushHardnessChanged(v)) - .step(0.01) - .width(Length::Fill); + let hardness_slider = slider(0.0..=1.0, current_hardness, |v| { + Message::BrushHardnessChanged(v) + }) + .step(0.01) + .width(Length::Fill); // Live preview — show current brush tip let preview_handle = get_brush_preview(current_style); - let live_preview = container( - iced::widget::image(preview_handle) - .width(64) - .height(64) - ) - .width(Length::Fill) - .height(80) - .center_x(Length::Fill) - .center_y(Length::Fill) - .style(move |_theme| iced::widget::container::Style { - background: Some(iced::Background::Color(colors.bg_active)), - border: iced::Border::default().color(colors.accent).width(1), - ..Default::default() - }); + let live_preview = container(iced::widget::image(preview_handle).width(64).height(64)) + .width(Length::Fill) + .height(80) + .center_x(Length::Fill) + .center_y(Length::Fill) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(colors.bg_active)), + border: iced::Border::default().color(colors.accent).width(1), + ..Default::default() + }); // Panel title is in the dock tab — no duplicate inside the panel. let panel = column![ @@ -331,11 +462,11 @@ pub fn view( horizontal_rule(1), scrollable(grid_rows).height(Length::Fill), horizontal_rule(1), - row![ - button(text("Import ABR").size(10)) - .on_press(Message::BrushImportAbr) - .padding([4, 8]) - .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { + row![button(text("Import ABR").size(10)) + .on_press(Message::BrushImportAbr) + .padding([4, 8]) + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| { match status { iced::widget::button::Status::Hovered => iced::widget::button::Style { background: Some(iced::Background::Color(colors.accent)), @@ -350,8 +481,9 @@ pub fn view( ..Default::default() }, } - }) - ].spacing(4), + } + )] + .spacing(4), text("Preview").size(10), live_preview, text(format!("Size: {:.0}", current_size)).size(10), diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/filter_params.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/filter_params.rs index e07c809..254f3ce 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/filter_params.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/filter_params.rs @@ -17,34 +17,22 @@ use serde_json::json; /// Read a float parameter from the JSON params, falling back to a default. fn param_f64(params: &serde_json::Value, key: &str, default: f64) -> f64 { - params - .get(key) - .and_then(|v| v.as_f64()) - .unwrap_or(default) + params.get(key).and_then(|v| v.as_f64()).unwrap_or(default) } /// Read an integer parameter from the JSON params, falling back to a default. fn param_u64(params: &serde_json::Value, key: &str, default: u64) -> u64 { - params - .get(key) - .and_then(|v| v.as_u64()) - .unwrap_or(default) + params.get(key).and_then(|v| v.as_u64()).unwrap_or(default) } /// Read a boolean parameter from the JSON params, falling back to a default. fn param_bool(params: &serde_json::Value, key: &str, default: bool) -> bool { - params - .get(key) - .and_then(|v| v.as_bool()) - .unwrap_or(default) + params.get(key).and_then(|v| v.as_bool()).unwrap_or(default) } /// Read a string parameter from the JSON params, falling back to a default. fn param_str<'a>(params: &'a serde_json::Value, key: &str, default: &'a str) -> &'a str { - params - .get(key) - .and_then(|v| v.as_str()) - .unwrap_or(default) + params.get(key).and_then(|v| v.as_str()).unwrap_or(default) } /// Build a labeled float slider row. @@ -71,14 +59,13 @@ fn float_slider( let label_owned = label.to_string(); let lbl = text(label_owned).size(11).width(Length::Fixed(80.0)); - let display = text(format!("{:.1}", value)).size(11).width(Length::Fixed(40.0)); + let display = text(format!("{:.1}", value)) + .size(11) + .width(Length::Fixed(40.0)); let s = slider(min_f..=max_f, val, move |v| { let rounded = (v as f64 / step).round() * step; - Message::FilterParamChanged( - filter_owned.clone(), - json!({ key_owned.clone(): rounded }), - ) + Message::FilterParamChanged(filter_owned.clone(), json!({ key_owned.clone(): rounded })) }) .step(step_f) .width(Length::Fill); @@ -104,7 +91,9 @@ fn int_slider( let label_owned = label.to_string(); let lbl = text(label_owned).size(11).width(Length::Fixed(80.0)); - let display = text(format!("{}", value)).size(11).width(Length::Fixed(40.0)); + let display = text(format!("{}", value)) + .size(11) + .width(Length::Fixed(40.0)); let s = slider(min_f..=max_f, val, move |v| { Message::FilterParamChanged( @@ -121,9 +110,13 @@ fn int_slider( /// Build a section header for a group of parameters. fn section_header(label: &str, colors: ThemeColors) -> Element<'static, Message> { let label_owned = label.to_string(); - container(text(label_owned).size(11).style(move |_theme: &iced::Theme| iced::widget::text::Style { - color: Some(colors.text_secondary), - })) + container( + text(label_owned) + .size(11) + .style(move |_theme: &iced::Theme| iced::widget::text::Style { + color: Some(colors.text_secondary), + }), + ) .padding(iced::Padding::default().top(4.0).bottom(2.0)) .into() } @@ -173,8 +166,25 @@ pub fn view( let distance = param_u64(params, "distance", 10); column![ section_header("Motion Blur", colors), - float_slider("Angle", "angle", "motion_blur", angle, 0.0, 360.0, 1.0, colors), - int_slider("Distance", "distance", "motion_blur", distance, 1, 500, colors), + float_slider( + "Angle", + "angle", + "motion_blur", + angle, + 0.0, + 360.0, + 1.0, + colors + ), + int_slider( + "Distance", + "distance", + "motion_blur", + distance, + 1, + 500, + colors + ), ] .spacing(2) .into() @@ -264,9 +274,36 @@ pub fn view( section_header("Levels", colors), float_slider("Shadows", "shadows", "levels", shadows, 0.0, 255.0, 1.0, colors), float_slider("Midtones", "midtones", "levels", midtones, 0.0, 255.0, 1.0, colors), - float_slider("Highlights", "highlights", "levels", highlights, 0.0, 255.0, 1.0, colors), - float_slider("Out Min", "output_min", "levels", out_min, 0.0, 255.0, 1.0, colors), - float_slider("Out Max", "output_max", "levels", out_max, 0.0, 255.0, 1.0, colors), + float_slider( + "Highlights", + "highlights", + "levels", + highlights, + 0.0, + 255.0, + 1.0, + colors + ), + float_slider( + "Out Min", + "output_min", + "levels", + out_min, + 0.0, + 255.0, + 1.0, + colors + ), + float_slider( + "Out Max", + "output_max", + "levels", + out_max, + 0.0, + 255.0, + 1.0, + colors + ), ] .spacing(2) .into() @@ -275,7 +312,9 @@ pub fn view( let vibrance = param_f64(params, "vibrance", 0.0); column![ section_header("Vibrance", colors), - float_slider("Vibrance", "vibrance", "vibrance", vibrance, -100.0, 100.0, 1.0, colors), + float_slider( + "Vibrance", "vibrance", "vibrance", vibrance, -100.0, 100.0, 1.0, colors + ), ] .spacing(2) .into() @@ -288,7 +327,16 @@ pub fn view( section_header("Exposure", colors), float_slider("Exposure", "exposure", "exposure", exposure, -5.0, 5.0, 0.01, colors), float_slider("Offset", "offset", "exposure", offset, -1.0, 1.0, 0.01, colors), - float_slider("Gamma", "gamma_correction", "exposure", gamma, 0.1, 5.0, 0.01, colors), + float_slider( + "Gamma", + "gamma_correction", + "exposure", + gamma, + 0.1, + 5.0, + 0.01, + colors + ), ] .spacing(2) .into() @@ -307,8 +355,26 @@ pub fn view( let max_lum = param_f64(params, "max_luminance", 100.0); column![ section_header("Threshold", colors), - float_slider("Min Luminance", "min_luminance", "threshold", min_lum, 0.0, 100.0, 1.0, colors), - float_slider("Max Luminance", "max_luminance", "threshold", max_lum, 0.0, 100.0, 1.0, colors), + float_slider( + "Min Luminance", + "min_luminance", + "threshold", + min_lum, + 0.0, + 100.0, + 1.0, + colors + ), + float_slider( + "Max Luminance", + "max_luminance", + "threshold", + max_lum, + 0.0, + 100.0, + 1.0, + colors + ), ] .spacing(2) .into() @@ -319,10 +385,7 @@ pub fn view( let mono_g = param_f64(params, "monochrome_g", 40.0); let mono_b = param_f64(params, "monochrome_b", 20.0); let mono_c = param_f64(params, "monochrome_constant", 0.0); - let mut col = column![ - section_header("Channel Mixer", colors), - ] - .spacing(2); + let mut col = column![section_header("Channel Mixer", colors),].spacing(2); // Monochrome toggle let filter_id = "channel_mixer"; @@ -340,10 +403,46 @@ pub fn view( } if mono { - col = col.push(float_slider("Monochrome R", "monochrome_r", filter_id, mono_r, -200.0, 200.0, 1.0, colors)); - col = col.push(float_slider("Monochrome G", "monochrome_g", filter_id, mono_g, -200.0, 200.0, 1.0, colors)); - col = col.push(float_slider("Monochrome B", "monochrome_b", filter_id, mono_b, -200.0, 200.0, 1.0, colors)); - col = col.push(float_slider("Constant", "monochrome_constant", filter_id, mono_c, -200.0, 200.0, 1.0, colors)); + col = col.push(float_slider( + "Monochrome R", + "monochrome_r", + filter_id, + mono_r, + -200.0, + 200.0, + 1.0, + colors, + )); + col = col.push(float_slider( + "Monochrome G", + "monochrome_g", + filter_id, + mono_g, + -200.0, + 200.0, + 1.0, + colors, + )); + col = col.push(float_slider( + "Monochrome B", + "monochrome_b", + filter_id, + mono_b, + -200.0, + 200.0, + 1.0, + colors, + )); + col = col.push(float_slider( + "Constant", + "monochrome_constant", + filter_id, + mono_c, + -200.0, + 200.0, + 1.0, + colors, + )); } else { // RGB channels let red_r = param_f64(params, "red_r", 100.0); @@ -360,22 +459,88 @@ pub fn view( let blue_c = param_f64(params, "blue_constant", 0.0); col = col.push(section_header("Red Channel", colors)); - col = col.push(float_slider("Red→R", "red_r", filter_id, red_r, -200.0, 200.0, 1.0, colors)); - col = col.push(float_slider("Red→G", "red_g", filter_id, red_g, -200.0, 200.0, 1.0, colors)); - col = col.push(float_slider("Red→B", "red_b", filter_id, red_b, -200.0, 200.0, 1.0, colors)); - col = col.push(float_slider("Red Const", "red_constant", filter_id, red_c, -200.0, 200.0, 1.0, colors)); + col = col.push(float_slider( + "Red→R", "red_r", filter_id, red_r, -200.0, 200.0, 1.0, colors, + )); + col = col.push(float_slider( + "Red→G", "red_g", filter_id, red_g, -200.0, 200.0, 1.0, colors, + )); + col = col.push(float_slider( + "Red→B", "red_b", filter_id, red_b, -200.0, 200.0, 1.0, colors, + )); + col = col.push(float_slider( + "Red Const", + "red_constant", + filter_id, + red_c, + -200.0, + 200.0, + 1.0, + colors, + )); col = col.push(section_header("Green Channel", colors)); - col = col.push(float_slider("Green→R", "green_r", filter_id, green_r, -200.0, 200.0, 1.0, colors)); - col = col.push(float_slider("Green→G", "green_g", filter_id, green_g, -200.0, 200.0, 1.0, colors)); - col = col.push(float_slider("Green→B", "green_b", filter_id, green_b, -200.0, 200.0, 1.0, colors)); - col = col.push(float_slider("Green Const", "green_constant", filter_id, green_c, -200.0, 200.0, 1.0, colors)); + col = col.push(float_slider( + "Green→R", + "green_r", + filter_id, + green_r, + -200.0, + 200.0, + 1.0, + colors, + )); + col = col.push(float_slider( + "Green→G", + "green_g", + filter_id, + green_g, + -200.0, + 200.0, + 1.0, + colors, + )); + col = col.push(float_slider( + "Green→B", + "green_b", + filter_id, + green_b, + -200.0, + 200.0, + 1.0, + colors, + )); + col = col.push(float_slider( + "Green Const", + "green_constant", + filter_id, + green_c, + -200.0, + 200.0, + 1.0, + colors, + )); col = col.push(section_header("Blue Channel", colors)); - col = col.push(float_slider("Blue→R", "blue_r", filter_id, blue_r, -200.0, 200.0, 1.0, colors)); - col = col.push(float_slider("Blue→G", "blue_g", filter_id, blue_g, -200.0, 200.0, 1.0, colors)); - col = col.push(float_slider("Blue→B", "blue_b", filter_id, blue_b, -200.0, 200.0, 1.0, colors)); - col = col.push(float_slider("Blue Const", "blue_constant", filter_id, blue_c, -200.0, 200.0, 1.0, colors)); + col = col.push(float_slider( + "Blue→R", "blue_r", filter_id, blue_r, -200.0, 200.0, 1.0, colors, + )); + col = col.push(float_slider( + "Blue→G", "blue_g", filter_id, blue_g, -200.0, 200.0, 1.0, colors, + )); + col = col.push(float_slider( + "Blue→B", "blue_b", filter_id, blue_b, -200.0, 200.0, 1.0, colors, + )); + col = col.push(float_slider( + "Blue Const", + "blue_constant", + filter_id, + blue_c, + -200.0, + 200.0, + 1.0, + colors, + )); } col.into() @@ -392,16 +557,97 @@ pub fn view( let tint_b = param_f64(params, "tint_b", 255.0); column![ section_header("Black & White", colors), - float_slider("Reds", "reds", "black_and_white", reds, 0.0, 100.0, 1.0, colors), - float_slider("Yellows", "yellows", "black_and_white", yellows, 0.0, 100.0, 1.0, colors), - float_slider("Greens", "greens", "black_and_white", greens, 0.0, 100.0, 1.0, colors), - float_slider("Cyans", "cyans", "black_and_white", cyans, 0.0, 100.0, 1.0, colors), - float_slider("Blues", "blues", "black_and_white", blues, 0.0, 100.0, 1.0, colors), - float_slider("Magentas", "magentas", "black_and_white", magentas, 0.0, 100.0, 1.0, colors), + float_slider( + "Reds", + "reds", + "black_and_white", + reds, + 0.0, + 100.0, + 1.0, + colors + ), + float_slider( + "Yellows", + "yellows", + "black_and_white", + yellows, + 0.0, + 100.0, + 1.0, + colors + ), + float_slider( + "Greens", + "greens", + "black_and_white", + greens, + 0.0, + 100.0, + 1.0, + colors + ), + float_slider( + "Cyans", + "cyans", + "black_and_white", + cyans, + 0.0, + 100.0, + 1.0, + colors + ), + float_slider( + "Blues", + "blues", + "black_and_white", + blues, + 0.0, + 100.0, + 1.0, + colors + ), + float_slider( + "Magentas", + "magentas", + "black_and_white", + magentas, + 0.0, + 100.0, + 1.0, + colors + ), section_header("Tint Color", colors), - float_slider("Tint R", "tint_r", "black_and_white", tint_r, 0.0, 255.0, 1.0, colors), - float_slider("Tint G", "tint_g", "black_and_white", tint_g, 0.0, 255.0, 1.0, colors), - float_slider("Tint B", "tint_b", "black_and_white", tint_b, 0.0, 255.0, 1.0, colors), + float_slider( + "Tint R", + "tint_r", + "black_and_white", + tint_r, + 0.0, + 255.0, + 1.0, + colors + ), + float_slider( + "Tint G", + "tint_g", + "black_and_white", + tint_g, + 0.0, + 255.0, + 1.0, + colors + ), + float_slider( + "Tint B", + "tint_b", + "black_and_white", + tint_b, + 0.0, + 255.0, + 1.0, + colors + ), ] .spacing(2) .into() @@ -412,10 +658,7 @@ pub fn view( let mode = param_str(params, "mode", "relative"); let is_absolute = mode == "absolute"; - let mut col = column![ - section_header("Selective Color", colors), - ] - .spacing(2); + let mut col = column![section_header("Selective Color", colors),].spacing(2); // Mode toggle { @@ -441,7 +684,12 @@ pub fn view( ("Neutrals", "neutrals"), ("Blacks", "blacks"), ]; - let cmyk = [("Cyan", "cyan"), ("Magenta", "magenta"), ("Yellow", "yellow"), ("Black", "black")]; + let cmyk = [ + ("Cyan", "cyan"), + ("Magenta", "magenta"), + ("Yellow", "yellow"), + ("Black", "black"), + ]; for (range_name, range_key) in &ranges { col = col.push(section_header(range_name, colors)); @@ -449,14 +697,7 @@ pub fn view( let param_key = format!("{}_{}", range_key, cmyk_key); let val = param_f64(params, ¶m_key, 0.0); col = col.push(float_slider( - cmyk_name, - ¶m_key, - filter_id, - val, - -100.0, - 100.0, - 1.0, - colors, + cmyk_name, ¶m_key, filter_id, val, -100.0, 100.0, 1.0, colors, )); } } @@ -468,7 +709,16 @@ pub fn view( let inverse = param_bool(params, "inverse", false); let mut col = column![ section_header("Gamma Correction", colors), - float_slider("Gamma", "gamma", "gamma_correction", gamma, 0.1, 5.0, 0.1, colors), + float_slider( + "Gamma", + "gamma", + "gamma_correction", + gamma, + 0.1, + 5.0, + 0.1, + colors + ), ] .spacing(2); @@ -489,21 +739,25 @@ pub fn view( FilterType::ExtractChannel => { let channel = param_str(params, "channel", "red"); let channels = ["red", "green", "blue", "alpha"]; - let mut col = column![ - section_header("Extract Channel", colors), - ] - .spacing(2); + let mut col = column![section_header("Extract Channel", colors),].spacing(2); for ch in &channels { let is_active = channel == *ch; let filter_id = "extract_channel"; let ch_label = text(ch.to_string()).size(11).width(Length::Fixed(80.0)); - let btn = iced::widget::button(text(if is_active { format!(">> {}", ch) } else { ch.to_string() }).size(11)) - .on_press(Message::FilterParamChanged( - filter_id.to_string(), - json!({ "channel": ch }), - )) - .padding([2, 8]); + let btn = iced::widget::button( + text(if is_active { + format!(">> {}", ch) + } else { + ch.to_string() + }) + .size(11), + ) + .on_press(Message::FilterParamChanged( + filter_id.to_string(), + json!({ "channel": ch }), + )) + .padding([2, 8]); col = col.push(row![ch_label, btn].spacing(4)); } @@ -524,9 +778,7 @@ pub fn view( .padding([2, 8]); row![lbl, toggle_btn].spacing(4) }, - { - text("Gradient stops configured via color picker").size(10) - }, + { text("Gradient stops configured via color picker").size(10) }, ] .spacing(2) .into() @@ -585,7 +837,12 @@ pub fn view( for nt in &noise_types { let is_active = noise_type == *nt; let btn = iced::widget::button( - text(if is_active { format!(">> {}", nt) } else { nt.to_string() }).size(11), + text(if is_active { + format!(">> {}", nt) + } else { + nt.to_string() + }) + .size(11), ) .on_press(Message::FilterParamChanged( filter_id.to_string(), @@ -603,7 +860,12 @@ pub fn view( for ft in &fractal_types { let is_active = fractal_type == *ft; let btn = iced::widget::button( - text(if is_active { format!(">> {}", ft) } else { ft.to_string() }).size(11), + text(if is_active { + format!(">> {}", ft) + } else { + ft.to_string() + }) + .size(11), ) .on_press(Message::FilterParamChanged( filter_id.to_string(), @@ -615,9 +877,35 @@ pub fn view( } if fractal_type != "none" { - col = col.push(int_slider("Octaves", "fractal_octaves", filter_id, fractal_octaves, 1, 10, colors)); - col = col.push(float_slider("Lacunarity", "fractal_lacunarity", filter_id, fractal_lacunarity, 1.0, 4.0, 0.1, colors)); - col = col.push(float_slider("Gain", "fractal_gain", filter_id, fractal_gain, 0.0, 2.0, 0.01, colors)); + col = col.push(int_slider( + "Octaves", + "fractal_octaves", + filter_id, + fractal_octaves, + 1, + 10, + colors, + )); + col = col.push(float_slider( + "Lacunarity", + "fractal_lacunarity", + filter_id, + fractal_lacunarity, + 1.0, + 4.0, + 0.1, + colors, + )); + col = col.push(float_slider( + "Gain", + "fractal_gain", + filter_id, + fractal_gain, + 0.0, + 2.0, + 0.01, + colors, + )); } // Domain warp selector @@ -627,7 +915,12 @@ pub fn view( for wt in &warp_types { let is_active = domain_warp_type == *wt; let btn = iced::widget::button( - text(if is_active { format!(">> {}", wt) } else { wt.to_string() }).size(11), + text(if is_active { + format!(">> {}", wt) + } else { + wt.to_string() + }) + .size(11), ) .on_press(Message::FilterParamChanged( filter_id.to_string(), @@ -639,7 +932,16 @@ pub fn view( } if domain_warp_type != "none" { - col = col.push(float_slider("Warp Amp", "domain_warp_amplitude", filter_id, domain_warp_amp, 0.0, 500.0, 1.0, colors)); + col = col.push(float_slider( + "Warp Amp", + "domain_warp_amplitude", + filter_id, + domain_warp_amp, + 0.0, + 500.0, + 1.0, + colors, + )); } // Cellular settings (shown when noise_type is "cellular") @@ -650,7 +952,12 @@ pub fn view( for df in &dist_funcs { let is_active = cellular_distance == *df; let btn = iced::widget::button( - text(if is_active { format!(">> {}", df) } else { df.to_string() }).size(11), + text(if is_active { + format!(">> {}", df) + } else { + df.to_string() + }) + .size(11), ) .on_press(Message::FilterParamChanged( filter_id.to_string(), @@ -665,7 +972,12 @@ pub fn view( for rt in &ret_types { let is_active = cellular_return == *rt; let btn = iced::widget::button( - text(if is_active { format!(">> {}", rt) } else { rt.to_string() }).size(11), + text(if is_active { + format!(">> {}", rt) + } else { + rt.to_string() + }) + .size(11), ) .on_press(Message::FilterParamChanged( filter_id.to_string(), @@ -674,15 +986,21 @@ pub fn view( .padding([2, 8]); col = col.push(btn); } - col = col.push(float_slider("Jitter", "cellular_jitter", filter_id, cellular_jitter, 0.0, 1.0, 0.01, colors)); + col = col.push(float_slider( + "Jitter", + "cellular_jitter", + filter_id, + cellular_jitter, + 0.0, + 1.0, + 0.01, + colors, + )); } col.into() } }; - container(content) - .width(Length::Fill) - .padding(4) - .into() + container(content).width(Length::Fill).padding(4).into() } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/filters.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/filters.rs index a62d6bd..8c33261 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/filters.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/filters.rs @@ -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, filter_params: &serde_json::Value, filter_preview_active: bool, + layer_editable: bool, colors: ThemeColors, ) -> Element<'static, Message> { let mut filter_list = column![].spacing(2); for category in FILTER_CATEGORIES { - let cat_name = text(category.name) - .size(12) - ; + let cat_name = text(category.name).size(12); let mut cat_filters = column![].spacing(1).padding(iced::Padding::ZERO.left(12)); @@ -128,8 +196,8 @@ pub fn view( .style(move |_theme| item_style.clone()); let filter_type = entry.filter_type; - let clickable = iced::widget::mouse_area(item) - .on_press(Message::FilterSelect(filter_type)); + let clickable = + iced::widget::mouse_area(item).on_press(Message::FilterSelect(filter_type)); cat_filters = cat_filters.push(clickable); } @@ -138,9 +206,13 @@ pub fn view( filter_list = filter_list.push(cat_filters); } - let apply_btn = button(text("Apply").size(11)) - .on_press(Message::FilterApply) - .padding([6, 12]); + let apply_btn = if layer_editable { + button(text("Apply").size(11)) + .on_press(Message::FilterApply) + .padding([6, 12]) + } else { + button(text("Apply (layer locked/type unsupported)").size(10)).padding([6, 8]) + }; let reset_btn = button(text("Reset").size(11)) .on_press(Message::FilterReset) @@ -150,14 +222,17 @@ pub fn view( .on_toggle(Message::FilterPreviewToggle) .size(11); - let buttons = row![preview_check, apply_btn, reset_btn].spacing(8); + let buttons = column![preview_check, row![apply_btn, reset_btn].spacing(6)].spacing(3); // When a filter is selected, show its parameter panel below the list. let param_section = match selected_filter { Some(filter) => { let params_panel = super::filter_params::view(filter, filter_params, colors); - column![horizontal_rule(1), scrollable(params_panel).height(Length::Fill)] - .spacing(0) + column![ + horizontal_rule(1), + scrollable(params_panel).height(Length::Fill) + ] + .spacing(0) } None => column![], }; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/geometry.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/geometry.rs index c41ddcb..e9eee1c 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/geometry.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/geometry.rs @@ -6,11 +6,13 @@ use crate::app::Message; use crate::color_picker; +use crate::color_picker::ColorPickerTarget; use crate::panels::styles; +use crate::panels::typography::{BODY, SECTION}; use crate::theme::ThemeColors; use hcie_engine_api::{LineCap, VectorBooleanOp, VectorShape}; use iced::widget::{ - button, checkbox, column, container, row, scrollable, slider, text, Space, + button, checkbox, column, container, pick_list, row, scrollable, slider, text, Space, }; use iced::{Element, Length}; @@ -38,18 +40,15 @@ fn shape_label(shapes: &[VectorShape], i: usize) -> String { /// Build a section header bar with the given label. fn section_header(label: String, colors: ThemeColors) -> Element<'static, Message> { - container( - row![text(label).size(11)] - .spacing(4), - ) - .width(Length::Fill) - .padding([3, 6]) - .style(move |_theme| iced::widget::container::Style { - background: Some(iced::Background::Color(colors.bg_hover)), - border: iced::Border::default().color(colors.border_high).width(1), - ..Default::default() - }) - .into() + container(row![text(label).size(SECTION)].spacing(4)) + .width(Length::Fill) + .padding([3, 6]) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(colors.bg_hover)), + border: iced::Border::default().color(colors.border_high).width(1), + ..Default::default() + }) + .into() } /// Build a labeled row with a slider. @@ -60,9 +59,11 @@ fn labeled_slider<'a>( on_change: fn(f32) -> Message, ) -> Element<'a, Message> { row![ - text(label).size(10).width(30), - slider(range, value, on_change).step(0.5).width(Length::Fill), - text(format!("{:.0}", value)).size(9).width(32), + text(label).size(BODY).width(42), + slider(range, value, on_change) + .step(0.5) + .width(Length::Fill), + text(format!("{:.0}", value)).size(BODY).width(38), ] .spacing(4) .align_y(iced::Alignment::Center) @@ -94,7 +95,7 @@ pub fn view( container( row![ Space::with_width(Length::Fill), - button(text("X").size(10)) + button(text("X").size(BODY)) .on_press(Message::VectorShapeDelete) .padding([2, 6]), ] @@ -111,12 +112,16 @@ pub fn view( let is_selected = selected_shape == Some(i); let c = colors; - let item = button(text(label).size(11)) - .on_press(Message::VectorShapeSelect(if is_selected { None } else { Some(i) })) + let item = button(text(label).size(BODY)) + .on_press(Message::VectorShapeSelect(if is_selected { + None + } else { + Some(i) + })) .width(Length::Fill) .padding([4, 6]) - .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { - match status { + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { iced::widget::button::Status::Active => { if is_selected { iced::widget::button::Style { @@ -132,32 +137,36 @@ pub fn view( } } } - iced::widget::button::Status::Hovered => { - iced::widget::button::Style { - background: Some(iced::Background::Color(if is_selected { c.accent } else { c.bg_active })), - text_color: iced::Color::WHITE, - ..Default::default() - } - } + iced::widget::button::Status::Hovered => iced::widget::button::Style { + background: Some(iced::Background::Color(if is_selected { + c.accent + } else { + c.bg_active + })), + text_color: iced::Color::WHITE, + ..Default::default() + }, _ => iced::widget::button::Style { background: Some(iced::Background::Color(c.bg_hover)), text_color: c.text_primary, ..Default::default() }, - } - }); + }, + ); shape_list = shape_list.push(item); } content = content.push( - scrollable(shape_list).height(Length::Fill).width(Length::Fill), + scrollable(shape_list) + .height(Length::Fixed(132.0)) + .width(Length::Fill), ); } else { content = content.push( column![ - text("No vector shapes").size(11), - text("Use vector tools to create shapes").size(10), + text("No vector shapes").size(BODY), + text("Use vector tools to create shapes").size(BODY), ] .spacing(2), ); @@ -169,68 +178,52 @@ pub fn view( content = content.push(section_header("PATH BOOLEAN".to_string(), colors)); content = content.push(Space::with_height(4)); - // Shape A selector - let a_label = match bool_shape_a { - Some(i) if i < shape_count => shape_label(&shapes, i), - _ => "Shape A".to_string(), - }; - let mut a_menu = column![].spacing(1); - for i in 0..shape_count { - let lbl = shape_label(&shapes, i); - let idx = i; - a_menu = a_menu.push( - button(text(lbl).size(10)) - .on_press(Message::GeometryBoolShapeA(Some(idx))) - .width(Length::Fill) - .padding([3, 6]), - ); - } - - // Shape B selector - let b_label = match bool_shape_b { - Some(i) if i < shape_count => shape_label(&shapes, i), - _ => "Shape B".to_string(), - }; - let mut b_menu = column![].spacing(1); - for i in 0..shape_count { - let lbl = shape_label(&shapes, i); - let idx = i; - b_menu = b_menu.push( - button(text(lbl).size(10)) - .on_press(Message::GeometryBoolShapeB(Some(idx))) - .width(Length::Fill) - .padding([3, 6]), - ); - } + let labels: Vec = (0..shape_count).map(|i| shape_label(&shapes, i)).collect(); + let a_selected = bool_shape_a.and_then(|i| labels.get(i).cloned()); + let b_selected = bool_shape_b.and_then(|i| labels.get(i).cloned()); + let a_labels = labels.clone(); + let b_labels = labels.clone(); // Show selectors inline content = content.push( row![ container( - column![ - button(text(a_label).size(10)) - .width(Length::Fill) - .padding([4, 6]), - a_menu, - ] - .spacing(2) - ).width(Length::Fill).style(move |_theme| iced::widget::container::Style { + pick_list(labels.clone(), a_selected, move |selected| { + Message::GeometryBoolShapeA( + a_labels.iter().position(|label| *label == selected), + ) + }) + .placeholder("Shape A") + .text_size(BODY) + .width(Length::Fill) + ) + .width(Length::Fill) + .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(colors.bg_active)), - border: iced::Border::default().color(colors.border_high).width(1).rounded(2), + border: iced::Border::default() + .color(colors.border_high) + .width(1) + .rounded(2), ..Default::default() }), - text(" + ").size(10), + text(" + ").size(BODY), container( - column![ - button(text(b_label).size(10)) - .width(Length::Fill) - .padding([4, 6]), - b_menu, - ] - .spacing(2) - ).width(Length::Fill).style(move |_theme| iced::widget::container::Style { + pick_list(labels, b_selected, move |selected| { + Message::GeometryBoolShapeB( + b_labels.iter().position(|label| *label == selected), + ) + }) + .placeholder("Shape B") + .text_size(BODY) + .width(Length::Fill) + ) + .width(Length::Fill) + .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(colors.bg_active)), - border: iced::Border::default().color(colors.border_high).width(1).rounded(2), + border: iced::Border::default() + .color(colors.border_high) + .width(1) + .rounded(2), ..Default::default() }), ] @@ -242,10 +235,18 @@ pub fn view( content = content.push(Space::with_height(4)); content = content.push( row![ - button(text("Union").size(10)).on_press(Message::GeometryBooleanOp(VectorBooleanOp::Union)).padding([3, 8]), - button(text("Intersect").size(10)).on_press(Message::GeometryBooleanOp(VectorBooleanOp::Intersect)).padding([3, 8]), - button(text("Subtract").size(10)).on_press(Message::GeometryBooleanOp(VectorBooleanOp::Subtract)).padding([3, 8]), - button(text("Xor").size(10)).on_press(Message::GeometryBooleanOp(VectorBooleanOp::Xor)).padding([3, 8]), + button(text("Union").size(BODY)) + .on_press(Message::GeometryBooleanOp(VectorBooleanOp::Union)) + .padding([3, 8]), + button(text("Intersect").size(BODY)) + .on_press(Message::GeometryBooleanOp(VectorBooleanOp::Intersect)) + .padding([3, 8]), + button(text("Subtract").size(BODY)) + .on_press(Message::GeometryBooleanOp(VectorBooleanOp::Subtract)) + .padding([3, 8]), + button(text("Xor").size(BODY)) + .on_press(Message::GeometryBooleanOp(VectorBooleanOp::Xor)) + .padding([3, 8]), ] .spacing(4) .width(Length::Fill), @@ -264,20 +265,38 @@ pub fn view( let h = (y2 - y1).abs(); // Bounds sliders - content = content.push(labeled_slider("X1", x1, -2000.0..=5000.0, Message::GeometryBoundsX1)); - content = content.push(labeled_slider("Y1", y1, -2000.0..=5000.0, Message::GeometryBoundsY1)); - content = content.push(labeled_slider("X2", x2, -2000.0..=5000.0, Message::GeometryBoundsX2)); - content = content.push(labeled_slider("Y2", y2, -2000.0..=5000.0, Message::GeometryBoundsY2)); + content = content.push(labeled_slider( + "X1", + x1, + -2000.0..=5000.0, + Message::GeometryBoundsX1, + )); + content = content.push(labeled_slider( + "Y1", + y1, + -2000.0..=5000.0, + Message::GeometryBoundsY1, + )); + content = content.push(labeled_slider( + "X2", + x2, + -2000.0..=5000.0, + Message::GeometryBoundsX2, + )); + content = content.push(labeled_slider( + "Y2", + y2, + -2000.0..=5000.0, + Message::GeometryBoundsY2, + )); // Size display - content = content.push( - text(format!("Size: {:.0} x {:.0}", w, h)) - .size(10) - .style(move |_theme| iced::widget::text::Style { - color: Some(colors.text_secondary), - ..Default::default() - }), - ); + content = content.push(text(format!("Size: {:.0} x {:.0}", w, h)).size(BODY).style( + move |_theme| iced::widget::text::Style { + color: Some(colors.text_secondary), + ..Default::default() + }, + )); // Fill controls (not for Line shapes) let is_line = matches!(&shapes[idx], VectorShape::Line { .. }); @@ -288,7 +307,7 @@ pub fn view( content = content.push( row![ checkbox("", cur_fill).on_toggle(Message::GeometryFillToggled), - text("Fill").size(10), + text("Fill").size(BODY), ] .spacing(4) .align_y(iced::Alignment::Center), @@ -297,10 +316,10 @@ pub fn view( if cur_fill { // Fill color: HSV color picker content = content.push(Space::with_height(2)); - content = content.push(color_picker::color_popup( + content = content.push(color_picker::color_swatch( fc, "Fill", - Message::GeometryFillColorChanged, + ColorPickerTarget::GeometryFill, )); } } @@ -309,25 +328,37 @@ pub fn view( // Stroke color let stroke_color = shapes[idx].color(); content = content.push(Space::with_height(4)); - content = content.push(color_picker::color_popup( + content = content.push(color_picker::color_swatch( stroke_color, "Stroke", - Message::GeometryStrokeColorChanged, + ColorPickerTarget::GeometryStroke, )); // Opacity slider let cur_opacity = shapes[idx].shape_opacity(); content = content.push(Space::with_height(4)); - content = content.push(labeled_slider("Opacity", cur_opacity, 0.0..=1.0, Message::GeometryOpacityChanged)); + content = content.push(labeled_slider( + "Opacity", + cur_opacity, + 0.0..=1.0, + Message::GeometryOpacityChanged, + )); // Hardness slider let cur_hardness = shapes[idx].hardness(); - content = content.push(labeled_slider("Hardness", cur_hardness, 0.0..=1.0, Message::GeometryHardnessChanged)); + content = content.push(labeled_slider( + "Hardness", + cur_hardness, + 0.0..=1.0, + Message::GeometryHardnessChanged, + )); // Line caps (for Line shapes only) if is_line { let (cs, ce) = match &shapes[idx] { - VectorShape::Line { cap_start, cap_end, .. } => (*cap_start, *cap_end), + VectorShape::Line { + cap_start, cap_end, .. + } => (*cap_start, *cap_end), _ => (LineCap::Round, LineCap::Round), }; @@ -335,71 +366,59 @@ pub fn view( content = content.push(section_header("LINE CAPS".to_string(), colors)); content = content.push(Space::with_height(2)); - // Start cap combo + let cap_labels: Vec = 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)) diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/history.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/history.rs index 2ba0782..34948f3 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/history.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/history.rs @@ -11,15 +11,18 @@ use iced::widget::{column, container, horizontal_rule, scrollable, text}; use iced::{Element, Length}; /// Build the history panel element. -pub fn view<'a>(descriptions: &[(usize, String)], current_idx: i32, colors: ThemeColors) -> Element<'a, Message> { +pub fn view<'a>( + descriptions: &[(usize, String)], + current_idx: i32, + colors: ThemeColors, +) -> Element<'a, Message> { let mut history_list = column![].spacing(1); for (idx, desc) in descriptions { let is_current = *idx as i32 == current_idx; let is_future = *idx as i32 > current_idx; - let label = text(format!("{}: {}", idx, desc)) - .size(11); + let label = text(format!("{}: {}", idx, desc)).size(11); let label = if is_current { label.style(move |_theme: &iced::Theme| iced::widget::text::Style { @@ -44,18 +47,16 @@ pub fn view<'a>(descriptions: &[(usize, String)], current_idx: i32, colors: Them .padding([3, 6]) .style(move |_theme| item_style.clone()); - let clickable = iced::widget::mouse_area(item) - .on_press(Message::HistoryJumpTo(*idx as i32)); + let clickable = + iced::widget::mouse_area(item).on_press(Message::HistoryJumpTo(*idx as i32)); history_list = history_list.push(clickable); } // Panel title is in the dock tab — no duplicate inside the panel. - let panel = column![ - scrollable(history_list).height(Length::Fill), - ] - .spacing(0) - .padding(4); + let panel = column![scrollable(history_list).height(Length::Fill),] + .spacing(0) + .padding(4); container(panel) .width(Length::Fill) diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/layer_details.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/layer_details.rs index 6ab8bf3..9bc26a1 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/layer_details.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/layer_details.rs @@ -12,7 +12,11 @@ use iced::widget::{column, container, horizontal_rule, row, scrollable, text}; use iced::{Element, Length}; /// Build the layer details panel. -pub fn view<'a>(layers: &'a [LayerInfo], active_layer_id: u64, colors: ThemeColors) -> Element<'a, Message> { +pub fn view<'a>( + layers: &'a [LayerInfo], + active_layer_id: u64, + colors: ThemeColors, +) -> Element<'a, Message> { let active_layer = layers.iter().find(|l| l.id == active_layer_id); let content: Element<'a, Message> = match active_layer { @@ -24,20 +28,59 @@ pub fn view<'a>(layers: &'a [LayerInfo], active_layer_id: u64, colors: ThemeColo row![text("Name:").size(10), text(&layer.name).size(11)].spacing(4), row![text("ID:").size(10), text(format!("{}", layer.id)).size(10)].spacing(4), row![text("Type:").size(10), text(layer_type).size(10)].spacing(4), - row![text("Size:").size(10), text(format!("{}x{}", layer.width, layer.height)).size(10)].spacing(4), + row![ + text("Size:").size(10), + text(format!("{}x{}", layer.width, layer.height)).size(10) + ] + .spacing(4), horizontal_rule(1), row![text("Blend:").size(10), text(blend_mode).size(10)].spacing(4), - row![text("Opacity:").size(10), text(format!("{:.0}%", layer.opacity * 100.0)).size(10)].spacing(4), - row![text("Visible:").size(10), text(if layer.visible { "Yes" } else { "No" }).size(10)].spacing(4), - row![text("Locked:").size(10), text(if layer.locked { "Yes" } else { "No" }).size(10)].spacing(4), - row![text("Clip Mask:").size(10), text(if layer.clipping_mask { "Yes" } else { "No" }).size(10)].spacing(4), - row![text("Parent:").size(10), text(format!("{:?}", layer.parent_id)).size(10)].spacing(4), + row![ + text("Opacity:").size(10), + text(format!("{:.0}%", layer.opacity * 100.0)).size(10) + ] + .spacing(4), + row![ + text("Visible:").size(10), + text(if layer.visible { "Yes" } else { "No" }).size(10) + ] + .spacing(4), + row![ + text("Locked:").size(10), + text(if layer.locked { "Yes" } else { "No" }).size(10) + ] + .spacing(4), + row![ + text("Clip Mask:").size(10), + text(if layer.clipping_mask { "Yes" } else { "No" }).size(10) + ] + .spacing(4), + row![ + text("Parent:").size(10), + text(format!("{:?}", layer.parent_id)).size(10) + ] + .spacing(4), horizontal_rule(1), - row![text("Effects:").size(10), text(if layer.has_effects { "Yes" } else { "No" }).size(10)].spacing(4), - row![text("Vector:").size(10), text(if layer.has_vector_shapes { - format!("{} shapes", layer.shape_count) - } else { "None".to_string() }).size(10)].spacing(4), - row![text("Collapsed:").size(10), text(if layer.collapsed { "Yes" } else { "No" }).size(10)].spacing(4), + row![ + text("Effects:").size(10), + text(if layer.has_effects { "Yes" } else { "No" }).size(10) + ] + .spacing(4), + row![ + text("Vector:").size(10), + text(if layer.has_vector_shapes { + format!("{} shapes", layer.shape_count) + } else { + "None".to_string() + }) + .size(10) + ] + .spacing(4), + row![ + text("Collapsed:").size(10), + text(if layer.collapsed { "Yes" } else { "No" }).size(10) + ] + .spacing(4), ] .spacing(3) .into() @@ -46,11 +89,9 @@ pub fn view<'a>(layers: &'a [LayerInfo], active_layer_id: u64, colors: ThemeColo }; // Panel title is in the dock tab — no duplicate inside the panel. - let panel = column![ - scrollable(content).height(Length::Fill), - ] - .spacing(0) - .padding(4); + let panel = column![scrollable(content).height(Length::Fill),] + .spacing(0) + .padding(4); container(panel) .width(Length::Fill) diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/layer_styles.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/layer_styles.rs index 9eeec88..b3831ff 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/layer_styles.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/layer_styles.rs @@ -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 ── @@ -206,35 +230,47 @@ pub fn view<'a>( for (i, &(_disc, label, color_arr)) in STYLE_DEFS.iter().enumerate() { let enabled = style_enabled(_disc, styles_list); let is_selected = i == selected_style; - + let status = if enabled { "ON" } else { "OFF" }; let badge_color = iced::Color::from_rgb(color_arr[0], color_arr[1], color_arr[2]); - + let toggle_btn = button(text(status).size(9)) .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 { - color: Some(badge_color), - }); - + + 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 { @@ -242,15 +278,15 @@ pub fn view<'a>( } else { styles::inactive_item_bg(colors) }; - + let item_container = container(item_row) .width(Length::Fill) .padding([3, 4]) .style(move |_theme| item_bg.clone()); - + sidebar_items = sidebar_items.push(item_container); } - + let sidebar = column![ text("Styles").size(11), horizontal_rule(1), @@ -259,10 +295,10 @@ pub fn view<'a>( .spacing(2) .padding(4) .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)) .on_press(Message::LayerStyleApply) @@ -270,21 +306,22 @@ 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![ - sidebar, - column![params, horizontal_rule(1), buttons] - .width(Length::Fill) - .spacing(4) - .padding(8), - ].spacing(4), + let content = column![row![ + sidebar, + column![params, horizontal_rule(1), buttons] + .width(Length::Fill) + .spacing(4) + .padding(8), ] + .spacing(4),] .spacing(4) .padding(8); - + // ── Dialog frame with draggable title bar + X button ── let close_btn = button(text("\u{2715}").size(12)) .on_press(Message::LayerStyleCancel) @@ -297,29 +334,27 @@ pub fn view<'a>( .spacing(4) .align_y(iced::Alignment::Center) .padding([6, 8]); - + // 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 dialog = column![ - drag_title, - horizontal_rule(1), - content, - ] - .spacing(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); + // ── Floating panel with offset from center + backdrop ── let ox = drag_offset.0; let oy = drag_offset.1; - + let dialog_container = container(dialog) .width(Length::Fixed(580.0)) .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), @@ -327,39 +362,30 @@ pub fn view<'a>( }, ..Default::default() }); - + // 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, Space::with_width(Length::Fixed(-ox * 2.0)), ] }; - + // 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) .width(Length::Fill) .height(Length::Fill) .center_x(Length::Fill) .center_y(Length::Fill); - + backdrop.into() } @@ -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| { - Message::LayerStyleUpdateString(selected, "contour".to_string(), v.to_string()) - }).width(Length::Fixed(120.0)).text_size(10); + 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); 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) diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/layers.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/layers.rs index 290857b..0dd75b6 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/layers.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/layers.rs @@ -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 for BlendModeItem { - fn from(m: BlendMode) -> Self { Self(m) } + fn from(m: BlendMode) -> Self { + Self(m) + } } impl From 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> { if let Some(children) = children_of.get(&parent_id) { for info in children { let is_group = info.layer_type == LayerType::Group; - result.push(LayerEntry { info, depth, is_group }); + result.push(LayerEntry { + info, + depth, + is_group, + }); if is_group && !info.collapsed { walk(Some(info.id), depth + 1, children_of, result); } @@ -114,7 +137,8 @@ fn layer_thumbnail<'a>( } let info = entry.info; - let thumb = engine.get_layer_pixels(info.id) + let thumb = engine + .get_layer_pixels(info.id) .filter(|px| !px.is_empty() && info.width > 0 && info.height > 0) .map(|px| thumbnails::generate_thumbnail(&px, info.width, info.height, 28)); @@ -141,17 +165,19 @@ fn layer_thumbnail<'a>( _ => ("\u{25A3}", colors.text_secondary), }; - container( - text(icon_char).size(10).style(move |_theme: &iced::Theme| iced::widget::text::Style { + container(text(icon_char).size(10).style(move |_theme: &iced::Theme| { + iced::widget::text::Style { color: Some(icon_color), - }) - ) + } + })) .width(30) .height(22) .center_x(Length::Fixed(30.0)) .center_y(Length::Fixed(22.0)) .style(move |_theme| iced::widget::container::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba(1.0, 1.0, 1.0, 0.05))), + background: Some(iced::Background::Color(iced::Color::from_rgba( + 1.0, 1.0, 1.0, 0.05, + ))), border: iced::Border::default().color(colors.border_high).width(1), ..Default::default() }) @@ -169,18 +195,35 @@ pub fn view<'a>( // ── Active layer controls ────────────────────────────── let active_info = layers.iter().find(|l| l.id == active_layer_id); - let active_blend = active_info.map(|l| l.blend_mode).unwrap_or(BlendMode::Normal); + let active_blend = active_info + .map(|l| l.blend_mode) + .unwrap_or(BlendMode::Normal); let active_opacity = active_info.map(|l| l.opacity).unwrap_or(1.0); let active_locked = active_info.map(|l| l.locked).unwrap_or(false); + let active_metadata = active_info + .map(|layer| { + format!( + "{:?} {}x{} parent {}", + layer.layer_type, + layer.width, + layer.height, + layer + .parent_id + .map_or_else(|| "root".to_string(), |id| id.to_string()) + ) + }) + .unwrap_or_else(|| "No active layer".to_string()); - let blend_list = pick_list(BLEND_MODE_ITEMS, Some(BlendModeItem(active_blend)), move |item| { - Message::LayerSetBlendMode(active_layer_id, item.into()) - }) + let blend_list = pick_list( + BLEND_MODE_ITEMS, + Some(BlendModeItem(active_blend)), + move |item| Message::LayerSetBlendMode(active_layer_id, item.into()), + ) .width(Length::Fill) - .text_size(10); + .text_size(BODY); let opacity_label = text(format!("{}%", (active_opacity * 100.0) as u32)) - .size(10) + .size(BODY) .width(Length::Fixed(32.0)); let opacity_slider = slider(0.0..=1.0, active_opacity, move |v| { Message::LayerSetOpacity(active_layer_id, v) @@ -188,11 +231,15 @@ pub fn view<'a>( .step(0.01) .width(Length::Fill); - let opacity_row = row![text("Op").size(9), opacity_slider, opacity_label] + let opacity_row = row![text("Op").size(BODY), opacity_slider, opacity_label] .spacing(3) .align_y(iced::Alignment::Center); - let lock_icon = if active_locked { "\u{1F512}" } else { "\u{1F513}" }; + let lock_icon = if active_locked { + "\u{1F512}" + } else { + "\u{1F513}" + }; let lock_btn = button(text(lock_icon).size(11)) .on_press(Message::LayerToggleLock(active_layer_id)) .padding([1, 4]) @@ -206,7 +253,15 @@ pub fn view<'a>( let controls = column![ row![blend_list].spacing(2), opacity_row, - row![lock_btn, fx_btn].spacing(8).align_y(iced::Alignment::Center), + text("Fill opacity: unavailable in hcie-engine-api") + .size(BODY) + .style(move |_theme: &iced::Theme| iced::widget::text::Style { + color: Some(colors.text_secondary), + }), + row![lock_btn, fx_btn] + .spacing(8) + .align_y(iced::Alignment::Center), + text(active_metadata).size(BODY), ] .spacing(2); @@ -219,13 +274,16 @@ pub fn view<'a>( let depth = entry.depth; // Indent spacer - let indent: Element<'a, Message> = Space::with_width( - Length::Fixed((depth as f32) * 14.0) - ).into(); + let indent: Element<'a, Message> = + Space::with_width(Length::Fixed((depth as f32) * 14.0)).into(); // Collapse arrow (groups only) let collapse: Element<'a, Message> = if entry.is_group { - let arrow = if info.collapsed { "\u{25B6}" } else { "\u{25BC}" }; + let arrow = if info.collapsed { + "\u{25B6}" + } else { + "\u{25BC}" + }; button(text(arrow).size(8)) .on_press(Message::LayerToggleCollapse(info.id)) .padding([0, 1]) @@ -237,31 +295,46 @@ pub fn view<'a>( // Visibility toggle let vis_char = if info.visible { "\u{25CF}" } else { "\u{25CB}" }; - let vis_color = if info.visible { colors.text_primary } else { colors.text_secondary }; - let vis_btn = button(text(vis_char).size(10).style(move |_theme: &iced::Theme| iced::widget::text::Style { - color: Some(vis_color), + let vis_color = if info.visible { + colors.text_primary + } else { + colors.text_secondary + }; + let vis_btn = button(text(vis_char).size(10).style(move |_theme: &iced::Theme| { + iced::widget::text::Style { + color: Some(vis_color), + } })) .on_press(Message::LayerToggleVisibility(info.id)) .padding([0, 2]) .style(move |_theme, _status| flat_btn_style(colors)); + let row_lock = button(text(if info.locked { "L" } else { "-" }).size(9)) + .on_press(Message::LayerToggleLock(info.id)) + .padding([0, 2]) + .style(move |_theme, _status| flat_btn_style(colors)); + // Thumbnail let thumb_elem = layer_thumbnail(entry, engine, colors); // Layer name - let c = if is_active { colors.accent } else { colors.text_primary }; + let c = if is_active { + colors.accent + } else { + colors.text_primary + }; let name_text = text(&info.name) - .size(10) + .size(BODY) .width(Length::Fill) .style(move |_theme: &iced::Theme| iced::widget::text::Style { color: Some(c) }); // "fx" icon for layers with effects (Photoshop-style) let fx_icon: Element<'a, Message> = if info.has_effects { - button( - text("fx").size(8).style(move |_theme: &iced::Theme| iced::widget::text::Style { + button(text("fx").size(8).style(move |_theme: &iced::Theme| { + iced::widget::text::Style { color: Some(colors.accent), - }) - ) + } + })) .on_press(Message::LayerStyleToggle(0)) // Opens styles panel .padding([0, 2]) .style(move |_theme, _status| flat_btn_style(colors)) @@ -272,36 +345,38 @@ pub fn view<'a>( // Type badge let type_badge: Element<'a, Message> = match info.layer_type { - LayerType::Vector => text("V").size(8) + LayerType::Vector => text("V") + .size(8) .style(move |_theme: &iced::Theme| iced::widget::text::Style { color: Some(iced::Color::from_rgb(0.4, 0.8, 0.4)), - }).into(), - LayerType::Text => text("T").size(8) + }) + .into(), + LayerType::Text => text("T") + .size(8) .style(move |_theme: &iced::Theme| iced::widget::text::Style { color: Some(iced::Color::from_rgb(0.4, 0.6, 1.0)), - }).into(), - LayerType::Group => text("G").size(8) + }) + .into(), + LayerType::Group => text("G") + .size(8) .style(move |_theme: &iced::Theme| iced::widget::text::Style { color: Some(iced::Color::from_rgb(0.85, 0.7, 0.3)), - }).into(), - LayerType::Mask => text("M").size(8) + }) + .into(), + LayerType::Mask => text("M") + .size(8) .style(move |_theme: &iced::Theme| iced::widget::text::Style { color: Some(iced::Color::from_rgb(0.8, 0.4, 0.8)), - }).into(), + }) + .into(), _ => text("").size(8).into(), }; // Main layer row - let name_row = row![ - collapse, - vis_btn, - thumb_elem, - name_text, - fx_icon, - type_badge, - ] - .spacing(2) - .align_y(iced::Alignment::Center); + let name_row = + row![collapse, vis_btn, row_lock, thumb_elem, name_text, fx_icon, type_badge,] + .spacing(2) + .align_y(iced::Alignment::Center); let layer_item = if is_active { container(name_row) @@ -309,13 +384,21 @@ pub fn view<'a>( .padding([2, 3]) .style(move |_theme| { let accent_bg = iced::Color::from_rgba( - colors.accent.r, colors.accent.g, colors.accent.b, 0.18, + colors.accent.r, + colors.accent.g, + colors.accent.b, + 0.18, ); iced::widget::container::Style { background: Some(iced::Background::Color(accent_bg)), - border: iced::Border::default().color( - iced::Color::from_rgba(colors.accent.r, colors.accent.g, colors.accent.b, 0.4) - ).width(1), + border: iced::Border::default() + .color(iced::Color::from_rgba( + colors.accent.r, + colors.accent.g, + colors.accent.b, + 0.4, + )) + .width(1), ..Default::default() } }) @@ -326,8 +409,8 @@ pub fn view<'a>( .style(move |_theme| styles::inactive_item_bg(colors)) }; - let clickable = iced::widget::mouse_area(layer_item) - .on_press(Message::LayerSelect(info.id)); + let clickable = + iced::widget::mouse_area(layer_item).on_press(Message::LayerSelect(info.id)); let row_with_indent = row![indent, clickable].align_y(iced::Alignment::Center); layer_list = layer_list.push(row_with_indent); @@ -341,17 +424,20 @@ pub fn view<'a>( if *enabled { let effect_name = effect_full_name(code); let effect_color = fx_color(code); - let effect_indent = Space::with_width( - Length::Fixed((effects_depth as f32) * 14.0) - ); + let effect_indent = + Space::with_width(Length::Fixed((effects_depth as f32) * 14.0)); let effect_row = row![ effect_indent, - text("\u{25B8}").size(8).style(move |_theme: &iced::Theme| iced::widget::text::Style { - color: Some(colors.text_secondary), - }), - text(effect_name).size(9).style(move |_theme: &iced::Theme| iced::widget::text::Style { - color: Some(effect_color), + text("\u{25B8}").size(8).style(move |_theme: &iced::Theme| { + iced::widget::text::Style { + color: Some(colors.text_secondary), + } }), + text(effect_name) + .size(BODY) + .style(move |_theme: &iced::Theme| iced::widget::text::Style { + color: Some(effect_color), + }), ] .spacing(2) .align_y(iced::Alignment::Center); @@ -371,6 +457,9 @@ pub fn view<'a>( .on_press(Message::LayerAddGroup) .padding([3, 6]) .style(move |_theme, _status| flat_btn_style(colors)); + let duplicate_btn = button(text("Dup").size(9)).padding([3, 6]); + let mask_btn = button(text("Mask").size(9)).padding([3, 6]); + let rasterize_btn = button(text("Raster").size(9)).padding([3, 6]); let flatten_btn = button(text("Flatten").size(10)) .on_press(Message::LayerFlatten) .padding([3, 6]) @@ -392,17 +481,19 @@ pub fn view<'a>( .on_press(Message::OpenLayerStyleDialog) .padding([3, 6]) .style(move |_theme, _status| flat_btn_style(colors)); - let toolbar = row![ - add_btn, group_btn, flatten_btn, - iced::widget::horizontal_rule(1), - fx_btn, - iced::widget::horizontal_rule(1), - move_up_btn, move_down_btn, - iced::widget::horizontal_rule(1), - del_btn, + let toolbar = column![ + text("Disabled (engine API): duplicate / mask / rasterize").size(8), + row![duplicate_btn, mask_btn, rasterize_btn] + .spacing(2) + .align_y(iced::Alignment::Center), + row![add_btn, group_btn, flatten_btn, fx_btn] + .spacing(2) + .align_y(iced::Alignment::Center), + row![move_up_btn, move_down_btn, del_btn] + .spacing(2) + .align_y(iced::Alignment::Center), ] - .spacing(2) - .align_y(iced::Alignment::Center); + .spacing(2); // ── Panel layout (no duplicate title — dock tab provides it) ── let panel = column![ diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs index 56878dc..ae7648c 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs @@ -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, } 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 { // ── 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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> { 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,55 +582,83 @@ 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)) - .padding(0) - .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 { - 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() - }, - } - }); + 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| { + match status { + // Photopea-style hover: light blue highlight + 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 { // 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, - bottom: 0.0, - left: left_offset, - right: 0.0, - }) - ) - .on_press(Message::MenuClose); + 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, + }); + 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 { - color: colors.border_high, - width: 1, - radius: 0.0.into(), - fill_mode: iced::widget::rule::FillMode::Full, - })) + 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]) - ) - .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.text_primary, - border: iced::Border::default(), - ..Default::default() - }, - _ => iced::widget::button::Style { - background: Some(iced::Background::Color(c.menu_bg)), - text_color: c.text_primary, - border: iced::Border::default(), - ..Default::default() - }, - } - }) - .on_press(msg); + 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| { + 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() + }, + } + }, + ); + + 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 { - top: y, - bottom: 0.0, - left: x, - right: 0.0, - }) - ) + 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 = 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)); + } +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/mod.rs index 10771ec..c9655a8 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/mod.rs @@ -22,3 +22,4 @@ pub mod title_bar; pub mod tool_options; pub mod tool_settings; pub mod toolbar; +pub mod typography; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs index 8c8b678..1f0f407 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs @@ -6,10 +6,14 @@ use crate::app::Message; use crate::color_picker; +use crate::color_picker::ColorPickerTarget; use crate::panels::styles; +use crate::panels::typography::{BODY, SECTION}; use crate::theme::ThemeColors; use hcie_engine_api::{BlendMode, LayerType, Tool, VectorShape}; -use iced::widget::{button, checkbox, column, container, horizontal_rule, row, slider, text}; +use iced::widget::{ + button, checkbox, column, container, horizontal_rule, row, scrollable, slider, text, +}; use iced::{Element, Length}; /// Human-readable label for a shape at a given index. @@ -104,6 +108,8 @@ pub fn view<'a>( vector_points: u32, vector_sides: u32, active_layer_id: u64, + foreground_color: [u8; 4], + background_color: [u8; 4], ) -> Element<'a, Message> { // Build layer info section at the top let layer_section = layer_info_section( @@ -141,40 +147,40 @@ pub fn view<'a>( | Tool::VectorCross | Tool::VectorCrescent | Tool::VectorBolt - | Tool::VectorArrow4 => { - vector_tool_properties( - active_tool, - vector_stroke, - vector_opacity, - vector_fill, - vector_radius, - vector_points, - vector_sides, - vector_draw, - colors, - ) - } + | Tool::VectorArrow4 => vector_tool_properties( + active_tool, + vector_stroke, + vector_opacity, + vector_fill, + vector_radius, + vector_points, + vector_sides, + vector_draw, + colors, + foreground_color, + background_color, + ), Tool::Text => text_properties(colors), Tool::Eyedropper => column![ - text("Eyedropper").size(11), - text("Click on canvas to pick color").size(10), + text("Eyedropper").size(SECTION), + text("Click on canvas to pick color").size(BODY), ] .spacing(4) .into(), Tool::FloodFill => column![ - text("Flood Fill").size(11), - text("Click on canvas to fill area").size(10), + text("Flood Fill").size(SECTION), + text("Click on canvas to fill area").size(BODY), ] .spacing(4) .into(), - _ => text(format!("{:?}", active_tool)).size(11).into(), + _ => text(format!("{:?}", active_tool)).size(SECTION).into(), }; let panel = column![layer_section, horizontal_rule(1), tool_section,] .spacing(4) .padding(4); - container(panel) + container(scrollable(panel)) .width(Length::Fill) .height(Length::Fill) .style(move |_theme| styles::panel_background(colors)) @@ -193,25 +199,29 @@ fn layer_info_section<'a>( layer_id: u64, _colors: ThemeColors, ) -> Element<'a, Message> { - let opacity_slider = slider(0.0..=1.0, opacity, move |v| Message::LayerSetOpacity(layer_id, v)) - .step(0.01) - .width(Length::Fill); + let opacity_slider = slider(0.0..=1.0, opacity, move |v| { + Message::LayerSetOpacity(layer_id, v) + }) + .step(0.01) + .width(Length::Fill); column![ - text("Layer Info").size(11), + text("Layer Info").size(SECTION), row![ - text("Name").size(10).width(Length::Fixed(50.0)), - text(name).size(10), + text("Name").size(BODY).width(Length::Fixed(58.0)), + text(name).size(BODY), ] .spacing(4), row![ - text("Opacity").size(10).width(Length::Fixed(50.0)), - text(format!("{:.0}%", opacity * 100.0)).size(10).width(Length::Fixed(36.0)), + text("Opacity").size(BODY).width(Length::Fixed(58.0)), + text(format!("{:.0}%", opacity * 100.0)) + .size(BODY) + .width(Length::Fixed(36.0)), ] .spacing(4), opacity_slider, row![ - text("Visible").size(10).width(Length::Fixed(50.0)), + text("Visible").size(BODY).width(Length::Fixed(58.0)), checkbox("", visible) .on_toggle(move |v| Message::LayerToggleVisibility(layer_id)) .size(11), @@ -219,18 +229,18 @@ fn layer_info_section<'a>( .spacing(4) .align_y(iced::Alignment::Center), row![ - text("Blend").size(10).width(Length::Fixed(50.0)), - text(blend_mode_label(blend_mode)).size(10), + text("Blend").size(BODY).width(Length::Fixed(58.0)), + text(blend_mode_label(blend_mode)).size(BODY), ] .spacing(4), row![ - text("Type").size(10).width(Length::Fixed(50.0)), - text(layer_type_label(layer_type)).size(10), + text("Type").size(BODY).width(Length::Fixed(58.0)), + text(layer_type_label(layer_type)).size(BODY), ] .spacing(4), row![ - text("Size").size(10).width(Length::Fixed(50.0)), - text(format!("{}×{}", width, height)).size(10), + text("Size").size(BODY).width(Length::Fixed(58.0)), + text(format!("{}×{}", width, height)).size(BODY), ] .spacing(4), ] @@ -274,23 +284,39 @@ fn vector_select_properties<'a>( }; column![ - text("Shape Properties").size(11), - row![text("Shape").size(10), text(label).size(10)].spacing(4), - row![text("Type").size(10), text(shape_type).size(10)].spacing(4), - row![text("Stroke").size(10), text(format!("{:.1}", stroke)).size(10)].spacing(4), + text("Shape Properties").size(SECTION), + row![text("Shape").size(BODY), text(label).size(BODY)].spacing(4), + row![text("Type").size(BODY), text(shape_type).size(BODY)].spacing(4), + row![ + text("Stroke").size(BODY), + text(format!("{:.1}", stroke)).size(BODY) + ] + .spacing(4), slider(0.0..=50.0, stroke, move |v| Message::VectorStrokeChanged(v)) .step(0.5) .width(Length::Fill), - row![text("Opacity").size(10), text(format!("{:.0}%", opacity * 100.0)).size(10)].spacing(4), - slider(0.0..=1.0, opacity, move |v| Message::VectorOpacityChanged(v)) - .step(0.01) - .width(Length::Fill), - row![text("Hardness").size(10), text(format!("{:.0}%", hardness * 100.0)).size(10)].spacing(4), - slider(0.0..=1.0, hardness, move |v| Message::GeometryHardnessChanged(v)) - .step(0.01) - .width(Length::Fill), row![ - text("Fill").size(10), + text("Opacity").size(BODY), + text(format!("{:.0}%", opacity * 100.0)).size(BODY) + ] + .spacing(4), + slider(0.0..=1.0, opacity, move |v| Message::VectorOpacityChanged( + v + )) + .step(0.01) + .width(Length::Fill), + row![ + text("Hardness").size(BODY), + text(format!("{:.0}%", hardness * 100.0)).size(BODY) + ] + .spacing(4), + slider(0.0..=1.0, hardness, move |v| { + Message::GeometryHardnessChanged(v) + }) + .step(0.01) + .width(Length::Fill), + row![ + text("Fill").size(BODY), checkbox("", filled) .on_toggle(Message::VectorFillToggled) .size(11), @@ -299,27 +325,27 @@ fn vector_select_properties<'a>( .align_y(iced::Alignment::Center), // Fill color picker (only if fill is enabled) if filled { - color_picker::color_popup( + color_picker::color_swatch( fill_color, "Fill Color", - Message::GeometryFillColorChanged, + ColorPickerTarget::GeometryFill, ) } else { text("").into() }, // Stroke color picker - color_picker::color_popup( + color_picker::color_swatch( stroke_color, "Stroke Color", - Message::GeometryStrokeColorChanged, + ColorPickerTarget::GeometryStroke, ), ] .spacing(4) .into() } None => column![ - text("Vector Select").size(11), - text("Click a shape on canvas").size(10), + text("Vector Select").size(SECTION), + text("Click a shape on canvas").size(BODY), ] .spacing(4) .into(), @@ -337,19 +363,24 @@ fn vector_tool_properties<'a>( sides: u32, vector_draw: Option<((f32, f32), (f32, f32))>, _colors: ThemeColors, + foreground_color: [u8; 4], + background_color: [u8; 4], ) -> Element<'a, Message> { - // Get current fg/bg colors from app state (we need to pass them through) - // For now, use default colors - the actual colors will come from the app state - let default_color = [0, 0, 0, 255]; - let default_fill_color = [255, 255, 255, 255]; - let mut col = column![ text("Vector Shape").size(11), - row![text("Stroke").size(10), text(format!("{:.1}", stroke)).size(10)].spacing(4), + row![ + text("Stroke").size(10), + text(format!("{:.1}", stroke)).size(10) + ] + .spacing(4), slider(0.0..=50.0, stroke, |v| Message::VectorStrokeChanged(v)) .step(0.5) .width(Length::Fill), - row![text("Opacity").size(10), text(format!("{:.0}%", opacity * 100.0)).size(10)].spacing(4), + row![ + text("Opacity").size(10), + text(format!("{:.0}%", opacity * 100.0)).size(10) + ] + .spacing(4), slider(0.0..=1.0, opacity, |v| Message::VectorOpacityChanged(v)) .step(0.01) .width(Length::Fill), @@ -363,19 +394,19 @@ fn vector_tool_properties<'a>( .align_y(iced::Alignment::Center), // Fill color picker (only if fill is enabled) if fill { - color_picker::color_popup( - default_fill_color, + color_picker::color_swatch( + background_color, "Fill Color", - Message::GeometryFillColorChanged, + ColorPickerTarget::Background, ) } else { text("").into() }, // Stroke color picker - color_picker::color_popup( - default_color, + color_picker::color_swatch( + foreground_color, "Stroke Color", - Message::GeometryStrokeColorChanged, + ColorPickerTarget::Foreground, ), ] .spacing(4); @@ -384,7 +415,11 @@ fn vector_tool_properties<'a>( match tool { Tool::VectorRect => { col = col.push( - row![text("Radius").size(10), text(format!("{:.1}", radius)).size(10)].spacing(4), + row![ + text("Radius").size(10), + text(format!("{:.1}", radius)).size(10) + ] + .spacing(4), ); col = col.push( slider(0.0..=100.0, radius, |v| Message::VectorRadiusChanged(v)) @@ -394,22 +429,29 @@ fn vector_tool_properties<'a>( } Tool::VectorStar => { col = col.push( - row![text("Points").size(10), text(format!("{}", points)).size(10)].spacing(4), + row![ + text("Points").size(10), + text(format!("{}", points)).size(10) + ] + .spacing(4), ); col = col.push( - slider(3.0..=20.0, points as f32, |v| Message::VectorPointsChanged(v as u32)) - .step(1.0) - .width(Length::Fill), + slider(3.0..=20.0, points as f32, |v| { + Message::VectorPointsChanged(v as u32) + }) + .step(1.0) + .width(Length::Fill), ); } Tool::VectorPolygon => { + col = col + .push(row![text("Sides").size(10), text(format!("{}", sides)).size(10)].spacing(4)); col = col.push( - row![text("Sides").size(10), text(format!("{}", sides)).size(10)].spacing(4), - ); - col = col.push( - slider(3.0..=12.0, sides as f32, |v| Message::VectorSidesChanged(v as u32)) - .step(1.0) - .width(Length::Fill), + slider(3.0..=12.0, sides as f32, |v| { + Message::VectorSidesChanged(v as u32) + }) + .step(1.0) + .width(Length::Fill), ); } _ => {} @@ -420,16 +462,20 @@ fn vector_tool_properties<'a>( let w = (x1 - x0).abs() as u32; let h = (y1 - y0).abs() as u32; col = col.push(horizontal_rule(1)); - col = col.push( - row![text("Size").size(10), text(format!("{}×{}", w, h)).size(10)].spacing(4), - ); + col = + col.push(row![text("Size").size(10), text(format!("{}×{}", w, h)).size(10)].spacing(4)); } col.into() } /// Brush tool properties (Pen, Brush, Eraser, Spray). -fn brush_properties<'a>(size: f32, opacity: f32, hardness: f32, _colors: ThemeColors) -> Element<'a, Message> { +fn brush_properties<'a>( + size: f32, + opacity: f32, + hardness: f32, + _colors: ThemeColors, +) -> Element<'a, Message> { let size_slider = slider(1.0..=200.0, size, |v| Message::BrushSizeChanged(v)) .step(1.0) .width(Length::Fill); @@ -444,9 +490,17 @@ fn brush_properties<'a>(size: f32, opacity: f32, hardness: f32, _colors: ThemeCo text("Brush Properties").size(11), row![text("Size").size(10), text(format!("{:.0}", size)).size(10)].spacing(4), size_slider, - row![text("Opacity").size(10), text(format!("{:.0}%", opacity * 100.0)).size(10)].spacing(4), + row![ + text("Opacity").size(10), + text(format!("{:.0}%", opacity * 100.0)).size(10) + ] + .spacing(4), opacity_slider, - row![text("Hardness").size(10), text(format!("{:.0}%", hardness * 100.0)).size(10)].spacing(4), + row![ + text("Hardness").size(10), + text(format!("{:.0}%", hardness * 100.0)).size(10) + ] + .spacing(4), hardness_slider, ] .spacing(4) @@ -454,7 +508,10 @@ fn brush_properties<'a>(size: f32, opacity: f32, hardness: f32, _colors: ThemeCo } /// Selection tool properties. -fn selection_properties<'a>(rect: Option<(f32, f32, f32, f32)>, _colors: ThemeColors) -> Element<'a, Message> { +fn selection_properties<'a>( + rect: Option<(f32, f32, f32, f32)>, + _colors: ThemeColors, +) -> Element<'a, Message> { match rect { Some((x0, y0, x1, y1)) => { let w = (x1 - x0).abs() as u32; @@ -466,7 +523,9 @@ fn selection_properties<'a>(rect: Option<(f32, f32, f32, f32)>, _colors: ThemeCo .spacing(4) .into() } - None => column![text("Selection").size(11), text("Drag on canvas").size(10)].spacing(4).into(), + None => column![text("Selection").size(11), text("Drag on canvas").size(10)] + .spacing(4) + .into(), } } @@ -477,10 +536,17 @@ fn text_properties<'a>(_colors: ThemeColors) -> Element<'a, Message> { text("Click on canvas to add text").size(10), horizontal_rule(1), row![ - button(text("Left").size(10)).on_press(Message::TextAlignChanged("Left".to_string())).padding([4, 8]), - button(text("Center").size(10)).on_press(Message::TextAlignChanged("Center".to_string())).padding([4, 8]), - button(text("Right").size(10)).on_press(Message::TextAlignChanged("Right".to_string())).padding([4, 8]), - ].spacing(4), + button(text("Left").size(10)) + .on_press(Message::TextAlignChanged("Left".to_string())) + .padding([4, 8]), + button(text("Center").size(10)) + .on_press(Message::TextAlignChanged("Center".to_string())) + .padding([4, 8]), + button(text("Right").size(10)) + .on_press(Message::TextAlignChanged("Right".to_string())) + .padding([4, 8]), + ] + .spacing(4), ] .spacing(4) .into() diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/script_panel.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/script_panel.rs index 994a53c..5ce4d4f 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/script_panel.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/script_panel.rs @@ -7,7 +7,9 @@ use crate::app::Message; use crate::panels::styles; use crate::theme::ThemeColors; -use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, text_editor}; +use iced::widget::{ + button, column, container, horizontal_rule, row, scrollable, text, text_editor, +}; use iced::{Element, Length}; /// Build the full script editor panel. @@ -43,7 +45,12 @@ pub fn view<'a>( .fold(column![].spacing(0), |col, t| col.push(t)); let gutter = container(scrollable(gutter_lines).height(Length::Fill)) - .padding(iced::Padding { top: 8.0, right: 4.0, bottom: 8.0, left: 8.0 }) + .padding(iced::Padding { + top: 8.0, + right: 4.0, + bottom: 8.0, + left: 8.0, + }) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(colors.bg_app)), border: iced::Border::default().rounded(4), @@ -69,9 +76,13 @@ pub fn view<'a>( .padding(iced::Padding::from([6.0, 12.0])); let status_text = if script_is_running { - text("⟳ Executing...").size(11).color(iced::Color::from_rgb(0.9, 0.7, 0.2)) + text("⟳ Executing...") + .size(11) + .color(iced::Color::from_rgb(0.9, 0.7, 0.2)) } else { - text("Ready").size(11).color(iced::Color::from_rgb(0.4, 0.7, 0.4)) + text("Ready") + .size(11) + .color(iced::Color::from_rgb(0.4, 0.7, 0.4)) }; let buttons = row![run_btn, clear_btn, status_text].spacing(6); @@ -148,7 +159,7 @@ pub fn view<'a>( // ── Command Reference ─────────────────────────────────── let help_text = [ ("Layer:", "layer Name — create raster layer"), - ("", "vector_layer Name — create vector layer"), + ("", "vector_layer — unavailable (public engine API)"), ("", "select_layer Name — switch active layer"), ("Brush & Color:", "brush round — set brush style"), ("", "color #FF0000 — set foreground color"), @@ -162,7 +173,10 @@ pub fn view<'a>( ("", "circle cx,cy — single dab"), ("", "triangle x1,y1 x2,y2 x3,y3 — vector triangle"), ("", "bezier x1,y1 cx1,cy1 cx2,cy2 x2,y2 [steps]"), - ("Organic:", "scatter N x,y w,h [size] — golden-angle scatter"), + ( + "Organic:", + "scatter N x,y w,h [size] — golden-angle scatter", + ), ("", "gradient x1,y1 x2,y2 #hex1 #hex2 ... N"), ("", "wiggle N — per-point jitter"), ("", "pressure / pressure_start / pressure_end"), @@ -172,7 +186,9 @@ pub fn view<'a>( ]; let mut reference = column![ - text("Command Reference").size(11).font(iced::Font::MONOSPACE), + text("Command Reference") + .size(11) + .font(iced::Font::MONOSPACE), horizontal_rule(1), ] .spacing(2); @@ -193,16 +209,20 @@ pub fn view<'a>( ); } - let reference_container = container(scrollable(reference).height(Length::Fill)) - .style(move |_theme| iced::widget::container::Style { - background: Some(iced::Background::Color(colors.bg_app)), - border: iced::Border::default().rounded(4), - ..Default::default() + let reference_container = + container(scrollable(reference).height(Length::Fill)).style(move |_theme| { + iced::widget::container::Style { + background: Some(iced::Background::Color(colors.bg_app)), + border: iced::Border::default().rounded(4), + ..Default::default() + } }); // ── Assemble the panel ────────────────────────────────── let panel = column![ - text("HCIE Script — Organic DSL").size(13).font(iced::Font::MONOSPACE), + text("HCIE Script — Organic DSL") + .size(13) + .font(iced::Font::MONOSPACE), horizontal_rule(1), editor_row, buttons, diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/styles.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/styles.rs index f8334cc..6439490 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/styles.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/styles.rs @@ -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() } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/text_editor.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/text_editor.rs index ee4d9f2..3512c07 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/text_editor.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/text_editor.rs @@ -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() diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/thumbnails.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/thumbnails.rs index a09aec4..1b6c64e 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/thumbnails.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/thumbnails.rs @@ -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 { 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; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/title_bar.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/title_bar.rs index a801330..d890523 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/title_bar.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/title_bar.rs @@ -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::(); + natural.min((viewport_width - dropdown_width).max(0.0)) +} + /// Build the Photopea-style unified title/menu bar element. /// /// Layout: [File Edit Image ... More] [doc info] [─ □ ×] /// The left portion (before window controls) is draggable. pub fn view<'a>( doc_name: &'a str, + modified: bool, zoom: f32, canvas_w: u32, canvas_h: u32, @@ -36,10 +63,15 @@ pub fn view<'a>( for (idx, &label) in MENU_LABELS.iter().enumerate() { let is_open = active_menu == Some(idx); let c = colors; - let btn = button(text(label).size(13).font(iced::Font { weight: iced::font::Weight::Semibold, ..iced::Font::default() })) - .on_press(Message::MenuOpen(idx)) - .padding([3, 8]) - .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { + let btn = button(text(label).size(13).font(iced::Font { + weight: iced::font::Weight::Semibold, + ..iced::Font::default() + })) + .on_press(Message::MenuOpen(idx)) + .padding([3, 8]) + .width(MENU_BUTTON_WIDTHS[idx]) + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| { if is_open { // Active menu: light background with dark text (Photopea style) iced::widget::button::Style { @@ -57,23 +89,30 @@ pub fn view<'a>( ..Default::default() }, _ => iced::widget::button::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))), + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.0, 0.0, 0.0, 0.0, + ))), text_color: c.text_primary, border: iced::Border::default(), ..Default::default() }, } } - }); + }, + ); menu_items = menu_items.push(btn); } // ── Document info — Photopea style: "New Project.psd *" ── - let doc_info = text(format!("{} *", doc_name)) - .size(11) - .style(move |_theme: &iced::Theme| iced::widget::text::Style { - color: Some(colors.text_secondary), - }); + let doc_info = text(if modified { + format!("{} *", doc_name) + } else { + doc_name.to_string() + }) + .size(11) + .style(move |_theme: &iced::Theme| iced::widget::text::Style { + color: Some(colors.text_secondary), + }); // ── Window controls — Photopea style: ─ □ × ── let c = colors; @@ -81,8 +120,8 @@ pub fn view<'a>( let minimize_btn = button(text("─").size(12)) .on_press(Message::WindowMinimize) .padding([2, 8]) - .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { - match status { + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { iced::widget::button::Status::Hovered => iced::widget::button::Style { background: Some(iced::Background::Color(c.bg_hover)), text_color: c.text_primary, @@ -90,19 +129,21 @@ pub fn view<'a>( ..Default::default() }, _ => iced::widget::button::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))), + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.0, 0.0, 0.0, 0.0, + ))), text_color: c.text_secondary, border: iced::Border::default(), ..Default::default() }, - } - }); + }, + ); let maximize_btn = button(text("□").size(12)) .on_press(Message::WindowMaximize) .padding([2, 8]) - .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { - match status { + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { iced::widget::button::Status::Hovered => iced::widget::button::Style { background: Some(iced::Background::Color(c.bg_hover)), text_color: c.text_primary, @@ -110,19 +151,21 @@ pub fn view<'a>( ..Default::default() }, _ => iced::widget::button::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))), + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.0, 0.0, 0.0, 0.0, + ))), text_color: c.text_secondary, border: iced::Border::default(), ..Default::default() }, - } - }); + }, + ); let close_btn = button(text("×").size(13)) .on_press(Message::WindowClose) .padding([2, 10]) - .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { - match status { + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { iced::widget::button::Status::Hovered => iced::widget::button::Style { background: Some(iced::Background::Color(c.danger)), text_color: iced::Color::WHITE, @@ -130,37 +173,41 @@ pub fn view<'a>( ..Default::default() }, _ => iced::widget::button::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))), + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.0, 0.0, 0.0, 0.0, + ))), text_color: c.text_secondary, border: iced::Border::default(), ..Default::default() }, - } - }); + }, + ); let window_controls = row![minimize_btn, maximize_btn, close_btn].spacing(0); - // ── Left side (draggable): menus + doc info ── - let left_side = row![ - menu_items, - iced::widget::Space::with_width(Length::Fill), - doc_info, - ] - .spacing(0) - .align_y(iced::Alignment::Center); - + // Only the empty title region is draggable. Interactive menus are siblings, + // preventing their presses from also initiating a native window drag. let draggable_left = mouse_area( - container(left_side) - .width(Length::Fill) - .height(24) - .align_y(iced::Alignment::Center) - .padding([0, 4]) + container(row![ + iced::widget::Space::with_width(Length::Fill), + doc_info + ]) + .width(Length::Fill) + .height(MENU_BAR_HEIGHT) + .align_y(iced::Alignment::Center) + .padding([0, 4]), ) .on_press(Message::WindowDrag) .interaction(iced::mouse::Interaction::Grab); // ── Full bar ── let bar = row![ + container(menu_items).padding(iced::Padding { + left: MENU_LEFT_INSET, + right: 0.0, + top: 0.0, + bottom: 0.0, + }), draggable_left, window_controls, ] @@ -168,7 +215,7 @@ pub fn view<'a>( container(bar) .width(Length::Fill) - .height(24) + .height(MENU_BAR_HEIGHT) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(colors.bg_panel)), border: iced::Border::default().color(colors.border_low).width(1), @@ -176,3 +223,18 @@ pub fn view<'a>( }) .into() } + +#[cfg(test)] +mod tests { + use super::menu_anchor_x; + + /// Verifies natural anchors and narrow-viewport clamping use rendered button metrics. + #[test] + fn menu_anchors_are_deterministic_across_viewport_widths() { + assert_eq!(menu_anchor_x(0, 1280.0, 260.0), 4.0); + assert_eq!(menu_anchor_x(3, 1280.0, 260.0), 140.0); + assert_eq!(menu_anchor_x(8, 1280.0, 260.0), 421.0); + assert_eq!(menu_anchor_x(8, 500.0, 260.0), 240.0); + assert_eq!(menu_anchor_x(8, 180.0, 260.0), 0.0); + } +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_options.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_options.rs index 5608d3f..d4718db 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_options.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_options.rs @@ -28,7 +28,6 @@ use iced::{Element, Length}; pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, Message> { let tool_name = text(tool_name_label(tool_state.active_tool)) .size(11) - .style(move |_theme: &iced::Theme| iced::widget::text::Style { color: Some(colors.text_primary), }); @@ -58,9 +57,12 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M .step(0.01) .width(Length::Fixed(100.0)); - let hardness_label = text(format!("Hardness: {:.0}%", tool_state.brush_hardness * 100.0)) - .size(10) - .width(Length::Fixed(75.0)); + let hardness_label = text(format!( + "Hardness: {:.0}%", + tool_state.brush_hardness * 100.0 + )) + .size(10) + .width(Length::Fixed(75.0)); let hardness_slider = slider( 0.0..=1.0, tool_state.brush_hardness, @@ -70,11 +72,14 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M .width(Length::Fixed(80.0)); row![ - size_label, size_slider, + size_label, + size_slider, text(" ").size(10), - opacity_label, opacity_slider, + opacity_label, + opacity_slider, text(" ").size(10), - hardness_label, hardness_slider, + hardness_label, + hardness_slider, ] .spacing(2) .align_y(iced::Alignment::Center) @@ -82,15 +87,12 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M // ── Magic Wand ── Tool::MagicWand => { - let tolerance_label = text("Tolerance:") - .size(10) - .width(Length::Fixed(55.0)); - let tolerance_input = text_input("16", "16") - .width(Length::Fixed(40.0)) - .size(10); + let tolerance_label = text("Tolerance:").size(10).width(Length::Fixed(55.0)); + let tolerance_input = text_input("16", "16").width(Length::Fixed(40.0)).size(10); row![ - tolerance_label, tolerance_input, + tolerance_label, + tolerance_input, text(" ").size(10), checkbox_label("Anti-alias", true), text(" ").size(10), @@ -102,15 +104,12 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M // ── Select tools ── Tool::Select | Tool::Lasso => { - let feather_label = text("Feather:") - .size(10) - .width(Length::Fixed(50.0)); - let feather_input = text_input("0 px", "0") - .width(Length::Fixed(50.0)) - .size(10); + let feather_label = text("Feather:").size(10).width(Length::Fixed(50.0)); + let feather_input = text_input("0 px", "0").width(Length::Fixed(50.0)).size(10); row![ - feather_label, feather_input, + feather_label, + feather_input, text(" ").size(10), small_button("Refine Edge", colors), text(" ").size(10), @@ -121,82 +120,68 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M } // ── Move tool ── - Tool::Move => { - row![ - checkbox_label("Auto-Select", true), - text(" ").size(10), - text("Layer").size(10), - ] - .spacing(2) - .align_y(iced::Alignment::Center) - } + Tool::Move => row![ + checkbox_label("Auto-Select", true), + text(" ").size(10), + text("Layer").size(10), + ] + .spacing(2) + .align_y(iced::Alignment::Center), // ── Eyedropper ── - Tool::Eyedropper => { - row![ - text("Sample Size: Point Sample").size(10), - text(" ").size(10), - checkbox_label("Sample All Layers", false), - ] - .spacing(2) - .align_y(iced::Alignment::Center) - } + Tool::Eyedropper => row![ + text("Sample Size: Point Sample").size(10), + text(" ").size(10), + checkbox_label("Sample All Layers", false), + ] + .spacing(2) + .align_y(iced::Alignment::Center), // ── Gradient / FloodFill ── - Tool::Gradient | Tool::FloodFill => { - row![ - text("Mode:").size(10), - text("Normal").size(10), - text(" ").size(10), - text("Opacity:").size(10), - text("100%").size(10), - ] - .spacing(2) - .align_y(iced::Alignment::Center) - } + Tool::Gradient | Tool::FloodFill => row![ + text("Mode:").size(10), + text("Normal").size(10), + text(" ").size(10), + text("Opacity:").size(10), + text("100%").size(10), + ] + .spacing(2) + .align_y(iced::Alignment::Center), // ── Text ── - Tool::Text => { - row![ - text("Font:").size(10), - text("Arial").size(10), - text(" ").size(10), - text("Size:").size(10), - text("24px").size(10), - ] - .spacing(2) - .align_y(iced::Alignment::Center) - } + Tool::Text => row![ + text("Font:").size(10), + text("Arial").size(10), + text(" ").size(10), + text("Size:").size(10), + text("24px").size(10), + ] + .spacing(2) + .align_y(iced::Alignment::Center), // ── Crop ── - Tool::Crop => { - row![ - text("Ratio:").size(10), - text("Original").size(10), - text(" ").size(10), - small_button("Straighten", colors), - ] - .spacing(2) - .align_y(iced::Alignment::Center) - } + Tool::Crop => row![ + text("Ratio:").size(10), + text("Original").size(10), + text(" ").size(10), + small_button("Straighten", colors), + ] + .spacing(2) + .align_y(iced::Alignment::Center), // ── Vector tools ── - Tool::VectorLine | Tool::VectorRect | Tool::VectorCircle => { - row![ - text("Fill:").size(10), - text("None").size(10), - text(" ").size(10), - text("Stroke:").size(10), - text("1px").size(10), - ] - .spacing(2) - .align_y(iced::Alignment::Center) - } + Tool::VectorLine | Tool::VectorRect | Tool::VectorCircle => row![ + text("Fill:").size(10), + text("None").size(10), + text(" ").size(10), + text("Stroke:").size(10), + text("1px").size(10), + ] + .spacing(2) + .align_y(iced::Alignment::Center), // ── Default (no specific options) ── - _ => { - row![].spacing(0) - } + _ => row![].spacing(0), }; let bar = row![ @@ -244,13 +229,10 @@ fn tool_name_label(tool: Tool) -> &'static str { fn checkbox_label(label: &str, checked: bool) -> Element<'static, Message> { let mark = if checked { "☑" } else { "☐" }; let label_owned = label.to_string(); - row![ - text(mark).size(10), - text(label_owned).size(10), - ] - .spacing(2) - .align_y(iced::Alignment::Center) - .into() + row![text(mark).size(10), text(label_owned).size(10),] + .spacing(2) + .align_y(iced::Alignment::Center) + .into() } /// A small flat button for tool options. @@ -265,8 +247,8 @@ fn small_button(label: &str, colors: ThemeColors) -> Element<'static, Message> { button(text(label_owned).size(10)) .on_press(Message::NoOp) .padding([2, 6]) - .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { - match status { + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { iced::widget::button::Status::Hovered => iced::widget::button::Style { background: Some(iced::Background::Color(bg_hover)), text_color: text_primary, @@ -279,7 +261,7 @@ fn small_button(label: &str, colors: ThemeColors) -> Element<'static, Message> { border: iced::Border::default().color(border_low).width(1), ..Default::default() }, - } - }) + }, + ) .into() } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_settings.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_settings.rs index 97bbccc..fe0f8b4 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_settings.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_settings.rs @@ -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(label: &str, value: f32, min: f32, max: f32, msg_fn: F, _c: ThemeColors) -> Element<'static, Message> +fn prop_slider( + label: &str, + value: f32, + min: f32, + max: f32, + msg_fn: F, + _c: ThemeColors, +) -> Element<'static, Message> where F: Fn(f32) -> Message + 'static, { let l = label.to_string(); let label_text = text(l).size(10).width(Length::Fixed(70.0)); - let val_text = text(format!("{:.1}", value)).size(10).width(Length::Fixed(40.0)); - let sl = slider(min..=max, value, msg_fn).step(0.1).width(Length::Fill); + let val_text = text(format!("{:.1}", value)) + .size(10) + .width(Length::Fixed(40.0)); + let sl = slider(min..=max, value, msg_fn) + .step(0.1) + .width(Length::Fill); row![label_text, sl, val_text] .spacing(4) @@ -296,15 +489,15 @@ fn color_row(label: &str, color: [u8; 4], _c: ThemeColors) -> Element<'static, M color[2] as f32 / 255.0, color[3] as f32 / 255.0, ))), - border: iced::Border::default().rounded(2).color(iced::Color::from_rgb(0.3, 0.3, 0.3)).width(1), + border: iced::Border::default() + .rounded(2) + .color(iced::Color::from_rgb(0.3, 0.3, 0.3)) + .width(1), ..Default::default() }); - row![ - text(l).size(10).width(Length::Fixed(50.0)), - swatch, - ] - .spacing(4) - .align_y(iced::Alignment::Center) - .into() -} \ No newline at end of file + row![text(l).size(10).width(Length::Fixed(50.0)), swatch,] + .spacing(4) + .align_y(iced::Alignment::Center) + .into() +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs index e6d9576..1d20fec 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs @@ -5,6 +5,7 @@ //! Icons are 16px SVGs loaded from assets/icons/. use crate::app::Message; +use crate::panels::menus::MenuCommand; use crate::panels::styles; use crate::theme::ThemeColors; use hcie_engine_api::Tool; @@ -12,13 +13,32 @@ use iced::widget::{button, container, row, slider, svg, text}; use iced::{Element, Length}; /// Build the Photopea-style toolbar: SVG icon buttons + blend mode/opacity/flow in one row. -pub fn view<'a>(tool_state: &'a crate::app::ToolState, settings: &'a crate::settings::AppSettings, colors: ThemeColors) -> Element<'a, Message> { +pub fn view<'a>( + tool_state: &'a crate::app::ToolState, + settings: &'a crate::settings::AppSettings, + colors: ThemeColors, +) -> Element<'a, Message> { let c = colors; // ── SVG icon buttons (no text) ── - let new_btn = svg_btn("icons/new_file.svg", "New", Message::MenuAction(0, 0), c); - let open_btn = svg_btn("icons/open_file.svg", "Open", Message::MenuAction(0, 1), c); - let save_btn = svg_btn("icons/save_file.svg", "Save", Message::MenuAction(0, 7), c); + let new_btn = svg_btn( + "icons/new_file.svg", + "New", + Message::MenuCommand(MenuCommand::NewDocument), + c, + ); + let open_btn = svg_btn( + "icons/open_file.svg", + "Open", + Message::MenuCommand(MenuCommand::OpenFile), + c, + ); + let save_btn = svg_btn( + "icons/save_file.svg", + "Save", + Message::MenuCommand(MenuCommand::Save), + c, + ); let undo_btn = svg_btn("icons/undo.svg", "Undo", Message::Undo, c); let redo_btn = svg_btn("icons/redo.svg", "Redo", Message::Redo, c); @@ -27,32 +47,45 @@ pub fn view<'a>(tool_state: &'a crate::app::ToolState, settings: &'a crate::sett .align_y(iced::Alignment::Center); // ── Blend Mode, Opacity, Flow, Smooth (Photopea-style) ── - let blend_label = text("Blend Mode:").size(10).color(c.text_secondary); - let blend_value = text("Normal").size(10).color(c.text_primary); + let blend_value = compact_value("Normal".to_string(), "Blend mode: Normal", c); + let opacity_value = compact_value( + format!("O {:.0}%", tool_state.brush_opacity * 100.0), + "Brush opacity", + c, + ); + let opacity_sl = slider( + 0.0..=1.0, + tool_state.brush_opacity, + Message::BrushOpacityChanged, + ) + .step(0.01) + .width(Length::Fixed(52.0)); - let opacity_label = text("Opacity:").size(10).color(c.text_secondary); - let opacity_value = text(format!("{:.0}%", tool_state.brush_opacity * 100.0)).size(10).color(c.text_primary); - let opacity_sl = slider(0.0..=1.0, tool_state.brush_opacity, Message::BrushOpacityChanged) - .step(0.01) - .width(Length::Fixed(60.0)); + let flow_value = compact_value( + format!("F {:.0}%", settings.tool_settings.brush_flow * 100.0), + "Brush flow", + c, + ); + let flow_sl = slider( + 0.0..=1.0, + settings.tool_settings.brush_flow, + Message::BrushFlowChanged, + ) + .step(0.01) + .width(Length::Fixed(52.0)); - let flow_label = text("Flow:").size(10).color(c.text_secondary); - let flow_value = text(format!("{:.0}%", settings.tool_settings.brush_flow * 100.0)).size(10).color(c.text_primary); - let flow_sl = slider(0.0..=1.0, settings.tool_settings.brush_flow, Message::BrushFlowChanged) - .step(0.01) - .width(Length::Fixed(60.0)); - - let smooth_label = text("Smooth:").size(10).color(c.text_secondary); - let smooth_value = text("0%").size(10).color(c.text_primary); + let smooth_value = compact_value("S 0%".to_string(), "Stroke smoothing", c); let tool_options = row![ - blend_label, blend_value, + blend_value, sep(c), - opacity_label, opacity_value, opacity_sl, + opacity_value, + opacity_sl, sep(c), - flow_label, flow_value, flow_sl, + flow_value, + flow_sl, sep(c), - smooth_label, smooth_value, + smooth_value, ] .spacing(4) .align_y(iced::Alignment::Center); @@ -60,52 +93,67 @@ pub fn view<'a>(tool_state: &'a crate::app::ToolState, settings: &'a crate::sett // ── Tool-specific options (right side) ── let tool_options_extra = match tool_state.active_tool { Tool::Brush | Tool::Eraser | Tool::Pen | Tool::Spray => { - let sz = text(format!("Size: {:.0}", tool_state.brush_size)) - .size(10) - .width(Length::Fixed(55.0)); - let sz_sl = slider(1.0..=200.0, tool_state.brush_size, Message::BrushSizeChanged) - .step(1.0) - .width(Length::Fixed(80.0)); - let ha = text(format!("Hardness: {:.0}%", tool_state.brush_hardness * 100.0)) - .size(10) - .width(Length::Fixed(70.0)); - let ha_sl = slider(0.0..=1.0, tool_state.brush_hardness, Message::BrushHardnessChanged) - .step(0.01) - .width(Length::Fixed(80.0)); - row![sz, sz_sl, ha, ha_sl].spacing(4).align_y(iced::Alignment::Center) + let sz = compact_value(format!("{:.0}px", tool_state.brush_size), "Brush size", c); + let sz_sl = slider( + 1.0..=200.0, + tool_state.brush_size, + Message::BrushSizeChanged, + ) + .step(1.0) + .width(Length::Fixed(58.0)); + let ha = compact_value( + format!("H {:.0}%", tool_state.brush_hardness * 100.0), + "Brush hardness", + c, + ); + let ha_sl = slider( + 0.0..=1.0, + tool_state.brush_hardness, + Message::BrushHardnessChanged, + ) + .step(0.01) + .width(Length::Fixed(58.0)); + row![sz, sz_sl, ha, ha_sl] + .spacing(4) + .align_y(iced::Alignment::Center) } Tool::MagicWand => { let tol = settings.tool_settings.magic_wand_tolerance; row![ - text("Tolerance:").size(10), - slider(0.0..=255.0, tol, |v| Message::SelectionToleranceChanged(v as u8)) - .step(1.0) - .width(Length::Fixed(80.0)), - text(format!("{:.0}", tol)).size(10).width(Length::Fixed(30.0)), - ].spacing(6).align_y(iced::Alignment::Center) + compact_value(format!("T {:.0}", tol), "Selection tolerance", c), + slider(0.0..=255.0, tol, |v| Message::SelectionToleranceChanged( + v as u8 + )) + .step(1.0) + .width(Length::Fixed(64.0)), + ] + .spacing(6) + .align_y(iced::Alignment::Center) } Tool::Select | Tool::Lasso => { let feather = settings.tool_settings.select_feather; row![ - text("Feather:").size(10), + compact_value(format!("F {:.0}px", feather), "Selection feather", c), slider(0.0..=250.0, feather, Message::SelectionFeatherChanged) .step(1.0) - .width(Length::Fixed(80.0)), - text(format!("{:.0}px", feather)).size(10).width(Length::Fixed(40.0)), - ].spacing(6).align_y(iced::Alignment::Center) - } - Tool::Move => { - row![text("Auto-Select").size(10)].spacing(4).align_y(iced::Alignment::Center) + .width(Length::Fixed(64.0)), + ] + .spacing(6) + .align_y(iced::Alignment::Center) } + Tool::Move => row![compact_value("Auto".to_string(), "Auto-select layer", c)] + .spacing(4) + .align_y(iced::Alignment::Center), Tool::VectorLine | Tool::VectorRect | Tool::VectorCircle => { let stroke = settings.tool_settings.vector_stroke_width; row![ - text("Stroke:").size(10), + compact_value(format!("{:.1}px", stroke), "Vector stroke width", c), slider(0.0..=50.0, stroke, Message::VectorStrokeChanged) .step(0.5) - .width(Length::Fixed(60.0)), - text(format!("{:.1}px", stroke)).size(10).width(Length::Fixed(40.0)), - ].spacing(4).align_y(iced::Alignment::Center) + .width(Length::Fixed(58.0)), + ] + .spacing(4) + .align_y(iced::Alignment::Center) } _ => row![].spacing(0), }; @@ -129,7 +177,12 @@ pub fn view<'a>(tool_state: &'a crate::app::ToolState, settings: &'a crate::sett } /// SVG icon button (16px icon, no text). -fn svg_btn(icon_path: &str, tip: &str, msg: Message, colors: ThemeColors) -> Element<'static, Message> { +fn svg_btn( + icon_path: &str, + tip: &str, + msg: Message, + colors: ThemeColors, +) -> Element<'static, Message> { let c = colors; let tip_owned = tip.to_string(); @@ -144,9 +197,11 @@ fn svg_btn(icon_path: &str, tip: &str, msg: Message, colors: ThemeColors) -> Ele svg(handle) .width(16) .height(16) - .style(move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style { - color: Some(c.text_primary), - }) + .style( + move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style { + color: Some(c.text_primary), + }, + ) .into() } else { // Fallback to text @@ -154,25 +209,22 @@ fn svg_btn(icon_path: &str, tip: &str, msg: Message, colors: ThemeColors) -> Ele }; iced::widget::tooltip( - button(icon) - .on_press(msg) - .padding([2, 4]) - .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { - match status { - iced::widget::button::Status::Hovered => iced::widget::button::Style { - background: Some(iced::Background::Color(c.bg_hover)), - text_color: c.text_primary, - border: iced::Border::default().color(c.border_low).width(1), - ..Default::default() - }, - _ => iced::widget::button::Style { - background: Some(iced::Background::Color(iced::Color::TRANSPARENT)), - text_color: c.text_primary, - border: iced::Border::default(), - ..Default::default() - }, - } - }), + button(icon).on_press(msg).padding([2, 4]).style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { + iced::widget::button::Status::Hovered => iced::widget::button::Style { + background: Some(iced::Background::Color(c.bg_hover)), + text_color: c.text_primary, + border: iced::Border::default().color(c.border_low).width(1), + ..Default::default() + }, + _ => iced::widget::button::Style { + background: Some(iced::Background::Color(iced::Color::TRANSPARENT)), + text_color: c.text_primary, + border: iced::Border::default(), + ..Default::default() + }, + }, + ), text(tip_owned).size(11), iced::widget::tooltip::Position::Bottom, ) @@ -185,8 +237,8 @@ fn small_btn(label: &str, colors: ThemeColors) -> Element<'static, Message> { button(text(l).size(10)) .on_press(Message::NoOp) .padding([2, 6]) - .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { - match status { + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { iced::widget::button::Status::Hovered => iced::widget::button::Style { background: Some(iced::Background::Color(c.bg_hover)), text_color: c.text_primary, @@ -199,8 +251,8 @@ fn small_btn(label: &str, colors: ThemeColors) -> Element<'static, Message> { border: iced::Border::default().color(c.border_low).width(1), ..Default::default() }, - } - }) + }, + ) .into() } @@ -213,3 +265,13 @@ fn sep(colors: ThemeColors) -> Element<'static, Message> { }) .into() } + +/// Displays a compact toolbar value while moving its full meaning into a tooltip. +fn compact_value(value: String, tip: &str, colors: ThemeColors) -> Element<'static, Message> { + iced::widget::tooltip( + container(text(value).size(10).color(colors.text_primary)).padding([1, 3]), + text(tip.to_string()).size(11), + iced::widget::tooltip::Position::Bottom, + ) + .into() +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/typography.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/typography.rs new file mode 100644 index 0000000..a83d4c6 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/typography.rs @@ -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; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/raster.rs b/hcie-iced-app/crates/hcie-iced-gui/src/raster.rs new file mode 100644 index 0000000..33ab132 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/raster.rs @@ -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; + } + } + } +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/screenshot.rs b/hcie-iced-app/crates/hcie-iced-gui/src/screenshot.rs new file mode 100644 index 0000000..6216c57 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/screenshot.rs @@ -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 { + 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, +) -> Result, 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 + } + ); + } +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/script/executor.rs b/hcie-iced-app/crates/hcie-iced-gui/src/script/executor.rs index 0f4d82d..3fcd61e 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/script/executor.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/script/executor.rs @@ -98,7 +98,10 @@ fn find_layer_id(engine: &mut Engine, name_or_index: &str) -> Option { 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); } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/script/parser.rs b/hcie-iced-app/crates/hcie-iced-gui/src/script/parser.rs index 30d430c..36423ce 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/script/parser.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/script/parser.rs @@ -83,7 +83,11 @@ fn tokenize(s: &str) -> Result, String> { Ok(tokens) } -fn parse_expression(tokens: &[Token], pos: usize, vars: &std::collections::HashMap) -> Result<(f32, usize), String> { +fn parse_expression( + tokens: &[Token], + pos: usize, + vars: &std::collections::HashMap, +) -> 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) -> Result<(f32, usize), String> { +fn parse_term( + tokens: &[Token], + pos: usize, + vars: &std::collections::HashMap, +) -> 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) -> Result<(f32, usize), String> { +fn parse_factor( + tokens: &[Token], + pos: usize, + vars: &std::collections::HashMap, +) -> 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) -> 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, hardness: Option, }, - 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, canvas: f32) -> Result { +fn parse_coord( + s: &str, + vars: &std::collections::HashMap, + canvas: f32, +) -> Result { 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 { 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, String> { +fn parse_single_line( + state: &mut ScriptParserState, + line: &str, + line_num: u64, +) -> Result, 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, 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, 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") diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/selection/clipboard.rs b/hcie-iced-app/crates/hcie-iced-gui/src/selection/clipboard.rs index e497250..ff71880 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/selection/clipboard.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/selection/clipboard.rs @@ -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, + width: u32, + height: u32, + canvas_w: u32, + canvas_h: u32, +) -> Option { + 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, + }) +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/selection/crop.rs b/hcie-iced-app/crates/hcie-iced-gui/src/selection/crop.rs index 7701fe9..f8a1117 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/selection/crop.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/selection/crop.rs @@ -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; } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/selection/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/selection/mod.rs index 8a41699..df23619 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/selection/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/selection/mod.rs @@ -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}; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/selection/state.rs b/hcie-iced-app/crates/hcie-iced-gui/src/selection/state.rs new file mode 100644 index 0000000..ddb5e1c --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/selection/state.rs @@ -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 { + 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 +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/selection/transform.rs b/hcie-iced-app/crates/hcie-iced-gui/src/selection/transform.rs index ec47a8c..876ca56 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/selection/transform.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/selection/transform.rs @@ -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. diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs b/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs index 09e56db..2d3de6b 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs @@ -36,6 +36,9 @@ pub struct AppSettings { /// Saved dock layout profiles. #[serde(default)] pub dock_profiles: Vec, + /// Selected application color preset. + #[serde(default)] + pub theme_preset: crate::theme::ThemePreset, } /// Tool-specific settings that persist between sessions. @@ -98,21 +101,42 @@ pub struct ToolSettings { pub last_active_tool: String, } -fn default_vector_fill() -> bool { true } -fn default_vector_points() -> u32 { 5 } -fn default_vector_sides() -> u32 { 6 } -fn default_spray_particle_size() -> f32 { 4.0 } -fn default_spray_density() -> u32 { 80 } -fn default_contiguous() -> bool { true } -fn default_anti_alias() -> bool { true } -fn default_text_font() -> String { "Arial".to_string() } -fn default_apply_to_new_layer() -> bool { true } +fn default_vector_fill() -> bool { + true +} +fn default_vector_points() -> u32 { + 5 +} +fn default_vector_sides() -> u32 { + 6 +} +fn default_spray_particle_size() -> f32 { + 4.0 +} +fn default_spray_density() -> u32 { + 80 +} +fn default_contiguous() -> bool { + true +} +fn default_anti_alias() -> bool { + true +} +fn default_text_font() -> String { + "Arial".to_string() +} +fn default_apply_to_new_layer() -> bool { + true +} /// Panel layout positions and visibility. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PanelLayout { /// Left sidebar width in pixels. pub sidebar_width: f32, + /// Whether the fixed toolbox sidebar shows labels as well as icons. + #[serde(default)] + pub sidebar_expanded: bool, /// Right tool settings panel width in pixels. pub tool_settings_width: f32, /// Which panels are visible. @@ -136,6 +160,7 @@ impl Default for AppSettings { panel_layout: PanelLayout::default(), window: WindowSettings::default(), dock_profiles: Vec::new(), + theme_preset: crate::theme::ThemePreset::default(), } } } @@ -182,6 +207,7 @@ impl Default for PanelLayout { fn default() -> Self { Self { sidebar_width: 36.0, + sidebar_expanded: false, tool_settings_width: 200.0, visible_panels: vec![ "Brushes & Tips".to_string(), @@ -223,15 +249,13 @@ impl AppSettings { let path = Self::file_path(); if path.exists() { match std::fs::read_to_string(&path) { - Ok(content) => { - match serde_json::from_str(&content) { - Ok(settings) => settings, - Err(e) => { - log::warn!("Failed to parse settings: {}, using defaults", e); - Self::default() - } + Ok(content) => match serde_json::from_str(&content) { + Ok(settings) => settings, + Err(e) => { + log::warn!("Failed to parse settings: {}, using defaults", e); + Self::default() } - } + }, Err(e) => { log::warn!("Failed to read settings: {}, using defaults", e); Self::default() @@ -245,7 +269,7 @@ impl AppSettings { /// Save settings to disk. pub fn save(&self) -> Result<(), String> { let path = Self::file_path(); - + // Create directory if it doesn't exist if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) @@ -255,8 +279,7 @@ impl AppSettings { let content = serde_json::to_string_pretty(self) .map_err(|e| format!("Failed to serialize settings: {}", e))?; - std::fs::write(&path, content) - .map_err(|e| format!("Failed to write settings: {}", e))?; + std::fs::write(&path, content).map_err(|e| format!("Failed to write settings: {}", e))?; log::info!("Settings saved to {}", path.display()); Ok(()) @@ -282,3 +305,27 @@ impl AppSettings { tool_state.brush_hardness = self.tool_settings.brush_hardness; } } + +#[cfg(test)] +mod tests { + use super::AppSettings; + use crate::theme::ThemePreset; + + /// Proves settings written before Cycle 1 acquire safe theme and sidebar defaults. + #[test] + fn legacy_settings_default_new_cycle_one_fields() { + let mut legacy = serde_json::to_value(AppSettings::default()).expect("serialize defaults"); + let object = legacy.as_object_mut().expect("settings object"); + object.remove("theme_preset"); + object + .get_mut("panel_layout") + .and_then(serde_json::Value::as_object_mut) + .expect("panel layout object") + .remove("sidebar_expanded"); + + let settings: AppSettings = + serde_json::from_value(legacy).expect("deserialize legacy settings"); + assert_eq!(settings.theme_preset, ThemePreset::Photopea); + assert!(!settings.panel_layout.sidebar_expanded); + } +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/shape_sync.rs b/hcie-iced-app/crates/hcie-iced-gui/src/shape_sync.rs index cf5de8f..e30a216 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/shape_sync.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/shape_sync.rs @@ -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; } } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs index 24d605f..9957f3a 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs @@ -15,7 +15,7 @@ use crate::app::{Message, ToolState}; use crate::panels::styles; use crate::theme::ThemeColors; use hcie_engine_api::Tool; -use iced::widget::{button, column, container, row, svg, text}; +use iced::widget::{button, column, container, row, scrollable, svg, text}; use iced::{Element, Length}; /// Tool slot with sub-tools — matches egui's TOOL_SLOTS structure. @@ -28,36 +28,121 @@ pub struct ToolSlot { /// All tool slots matching egui's TOOL_SLOTS structure exactly. /// Each slot can have multiple tools (sub-tools shown on click). pub const TOOL_SLOTS: &[ToolSlot] = &[ - ToolSlot { tools: &[Tool::Move], label: "Move", icon: "icons/move.svg" }, - ToolSlot { tools: &[Tool::VectorSelect], label: "Vector Select", icon: "icons/vector_select.svg" }, - ToolSlot { tools: &[Tool::Select], label: "Rectangle Select", icon: "icons/rect_select.svg" }, - ToolSlot { tools: &[Tool::Lasso, Tool::PolygonSelect], label: "Lasso", icon: "icons/lasso.svg" }, - ToolSlot { tools: &[Tool::MagicWand], label: "Magic Wand", icon: "icons/magic_wand.svg" }, - ToolSlot { tools: &[Tool::SmartSelect, Tool::VisionSelect], label: "Smart Select", icon: "icons/smart_select.svg" }, - ToolSlot { tools: &[Tool::Crop], label: "Crop", icon: "icons/crop.svg" }, - ToolSlot { tools: &[Tool::Eyedropper], label: "Eyedropper", icon: "icons/eyedropper.svg" }, - ToolSlot { tools: &[Tool::Brush], label: "Brush", icon: "icons/brush.svg" }, - ToolSlot { tools: &[Tool::Pen], label: "Pen", icon: "icons/pen.svg" }, - ToolSlot { tools: &[Tool::Spray], label: "Spray", icon: "icons/spray.svg" }, - ToolSlot { tools: &[Tool::Eraser], label: "Eraser", icon: "icons/eraser.svg" }, - ToolSlot { tools: &[Tool::FloodFill], label: "Flood Fill", icon: "icons/bucket.svg" }, - ToolSlot { tools: &[Tool::Gradient], label: "Gradient", icon: "icons/gradient.svg" }, - ToolSlot { tools: &[Tool::Text], label: "Text", icon: "icons/text.svg" }, - ToolSlot { tools: &[Tool::VectorLine], label: "Vector Line", icon: "icons/line.svg" }, - ToolSlot { tools: &[Tool::VectorRect], label: "Vector Rectangle", icon: "icons/vector_rect.svg" }, - ToolSlot { tools: &[Tool::VectorCircle], label: "Vector Circle", icon: "icons/circle.svg" }, + ToolSlot { + tools: &[Tool::Move], + label: "Move", + icon: "icons/move.svg", + }, + ToolSlot { + tools: &[Tool::VectorSelect], + label: "Vector Select", + icon: "icons/vector_select.svg", + }, + ToolSlot { + tools: &[Tool::Select], + label: "Rectangle Select", + icon: "icons/rect_select.svg", + }, + ToolSlot { + tools: &[Tool::Lasso, Tool::PolygonSelect], + label: "Lasso", + icon: "icons/lasso.svg", + }, + ToolSlot { + tools: &[Tool::MagicWand], + label: "Magic Wand", + icon: "icons/magic_wand.svg", + }, + ToolSlot { + tools: &[Tool::SmartSelect, Tool::VisionSelect], + label: "Smart Select", + icon: "icons/smart_select.svg", + }, + ToolSlot { + tools: &[Tool::Crop], + label: "Crop", + icon: "icons/crop.svg", + }, + ToolSlot { + tools: &[Tool::Eyedropper], + label: "Eyedropper", + icon: "icons/eyedropper.svg", + }, + ToolSlot { + tools: &[Tool::Brush], + label: "Brush", + icon: "icons/brush.svg", + }, + ToolSlot { + tools: &[Tool::Pen], + label: "Pen", + icon: "icons/pen.svg", + }, + ToolSlot { + tools: &[Tool::Spray], + label: "Spray", + icon: "icons/spray.svg", + }, + ToolSlot { + tools: &[Tool::Eraser], + label: "Eraser", + icon: "icons/eraser.svg", + }, + ToolSlot { + tools: &[Tool::FloodFill], + label: "Flood Fill", + icon: "icons/bucket.svg", + }, + ToolSlot { + tools: &[Tool::Gradient], + label: "Gradient", + icon: "icons/gradient.svg", + }, + ToolSlot { + tools: &[Tool::Text], + label: "Text", + icon: "icons/text.svg", + }, + ToolSlot { + tools: &[Tool::VectorLine], + label: "Vector Line", + icon: "icons/line.svg", + }, + ToolSlot { + tools: &[Tool::VectorRect], + label: "Vector Rectangle", + icon: "icons/vector_rect.svg", + }, + ToolSlot { + tools: &[Tool::VectorCircle], + label: "Vector Circle", + icon: "icons/circle.svg", + }, ToolSlot { tools: &[ - Tool::VectorArrow, Tool::VectorStar, Tool::VectorPolygon, - Tool::VectorRhombus, Tool::VectorCylinder, Tool::VectorHeart, - Tool::VectorBubble, Tool::VectorGear, Tool::VectorCross, - Tool::VectorCrescent, Tool::VectorBolt, Tool::VectorArrow4, + Tool::VectorArrow, + Tool::VectorStar, + Tool::VectorPolygon, + Tool::VectorRhombus, + Tool::VectorCylinder, + Tool::VectorHeart, + Tool::VectorBubble, + Tool::VectorGear, + Tool::VectorCross, + Tool::VectorCrescent, + Tool::VectorBolt, + Tool::VectorArrow4, ], label: "Vector Shapes", icon: "icons/star.svg", }, ToolSlot { - tools: &[Tool::SpotRemoval, Tool::RedEyeRemoval, Tool::SmartPatch, Tool::AiObjectRemoval], + tools: &[ + Tool::SpotRemoval, + Tool::RedEyeRemoval, + Tool::SmartPatch, + Tool::AiObjectRemoval, + ], label: "Retouch", icon: "icons/spot.svg", }, @@ -82,21 +167,55 @@ pub fn view<'a>( // Tool buttons — single column or 2-column grid depending on expanded state let mut tool_list = column![].spacing(1); if expanded { - // 2-column layout: show tools in pairs + // The open subtool slot owns a full row: 34px parent plus 34px popup. + // Other slots continue to use the regular two-column grid. let mut row_children = iced::widget::Row::new().spacing(1); + let mut row_items = 0; for (idx, slot) in TOOL_SLOTS.iter().enumerate() { - let btn = tool_button(slot, tool_state, colors, idx, active_slot, sub_tools_open, slot_last_used); + let btn = tool_button( + slot, + tool_state, + colors, + idx, + active_slot, + sub_tools_open, + slot_last_used, + ); + + let popup_row = sub_tools_open && active_slot == idx && slot.tools.len() > 1; + if popup_row { + if row_items > 0 { + tool_list = tool_list.push(row_children); + row_children = iced::widget::Row::new().spacing(1); + row_items = 0; + } + tool_list = tool_list.push(btn); + continue; + } + row_children = row_children.push(btn); - // Every 2 tools, start a new row - if idx % 2 == 1 || idx == TOOL_SLOTS.len() - 1 { + row_items += 1; + if row_items == 2 { tool_list = tool_list.push(row_children); row_children = iced::widget::Row::new().spacing(1); + row_items = 0; } } + if row_items > 0 { + tool_list = tool_list.push(row_children); + } } else { // Single column layout (default) for (idx, slot) in TOOL_SLOTS.iter().enumerate() { - let btn = tool_button(slot, tool_state, colors, idx, active_slot, sub_tools_open, slot_last_used); + let btn = tool_button( + slot, + tool_state, + colors, + idx, + active_slot, + sub_tools_open, + slot_last_used, + ); tool_list = tool_list.push(btn); } } @@ -107,95 +226,142 @@ pub fn view<'a>( let bg = bg_color; let fg_swatch = container(text("")) - .width(18) - .height(18) + .width(24) + .height(24) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(iced::Color::from_rgba( - fg[0] as f32 / 255.0, fg[1] as f32 / 255.0, fg[2] as f32 / 255.0, fg[3] as f32 / 255.0, + fg[0] as f32 / 255.0, + fg[1] as f32 / 255.0, + fg[2] as f32 / 255.0, + fg[3] as f32 / 255.0, ))), - border: iced::Border::default().rounded(2).color(colors.border_high).width(1), + border: iced::Border::default() + .rounded(2) + .color(colors.border_high) + .width(2), ..Default::default() }); let bg_swatch = container(text("")) - .width(18) - .height(18) + .width(24) + .height(24) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(iced::Color::from_rgba( - bg[0] as f32 / 255.0, bg[1] as f32 / 255.0, bg[2] as f32 / 255.0, bg[3] as f32 / 255.0, + bg[0] as f32 / 255.0, + bg[1] as f32 / 255.0, + bg[2] as f32 / 255.0, + bg[3] as f32 / 255.0, ))), - border: iced::Border::default().rounded(2).color(colors.border_high).width(1), + border: iced::Border::default() + .rounded(2) + .color(colors.border_high) + .width(2), ..Default::default() }); + let fg_color_val = *fg_color; + let fg_clickable = iced::widget::tooltip( + iced::widget::mouse_area(fg_swatch).on_press(Message::FgColorChanged(fg_color_val)), + text("Foreground color - click to apply").size(11), + iced::widget::tooltip::Position::Right, + ); + // Make the background swatch clickable so a click promotes the background // color to the foreground (egui: toolbox.rs:355-358). let bg_color_val = *bg_color; - let bg_clickable = iced::widget::mouse_area(bg_swatch) - .on_press(Message::FgColorChanged(bg_color_val)); + let bg_clickable = iced::widget::tooltip( + iced::widget::mouse_area(bg_swatch).on_press(Message::FgColorChanged(bg_color_val)), + text("Background color - click to promote to foreground").size(11), + iced::widget::tooltip::Position::Right, + ); // Small reset-to-defaults button (black/white) below the swatches // (egui: toolbox.rs:351-354 resets fg=black/bg=white on a bottom-left click). - let reset_btn = button(text("D").size(7).color(colors.text_secondary)) - .on_press(Message::ResetColors) - .padding(1) - .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { - match status { - iced::widget::button::Status::Hovered => iced::widget::button::Style { - background: Some(iced::Background::Color(colors.bg_hover)), - text_color: colors.text_primary, - border: iced::Border::default(), - ..Default::default() + let reset_btn = iced::widget::tooltip( + button(text("D").size(10).color(colors.text_secondary)) + .on_press(Message::ResetColors) + .padding([1, 3]) + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { + iced::widget::button::Status::Hovered => iced::widget::button::Style { + background: Some(iced::Background::Color(colors.bg_hover)), + text_color: colors.text_primary, + border: iced::Border::default(), + ..Default::default() + }, + _ => iced::widget::button::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.0, 0.0, 0.0, 0.0, + ))), + text_color: colors.text_secondary, + border: iced::Border::default(), + ..Default::default() + }, }, - _ => iced::widget::button::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))), - text_color: colors.text_secondary, - border: iced::Border::default(), - ..Default::default() - }, - } - }); + ), + text("Reset foreground/background colors").size(11), + iced::widget::tooltip::Position::Right, + ); // Photopea-style: fg at top-left, bg at bottom-right (diagonal overlap) // with a swap arrow button between them let color_section = container( iced::widget::Stack::new() + .push(container(bg_clickable).padding(iced::Padding { + top: 10.0, + bottom: 0.0, + left: 8.0, + right: 0.0, + })) + .push(container(fg_clickable).padding(iced::Padding { + top: 0.0, + bottom: 0.0, + left: 0.0, + right: 0.0, + })) .push( - container(fg_swatch) - .padding(iced::Padding { top: 0.0, bottom: 0.0, left: 4.0, right: 0.0 }) - ) - .push( - container(bg_clickable) - .padding(iced::Padding { top: 10.0, bottom: 0.0, left: 16.0, right: 0.0 }) - ) - .push( - container( - button(text("⇄").size(8).color(colors.text_primary)) + container(iced::widget::tooltip( + button(text("⇄").size(10).color(colors.text_primary)) .on_press(Message::SwapColors) .padding(1) - .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { - match status { - iced::widget::button::Status::Hovered => iced::widget::button::Style { - background: Some(iced::Background::Color(colors.bg_hover)), - text_color: colors.text_primary, - border: iced::Border::default(), - ..Default::default() - }, - _ => iced::widget::button::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))), - text_color: colors.text_primary, - border: iced::Border::default(), - ..Default::default() - }, - } - }) - ) - .padding(iced::Padding { top: 6.0, bottom: 0.0, left: 10.0, right: 0.0 }) - ) + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| { + match status { + iced::widget::button::Status::Hovered => { + iced::widget::button::Style { + background: Some(iced::Background::Color( + colors.bg_hover, + )), + text_color: colors.text_primary, + border: iced::Border::default(), + ..Default::default() + } + } + _ => iced::widget::button::Style { + background: Some(iced::Background::Color( + iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0), + )), + text_color: colors.text_primary, + border: iced::Border::default(), + ..Default::default() + }, + } + }, + ), + text("Swap foreground/background colors").size(11), + iced::widget::tooltip::Position::Right, + )) + .padding(iced::Padding { + top: 0.0, + bottom: 0.0, + left: 21.0, + right: 0.0, + }), + ), ) .padding([4, 2]) .width(sidebar_width) - .height(32); + .height(40); // Separator line let sep_style = move |_theme: &iced::Theme| iced::widget::container::Style { @@ -209,8 +375,8 @@ pub fn view<'a>( .on_press(Message::SidebarToggleExpanded) .padding(2) .width(sidebar_width) - .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { - match status { + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { iced::widget::button::Status::Hovered => iced::widget::button::Style { background: Some(iced::Background::Color(colors.bg_hover)), text_color: colors.text_primary, @@ -218,17 +384,21 @@ pub fn view<'a>( ..Default::default() }, _ => iced::widget::button::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))), + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.0, 0.0, 0.0, 0.0, + ))), text_color: colors.text_secondary, border: iced::Border::default(), ..Default::default() }, - } - }); + }, + ); let sidebar = column![ - container(tool_list).padding([2, 0]), - container(text("").height(1)).width(Length::Fill).style(sep_style), + scrollable(container(tool_list).padding([2, 0])).height(Length::Fill), + container(text("").height(1)) + .width(Length::Fill) + .style(sep_style), color_section, container(reset_btn).padding([2, 4]), container(toggle_btn).padding([2, 0]), @@ -266,30 +436,41 @@ fn tool_button<'a>( let represented_tool = slot.tools.get(last_idx).copied().unwrap_or(slot.tools[0]); // Try to load SVG icon - search multiple paths + let represented_icon = tool_icon(represented_tool); let icon_paths = [ - std::path::PathBuf::from(slot.icon), - std::path::Path::new("hcie-iced-app/assets").join(slot.icon), - std::path::PathBuf::from(format!("/mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-iced-app/assets/{}", slot.icon)), + std::path::PathBuf::from(represented_icon), + std::path::Path::new("hcie-iced-app/assets").join(represented_icon), + std::path::PathBuf::from(format!( + "/mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-iced-app/assets/{}", + represented_icon + )), ]; - let icon_color = if is_active { colors.accent } else { colors.text_primary }; - - let icon_element: Element<'_, Message> = if let Some(icon_path) = icon_paths.iter().find(|p| p.exists()) { - let handle = svg::Handle::from_path(icon_path); - svg(handle) - .width(20) - .height(20) - .style(move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style { - color: Some(icon_color), - }) - .into() + let icon_color = if is_active { + colors.accent } else { - text(slot.label.chars().next().unwrap_or('?').to_string()) - .size(14) - .color(icon_color) - .into() + colors.text_primary }; + let icon_element: Element<'_, Message> = + if let Some(icon_path) = icon_paths.iter().find(|p| p.exists()) { + let handle = svg::Handle::from_path(icon_path); + svg(handle) + .width(20) + .height(20) + .style( + move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style { + color: Some(icon_color), + }, + ) + .into() + } else { + text(slot.label.chars().next().unwrap_or('?').to_string()) + .size(14) + .color(icon_color) + .into() + }; + // If slot has sub-tools and is active, show sub-tools popup let has_sub_tools = slot.tools.len() > 1; let show_popup = has_sub_tools && sub_tools_open && active_slot == slot_idx; @@ -300,15 +481,15 @@ fn tool_button<'a>( let btn = iced::widget::button( container(icon_element) - .width(28) + .width(34) .height(28) .center_y(20) - .center_x(20) + .center_x(20), ) .on_press(Message::ToolSlotClicked(slot_idx, primary_tool)) .padding(0) - .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { - match status { + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { iced::widget::button::Status::Active | iced::widget::button::Status::Disabled => { if active { iced::widget::button::Style { @@ -319,7 +500,9 @@ fn tool_button<'a>( } } else { iced::widget::button::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))), + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.0, 0.0, 0.0, 0.0, + ))), border: iced::Border::default(), text_color: c.text_primary, ..Default::default() @@ -343,96 +526,236 @@ fn tool_button<'a>( } } } - iced::widget::button::Status::Pressed => { - iced::widget::button::Style { - background: Some(iced::Background::Color(c.accent_green)), + iced::widget::button::Status::Pressed => iced::widget::button::Style { + background: Some(iced::Background::Color(c.accent_green)), + border: iced::Border::default(), + text_color: iced::Color::WHITE, + ..Default::default() + }, + }, + ); + + let popup_affordance: Element<'a, Message> = if has_sub_tools { + iced::widget::tooltip( + button(text("▸").size(12).color(colors.text_primary)) + .on_press(Message::SubtoolsToggle(slot_idx)) + .padding(0) + .width(12) + .height(14) + .style(move |_theme, _status| iced::widget::button::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba( + colors.bg_app.r, + colors.bg_app.g, + colors.bg_app.b, + 0.82, + ))), + text_color: colors.text_primary, border: iced::Border::default(), - text_color: iced::Color::WHITE, ..Default::default() - } - } - } - }); + }), + text(format!("Open {} sub-tools", slot.label)).size(11), + iced::widget::tooltip::Position::Right, + ) + .into() + } else { + iced::widget::Space::with_width(0).into() + }; + + let btn_element: Element<'a, Message> = iced::widget::Stack::new() + .push(btn) + .push(container(popup_affordance).padding(iced::Padding { + top: 14.0, + bottom: 0.0, + left: 22.0, + right: 0.0, + })) + .width(34) + .height(28) + .into(); // Build the tool button with optional sub-tools popup if show_popup { - let mut popup_items = column![].spacing(0); - for &tool in slot.tools { + let mut popup_grid = column![].spacing(1); + let mut popup_row = row![].spacing(1); + for (tool_index, &tool) in slot.tools.iter().enumerate() { let tool_name = tool.label(); let is_tool_active = tool == tool_state.active_tool; let c = colors; - let item = button( - container( - row![ - text(tool_name).size(11).width(Length::Fill), - text(tool_shortcut(tool)).size(10), - ] - .spacing(8) - ) - .width(150) - .padding([4, 8]) - ) - .on_press(Message::ToolSelected(tool)) - .padding(0) - .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { - match status { - iced::widget::button::Status::Hovered => iced::widget::button::Style { - background: Some(iced::Background::Color(c.menu_hover)), - text_color: c.menu_text, - border: iced::Border::default(), - ..Default::default() - }, - _ => iced::widget::button::Style { - background: Some(iced::Background::Color(c.bg_hover)), - text_color: if is_tool_active { c.accent } else { c.text_primary }, - border: iced::Border::default(), - ..Default::default() - }, - } - }); + let popup_icon: Element<'_, Message> = + svg(svg::Handle::from_path(resolve_icon_path(tool_icon(tool)))) + .width(18) + .height(18) + .style(move |_theme, _status| svg::Style { + color: Some(if is_tool_active { + iced::Color::WHITE + } else { + c.text_primary + }), + }) + .into(); - popup_items = popup_items.push(item); + let item = button(container(popup_icon).width(32).height(28).center(18)) + .on_press(Message::ToolSelected(tool)) + .padding(0) + .width(32) + .height(28) + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { + iced::widget::button::Status::Hovered => iced::widget::button::Style { + background: Some(iced::Background::Color(if is_tool_active { + c.accent_green + } else { + c.bg_hover + })), + text_color: c.text_primary, + border: iced::Border::default() + .color(if is_tool_active { + c.accent + } else { + c.border_high + }) + .width(if is_tool_active { 1 } else { 0 }), + ..Default::default() + }, + _ => iced::widget::button::Style { + background: Some(iced::Background::Color(if is_tool_active { + c.accent_green + } else { + c.bg_active + })), + text_color: c.text_primary, + border: iced::Border::default() + .color(if is_tool_active { + c.accent + } else { + c.border_high + }) + .width(if is_tool_active { 1 } else { 0 }), + ..Default::default() + }, + }, + ); + + let shortcut = tool_shortcut(tool); + let tooltip_text = if shortcut.is_empty() { + tool_name.to_string() + } else { + format!("{tool_name} ({shortcut})") + }; + popup_row = popup_row.push(iced::widget::tooltip( + item, + text(tooltip_text).size(11), + iced::widget::tooltip::Position::Right, + )); + if (tool_index + 1) % 2 == 0 || tool_index + 1 == slot.tools.len() { + popup_grid = popup_grid.push(popup_row); + popup_row = row![].spacing(1); + } } - let popup = container(popup_items) - .padding(2) + let popup = container(popup_grid) + .padding(1) + .width(68) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(colors.bg_active)), border: iced::Border::default().color(colors.border_high).width(1), shadow: iced::Shadow { color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.4), - offset: iced::Vector::new(2.0, 0.0), - blur_radius: 8.0, + offset: iced::Vector::new(1.0, 0.0), + blur_radius: 5.0, }, ..Default::default() }); - // Position popup to the right of the button - let btn_element: Element<'a, Message> = container( - row![btn, popup].spacing(0).align_y(iced::Alignment::Center) - ).into(); - + let parent = container(iced::widget::tooltip( + btn_element, + iced::widget::text(format!("{} (sub-tools open)", slot.label)).size(11), + iced::widget::tooltip::Position::Right, + )) + .width(68); + container(column![parent, popup].spacing(1)) + .width(68) + .into() + } else { // Tooltip on hover - let tooltip_text = format!("{} (click for sub-tools)", slot.label); + let tooltip_text = slot.label.to_string(); iced::widget::tooltip( btn_element, iced::widget::text(tooltip_text).size(11), iced::widget::tooltip::Position::Right, ) .into() - } else { - // Tooltip on hover - let tooltip_text = slot.label.to_string(); - iced::widget::tooltip( - btn, - iced::widget::text(tooltip_text).size(11), - iced::widget::tooltip::Position::Right, - ) - .into() } } +/// Returns the SVG asset associated with an individual tool, including subtools. +/// +/// **Arguments:** `tool` is the concrete tool represented by a toolbox slot or popup row. +/// **Returns:** The crate-relative SVG path for the concrete tool. +/// **Side Effects / Dependencies:** None; callers resolve the returned path against assets. +fn tool_icon(tool: Tool) -> &'static str { + match tool { + Tool::Move => "icons/move.svg", + Tool::VectorSelect => "icons/vector_select.svg", + Tool::Select => "icons/rect_select.svg", + Tool::Lasso | Tool::PolygonSelect => "icons/lasso.svg", + Tool::MagicWand => "icons/magic_wand.svg", + Tool::SmartSelect | Tool::VisionSelect => "icons/smart_select.svg", + Tool::Crop => "icons/crop.svg", + Tool::Eyedropper => "icons/eyedropper.svg", + Tool::Brush => "icons/brush.svg", + Tool::Pen => "icons/pen.svg", + Tool::Spray => "icons/spray.svg", + Tool::Eraser => "icons/eraser.svg", + Tool::FloodFill => "icons/bucket.svg", + Tool::Gradient => "icons/gradient.svg", + Tool::Text => "icons/text.svg", + Tool::VectorLine => "icons/line.svg", + Tool::VectorRect => "icons/vector_rect.svg", + Tool::VectorCircle => "icons/circle.svg", + Tool::VectorArrow => "icons/vector_arrow.svg", + Tool::VectorStar => "icons/star.svg", + Tool::VectorPolygon => "icons/vector_polygon.svg", + Tool::VectorRhombus => "icons/vector_rhombus.svg", + Tool::VectorCylinder => "icons/vector_cylinder.svg", + Tool::VectorHeart => "icons/vector_heart.svg", + Tool::VectorBubble => "icons/vector_bubble.svg", + Tool::VectorGear => "icons/vector_gear.svg", + Tool::VectorCross => "icons/vector_cross.svg", + Tool::VectorCrescent => "icons/vector_crescent.svg", + Tool::VectorBolt => "icons/vector_bolt.svg", + Tool::VectorArrow4 => "icons/vector_arrow4.svg", + Tool::SpotRemoval => "icons/spot.svg", + Tool::RedEyeRemoval => "icons/redeye.svg", + Tool::SmartPatch | Tool::AiObjectRemoval => "icons/patch.svg", + } +} + +/// Resolves an SVG path across supported repository and application working directories. +/// +/// **Arguments:** `icon` is the asset-relative SVG path. +/// **Returns:** The first existing path, or the application-relative fallback. +/// **Side Effects / Dependencies:** Reads filesystem metadata without modifying it. +fn resolve_icon_path(icon: &str) -> std::path::PathBuf { + let candidates = [ + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../assets") + .join(icon), + std::path::PathBuf::from(icon), + std::path::Path::new("assets").join(icon), + std::path::Path::new("hcie-iced-app/assets").join(icon), + ]; + candidates + .into_iter() + .find(|path| path.exists()) + .unwrap_or_else(|| { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../assets") + .join(icon) + }) +} + /// Get keyboard shortcut display for a tool. fn tool_shortcut(tool: Tool) -> &'static str { match tool { @@ -480,3 +803,24 @@ pub fn last_used_tool(slot_idx: usize, last_used: &[usize]) -> Tool { .and_then(|s| s.tools.get(tidx).copied()) .unwrap_or(Tool::Brush) } + +#[cfg(test)] +mod tests { + use super::{resolve_icon_path, tool_icon, TOOL_SLOTS}; + + /// Verifies every concrete popup choice resolves to an existing SVG asset. + #[test] + fn every_subtool_has_an_svg_icon() { + for tool in TOOL_SLOTS + .iter() + .flat_map(|slot| slot.tools.iter().copied()) + { + let path = resolve_icon_path(tool_icon(tool)); + assert!( + path.exists(), + "missing icon for {tool:?}: {}", + path.display() + ); + } + } +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/theme.rs b/hcie-iced-app/crates/hcie-iced-gui/src/theme.rs index 8009ac2..bafa99e 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/theme.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/theme.rs @@ -13,31 +13,59 @@ use std::sync::{Arc, Mutex}; /// Shared theme state for the application. #[derive(Clone)] pub struct ThemeState { - colors: Arc>, + state: Arc>, } 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); } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/vector_edit.rs b/hcie-iced-app/crates/hcie-iced-gui/src/vector_edit.rs new file mode 100644 index 0000000..8f64079 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/vector_edit.rs @@ -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, +} + +/// 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) -> Option { + 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 + ); + } +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/viewer/directory_tree.rs b/hcie-iced-app/crates/hcie-iced-gui/src/viewer/directory_tree.rs index 7eda194..0c86fb8 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/viewer/directory_tree.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/viewer/directory_tree.rs @@ -129,8 +129,7 @@ fn draw_tree_node( let mut children_col = column![].spacing(1); for child in &subdirs { if let Some(child_name) = child.file_name().and_then(|n| n.to_str()) { - let child_node = - draw_tree_node(child, child_name, ">", state, colors, depth + 1); + let child_node = draw_tree_node(child, child_name, ">", state, colors, depth + 1); children_col = children_col.push(child_node); } } @@ -138,36 +137,40 @@ fn draw_tree_node( let btn = button(label_text) .on_press(Message::ViewerNavigate(path.to_path_buf())) .width(Length::Fill) - .style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { - iced::widget::button::Style { - background: Some(iced::Background::Color(if is_selected { - color_alpha(colors.accent, 0.15) - } else { - iced::Color::TRANSPARENT - })), - text_color: label_color, - border: iced::Border::default(), - ..Default::default() - } - }); + .style( + move |_theme: &iced::Theme, _status: iced::widget::button::Status| { + iced::widget::button::Style { + background: Some(iced::Background::Color(if is_selected { + color_alpha(colors.accent, 0.15) + } else { + iced::Color::TRANSPARENT + })), + text_color: label_color, + border: iced::Border::default(), + ..Default::default() + } + }, + ); column![btn, children_col].spacing(1).into() } else { button(label_text) .on_press(Message::ViewerNavigate(path.to_path_buf())) .width(Length::Fill) - .style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { - iced::widget::button::Style { - background: Some(iced::Background::Color(if is_selected { - color_alpha(colors.accent, 0.15) - } else { - iced::Color::TRANSPARENT - })), - text_color: label_color, - border: iced::Border::default(), - ..Default::default() - } - }) + .style( + move |_theme: &iced::Theme, _status: iced::widget::button::Status| { + iced::widget::button::Style { + background: Some(iced::Background::Color(if is_selected { + color_alpha(colors.accent, 0.15) + } else { + iced::Color::TRANSPARENT + })), + text_color: label_color, + border: iced::Border::default(), + ..Default::default() + } + }, + ) .into() }; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/viewer/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/viewer/mod.rs index b7d0b40..676b340 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/viewer/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/viewer/mod.rs @@ -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, u32, u32)>, /// Preview load error message. pub load_error: Option, + /// Directory enumeration error shown without pretending the folder is empty. + pub directory_error: Option, /// Zoom level for the viewer panel. pub zoom: f32, /// Pan offset for the viewer panel. @@ -75,6 +77,7 @@ impl Default for ViewerState { failed_thumbnails: HashSet::new(), preview_pixels: None, load_error: None, + directory_error: None, zoom: 1.0, pan_offset: (0.0, 0.0), } @@ -99,17 +102,32 @@ impl ViewerState { /// Refresh the images list in the current directory. pub fn refresh_images(&mut self) { log::debug!("Refreshing image list for: {}", self.current_dir.display()); + let selected = self.images.get(self.active_image_idx).cloned(); self.images.clear(); - if let Ok(entries) = std::fs::read_dir(&self.current_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_file() && is_supported_image(&path) { - self.images.push(path); + self.directory_error = None; + match std::fs::read_dir(&self.current_dir) { + Ok(entries) => { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() && is_supported_image(&path) { + self.images.push(path); + } } } + Err(error) => { + self.directory_error = Some(format!( + "Cannot read {}: {error}", + self.current_dir.display() + )) + } } - self.images.sort(); - self.active_image_idx = 0; + self.images.sort_by_cached_key(|path| { + path.file_name() + .map(|name| name.to_string_lossy().to_lowercase()) + }); + self.active_image_idx = selected + .and_then(|path| self.images.iter().position(|item| item == &path)) + .unwrap_or(0); self.preview_pixels = None; self.load_error = None; log::debug!("Found {} supported images", self.images.len()); @@ -220,7 +238,8 @@ impl ViewerState { let w = rgba.width(); let h = rgba.height(); let pixels = rgba.into_raw(); - self.thumbnail_cache.insert(path.to_path_buf(), (pixels, w, h)); + self.thumbnail_cache + .insert(path.to_path_buf(), (pixels, w, h)); } Err(_) => { self.failed_thumbnails.insert(path.to_path_buf()); @@ -247,10 +266,7 @@ fn _fit_size(texture_w: f32, texture_h: f32, avail_w: f32, avail_h: f32) -> (f32 } /// Build the viewer panel UI element. -pub fn view( - state: &ViewerState, - colors: ThemeColors, -) -> Element<'static, Message> { +pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> { let header = view_header(state, colors); let body = match state.view_mode { ViewerMode::Browser => view_browser(state, colors), @@ -266,77 +282,91 @@ pub fn view( fn view_header(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> { let exit_btn = button(text(" Exit Viewer ")) .on_press(Message::ViewerToggle) - .style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { - iced::widget::button::Style { - background: Some(iced::Background::Color(colors.accent)), - text_color: iced::Color::WHITE, - border: iced::Border::default().color(colors.accent).width(1), - ..Default::default() - } - }); + .style( + move |_theme: &iced::Theme, _status: iced::widget::button::Status| { + iced::widget::button::Style { + background: Some(iced::Background::Color(colors.accent)), + text_color: iced::Color::WHITE, + border: iced::Border::default().color(colors.accent).width(1), + ..Default::default() + } + }, + ); - let fs_label = if state.fullscreen { "Windowed" } else { "Fullscreen" }; + let fs_label = if state.fullscreen { + "Windowed" + } else { + "Fullscreen" + }; let fs_btn = button(text(format!(" {} ", fs_label))) .on_press(Message::ViewerFullscreen(!state.fullscreen)) - .style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { - iced::widget::button::Style { - background: Some(iced::Background::Color(colors.bg_panel)), - text_color: colors.text_primary, - border: iced::Border::default().color(colors.border_low).width(1), - ..Default::default() - } - }); + .style( + move |_theme: &iced::Theme, _status: iced::widget::button::Status| { + iced::widget::button::Style { + background: Some(iced::Background::Color(colors.bg_panel)), + text_color: colors.text_primary, + border: iced::Border::default().color(colors.border_low).width(1), + ..Default::default() + } + }, + ); let is_browser = state.view_mode == ViewerMode::Browser; let browser_btn = button(text(" Browse ")) - .on_press(Message::ViewerNavigate(state.current_dir.clone())) - .style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { - iced::widget::button::Style { - background: Some(iced::Background::Color(if is_browser { - colors.accent - } else { - colors.bg_panel - })), - text_color: if is_browser { - iced::Color::WHITE - } else { - colors.text_primary - }, - border: iced::Border::default().color(colors.border_low).width(1), - ..Default::default() - } - }); + .on_press(Message::ViewerSetMode(ViewerMode::Browser)) + .style( + move |_theme: &iced::Theme, _status: iced::widget::button::Status| { + iced::widget::button::Style { + background: Some(iced::Background::Color(if is_browser { + colors.accent + } else { + colors.bg_panel + })), + text_color: if is_browser { + iced::Color::WHITE + } else { + colors.text_primary + }, + border: iced::Border::default().color(colors.border_low).width(1), + ..Default::default() + } + }, + ); let is_viewer = state.view_mode == ViewerMode::Viewer; let viewer_btn = button(text(" Viewer ")) - .on_press(Message::ViewerNavigate(state.current_dir.clone())) - .style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { - iced::widget::button::Style { - background: Some(iced::Background::Color(if is_viewer { - colors.accent - } else { - colors.bg_panel - })), - text_color: if is_viewer { - iced::Color::WHITE - } else { - colors.text_primary - }, - border: iced::Border::default().color(colors.border_low).width(1), - ..Default::default() - } - }); + .on_press(Message::ViewerSetMode(ViewerMode::Viewer)) + .style( + move |_theme: &iced::Theme, _status: iced::widget::button::Status| { + iced::widget::button::Style { + background: Some(iced::Background::Color(if is_viewer { + colors.accent + } else { + colors.bg_panel + })), + text_color: if is_viewer { + iced::Color::WHITE + } else { + colors.text_primary + }, + border: iced::Border::default().color(colors.border_low).width(1), + ..Default::default() + } + }, + ); let refresh_btn = button(text(" Refresh ")) .on_press(Message::ViewerRefresh) - .style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { - iced::widget::button::Style { - background: Some(iced::Background::Color(colors.bg_panel)), - text_color: colors.text_primary, - border: iced::Border::default().color(colors.border_low).width(1), - ..Default::default() - } - }); + .style( + move |_theme: &iced::Theme, _status: iced::widget::button::Status| { + iced::widget::button::Style { + background: Some(iced::Background::Color(colors.bg_panel)), + text_color: colors.text_primary, + border: iced::Border::default().color(colors.border_low).width(1), + ..Default::default() + } + }, + ); row![ exit_btn, @@ -407,7 +437,9 @@ fn view_left_sidebar(state: &ViewerState, colors: ThemeColors) -> Element<'stati fn view_preview(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> { let content = if let Some((pixels, w, h)) = &state.preview_pixels { let img = iced::widget::Image::new(iced::widget::image::Handle::from_rgba( - *w, *h, pixels.clone(), + *w, + *h, + pixels.clone(), )) .width(Length::Fill) .height(Length::Shrink); @@ -438,15 +470,19 @@ fn view_preview(state: &ViewerState, colors: ThemeColors) -> Element<'static, Me .align_x(iced::Alignment::Center) .width(Length::Fill) } else if let Some(err) = &state.load_error { - column![text(format!("Error: {}", err)).style(move |_theme| iced::widget::text::Style { - color: Some(colors.danger), - })] + column![ + text(format!("Error: {}", err)).style(move |_theme| iced::widget::text::Style { + color: Some(colors.danger), + }) + ] .align_x(iced::Alignment::Center) .width(Length::Fill) } else { - column![text("No image selected").style(move |_theme| iced::widget::text::Style { - color: Some(colors.text_secondary), - })] + column![ + text("No image selected").style(move |_theme| iced::widget::text::Style { + color: Some(colors.text_secondary), + }) + ] .align_x(iced::Alignment::Center) .width(Length::Fill) }; @@ -481,23 +517,23 @@ fn view_right_panel(state: &ViewerState, colors: ThemeColors) -> Element<'static let has_parent = state.current_dir.parent().is_some(); let parent_btn = if has_parent { - button(text(" Parent ")) - .on_press(Message::ViewerNavigate( - state.current_dir.parent().unwrap().to_path_buf(), - )) + button(text(" Parent ")).on_press(Message::ViewerNavigate( + state.current_dir.parent().unwrap().to_path_buf(), + )) } else { button(text(" Parent ")) }; - let parent_btn = parent_btn - .style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { + let parent_btn = parent_btn.style( + move |_theme: &iced::Theme, _status: iced::widget::button::Status| { iced::widget::button::Style { background: Some(iced::Background::Color(colors.bg_panel)), text_color: colors.text_primary, border: iced::Border::default().color(colors.border_low).width(1), ..Default::default() } - }); + }, + ); let dir_str = state.current_dir.to_string_lossy().to_string(); let total_files = state.images.len(); @@ -506,7 +542,10 @@ fn view_right_panel(state: &ViewerState, colors: ThemeColors) -> Element<'static } else { 0 }; - let stats_str = format!("{} Files | Selected: {} / {}", total_files, selected_num, total_files); + let stats_str = format!( + "{} Files | Selected: {} / {}", + total_files, selected_num, total_files + ); let toolbar = row![ parent_btn, @@ -526,19 +565,20 @@ fn view_right_panel(state: &ViewerState, colors: ThemeColors) -> Element<'static color: Some(colors.text_secondary), }); - let grid = thumbnail_grid::view(state, colors); + let grid: Element<'static, Message> = if let Some(error) = &state.directory_error { + container(text(error.clone()).color(colors.danger)) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() + } else { + thumbnail_grid::view(state, colors) + }; container( - column![ - toolbar, - horizontal_rule(1), - stats, - horizontal_rule(1), - grid, - ] - .spacing(4) - .width(Length::Fill) - .height(Length::Fill), + column![toolbar, horizontal_rule(1), stats, horizontal_rule(1), grid,] + .spacing(4) + .width(Length::Fill) + .height(Length::Fill), ) .width(Length::Fill) .height(Length::Fill) @@ -557,12 +597,13 @@ fn view_right_panel(state: &ViewerState, colors: ThemeColors) -> Element<'static fn view_viewer(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> { if state.images.is_empty() { return column![ - text("No images found in this folder.").style(move |_theme| iced::widget::text::Style { - color: Some(colors.text_secondary), + text("No images found in this folder.").style(move |_theme| { + iced::widget::text::Style { + color: Some(colors.text_secondary), + } }), - button(text("Browse Mode")).on_press(Message::ViewerNavigate( - state.current_dir.clone(), - )), + button(text("Browse Mode")) + .on_press(Message::ViewerNavigate(state.current_dir.clone(),)), ] .align_x(iced::Alignment::Center) .width(Length::Fill) @@ -585,35 +626,38 @@ fn view_viewer(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes color: Some(colors.text_primary), }), Space::with_width(Length::Fill), - text(format!("{} / {}", current_idx + 1, total)).style(move |_theme| iced::widget::text::Style { - color: Some(colors.text_secondary), + text(format!("{} / {}", current_idx + 1, total)).style(move |_theme| { + iced::widget::text::Style { + color: Some(colors.text_secondary), + } }), text(" "), - button(text("Edit")).on_press(Message::ViewerOpenFile(edit_path)) - .style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { - iced::widget::button::Style { - background: Some(iced::Background::Color(colors.accent)), - text_color: iced::Color::WHITE, - border: iced::Border::default().color(colors.accent).width(1), - ..Default::default() + button(text("Edit")) + .on_press(Message::ViewerOpenFile(edit_path)) + .style( + move |_theme: &iced::Theme, _status: iced::widget::button::Status| { + iced::widget::button::Style { + background: Some(iced::Background::Color(colors.accent)), + text_color: iced::Color::WHITE, + border: iced::Border::default().color(colors.accent).width(1), + ..Default::default() + } } - }), + ), ] .align_y(iced::Alignment::Center) .width(Length::Fill); - let image_area: Element<'static, Message> = if let Some((pixels, w, h)) = &state.preview_pixels { - let handle: iced::widget::image::Handle = iced::widget::image::Handle::from_rgba( - *w, *h, pixels.clone(), - ); + let image_area: Element<'static, Message> = if let Some((pixels, w, h)) = &state.preview_pixels + { + let handle: iced::widget::image::Handle = + iced::widget::image::Handle::from_rgba(*w, *h, pixels.clone()); let img = iced::widget::Image::new(handle) .width(Length::Fill) .height(Length::Fill); - let prev_path = state.images[current_idx].clone(); - let prev_btn = button(text(" < ")) - .on_press(Message::ViewerNavigate(prev_path)) - .style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { + let prev_btn = button(text(" < ")).on_press(Message::ViewerPrev).style( + move |_theme: &iced::Theme, _status: iced::widget::button::Status| { iced::widget::button::Style { background: Some(iced::Background::Color(iced::Color::from_rgba( 0.0, 0.0, 0.0, 0.5, @@ -622,17 +666,11 @@ fn view_viewer(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes border: iced::Border::default().rounded(8), ..Default::default() } - }); + }, + ); - let next_idx = if current_idx + 1 < total { - current_idx + 1 - } else { - 0 - }; - let next_path = state.images[next_idx].clone(); - let next_btn = button(text(" > ")) - .on_press(Message::ViewerNavigate(next_path)) - .style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { + let next_btn = button(text(" > ")).on_press(Message::ViewerNext).style( + move |_theme: &iced::Theme, _status: iced::widget::button::Status| { iced::widget::button::Style { background: Some(iced::Background::Color(iced::Color::from_rgba( 0.0, 0.0, 0.0, 0.5, @@ -641,26 +679,31 @@ fn view_viewer(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes border: iced::Border::default().rounded(8), ..Default::default() } - }); + }, + ); row![prev_btn, img, next_btn] .width(Length::Fill) .height(Length::Fill) .into() } else if let Some(err) = &state.load_error { - column![text(format!("Failed to load: {}", err)).style(move |_theme| { - iced::widget::text::Style { - color: Some(colors.danger), - } - })] + column![ + text(format!("Failed to load: {}", err)).style(move |_theme| { + iced::widget::text::Style { + color: Some(colors.danger), + } + }) + ] .align_x(iced::Alignment::Center) .width(Length::Fill) .height(Length::Fill) .into() } else { - column![text("Loading...").style(move |_theme| iced::widget::text::Style { - color: Some(colors.text_secondary), - })] + column![ + text("Loading...").style(move |_theme| iced::widget::text::Style { + color: Some(colors.text_secondary), + }) + ] .align_x(iced::Alignment::Center) .width(Length::Fill) .height(Length::Fill) @@ -672,3 +715,24 @@ fn view_viewer(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes .height(Length::Fill) .into() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn navigation_wraps_deterministically() { + let mut state = ViewerState::default(); + state.images = vec![PathBuf::from("a.png"), PathBuf::from("b.png")]; + state.prev_image(); + assert_eq!(state.active_image_idx, 1); + state.next_image(); + assert_eq!(state.active_image_idx, 0); + } + + #[test] + fn supported_extensions_are_case_insensitive() { + assert!(is_supported_image(Path::new("IMAGE.PNG"))); + assert!(!is_supported_image(Path::new("notes.txt"))); + } +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/viewer/thumbnail_grid.rs b/hcie-iced-app/crates/hcie-iced-gui/src/viewer/thumbnail_grid.rs index 3f76e7e..d674ccf 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/viewer/thumbnail_grid.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/viewer/thumbnail_grid.rs @@ -42,8 +42,9 @@ pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Messag .into(); } - let avail_w = 600.0_f32; - let cols = ((avail_w / (CARD_W + CARD_SPACING)).floor().max(1.0)) as usize; + // Four compact columns remain usable at the minimum editor width while the + // surrounding scrollable preserves access on narrower windows. + let cols = 4; let mut grid_rows = column![].spacing(CARD_SPACING); let total = state.images.len(); @@ -68,7 +69,11 @@ pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Messag } /// Render a single thumbnail card. -fn thumbnail_card(state: &ViewerState, index: usize, colors: ThemeColors) -> Element<'static, Message> { +fn thumbnail_card( + state: &ViewerState, + index: usize, + colors: ThemeColors, +) -> Element<'static, Message> { let img_path = &state.images[index]; let is_selected = state.active_image_idx == index; let filename = img_path @@ -82,19 +87,17 @@ fn thumbnail_card(state: &ViewerState, index: usize, colors: ThemeColors) -> Ele filename.clone() }; - let thumb_content: Element<'static, Message> = if let Some((pixels, w, h)) = state.thumbnail_cache.get(img_path) { - let handle: iced::widget::image::Handle = iced::widget::image::Handle::from_rgba( - *w, *h, pixels.clone(), - ); - let img = iced::widget::Image::new(handle) - .width(110) - .height(100); - img.into() - } else if state.failed_thumbnails.contains(img_path) { - text("Error").size(10).into() - } else { - text("...").size(10).into() - }; + let thumb_content: Element<'static, Message> = + if let Some((pixels, w, h)) = state.thumbnail_cache.get(img_path) { + let handle: iced::widget::image::Handle = + iced::widget::image::Handle::from_rgba(*w, *h, pixels.clone()); + let img = iced::widget::Image::new(handle).width(110).height(100); + img.into() + } else if state.failed_thumbnails.contains(img_path) { + text("Error").size(10).into() + } else { + text("...").size(10).into() + }; let card_content = column![ Space::with_height(4), @@ -124,17 +127,29 @@ fn thumbnail_card(state: &ViewerState, index: usize, colors: ThemeColors) -> Ele button(card_content) .on_press(Message::ViewerSelectFile(img_path.clone())) .width(CARD_W) - .style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { - iced::widget::button::Style { - background: Some(iced::Background::Color(frame_color)), - text_color: colors.text_primary, - border: iced::Border::default() - .color(border_color) - .width(if is_selected { 2.0 } else { 1.0 }) - .rounded(6), - ..Default::default() - } - }) + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| { + let background = match status { + iced::widget::button::Status::Hovered => colors.bg_hover, + iced::widget::button::Status::Pressed => colors.bg_active, + iced::widget::button::Status::Disabled => colors.bg_app, + iced::widget::button::Status::Active => frame_color, + }; + iced::widget::button::Style { + background: Some(iced::Background::Color(background)), + text_color: if status == iced::widget::button::Status::Disabled { + colors.text_disabled + } else { + colors.text_primary + }, + border: iced::Border::default() + .color(border_color) + .width(if is_selected { 2.0 } else { 1.0 }) + .rounded(6), + ..Default::default() + } + }, + ) .into() } @@ -143,7 +158,9 @@ pub fn count_unloaded(state: &ViewerState) -> usize { state .images .iter() - .filter(|p| !state.thumbnail_cache.contains_key(*p) && !state.failed_thumbnails.contains(*p)) + .filter(|p| { + !state.thumbnail_cache.contains_key(*p) && !state.failed_thumbnails.contains(*p) + }) .count() } @@ -152,7 +169,9 @@ pub fn next_load_batch(state: &ViewerState) -> Vec { 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() diff --git a/hcie-iced-app/crates/hcie-iced-gui/tests/feature_scorecard.rs b/hcie-iced-app/crates/hcie-iced-gui/tests/feature_scorecard.rs new file mode 100644 index 0000000..afc9143 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/tests/feature_scorecard.rs @@ -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"), + "{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") + && 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") + && 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}" + ); +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/tests/raster_test.rs b/hcie-iced-app/crates/hcie-iced-gui/tests/raster_test.rs new file mode 100644 index 0000000..f80ad8f --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/tests/raster_test.rs @@ -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); +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/tests/selection_test.rs b/hcie-iced-app/crates/hcie-iced-gui/tests/selection_test.rs index 6fa5bf8..071190e 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/tests/selection_test.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/tests/selection_test.rs @@ -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); +} diff --git a/hcie-iced-app/crates/iced-panel-adapter/Cargo.toml b/hcie-iced-app/crates/iced-panel-adapter/Cargo.toml index 825223d..007e1ec 100644 --- a/hcie-iced-app/crates/iced-panel-adapter/Cargo.toml +++ b/hcie-iced-app/crates/iced-panel-adapter/Cargo.toml @@ -10,3 +10,4 @@ crate-type = ["rlib"] hcie-engine-api = { path = "../../../hcie-engine-api" } iced = { workspace = true } serde_json = { workspace = true } +serde = { workspace = true } diff --git a/hcie-iced-app/crates/iced-panel-adapter/src/lib.rs b/hcie-iced-app/crates/iced-panel-adapter/src/lib.rs index 3499d19..a884c04 100644 --- a/hcie-iced-app/crates/iced-panel-adapter/src/lib.rs +++ b/hcie-iced-app/crates/iced-panel-adapter/src/lib.rs @@ -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) diff --git a/hcie-iced-app/crates/iced-panel-adapter/src/theme.rs b/hcie-iced-app/crates/iced-panel-adapter/src/theme.rs index 9bd18a3..bff149e 100644 --- a/hcie-iced-app/crates/iced-panel-adapter/src/theme.rs +++ b/hcie-iced-app/crates/iced-panel-adapter/src/theme.rs @@ -16,7 +16,7 @@ use iced::Color; /// /// `Photopea` is the default — matches the Photopea image editor's dark UI. /// Other presets are kept as alternatives. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] pub enum ThemePreset { #[default] Photopea, @@ -82,127 +82,127 @@ impl ThemeColors { // ── Photopea (Default) ───────────────────────────────────── // Exact colors from 93 Photopea screenshots. ThemePreset::Photopea => Self { - bg_app: Color::from_rgb(0.118, 0.118, 0.118), // #1e1e1e - canvas - bg_panel: Color::from_rgb(0.165, 0.165, 0.165), // #2a2a2a - panels - bg_hover: Color::from_rgb(0.227, 0.227, 0.227), // #3a3a3a - hover - bg_active: Color::from_rgb(0.200, 0.200, 0.200), // #333333 - active tab - accent: Color::from_rgb(0.290, 0.620, 1.000), // #4a9eff - selections - accent_hover: Color::from_rgb(0.350, 0.680, 1.000), - accent_green: Color::from_rgb(0.298, 0.686, 0.314), // #4caf50 - tool active - danger: Color::from_rgb(0.906, 0.298, 0.235), // #e74c3c - account btn - border_low: Color::from_rgb(0.200, 0.200, 0.200), // #333333 - dividers - border_high: Color::from_rgb(0.267, 0.267, 0.267), // #444444 - borders - text_primary: Color::from_rgb(0.878, 0.878, 0.878), // #e0e0e0 - primary - text_secondary:Color::from_rgb(0.600, 0.600, 0.600), // #999999 - muted - text_disabled: Color::from_rgb(0.400, 0.400, 0.400), // #666666 - disabled - menu_bg: Color::from_rgb(0.910, 0.910, 0.910), // #e8e8e8 - menu bg - menu_hover: Color::from_rgb(0.702, 0.851, 1.000), // #b3d9ff - menu hover - menu_text: Color::from_rgb(0.000, 0.000, 0.000), // #000000 - menu text - menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400), // #666666 - shortcuts + bg_app: Color::from_rgb(0.118, 0.118, 0.118), // #1e1e1e - canvas + bg_panel: Color::from_rgb(0.165, 0.165, 0.165), // #2a2a2a - panels + bg_hover: Color::from_rgb(0.227, 0.227, 0.227), // #3a3a3a - hover + bg_active: Color::from_rgb(0.200, 0.200, 0.200), // #333333 - active tab + accent: Color::from_rgb(0.290, 0.620, 1.000), // #4a9eff - selections + accent_hover: Color::from_rgb(0.350, 0.680, 1.000), + accent_green: Color::from_rgb(0.298, 0.686, 0.314), // #4caf50 - tool active + danger: Color::from_rgb(0.906, 0.298, 0.235), // #e74c3c - account btn + border_low: Color::from_rgb(0.200, 0.200, 0.200), // #333333 - dividers + border_high: Color::from_rgb(0.267, 0.267, 0.267), // #444444 - borders + text_primary: Color::from_rgb(0.878, 0.878, 0.878), // #e0e0e0 - primary + text_secondary: Color::from_rgb(0.600, 0.600, 0.600), // #999999 - muted + text_disabled: Color::from_rgb(0.400, 0.400, 0.400), // #666666 - disabled + menu_bg: Color::from_rgb(0.910, 0.910, 0.910), // #e8e8e8 - menu bg + menu_hover: Color::from_rgb(0.702, 0.851, 1.000), // #b3d9ff - menu hover + menu_text: Color::from_rgb(0.000, 0.000, 0.000), // #000000 - menu text + menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400), // #666666 - shortcuts is_light: false, }, // ── Photoshop (Legacy) ───────────────────────────────────── ThemePreset::Photoshop => Self { - bg_app: Color::from_rgb(0.110, 0.110, 0.118), - bg_panel: Color::from_rgb(0.196, 0.196, 0.212), - bg_hover: Color::from_rgb(0.243, 0.243, 0.259), - bg_active: Color::from_rgb(0.227, 0.227, 0.243), - accent: Color::from_rgb(0.220, 0.471, 0.863), - accent_hover: Color::from_rgb(0.260, 0.520, 0.900), - accent_green: Color::from_rgb(0.298, 0.686, 0.314), - danger: Color::from_rgb(0.800, 0.200, 0.200), - border_low: Color::from_rgb(0.157, 0.157, 0.165), - border_high: Color::from_rgb(0.251, 0.251, 0.267), - text_primary: Color::from_rgb(0.878, 0.878, 0.878), - text_secondary:Color::from_rgb(0.580, 0.580, 0.596), + bg_app: Color::from_rgb(0.110, 0.110, 0.118), + bg_panel: Color::from_rgb(0.196, 0.196, 0.212), + bg_hover: Color::from_rgb(0.243, 0.243, 0.259), + bg_active: Color::from_rgb(0.227, 0.227, 0.243), + accent: Color::from_rgb(0.220, 0.471, 0.863), + accent_hover: Color::from_rgb(0.260, 0.520, 0.900), + accent_green: Color::from_rgb(0.298, 0.686, 0.314), + danger: Color::from_rgb(0.800, 0.200, 0.200), + border_low: Color::from_rgb(0.157, 0.157, 0.165), + border_high: Color::from_rgb(0.251, 0.251, 0.267), + text_primary: Color::from_rgb(0.878, 0.878, 0.878), + text_secondary: Color::from_rgb(0.580, 0.580, 0.596), text_disabled: Color::from_rgb(0.350, 0.350, 0.360), - menu_bg: Color::from_rgb(0.910, 0.910, 0.910), - menu_hover: Color::from_rgb(0.702, 0.851, 1.000), - menu_text: Color::from_rgb(0.000, 0.000, 0.000), + menu_bg: Color::from_rgb(0.910, 0.910, 0.910), + menu_hover: Color::from_rgb(0.702, 0.851, 1.000), + menu_text: Color::from_rgb(0.000, 0.000, 0.000), menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400), is_light: false, }, // ── Monokai ──────────────────────────────────────────────── ThemePreset::ProDark => Self { - bg_app: Color::from_rgb(0.086, 0.082, 0.071), - bg_panel: Color::from_rgb(0.153, 0.157, 0.133), - bg_hover: Color::from_rgb(0.212, 0.220, 0.188), - bg_active: Color::from_rgb(0.180, 0.184, 0.160), - accent: Color::from_rgb(0.902, 0.588, 0.196), - accent_hover: Color::from_rgb(0.940, 0.640, 0.260), - accent_green: Color::from_rgb(0.298, 0.686, 0.314), - danger: Color::from_rgb(0.780, 0.220, 0.267), - border_low: Color::from_rgb(0.110, 0.106, 0.086), - border_high: Color::from_rgb(0.243, 0.251, 0.212), - text_primary: Color::from_rgb(0.973, 0.973, 0.941), - text_secondary:Color::from_rgb(0.580, 0.588, 0.533), + bg_app: Color::from_rgb(0.086, 0.082, 0.071), + bg_panel: Color::from_rgb(0.153, 0.157, 0.133), + bg_hover: Color::from_rgb(0.212, 0.220, 0.188), + bg_active: Color::from_rgb(0.180, 0.184, 0.160), + accent: Color::from_rgb(0.902, 0.588, 0.196), + accent_hover: Color::from_rgb(0.940, 0.640, 0.260), + accent_green: Color::from_rgb(0.298, 0.686, 0.314), + danger: Color::from_rgb(0.780, 0.220, 0.267), + border_low: Color::from_rgb(0.110, 0.106, 0.086), + border_high: Color::from_rgb(0.243, 0.251, 0.212), + text_primary: Color::from_rgb(0.973, 0.973, 0.941), + text_secondary: Color::from_rgb(0.580, 0.588, 0.533), text_disabled: Color::from_rgb(0.350, 0.350, 0.330), - menu_bg: Color::from_rgb(0.910, 0.910, 0.910), - menu_hover: Color::from_rgb(0.702, 0.851, 1.000), - menu_text: Color::from_rgb(0.000, 0.000, 0.000), + menu_bg: Color::from_rgb(0.910, 0.910, 0.910), + menu_hover: Color::from_rgb(0.702, 0.851, 1.000), + menu_text: Color::from_rgb(0.000, 0.000, 0.000), menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400), is_light: false, }, // ── Dracula ──────────────────────────────────────────────── ThemePreset::Amoled => Self { - bg_app: Color::from_rgb(0.094, 0.086, 0.133), - bg_panel: Color::from_rgb(0.157, 0.165, 0.212), - bg_hover: Color::from_rgb(0.220, 0.227, 0.290), - bg_active: Color::from_rgb(0.184, 0.192, 0.243), - accent: Color::from_rgb(1.000, 0.475, 0.776), - accent_hover: Color::from_rgb(1.000, 0.530, 0.820), - accent_green: Color::from_rgb(0.298, 0.686, 0.314), - danger: Color::from_rgb(0.753, 0.220, 0.294), - border_low: Color::from_rgb(0.118, 0.110, 0.165), - border_high: Color::from_rgb(0.267, 0.275, 0.345), - text_primary: Color::from_rgb(0.973, 0.973, 0.949), - text_secondary:Color::from_rgb(0.588, 0.596, 0.667), + bg_app: Color::from_rgb(0.094, 0.086, 0.133), + bg_panel: Color::from_rgb(0.157, 0.165, 0.212), + bg_hover: Color::from_rgb(0.220, 0.227, 0.290), + bg_active: Color::from_rgb(0.184, 0.192, 0.243), + accent: Color::from_rgb(1.000, 0.475, 0.776), + accent_hover: Color::from_rgb(1.000, 0.530, 0.820), + accent_green: Color::from_rgb(0.298, 0.686, 0.314), + danger: Color::from_rgb(0.753, 0.220, 0.294), + border_low: Color::from_rgb(0.118, 0.110, 0.165), + border_high: Color::from_rgb(0.267, 0.275, 0.345), + text_primary: Color::from_rgb(0.973, 0.973, 0.949), + text_secondary: Color::from_rgb(0.588, 0.596, 0.667), text_disabled: Color::from_rgb(0.350, 0.350, 0.380), - menu_bg: Color::from_rgb(0.910, 0.910, 0.910), - menu_hover: Color::from_rgb(0.702, 0.851, 1.000), - menu_text: Color::from_rgb(0.000, 0.000, 0.000), + menu_bg: Color::from_rgb(0.910, 0.910, 0.910), + menu_hover: Color::from_rgb(0.702, 0.851, 1.000), + menu_text: Color::from_rgb(0.000, 0.000, 0.000), menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400), is_light: false, }, // ── Solarized Light ──────────────────────────────────────── ThemePreset::PhotoshopLight => Self { - bg_app: Color::from_rgb(0.992, 0.965, 0.890), - bg_panel: Color::from_rgb(1.000, 0.980, 0.922), - bg_hover: Color::from_rgb(0.933, 0.910, 0.835), - bg_active: Color::from_rgb(0.878, 0.855, 0.780), - accent: Color::from_rgb(0.796, 0.294, 0.086), - accent_hover: Color::from_rgb(0.840, 0.340, 0.140), - accent_green: Color::from_rgb(0.298, 0.686, 0.314), - danger: Color::from_rgb(0.800, 0.200, 0.200), - border_low: Color::from_rgb(0.871, 0.847, 0.769), - border_high: Color::from_rgb(0.784, 0.753, 0.667), - text_primary: Color::from_rgb(0.196, 0.196, 0.157), - text_secondary:Color::from_rgb(0.471, 0.463, 0.392), + bg_app: Color::from_rgb(0.992, 0.965, 0.890), + bg_panel: Color::from_rgb(1.000, 0.980, 0.922), + bg_hover: Color::from_rgb(0.933, 0.910, 0.835), + bg_active: Color::from_rgb(0.878, 0.855, 0.780), + accent: Color::from_rgb(0.796, 0.294, 0.086), + accent_hover: Color::from_rgb(0.840, 0.340, 0.140), + accent_green: Color::from_rgb(0.298, 0.686, 0.314), + danger: Color::from_rgb(0.800, 0.200, 0.200), + border_low: Color::from_rgb(0.871, 0.847, 0.769), + border_high: Color::from_rgb(0.784, 0.753, 0.667), + text_primary: Color::from_rgb(0.196, 0.196, 0.157), + text_secondary: Color::from_rgb(0.471, 0.463, 0.392), text_disabled: Color::from_rgb(0.650, 0.640, 0.580), - menu_bg: Color::from_rgb(0.910, 0.910, 0.910), - menu_hover: Color::from_rgb(0.702, 0.851, 1.000), - menu_text: Color::from_rgb(0.000, 0.000, 0.000), + menu_bg: Color::from_rgb(0.910, 0.910, 0.910), + menu_hover: Color::from_rgb(0.702, 0.851, 1.000), + menu_text: Color::from_rgb(0.000, 0.000, 0.000), menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400), is_light: true, }, // ── Nord ─────────────────────────────────────────────────── ThemePreset::ProLight => Self { - bg_app: Color::from_rgb(0.894, 0.910, 0.933), - bg_panel: Color::from_rgb(0.925, 0.937, 0.957), - bg_hover: Color::from_rgb(0.847, 0.871, 0.902), - bg_active: Color::from_rgb(0.816, 0.839, 0.871), - accent: Color::from_rgb(0.369, 0.506, 0.675), - accent_hover: Color::from_rgb(0.410, 0.540, 0.710), - accent_green: Color::from_rgb(0.298, 0.686, 0.314), - danger: Color::from_rgb(0.820, 0.220, 0.220), - border_low: Color::from_rgb(0.831, 0.855, 0.886), - border_high: Color::from_rgb(0.706, 0.737, 0.784), - text_primary: Color::from_rgb(0.180, 0.204, 0.251), - text_secondary:Color::from_rgb(0.392, 0.431, 0.502), + bg_app: Color::from_rgb(0.894, 0.910, 0.933), + bg_panel: Color::from_rgb(0.925, 0.937, 0.957), + bg_hover: Color::from_rgb(0.847, 0.871, 0.902), + bg_active: Color::from_rgb(0.816, 0.839, 0.871), + accent: Color::from_rgb(0.369, 0.506, 0.675), + accent_hover: Color::from_rgb(0.410, 0.540, 0.710), + accent_green: Color::from_rgb(0.298, 0.686, 0.314), + danger: Color::from_rgb(0.820, 0.220, 0.220), + border_low: Color::from_rgb(0.831, 0.855, 0.886), + border_high: Color::from_rgb(0.706, 0.737, 0.784), + text_primary: Color::from_rgb(0.180, 0.204, 0.251), + text_secondary: Color::from_rgb(0.392, 0.431, 0.502), text_disabled: Color::from_rgb(0.580, 0.610, 0.650), - menu_bg: Color::from_rgb(0.910, 0.910, 0.910), - menu_hover: Color::from_rgb(0.702, 0.851, 1.000), - menu_text: Color::from_rgb(0.000, 0.000, 0.000), + menu_bg: Color::from_rgb(0.910, 0.910, 0.910), + menu_hover: Color::from_rgb(0.702, 0.851, 1.000), + menu_text: Color::from_rgb(0.000, 0.000, 0.000), menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400), is_light: true, },