feat(gui): implement selection mask state and vector editing functionality
- Add `selection/state.rs` to manage selection masks, including combining masks and calculating bounds. - Introduce `vector_edit.rs` for vector shape manipulation, handling drag sessions, and hit testing for handles. - Create `feature_scorecard.rs` to ensure stable feature identifiers and regression gates for GUI components. - Implement raster behavior tests in `raster_test.rs` to validate gradient application against masks.
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
# ICED GUI Parity: Scored Migration and Vector Tool Plan
|
||||
|
||||
## Goal
|
||||
Bring every retained user-facing ICED editor feature to **at least 9/10 relative to egui**. Work proceeds in scored cycles. A cycle is incomplete until the complete scorecard is rerun and touched features reach target without regressing earlier work.
|
||||
|
||||
## Constraints
|
||||
- GUI changes only under `hcie-iced-app/crates/`; `hcie-engine-api` remains the sole engine entry point.
|
||||
- Preserve persistent wgpu textures, dirty-region uploads, viewport mapping, nearest filtering, and the `RefCell` dirty accumulator.
|
||||
- Theme changes are UI-only. They must never invoke image/viewer loading, imports, dialogs, document replacement, or pixel mutation.
|
||||
- Reachable `NoOp`, static fake controls, wrong menu aliases, and materially incorrect fallbacks score at most 2/10.
|
||||
- Add ICED screenshot capture first; inspect screenshots after every visual change.
|
||||
- Preserve unrelated working-tree changes.
|
||||
|
||||
## Scoring
|
||||
| Score | Meaning |
|
||||
|---|---|
|
||||
| 0 | Absent/unusable |
|
||||
| 1-2 | Placeholder; no real action |
|
||||
| 3-4 | Partial, major paths missing |
|
||||
| 5-6 | Basic workflow, substantial defects |
|
||||
| 7 | Main workflow works, notable gaps |
|
||||
| 8 | Near parity, minor gaps |
|
||||
| 9 | Production parity: correct UI/action/undo/persistence as applicable, tested and visually verified |
|
||||
| 10 | No known gap under covered scenarios |
|
||||
|
||||
## Baseline Inventory
|
||||
Scores reflect the current working tree; the old vector `2.5/10` assessment is obsolete.
|
||||
|
||||
### Shell, Menus, Docking
|
||||
| Feature | Current | Key gap | Target |
|
||||
|---|---:|---|---:|
|
||||
| Shell/title/tabs/window controls | 7 | Document close is `NoOp`; title drag overlaps menu region | 9 |
|
||||
| Menu typography | 5 | Dropdown labels are thin/mixed contrast | 9 |
|
||||
| Dropdown placement/event capture | 4 | Character-count X estimate; shifted/click-through risk | 9 |
|
||||
| Menu command coverage | 3 | Most enabled-looking commands are labels/comments | 9 |
|
||||
| Theme switching | 6 | Reported image-load side effect untested; no persistence | 9 |
|
||||
| Dock resize/maximize/close | 8 | Main behavior works | 9 |
|
||||
| Dock relocation | 6 | Drop only swaps; directional drop regions ignored | 9 |
|
||||
| Dock profile/layout persistence | 5 | Does not serialize exact split tree/order/ratios | 9 |
|
||||
| Panel reopen/focus | 7 | Coarse hardcoded placement | 9 |
|
||||
| Settings persistence | 6 | Theme/toolbox/exact dock state missing | 9 |
|
||||
| Internationalization | 3 | Most strings bypass i18n | 9 |
|
||||
| ICED screenshot harness | 1 | F12 only refreshes | 9 |
|
||||
|
||||
### Canvas and Tools
|
||||
| Feature | Current | Key gap | Target |
|
||||
|---|---:|---|---:|
|
||||
| GPU canvas/checkerboard/partial upload | 9 | Protected path active | 9 |
|
||||
| Pan/zoom/100% | 8 | True fit-to-area incomplete | 9 |
|
||||
| Fit/navigation/pinch/space-pan | 5 | Ctrl+0 sets 1.0; missing expected navigation | 9 |
|
||||
| Brush/pressure | 8 | Modifier UX/cursor footprint | 9 |
|
||||
| Pen | 5 | Mostly treated as Brush unlike egui pen segments | 9 |
|
||||
| Eraser | 8 | Per-tool config/cursor polish | 9 |
|
||||
| Spray | 7 | Verify density/particle settings reach engine | 9 |
|
||||
| Flood fill | 7 | Contiguous/AA/history validation | 9 |
|
||||
| Gradient | 5 | Stroke approximation, not true linear/radial gradient | 9 |
|
||||
| Eyedropper | 7 | Ctrl-click temporary pick missing | 9 |
|
||||
| Brush cursor/Shift resize HUD | 3 | Fixed crosshair only | 9 |
|
||||
| Canvas context menu | 5 | Cut maps to Copy; partial actions | 9 |
|
||||
| Rulers/grid/guides/snap/pixel grid | 1 | Menu shells only | 9 |
|
||||
| Status/coordinates/pressure | 7 | Needs live cross-theme verification | 9 |
|
||||
| Retouch tools | 2 | Selectable toolbox entries, incomplete execution/options | 9 |
|
||||
|
||||
### Selection, Transform, Clipboard
|
||||
| Feature | Current | Key gap | Target |
|
||||
|---|---:|---|---:|
|
||||
| Rectangle selection | 8 | Minor parity/tests | 9 |
|
||||
| Lasso/polygon | 7 | Preview/edge/close behavior | 9 |
|
||||
| Magic wand | 7 | AA/contiguous partially `NoOp` | 9 |
|
||||
| SmartSelect/SAM | 3 | Magic-wand fallback, no real backend flow | 9 |
|
||||
| VisionSelect | 3 | Rectangle fallback, no model/task flow | 9 |
|
||||
| Marching ants/fill | 5 | Bounds, not irregular mask edges | 9 |
|
||||
| Raster transform | 7 | Undo/aspect/handles verification | 9 |
|
||||
| Crop | 8 | Minor parity/tests | 9 |
|
||||
| Copy/paste | 7 | External/internal selection tests | 9 |
|
||||
| Cut/clear | 2 | Cut is Copy; Clear incomplete | 9 |
|
||||
| Selection modify/load/save/quick mask | 4 | Some operations exist; exposed gaps remain | 9 |
|
||||
|
||||
### Vector and Text
|
||||
| Feature | Current | Key gap | Target |
|
||||
|---|---:|---|---:|
|
||||
| Creation, 15 shape types | 8 | Verify layer safety/undo | 9 |
|
||||
| Accurate previews | 7 | Several shapes remain approximate | 9 |
|
||||
| VectorSelect/cycling | 7 | Implemented but insufficiently tested | 9 |
|
||||
| Handles/hit testing | 7 | Rotated/hover/zoom behavior needs hardening | 9 |
|
||||
| Move/8-way resize/rotate | 7 | Constraints, undo, edge cases | 9 |
|
||||
| Apply/Cancel/snapshot | 7 | Verify exact restoration and overlay UX | 9 |
|
||||
| Flip H/V | 6 | Verify supported shapes/output | 9 |
|
||||
| Delete | 8 | Main path exists | 9 |
|
||||
| Geometry list/selection | 8 | Main path exists | 9 |
|
||||
| Bounds/fill/stroke/opacity/hardness | 8 | Precision/sync/tests | 9 |
|
||||
| Line caps | 7 | Always-expanded list, weak selector UX | 9 |
|
||||
| Boolean operations | 7 | Validation/failure/undo/result selection | 9 |
|
||||
| Tool↔shape sync | 6 | Properties/Geometry/toolbar can diverge | 9 |
|
||||
| Text create/inline edit | 6 | Focus/multiline/existing-layer edit/cancel | 9 |
|
||||
| Text properties/effects | 6 | Full egui parity missing | 9 |
|
||||
|
||||
### Panels and Workflows
|
||||
| Feature | Current | Key gap | Target |
|
||||
|---|---:|---|---:|
|
||||
| Layers | 8 | Hierarchy/duplicate/masks/fill opacity verification | 9 |
|
||||
| History | 7 | Every operation needs correct undo boundary | 9 |
|
||||
| Brushes & Tips | 7 | Category filter `NoOp`; advanced editor gaps | 9 |
|
||||
| Color/palette/recent/alpha | 7 | Continuous wheel/recent UX/vector sync | 9 |
|
||||
| Filters/preview | 8 | Audit every schema/default/apply/reset | 9 |
|
||||
| Properties | 7 | Duplicate state surfaces may diverge | 9 |
|
||||
| Tool Settings/toolbar | 7 | AA toggles/buttons remain `NoOp` | 9 |
|
||||
| Layer Styles | 8 | Add-style placeholder/undo validation | 9 |
|
||||
| Layer Details | 7 | Field/action parity audit | 9 |
|
||||
| Script | 8 | Diagnostics/integration tests | 9 |
|
||||
| AI Chat | 7 | Cancel/error/model discovery tests | 9 |
|
||||
| AI Script | 7 | Provider/error/validation robustness | 9 |
|
||||
| Viewer/browser | 7 | Cache/fullscreen/error/open-edit tests | 9 |
|
||||
| Dialogs | 8 | Focus/validation/modal behavior | 9 |
|
||||
| Plugins/actions/channels/histogram/navigator exposed by More | 1 | Labels only | 9 |
|
||||
|
||||
### Files and Documents
|
||||
| Feature | Current | Key gap | Target |
|
||||
|---|---:|---|---:|
|
||||
| New/open/recent/import | 8 | Error and format coverage | 9 |
|
||||
| Save/save-as/export | 6 | Unsupported menu claims/export paths | 9 |
|
||||
| Multi-document tabs/dirty close | 6 | Tab close `NoOp`; dirty coverage | 9 |
|
||||
| Image/canvas size/crop/merge/flatten | 8 | Regression tests | 9 |
|
||||
| Layer duplicate/group/masks/rasterize/smart object | 3 | Menu shells/TODOs | 9 |
|
||||
| Print/share/file-info/automation | 1 | Menu shells | 9 |
|
||||
|
||||
## Score Evidence Rules
|
||||
A score may rise only when:
|
||||
- UI is reachable and correctly positioned.
|
||||
- A semantic message and real handler exist; no `NoOp` or wrong alias.
|
||||
- State/engine output is correct.
|
||||
- Undo/dirty state and persistence are correct where expected.
|
||||
- Automated test passes.
|
||||
- Visual behavior has an inspected screenshot.
|
||||
|
||||
Generate after every cycle under `target/iced-audit/`: full score table, failing evidence, test output, and screenshots. Add `tests/feature_scorecard.rs` with stable feature IDs and evidence test names.
|
||||
|
||||
## Cycle 0: Audit and Verification Infrastructure
|
||||
1. Build a canonical registry from egui panes, tools, menus, dialogs, shortcuts and canvas interactions.
|
||||
2. Add tests that fail for reachable `NoOp`, Cut→Copy aliases, enabled menu items without semantic handlers, or menu definition/index drift.
|
||||
3. Add ICED screenshot CLI: `--screenshot output.png` and `--screenshot-panel "Panel" output.png`; settle layout/texture then crop from measured pane bounds.
|
||||
4. Capture baseline full viewport, all menus, collapsed/expanded toolbox, Geometry, Properties, Layers, Color, and selected-vector canvas.
|
||||
5. Run build/tests and record baseline scores. Do not raise scores without evidence.
|
||||
6. **Recheck all scores.**
|
||||
|
||||
## Cycle 1: Requested UX Fixes
|
||||
### Menus
|
||||
Files: `panels/title_bar.rs`, `panels/menus.rs`, `app.rs`.
|
||||
1. Replace approximate character-width offsets with measured menu-button anchor rectangles or an overlay anchored in the same layout subtree.
|
||||
2. Place each dropdown at `anchor.x/anchor.bottom`, clamp to viewport, and make its outside-click layer fill/capture the viewport.
|
||||
3. Separate draggable title space from menu buttons; menu clicks must not emit `WindowDrag` or reach underlying canvas/viewer.
|
||||
4. Use a bundled readable UI font and Medium/Semibold weight for dropdown labels, shortcuts and arrows; use theme contrast tokens.
|
||||
5. Replace raw `(menu_idx,item_idx)` dispatch with semantic `MenuCommand` generated from the menu definition.
|
||||
6. Test all nine anchors at multiple widths/DPI; screenshot every dropdown in dark/light themes.
|
||||
|
||||
### Theme side-effect regression
|
||||
1. Make theme commands synchronous: set colors, persist preset, close menu, repaint only.
|
||||
2. Instrument file/import/viewer-preview/dialog/document replacement calls.
|
||||
3. Test all themes preserve document ID/path/layers/composite hash/viewer selection/recent files/file-dialog state.
|
||||
4. Switch themes while drawing and with viewer active/inactive; prove no image load or texture reset.
|
||||
|
||||
### Movable/reconfigurable panels
|
||||
Files: `dock/state.rs`, `dock/view.rs`, `app.rs`, `settings.rs`, dock-profile dialog.
|
||||
1. Handle full PaneGrid drop target: center swap; left/right/top/bottom close-and-split in requested side/order.
|
||||
2. Preserve exactly one Canvas and one instance per utility pane; prevent closing/moving away the last Canvas.
|
||||
3. Serialize actual dock tree recursively: pane type, split axis, ratio, child order, maximized/focused state.
|
||||
4. Restore defensively with dedupe, ratio clamps, unknown-pane tolerance and default fallback.
|
||||
5. Save/load/rename/delete exact profile layouts; add Painting, Vector, AI and Viewer presets.
|
||||
6. Test every panel move/resize/close/reopen/maximize/save/reload.
|
||||
|
||||
### Expandable two-column toolbox
|
||||
Files: `sidebar/mod.rs`, `app.rs`, `settings.rs`.
|
||||
1. Persist collapsed 36px one-column and expanded ~72px two-column mode.
|
||||
2. Add accessible chevron and shortcut.
|
||||
3. Short click activates last-used subtool; secondary click/long press opens subtools; outside/Escape closes.
|
||||
4. Expanded mode exposes all tools via a scrollable grouped two-column grid.
|
||||
5. Every selectable tool must execute a real action or be visibly disabled with explanation.
|
||||
6. Verify all 15 vector shapes, VectorSelect, Smart/Vision selection and retouch tools.
|
||||
7. Screenshot both modes and popups at minimum height.
|
||||
|
||||
### Cycle 1 gate
|
||||
Recheck the complete scorecard. Do not proceed until menu readability/placement, theme isolation, docking relocation/persistence and toolbox mode are each ≥9.
|
||||
|
||||
## Cycle 2: Vector Completion
|
||||
1. Test selection, deselection, overlap cycling and active-layer changes.
|
||||
2. Make handle hit areas screen-constant across zoom/pan. Render rotated geometry handles with hover/active styles and correct cursors.
|
||||
3. Test move, all eight resize handles and rotation for every shape; prevent NaN/min-size/inversion errors; add Shift aspect and Alt center constraints.
|
||||
4. Use one undo transaction per drag.
|
||||
5. Render viewport-clamped Apply, Cancel, Flip H/V and Delete controls. Snapshot once; Apply commits once; Cancel restores exact state.
|
||||
6. Centralize `sync_tool_from_shape` and `sync_shape_from_tool` so Geometry, Properties, toolbar, Tool Settings and color picker agree.
|
||||
7. Replace always-expanded Geometry selectors with pick lists/popovers; add precise numeric inputs and shape-specific controls.
|
||||
8. Validate boolean operands, failures, one undo entry and result selection for Union/Intersect/Subtract/Xor.
|
||||
9. Share geometry generation between live preview and committed shape for all 15 variants plus FreePath; preserve anti-alias density.
|
||||
10. Verify safe auto-vector-layer creation on raster/locked layers.
|
||||
11. **Recheck every vector row; none below 9.**
|
||||
|
||||
## Cycle 3: Canvas, Selection, Raster, Text, Clipboard
|
||||
1. Extract/cache actual mask edges for marching ants and fill irregular selected pixels, not bounds.
|
||||
2. Complete lasso/polygon previews, MagicWand AA/contiguous and selection modify/load/save/quick-mask.
|
||||
3. Make raster transform fully undoable with aspect lock and on-canvas Apply/Cancel.
|
||||
4. Implement real Cut/Clear and selection-aware internal/external clipboard.
|
||||
5. Add Ctrl-click eyedropper, Shift brush resize, space-pan, true fit-to-area and actual brush footprint cursor.
|
||||
6. Give Pen distinct egui behavior; prove Spray settings reach engine; implement true linear/radial gradients.
|
||||
7. Complete inline text focus/multiline/live preview/rotated caret/edit-existing/Accept/Cancel/properties/effects.
|
||||
8. Wire real SmartSelect/VisionSelect/retouch backends; fake fallbacks cannot score 9. Disable with explanation only as a temporary state.
|
||||
9. Implement exposed rulers/grid/guides/snap/pixel-grid states and rendering.
|
||||
10. **Recheck all scores; no Cycle 3 row below 9.**
|
||||
|
||||
## Cycle 4: Panels
|
||||
1. Layers: duplicate, grouping/hierarchy, reorder, fill opacity, masks/rasterize where API exists, context actions and history.
|
||||
2. Brushes: category filter, presets/editing/imports/thumbnails/per-tool isolation.
|
||||
3. Color: continuous wheel/SL drag, 40 recent wrapping/collapse/right-click-background, theme style and vector sync.
|
||||
4. Filters: audit every FilterType schema/default/range/reset/preview/apply/error.
|
||||
5. Establish one authoritative tool/property state; remove contradictory duplicates and all reachable `NoOp`.
|
||||
6. Complete Layer Styles/Details/History undo/empty/selection behavior.
|
||||
7. Complete dialog validation/focus/Enter/Escape/modal capture/responsiveness.
|
||||
8. Add panel UI tests/screenshots in every theme.
|
||||
9. **Recheck all scores; no panel row below 9.**
|
||||
|
||||
## Cycle 5: Menus, Files, Viewer, Script, AI, Remaining Features
|
||||
1. Derive menu labels and semantic dispatch from one source; implement every enabled item.
|
||||
2. Where support truly does not exist, disable with tooltip rather than presenting an enabled no-op. Retained egui features still require implementation.
|
||||
3. Complete document close/dirty prompts, recent failures, save/export format selection/errors.
|
||||
4. Complete viewer cache/navigation/fullscreen/open-edit/malformed-file behavior.
|
||||
5. Script/AI: cancellation, provider/model discovery, network errors, diagnostics, execution confirmation, history/refresh.
|
||||
6. Add real egui-supported More panels/actions (notably Plugins and Layer Styles); remove unsupported Photopea-only claims.
|
||||
7. Apply i18n keys to every visible string and persist language.
|
||||
8. Ensure menu and shortcut dispatch the same semantic command.
|
||||
9. **Recheck the complete inventory; repeat Cycle 5 for every row below 9.**
|
||||
|
||||
## Final Regression Cycle
|
||||
Run:
|
||||
- `cargo fmt --check`
|
||||
- `cargo build -p hcie-iced-gui`
|
||||
- `cargo test -p hcie-iced-gui`
|
||||
- scorecard/UI/integration tests
|
||||
- `cargo test -p hcie-engine-api --test visual_regression` for engine-visible pixels
|
||||
- `cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture` for stroke/canvas work
|
||||
|
||||
Then launch ICED, exercise every scorecard scenario, compare final screenshots to Cycle 0, prove theme composite hashes are unchanged, verify dock restart recovery, and rescore everything. **Stop only when every retained feature is ≥9/10.**
|
||||
|
||||
## Mandatory Scenarios
|
||||
- Every menu anchor, narrow/wide viewport, DPI scales, dark/light themes.
|
||||
- Six themes with active drawing and viewer active/inactive; no document mutation.
|
||||
- Every pane moved to every region, resized, closed/reopened, maximized, persisted.
|
||||
- Toolbox one/two columns, all subtools, minimum height, Escape/outside click.
|
||||
- Every vector shape create/select/move/8-resize/rotate/apply/cancel/delete/flip/properties/boolean.
|
||||
- Rect/lasso/polygon/magic/AI selection, irregular ants, transform, crop, clipboard, undo.
|
||||
- Multi-document open/switch/close/dirty, import/export failures.
|
||||
- 4K continuous stroke, pan/zoom, vector drag and theme switch without full-upload regressions.
|
||||
|
||||
## Risks
|
||||
- PaneGrid may not support floating windows/tabs directly. Required acceptance is arbitrary split relocation and exact persistence; floating windows are optional unless added without state/event duplication.
|
||||
- Some menu claims exceed egui/engine support. Honest disabled states are preferable to fake enabled actions, but egui parity features must be implemented.
|
||||
- Document engine API gaps before any locked-crate proposal; never import internal crates into GUI.
|
||||
- Re-read files before patches because the working tree has concurrent/uncommitted changes.
|
||||
|
||||
## Done Definition
|
||||
- No retained feature below 9.
|
||||
- No reachable production `NoOp` or wrong semantic alias.
|
||||
- No enabled menu item without a tested handler.
|
||||
- Panels relocate, resize and restore exactly.
|
||||
- Menus are readable and exactly anchored.
|
||||
- Theme changes cannot load/mutate images.
|
||||
- Toolbox has persisted one/two-column modes and all vector tools are connected.
|
||||
- Vector handles/editing are accurate, undoable and visually verified.
|
||||
- Protected GPU optimizations remain intact.
|
||||
Generated
+1
@@ -3054,6 +3054,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"hcie-engine-api",
|
||||
"iced",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# Vector Tool Migration — Scoring & Implementation Plan
|
||||
|
||||
## All Vector Tool Features — Current ICED Score (0-10)
|
||||
|
||||
| # | Feature | egui | ICED | Gap |
|
||||
|---|---------|------|------|-----|
|
||||
| 1 | Shape creation (15 types) via drag | 10 | 8 | Previews for 11 shapes are bounding-box fallback |
|
||||
| 2 | Auto-vector-layer creation | 10 | 10 | None |
|
||||
| 3 | VectorSelect click-to-select | 10 | 0 | **MISSING** |
|
||||
| 4 | VectorEditHandle hit-testing | 10 | 0 | **MISSING** |
|
||||
| 5 | Shape drag-move via handles | 10 | 0 | **MISSING** |
|
||||
| 6 | Shape resize via handles | 10 | 0 | **MISSING** |
|
||||
| 7 | Shape rotation via handle | 10 | 0 | **MISSING** |
|
||||
| 8 | Shape cycling (overlapping) | 10 | 0 | **MISSING** |
|
||||
| 9 | Geometry panel: shape list | 10 | 2 | Hardcoded `&[]`, no selection |
|
||||
| 10 | Geometry panel: shape selection | 10 | 0 | **MISSING** |
|
||||
| 11 | Geometry panel: X1/Y1/X2/Y2 bounds | 10 | 0 | **MISSING** |
|
||||
| 12 | Geometry panel: fill toggle | 10 | 0 | **MISSING** |
|
||||
| 13 | Geometry panel: fill color picker | 10 | 0 | **MISSING** |
|
||||
| 14 | Geometry panel: stroke color picker | 10 | 0 | **MISSING** |
|
||||
| 15 | Geometry panel: opacity slider | 10 | 0 | **MISSING** |
|
||||
| 16 | Geometry panel: hardness slider | 10 | 0 | **MISSING** |
|
||||
| 17 | Geometry panel: line cap controls | 10 | 0 | **MISSING** |
|
||||
| 18 | Geometry panel: boolean operations | 10 | 0 | **MISSING** |
|
||||
| 19 | Shape Apply/Cancel overlay | 10 | 0 | **MISSING** |
|
||||
| 20 | Shape Flip H/V | 10 | 0 | **MISSING** |
|
||||
| 21 | shape_sync bidirectional sync | 10 | 0 | **MISSING** |
|
||||
| 22 | Shape deletion | 10 | 0 | **MISSING** |
|
||||
| 23 | Accurate previews (Arrow, etc.) | 10 | 3 | 11 shapes use bounding-box fallback |
|
||||
| 24 | Tool settings panel (stroke, fill, etc.) | 10 | 7 | Missing hardness, line cap |
|
||||
| 25 | Shape-specific preview (Star, Polygon) | 10 | 6 | Only 4 shapes have accurate preview |
|
||||
|
||||
**Overall ICED Score: ~2.5/10** — Shape creation works but selection/editing is completely missing.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase A: VectorSelect Core (Features 3-8, 21-22) — Score Target: 8/10
|
||||
|
||||
**Sub-goal A1: VectorSelect state and click-to-select**
|
||||
- Add `selected_vector_shape: Option<usize>` to app state
|
||||
- Add `vector_shapes_snapshot: Option<Vec<VectorShape>>` for undo
|
||||
- Add `vector_edit_handle: VectorEditHandle` state
|
||||
- Add `vector_drag_last: Option<(f32, f32)>` tracking
|
||||
- Implement `VectorSelect` click handling in canvas
|
||||
- Call `engine.find_vector_shape_at()` for hit-testing
|
||||
- Support overlapping shape cycling
|
||||
|
||||
**Sub-goal A2: VectorEditHandle rendering on canvas**
|
||||
- Render 8 resize handles (white squares with black stroke)
|
||||
- Render rotation handle (blue circle above top-center)
|
||||
- Render selection outline (blue stroke around shape)
|
||||
- Hit-test handles on click
|
||||
|
||||
**Sub-goal A3: Shape drag/resize/rotate via handles**
|
||||
- Move: drag shape body → `engine.modify_vector_shape()`
|
||||
- Resize: drag corner/edge handles → `engine.modify_vector_shape()`
|
||||
- Rotate: drag rotation handle → `engine.set_vector_shape_angle()`
|
||||
|
||||
**Sub-goal A4: Apply/Cancel overlay**
|
||||
- Show Apply (checkmark) and Cancel (X) buttons when shape is selected
|
||||
- Apply: commit changes, clear snapshot, deselect
|
||||
- Cancel: restore snapshot, deselect
|
||||
- Flip H/V buttons for applicable shapes
|
||||
|
||||
**Sub-goal A5: Shape deletion**
|
||||
- Delete key or X button in geometry panel
|
||||
- `engine.delete_vector_shape()` or equivalent
|
||||
|
||||
---
|
||||
|
||||
### Phase B: Geometry Panel (Features 9-18) — Score Target: 8/10
|
||||
|
||||
**Sub-goal B1: Shape list with selection**
|
||||
- Render all shapes in active vector layer
|
||||
- Click to select/deselect
|
||||
- Delete button per shape
|
||||
|
||||
**Sub-goal B2: Bounds editing (X1/Y1/X2/Y2)**
|
||||
- 4 sliders for shape bounds
|
||||
- `engine.set_vector_shape_bounds()` on change
|
||||
|
||||
**Sub-goal B3: Fill toggle + fill color**
|
||||
- Checkbox for fill on/off
|
||||
- Color picker for fill color
|
||||
- `engine.set_vector_shape_fill()` and `set_vector_shape_fill_color()`
|
||||
|
||||
**Sub-goal B4: Stroke color picker**
|
||||
- Color picker for stroke color
|
||||
- `engine.set_vector_shape_color()`
|
||||
|
||||
**Sub-goal B5: Opacity and Hardness sliders**
|
||||
- Slider for opacity (0-1)
|
||||
- Slider for hardness (0-1)
|
||||
- `engine.set_vector_shape_opacity()` and `set_vector_shape_hardness()`
|
||||
|
||||
**Sub-goal B6: Line cap controls**
|
||||
- Start/End cap combo boxes (for Line shapes)
|
||||
- `engine.set_vector_shape_line_caps()`
|
||||
|
||||
**Sub-goal B7: Boolean operations**
|
||||
- Shape A/B selectors
|
||||
- Union/Intersect/Subtract/Xor buttons
|
||||
- `engine.boolean_vector_shapes()`
|
||||
|
||||
---
|
||||
|
||||
### Phase C: Shape Sync & Polish (Features 21, 23-25) — Score Target: 9/10
|
||||
|
||||
**Sub-goal C1: shape_sync bidirectional sync**
|
||||
- `sync_shape_from_tool()`: push tool state to selected shape
|
||||
- `sync_tool_from_shape()`: pull shape properties to tool state
|
||||
|
||||
**Sub-goal C2: Accurate previews for all 15 shapes**
|
||||
- Arrow, Rhombus, Cylinder, Heart, Bubble, Gear, Cross, Crescent, Bolt, Arrow4
|
||||
- FreePath preview
|
||||
|
||||
**Sub-goal C3: Tool settings panel completion**
|
||||
- Add hardness slider
|
||||
- Add line cap controls
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. **A1** → A2 → A3 → A4 → A5 (VectorSelect core)
|
||||
2. **B1** → B2 → B3 → B4 → B5 → B6 → B7 (Geometry panel)
|
||||
3. **C1** → C2 → C3 (Sync & polish)
|
||||
|
||||
## Recheck Points
|
||||
|
||||
After each phase, recheck scores:
|
||||
- After Phase A: VectorSelect features should be 8/10
|
||||
- After Phase B: Geometry panel features should be 8/10
|
||||
- After Phase C: All vector features should be 9/10
|
||||
@@ -7,6 +7,9 @@
|
||||
use hcie_engine_api::{Engine, EngineCommand};
|
||||
use serde_json::json;
|
||||
|
||||
const MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
|
||||
const MAX_INLINE_TOOL_CALLS: usize = 64;
|
||||
|
||||
/// System prompt that defines the AI assistant's capabilities and tool-calling format.
|
||||
pub const SYSTEM_PROMPT: &str = r##"You are an expert digital artist AI assistant integrated inside HCIE-Rust, a pixel-grade digital painting and image editing application.
|
||||
You can talk to the user AND draw or edit their canvas.
|
||||
@@ -203,7 +206,11 @@ pub fn parse_coords(s: &str) -> Option<(f32, f32)> {
|
||||
}
|
||||
|
||||
/// Execute a parsed JSON tool call directly on the Engine.
|
||||
pub fn execute_tool_call(engine: &mut Engine, name: &str, args: &serde_json::Value) -> Result<String, String> {
|
||||
pub fn execute_tool_call(
|
||||
engine: &mut Engine,
|
||||
name: &str,
|
||||
args: &serde_json::Value,
|
||||
) -> Result<String, String> {
|
||||
match name {
|
||||
"create_layer" | "add_layer" => {
|
||||
let layer_name = args["name"].as_str().unwrap_or("Layer");
|
||||
@@ -213,13 +220,23 @@ pub fn execute_tool_call(engine: &mut Engine, name: &str, args: &serde_json::Val
|
||||
}
|
||||
"select_layer" => {
|
||||
let layer_name = args["name"].as_str().unwrap_or("");
|
||||
if let Some(layer) = engine.layer_infos().iter().find(|l| l.name.eq_ignore_ascii_case(layer_name)) {
|
||||
if let Some(layer) = engine
|
||||
.layer_infos()
|
||||
.iter()
|
||||
.find(|l| l.name.eq_ignore_ascii_case(layer_name))
|
||||
{
|
||||
engine.set_active_layer(layer.id);
|
||||
Ok(format!("Selected layer '{}' (ID: {})", layer_name, layer.id))
|
||||
Ok(format!(
|
||||
"Selected layer '{}' (ID: {})",
|
||||
layer_name, layer.id
|
||||
))
|
||||
} else {
|
||||
let id = engine.add_layer(layer_name);
|
||||
engine.set_active_layer(id);
|
||||
Ok(format!("Layer '{}' not found. Created and selected it.", layer_name))
|
||||
Ok(format!(
|
||||
"Layer '{}' not found. Created and selected it.",
|
||||
layer_name
|
||||
))
|
||||
}
|
||||
}
|
||||
"draw_rect" => {
|
||||
@@ -236,7 +253,10 @@ pub fn execute_tool_call(engine: &mut Engine, name: &str, args: &serde_json::Val
|
||||
let rx = args["rx"].as_f64().unwrap_or(0.0) as f32;
|
||||
let ry = args["ry"].as_f64().unwrap_or(0.0) as f32;
|
||||
engine.draw_ellipse(cx, cy, rx, ry);
|
||||
Ok(format!("Drew ellipse center ({}, {}) radius ({}, {})", cx, cy, rx, ry))
|
||||
Ok(format!(
|
||||
"Drew ellipse center ({}, {}) radius ({}, {})",
|
||||
cx, cy, rx, ry
|
||||
))
|
||||
}
|
||||
"draw_line" => {
|
||||
let x0 = args["x0"].as_f64().unwrap_or(0.0) as f32;
|
||||
@@ -244,7 +264,10 @@ pub fn execute_tool_call(engine: &mut Engine, name: &str, args: &serde_json::Val
|
||||
let x1 = args["x1"].as_f64().unwrap_or(0.0) as f32;
|
||||
let y1 = args["y1"].as_f64().unwrap_or(0.0) as f32;
|
||||
engine.draw_line(x0, y0, x1, y1);
|
||||
Ok(format!("Drew line from ({}, {}) to ({}, {})", x0, y0, x1, y1))
|
||||
Ok(format!(
|
||||
"Drew line from ({}, {}) to ({}, {})",
|
||||
x0, y0, x1, y1
|
||||
))
|
||||
}
|
||||
"add_text" => {
|
||||
let text = args["text"].as_str().unwrap_or("");
|
||||
@@ -256,7 +279,12 @@ pub fn execute_tool_call(engine: &mut Engine, name: &str, args: &serde_json::Val
|
||||
let color = parse_hex_color(color_hex).unwrap_or([0, 0, 0, 255]);
|
||||
let effects = vec![];
|
||||
engine.add_text(
|
||||
text, font, size, x, y, color,
|
||||
text,
|
||||
font,
|
||||
size,
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
0.0,
|
||||
hcie_engine_api::TextAlignment::Left,
|
||||
hcie_engine_api::TextOrientation::Horizontal,
|
||||
@@ -283,7 +311,7 @@ pub fn execute_tool_call(engine: &mut Engine, name: &str, args: &serde_json::Val
|
||||
engine.draw_stroke(&points);
|
||||
Ok(format!("Drew brush stroke with {} points", points.len()))
|
||||
}
|
||||
_ => Err(format!("Unknown tool: {}", name))
|
||||
_ => Err(format!("Unknown tool: {}", name)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,10 +336,22 @@ pub fn execute_command_on_engine(engine: &mut Engine, cmd: &EngineCommand) {
|
||||
EngineCommand::DrawStroke { points } => {
|
||||
engine.draw_stroke(points);
|
||||
}
|
||||
EngineCommand::AddText { text, font, size, x, y, color } => {
|
||||
EngineCommand::AddText {
|
||||
text,
|
||||
font,
|
||||
size,
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
} => {
|
||||
let effects = vec![];
|
||||
engine.add_text(
|
||||
text, font, *size, *x, *y, *color,
|
||||
text,
|
||||
font,
|
||||
*size,
|
||||
*x,
|
||||
*y,
|
||||
*color,
|
||||
0.0,
|
||||
hcie_engine_api::TextAlignment::Left,
|
||||
hcie_engine_api::TextOrientation::Horizontal,
|
||||
@@ -354,7 +394,9 @@ pub fn parse_dsl_script(script: &str, _engine: &Engine) -> Vec<EngineCommand> {
|
||||
"select_layer" => {
|
||||
if parts.len() >= 2 {
|
||||
let name = parts[1..].join(" ");
|
||||
let id_opt = _engine.layer_infos().iter()
|
||||
let id_opt = _engine
|
||||
.layer_infos()
|
||||
.iter()
|
||||
.find(|l| l.name.eq_ignore_ascii_case(&name))
|
||||
.map(|l| l.id);
|
||||
if let Some(id) = id_opt {
|
||||
@@ -396,7 +438,12 @@ pub fn parse_dsl_script(script: &str, _engine: &Engine) -> Vec<EngineCommand> {
|
||||
} else {
|
||||
brush_size / 2.0
|
||||
};
|
||||
commands.push(EngineCommand::DrawEllipse { cx, cy, rx: r, ry: r });
|
||||
commands.push(EngineCommand::DrawEllipse {
|
||||
cx,
|
||||
cy,
|
||||
rx: r,
|
||||
ry: r,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -430,7 +477,14 @@ pub fn parse_dsl_script(script: &str, _engine: &Engine) -> Vec<EngineCommand> {
|
||||
} else {
|
||||
current_color
|
||||
};
|
||||
commands.push(EngineCommand::AddText { text, font, size, x, y, color });
|
||||
commands.push(EngineCommand::AddText {
|
||||
text,
|
||||
font,
|
||||
size,
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
});
|
||||
}
|
||||
}
|
||||
"filter" => {
|
||||
@@ -438,7 +492,8 @@ pub fn parse_dsl_script(script: &str, _engine: &Engine) -> Vec<EngineCommand> {
|
||||
let filter_id = parts[1].to_string();
|
||||
let params = if parts.len() >= 3 {
|
||||
let json_str = parts[2..].join(" ");
|
||||
serde_json::from_str::<serde_json::Value>(&json_str).unwrap_or(serde_json::Value::Null)
|
||||
serde_json::from_str::<serde_json::Value>(&json_str)
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
} else {
|
||||
serde_json::Value::Null
|
||||
};
|
||||
@@ -465,7 +520,11 @@ pub fn parse_dsl_script(script: &str, _engine: &Engine) -> Vec<EngineCommand> {
|
||||
/// Scan the entire text for ```json or ```dsl code blocks and execute them on the engine.
|
||||
pub fn execute_inline_tools(text: &str, engine: &mut Engine) -> Vec<String> {
|
||||
let mut results = Vec::new();
|
||||
if text.len() > MAX_RESPONSE_BYTES {
|
||||
return vec!["Tool payload rejected: response exceeds 4 MiB".to_string()];
|
||||
}
|
||||
let mut start = 0;
|
||||
let mut executed = 0;
|
||||
|
||||
// Parse JSON block tool calls
|
||||
while let Some(open_idx) = text[start..].find("```json") {
|
||||
@@ -478,21 +537,32 @@ pub fn execute_inline_tools(text: &str, engine: &mut Engine) -> Vec<String> {
|
||||
if val.is_array() {
|
||||
if let Some(arr) = val.as_array() {
|
||||
for item in arr {
|
||||
if executed >= MAX_INLINE_TOOL_CALLS {
|
||||
results.push(
|
||||
"Tool payload stopped at the 64-call safety limit".to_string(),
|
||||
);
|
||||
return results;
|
||||
}
|
||||
if let Some(tool_name) = item["tool"].as_str() {
|
||||
executed += 1;
|
||||
let params = &item["parameters"];
|
||||
match execute_tool_call(engine, tool_name, params) {
|
||||
Ok(msg) => results.push(format!("🔧 {}: {}", tool_name, msg)),
|
||||
Err(err) => results.push(format!("❌ Tool Error ({}): {}", tool_name, err)),
|
||||
Err(err) => results
|
||||
.push(format!("❌ Tool Error ({}): {}", tool_name, err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if val.is_object() {
|
||||
if let Some(tool_name) = val["tool"].as_str() {
|
||||
executed += 1;
|
||||
let params = &val["parameters"];
|
||||
match execute_tool_call(engine, tool_name, params) {
|
||||
Ok(msg) => results.push(format!("🔧 {}: {}", tool_name, msg)),
|
||||
Err(err) => results.push(format!("❌ Tool Error ({}): {}", tool_name, err)),
|
||||
Err(err) => {
|
||||
results.push(format!("❌ Tool Error ({}): {}", tool_name, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -547,11 +617,12 @@ pub fn build_messages_json(
|
||||
}));
|
||||
|
||||
// Conversation history
|
||||
for msg in history {
|
||||
let last_user = history.iter().rposition(|message| message.role == "user");
|
||||
for (index, msg) in history.iter().enumerate() {
|
||||
if msg.role == "user" || msg.role == "assistant" {
|
||||
messages_json.push(json!({
|
||||
"role": &msg.role,
|
||||
"content": if msg.content == final_prompt { final_prompt } else { &msg.content }
|
||||
"content": if Some(index) == last_user { final_prompt } else { &msg.content }
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -565,97 +636,174 @@ pub fn build_messages_json(
|
||||
/// and accumulates the response text. Returns the full response on success or
|
||||
/// an error message on failure. This function is designed to be used with
|
||||
/// `Task::perform` in the Iced application.
|
||||
pub async fn stream_llm_response(
|
||||
pub fn stream_llm_response(
|
||||
provider: AiProvider,
|
||||
url: String,
|
||||
model: String,
|
||||
messages: Vec<serde_json::Value>,
|
||||
) -> Result<String, String> {
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": true,
|
||||
"temperature": 0.7
|
||||
});
|
||||
|
||||
let url_endpoint = if url.ends_with("/v1/chat/completions") || url.ends_with("/api/chat") {
|
||||
url.clone()
|
||||
} else {
|
||||
format!("{}/v1/chat/completions", url.trim_end_matches('/'))
|
||||
) -> impl futures_util::Stream<Item = ChatUpdate> {
|
||||
iced::stream::channel(32, move |mut output| async move {
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
let result = async {
|
||||
let (url_endpoint, body) = match provider {
|
||||
AiProvider::Ollama => (
|
||||
format!("{}/api/chat", url.trim_end_matches('/')),
|
||||
json!({"model": model, "messages": messages, "stream": true}),
|
||||
),
|
||||
AiProvider::Claude => (
|
||||
format!("{}/v1/messages", url.trim_end_matches('/')),
|
||||
json!({"model": model, "system": SYSTEM_PROMPT, "messages": messages.into_iter().filter(|message| message["role"] != "system").collect::<Vec<_>>(), "max_tokens": 4096, "stream": true}),
|
||||
),
|
||||
AiProvider::OpenAI => (
|
||||
format!("{}/chat/completions", url.trim_end_matches('/')),
|
||||
json!({"model": model, "messages": messages, "stream": true, "temperature": 0.7}),
|
||||
),
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(15))
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
let response = client.post(&url_endpoint).json(&body).send().await
|
||||
.map_err(|e| format!("Network Failure: {}", e))?;
|
||||
|
||||
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
|
||||
let mut request = client.post(&url_endpoint).json(&body);
|
||||
match provider {
|
||||
AiProvider::Claude => {
|
||||
let key = std::env::var("ANTHROPIC_API_KEY").map_err(|_| "ANTHROPIC_API_KEY is not set".to_string())?;
|
||||
request = request.header("x-api-key", key).header("anthropic-version", "2023-06-01");
|
||||
}
|
||||
AiProvider::OpenAI => {
|
||||
let key = std::env::var("OPENAI_API_KEY").map_err(|_| "OPENAI_API_KEY is not set".to_string())?;
|
||||
request = request.bearer_auth(key);
|
||||
}
|
||||
AiProvider::Ollama => {}
|
||||
}
|
||||
let response = request.send().await.map_err(|e| format!("Network failure: {e}"))?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let err_msg = response.text().await.unwrap_or_else(|_| "Unknown HTTP error".to_string());
|
||||
return Err(format!("HTTP Error {}: {}", status, err_msg));
|
||||
let detail = response.text().await.unwrap_or_else(|_| "response body unavailable".into());
|
||||
return Err(format!("HTTP {status}: {}", detail.chars().take(1000).collect::<String>()));
|
||||
}
|
||||
|
||||
let mut full_accumulated = String::new();
|
||||
let mut stream = response.bytes_stream();
|
||||
|
||||
use futures_util::StreamExt;
|
||||
let mut buffer = String::new();
|
||||
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
match chunk_result {
|
||||
Ok(chunk) => {
|
||||
buffer.push_str(&String::from_utf8_lossy(&chunk));
|
||||
|
||||
// Process complete lines
|
||||
while let Some(newline_pos) = buffer.find('\n') {
|
||||
let line = buffer[..newline_pos].trim().to_string();
|
||||
buffer = buffer[newline_pos + 1..].to_string();
|
||||
|
||||
buffer.drain(..=newline_pos);
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let json_part = line.strip_prefix("data: ").unwrap_or(&line);
|
||||
if line.starts_with("data: ") {
|
||||
let json_part = &line[6..];
|
||||
if json_part.trim() == "[DONE]" {
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(json_part) {
|
||||
if let Some(choices) = v["choices"].as_array() {
|
||||
if !choices.is_empty() {
|
||||
let delta = &choices[0]["delta"];
|
||||
// Handle reasoning content (deep thinking models)
|
||||
if let Some(_reasoning) = delta["reasoning_content"].as_str() {
|
||||
// Reasoning chunks are logged but not accumulated in this path
|
||||
let delta = &v["choices"][0]["delta"];
|
||||
if let Some(reasoning) = delta["reasoning_content"].as_str().or_else(|| delta["reasoning"].as_str()) {
|
||||
output.send(ChatUpdate::ReasoningChunk(reasoning.to_string())).await.ok();
|
||||
}
|
||||
// Handle regular content
|
||||
if let Some(content) = delta["content"].as_str() {
|
||||
if !content.is_empty() {
|
||||
let content = delta["content"].as_str().or_else(|| v["delta"]["text"].as_str());
|
||||
if let Some(content) = content.filter(|text| !text.is_empty()) {
|
||||
full_accumulated.push_str(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
output.send(ChatUpdate::Chunk(content.to_string())).await.ok();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to Ollama native chat ND-JSON format
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) {
|
||||
if let Some(reasoning) = v["message"]["thinking"].as_str() {
|
||||
output.send(ChatUpdate::ReasoningChunk(reasoning.to_string())).await.ok();
|
||||
}
|
||||
if let Some(content) = v["message"]["content"].as_str() {
|
||||
if !content.is_empty() {
|
||||
full_accumulated.push_str(content);
|
||||
output.send(ChatUpdate::Chunk(content.to_string())).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if full_accumulated.len() > MAX_RESPONSE_BYTES {
|
||||
return Err("AI response exceeded the 4 MiB safety limit".to_string());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("Stream read error: {}", e));
|
||||
}
|
||||
Err(e) => return Err(format!("Stream read error: {e}")),
|
||||
}
|
||||
}
|
||||
Ok(full_accumulated)
|
||||
}.await;
|
||||
output.send(ChatUpdate::Finished(result)).await.ok();
|
||||
})
|
||||
}
|
||||
|
||||
Ok(full_accumulated)
|
||||
/// Format every visible message as a complete plain-text transcript.
|
||||
pub fn transcript_text(messages: &[ChatMessage]) -> String {
|
||||
messages
|
||||
.iter()
|
||||
.map(|message| {
|
||||
let role = if message.is_reasoning {
|
||||
"Reasoning"
|
||||
} else {
|
||||
message.role.as_str()
|
||||
};
|
||||
format!("{}:\n{}", role, message.content)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn canvas_context_replaces_only_latest_user_payload() {
|
||||
let history = vec![
|
||||
ChatMessage {
|
||||
role: "user".into(),
|
||||
content: "old".into(),
|
||||
is_reasoning: false,
|
||||
},
|
||||
ChatMessage {
|
||||
role: "assistant".into(),
|
||||
content: "reply".into(),
|
||||
is_reasoning: false,
|
||||
},
|
||||
ChatMessage {
|
||||
role: "user".into(),
|
||||
content: "new".into(),
|
||||
is_reasoning: false,
|
||||
},
|
||||
];
|
||||
let payload = build_messages_json(&history, "new\n[canvas]", "system");
|
||||
assert_eq!(payload[1]["content"], "old");
|
||||
assert_eq!(payload[3]["content"], "new\n[canvas]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_includes_reasoning_and_tool_results() {
|
||||
let messages = vec![
|
||||
ChatMessage {
|
||||
role: "assistant".into(),
|
||||
content: "answer".into(),
|
||||
is_reasoning: false,
|
||||
},
|
||||
ChatMessage {
|
||||
role: "assistant".into(),
|
||||
content: "thought".into(),
|
||||
is_reasoning: true,
|
||||
},
|
||||
ChatMessage {
|
||||
role: "tool".into(),
|
||||
content: "result".into(),
|
||||
is_reasoning: false,
|
||||
},
|
||||
];
|
||||
let transcript = transcript_text(&messages);
|
||||
assert!(transcript.contains("assistant:\nanswer"));
|
||||
assert!(transcript.contains("Reasoning:\nthought"));
|
||||
assert!(transcript.contains("tool:\nresult"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ Output ONLY valid HCIE-Script DSL commands with no explanation, no markdown, no
|
||||
|
||||
Available commands:
|
||||
- layer Name — create raster layer
|
||||
- vector_layer Name — create vector layer
|
||||
- select_layer Name
|
||||
- brush STYLE — round, square, pencil, inkpen, charcoal, watercolor, marker, oil, airbrush, spray, clouds, dirt, tree, meadow, rock, bristle, leaf, wetpaint, sketch, hatch, calligraphy, blender, mixer, glow, crayon
|
||||
- color #RRGGBB — set foreground color
|
||||
@@ -82,7 +81,10 @@ pub async fn generate_via_ollama(provider: &Provider, prompt: &str) -> Result<St
|
||||
return Err(format!("API error: {}", resp.status()));
|
||||
}
|
||||
|
||||
let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse error: {}", e))?;
|
||||
let json: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Parse error: {}", e))?;
|
||||
let content = json["message"]["content"]
|
||||
.as_str()
|
||||
.ok_or_else(|| "No content in response".to_string())?;
|
||||
@@ -110,8 +112,12 @@ pub async fn generate_via_claude(provider: &Provider, prompt: &str) -> Result<St
|
||||
.build()
|
||||
.map_err(|e| format!("Client error: {}", e))?;
|
||||
|
||||
let api_key = std::env::var("ANTHROPIC_API_KEY")
|
||||
.map_err(|_| "ANTHROPIC_API_KEY is not set".to_string())?;
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("x-api-key", api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
@@ -123,7 +129,10 @@ pub async fn generate_via_claude(provider: &Provider, prompt: &str) -> Result<St
|
||||
return Err(format!("API error {}: {}", status, text));
|
||||
}
|
||||
|
||||
let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse error: {}", e))?;
|
||||
let json: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Parse error: {}", e))?;
|
||||
let content = json["content"][0]["text"]
|
||||
.as_str()
|
||||
.ok_or_else(|| "No content in response".to_string())?;
|
||||
@@ -136,7 +145,10 @@ pub async fn generate_via_claude(provider: &Provider, prompt: &str) -> Result<St
|
||||
/// Uses the standard Chat Completions format with the system prompt as the
|
||||
/// first message. Returns the cleaned DSL script text.
|
||||
pub async fn generate_via_openai(provider: &Provider, prompt: &str) -> Result<String, String> {
|
||||
let url = format!("{}/v1/chat/completions", provider.base_url.trim_end_matches('/'));
|
||||
let url = format!(
|
||||
"{}/chat/completions",
|
||||
provider.base_url.trim_end_matches('/')
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"model": provider.model,
|
||||
"messages": [
|
||||
@@ -151,8 +163,11 @@ pub async fn generate_via_openai(provider: &Provider, prompt: &str) -> Result<St
|
||||
.build()
|
||||
.map_err(|e| format!("Client error: {}", e))?;
|
||||
|
||||
let api_key =
|
||||
std::env::var("OPENAI_API_KEY").map_err(|_| "OPENAI_API_KEY is not set".to_string())?;
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(api_key)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
@@ -164,7 +179,10 @@ pub async fn generate_via_openai(provider: &Provider, prompt: &str) -> Result<St
|
||||
return Err(format!("API error {}: {}", status, text));
|
||||
}
|
||||
|
||||
let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse error: {}", e))?;
|
||||
let json: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Parse error: {}", e))?;
|
||||
let content = json["choices"][0]["message"]["content"]
|
||||
.as_str()
|
||||
.ok_or_else(|| "No content in response".to_string())?;
|
||||
@@ -186,14 +204,49 @@ pub fn clean_script_output(text: &str) -> String {
|
||||
text.trim().to_string()
|
||||
}
|
||||
|
||||
/// Validate a generated payload before exposing Run as an executable action.
|
||||
pub fn validate_script_output(text: &str) -> Result<String, String> {
|
||||
let cleaned = clean_script_output(text);
|
||||
if cleaned.is_empty() {
|
||||
return Err("Provider returned an empty script".to_string());
|
||||
}
|
||||
if cleaned.len() > 512 * 1024 {
|
||||
return Err("Generated script exceeds the 512 KiB safety limit".to_string());
|
||||
}
|
||||
crate::script::parser::parse_dsl_script(&cleaned)
|
||||
.map_err(|error| format!("Generated script is invalid: {error}"))?;
|
||||
Ok(cleaned)
|
||||
}
|
||||
|
||||
/// Generate a script using the given provider type and configuration (async).
|
||||
///
|
||||
/// Dispatches to the appropriate provider function based on the `provider_type`
|
||||
/// string ("ollama", "claude", or "openai"). Defaults to Ollama.
|
||||
pub async fn generate_script(provider_type: &str, provider: &Provider, prompt: &str) -> Result<String, String> {
|
||||
pub async fn generate_script(
|
||||
provider_type: &str,
|
||||
provider: &Provider,
|
||||
prompt: &str,
|
||||
) -> Result<String, String> {
|
||||
match provider_type {
|
||||
"claude" => generate_via_claude(provider, prompt).await,
|
||||
"openai" => generate_via_openai(provider, prompt).await,
|
||||
_ => generate_via_ollama(provider, prompt).await,
|
||||
"ollama" => generate_via_ollama(provider, prompt).await,
|
||||
unsupported => Err(format!("Unsupported AI provider: {unsupported}")),
|
||||
}
|
||||
.and_then(|output| validate_script_output(&output))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::validate_script_output;
|
||||
|
||||
#[test]
|
||||
fn generated_output_must_parse_before_run_is_enabled() {
|
||||
assert!(
|
||||
validate_script_output("```dsl\nlayer Sky\ncolor #112233\nrect 0,0 10,10\n```").is_ok()
|
||||
);
|
||||
assert!(validate_script_output("paint magically")
|
||||
.unwrap_err()
|
||||
.contains("invalid"));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -265,7 +265,8 @@ fn harvest_brs2_sampled_brushes(data: &[u8], base_pos: usize) -> Vec<BrushPreset
|
||||
} else {
|
||||
(w, h)
|
||||
};
|
||||
if let Some(decoded) = try_decode_and_scale(slice, w as usize, h as usize, final_w, final_h) {
|
||||
if let Some(decoded) = try_decode_and_scale(slice, w as usize, h as usize, final_w, final_h)
|
||||
{
|
||||
presets.push(BrushPreset {
|
||||
id: format!("abr_brs2_{}_{}x{}", base_pos + i, final_w, final_h),
|
||||
name: format!("Brush {}x{}", final_w, final_h),
|
||||
|
||||
@@ -1,30 +1,46 @@
|
||||
//! Cached extraction of exact selection-mask edges.
|
||||
//!
|
||||
//! **Purpose:** Avoids rescanning a full selection mask every animation frame. **Logic & Workflow:**
|
||||
//! A content fingerprint, dimensions, and length identify the current mask; changed content is
|
||||
//! rescanned into pixel-boundary line segments. **Side Effects / Dependencies:** Allocates only
|
||||
//! when the mask changes and shares the result through `Arc`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SelectionEdgeCache {
|
||||
mask_ptr: usize,
|
||||
fingerprint: u64,
|
||||
mask_len: usize,
|
||||
edges: Arc<Vec<(u32, u32, u32, u32)>>,
|
||||
}
|
||||
|
||||
impl SelectionEdgeCache {
|
||||
/// Returns cached edges only when mask content and dimensions still match.
|
||||
pub fn get(&self, mask: &[u8]) -> Option<Arc<Vec<(u32, u32, u32, u32)>>> {
|
||||
if mask.as_ptr() as usize == self.mask_ptr && mask.len() == self.mask_len {
|
||||
if mask_fingerprint(mask) == self.fingerprint && mask.len() == self.mask_len {
|
||||
Some(Arc::clone(&self.edges))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuilds and stores exact pixel-boundary edges for `mask`.
|
||||
pub fn rebuild(&mut self, mask: &[u8], w: u32, h: u32) -> Arc<Vec<(u32, u32, u32, u32)>> {
|
||||
let edges = Arc::new(extract_selection_edges(mask, w, h));
|
||||
self.mask_ptr = mask.as_ptr() as usize;
|
||||
self.fingerprint = mask_fingerprint(mask);
|
||||
self.mask_len = mask.len();
|
||||
self.edges = Arc::clone(&edges);
|
||||
edges
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes a fast deterministic fingerprint that detects in-place mask mutations.
|
||||
fn mask_fingerprint(mask: &[u8]) -> u64 {
|
||||
mask.iter().fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
|
||||
(hash ^ u64::from(*byte)).wrapping_mul(0x1000_0000_01b3)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn extract_selection_edges(
|
||||
selection_mask: &[u8],
|
||||
canvas_width: u32,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,11 +17,11 @@
|
||||
//!
|
||||
|
||||
use crate::app::Message;
|
||||
use iced::widget::shader;
|
||||
use iced::mouse;
|
||||
use iced::{Rectangle, Point, Vector};
|
||||
use iced::advanced::Shell;
|
||||
use iced::mouse;
|
||||
use iced::widget::shader;
|
||||
use iced::widget::shader::wgpu::util::DeviceExt;
|
||||
use iced::{Point, Rectangle, Vector};
|
||||
|
||||
use hcie_engine_api::{ZOOM_MAX, ZOOM_MIN};
|
||||
|
||||
@@ -88,13 +88,31 @@ const VERTEX_LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayou
|
||||
/// Position [0,1] × [0,1], UV [0,1] × [0,1].
|
||||
const QUAD_VERTICES: [CanvasVertex; 6] = [
|
||||
// Triangle 1: top-left, bottom-left, bottom-right
|
||||
CanvasVertex { position: [0.0, 0.0], uv: [0.0, 0.0] },
|
||||
CanvasVertex { position: [0.0, 1.0], uv: [0.0, 1.0] },
|
||||
CanvasVertex { position: [1.0, 1.0], uv: [1.0, 1.0] },
|
||||
CanvasVertex {
|
||||
position: [0.0, 0.0],
|
||||
uv: [0.0, 0.0],
|
||||
},
|
||||
CanvasVertex {
|
||||
position: [0.0, 1.0],
|
||||
uv: [0.0, 1.0],
|
||||
},
|
||||
CanvasVertex {
|
||||
position: [1.0, 1.0],
|
||||
uv: [1.0, 1.0],
|
||||
},
|
||||
// Triangle 2: top-left, bottom-right, top-right
|
||||
CanvasVertex { position: [0.0, 0.0], uv: [0.0, 0.0] },
|
||||
CanvasVertex { position: [1.0, 1.0], uv: [1.0, 1.0] },
|
||||
CanvasVertex { position: [1.0, 0.0], uv: [1.0, 0.0] },
|
||||
CanvasVertex {
|
||||
position: [0.0, 0.0],
|
||||
uv: [0.0, 0.0],
|
||||
},
|
||||
CanvasVertex {
|
||||
position: [1.0, 1.0],
|
||||
uv: [1.0, 1.0],
|
||||
},
|
||||
CanvasVertex {
|
||||
position: [1.0, 0.0],
|
||||
uv: [1.0, 0.0],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── wgpu re-export (from iced's shader module) ──────────────────────────────
|
||||
@@ -153,7 +171,9 @@ impl CanvasShaderPipeline {
|
||||
) -> Self {
|
||||
log::debug!(
|
||||
"[CanvasShaderPipeline::new] Creating pipeline for {}×{} canvas, format={:?}",
|
||||
canvas_w, canvas_h, format
|
||||
canvas_w,
|
||||
canvas_h,
|
||||
format
|
||||
);
|
||||
|
||||
// ── Texture ──────────────────────────────────────────────────────
|
||||
@@ -175,7 +195,8 @@ impl CanvasShaderPipeline {
|
||||
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
|
||||
// Upload initial pixel data
|
||||
if !initial_pixels.is_empty() && initial_pixels.len() == (canvas_w * canvas_h * 4) as usize {
|
||||
if !initial_pixels.is_empty() && initial_pixels.len() == (canvas_w * canvas_h * 4) as usize
|
||||
{
|
||||
queue.write_texture(
|
||||
wgpu::ImageCopyTexture {
|
||||
texture: &texture,
|
||||
@@ -197,7 +218,9 @@ impl CanvasShaderPipeline {
|
||||
);
|
||||
log::debug!(
|
||||
"[CanvasShaderPipeline::new] Uploaded initial {}×{} texture ({} bytes)",
|
||||
canvas_w, canvas_h, initial_pixels.len()
|
||||
canvas_w,
|
||||
canvas_h,
|
||||
initial_pixels.len()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -301,7 +324,9 @@ impl CanvasShaderPipeline {
|
||||
// ── Shader module ────────────────────────────────────────────────
|
||||
let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("hcie-canvas-shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!("canvas.wgsl"))),
|
||||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
|
||||
"canvas.wgsl"
|
||||
))),
|
||||
});
|
||||
|
||||
// ── Pipeline layout ─────────────────────────────────────────────
|
||||
@@ -345,7 +370,8 @@ impl CanvasShaderPipeline {
|
||||
|
||||
log::info!(
|
||||
"[CanvasShaderPipeline::new] Pipeline created successfully for {}×{} canvas",
|
||||
canvas_w, canvas_h
|
||||
canvas_w,
|
||||
canvas_h
|
||||
);
|
||||
|
||||
Self {
|
||||
@@ -380,7 +406,10 @@ impl CanvasShaderPipeline {
|
||||
) {
|
||||
log::info!(
|
||||
"[CanvasShaderPipeline::resize_texture] {}×{} → {}×{}",
|
||||
self.texture_w, self.texture_h, canvas_w, canvas_h
|
||||
self.texture_w,
|
||||
self.texture_h,
|
||||
canvas_w,
|
||||
canvas_h
|
||||
);
|
||||
|
||||
self.texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
@@ -398,7 +427,9 @@ impl CanvasShaderPipeline {
|
||||
view_formats: &[],
|
||||
});
|
||||
|
||||
self.texture_view = self.texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
self.texture_view = self
|
||||
.texture
|
||||
.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
|
||||
// Upload full pixel data
|
||||
if pixels.len() == (canvas_w * canvas_h * 4) as usize {
|
||||
@@ -583,15 +614,19 @@ impl shader::Primitive for CanvasShaderPrimitive {
|
||||
// ── Resize texture if canvas dimensions changed ──────────────────
|
||||
if pipeline.texture_w != self.canvas_w || pipeline.texture_h != self.canvas_h {
|
||||
pipeline.resize_texture(
|
||||
device, queue,
|
||||
self.canvas_w, self.canvas_h,
|
||||
device,
|
||||
queue,
|
||||
self.canvas_w,
|
||||
self.canvas_h,
|
||||
&self.composite_pixels,
|
||||
);
|
||||
} else if self.full_upload {
|
||||
// Full upload requested (e.g., after loading a file)
|
||||
pipeline.resize_texture(
|
||||
device, queue,
|
||||
self.canvas_w, self.canvas_h,
|
||||
device,
|
||||
queue,
|
||||
self.canvas_w,
|
||||
self.canvas_h,
|
||||
&self.composite_pixels,
|
||||
);
|
||||
} else if let Some(region) = self.dirty_region {
|
||||
@@ -612,8 +647,14 @@ impl shader::Primitive for CanvasShaderPrimitive {
|
||||
let raw_y = (viewport_h - display_h) / 2.0 + self.pan_offset.y;
|
||||
let min_vis_w = display_w * 0.25;
|
||||
let min_vis_h = display_h * 0.25;
|
||||
let origin_x = raw_x.clamp(-(display_w - min_vis_w).max(0.0), (viewport_w - min_vis_w).max(0.0));
|
||||
let origin_y = raw_y.clamp(-(display_h - min_vis_h).max(0.0), (viewport_h - min_vis_h).max(0.0));
|
||||
let origin_x = raw_x.clamp(
|
||||
-(display_w - min_vis_w).max(0.0),
|
||||
(viewport_w - min_vis_w).max(0.0),
|
||||
);
|
||||
let origin_y = raw_y.clamp(
|
||||
-(display_h - min_vis_h).max(0.0),
|
||||
(viewport_h - min_vis_h).max(0.0),
|
||||
);
|
||||
|
||||
// Convert to NDC: viewport [0, viewport_w] → NDC [-1, 1]
|
||||
// The quad vertices are in [0,1], vertex shader does:
|
||||
@@ -645,8 +686,10 @@ impl shader::Primitive for CanvasShaderPrimitive {
|
||||
log::trace!(
|
||||
"[CanvasShaderPrimitive::prepare] {:.2}ms | viewport={}×{} | canvas={}×{} | zoom={:.2}",
|
||||
elapsed.as_secs_f64() * 1000.0,
|
||||
viewport_w as u32, viewport_h as u32,
|
||||
self.canvas_w, self.canvas_h,
|
||||
viewport_w as u32,
|
||||
viewport_h as u32,
|
||||
self.canvas_w,
|
||||
self.canvas_h,
|
||||
self.zoom
|
||||
);
|
||||
}
|
||||
@@ -723,6 +766,8 @@ pub struct CanvasShaderState {
|
||||
pub left_pressed: bool,
|
||||
/// Track middle button state.
|
||||
pub middle_pressed: bool,
|
||||
/// Distinguishes Space+left temporary pan from a physical middle-button pan.
|
||||
pub space_left_pan: bool,
|
||||
}
|
||||
|
||||
/// Canvas shader program — implements `iced::widget::shader::Program`.
|
||||
@@ -745,6 +790,8 @@ pub struct CanvasShaderProgram {
|
||||
pub dirty_region: Option<[u32; 4]>,
|
||||
/// Whether a full texture upload is needed (first frame, resize, file load).
|
||||
pub full_upload: bool,
|
||||
/// Whether Space temporarily changes left-drag into panning.
|
||||
pub space_pan: bool,
|
||||
}
|
||||
|
||||
/// Convert a viewport-local point to canvas-space coordinates.
|
||||
@@ -767,14 +814,28 @@ fn screen_to_canvas_local(
|
||||
let raw_y = (viewport_h - display_h) / 2.0 + pan_offset.y;
|
||||
let min_vis_w = display_w * 0.25;
|
||||
let min_vis_h = display_h * 0.25;
|
||||
let origin_x = raw_x.clamp(-(display_w - min_vis_w).max(0.0), (viewport_w - min_vis_w).max(0.0));
|
||||
let origin_y = raw_y.clamp(-(display_h - min_vis_h).max(0.0), (viewport_h - min_vis_h).max(0.0));
|
||||
let origin_x = raw_x.clamp(
|
||||
-(display_w - min_vis_w).max(0.0),
|
||||
(viewport_w - min_vis_w).max(0.0),
|
||||
);
|
||||
let origin_y = raw_y.clamp(
|
||||
-(display_h - min_vis_h).max(0.0),
|
||||
(viewport_h - min_vis_h).max(0.0),
|
||||
);
|
||||
|
||||
let canvas_x = (local_pos.x - origin_x) / zoom;
|
||||
let canvas_y = (local_pos.y - origin_y) / zoom;
|
||||
|
||||
let cx = if canvas_x >= 0.0 && canvas_x < engine_w { Some(canvas_x) } else { None };
|
||||
let cy = if canvas_y >= 0.0 && canvas_y < engine_h { Some(canvas_y) } else { None };
|
||||
let cx = if canvas_x >= 0.0 && canvas_x < engine_w {
|
||||
Some(canvas_x)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let cy = if canvas_y >= 0.0 && canvas_y < engine_h {
|
||||
Some(canvas_y)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(cx, cy)
|
||||
}
|
||||
|
||||
@@ -798,10 +859,7 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
shader::Event::Mouse(mouse_event) => {
|
||||
match mouse_event {
|
||||
mouse::Event::CursorMoved { position } => {
|
||||
let local_pos = Point::new(
|
||||
position.x - bounds.x,
|
||||
position.y - bounds.y,
|
||||
);
|
||||
let local_pos = Point::new(position.x - bounds.x, position.y - bounds.y);
|
||||
state.cursor_pos = Some(local_pos);
|
||||
state.is_hovered = bounds.contains(position);
|
||||
|
||||
@@ -820,7 +878,10 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
// Left mouse drag (drawing)
|
||||
if state.left_pressed {
|
||||
if let (Some(cx), Some(cy)) = (canvas_x, canvas_y) {
|
||||
return (iced::event::Status::Captured, Some(Message::CanvasPointerMoved { x: cx, y: cy }));
|
||||
return (
|
||||
iced::event::Status::Captured,
|
||||
Some(Message::CanvasPointerMoved { x: cx, y: cy }),
|
||||
);
|
||||
}
|
||||
return (iced::event::Status::Captured, Some(pane_size_msg));
|
||||
}
|
||||
@@ -854,25 +915,39 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
(bounds.height - min_vis_h).max(0.0),
|
||||
) - default_oy;
|
||||
|
||||
return (iced::event::Status::Captured, Some(Message::CanvasPanZoom {
|
||||
return (
|
||||
iced::event::Status::Captured,
|
||||
Some(Message::CanvasPanZoom {
|
||||
zoom: self.zoom,
|
||||
pan_offset: new_pan,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Cursor over canvas → status bar coords
|
||||
if let (Some(cx), Some(cy)) = (canvas_x, canvas_y) {
|
||||
return (iced::event::Status::Captured, Some(Message::CanvasCursorPos {
|
||||
return (
|
||||
iced::event::Status::Captured,
|
||||
Some(Message::CanvasCursorPos {
|
||||
x: cx as u32,
|
||||
y: cy as u32,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return (iced::event::Status::Captured, Some(pane_size_msg));
|
||||
}
|
||||
|
||||
mouse::Event::ButtonPressed(button) => {
|
||||
if button == mouse::Button::Left && bounds.contains(cursor.position().unwrap_or(Point::ORIGIN)) {
|
||||
if button == mouse::Button::Left
|
||||
&& bounds.contains(cursor.position().unwrap_or(Point::ORIGIN))
|
||||
{
|
||||
if self.space_pan {
|
||||
state.middle_pressed = true;
|
||||
state.space_left_pan = true;
|
||||
state.pan_start = cursor.position();
|
||||
return (iced::event::Status::Captured, None);
|
||||
}
|
||||
let local_pos = {
|
||||
let p = cursor.position().unwrap_or(Point::ORIGIN);
|
||||
Point::new(p.x - bounds.x, p.y - bounds.y)
|
||||
@@ -888,10 +963,10 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
);
|
||||
if let (Some(cx), Some(cy)) = (cx_opt, cy_opt) {
|
||||
state.left_pressed = true;
|
||||
return (iced::event::Status::Captured, Some(Message::CanvasPointerPressed {
|
||||
x: cx,
|
||||
y: cy,
|
||||
}));
|
||||
return (
|
||||
iced::event::Status::Captured,
|
||||
Some(Message::CanvasPointerPressed { x: cx, y: cy }),
|
||||
);
|
||||
}
|
||||
} else if button == mouse::Button::Middle {
|
||||
if let Some(pos) = cursor.position() {
|
||||
@@ -904,15 +979,24 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
if bounds.contains(pos) {
|
||||
let local_pos = Point::new(pos.x - bounds.x, pos.y - bounds.y);
|
||||
let (cx_opt, cy_opt) = screen_to_canvas_local(
|
||||
local_pos, bounds.width, bounds.height, self.engine_w as f32, self.engine_h as f32, self.zoom, self.pan_offset
|
||||
local_pos,
|
||||
bounds.width,
|
||||
bounds.height,
|
||||
self.engine_w as f32,
|
||||
self.engine_h as f32,
|
||||
self.zoom,
|
||||
self.pan_offset,
|
||||
);
|
||||
if let (Some(cx), Some(cy)) = (cx_opt, cy_opt) {
|
||||
return (iced::event::Status::Captured, Some(Message::CanvasPointerRightClicked {
|
||||
return (
|
||||
iced::event::Status::Captured,
|
||||
Some(Message::CanvasPointerRightClicked {
|
||||
x: cx,
|
||||
y: cy,
|
||||
screen_x: pos.x,
|
||||
screen_y: pos.y,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -921,8 +1005,17 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
|
||||
mouse::Event::ButtonReleased(button) => {
|
||||
if button == mouse::Button::Left {
|
||||
if state.space_left_pan {
|
||||
state.middle_pressed = false;
|
||||
state.space_left_pan = false;
|
||||
state.pan_start = None;
|
||||
return (iced::event::Status::Captured, None);
|
||||
}
|
||||
state.left_pressed = false;
|
||||
return (iced::event::Status::Captured, Some(Message::CanvasPointerReleased));
|
||||
return (
|
||||
iced::event::Status::Captured,
|
||||
Some(Message::CanvasPointerReleased),
|
||||
);
|
||||
} else if button == mouse::Button::Middle {
|
||||
state.middle_pressed = false;
|
||||
state.pan_start = None;
|
||||
@@ -938,10 +1031,8 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
};
|
||||
if scroll_y != 0.0 {
|
||||
let abs_cursor = cursor.position().unwrap_or(Point::ORIGIN);
|
||||
let local_cursor = Point::new(
|
||||
abs_cursor.x - bounds.x,
|
||||
abs_cursor.y - bounds.y,
|
||||
);
|
||||
let local_cursor =
|
||||
Point::new(abs_cursor.x - bounds.x, abs_cursor.y - bounds.y);
|
||||
|
||||
let old_zoom = self.zoom;
|
||||
let factor = if scroll_y > 0.0 { 1.1 } else { 1.0 / 1.1 };
|
||||
@@ -952,8 +1043,10 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
|
||||
let old_display_w = engine_w * old_zoom;
|
||||
let old_display_h = engine_h * old_zoom;
|
||||
let old_origin_x = (bounds.width - old_display_w) / 2.0 + self.pan_offset.x;
|
||||
let old_origin_y = (bounds.height - old_display_h) / 2.0 + self.pan_offset.y;
|
||||
let old_origin_x =
|
||||
(bounds.width - old_display_w) / 2.0 + self.pan_offset.x;
|
||||
let old_origin_y =
|
||||
(bounds.height - old_display_h) / 2.0 + self.pan_offset.y;
|
||||
|
||||
let canvas_x = (local_cursor.x - old_origin_x) / old_zoom;
|
||||
let canvas_y = (local_cursor.y - old_origin_y) / old_zoom;
|
||||
@@ -983,10 +1076,13 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
(bounds.height - new_min_vis_h).max(0.0),
|
||||
) - new_default_oy;
|
||||
|
||||
return (iced::event::Status::Captured, Some(Message::CanvasPanZoom {
|
||||
return (
|
||||
iced::event::Status::Captured,
|
||||
Some(Message::CanvasPanZoom {
|
||||
zoom: new_zoom,
|
||||
pan_offset: new_pan,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Command-line parsing for startup files and automated GUI screenshots.
|
||||
//!
|
||||
//! **Purpose:** Converts process arguments into a validated launch configuration without
|
||||
//! terminating the process, allowing both the binary and integration tests to use one parser.
|
||||
//! **Logic & Workflow:** Options are consumed left-to-right, screenshot options consume their
|
||||
//! required operands, `--` ends option parsing, and at most one startup file is accepted.
|
||||
//! **Side Effects / Dependencies:** This module performs no I/O and does not inspect the paths.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Describes an automated screenshot requested by the command line.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ScreenshotRequest {
|
||||
/// Optional exact dock-pane label. `None` captures the complete viewport.
|
||||
pub panel: Option<String>,
|
||||
/// PNG output path supplied by the caller.
|
||||
pub output: PathBuf,
|
||||
}
|
||||
|
||||
/// Validated application launch arguments.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct CliArgs {
|
||||
/// Optional image or project to open after application initialization.
|
||||
pub load_path: Option<PathBuf>,
|
||||
/// Optional automated screenshot operation.
|
||||
pub screenshot: Option<ScreenshotRequest>,
|
||||
/// Whether usage text should be printed instead of launching the GUI.
|
||||
pub help: bool,
|
||||
}
|
||||
|
||||
/// Parses command-line arguments after the executable name.
|
||||
///
|
||||
/// **Arguments:** `args` yields UTF-8 command-line tokens in their original order.
|
||||
/// **Returns:** A validated [`CliArgs`] value, or a user-facing error for malformed input.
|
||||
/// **Logic & Workflow:** Recognized options are parsed before positional files; `--` permits a
|
||||
/// path beginning with a dash. Duplicate screenshot operations and startup files are rejected.
|
||||
/// **Side Effects / Dependencies:** None.
|
||||
pub fn parse_args<I, S>(args: I) -> Result<CliArgs, String>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
let tokens: Vec<String> = args.into_iter().map(Into::into).collect();
|
||||
let mut parsed = CliArgs::default();
|
||||
let mut positional_only = false;
|
||||
let mut index = 0;
|
||||
|
||||
while index < tokens.len() {
|
||||
let token = &tokens[index];
|
||||
if !positional_only {
|
||||
match token.as_str() {
|
||||
"--" => {
|
||||
positional_only = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
parsed.help = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
"--screenshot" => {
|
||||
ensure_no_screenshot(&parsed)?;
|
||||
let output = required_operand(&tokens, index + 1, "--screenshot")?;
|
||||
parsed.screenshot = Some(ScreenshotRequest {
|
||||
panel: None,
|
||||
output: PathBuf::from(output),
|
||||
});
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
"--screenshot-panel" => {
|
||||
ensure_no_screenshot(&parsed)?;
|
||||
let panel = required_operand(&tokens, index + 1, "--screenshot-panel")?;
|
||||
let output = required_operand(&tokens, index + 2, "--screenshot-panel")?;
|
||||
parsed.screenshot = Some(ScreenshotRequest {
|
||||
panel: Some(panel.to_owned()),
|
||||
output: PathBuf::from(output),
|
||||
});
|
||||
index += 3;
|
||||
continue;
|
||||
}
|
||||
_ if token.starts_with('-') => {
|
||||
return Err(format!("unknown argument: {token}"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if parsed.load_path.replace(PathBuf::from(token)).is_some() {
|
||||
return Err("only one startup file may be specified".to_string());
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
/// Returns an option operand or a descriptive missing-value error.
|
||||
fn required_operand<'a>(
|
||||
tokens: &'a [String],
|
||||
index: usize,
|
||||
option: &str,
|
||||
) -> Result<&'a str, String> {
|
||||
tokens
|
||||
.get(index)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(String::as_str)
|
||||
.ok_or_else(|| format!("{option} requires more arguments"))
|
||||
}
|
||||
|
||||
/// Rejects multiple screenshot operations in one launch.
|
||||
fn ensure_no_screenshot(parsed: &CliArgs) -> Result<(), String> {
|
||||
if parsed.screenshot.is_some() {
|
||||
Err("only one screenshot operation may be specified".to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the command-line help shown by the binary.
|
||||
pub fn help_text() -> &'static str {
|
||||
"Usage: hcie-iced [OPTIONS] [FILE]\n\nHCIE - Iced Edition\n\nOptions:\n -h, --help Print this help message\n --screenshot OUTPUT Capture the full viewport as PNG\n --screenshot-panel PANEL OUTPUT\n Capture a named panel as PNG\n\nArguments:\n FILE Image file to open on startup"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_args, ScreenshotRequest};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn parses_full_screenshot_and_startup_file() {
|
||||
let args = parse_args(["--screenshot", "full.png", "drawing.hcie"]).unwrap();
|
||||
assert_eq!(args.load_path, Some(PathBuf::from("drawing.hcie")));
|
||||
assert_eq!(
|
||||
args.screenshot,
|
||||
Some(ScreenshotRequest {
|
||||
panel: None,
|
||||
output: PathBuf::from("full.png"),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_named_panel_screenshot() {
|
||||
let args = parse_args(["--screenshot-panel", "Geometry", "geometry.png"]).unwrap();
|
||||
assert_eq!(args.screenshot.unwrap().panel.as_deref(), Some("Geometry"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_positional_files() {
|
||||
assert!(parse_args(["one.png", "two.png"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_dash_prefixed_file_after_terminator() {
|
||||
let args = parse_args(["--", "-drawing.png"]).unwrap();
|
||||
assert_eq!(args.load_path, Some(PathBuf::from("-drawing.png")));
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,28 @@
|
||||
//! - HSL sliders
|
||||
//! - 10x10 palette grid
|
||||
//! - Recent colors
|
||||
//! - HSV color picker popup for geometry and properties panels
|
||||
//! - Shared targeted popup selector for vector and layer-style colors
|
||||
|
||||
use crate::app::Message;
|
||||
use iced::widget::{button, column, container, horizontal_rule, row, slider, text, text_input};
|
||||
use iced::{Element, Length, Padding};
|
||||
use crate::theme::ThemeColors;
|
||||
use iced::mouse;
|
||||
use iced::widget::canvas::{self, Canvas, Frame, Path, Stroke};
|
||||
use iced::widget::{
|
||||
button, column, container, horizontal_rule, row, scrollable, slider, text, text_input,
|
||||
};
|
||||
use iced::{Element, Length, Padding, Point, Rectangle, Size};
|
||||
|
||||
/// Identifies the application value edited by the shared color selector.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ColorPickerTarget {
|
||||
Foreground,
|
||||
Background,
|
||||
GeometryFill,
|
||||
GeometryStroke,
|
||||
StyleMain(usize),
|
||||
StyleHighlight(usize),
|
||||
StyleShadow(usize),
|
||||
}
|
||||
|
||||
/// HSV color representation (Hue, Saturation, Value)
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -48,11 +65,7 @@ pub fn rgb_to_hsv(r: u8, g: u8, b: u8) -> Hsv {
|
||||
let s = if max == 0.0 { 0.0 } else { d / max };
|
||||
let v = max;
|
||||
|
||||
Hsv {
|
||||
h: h * 360.0,
|
||||
s,
|
||||
v,
|
||||
}
|
||||
Hsv { h: h * 360.0, s, v }
|
||||
}
|
||||
|
||||
/// Convert HSV to RGB.
|
||||
@@ -75,85 +88,38 @@ pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
|
||||
_ => (v, p, q),
|
||||
}
|
||||
};
|
||||
((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8)
|
||||
(to_byte(r), to_byte(g), to_byte(b))
|
||||
}
|
||||
|
||||
/// Create a compact HSV color picker popup for inline use in panels.
|
||||
/// Returns a color swatch that opens a popup when clicked.
|
||||
pub fn color_popup<'a>(
|
||||
/// Builds a compact swatch row that opens the shared application popup.
|
||||
pub fn color_swatch<'a>(
|
||||
color: [u8; 4],
|
||||
label: &'a str,
|
||||
on_change: fn([u8; 4]) -> Message,
|
||||
target: ColorPickerTarget,
|
||||
) -> Element<'a, Message> {
|
||||
let r = color[0];
|
||||
let g = color[1];
|
||||
let b = color[2];
|
||||
let a = color[3];
|
||||
let hsv = rgb_to_hsv(r, g, b);
|
||||
|
||||
// Color swatch button
|
||||
let swatch = container(text(""))
|
||||
.width(20)
|
||||
.height(20)
|
||||
.width(30)
|
||||
.height(22)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, a as f32 / 255.0,
|
||||
color[0] as f32 / 255.0,
|
||||
color[1] as f32 / 255.0,
|
||||
color[2] as f32 / 255.0,
|
||||
color[3] as f32 / 255.0,
|
||||
))),
|
||||
border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1).rounded(2),
|
||||
border: iced::Border::default()
|
||||
.color(iced::Color::from_rgb(0.55, 0.55, 0.55))
|
||||
.width(1)
|
||||
.rounded(3),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// HSV sliders
|
||||
let h_slider = slider(0.0..=360.0, hsv.h, move |v| {
|
||||
let (r, g, b) = hsv_to_rgb(v, hsv.s, hsv.v);
|
||||
on_change([r, g, b, a])
|
||||
}).step(1.0);
|
||||
|
||||
let s_slider = slider(0.0..=100.0, hsv.s * 100.0, move |v| {
|
||||
let (r, g, b) = hsv_to_rgb(hsv.h, v / 100.0, hsv.v);
|
||||
on_change([r, g, b, a])
|
||||
}).step(1.0);
|
||||
|
||||
let v_slider = slider(0.0..=100.0, hsv.v * 100.0, move |v| {
|
||||
let (r, g, b) = hsv_to_rgb(hsv.h, hsv.s, v / 100.0);
|
||||
on_change([r, g, b, a])
|
||||
}).step(1.0);
|
||||
|
||||
// Alpha slider
|
||||
let a_slider = slider(0..=255u8, a, move |v| {
|
||||
on_change([r, g, b, v])
|
||||
}).width(Length::Fill);
|
||||
|
||||
// Hex input
|
||||
let hex_str = format!("{:02X}{:02X}{:02X}", r, g, b);
|
||||
let alpha = a;
|
||||
let hex_input = text_input("#", &hex_str)
|
||||
.on_input(move |input| {
|
||||
let clean = input.trim_start_matches('#').to_uppercase();
|
||||
if clean.len() == 6 {
|
||||
if let (Ok(r), Ok(g), Ok(b)) = (
|
||||
u8::from_str_radix(&clean[0..2], 16),
|
||||
u8::from_str_radix(&clean[2..4], 16),
|
||||
u8::from_str_radix(&clean[4..6], 16),
|
||||
) {
|
||||
return on_change([r, g, b, alpha]);
|
||||
}
|
||||
}
|
||||
Message::NoOp
|
||||
})
|
||||
.width(70)
|
||||
.font(iced::Font::MONOSPACE)
|
||||
.size(10);
|
||||
|
||||
column![
|
||||
row![text(label).size(10), swatch].spacing(4).align_y(iced::Alignment::Center),
|
||||
row![text("H").size(9), h_slider, text(format!("{:.0}°", hsv.h)).size(9)].spacing(2),
|
||||
row![text("S").size(9), s_slider, text(format!("{:.0}%", hsv.s * 100.0)).size(9)].spacing(2),
|
||||
row![text("V").size(9), v_slider, text(format!("{:.0}%", hsv.v * 100.0)).size(9)].spacing(2),
|
||||
row![text("A").size(9), a_slider, text(format!("{:.0}%", a as f32 / 255.0 * 100.0)).size(9)].spacing(2),
|
||||
row![text("Hex:").size(10), hex_input].spacing(4).align_y(iced::Alignment::Center),
|
||||
]
|
||||
.spacing(2)
|
||||
let trigger = iced::widget::mouse_area(swatch)
|
||||
.on_press(Message::OpenColorPicker(target, color))
|
||||
.interaction(mouse::Interaction::Pointer);
|
||||
row![text(label).size(10).width(Length::Fill), trigger]
|
||||
.spacing(6)
|
||||
.align_y(iced::Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -173,16 +139,22 @@ fn palette_color(row: usize, col: usize) -> [u8; 3] {
|
||||
}
|
||||
|
||||
/// Convert RGB to HSL.
|
||||
fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
|
||||
pub fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
|
||||
let r = r as f32 / 255.0;
|
||||
let g = g as f32 / 255.0;
|
||||
let b = b as f32 / 255.0;
|
||||
let max = r.max(g).max(b);
|
||||
let min = r.min(g).min(b);
|
||||
let l = (max + min) / 2.0;
|
||||
if max == min { return (0.0, 0.0, l); }
|
||||
if max == min {
|
||||
return (0.0, 0.0, l);
|
||||
}
|
||||
let d = max - min;
|
||||
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
|
||||
let s = if l > 0.5 {
|
||||
d / (2.0 - max - min)
|
||||
} else {
|
||||
d / (max + min)
|
||||
};
|
||||
let h = if max == r {
|
||||
((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0
|
||||
} else if max == g {
|
||||
@@ -194,24 +166,437 @@ fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
|
||||
}
|
||||
|
||||
/// Convert HSL to RGB.
|
||||
fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
|
||||
pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
|
||||
let h = h / 360.0;
|
||||
let (r, g, b) = if s == 0.0 {
|
||||
(l, l, l)
|
||||
} else {
|
||||
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
|
||||
let p = 2.0 * l - q;
|
||||
(hue_to_rgb(p, q, h + 1.0 / 3.0), hue_to_rgb(p, q, h), hue_to_rgb(p, q, h - 1.0 / 3.0))
|
||||
let q = if l < 0.5 {
|
||||
l * (1.0 + s)
|
||||
} else {
|
||||
l + s - l * s
|
||||
};
|
||||
((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8)
|
||||
let p = 2.0 * l - q;
|
||||
(
|
||||
hue_to_rgb(p, q, h + 1.0 / 3.0),
|
||||
hue_to_rgb(p, q, h),
|
||||
hue_to_rgb(p, q, h - 1.0 / 3.0),
|
||||
)
|
||||
};
|
||||
(to_byte(r), to_byte(g), to_byte(b))
|
||||
}
|
||||
|
||||
fn to_byte(value: f32) -> u8 {
|
||||
(value.clamp(0.0, 1.0) * 255.0).round() as u8
|
||||
}
|
||||
|
||||
/// Parses `RRGGBB` or `RRGGBBAA`, preserving the supplied alpha for six digits.
|
||||
pub fn parse_hex_color(value: &str, current_alpha: u8) -> Option<[u8; 4]> {
|
||||
let value = value.trim().trim_start_matches('#');
|
||||
if value.len() != 6 && value.len() != 8 {
|
||||
return None;
|
||||
}
|
||||
let r = u8::from_str_radix(&value[0..2], 16).ok()?;
|
||||
let g = u8::from_str_radix(&value[2..4], 16).ok()?;
|
||||
let b = u8::from_str_radix(&value[4..6], 16).ok()?;
|
||||
let a = if value.len() == 8 {
|
||||
u8::from_str_radix(&value[6..8], 16).ok()?
|
||||
} else {
|
||||
current_alpha
|
||||
};
|
||||
Some([r, g, b, a])
|
||||
}
|
||||
|
||||
fn hue_to_rgb(p: f32, q: f32, t: f32) -> f32 {
|
||||
let t = if t < 0.0 { t + 1.0 } else if t > 1.0 { t - 1.0 } else { t };
|
||||
if t < 1.0 / 6.0 { p + (q - p) * 6.0 * t }
|
||||
else if t < 1.0 / 2.0 { q }
|
||||
else if t < 2.0 / 3.0 { p + (q - p) * (2.0 / 3.0 - t) * 6.0 }
|
||||
else { p }
|
||||
let t = if t < 0.0 {
|
||||
t + 1.0
|
||||
} else if t > 1.0 {
|
||||
t - 1.0
|
||||
} else {
|
||||
t
|
||||
};
|
||||
if t < 1.0 / 6.0 {
|
||||
p + (q - p) * 6.0 * t
|
||||
} else if t < 1.0 / 2.0 {
|
||||
q
|
||||
} else if t < 2.0 / 3.0 {
|
||||
p + (q - p) * (2.0 / 3.0 - t) * 6.0
|
||||
} else {
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the shared popup overlay for the active target.
|
||||
///
|
||||
/// The popup keeps alpha and hexadecimal editing common to all three compact
|
||||
/// tabs. RGB and HSL expose sliders plus numeric inputs, while Wheel uses a
|
||||
/// custom Iced canvas with continuous hue-ring and saturation/lightness hit
|
||||
/// testing. Changes are emitted immediately through `PopupColorChanged`.
|
||||
pub fn popup_view(
|
||||
color: [u8; 4],
|
||||
hex_value: &str,
|
||||
tab: usize,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'static, Message> {
|
||||
let (h, s, l) = rgb_to_hsl(color[0], color[1], color[2]);
|
||||
let tab_button = |label: &'static str, index: usize| {
|
||||
let active = tab == index;
|
||||
button(text(label).size(10))
|
||||
.on_press(Message::ColorPickerTabChanged(index))
|
||||
.padding([4, 10])
|
||||
.style(move |_theme, status| iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(if active {
|
||||
colors.bg_active
|
||||
} else if status == iced::widget::button::Status::Hovered {
|
||||
colors.bg_hover
|
||||
} else {
|
||||
colors.bg_panel
|
||||
})),
|
||||
text_color: if active {
|
||||
colors.accent
|
||||
} else {
|
||||
colors.text_primary
|
||||
},
|
||||
border: iced::Border::default()
|
||||
.color(if active {
|
||||
colors.accent
|
||||
} else {
|
||||
colors.border_low
|
||||
})
|
||||
.width(1)
|
||||
.rounded(3),
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
|
||||
let body: Element<'static, Message> = match tab {
|
||||
0 => rgb_popup_section(color),
|
||||
1 => Canvas::new(ColorWheel { color })
|
||||
.width(Length::Fixed(220.0))
|
||||
.height(Length::Fixed(220.0))
|
||||
.into(),
|
||||
_ => hsl_popup_section(color, h, s, l),
|
||||
};
|
||||
|
||||
let alpha = color[3];
|
||||
let alpha_row = row![
|
||||
text("A").size(10).width(16),
|
||||
slider(0..=255u8, alpha, move |value| {
|
||||
Message::PopupColorChanged([color[0], color[1], color[2], value])
|
||||
})
|
||||
.width(Length::Fill),
|
||||
text(format!("{alpha}")).size(10).width(28),
|
||||
]
|
||||
.spacing(5)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
let popup = column![
|
||||
row![
|
||||
text("Color Selector").size(12).width(Length::Fill),
|
||||
button(text("X").size(10))
|
||||
.on_press(Message::CloseColorPicker)
|
||||
.padding([2, 7]),
|
||||
]
|
||||
.align_y(iced::Alignment::Center),
|
||||
row![
|
||||
tab_button("RGB", 0),
|
||||
tab_button("Wheel", 1),
|
||||
tab_button("HSL", 2)
|
||||
]
|
||||
.spacing(3),
|
||||
body,
|
||||
alpha_row,
|
||||
row![
|
||||
text("Hex").size(10),
|
||||
text_input("#RRGGBBAA", hex_value)
|
||||
.on_input(Message::ColorPickerHexChanged)
|
||||
.font(iced::Font::MONOSPACE)
|
||||
.size(10)
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Alignment::Center),
|
||||
]
|
||||
.spacing(7)
|
||||
.padding(10)
|
||||
.width(Length::Fill);
|
||||
|
||||
let panel = container(scrollable(popup).height(Length::Shrink))
|
||||
.width(Length::Fill)
|
||||
.max_width(320.0)
|
||||
.max_height(520.0)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_panel)),
|
||||
border: iced::Border::default()
|
||||
.color(colors.border_high)
|
||||
.width(1)
|
||||
.rounded(6),
|
||||
shadow: iced::Shadow {
|
||||
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.65),
|
||||
offset: iced::Vector::new(0.0, 3.0),
|
||||
blur_radius: 14.0,
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
container(panel)
|
||||
.padding(8)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Builds RGB sliders and directly editable byte values.
|
||||
fn rgb_popup_section(color: [u8; 4]) -> Element<'static, Message> {
|
||||
let channel = |label, index: usize| {
|
||||
let slider_color = color;
|
||||
let input_color = color;
|
||||
row![
|
||||
text(label).size(10).width(16),
|
||||
slider(0..=255u8, color[index], move |value| {
|
||||
let mut next = slider_color;
|
||||
next[index] = value;
|
||||
Message::PopupColorChanged(next)
|
||||
})
|
||||
.width(Length::Fill),
|
||||
text_input("0", &color[index].to_string())
|
||||
.on_input(move |value| match value.parse::<u8>() {
|
||||
Ok(value) => {
|
||||
let mut next = input_color;
|
||||
next[index] = value;
|
||||
Message::PopupColorChanged(next)
|
||||
}
|
||||
Err(_) => Message::NoOp,
|
||||
})
|
||||
.size(10)
|
||||
.width(42),
|
||||
]
|
||||
.spacing(5)
|
||||
.align_y(iced::Alignment::Center)
|
||||
};
|
||||
column![channel("R", 0), channel("G", 1), channel("B", 2)]
|
||||
.spacing(5)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Builds HSL sliders and numeric readouts without duplicating conversion logic.
|
||||
fn hsl_popup_section(color: [u8; 4], h: f32, s: f32, l: f32) -> Element<'static, Message> {
|
||||
let alpha = color[3];
|
||||
let hue = slider(0.0..=360.0, h, move |value| {
|
||||
let (r, g, b) = hsl_to_rgb(value, s, l);
|
||||
Message::PopupColorChanged([r, g, b, alpha])
|
||||
});
|
||||
let saturation = slider(0.0..=100.0, s * 100.0, move |value| {
|
||||
let (r, g, b) = hsl_to_rgb(h, value / 100.0, l);
|
||||
Message::PopupColorChanged([r, g, b, alpha])
|
||||
});
|
||||
let lightness = slider(0.0..=100.0, l * 100.0, move |value| {
|
||||
let (r, g, b) = hsl_to_rgb(h, s, value / 100.0);
|
||||
Message::PopupColorChanged([r, g, b, alpha])
|
||||
});
|
||||
column![
|
||||
popup_slider_row("H", hue, format!("{h:.0}")),
|
||||
popup_slider_row("S", saturation, format!("{:.0}", s * 100.0)),
|
||||
popup_slider_row("L", lightness, format!("{:.0}", l * 100.0)),
|
||||
]
|
||||
.spacing(5)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Aligns one popup slider with a compact numeric readout.
|
||||
fn popup_slider_row<'a>(
|
||||
label: &'static str,
|
||||
control: iced::widget::Slider<'a, f32, Message>,
|
||||
value: String,
|
||||
) -> Element<'a, Message> {
|
||||
row![
|
||||
text(label).size(10).width(16),
|
||||
control.width(Length::Fill),
|
||||
text(value).size(10).width(34)
|
||||
]
|
||||
.spacing(5)
|
||||
.align_y(iced::Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Interactive hue ring and saturation/lightness square.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct ColorWheel {
|
||||
color: [u8; 4],
|
||||
}
|
||||
|
||||
/// Tracks whether a wheel drag began over an editable region.
|
||||
#[derive(Debug, Default)]
|
||||
struct ColorWheelState {
|
||||
dragging: bool,
|
||||
}
|
||||
|
||||
impl ColorWheel {
|
||||
/// Maps a local wheel position to HSL, preserving the current component when
|
||||
/// the pointer lies outside both the ring and center square.
|
||||
fn color_at(self, bounds: Rectangle, position: Point) -> Option<[u8; 4]> {
|
||||
let size = bounds.width.min(bounds.height);
|
||||
let center = Point::new(bounds.width / 2.0, bounds.height / 2.0);
|
||||
let wheel_radius = size * 0.40;
|
||||
let inner_radius = size * 0.24;
|
||||
let square_size = inner_radius * 1.7;
|
||||
let square = Rectangle::new(
|
||||
Point::new(center.x - square_size / 2.0, center.y - square_size / 2.0),
|
||||
Size::new(square_size, square_size),
|
||||
);
|
||||
let (mut h, mut s, mut l) = rgb_to_hsl(self.color[0], self.color[1], self.color[2]);
|
||||
if square.contains(position) {
|
||||
s = ((position.x - square.x) / square.width).clamp(0.0, 1.0);
|
||||
l = ((square.y + square.height - position.y) / square.height).clamp(0.0, 1.0);
|
||||
} else {
|
||||
let dx = position.x - center.x;
|
||||
let dy = position.y - center.y;
|
||||
let distance = (dx * dx + dy * dy).sqrt();
|
||||
if distance < inner_radius - 5.0 || distance > wheel_radius + 8.0 {
|
||||
return None;
|
||||
}
|
||||
h = (dy.atan2(dx).to_degrees() + 90.0 + 360.0) % 360.0;
|
||||
}
|
||||
let (r, g, b) = hsl_to_rgb(h, s, l);
|
||||
Some([r, g, b, self.color[3]])
|
||||
}
|
||||
}
|
||||
|
||||
impl canvas::Program<Message> for ColorWheel {
|
||||
type State = ColorWheelState;
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
_state: &Self::State,
|
||||
renderer: &iced::Renderer,
|
||||
_theme: &iced::Theme,
|
||||
bounds: Rectangle,
|
||||
_cursor: mouse::Cursor,
|
||||
) -> Vec<canvas::Geometry> {
|
||||
let mut frame = Frame::new(renderer, bounds.size());
|
||||
let size = bounds.width.min(bounds.height);
|
||||
let center = Point::new(bounds.width / 2.0, bounds.height / 2.0);
|
||||
let outer = size * 0.40;
|
||||
let inner = size * 0.24;
|
||||
let (h, s, l) = rgb_to_hsl(self.color[0], self.color[1], self.color[2]);
|
||||
|
||||
for segment in 0..120 {
|
||||
let h1 = segment as f32 * 3.0;
|
||||
let h2 = (segment + 1) as f32 * 3.0;
|
||||
let a1 = (h1 - 90.0).to_radians();
|
||||
let a2 = (h2 - 90.0).to_radians();
|
||||
let path = Path::new(|builder| {
|
||||
builder.move_to(Point::new(
|
||||
center.x + a1.cos() * outer,
|
||||
center.y + a1.sin() * outer,
|
||||
));
|
||||
builder.line_to(Point::new(
|
||||
center.x + a2.cos() * outer,
|
||||
center.y + a2.sin() * outer,
|
||||
));
|
||||
builder.line_to(Point::new(
|
||||
center.x + a2.cos() * inner,
|
||||
center.y + a2.sin() * inner,
|
||||
));
|
||||
builder.line_to(Point::new(
|
||||
center.x + a1.cos() * inner,
|
||||
center.y + a1.sin() * inner,
|
||||
));
|
||||
builder.close();
|
||||
});
|
||||
let (r, g, b) = hsl_to_rgb((h1 + h2) * 0.5, 1.0, 0.5);
|
||||
frame.fill(&path, iced::Color::from_rgb8(r, g, b));
|
||||
}
|
||||
|
||||
let square_size = inner * 1.7;
|
||||
let square_origin = Point::new(center.x - square_size / 2.0, center.y - square_size / 2.0);
|
||||
let cells = 20;
|
||||
let cell = square_size / cells as f32;
|
||||
for y in 0..cells {
|
||||
for x in 0..cells {
|
||||
let saturation = (x as f32 + 0.5) / cells as f32;
|
||||
let lightness = 1.0 - (y as f32 + 0.5) / cells as f32;
|
||||
let (r, g, b) = hsl_to_rgb(h, saturation, lightness);
|
||||
frame.fill_rectangle(
|
||||
Point::new(
|
||||
square_origin.x + x as f32 * cell,
|
||||
square_origin.y + y as f32 * cell,
|
||||
),
|
||||
Size::new(cell + 0.5, cell + 0.5),
|
||||
iced::Color::from_rgb8(r, g, b),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let hue_angle = (h - 90.0).to_radians();
|
||||
let hue_point = Point::new(
|
||||
center.x + hue_angle.cos() * (outer + inner) * 0.5,
|
||||
center.y + hue_angle.sin() * (outer + inner) * 0.5,
|
||||
);
|
||||
let sl_point = Point::new(
|
||||
square_origin.x + s * square_size,
|
||||
square_origin.y + (1.0 - l) * square_size,
|
||||
);
|
||||
for point in [hue_point, sl_point] {
|
||||
frame.fill(&Path::circle(point, 5.0), iced::Color::WHITE);
|
||||
frame.stroke(
|
||||
&Path::circle(point, 5.0),
|
||||
Stroke::default()
|
||||
.with_color(iced::Color::BLACK)
|
||||
.with_width(1.5),
|
||||
);
|
||||
}
|
||||
vec![frame.into_geometry()]
|
||||
}
|
||||
|
||||
fn update(
|
||||
&self,
|
||||
state: &mut Self::State,
|
||||
event: canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> (canvas::event::Status, Option<Message>) {
|
||||
let local = cursor.position_in(bounds);
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||||
if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) {
|
||||
state.dragging = true;
|
||||
return (
|
||||
canvas::event::Status::Captured,
|
||||
Some(Message::PopupColorChanged(color)),
|
||||
);
|
||||
}
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => {
|
||||
if let Some(color) = local.and_then(|position| self.color_at(bounds, position)) {
|
||||
return (
|
||||
canvas::event::Status::Captured,
|
||||
Some(Message::PopupColorChanged(color)),
|
||||
);
|
||||
}
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
state.dragging = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
(canvas::event::Status::Ignored, None)
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
_state: &Self::State,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> mouse::Interaction {
|
||||
if cursor.is_over(bounds) {
|
||||
mouse::Interaction::Crosshair
|
||||
} else {
|
||||
mouse::Interaction::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the full color picker panel.
|
||||
@@ -234,9 +619,15 @@ pub fn view<'a>(
|
||||
.height(20)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
fg[0] as f32 / 255.0, fg[1] as f32 / 255.0, fg[2] as f32 / 255.0, fg[3] as f32 / 255.0,
|
||||
fg[0] as f32 / 255.0,
|
||||
fg[1] as f32 / 255.0,
|
||||
fg[2] as f32 / 255.0,
|
||||
fg[3] as f32 / 255.0,
|
||||
))),
|
||||
border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1).rounded(2),
|
||||
border: iced::Border::default()
|
||||
.color(iced::Color::from_rgb(0.4, 0.4, 0.4))
|
||||
.width(1)
|
||||
.rounded(2),
|
||||
..Default::default()
|
||||
});
|
||||
let secondary_swatch = container(text(""))
|
||||
@@ -244,15 +635,23 @@ pub fn view<'a>(
|
||||
.height(20)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
bg[0] as f32 / 255.0, bg[1] as f32 / 255.0, bg[2] as f32 / 255.0, bg[3] as f32 / 255.0,
|
||||
bg[0] as f32 / 255.0,
|
||||
bg[1] as f32 / 255.0,
|
||||
bg[2] as f32 / 255.0,
|
||||
bg[3] as f32 / 255.0,
|
||||
))),
|
||||
border: iced::Border::default().color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1).rounded(2),
|
||||
border: iced::Border::default()
|
||||
.color(iced::Color::from_rgb(0.4, 0.4, 0.4))
|
||||
.width(1)
|
||||
.rounded(2),
|
||||
..Default::default()
|
||||
});
|
||||
let swap_btn = button(text("\u{21C5}").size(11))
|
||||
.on_press(Message::SwapColors)
|
||||
.padding([2, 4]);
|
||||
let swatch_row = row![primary_swatch, secondary_swatch, swap_btn].spacing(4).align_y(iced::Alignment::Center);
|
||||
let swatch_row = row![primary_swatch, secondary_swatch, swap_btn]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
// ── 2. Hex input ──
|
||||
// Preserve the current alpha channel: the hex field only edits RGB, but the
|
||||
@@ -277,7 +676,9 @@ pub fn view<'a>(
|
||||
.width(70)
|
||||
.font(iced::Font::MONOSPACE)
|
||||
.size(10);
|
||||
let hex_row = row![text("Hex:").size(10), hex_input].spacing(4).align_y(iced::Alignment::Center);
|
||||
let hex_row = row![text("Hex:").size(10), hex_input]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
// ── 2b. Alpha slider ──
|
||||
// Allows transparency editing, which egui supports in the layer-style panel
|
||||
@@ -300,13 +701,18 @@ pub fn view<'a>(
|
||||
// ── 3. Tab bar ──
|
||||
let tab_w = 30;
|
||||
let make_tab = |label: &'static str, idx: usize, color_tab: usize| {
|
||||
button(text(label).size(10)).padding([2, tab_w / 2 - 4])
|
||||
button(text(label).size(10))
|
||||
.padding([2, tab_w / 2 - 4])
|
||||
.on_press(Message::ColorTabChanged(idx))
|
||||
.style(move |_theme, _status| iced::widget::button::Style {
|
||||
background: if color_tab == idx {
|
||||
Some(iced::Background::Color(iced::Color::from_rgb(0.35, 0.35, 0.35)))
|
||||
Some(iced::Background::Color(iced::Color::from_rgb(
|
||||
0.35, 0.35, 0.35,
|
||||
)))
|
||||
} else {
|
||||
Some(iced::Background::Color(iced::Color::from_rgb(0.22, 0.22, 0.22)))
|
||||
Some(iced::Background::Color(iced::Color::from_rgb(
|
||||
0.22, 0.22, 0.22,
|
||||
)))
|
||||
},
|
||||
text_color: iced::Color::from_rgb(0.8, 0.8, 0.8),
|
||||
border: iced::Border::default().rounded(2),
|
||||
@@ -317,7 +723,8 @@ pub fn view<'a>(
|
||||
make_tab("W", 0, color_tab),
|
||||
make_tab("H", 1, color_tab),
|
||||
make_tab("G", 2, color_tab),
|
||||
].spacing(1);
|
||||
]
|
||||
.spacing(1);
|
||||
|
||||
// ── 4. Tab content ──
|
||||
let tab_content: Element<'_, Message> = match color_tab {
|
||||
@@ -332,7 +739,11 @@ pub fn view<'a>(
|
||||
let c = color;
|
||||
let is_selected = c == *fg_color;
|
||||
let brightness = c[0] as f32 * 0.299 + c[1] as f32 * 0.587 + c[2] as f32 * 0.114;
|
||||
let dot_color = if brightness > 128.0 { iced::Color::BLACK } else { iced::Color::WHITE };
|
||||
let dot_color = if brightness > 128.0 {
|
||||
iced::Color::BLACK
|
||||
} else {
|
||||
iced::Color::WHITE
|
||||
};
|
||||
|
||||
let dot = text(if is_selected { "\u{2022}" } else { "" })
|
||||
.size(10)
|
||||
@@ -347,10 +758,16 @@ pub fn view<'a>(
|
||||
.center_y(Length::Fill)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, c[3] as f32 / 255.0,
|
||||
c[0] as f32 / 255.0,
|
||||
c[1] as f32 / 255.0,
|
||||
c[2] as f32 / 255.0,
|
||||
c[3] as f32 / 255.0,
|
||||
))),
|
||||
border: if is_selected {
|
||||
iced::Border::default().color(dot_color).width(1.0).rounded(2)
|
||||
iced::Border::default()
|
||||
.color(dot_color)
|
||||
.width(1.0)
|
||||
.rounded(2)
|
||||
} else {
|
||||
iced::Border::default().rounded(2)
|
||||
},
|
||||
@@ -394,11 +811,16 @@ fn build_wheel_view<'a>(h: f32, s: f32, l: f32, _fg_color: &'a [u8; 4]) -> Eleme
|
||||
for i in 0..36 {
|
||||
let hue = i as f32 * 10.0;
|
||||
let (r, g, b) = hsl_to_rgb(hue, 1.0, 0.5);
|
||||
let cell_color = iced::Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0);
|
||||
let cell_color =
|
||||
iced::Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0);
|
||||
let is_current_h = (h - hue).abs() < 5.0 || (h - hue).abs() > 355.0;
|
||||
let is_selected = is_current_h && (s - 1.0).abs() < 0.1 && (l - 0.5).abs() < 0.1;
|
||||
let brightness = r as f32 * 0.299 + g as f32 * 0.587 + b as f32 * 0.114;
|
||||
let dot_color = if brightness > 128.0 { iced::Color::BLACK } else { iced::Color::WHITE };
|
||||
let dot_color = if brightness > 128.0 {
|
||||
iced::Color::BLACK
|
||||
} else {
|
||||
iced::Color::WHITE
|
||||
};
|
||||
|
||||
let dot = text(if is_selected { "\u{2022}" } else { "" })
|
||||
.size(8)
|
||||
@@ -442,9 +864,14 @@ fn build_wheel_view<'a>(h: f32, s: f32, l: f32, _fg_color: &'a [u8; 4]) -> Eleme
|
||||
let sat = col_idx as f32 / 11.0;
|
||||
let (r, g, b) = hsl_to_rgb(h, sat, lightness);
|
||||
let is_current = (s - sat).abs() < 0.05 && (l - lightness).abs() < 0.05;
|
||||
let cell_color = iced::Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0);
|
||||
let cell_color =
|
||||
iced::Color::from_rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0);
|
||||
let brightness = r as f32 * 0.299 + g as f32 * 0.587 + b as f32 * 0.114;
|
||||
let dot_color = if brightness > 128.0 { iced::Color::BLACK } else { iced::Color::WHITE };
|
||||
let dot_color = if brightness > 128.0 {
|
||||
iced::Color::BLACK
|
||||
} else {
|
||||
iced::Color::WHITE
|
||||
};
|
||||
|
||||
let dot = text(if is_current { "\u{2022}" } else { "" })
|
||||
.size(8)
|
||||
@@ -481,8 +908,14 @@ fn build_wheel_view<'a>(h: f32, s: f32, l: f32, _fg_color: &'a [u8; 4]) -> Eleme
|
||||
}
|
||||
|
||||
// HSL readout
|
||||
let readout = text(format!("H:{:.0}\u{00B0} S:{:.0}% L:{:.0}%", h, s * 100.0, l * 100.0))
|
||||
.size(9).font(iced::Font::MONOSPACE);
|
||||
let readout = text(format!(
|
||||
"H:{:.0}\u{00B0} S:{:.0}% L:{:.0}%",
|
||||
h,
|
||||
s * 100.0,
|
||||
l * 100.0
|
||||
))
|
||||
.size(9)
|
||||
.font(iced::Font::MONOSPACE);
|
||||
|
||||
column![
|
||||
text("Hue").size(9),
|
||||
@@ -500,20 +933,38 @@ fn build_slider_view<'a>(h: f32, s: f32, l: f32) -> Element<'a, Message> {
|
||||
let h_slider = slider(0.0..=360.0, h, move |v: f32| {
|
||||
let (r, g, b) = hsl_to_rgb(v, s, l);
|
||||
Message::FgColorChanged([r, g, b, 255])
|
||||
}).step(1.0);
|
||||
})
|
||||
.step(1.0);
|
||||
let s_slider = slider(0.0..=100.0, s * 100.0, move |v: f32| {
|
||||
let (r, g, b) = hsl_to_rgb(h, v / 100.0, l);
|
||||
Message::FgColorChanged([r, g, b, 255])
|
||||
}).step(1.0);
|
||||
})
|
||||
.step(1.0);
|
||||
let l_slider = slider(0.0..=100.0, l * 100.0, move |v: f32| {
|
||||
let (r, g, b) = hsl_to_rgb(h, s, v / 100.0);
|
||||
Message::FgColorChanged([r, g, b, 255])
|
||||
}).step(1.0);
|
||||
})
|
||||
.step(1.0);
|
||||
|
||||
column![
|
||||
row![text("H").size(9), h_slider, text(format!("{:.0}\u{00B0}", h)).size(9)].spacing(2),
|
||||
row![text("S").size(9), s_slider, text(format!("{:.0}%", s * 100.0)).size(9)].spacing(2),
|
||||
row![text("L").size(9), l_slider, text(format!("{:.0}%", l * 100.0)).size(9)].spacing(2),
|
||||
row![
|
||||
text("H").size(9),
|
||||
h_slider,
|
||||
text(format!("{:.0}\u{00B0}", h)).size(9)
|
||||
]
|
||||
.spacing(2),
|
||||
row![
|
||||
text("S").size(9),
|
||||
s_slider,
|
||||
text(format!("{:.0}%", s * 100.0)).size(9)
|
||||
]
|
||||
.spacing(2),
|
||||
row![
|
||||
text("L").size(9),
|
||||
l_slider,
|
||||
text(format!("{:.0}%", l * 100.0)).size(9)
|
||||
]
|
||||
.spacing(2),
|
||||
]
|
||||
.spacing(2)
|
||||
.into()
|
||||
@@ -528,7 +979,11 @@ fn build_grid_view<'a>(fg_color: &'a [u8; 4]) -> Element<'a, Message> {
|
||||
let c = palette_color(row_idx, col_idx);
|
||||
let is_selected = [c[0], c[1], c[2], 255] == *fg_color;
|
||||
let brightness = c[0] as f32 * 0.299 + c[1] as f32 * 0.587 + c[2] as f32 * 0.114;
|
||||
let dot_color = if brightness > 128.0 { iced::Color::BLACK } else { iced::Color::WHITE };
|
||||
let dot_color = if brightness > 128.0 {
|
||||
iced::Color::BLACK
|
||||
} else {
|
||||
iced::Color::WHITE
|
||||
};
|
||||
|
||||
let dot = text(if is_selected { "\u{2022}" } else { "" })
|
||||
.size(10)
|
||||
@@ -543,10 +998,15 @@ fn build_grid_view<'a>(fg_color: &'a [u8; 4]) -> Element<'a, Message> {
|
||||
.center_y(Length::Fill)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgb(
|
||||
c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0,
|
||||
c[0] as f32 / 255.0,
|
||||
c[1] as f32 / 255.0,
|
||||
c[2] as f32 / 255.0,
|
||||
))),
|
||||
border: if is_selected {
|
||||
iced::Border::default().color(dot_color).width(1.0).rounded(1)
|
||||
iced::Border::default()
|
||||
.color(dot_color)
|
||||
.width(1.0)
|
||||
.rounded(1)
|
||||
} else {
|
||||
iced::Border::default().rounded(1)
|
||||
},
|
||||
@@ -560,10 +1020,44 @@ fn build_grid_view<'a>(fg_color: &'a [u8; 4]) -> Element<'a, Message> {
|
||||
palette_grid = palette_grid.push(row_widgets);
|
||||
}
|
||||
|
||||
column![
|
||||
text("Palette").size(10),
|
||||
palette_grid,
|
||||
]
|
||||
column![text("Palette").size(10), palette_grid,]
|
||||
.spacing(2)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rgb_hsl_roundtrip_preserves_channels_with_rounding_tolerance() {
|
||||
for color in [[0, 0, 0], [255, 255, 255], [231, 17, 93], [12, 190, 244]] {
|
||||
let (h, s, l) = rgb_to_hsl(color[0], color[1], color[2]);
|
||||
let actual = hsl_to_rgb(h, s, l);
|
||||
for (expected, actual) in color.into_iter().zip([actual.0, actual.1, actual.2]) {
|
||||
assert!((expected as i16 - actual as i16).abs() <= 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_hsv_roundtrip_preserves_channels_with_rounding_tolerance() {
|
||||
for color in [[0, 0, 0], [255, 255, 255], [231, 17, 93], [12, 190, 244]] {
|
||||
let hsv = rgb_to_hsv(color[0], color[1], color[2]);
|
||||
let actual = hsv.to_rgb();
|
||||
for (expected, actual) in color.into_iter().zip([actual.0, actual.1, actual.2]) {
|
||||
assert!((expected as i16 - actual as i16).abs() <= 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_parser_preserves_or_explicitly_updates_alpha() {
|
||||
assert_eq!(parse_hex_color("#112233", 77), Some([0x11, 0x22, 0x33, 77]));
|
||||
assert_eq!(
|
||||
parse_hex_color("11223344", 77),
|
||||
Some([0x11, 0x22, 0x33, 0x44])
|
||||
);
|
||||
assert_eq!(parse_hex_color("xyz", 77), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! About dialog — displays application name, version, and credits.
|
||||
|
||||
use crate::app::Message;
|
||||
use iced::widget::{button, column, container, horizontal_rule, text};
|
||||
use iced::{Element, Length};
|
||||
use crate::theme::ThemeColors;
|
||||
use iced::widget::{button, column, horizontal_rule, text};
|
||||
use iced::Element;
|
||||
|
||||
/// Build the About dialog.
|
||||
pub fn view() -> Element<'static, Message> {
|
||||
pub fn view(colors: ThemeColors) -> Element<'static, Message> {
|
||||
let ok_btn = button(text("OK").size(12))
|
||||
.on_press(Message::DialogClose)
|
||||
.padding([8, 16]);
|
||||
@@ -25,14 +26,5 @@ pub fn view() -> Element<'static, Message> {
|
||||
.width(320)
|
||||
.align_x(iced::Alignment::Center);
|
||||
|
||||
container(dialog)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.5))),
|
||||
..Default::default()
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
super::modal(dialog, colors)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
//! Adjustments dialog — brightness/contrast and HSL adjustments.
|
||||
|
||||
use crate::app::Message;
|
||||
use iced::widget::{button, column, container, horizontal_rule, row, slider, text};
|
||||
use crate::theme::ThemeColors;
|
||||
use iced::widget::{button, column, horizontal_rule, row, slider, text};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Build the Brightness/Contrast adjustment dialog.
|
||||
pub fn brightness_contrast_view(
|
||||
brightness: f32,
|
||||
contrast: f32,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'static, Message> {
|
||||
let brightness_slider = slider(-100.0..=100.0, brightness, |v| Message::AdjBrightness(v))
|
||||
.step(1.0)
|
||||
@@ -26,7 +28,9 @@ pub fn brightness_contrast_view(
|
||||
.padding([6, 12]);
|
||||
|
||||
let dialog = column![
|
||||
text("Brightness / Contrast").size(14).font(iced::Font::MONOSPACE),
|
||||
text("Brightness / Contrast")
|
||||
.size(14)
|
||||
.font(iced::Font::MONOSPACE),
|
||||
horizontal_rule(1),
|
||||
text(format!("Brightness: {:.0}", brightness)).size(11),
|
||||
brightness_slider,
|
||||
@@ -39,16 +43,7 @@ pub fn brightness_contrast_view(
|
||||
.padding(16)
|
||||
.width(350);
|
||||
|
||||
container(dialog)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.5))),
|
||||
..Default::default()
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
super::modal(dialog, colors)
|
||||
}
|
||||
|
||||
/// Build the Hue/Saturation/Lightness adjustment dialog.
|
||||
@@ -56,6 +51,7 @@ pub fn hsl_view(
|
||||
hue: f32,
|
||||
saturation: f32,
|
||||
lightness: f32,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'static, Message> {
|
||||
let hue_slider = slider(-180.0..=180.0, hue, |v| Message::AdjHue(v))
|
||||
.step(1.0)
|
||||
@@ -78,7 +74,9 @@ pub fn hsl_view(
|
||||
.padding([6, 12]);
|
||||
|
||||
let dialog = column![
|
||||
text("Hue / Saturation / Lightness").size(14).font(iced::Font::MONOSPACE),
|
||||
text("Hue / Saturation / Lightness")
|
||||
.size(14)
|
||||
.font(iced::Font::MONOSPACE),
|
||||
horizontal_rule(1),
|
||||
text(format!("Hue: {:.0}°", hue)).size(11),
|
||||
hue_slider,
|
||||
@@ -93,14 +91,5 @@ pub fn hsl_view(
|
||||
.padding(16)
|
||||
.width(350);
|
||||
|
||||
container(dialog)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.5))),
|
||||
..Default::default()
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
super::modal(dialog, colors)
|
||||
}
|
||||
|
||||
@@ -5,15 +5,12 @@
|
||||
//! when the canvas is resized.
|
||||
|
||||
use crate::app::Message;
|
||||
use iced::widget::{button, column, container, horizontal_rule, row, text, text_input};
|
||||
use iced::{Element, Length};
|
||||
use crate::theme::ThemeColors;
|
||||
use iced::widget::{button, column, horizontal_rule, row, text, text_input};
|
||||
use iced::Element;
|
||||
|
||||
/// Anchor position labels for the 9-point grid (row-major).
|
||||
const ANCHOR_LABELS: [[&str; 3]; 3] = [
|
||||
["TL", "TC", "TR"],
|
||||
["ML", "MC", "MR"],
|
||||
["BL", "BC", "BR"],
|
||||
];
|
||||
const ANCHOR_LABELS: [[&str; 3]; 3] = [["TL", "TC", "TR"], ["ML", "MC", "MR"], ["BL", "BC", "BR"]];
|
||||
|
||||
/// Build the Canvas Size dialog.
|
||||
pub fn view(
|
||||
@@ -21,6 +18,7 @@ pub fn view(
|
||||
height: u32,
|
||||
anchor_row: usize,
|
||||
anchor_col: usize,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'static, Message> {
|
||||
let width_str = width.to_string();
|
||||
let height_str = height.to_string();
|
||||
@@ -86,14 +84,5 @@ pub fn view(
|
||||
.padding(16)
|
||||
.width(360);
|
||||
|
||||
container(dialog)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.5))),
|
||||
..Default::default()
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
super::modal(dialog, colors)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Confirm dialog — save/discard/cancel confirmation.
|
||||
|
||||
use crate::app::Message;
|
||||
use iced::widget::{button, column, container, horizontal_rule, row, slider, text};
|
||||
use crate::theme::ThemeColors;
|
||||
use iced::widget::{button, column, horizontal_rule, row, slider, text};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Build a confirmation dialog with save/discard/cancel.
|
||||
pub fn close_confirm_view(doc_name: &str) -> Element<'static, Message> {
|
||||
pub fn close_confirm_view(doc_name: &str, colors: ThemeColors) -> Element<'static, Message> {
|
||||
let msg = format!("\"{}\" has unsaved changes.", doc_name);
|
||||
|
||||
let save_btn = button(text("Save").size(12))
|
||||
@@ -31,16 +32,7 @@ pub fn close_confirm_view(doc_name: &str) -> Element<'static, Message> {
|
||||
.padding(16)
|
||||
.width(400);
|
||||
|
||||
container(dialog)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.5))),
|
||||
..Default::default()
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
super::modal(dialog, colors)
|
||||
}
|
||||
|
||||
/// Build a selection operation dialog (grow/shrink/feather).
|
||||
@@ -49,6 +41,7 @@ pub fn selection_op_view(
|
||||
value: f32,
|
||||
min: f32,
|
||||
max: f32,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'static, Message> {
|
||||
let op_owned = op_name.to_string();
|
||||
let value_slider = slider(min..=max, value, |v| Message::SelectionOpValue(v))
|
||||
@@ -75,14 +68,5 @@ pub fn selection_op_view(
|
||||
.padding(16)
|
||||
.width(350);
|
||||
|
||||
container(dialog)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.5))),
|
||||
..Default::default()
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
super::modal(dialog, colors)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Dock Profile dialog — save, load, rename, and delete dock layout profiles.
|
||||
|
||||
use crate::app::Message;
|
||||
use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, text_input};
|
||||
use crate::theme::ThemeColors;
|
||||
use iced::widget::{button, column, horizontal_rule, row, scrollable, text, text_input};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Build the dock profile management dialog.
|
||||
@@ -17,6 +18,7 @@ pub fn view(
|
||||
profiles: &[crate::settings::DockProfile],
|
||||
active_profile: Option<&str>,
|
||||
new_name: &str,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'static, Message> {
|
||||
let new_name_owned = new_name.to_string();
|
||||
|
||||
@@ -24,9 +26,7 @@ pub fn view(
|
||||
let mut profile_list = column![].spacing(4);
|
||||
|
||||
if profiles.is_empty() {
|
||||
profile_list = profile_list.push(
|
||||
text("No saved profiles yet.").size(11),
|
||||
);
|
||||
profile_list = profile_list.push(text("No saved profiles yet.").size(11));
|
||||
} else {
|
||||
for profile in profiles {
|
||||
let name = profile.name.clone();
|
||||
@@ -45,9 +45,7 @@ pub fn view(
|
||||
.on_press(Message::DockProfileDelete(name.clone()))
|
||||
.padding([4, 8]);
|
||||
|
||||
let name_text = text(name.clone())
|
||||
.size(12)
|
||||
.width(Length::Fill);
|
||||
let name_text = text(name.clone()).size(12).width(Length::Fill);
|
||||
|
||||
profile_list = profile_list.push(
|
||||
row![apply_btn, name_text, rename_btn, delete_btn]
|
||||
@@ -68,8 +66,7 @@ pub fn view(
|
||||
.on_press(Message::DockProfileSave(new_name_owned.trim().to_string()))
|
||||
.padding([6, 12])
|
||||
} else {
|
||||
button(text("Save Current").size(11))
|
||||
.padding([6, 12])
|
||||
button(text("Save Current").size(11)).padding([6, 12])
|
||||
};
|
||||
|
||||
// Scrollable profile list with fixed max height
|
||||
@@ -94,14 +91,5 @@ pub fn view(
|
||||
.padding(16)
|
||||
.width(420);
|
||||
|
||||
container(dialog)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.5))),
|
||||
..Default::default()
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
super::modal(dialog, colors)
|
||||
}
|
||||
|
||||
@@ -5,14 +5,16 @@
|
||||
//! updates the other to maintain the current aspect ratio.
|
||||
|
||||
use crate::app::Message;
|
||||
use iced::widget::{button, checkbox, column, container, horizontal_rule, row, text, text_input};
|
||||
use iced::{Element, Length};
|
||||
use crate::theme::ThemeColors;
|
||||
use iced::widget::{button, checkbox, column, horizontal_rule, row, text, text_input};
|
||||
use iced::Element;
|
||||
|
||||
/// Build the Image Size dialog.
|
||||
pub fn view(
|
||||
width: u32,
|
||||
height: u32,
|
||||
constrain: bool,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'static, Message> {
|
||||
let width_str = width.to_string();
|
||||
let height_str = height.to_string();
|
||||
@@ -60,14 +62,5 @@ pub fn view(
|
||||
.padding(16)
|
||||
.width(360);
|
||||
|
||||
container(dialog)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.5))),
|
||||
..Default::default()
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
super::modal(dialog, colors)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,57 @@
|
||||
//! Dialog modules — modal dialogs for the Iced GUI.
|
||||
|
||||
pub mod new_image;
|
||||
use crate::app::Message;
|
||||
use crate::theme::ThemeColors;
|
||||
use iced::widget::container;
|
||||
use iced::{Element, Length};
|
||||
|
||||
pub mod about;
|
||||
pub mod adjustments;
|
||||
pub mod canvas_size;
|
||||
pub mod confirm;
|
||||
pub mod dock_profile;
|
||||
pub mod image_size;
|
||||
pub mod canvas_size;
|
||||
pub mod about;
|
||||
pub mod new_image;
|
||||
|
||||
/// Wraps dialog content in an opaque themed card over a translucent full-screen scrim.
|
||||
///
|
||||
/// **Purpose:** Prevents the canvas checkerboard from showing through dialog content while keeping
|
||||
/// the obscured editor visible around the modal.
|
||||
/// **Logic & Workflow:** The inner container supplies the active panel surface, readable inherited
|
||||
/// text, border, and shadow. The outer fill container centers it and draws only the modal scrim.
|
||||
/// **Arguments:** `content` is the complete dialog body and `colors` is the active application
|
||||
/// palette.
|
||||
/// **Returns:** A full-viewport modal element.
|
||||
/// **Side Effects / Dependencies:** None; styling uses only the supplied theme tokens.
|
||||
pub fn modal<'a>(
|
||||
content: impl Into<Element<'a, Message>>,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'a, Message> {
|
||||
let card = container(content).style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_panel)),
|
||||
text_color: Some(colors.text_primary),
|
||||
border: iced::Border::default()
|
||||
.rounded(3)
|
||||
.color(colors.border_high)
|
||||
.width(1),
|
||||
shadow: iced::Shadow {
|
||||
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.45),
|
||||
offset: iced::Vector::new(0.0, 4.0),
|
||||
blur_radius: 18.0,
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
container(card)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.0, 0.0, 0.0, 0.55,
|
||||
))),
|
||||
..Default::default()
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! New Image dialog — create a new document with preset sizes.
|
||||
|
||||
use crate::app::Message;
|
||||
use iced::widget::{button, checkbox, column, container, horizontal_rule, row, scrollable, slider, text, text_input};
|
||||
use crate::theme::ThemeColors;
|
||||
use iced::widget::{
|
||||
button, checkbox, column, horizontal_rule, row, scrollable, slider, text, text_input,
|
||||
};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// A canvas preset size.
|
||||
@@ -12,14 +15,46 @@ struct CanvasPreset {
|
||||
}
|
||||
|
||||
const PRESETS: &[CanvasPreset] = &[
|
||||
CanvasPreset { name: "Custom", width: 0, height: 0 },
|
||||
CanvasPreset { name: "HD (1280×720)", width: 1280, height: 720 },
|
||||
CanvasPreset { name: "Full HD (1920×1080)", width: 1920, height: 1080 },
|
||||
CanvasPreset { name: "4K (3840×2160)", width: 3840, height: 2160 },
|
||||
CanvasPreset { name: "Square (1024×1024)", width: 1024, height: 1024 },
|
||||
CanvasPreset { name: "A4 (2480×3508)", width: 2480, height: 3508 },
|
||||
CanvasPreset { name: "800×600", width: 800, height: 600 },
|
||||
CanvasPreset { name: "640×480", width: 640, height: 480 },
|
||||
CanvasPreset {
|
||||
name: "Custom",
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
CanvasPreset {
|
||||
name: "HD (1280×720)",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
},
|
||||
CanvasPreset {
|
||||
name: "Full HD (1920×1080)",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
CanvasPreset {
|
||||
name: "4K (3840×2160)",
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
},
|
||||
CanvasPreset {
|
||||
name: "Square (1024×1024)",
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
CanvasPreset {
|
||||
name: "A4 (2480×3508)",
|
||||
width: 2480,
|
||||
height: 3508,
|
||||
},
|
||||
CanvasPreset {
|
||||
name: "800×600",
|
||||
width: 800,
|
||||
height: 600,
|
||||
},
|
||||
CanvasPreset {
|
||||
name: "640×480",
|
||||
width: 640,
|
||||
height: 480,
|
||||
},
|
||||
];
|
||||
|
||||
/// Build the New Image dialog.
|
||||
@@ -28,6 +63,7 @@ pub fn view(
|
||||
width: u32,
|
||||
height: u32,
|
||||
transparent: bool,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'static, Message> {
|
||||
let name_owned = name.to_string();
|
||||
let name_input = text_input("Document name", &name_owned)
|
||||
@@ -88,24 +124,5 @@ pub fn view(
|
||||
.padding(16)
|
||||
.width(400);
|
||||
|
||||
// Center the dialog on screen with a semi-transparent backdrop
|
||||
// and a solid panel background for the dialog content itself.
|
||||
let backdrop = container(
|
||||
container(dialog)
|
||||
.padding(1)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgb(0.176, 0.176, 0.176))),
|
||||
border: iced::Border::default().color(iced::Color::from_rgb(0.267, 0.267, 0.267)).width(1),
|
||||
..Default::default()
|
||||
})
|
||||
)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.6))),
|
||||
..Default::default()
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
backdrop.into()
|
||||
super::modal(dialog, colors)
|
||||
}
|
||||
|
||||
@@ -67,6 +67,93 @@ impl PaneType {
|
||||
PaneType::AiScript => "AI Script Generator",
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves an exact user-facing pane label to its dock type.
|
||||
///
|
||||
/// **Arguments:** `label` is the case-sensitive panel name accepted by automation commands.
|
||||
/// **Returns:** The matching pane type, or `None` for unknown labels.
|
||||
/// **Logic & Workflow:** Every supported variant is compared through its canonical label so
|
||||
/// command-line capture and dock UI naming cannot drift independently.
|
||||
/// **Side Effects / Dependencies:** None.
|
||||
pub fn from_label(label: &str) -> Option<Self> {
|
||||
[
|
||||
Self::Canvas,
|
||||
Self::Layers,
|
||||
Self::History,
|
||||
Self::Brushes,
|
||||
Self::Filters,
|
||||
Self::ColorPicker,
|
||||
Self::Properties,
|
||||
Self::Script,
|
||||
Self::AiChat,
|
||||
Self::LayerDetails,
|
||||
Self::Geometry,
|
||||
Self::ToolSettings,
|
||||
Self::AiScript,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|pane| pane.label() == label)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DockState, PaneType};
|
||||
use iced::widget::pane_grid::{Edge, Region, Target};
|
||||
|
||||
#[test]
|
||||
fn pane_labels_round_trip() {
|
||||
assert_eq!(PaneType::from_label("Geometry"), Some(PaneType::Geometry));
|
||||
assert_eq!(PaneType::from_label("geometry"), None);
|
||||
assert_eq!(PaneType::from_label("Unknown"), None);
|
||||
}
|
||||
|
||||
/// Exercises Iced's complete drop target model while proving pane moves never duplicate Canvas.
|
||||
#[test]
|
||||
fn pane_drops_support_all_regions_and_preserve_one_canvas() {
|
||||
let regions = [
|
||||
Region::Center,
|
||||
Region::Edge(Edge::Top),
|
||||
Region::Edge(Edge::Right),
|
||||
Region::Edge(Edge::Bottom),
|
||||
Region::Edge(Edge::Left),
|
||||
];
|
||||
|
||||
for region in regions {
|
||||
let mut dock = DockState::new();
|
||||
let source = pane(&dock, PaneType::Brushes);
|
||||
let target = pane(&dock, PaneType::Layers);
|
||||
dock.pane_grid.drop(source, Target::Pane(target, region));
|
||||
assert_canvas_invariant(&dock);
|
||||
}
|
||||
|
||||
for edge in [Edge::Top, Edge::Right, Edge::Bottom, Edge::Left] {
|
||||
let mut dock = DockState::new();
|
||||
let source = pane(&dock, PaneType::Brushes);
|
||||
dock.pane_grid.drop(source, Target::Edge(edge));
|
||||
assert_canvas_invariant(&dock);
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the pane identifier containing `pane_type` in a test dock.
|
||||
fn pane(dock: &DockState, pane_type: PaneType) -> iced::widget::pane_grid::Pane {
|
||||
dock.pane_grid
|
||||
.iter()
|
||||
.find(|(_, candidate)| **candidate == pane_type)
|
||||
.map(|(pane, _)| *pane)
|
||||
.expect("default dock pane must exist")
|
||||
}
|
||||
|
||||
/// Asserts that a layout operation retained exactly one Canvas payload.
|
||||
fn assert_canvas_invariant(dock: &DockState) {
|
||||
assert_eq!(
|
||||
dock.pane_grid
|
||||
.iter()
|
||||
.filter(|(_, pane_type)| **pane_type == PaneType::Canvas)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The dock layout state, wrapping iced's PaneGrid state.
|
||||
@@ -154,7 +241,8 @@ impl DockState {
|
||||
let pane_grid = State::with_configuration(root_config);
|
||||
|
||||
// Find the Canvas pane (it's the center pane we want as root)
|
||||
let root_pane = pane_grid.iter()
|
||||
let root_pane = pane_grid
|
||||
.iter()
|
||||
.find(|(_, pane_type)| **pane_type == PaneType::Canvas)
|
||||
.map(|(pane, _)| *pane)
|
||||
.unwrap_or_else(|| pane_grid.iter().next().map(|(p, _)| *p).unwrap());
|
||||
@@ -169,22 +257,18 @@ impl DockState {
|
||||
/// Split a pane horizontally with a new panel type.
|
||||
#[allow(dead_code)]
|
||||
pub fn split_horizontal(&mut self, target: Pane, pane_type: PaneType) -> Option<Pane> {
|
||||
let (new_pane, _) = self.pane_grid.split(
|
||||
iced::widget::pane_grid::Axis::Horizontal,
|
||||
target,
|
||||
pane_type,
|
||||
)?;
|
||||
let (new_pane, _) =
|
||||
self.pane_grid
|
||||
.split(iced::widget::pane_grid::Axis::Horizontal, target, pane_type)?;
|
||||
Some(new_pane)
|
||||
}
|
||||
|
||||
/// Split a pane vertically with a new panel type.
|
||||
#[allow(dead_code)]
|
||||
pub fn split_vertical(&mut self, target: Pane, pane_type: PaneType) -> Option<Pane> {
|
||||
let (new_pane, _) = self.pane_grid.split(
|
||||
iced::widget::pane_grid::Axis::Vertical,
|
||||
target,
|
||||
pane_type,
|
||||
)?;
|
||||
let (new_pane, _) =
|
||||
self.pane_grid
|
||||
.split(iced::widget::pane_grid::Axis::Vertical, target, pane_type)?;
|
||||
Some(new_pane)
|
||||
}
|
||||
|
||||
@@ -226,7 +310,9 @@ impl DockState {
|
||||
return;
|
||||
}
|
||||
|
||||
let target_pane = self.pane_grid.iter()
|
||||
let target_pane = self
|
||||
.pane_grid
|
||||
.iter()
|
||||
.find(|(_, t)| **t == PaneType::Canvas)
|
||||
.map(|(p, _)| *p)
|
||||
.or_else(|| self.focused_pane)
|
||||
@@ -234,9 +320,11 @@ impl DockState {
|
||||
|
||||
if let Some(target) = target_pane {
|
||||
let axis = match pane_type {
|
||||
PaneType::Layers | PaneType::Properties | PaneType::History | PaneType::LayerDetails | PaneType::AiScript => {
|
||||
iced::widget::pane_grid::Axis::Horizontal
|
||||
}
|
||||
PaneType::Layers
|
||||
| PaneType::Properties
|
||||
| PaneType::History
|
||||
| PaneType::LayerDetails
|
||||
| PaneType::AiScript => iced::widget::pane_grid::Axis::Horizontal,
|
||||
_ => iced::widget::pane_grid::Axis::Vertical,
|
||||
};
|
||||
if let Some((new_pane, _)) = self.pane_grid.split(axis, target, pane_type) {
|
||||
@@ -253,7 +341,11 @@ impl DockState {
|
||||
/// Toggle a pane: close it if open, reopen it if closed.
|
||||
pub fn toggle_pane(&mut self, pane_type: PaneType) {
|
||||
let is_open = self.pane_grid.iter().any(|(_, t)| *t == pane_type);
|
||||
let pane = self.pane_grid.iter().find(|(_, t)| **t == pane_type).map(|(p, _)| *p);
|
||||
let pane = self
|
||||
.pane_grid
|
||||
.iter()
|
||||
.find(|(_, t)| **t == pane_type)
|
||||
.map(|(p, _)| *p);
|
||||
|
||||
if is_open {
|
||||
// Don't close the canvas pane
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
use crate::app::Message;
|
||||
use crate::dock::state::{DockState, PaneType};
|
||||
use crate::panels::styles;
|
||||
use iced::widget::svg;
|
||||
use crate::panels::typography::TITLE;
|
||||
use crate::theme::ThemeColors;
|
||||
use iced::widget::pane_grid::{Content, TitleBar};
|
||||
use iced::widget::svg;
|
||||
use iced::widget::{button, column, container, pane_grid, row, text};
|
||||
use iced::{Element, Length};
|
||||
|
||||
@@ -40,9 +41,21 @@ pub fn dock_view<'a>(
|
||||
let doc_tab_bar = document_tab_bar(app, colors);
|
||||
let canvas = {
|
||||
let doc = &app.documents[app.active_doc];
|
||||
let pressure = app.tablet_state.lock().map(|s| s.current_pressure()).unwrap_or(1.0);
|
||||
let pressure = app
|
||||
.tablet_state
|
||||
.lock()
|
||||
.map(|s| s.current_pressure())
|
||||
.unwrap_or(1.0);
|
||||
let ts = &app.settings.tool_settings;
|
||||
crate::canvas::view(doc, &app.tool_state, app.marching_ants_offset, pressure, ts.vector_points, ts.vector_sides, ts.vector_radius)
|
||||
crate::canvas::view(
|
||||
doc,
|
||||
&app.tool_state,
|
||||
app.marching_ants_offset,
|
||||
pressure,
|
||||
ts.vector_points,
|
||||
ts.vector_sides,
|
||||
ts.vector_radius,
|
||||
)
|
||||
};
|
||||
column![doc_tab_bar, canvas]
|
||||
.width(Length::Fill)
|
||||
@@ -52,27 +65,39 @@ pub fn dock_view<'a>(
|
||||
// Tools is no longer in the dock — toolbox is a fixed strip on the left
|
||||
PaneType::Layers => {
|
||||
let doc = &app.documents[app.active_doc];
|
||||
crate::panels::layers::view(&doc.cached_layers, doc.engine.active_layer_id(), &doc.engine, colors)
|
||||
crate::panels::layers::view(
|
||||
&doc.cached_layers,
|
||||
doc.engine.active_layer_id(),
|
||||
&doc.engine,
|
||||
colors,
|
||||
)
|
||||
}
|
||||
PaneType::History => {
|
||||
let doc = &app.documents[app.active_doc];
|
||||
crate::panels::history::view(&doc.cached_history, doc.history_current, colors)
|
||||
}
|
||||
PaneType::Brushes => {
|
||||
crate::panels::brushes::view(
|
||||
PaneType::Brushes => crate::panels::brushes::view(
|
||||
app.tool_state.brush_style,
|
||||
app.tool_state.brush_size,
|
||||
app.tool_state.brush_opacity,
|
||||
app.tool_state.brush_hardness,
|
||||
colors,
|
||||
)
|
||||
}
|
||||
PaneType::Filters => {
|
||||
crate::panels::filters::view(app.selected_filter, &app.filter_params, app.filter_preview_active, colors)
|
||||
}
|
||||
PaneType::ColorPicker => {
|
||||
crate::color_picker::view(&app.fg_color, &app.bg_color, &app.recent_colors, app.color_tab)
|
||||
}
|
||||
),
|
||||
PaneType::Filters => crate::panels::filters::view(
|
||||
app.selected_filter,
|
||||
&app.filter_params,
|
||||
app.filter_preview_active,
|
||||
app.documents[app.active_doc]
|
||||
.engine
|
||||
.active_layer_is_editable(),
|
||||
colors,
|
||||
),
|
||||
PaneType::ColorPicker => crate::color_picker::view(
|
||||
&app.fg_color,
|
||||
&app.bg_color,
|
||||
&app.recent_colors,
|
||||
app.color_tab,
|
||||
),
|
||||
PaneType::Properties => {
|
||||
let doc = &app.documents[app.active_doc];
|
||||
let active_id = doc.engine.active_layer_id();
|
||||
@@ -80,8 +105,12 @@ pub fn dock_view<'a>(
|
||||
let layer_name = layer_info.map(|l| l.name.as_str()).unwrap_or("—");
|
||||
let layer_opacity = layer_info.map(|l| l.opacity).unwrap_or(1.0);
|
||||
let layer_visible = layer_info.map(|l| l.visible).unwrap_or(true);
|
||||
let layer_blend_mode = layer_info.map(|l| l.blend_mode).unwrap_or(hcie_engine_api::BlendMode::Normal);
|
||||
let layer_type = layer_info.map(|l| l.layer_type).unwrap_or(hcie_engine_api::LayerType::Raster);
|
||||
let layer_blend_mode = layer_info
|
||||
.map(|l| l.blend_mode)
|
||||
.unwrap_or(hcie_engine_api::BlendMode::Normal);
|
||||
let layer_type = layer_info
|
||||
.map(|l| l.layer_type)
|
||||
.unwrap_or(hcie_engine_api::LayerType::Raster);
|
||||
let layer_width = layer_info.map(|l| l.width).unwrap_or(0);
|
||||
let layer_height = layer_info.map(|l| l.height).unwrap_or(0);
|
||||
let vector_shapes = doc.engine.active_vector_shapes().unwrap_or_default();
|
||||
@@ -110,29 +139,32 @@ pub fn dock_view<'a>(
|
||||
ts2.vector_points,
|
||||
ts2.vector_sides,
|
||||
active_id,
|
||||
app.fg_color,
|
||||
app.bg_color,
|
||||
)
|
||||
}
|
||||
PaneType::Script => {
|
||||
crate::panels::script_panel::view(
|
||||
PaneType::Script => crate::panels::script_panel::view(
|
||||
&app.script_content,
|
||||
app.script_error.as_deref(),
|
||||
app.script_error_line,
|
||||
app.script_is_running,
|
||||
colors,
|
||||
)
|
||||
}
|
||||
PaneType::AiChat => {
|
||||
crate::panels::ai_chat_panel::view(&app.ai_chat_state, colors)
|
||||
}
|
||||
PaneType::AiScript => {
|
||||
crate::panels::ai_script_panel::view(app)
|
||||
}
|
||||
),
|
||||
PaneType::AiChat => crate::panels::ai_chat_panel::view(&app.ai_chat_state, colors),
|
||||
PaneType::AiScript => crate::panels::ai_script_panel::view(app),
|
||||
PaneType::LayerDetails => {
|
||||
let doc = &app.documents[app.active_doc];
|
||||
crate::panels::layer_details::view(&doc.cached_layers, doc.engine.active_layer_id(), colors)
|
||||
crate::panels::layer_details::view(
|
||||
&doc.cached_layers,
|
||||
doc.engine.active_layer_id(),
|
||||
colors,
|
||||
)
|
||||
}
|
||||
PaneType::Geometry => {
|
||||
let shapes = app.documents[app.active_doc].engine.active_vector_shapes().unwrap_or_default();
|
||||
let shapes = app.documents[app.active_doc]
|
||||
.engine
|
||||
.active_vector_shapes()
|
||||
.unwrap_or_default();
|
||||
crate::panels::geometry::view(
|
||||
shapes,
|
||||
colors,
|
||||
@@ -144,7 +176,13 @@ pub fn dock_view<'a>(
|
||||
PaneType::ToolSettings => {
|
||||
let fg = app.fg_color;
|
||||
let draft = app.documents[app.active_doc].text_draft.as_ref();
|
||||
crate::panels::tool_settings::view(&app.tool_state, &app.settings, fg, draft, colors)
|
||||
crate::panels::tool_settings::view(
|
||||
&app.tool_state,
|
||||
&app.settings,
|
||||
fg,
|
||||
draft,
|
||||
colors,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -173,7 +211,10 @@ pub fn dock_view<'a>(
|
||||
.style(move |_theme| pane_grid::Style {
|
||||
hovered_region: pane_grid::Highlight {
|
||||
background: iced::Background::Color(iced::Color::from_rgba(
|
||||
colors.accent.r, colors.accent.g, colors.accent.b, 0.15,
|
||||
colors.accent.r,
|
||||
colors.accent.g,
|
||||
colors.accent.b,
|
||||
0.15,
|
||||
)),
|
||||
border: iced::Border::default().color(colors.accent).width(1),
|
||||
},
|
||||
@@ -194,7 +235,10 @@ pub fn dock_view<'a>(
|
||||
/// Shows a horizontal tab for each open document. The active document tab
|
||||
/// is highlighted. Each tab shows the document name and a close button.
|
||||
/// Modified documents show an asterisk (*). Tabs have hover feedback.
|
||||
fn document_tab_bar<'a>(app: &'a crate::app::HcieIcedApp, colors: ThemeColors) -> Element<'a, Message> {
|
||||
fn document_tab_bar<'a>(
|
||||
app: &'a crate::app::HcieIcedApp,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'a, Message> {
|
||||
let mut tabs = row![].spacing(0);
|
||||
|
||||
for (idx, doc) in app.documents.iter().enumerate() {
|
||||
@@ -219,33 +263,43 @@ fn document_tab_bar<'a>(app: &'a crate::app::HcieIcedApp, colors: ThemeColors) -
|
||||
let tab = button(
|
||||
row![
|
||||
tab_text,
|
||||
iced::widget::tooltip(
|
||||
button(text("×").size(10))
|
||||
.on_press(Message::NoOp)
|
||||
.on_press(Message::DocClose(idx))
|
||||
.padding([0, 4])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
iced::widget::button::Status::Hovered => {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.bg_active)),
|
||||
text_color: c.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
background: Some(iced::Background::Color(
|
||||
iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0),
|
||||
)),
|
||||
text_color: c.text_secondary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
),
|
||||
text(format!("Close {}", doc.name)).size(11),
|
||||
iced::widget::tooltip::Position::Bottom,
|
||||
),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center)
|
||||
.align_y(iced::Alignment::Center),
|
||||
)
|
||||
.on_press(Message::DocSwitch(idx))
|
||||
.padding([4, 8])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
|
||||
iced::widget::button::Status::Active => {
|
||||
if active {
|
||||
iced::widget::button::Style {
|
||||
@@ -286,8 +340,8 @@ fn document_tab_bar<'a>(app: &'a crate::app::HcieIcedApp, colors: ThemeColors) -
|
||||
text_color: c.accent,
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
tabs = tabs.push(tab);
|
||||
}
|
||||
@@ -296,8 +350,8 @@ fn document_tab_bar<'a>(app: &'a crate::app::HcieIcedApp, colors: ThemeColors) -
|
||||
let new_btn = button(text("+").size(12))
|
||||
.on_press(Message::DialogOpen(crate::app::ActiveDialog::NewImage))
|
||||
.padding([4, 8])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_hover)),
|
||||
text_color: colors.text_primary,
|
||||
@@ -305,13 +359,15 @@ fn document_tab_bar<'a>(app: &'a crate::app::HcieIcedApp, colors: ThemeColors) -
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.0, 0.0, 0.0, 0.0,
|
||||
))),
|
||||
text_color: colors.text_secondary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
tabs = tabs.push(new_btn);
|
||||
|
||||
@@ -345,7 +401,11 @@ fn pane_type_icon(pane_type: PaneType) -> Option<&'static str> {
|
||||
/// Shows a panel type icon + label with close and maximize buttons.
|
||||
/// Uses `panel_header` style from the theme for consistent header appearance.
|
||||
/// Buttons have hover feedback: transparent by default, bg_hover on hover.
|
||||
fn make_title_bar<'a>(pane: pane_grid::Pane, pane_type: PaneType, colors: ThemeColors) -> TitleBar<'a, Message> {
|
||||
fn make_title_bar<'a>(
|
||||
pane: pane_grid::Pane,
|
||||
pane_type: PaneType,
|
||||
colors: ThemeColors,
|
||||
) -> TitleBar<'a, Message> {
|
||||
// Panel type icon
|
||||
let icon_paths = [
|
||||
std::path::PathBuf::from("hcie-iced-app/assets"),
|
||||
@@ -355,29 +415,39 @@ fn make_title_bar<'a>(pane: pane_grid::Pane, pane_type: PaneType, colors: ThemeC
|
||||
let title_content: Element<'_, Message> = if let Some(icon_file) = pane_type_icon(pane_type) {
|
||||
if let Some(base) = icon_paths.iter().find(|p| p.join(icon_file).exists()) {
|
||||
let handle = svg::Handle::from_path(base.join(icon_file));
|
||||
let icon = svg(handle)
|
||||
.width(14)
|
||||
.height(14)
|
||||
.style(move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style {
|
||||
let icon = svg(handle).width(14).height(14).style(
|
||||
move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style {
|
||||
color: Some(colors.text_secondary),
|
||||
});
|
||||
row![icon, text(pane_type.label()).size(11).color(colors.text_secondary)]
|
||||
},
|
||||
);
|
||||
row![
|
||||
icon,
|
||||
text(pane_type.label())
|
||||
.size(TITLE)
|
||||
.color(colors.text_secondary)
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center)
|
||||
.into()
|
||||
} else {
|
||||
text(pane_type.label()).size(11).color(colors.text_secondary).into()
|
||||
text(pane_type.label())
|
||||
.size(TITLE)
|
||||
.color(colors.text_secondary)
|
||||
.into()
|
||||
}
|
||||
} else {
|
||||
text(pane_type.label()).size(11).color(colors.text_secondary).into()
|
||||
text(pane_type.label())
|
||||
.size(TITLE)
|
||||
.color(colors.text_secondary)
|
||||
.into()
|
||||
};
|
||||
|
||||
// Close button with hover state
|
||||
let close_btn = button(text("×").size(11))
|
||||
.on_press(Message::PaneClose(pane))
|
||||
.padding([2, 6])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_hover)),
|
||||
text_color: colors.text_primary,
|
||||
@@ -391,20 +461,22 @@ fn make_title_bar<'a>(pane: pane_grid::Pane, pane_type: PaneType, colors: ThemeC
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.0, 0.0, 0.0, 0.0,
|
||||
))),
|
||||
text_color: colors.text_secondary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Maximize button with hover state
|
||||
let maximize_btn = button(text("□").size(11))
|
||||
.on_press(Message::PaneMaximize(pane))
|
||||
.padding([2, 6])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_hover)),
|
||||
text_color: colors.text_primary,
|
||||
@@ -418,18 +490,25 @@ fn make_title_bar<'a>(pane: pane_grid::Pane, pane_type: PaneType, colors: ThemeC
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.0, 0.0, 0.0, 0.0,
|
||||
))),
|
||||
text_color: colors.text_secondary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
TitleBar::new(
|
||||
row![title_content, iced::widget::Space::with_width(Length::Fill), maximize_btn, close_btn]
|
||||
row![
|
||||
title_content,
|
||||
iced::widget::Space::with_width(Length::Fill),
|
||||
maximize_btn,
|
||||
close_btn
|
||||
]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center)
|
||||
.align_y(iced::Alignment::Center),
|
||||
)
|
||||
.style(move |_theme| styles::panel_header(colors))
|
||||
}
|
||||
|
||||
@@ -148,7 +148,12 @@ pub fn start_evdev_listener(state: Arc<Mutex<TabletState>>) {
|
||||
let pr = state[abs_pressure.0 as usize];
|
||||
log::info!(
|
||||
"[tablet-evdev] ABS_X=[{},{}], ABS_Y=[{},{}], ABS_PRESSURE=[{},{}]",
|
||||
xr.minimum, xr.maximum, yr.minimum, yr.maximum, pr.minimum, pr.maximum
|
||||
xr.minimum,
|
||||
xr.maximum,
|
||||
yr.minimum,
|
||||
yr.maximum,
|
||||
pr.minimum,
|
||||
pr.maximum
|
||||
);
|
||||
(
|
||||
(xr.minimum, xr.maximum),
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! HCIE Iced GUI library — re-exports selection types for integration tests.
|
||||
//! HCIE Iced GUI library - exposes testable, UI-independent application components.
|
||||
|
||||
pub mod cli;
|
||||
pub mod raster;
|
||||
pub mod selection;
|
||||
pub mod vector_edit;
|
||||
|
||||
@@ -7,18 +7,22 @@ mod ai_script;
|
||||
mod app;
|
||||
mod brush_import;
|
||||
mod canvas;
|
||||
mod cli;
|
||||
mod color_picker;
|
||||
mod dialogs;
|
||||
mod dock;
|
||||
mod i18n;
|
||||
mod io;
|
||||
mod panels;
|
||||
mod raster;
|
||||
mod screenshot;
|
||||
mod script;
|
||||
mod selection;
|
||||
mod settings;
|
||||
mod shape_sync;
|
||||
mod sidebar;
|
||||
mod theme;
|
||||
mod vector_edit;
|
||||
mod viewer;
|
||||
|
||||
use app::HcieIcedApp;
|
||||
@@ -29,49 +33,39 @@ fn main() {
|
||||
|
||||
log::info!("Starting HCIE Iced GUI");
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut load_path: Option<std::path::PathBuf> = None;
|
||||
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--help" | "-h" => {
|
||||
println!("Usage: hcie-iced [OPTIONS] [FILE]");
|
||||
println!();
|
||||
println!("HCIE — Iced Edition");
|
||||
println!();
|
||||
println!("Options:");
|
||||
println!(" -h, --help Print this help message");
|
||||
println!();
|
||||
println!("Arguments:");
|
||||
println!(" FILE Image file to open on startup");
|
||||
std::process::exit(0);
|
||||
let args = match cli::parse_args(std::env::args().skip(1)) {
|
||||
Ok(args) => args,
|
||||
Err(error) => {
|
||||
eprintln!("{error}\n\n{}", cli::help_text());
|
||||
std::process::exit(2);
|
||||
}
|
||||
other if !other.starts_with('-') => {
|
||||
load_path = Some(std::path::PathBuf::from(other));
|
||||
}
|
||||
unknown => {
|
||||
eprintln!("Unknown argument: {}", unknown);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
};
|
||||
if args.help {
|
||||
println!("{}", cli::help_text());
|
||||
return;
|
||||
}
|
||||
|
||||
// Start tablet evdev listener
|
||||
let tablet_state = std::sync::Arc::new(std::sync::Mutex::new(
|
||||
io::tablet::TabletState::new(),
|
||||
));
|
||||
let tablet_state = std::sync::Arc::new(std::sync::Mutex::new(io::tablet::TabletState::new()));
|
||||
io::tablet::start_evdev_listener(tablet_state.clone());
|
||||
|
||||
let _ = iced::application("HCIE — Iced Edition", HcieIcedApp::update, HcieIcedApp::view)
|
||||
let load_path = args.load_path;
|
||||
let screenshot = args.screenshot;
|
||||
if let Err(error) = iced::application(
|
||||
"HCIE — Iced Edition",
|
||||
HcieIcedApp::update,
|
||||
HcieIcedApp::view,
|
||||
)
|
||||
.subscription(HcieIcedApp::subscription)
|
||||
.theme(HcieIcedApp::theme)
|
||||
.window_size((1280.0, 800.0))
|
||||
.decorations(false)
|
||||
.run_with(move || {
|
||||
let (mut app, init_task) = HcieIcedApp::new(load_path);
|
||||
let (mut app, init_task) = HcieIcedApp::new(load_path, screenshot);
|
||||
app.tablet_state = tablet_state;
|
||||
(app, init_task)
|
||||
});
|
||||
}) {
|
||||
eprintln!("failed to run HCIE Iced GUI: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,21 @@ use iced::{Element, Length};
|
||||
pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Message> {
|
||||
// ── Provider selector row ──
|
||||
let provider_label = text("Provider:").size(10).color(colors.text_secondary);
|
||||
let ollama_btn = make_provider_btn("Ollama", state.config.provider == crate::ai_chat::AiProvider::Ollama, colors);
|
||||
let claude_btn = make_provider_btn("Claude", state.config.provider == crate::ai_chat::AiProvider::Claude, colors);
|
||||
let openai_btn = make_provider_btn("OpenAI", state.config.provider == crate::ai_chat::AiProvider::OpenAI, colors);
|
||||
let ollama_btn = make_provider_btn(
|
||||
"Ollama",
|
||||
state.config.provider == crate::ai_chat::AiProvider::Ollama,
|
||||
colors,
|
||||
);
|
||||
let claude_btn = make_provider_btn(
|
||||
"Claude",
|
||||
state.config.provider == crate::ai_chat::AiProvider::Claude,
|
||||
colors,
|
||||
);
|
||||
let openai_btn = make_provider_btn(
|
||||
"OpenAI",
|
||||
state.config.provider == crate::ai_chat::AiProvider::OpenAI,
|
||||
colors,
|
||||
);
|
||||
let provider_row = row![provider_label, ollama_btn, claude_btn, openai_btn]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center);
|
||||
@@ -35,28 +47,37 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
|
||||
.size(10)
|
||||
.width(Length::Fill);
|
||||
|
||||
let config_row = row![
|
||||
url_label,
|
||||
url_input,
|
||||
model_label,
|
||||
model_input,
|
||||
]
|
||||
let config_row = row![url_label, url_input, model_label, model_input,]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
// ── Toggles row ──
|
||||
let reasoning_label = text("Reasoning").size(9).color(colors.text_secondary);
|
||||
let reasoning_btn = button(
|
||||
text(if state.config.reasoning_enabled { "ON" } else { "OFF" }).size(9)
|
||||
text(if state.config.reasoning_enabled {
|
||||
"ON"
|
||||
} else {
|
||||
"OFF"
|
||||
})
|
||||
.size(9),
|
||||
)
|
||||
.on_press(Message::AiChatReasoningToggled(!state.config.reasoning_enabled))
|
||||
.on_press(Message::AiChatReasoningToggled(
|
||||
!state.config.reasoning_enabled,
|
||||
))
|
||||
.padding([2, 6]);
|
||||
|
||||
let canvas_label = text("Canvas ctx").size(9).color(colors.text_secondary);
|
||||
let canvas_btn = button(
|
||||
text(if state.config.canvas_context { "ON" } else { "OFF" }).size(9)
|
||||
text(if state.config.canvas_context {
|
||||
"ON"
|
||||
} else {
|
||||
"OFF"
|
||||
})
|
||||
.size(9),
|
||||
)
|
||||
.on_press(Message::AiChatCanvasContextToggled(!state.config.canvas_context))
|
||||
.on_press(Message::AiChatCanvasContextToggled(
|
||||
!state.config.canvas_context,
|
||||
))
|
||||
.padding([2, 6]);
|
||||
|
||||
let toggles_row = row![
|
||||
@@ -78,12 +99,18 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
|
||||
column![
|
||||
text("AI Creative Assistant").size(13).color(colors.accent),
|
||||
text("").size(4),
|
||||
text("Hello! I am your AI Drawing Partner.").size(11).color(colors.text_secondary),
|
||||
text("Hello! I am your AI Drawing Partner.")
|
||||
.size(11)
|
||||
.color(colors.text_secondary),
|
||||
text("").size(4),
|
||||
text("Ask me to paint landscapes, add details,").size(10).color(colors.text_disabled),
|
||||
text("apply blur filters, or manipulate layers!").size(10).color(colors.text_disabled),
|
||||
text("Ask me to paint landscapes, add details,")
|
||||
.size(10)
|
||||
.color(colors.text_disabled),
|
||||
text("apply blur filters, or manipulate layers!")
|
||||
.size(10)
|
||||
.color(colors.text_disabled),
|
||||
]
|
||||
.spacing(2)
|
||||
.spacing(2),
|
||||
)
|
||||
.padding(12)
|
||||
.width(Length::Fill)
|
||||
@@ -105,16 +132,24 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
|
||||
if state.config.reasoning_enabled && !state.current_reasoning.is_empty() {
|
||||
let reasoning_bubble = container(
|
||||
column![
|
||||
text("Deep Thoughts:").size(10).color(iced::Color::from_rgb(0.85, 0.47, 0.02)),
|
||||
text(state.current_reasoning.clone()).size(10).color(colors.text_secondary),
|
||||
text("Deep Thoughts:")
|
||||
.size(10)
|
||||
.color(iced::Color::from_rgb(0.85, 0.47, 0.02)),
|
||||
text(state.current_reasoning.clone())
|
||||
.size(10)
|
||||
.color(colors.text_secondary),
|
||||
]
|
||||
.spacing(2)
|
||||
.spacing(2),
|
||||
)
|
||||
.padding(8)
|
||||
.width(Length::Fill)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.15, 0.12, 0.08, 1.0))),
|
||||
border: iced::Border::default().rounded(6).color(iced::Color::from_rgb(0.85, 0.47, 0.02)),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.15, 0.12, 0.08, 1.0,
|
||||
))),
|
||||
border: iced::Border::default()
|
||||
.rounded(6)
|
||||
.color(iced::Color::from_rgb(0.85, 0.47, 0.02)),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
@@ -124,13 +159,19 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
|
||||
// Render streaming response if in progress
|
||||
if state.is_streaming && !state.current_response.is_empty() {
|
||||
let streaming_bubble = container(
|
||||
text(state.current_response.clone()).size(11).color(colors.text_primary)
|
||||
text(state.current_response.clone())
|
||||
.size(11)
|
||||
.color(colors.text_primary),
|
||||
)
|
||||
.padding(8)
|
||||
.width(Length::Fill)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.18, 0.22, 0.28, 1.0))),
|
||||
border: iced::Border::default().rounded(6).color(iced::Color::from_rgba(0.29, 0.62, 1.0, 0.5)),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.18, 0.22, 0.28, 1.0,
|
||||
))),
|
||||
border: iced::Border::default()
|
||||
.rounded(6)
|
||||
.color(iced::Color::from_rgba(0.29, 0.62, 1.0, 0.5)),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
@@ -141,7 +182,9 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
|
||||
if state.is_streaming {
|
||||
let indicator = row![
|
||||
text("...").size(10).color(colors.text_disabled),
|
||||
text("AI is thinking...").size(10).color(colors.text_disabled),
|
||||
text("AI is thinking...")
|
||||
.size(10)
|
||||
.color(colors.text_disabled),
|
||||
]
|
||||
.spacing(4);
|
||||
|
||||
@@ -155,15 +198,24 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
|
||||
.size(11)
|
||||
.width(Length::Fill);
|
||||
|
||||
let send_btn = button(text("Send").size(10))
|
||||
.on_press(Message::AiChatSend)
|
||||
.padding([6, 10]);
|
||||
let send_btn = action_button(
|
||||
"Send",
|
||||
(!state.is_streaming && !state.input.trim().is_empty()).then_some(Message::AiChatSend),
|
||||
colors,
|
||||
);
|
||||
let stream_btn = action_button(
|
||||
"Cancel",
|
||||
state.is_streaming.then_some(Message::AiChatCancel),
|
||||
colors,
|
||||
);
|
||||
let clear_btn = action_button("Clear", Some(Message::AiChatClear), colors);
|
||||
let copy_btn = action_button(
|
||||
"Copy all",
|
||||
(!state.messages.is_empty() || state.is_streaming).then_some(Message::AiChatCopyTranscript),
|
||||
colors,
|
||||
);
|
||||
|
||||
let clear_btn = button(text("Clear").size(10))
|
||||
.on_press(Message::AiChatClear)
|
||||
.padding([6, 10]);
|
||||
|
||||
let input_row = row![input_field, send_btn, clear_btn]
|
||||
let input_row = row![input_field, send_btn, stream_btn, copy_btn, clear_btn]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
@@ -172,6 +224,9 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
|
||||
text("AI Assistant").size(13).color(colors.text_primary),
|
||||
provider_row,
|
||||
config_row,
|
||||
text("Model discovery is unavailable; enter the exact provider model ID.")
|
||||
.size(9)
|
||||
.color(colors.text_disabled),
|
||||
toggles_row,
|
||||
horizontal_rule(1),
|
||||
scrollable(msg_list).height(Length::Fill),
|
||||
@@ -188,26 +243,82 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Build an action button whose disabled, hover, pressed, and focus-border states are explicit.
|
||||
fn action_button(
|
||||
label: &'static str,
|
||||
message: Option<Message>,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'static, Message> {
|
||||
let mut control = button(text(label).size(10)).padding([6, 10]);
|
||||
if let Some(message) = message {
|
||||
control = control.on_press(message);
|
||||
}
|
||||
control
|
||||
.style(move |_theme, status| {
|
||||
let (background, text_color, border) = match status {
|
||||
iced::widget::button::Status::Active => {
|
||||
(colors.bg_active, colors.text_primary, colors.border_high)
|
||||
}
|
||||
iced::widget::button::Status::Hovered => {
|
||||
(colors.accent_hover, iced::Color::WHITE, colors.accent)
|
||||
}
|
||||
iced::widget::button::Status::Pressed => {
|
||||
(colors.accent, iced::Color::WHITE, colors.text_primary)
|
||||
}
|
||||
iced::widget::button::Status::Disabled => {
|
||||
(colors.bg_app, colors.text_disabled, colors.border_low)
|
||||
}
|
||||
};
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(background)),
|
||||
text_color,
|
||||
border: iced::Border::default().rounded(3).color(border).width(1),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Render a single chat message as a styled bubble.
|
||||
fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'static, Message> {
|
||||
if msg.is_reasoning {
|
||||
return container(
|
||||
column![
|
||||
text("Reasoning").size(9).color(colors.text_secondary),
|
||||
text(msg.content.clone())
|
||||
.size(10)
|
||||
.color(colors.text_secondary),
|
||||
]
|
||||
.spacing(2),
|
||||
)
|
||||
.padding(8)
|
||||
.width(Length::Fill)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_app)),
|
||||
border: iced::Border::default().rounded(6).color(colors.border_high),
|
||||
..Default::default()
|
||||
})
|
||||
.into();
|
||||
}
|
||||
match msg.role.as_str() {
|
||||
"user" => {
|
||||
// User bubble — right-aligned, blue tint
|
||||
let label = text("You").size(9).color(colors.accent);
|
||||
let content = text(msg.content.clone()).size(11).color(colors.text_primary);
|
||||
let bubble = container(
|
||||
column![label, content].spacing(2)
|
||||
)
|
||||
let content = text(msg.content.clone())
|
||||
.size(11)
|
||||
.color(colors.text_primary);
|
||||
let bubble = container(column![label, content].spacing(2))
|
||||
.padding(8)
|
||||
.max_width(400.0)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.16, 0.33, 0.58, 1.0))),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.16, 0.33, 0.58, 1.0,
|
||||
))),
|
||||
border: iced::Border::default().rounded(6),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
row![iced::widget::Space::with_width(Length::Fill), bubble]
|
||||
.into()
|
||||
row![iced::widget::Space::with_width(Length::Fill), bubble].into()
|
||||
}
|
||||
"tool" => {
|
||||
// Tool result — monospace pill
|
||||
@@ -224,40 +335,45 @@ fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'sta
|
||||
_ => {
|
||||
// Assistant bubble — left-aligned, slate tint
|
||||
let label = text("AI").size(9).color(colors.accent_green);
|
||||
let content = text(msg.content.clone()).size(11).color(colors.text_primary);
|
||||
let bubble = container(
|
||||
column![label, content].spacing(2)
|
||||
)
|
||||
let content = text(msg.content.clone())
|
||||
.size(11)
|
||||
.color(colors.text_primary);
|
||||
let bubble = container(column![label, content].spacing(2))
|
||||
.padding(8)
|
||||
.max_width(400.0)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.18, 0.22, 0.28, 1.0))),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.18, 0.22, 0.28, 1.0,
|
||||
))),
|
||||
border: iced::Border::default().rounded(6),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
row![bubble, iced::widget::Space::with_width(Length::Fill)]
|
||||
.into()
|
||||
row![bubble, iced::widget::Space::with_width(Length::Fill)].into()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a provider selection button with active state highlighting.
|
||||
fn make_provider_btn(label: &str, is_active: bool, colors: ThemeColors) -> Element<'static, Message> {
|
||||
fn make_provider_btn(
|
||||
label: &str,
|
||||
is_active: bool,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'static, Message> {
|
||||
let label_owned = label.to_string();
|
||||
let msg = match label {
|
||||
"Ollama" => Message::AiChatProviderChanged("Ollama".to_string()),
|
||||
"Claude" => Message::AiChatProviderChanged("Claude".to_string()),
|
||||
"OpenAI" => Message::AiChatProviderChanged("OpenAI".to_string()),
|
||||
_ => Message::NoOp,
|
||||
_ => unreachable!("provider buttons are built from known labels"),
|
||||
};
|
||||
|
||||
button(text(label_owned).size(10))
|
||||
.on_press(msg)
|
||||
.padding([4, 8])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Active | iced::widget::button::Status::Hovered => {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
|
||||
iced::widget::button::Status::Active => {
|
||||
if is_active {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.accent)),
|
||||
@@ -274,13 +390,33 @@ fn make_provider_btn(label: &str, is_active: bool, colors: ThemeColors) -> Eleme
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_hover)),
|
||||
text_color: colors.text_secondary,
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(if is_active {
|
||||
colors.accent_hover
|
||||
} else {
|
||||
colors.bg_active
|
||||
})),
|
||||
text_color: if is_active {
|
||||
iced::Color::WHITE
|
||||
} else {
|
||||
colors.text_primary
|
||||
},
|
||||
border: iced::Border::default().rounded(4).color(colors.border_high),
|
||||
..Default::default()
|
||||
},
|
||||
iced::widget::button::Status::Pressed => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_active)),
|
||||
text_color: colors.text_primary,
|
||||
border: iced::Border::default().rounded(4).color(colors.accent),
|
||||
..Default::default()
|
||||
},
|
||||
iced::widget::button::Status::Disabled => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_app)),
|
||||
text_color: colors.text_disabled,
|
||||
border: iced::Border::default().rounded(4),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use iced::{Element, Length};
|
||||
/// action buttons (Generate, Clear, Run, Copy), and the generated script
|
||||
/// output with error display.
|
||||
pub fn view(app: &HcieIcedApp) -> Element<'static, Message> {
|
||||
let colors = app.theme_state.colors();
|
||||
// ── Provider selector row ────────────────────────────
|
||||
let provider_label = text("Provider").size(10);
|
||||
let ollama_btn = button(text("Ollama").size(10))
|
||||
@@ -50,13 +51,28 @@ pub fn view(app: &HcieIcedApp) -> Element<'static, Message> {
|
||||
.padding([6, 12])
|
||||
};
|
||||
|
||||
let run_btn = button(text("Run Script").size(11))
|
||||
let run_btn = if app.ai_script_output.is_empty() || app.ai_script_is_running {
|
||||
button(text("Run Script").size(11)).padding([6, 12])
|
||||
} else {
|
||||
button(text("Run Script").size(11))
|
||||
.on_press(Message::AiScriptRun)
|
||||
.padding([6, 12]);
|
||||
.padding([6, 12])
|
||||
};
|
||||
|
||||
let copy_btn = button(text("Copy to Editor").size(11))
|
||||
let copy_btn = if app.ai_script_output.is_empty() {
|
||||
button(text("To Editor").size(11)).padding([6, 12])
|
||||
} else {
|
||||
button(text("To Editor").size(11))
|
||||
.on_press(Message::AiScriptCopy)
|
||||
.padding([6, 12]);
|
||||
.padding([6, 12])
|
||||
};
|
||||
let copy_output_btn = if app.ai_script_output.is_empty() {
|
||||
button(text("Copy Output").size(11)).padding([6, 12])
|
||||
} else {
|
||||
button(text("Copy Output").size(11))
|
||||
.on_press(Message::AiScriptCopyOutput)
|
||||
.padding([6, 12])
|
||||
};
|
||||
|
||||
let clear_btn = button(text("Clear").size(11))
|
||||
.on_press(Message::AiScriptClear)
|
||||
@@ -69,13 +85,11 @@ pub fn view(app: &HcieIcedApp) -> Element<'static, Message> {
|
||||
text(app.ai_script_output.clone()).size(10)
|
||||
};
|
||||
|
||||
let script_area = container(
|
||||
scrollable(script_content).height(Length::Fill)
|
||||
)
|
||||
let script_area = container(scrollable(script_content).height(Length::Fill))
|
||||
.padding(8)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgb(0.12, 0.12, 0.12))),
|
||||
border: iced::Border::default().rounded(4),
|
||||
background: Some(iced::Background::Color(colors.bg_app)),
|
||||
border: iced::Border::default().rounded(4).color(colors.border_high),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
@@ -83,10 +97,18 @@ pub fn view(app: &HcieIcedApp) -> Element<'static, Message> {
|
||||
let error_section: Element<'static, Message> = if let Some(ref err) = app.ai_script_error {
|
||||
text(format!("Error: {}", err))
|
||||
.size(10)
|
||||
.color(iced::Color::from_rgb(0.9, 0.3, 0.3))
|
||||
.color(colors.danger)
|
||||
.into()
|
||||
} else {
|
||||
column![].into()
|
||||
app.ai_script_notice.as_ref().map_or_else(
|
||||
|| column![].into(),
|
||||
|notice| {
|
||||
text(notice.clone())
|
||||
.size(10)
|
||||
.color(colors.accent_green)
|
||||
.into()
|
||||
},
|
||||
)
|
||||
};
|
||||
|
||||
// ── Assemble panel ───────────────────────────────────
|
||||
@@ -97,6 +119,9 @@ pub fn view(app: &HcieIcedApp) -> Element<'static, Message> {
|
||||
provider_selector,
|
||||
url_input,
|
||||
model_input,
|
||||
text("Model discovery is unavailable; enter the exact provider model ID.")
|
||||
.size(9)
|
||||
.color(colors.text_disabled),
|
||||
horizontal_rule(1),
|
||||
prompt_input,
|
||||
row![generate_btn, clear_btn].spacing(4),
|
||||
@@ -104,7 +129,7 @@ pub fn view(app: &HcieIcedApp) -> Element<'static, Message> {
|
||||
script_area,
|
||||
error_section,
|
||||
horizontal_rule(1),
|
||||
row![run_btn, copy_btn].spacing(4),
|
||||
row![run_btn, copy_btn, copy_output_btn].spacing(4),
|
||||
]
|
||||
.spacing(4)
|
||||
.padding(8);
|
||||
@@ -113,8 +138,8 @@ pub fn view(app: &HcieIcedApp) -> Element<'static, Message> {
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgb(0.18, 0.18, 0.18))),
|
||||
border: iced::Border::default().color(iced::Color::from_rgb(0.3, 0.3, 0.3)).width(1),
|
||||
background: Some(iced::Background::Color(colors.bg_panel)),
|
||||
border: iced::Border::default().color(colors.border_high).width(1),
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
|
||||
@@ -12,7 +12,9 @@ use crate::app::Message;
|
||||
use crate::panels::styles;
|
||||
use crate::theme::ThemeColors;
|
||||
use hcie_engine_api::BrushStyle;
|
||||
use iced::widget::{button, column, container, horizontal_rule, row, scrollable, slider, text};
|
||||
use iced::widget::{
|
||||
button, column, container, horizontal_rule, row, scrollable, slider, svg, text,
|
||||
};
|
||||
use iced::{Element, Length};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -35,30 +37,118 @@ enum BrushCategory {
|
||||
|
||||
const BRUSH_PRESETS: &[BrushPreset] = &[
|
||||
// Drawing
|
||||
BrushPreset { name: "Default", style: BrushStyle::Default, category: BrushCategory::Drawing },
|
||||
BrushPreset { name: "Pencil", style: BrushStyle::Pencil, category: BrushCategory::Drawing },
|
||||
BrushPreset { name: "Pen", style: BrushStyle::Pen, category: BrushCategory::Drawing },
|
||||
BrushPreset { name: "Ink Pen", style: BrushStyle::InkPen, category: BrushCategory::Drawing },
|
||||
BrushPreset { name: "Calligraphy", style: BrushStyle::Calligraphy, category: BrushCategory::Drawing },
|
||||
BrushPreset { name: "Sketch", style: BrushStyle::Sketch, category: BrushCategory::Drawing },
|
||||
BrushPreset { name: "Marker", style: BrushStyle::Marker, category: BrushCategory::Drawing },
|
||||
BrushPreset { name: "Hatch", style: BrushStyle::Hatch, category: BrushCategory::Drawing },
|
||||
BrushPreset {
|
||||
name: "Default",
|
||||
style: BrushStyle::Default,
|
||||
category: BrushCategory::Drawing,
|
||||
},
|
||||
BrushPreset {
|
||||
name: "Pencil",
|
||||
style: BrushStyle::Pencil,
|
||||
category: BrushCategory::Drawing,
|
||||
},
|
||||
BrushPreset {
|
||||
name: "Pen",
|
||||
style: BrushStyle::Pen,
|
||||
category: BrushCategory::Drawing,
|
||||
},
|
||||
BrushPreset {
|
||||
name: "Ink Pen",
|
||||
style: BrushStyle::InkPen,
|
||||
category: BrushCategory::Drawing,
|
||||
},
|
||||
BrushPreset {
|
||||
name: "Calligraphy",
|
||||
style: BrushStyle::Calligraphy,
|
||||
category: BrushCategory::Drawing,
|
||||
},
|
||||
BrushPreset {
|
||||
name: "Sketch",
|
||||
style: BrushStyle::Sketch,
|
||||
category: BrushCategory::Drawing,
|
||||
},
|
||||
BrushPreset {
|
||||
name: "Marker",
|
||||
style: BrushStyle::Marker,
|
||||
category: BrushCategory::Drawing,
|
||||
},
|
||||
BrushPreset {
|
||||
name: "Hatch",
|
||||
style: BrushStyle::Hatch,
|
||||
category: BrushCategory::Drawing,
|
||||
},
|
||||
// Painting
|
||||
BrushPreset { name: "Oil", style: BrushStyle::Oil, category: BrushCategory::Painting },
|
||||
BrushPreset { name: "Charcoal", style: BrushStyle::Charcoal, category: BrushCategory::Painting },
|
||||
BrushPreset { name: "Watercolor", style: BrushStyle::Watercolor, category: BrushCategory::Painting },
|
||||
BrushPreset { name: "Crayon", style: BrushStyle::Crayon, category: BrushCategory::Painting },
|
||||
BrushPreset { name: "Airbrush", style: BrushStyle::Airbrush, category: BrushCategory::Painting },
|
||||
BrushPreset { name: "Spray", style: BrushStyle::Spray, category: BrushCategory::Painting },
|
||||
BrushPreset { name: "Soft Round", style: BrushStyle::SoftRound, category: BrushCategory::Painting },
|
||||
BrushPreset { name: "Hard Round", style: BrushStyle::HardRound, category: BrushCategory::Painting },
|
||||
BrushPreset {
|
||||
name: "Oil",
|
||||
style: BrushStyle::Oil,
|
||||
category: BrushCategory::Painting,
|
||||
},
|
||||
BrushPreset {
|
||||
name: "Charcoal",
|
||||
style: BrushStyle::Charcoal,
|
||||
category: BrushCategory::Painting,
|
||||
},
|
||||
BrushPreset {
|
||||
name: "Watercolor",
|
||||
style: BrushStyle::Watercolor,
|
||||
category: BrushCategory::Painting,
|
||||
},
|
||||
BrushPreset {
|
||||
name: "Crayon",
|
||||
style: BrushStyle::Crayon,
|
||||
category: BrushCategory::Painting,
|
||||
},
|
||||
BrushPreset {
|
||||
name: "Airbrush",
|
||||
style: BrushStyle::Airbrush,
|
||||
category: BrushCategory::Painting,
|
||||
},
|
||||
BrushPreset {
|
||||
name: "Spray",
|
||||
style: BrushStyle::Spray,
|
||||
category: BrushCategory::Painting,
|
||||
},
|
||||
BrushPreset {
|
||||
name: "Soft Round",
|
||||
style: BrushStyle::SoftRound,
|
||||
category: BrushCategory::Painting,
|
||||
},
|
||||
BrushPreset {
|
||||
name: "Hard Round",
|
||||
style: BrushStyle::HardRound,
|
||||
category: BrushCategory::Painting,
|
||||
},
|
||||
// Effects
|
||||
BrushPreset { name: "Star", style: BrushStyle::Star, category: BrushCategory::Effects },
|
||||
BrushPreset { name: "Noise", style: BrushStyle::Noise, category: BrushCategory::Effects },
|
||||
BrushPreset { name: "Texture", style: BrushStyle::Texture, category: BrushCategory::Effects },
|
||||
BrushPreset { name: "Glow", style: BrushStyle::Glow, category: BrushCategory::Effects },
|
||||
BrushPreset { name: "Leaf", style: BrushStyle::Leaf, category: BrushCategory::Effects },
|
||||
BrushPreset { name: "Clouds", style: BrushStyle::Clouds, category: BrushCategory::Effects },
|
||||
BrushPreset {
|
||||
name: "Star",
|
||||
style: BrushStyle::Star,
|
||||
category: BrushCategory::Effects,
|
||||
},
|
||||
BrushPreset {
|
||||
name: "Noise",
|
||||
style: BrushStyle::Noise,
|
||||
category: BrushCategory::Effects,
|
||||
},
|
||||
BrushPreset {
|
||||
name: "Texture",
|
||||
style: BrushStyle::Texture,
|
||||
category: BrushCategory::Effects,
|
||||
},
|
||||
BrushPreset {
|
||||
name: "Glow",
|
||||
style: BrushStyle::Glow,
|
||||
category: BrushCategory::Effects,
|
||||
},
|
||||
BrushPreset {
|
||||
name: "Leaf",
|
||||
style: BrushStyle::Leaf,
|
||||
category: BrushCategory::Effects,
|
||||
},
|
||||
BrushPreset {
|
||||
name: "Clouds",
|
||||
style: BrushStyle::Clouds,
|
||||
category: BrushCategory::Effects,
|
||||
},
|
||||
];
|
||||
|
||||
/// Cached brush preview images: (style_index, pixels).
|
||||
@@ -109,12 +199,20 @@ fn generate_brush_preview(style: BrushStyle) -> Vec<u8> {
|
||||
let (x2, y2, _) = points[i + 1];
|
||||
|
||||
let thickness = match style {
|
||||
BrushStyle::Pencil | BrushStyle::Sketch => 1.0 + 2.0 * (t1 * std::f32::consts::PI).sin().abs(),
|
||||
BrushStyle::Pen | BrushStyle::InkPen => 2.0 + 3.0 * (t1 * std::f32::consts::PI).sin().abs(),
|
||||
BrushStyle::Pencil | BrushStyle::Sketch => {
|
||||
1.0 + 2.0 * (t1 * std::f32::consts::PI).sin().abs()
|
||||
}
|
||||
BrushStyle::Pen | BrushStyle::InkPen => {
|
||||
2.0 + 3.0 * (t1 * std::f32::consts::PI).sin().abs()
|
||||
}
|
||||
BrushStyle::Calligraphy => 1.0 + 5.0 * ((t1 * 3.0).sin().abs()),
|
||||
BrushStyle::Oil | BrushStyle::Charcoal => 3.0 + 6.0 * (t1 * std::f32::consts::PI).sin().abs(),
|
||||
BrushStyle::Oil | BrushStyle::Charcoal => {
|
||||
3.0 + 6.0 * (t1 * std::f32::consts::PI).sin().abs()
|
||||
}
|
||||
BrushStyle::Watercolor => 2.0 + 4.0 * (t1 * std::f32::consts::PI).sin().abs(),
|
||||
BrushStyle::Airbrush | BrushStyle::Spray => 4.0 + 8.0 * (t1 * std::f32::consts::PI).sin().abs(),
|
||||
BrushStyle::Airbrush | BrushStyle::Spray => {
|
||||
4.0 + 8.0 * (t1 * std::f32::consts::PI).sin().abs()
|
||||
}
|
||||
BrushStyle::Glow => 3.0 + 7.0 * (t1 * std::f32::consts::PI).sin().abs(),
|
||||
_ => 2.0 + 5.0 * (t1 * std::f32::consts::PI).sin().abs(),
|
||||
};
|
||||
@@ -146,9 +244,15 @@ fn generate_brush_preview(style: BrushStyle) -> Vec<u8> {
|
||||
let dst_a = pixels[idx + 3] as f32 / 255.0;
|
||||
let out_a = src_a + dst_a * (1.0 - src_a);
|
||||
if out_a > 0.0 {
|
||||
pixels[idx] = ((color[0] as f32 * src_a + pixels[idx] as f32 * dst_a * (1.0 - src_a)) / out_a) as u8;
|
||||
pixels[idx + 1] = ((color[1] as f32 * src_a + pixels[idx + 1] as f32 * dst_a * (1.0 - src_a)) / out_a) as u8;
|
||||
pixels[idx + 2] = ((color[2] as f32 * src_a + pixels[idx + 2] as f32 * dst_a * (1.0 - src_a)) / out_a) as u8;
|
||||
pixels[idx] = ((color[0] as f32 * src_a
|
||||
+ pixels[idx] as f32 * dst_a * (1.0 - src_a))
|
||||
/ out_a) as u8;
|
||||
pixels[idx + 1] = ((color[1] as f32 * src_a
|
||||
+ pixels[idx + 1] as f32 * dst_a * (1.0 - src_a))
|
||||
/ out_a) as u8;
|
||||
pixels[idx + 2] = ((color[2] as f32 * src_a
|
||||
+ pixels[idx + 2] as f32 * dst_a * (1.0 - src_a))
|
||||
/ out_a) as u8;
|
||||
pixels[idx + 3] = (out_a * 255.0) as u8;
|
||||
}
|
||||
}
|
||||
@@ -195,19 +299,29 @@ pub fn view(
|
||||
) -> Element<'static, Message> {
|
||||
// Category tabs
|
||||
let categories = [
|
||||
(BrushCategory::All, "All"),
|
||||
(BrushCategory::Drawing, "Drawing"),
|
||||
(BrushCategory::Painting, "Painting"),
|
||||
(BrushCategory::Effects, "Effects"),
|
||||
(BrushCategory::All, "All brushes", "icons/panel-brush.svg"),
|
||||
(BrushCategory::Drawing, "Drawing", "icons/pen.svg"),
|
||||
(BrushCategory::Painting, "Painting", "icons/brush.svg"),
|
||||
(BrushCategory::Effects, "Effects", "icons/glow.svg"),
|
||||
];
|
||||
|
||||
let mut tabs = row![].spacing(2);
|
||||
for (_cat, label) in categories {
|
||||
let btn = button(text(label).size(10))
|
||||
.on_press(Message::NoOp) // TODO: category filter
|
||||
.padding([4, 8])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
for (cat, label, icon_path) in categories {
|
||||
let active = cat == BrushCategory::All;
|
||||
let icon = svg(svg::Handle::from_path(
|
||||
std::path::Path::new("hcie-iced-app/assets").join(icon_path),
|
||||
))
|
||||
.width(16)
|
||||
.height(16)
|
||||
.style(move |_theme, _status| svg::Style {
|
||||
color: Some(if active {
|
||||
colors.accent
|
||||
} else {
|
||||
colors.text_secondary
|
||||
}),
|
||||
});
|
||||
let btn = button(icon).padding([3, 6]).style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_hover)),
|
||||
text_color: colors.text_primary,
|
||||
@@ -215,17 +329,35 @@ pub fn view(
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
text_color: colors.text_secondary,
|
||||
border: iced::Border::default(),
|
||||
background: Some(iced::Background::Color(if active {
|
||||
colors.bg_active
|
||||
} else {
|
||||
iced::Color::TRANSPARENT
|
||||
})),
|
||||
text_color: if active {
|
||||
colors.accent
|
||||
} else {
|
||||
colors.text_secondary
|
||||
},
|
||||
border: iced::Border::default()
|
||||
.color(if active {
|
||||
colors.accent
|
||||
} else {
|
||||
colors.border_low
|
||||
})
|
||||
.width(if active { 1 } else { 0 }),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
tabs = tabs.push(btn);
|
||||
},
|
||||
);
|
||||
tabs = tabs.push(iced::widget::tooltip(
|
||||
btn,
|
||||
text(format!("{label} (category filtering is not available yet)")).size(10),
|
||||
iced::widget::tooltip::Position::Bottom,
|
||||
));
|
||||
}
|
||||
|
||||
// Brush preset grid — 3 columns
|
||||
// Brush preset grid — two columns remain usable in narrow dock panes.
|
||||
let mut grid_rows = column![].spacing(4);
|
||||
let mut current_row = row![].spacing(4);
|
||||
|
||||
@@ -235,11 +367,7 @@ pub fn view(
|
||||
let c = colors;
|
||||
let style = preset.style;
|
||||
|
||||
let thumb = container(
|
||||
iced::widget::image(preview)
|
||||
.width(48)
|
||||
.height(48)
|
||||
)
|
||||
let thumb = container(iced::widget::image(preview).width(48).height(48))
|
||||
.width(56)
|
||||
.height(56)
|
||||
.center_x(48)
|
||||
@@ -264,13 +392,15 @@ pub fn view(
|
||||
.width(Length::Fill)
|
||||
.center_x(Length::Fill);
|
||||
|
||||
let cell = column![thumb, label].spacing(2).align_x(iced::Alignment::Center);
|
||||
let cell = column![thumb, label]
|
||||
.spacing(2)
|
||||
.align_x(iced::Alignment::Center);
|
||||
|
||||
let clickable = button(cell)
|
||||
.on_press(Message::BrushStyleSelected(style))
|
||||
.padding(2)
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.bg_hover)),
|
||||
text_color: c.text_primary,
|
||||
@@ -278,18 +408,19 @@ pub fn view(
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.0, 0.0, 0.0, 0.0,
|
||||
))),
|
||||
text_color: c.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
current_row = current_row.push(clickable);
|
||||
|
||||
// Wrap row every 3 items
|
||||
if (idx + 1) % 3 == 0 || idx == BRUSH_PRESETS.len() - 1 {
|
||||
if (idx + 1) % 2 == 0 || idx == BRUSH_PRESETS.len() - 1 {
|
||||
grid_rows = grid_rows.push(current_row);
|
||||
current_row = row![].spacing(4);
|
||||
}
|
||||
@@ -300,21 +431,21 @@ pub fn view(
|
||||
.step(1.0)
|
||||
.width(Length::Fill);
|
||||
|
||||
let opacity_slider = slider(0.0..=1.0, current_opacity, |v| Message::BrushOpacityChanged(v))
|
||||
let opacity_slider = slider(0.0..=1.0, current_opacity, |v| {
|
||||
Message::BrushOpacityChanged(v)
|
||||
})
|
||||
.step(0.01)
|
||||
.width(Length::Fill);
|
||||
|
||||
let hardness_slider = slider(0.0..=1.0, current_hardness, |v| Message::BrushHardnessChanged(v))
|
||||
let hardness_slider = slider(0.0..=1.0, current_hardness, |v| {
|
||||
Message::BrushHardnessChanged(v)
|
||||
})
|
||||
.step(0.01)
|
||||
.width(Length::Fill);
|
||||
|
||||
// Live preview — show current brush tip
|
||||
let preview_handle = get_brush_preview(current_style);
|
||||
let live_preview = container(
|
||||
iced::widget::image(preview_handle)
|
||||
.width(64)
|
||||
.height(64)
|
||||
)
|
||||
let live_preview = container(iced::widget::image(preview_handle).width(64).height(64))
|
||||
.width(Length::Fill)
|
||||
.height(80)
|
||||
.center_x(Length::Fill)
|
||||
@@ -331,11 +462,11 @@ pub fn view(
|
||||
horizontal_rule(1),
|
||||
scrollable(grid_rows).height(Length::Fill),
|
||||
horizontal_rule(1),
|
||||
row![
|
||||
button(text("Import ABR").size(10))
|
||||
row![button(text("Import ABR").size(10))
|
||||
.on_press(Message::BrushImportAbr)
|
||||
.padding([4, 8])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.accent)),
|
||||
@@ -350,8 +481,9 @@ pub fn view(
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
})
|
||||
].spacing(4),
|
||||
}
|
||||
)]
|
||||
.spacing(4),
|
||||
text("Preview").size(10),
|
||||
live_preview,
|
||||
text(format!("Size: {:.0}", current_size)).size(10),
|
||||
|
||||
@@ -17,34 +17,22 @@ use serde_json::json;
|
||||
|
||||
/// Read a float parameter from the JSON params, falling back to a default.
|
||||
fn param_f64(params: &serde_json::Value, key: &str, default: f64) -> f64 {
|
||||
params
|
||||
.get(key)
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(default)
|
||||
params.get(key).and_then(|v| v.as_f64()).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Read an integer parameter from the JSON params, falling back to a default.
|
||||
fn param_u64(params: &serde_json::Value, key: &str, default: u64) -> u64 {
|
||||
params
|
||||
.get(key)
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(default)
|
||||
params.get(key).and_then(|v| v.as_u64()).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Read a boolean parameter from the JSON params, falling back to a default.
|
||||
fn param_bool(params: &serde_json::Value, key: &str, default: bool) -> bool {
|
||||
params
|
||||
.get(key)
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(default)
|
||||
params.get(key).and_then(|v| v.as_bool()).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Read a string parameter from the JSON params, falling back to a default.
|
||||
fn param_str<'a>(params: &'a serde_json::Value, key: &str, default: &'a str) -> &'a str {
|
||||
params
|
||||
.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(default)
|
||||
params.get(key).and_then(|v| v.as_str()).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Build a labeled float slider row.
|
||||
@@ -71,14 +59,13 @@ fn float_slider(
|
||||
let label_owned = label.to_string();
|
||||
|
||||
let lbl = text(label_owned).size(11).width(Length::Fixed(80.0));
|
||||
let display = text(format!("{:.1}", value)).size(11).width(Length::Fixed(40.0));
|
||||
let display = text(format!("{:.1}", value))
|
||||
.size(11)
|
||||
.width(Length::Fixed(40.0));
|
||||
|
||||
let s = slider(min_f..=max_f, val, move |v| {
|
||||
let rounded = (v as f64 / step).round() * step;
|
||||
Message::FilterParamChanged(
|
||||
filter_owned.clone(),
|
||||
json!({ key_owned.clone(): rounded }),
|
||||
)
|
||||
Message::FilterParamChanged(filter_owned.clone(), json!({ key_owned.clone(): rounded }))
|
||||
})
|
||||
.step(step_f)
|
||||
.width(Length::Fill);
|
||||
@@ -104,7 +91,9 @@ fn int_slider(
|
||||
let label_owned = label.to_string();
|
||||
|
||||
let lbl = text(label_owned).size(11).width(Length::Fixed(80.0));
|
||||
let display = text(format!("{}", value)).size(11).width(Length::Fixed(40.0));
|
||||
let display = text(format!("{}", value))
|
||||
.size(11)
|
||||
.width(Length::Fixed(40.0));
|
||||
|
||||
let s = slider(min_f..=max_f, val, move |v| {
|
||||
Message::FilterParamChanged(
|
||||
@@ -121,9 +110,13 @@ fn int_slider(
|
||||
/// Build a section header for a group of parameters.
|
||||
fn section_header(label: &str, colors: ThemeColors) -> Element<'static, Message> {
|
||||
let label_owned = label.to_string();
|
||||
container(text(label_owned).size(11).style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
container(
|
||||
text(label_owned)
|
||||
.size(11)
|
||||
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(colors.text_secondary),
|
||||
}))
|
||||
}),
|
||||
)
|
||||
.padding(iced::Padding::default().top(4.0).bottom(2.0))
|
||||
.into()
|
||||
}
|
||||
@@ -173,8 +166,25 @@ pub fn view(
|
||||
let distance = param_u64(params, "distance", 10);
|
||||
column![
|
||||
section_header("Motion Blur", colors),
|
||||
float_slider("Angle", "angle", "motion_blur", angle, 0.0, 360.0, 1.0, colors),
|
||||
int_slider("Distance", "distance", "motion_blur", distance, 1, 500, colors),
|
||||
float_slider(
|
||||
"Angle",
|
||||
"angle",
|
||||
"motion_blur",
|
||||
angle,
|
||||
0.0,
|
||||
360.0,
|
||||
1.0,
|
||||
colors
|
||||
),
|
||||
int_slider(
|
||||
"Distance",
|
||||
"distance",
|
||||
"motion_blur",
|
||||
distance,
|
||||
1,
|
||||
500,
|
||||
colors
|
||||
),
|
||||
]
|
||||
.spacing(2)
|
||||
.into()
|
||||
@@ -264,9 +274,36 @@ pub fn view(
|
||||
section_header("Levels", colors),
|
||||
float_slider("Shadows", "shadows", "levels", shadows, 0.0, 255.0, 1.0, colors),
|
||||
float_slider("Midtones", "midtones", "levels", midtones, 0.0, 255.0, 1.0, colors),
|
||||
float_slider("Highlights", "highlights", "levels", highlights, 0.0, 255.0, 1.0, colors),
|
||||
float_slider("Out Min", "output_min", "levels", out_min, 0.0, 255.0, 1.0, colors),
|
||||
float_slider("Out Max", "output_max", "levels", out_max, 0.0, 255.0, 1.0, colors),
|
||||
float_slider(
|
||||
"Highlights",
|
||||
"highlights",
|
||||
"levels",
|
||||
highlights,
|
||||
0.0,
|
||||
255.0,
|
||||
1.0,
|
||||
colors
|
||||
),
|
||||
float_slider(
|
||||
"Out Min",
|
||||
"output_min",
|
||||
"levels",
|
||||
out_min,
|
||||
0.0,
|
||||
255.0,
|
||||
1.0,
|
||||
colors
|
||||
),
|
||||
float_slider(
|
||||
"Out Max",
|
||||
"output_max",
|
||||
"levels",
|
||||
out_max,
|
||||
0.0,
|
||||
255.0,
|
||||
1.0,
|
||||
colors
|
||||
),
|
||||
]
|
||||
.spacing(2)
|
||||
.into()
|
||||
@@ -275,7 +312,9 @@ pub fn view(
|
||||
let vibrance = param_f64(params, "vibrance", 0.0);
|
||||
column![
|
||||
section_header("Vibrance", colors),
|
||||
float_slider("Vibrance", "vibrance", "vibrance", vibrance, -100.0, 100.0, 1.0, colors),
|
||||
float_slider(
|
||||
"Vibrance", "vibrance", "vibrance", vibrance, -100.0, 100.0, 1.0, colors
|
||||
),
|
||||
]
|
||||
.spacing(2)
|
||||
.into()
|
||||
@@ -288,7 +327,16 @@ pub fn view(
|
||||
section_header("Exposure", colors),
|
||||
float_slider("Exposure", "exposure", "exposure", exposure, -5.0, 5.0, 0.01, colors),
|
||||
float_slider("Offset", "offset", "exposure", offset, -1.0, 1.0, 0.01, colors),
|
||||
float_slider("Gamma", "gamma_correction", "exposure", gamma, 0.1, 5.0, 0.01, colors),
|
||||
float_slider(
|
||||
"Gamma",
|
||||
"gamma_correction",
|
||||
"exposure",
|
||||
gamma,
|
||||
0.1,
|
||||
5.0,
|
||||
0.01,
|
||||
colors
|
||||
),
|
||||
]
|
||||
.spacing(2)
|
||||
.into()
|
||||
@@ -307,8 +355,26 @@ pub fn view(
|
||||
let max_lum = param_f64(params, "max_luminance", 100.0);
|
||||
column![
|
||||
section_header("Threshold", colors),
|
||||
float_slider("Min Luminance", "min_luminance", "threshold", min_lum, 0.0, 100.0, 1.0, colors),
|
||||
float_slider("Max Luminance", "max_luminance", "threshold", max_lum, 0.0, 100.0, 1.0, colors),
|
||||
float_slider(
|
||||
"Min Luminance",
|
||||
"min_luminance",
|
||||
"threshold",
|
||||
min_lum,
|
||||
0.0,
|
||||
100.0,
|
||||
1.0,
|
||||
colors
|
||||
),
|
||||
float_slider(
|
||||
"Max Luminance",
|
||||
"max_luminance",
|
||||
"threshold",
|
||||
max_lum,
|
||||
0.0,
|
||||
100.0,
|
||||
1.0,
|
||||
colors
|
||||
),
|
||||
]
|
||||
.spacing(2)
|
||||
.into()
|
||||
@@ -319,10 +385,7 @@ pub fn view(
|
||||
let mono_g = param_f64(params, "monochrome_g", 40.0);
|
||||
let mono_b = param_f64(params, "monochrome_b", 20.0);
|
||||
let mono_c = param_f64(params, "monochrome_constant", 0.0);
|
||||
let mut col = column![
|
||||
section_header("Channel Mixer", colors),
|
||||
]
|
||||
.spacing(2);
|
||||
let mut col = column![section_header("Channel Mixer", colors),].spacing(2);
|
||||
|
||||
// Monochrome toggle
|
||||
let filter_id = "channel_mixer";
|
||||
@@ -340,10 +403,46 @@ pub fn view(
|
||||
}
|
||||
|
||||
if mono {
|
||||
col = col.push(float_slider("Monochrome R", "monochrome_r", filter_id, mono_r, -200.0, 200.0, 1.0, colors));
|
||||
col = col.push(float_slider("Monochrome G", "monochrome_g", filter_id, mono_g, -200.0, 200.0, 1.0, colors));
|
||||
col = col.push(float_slider("Monochrome B", "monochrome_b", filter_id, mono_b, -200.0, 200.0, 1.0, colors));
|
||||
col = col.push(float_slider("Constant", "monochrome_constant", filter_id, mono_c, -200.0, 200.0, 1.0, colors));
|
||||
col = col.push(float_slider(
|
||||
"Monochrome R",
|
||||
"monochrome_r",
|
||||
filter_id,
|
||||
mono_r,
|
||||
-200.0,
|
||||
200.0,
|
||||
1.0,
|
||||
colors,
|
||||
));
|
||||
col = col.push(float_slider(
|
||||
"Monochrome G",
|
||||
"monochrome_g",
|
||||
filter_id,
|
||||
mono_g,
|
||||
-200.0,
|
||||
200.0,
|
||||
1.0,
|
||||
colors,
|
||||
));
|
||||
col = col.push(float_slider(
|
||||
"Monochrome B",
|
||||
"monochrome_b",
|
||||
filter_id,
|
||||
mono_b,
|
||||
-200.0,
|
||||
200.0,
|
||||
1.0,
|
||||
colors,
|
||||
));
|
||||
col = col.push(float_slider(
|
||||
"Constant",
|
||||
"monochrome_constant",
|
||||
filter_id,
|
||||
mono_c,
|
||||
-200.0,
|
||||
200.0,
|
||||
1.0,
|
||||
colors,
|
||||
));
|
||||
} else {
|
||||
// RGB channels
|
||||
let red_r = param_f64(params, "red_r", 100.0);
|
||||
@@ -360,22 +459,88 @@ pub fn view(
|
||||
let blue_c = param_f64(params, "blue_constant", 0.0);
|
||||
|
||||
col = col.push(section_header("Red Channel", colors));
|
||||
col = col.push(float_slider("Red→R", "red_r", filter_id, red_r, -200.0, 200.0, 1.0, colors));
|
||||
col = col.push(float_slider("Red→G", "red_g", filter_id, red_g, -200.0, 200.0, 1.0, colors));
|
||||
col = col.push(float_slider("Red→B", "red_b", filter_id, red_b, -200.0, 200.0, 1.0, colors));
|
||||
col = col.push(float_slider("Red Const", "red_constant", filter_id, red_c, -200.0, 200.0, 1.0, colors));
|
||||
col = col.push(float_slider(
|
||||
"Red→R", "red_r", filter_id, red_r, -200.0, 200.0, 1.0, colors,
|
||||
));
|
||||
col = col.push(float_slider(
|
||||
"Red→G", "red_g", filter_id, red_g, -200.0, 200.0, 1.0, colors,
|
||||
));
|
||||
col = col.push(float_slider(
|
||||
"Red→B", "red_b", filter_id, red_b, -200.0, 200.0, 1.0, colors,
|
||||
));
|
||||
col = col.push(float_slider(
|
||||
"Red Const",
|
||||
"red_constant",
|
||||
filter_id,
|
||||
red_c,
|
||||
-200.0,
|
||||
200.0,
|
||||
1.0,
|
||||
colors,
|
||||
));
|
||||
|
||||
col = col.push(section_header("Green Channel", colors));
|
||||
col = col.push(float_slider("Green→R", "green_r", filter_id, green_r, -200.0, 200.0, 1.0, colors));
|
||||
col = col.push(float_slider("Green→G", "green_g", filter_id, green_g, -200.0, 200.0, 1.0, colors));
|
||||
col = col.push(float_slider("Green→B", "green_b", filter_id, green_b, -200.0, 200.0, 1.0, colors));
|
||||
col = col.push(float_slider("Green Const", "green_constant", filter_id, green_c, -200.0, 200.0, 1.0, colors));
|
||||
col = col.push(float_slider(
|
||||
"Green→R",
|
||||
"green_r",
|
||||
filter_id,
|
||||
green_r,
|
||||
-200.0,
|
||||
200.0,
|
||||
1.0,
|
||||
colors,
|
||||
));
|
||||
col = col.push(float_slider(
|
||||
"Green→G",
|
||||
"green_g",
|
||||
filter_id,
|
||||
green_g,
|
||||
-200.0,
|
||||
200.0,
|
||||
1.0,
|
||||
colors,
|
||||
));
|
||||
col = col.push(float_slider(
|
||||
"Green→B",
|
||||
"green_b",
|
||||
filter_id,
|
||||
green_b,
|
||||
-200.0,
|
||||
200.0,
|
||||
1.0,
|
||||
colors,
|
||||
));
|
||||
col = col.push(float_slider(
|
||||
"Green Const",
|
||||
"green_constant",
|
||||
filter_id,
|
||||
green_c,
|
||||
-200.0,
|
||||
200.0,
|
||||
1.0,
|
||||
colors,
|
||||
));
|
||||
|
||||
col = col.push(section_header("Blue Channel", colors));
|
||||
col = col.push(float_slider("Blue→R", "blue_r", filter_id, blue_r, -200.0, 200.0, 1.0, colors));
|
||||
col = col.push(float_slider("Blue→G", "blue_g", filter_id, blue_g, -200.0, 200.0, 1.0, colors));
|
||||
col = col.push(float_slider("Blue→B", "blue_b", filter_id, blue_b, -200.0, 200.0, 1.0, colors));
|
||||
col = col.push(float_slider("Blue Const", "blue_constant", filter_id, blue_c, -200.0, 200.0, 1.0, colors));
|
||||
col = col.push(float_slider(
|
||||
"Blue→R", "blue_r", filter_id, blue_r, -200.0, 200.0, 1.0, colors,
|
||||
));
|
||||
col = col.push(float_slider(
|
||||
"Blue→G", "blue_g", filter_id, blue_g, -200.0, 200.0, 1.0, colors,
|
||||
));
|
||||
col = col.push(float_slider(
|
||||
"Blue→B", "blue_b", filter_id, blue_b, -200.0, 200.0, 1.0, colors,
|
||||
));
|
||||
col = col.push(float_slider(
|
||||
"Blue Const",
|
||||
"blue_constant",
|
||||
filter_id,
|
||||
blue_c,
|
||||
-200.0,
|
||||
200.0,
|
||||
1.0,
|
||||
colors,
|
||||
));
|
||||
}
|
||||
|
||||
col.into()
|
||||
@@ -392,16 +557,97 @@ pub fn view(
|
||||
let tint_b = param_f64(params, "tint_b", 255.0);
|
||||
column![
|
||||
section_header("Black & White", colors),
|
||||
float_slider("Reds", "reds", "black_and_white", reds, 0.0, 100.0, 1.0, colors),
|
||||
float_slider("Yellows", "yellows", "black_and_white", yellows, 0.0, 100.0, 1.0, colors),
|
||||
float_slider("Greens", "greens", "black_and_white", greens, 0.0, 100.0, 1.0, colors),
|
||||
float_slider("Cyans", "cyans", "black_and_white", cyans, 0.0, 100.0, 1.0, colors),
|
||||
float_slider("Blues", "blues", "black_and_white", blues, 0.0, 100.0, 1.0, colors),
|
||||
float_slider("Magentas", "magentas", "black_and_white", magentas, 0.0, 100.0, 1.0, colors),
|
||||
float_slider(
|
||||
"Reds",
|
||||
"reds",
|
||||
"black_and_white",
|
||||
reds,
|
||||
0.0,
|
||||
100.0,
|
||||
1.0,
|
||||
colors
|
||||
),
|
||||
float_slider(
|
||||
"Yellows",
|
||||
"yellows",
|
||||
"black_and_white",
|
||||
yellows,
|
||||
0.0,
|
||||
100.0,
|
||||
1.0,
|
||||
colors
|
||||
),
|
||||
float_slider(
|
||||
"Greens",
|
||||
"greens",
|
||||
"black_and_white",
|
||||
greens,
|
||||
0.0,
|
||||
100.0,
|
||||
1.0,
|
||||
colors
|
||||
),
|
||||
float_slider(
|
||||
"Cyans",
|
||||
"cyans",
|
||||
"black_and_white",
|
||||
cyans,
|
||||
0.0,
|
||||
100.0,
|
||||
1.0,
|
||||
colors
|
||||
),
|
||||
float_slider(
|
||||
"Blues",
|
||||
"blues",
|
||||
"black_and_white",
|
||||
blues,
|
||||
0.0,
|
||||
100.0,
|
||||
1.0,
|
||||
colors
|
||||
),
|
||||
float_slider(
|
||||
"Magentas",
|
||||
"magentas",
|
||||
"black_and_white",
|
||||
magentas,
|
||||
0.0,
|
||||
100.0,
|
||||
1.0,
|
||||
colors
|
||||
),
|
||||
section_header("Tint Color", colors),
|
||||
float_slider("Tint R", "tint_r", "black_and_white", tint_r, 0.0, 255.0, 1.0, colors),
|
||||
float_slider("Tint G", "tint_g", "black_and_white", tint_g, 0.0, 255.0, 1.0, colors),
|
||||
float_slider("Tint B", "tint_b", "black_and_white", tint_b, 0.0, 255.0, 1.0, colors),
|
||||
float_slider(
|
||||
"Tint R",
|
||||
"tint_r",
|
||||
"black_and_white",
|
||||
tint_r,
|
||||
0.0,
|
||||
255.0,
|
||||
1.0,
|
||||
colors
|
||||
),
|
||||
float_slider(
|
||||
"Tint G",
|
||||
"tint_g",
|
||||
"black_and_white",
|
||||
tint_g,
|
||||
0.0,
|
||||
255.0,
|
||||
1.0,
|
||||
colors
|
||||
),
|
||||
float_slider(
|
||||
"Tint B",
|
||||
"tint_b",
|
||||
"black_and_white",
|
||||
tint_b,
|
||||
0.0,
|
||||
255.0,
|
||||
1.0,
|
||||
colors
|
||||
),
|
||||
]
|
||||
.spacing(2)
|
||||
.into()
|
||||
@@ -412,10 +658,7 @@ pub fn view(
|
||||
let mode = param_str(params, "mode", "relative");
|
||||
let is_absolute = mode == "absolute";
|
||||
|
||||
let mut col = column![
|
||||
section_header("Selective Color", colors),
|
||||
]
|
||||
.spacing(2);
|
||||
let mut col = column![section_header("Selective Color", colors),].spacing(2);
|
||||
|
||||
// Mode toggle
|
||||
{
|
||||
@@ -441,7 +684,12 @@ pub fn view(
|
||||
("Neutrals", "neutrals"),
|
||||
("Blacks", "blacks"),
|
||||
];
|
||||
let cmyk = [("Cyan", "cyan"), ("Magenta", "magenta"), ("Yellow", "yellow"), ("Black", "black")];
|
||||
let cmyk = [
|
||||
("Cyan", "cyan"),
|
||||
("Magenta", "magenta"),
|
||||
("Yellow", "yellow"),
|
||||
("Black", "black"),
|
||||
];
|
||||
|
||||
for (range_name, range_key) in &ranges {
|
||||
col = col.push(section_header(range_name, colors));
|
||||
@@ -449,14 +697,7 @@ pub fn view(
|
||||
let param_key = format!("{}_{}", range_key, cmyk_key);
|
||||
let val = param_f64(params, ¶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,16 +739,20 @@ pub fn view(
|
||||
FilterType::ExtractChannel => {
|
||||
let channel = param_str(params, "channel", "red");
|
||||
let channels = ["red", "green", "blue", "alpha"];
|
||||
let mut col = column![
|
||||
section_header("Extract Channel", colors),
|
||||
]
|
||||
.spacing(2);
|
||||
let mut col = column![section_header("Extract Channel", colors),].spacing(2);
|
||||
|
||||
for ch in &channels {
|
||||
let is_active = channel == *ch;
|
||||
let filter_id = "extract_channel";
|
||||
let ch_label = text(ch.to_string()).size(11).width(Length::Fixed(80.0));
|
||||
let btn = iced::widget::button(text(if is_active { format!(">> {}", ch) } else { ch.to_string() }).size(11))
|
||||
let btn = iced::widget::button(
|
||||
text(if is_active {
|
||||
format!(">> {}", ch)
|
||||
} else {
|
||||
ch.to_string()
|
||||
})
|
||||
.size(11),
|
||||
)
|
||||
.on_press(Message::FilterParamChanged(
|
||||
filter_id.to_string(),
|
||||
json!({ "channel": ch }),
|
||||
@@ -524,9 +778,7 @@ pub fn view(
|
||||
.padding([2, 8]);
|
||||
row![lbl, toggle_btn].spacing(4)
|
||||
},
|
||||
{
|
||||
text("Gradient stops configured via color picker").size(10)
|
||||
},
|
||||
{ text("Gradient stops configured via color picker").size(10) },
|
||||
]
|
||||
.spacing(2)
|
||||
.into()
|
||||
@@ -585,7 +837,12 @@ pub fn view(
|
||||
for nt in &noise_types {
|
||||
let is_active = noise_type == *nt;
|
||||
let btn = iced::widget::button(
|
||||
text(if is_active { format!(">> {}", nt) } else { nt.to_string() }).size(11),
|
||||
text(if is_active {
|
||||
format!(">> {}", nt)
|
||||
} else {
|
||||
nt.to_string()
|
||||
})
|
||||
.size(11),
|
||||
)
|
||||
.on_press(Message::FilterParamChanged(
|
||||
filter_id.to_string(),
|
||||
@@ -603,7 +860,12 @@ pub fn view(
|
||||
for ft in &fractal_types {
|
||||
let is_active = fractal_type == *ft;
|
||||
let btn = iced::widget::button(
|
||||
text(if is_active { format!(">> {}", ft) } else { ft.to_string() }).size(11),
|
||||
text(if is_active {
|
||||
format!(">> {}", ft)
|
||||
} else {
|
||||
ft.to_string()
|
||||
})
|
||||
.size(11),
|
||||
)
|
||||
.on_press(Message::FilterParamChanged(
|
||||
filter_id.to_string(),
|
||||
@@ -615,9 +877,35 @@ pub fn view(
|
||||
}
|
||||
|
||||
if fractal_type != "none" {
|
||||
col = col.push(int_slider("Octaves", "fractal_octaves", filter_id, fractal_octaves, 1, 10, colors));
|
||||
col = col.push(float_slider("Lacunarity", "fractal_lacunarity", filter_id, fractal_lacunarity, 1.0, 4.0, 0.1, colors));
|
||||
col = col.push(float_slider("Gain", "fractal_gain", filter_id, fractal_gain, 0.0, 2.0, 0.01, colors));
|
||||
col = col.push(int_slider(
|
||||
"Octaves",
|
||||
"fractal_octaves",
|
||||
filter_id,
|
||||
fractal_octaves,
|
||||
1,
|
||||
10,
|
||||
colors,
|
||||
));
|
||||
col = col.push(float_slider(
|
||||
"Lacunarity",
|
||||
"fractal_lacunarity",
|
||||
filter_id,
|
||||
fractal_lacunarity,
|
||||
1.0,
|
||||
4.0,
|
||||
0.1,
|
||||
colors,
|
||||
));
|
||||
col = col.push(float_slider(
|
||||
"Gain",
|
||||
"fractal_gain",
|
||||
filter_id,
|
||||
fractal_gain,
|
||||
0.0,
|
||||
2.0,
|
||||
0.01,
|
||||
colors,
|
||||
));
|
||||
}
|
||||
|
||||
// Domain warp selector
|
||||
@@ -627,7 +915,12 @@ pub fn view(
|
||||
for wt in &warp_types {
|
||||
let is_active = domain_warp_type == *wt;
|
||||
let btn = iced::widget::button(
|
||||
text(if is_active { format!(">> {}", wt) } else { wt.to_string() }).size(11),
|
||||
text(if is_active {
|
||||
format!(">> {}", wt)
|
||||
} else {
|
||||
wt.to_string()
|
||||
})
|
||||
.size(11),
|
||||
)
|
||||
.on_press(Message::FilterParamChanged(
|
||||
filter_id.to_string(),
|
||||
@@ -639,7 +932,16 @@ pub fn view(
|
||||
}
|
||||
|
||||
if domain_warp_type != "none" {
|
||||
col = col.push(float_slider("Warp Amp", "domain_warp_amplitude", filter_id, domain_warp_amp, 0.0, 500.0, 1.0, colors));
|
||||
col = col.push(float_slider(
|
||||
"Warp Amp",
|
||||
"domain_warp_amplitude",
|
||||
filter_id,
|
||||
domain_warp_amp,
|
||||
0.0,
|
||||
500.0,
|
||||
1.0,
|
||||
colors,
|
||||
));
|
||||
}
|
||||
|
||||
// Cellular settings (shown when noise_type is "cellular")
|
||||
@@ -650,7 +952,12 @@ pub fn view(
|
||||
for df in &dist_funcs {
|
||||
let is_active = cellular_distance == *df;
|
||||
let btn = iced::widget::button(
|
||||
text(if is_active { format!(">> {}", df) } else { df.to_string() }).size(11),
|
||||
text(if is_active {
|
||||
format!(">> {}", df)
|
||||
} else {
|
||||
df.to_string()
|
||||
})
|
||||
.size(11),
|
||||
)
|
||||
.on_press(Message::FilterParamChanged(
|
||||
filter_id.to_string(),
|
||||
@@ -665,7 +972,12 @@ pub fn view(
|
||||
for rt in &ret_types {
|
||||
let is_active = cellular_return == *rt;
|
||||
let btn = iced::widget::button(
|
||||
text(if is_active { format!(">> {}", rt) } else { rt.to_string() }).size(11),
|
||||
text(if is_active {
|
||||
format!(">> {}", rt)
|
||||
} else {
|
||||
rt.to_string()
|
||||
})
|
||||
.size(11),
|
||||
)
|
||||
.on_press(Message::FilterParamChanged(
|
||||
filter_id.to_string(),
|
||||
@@ -674,15 +986,21 @@ pub fn view(
|
||||
.padding([2, 8]);
|
||||
col = col.push(btn);
|
||||
}
|
||||
col = col.push(float_slider("Jitter", "cellular_jitter", filter_id, cellular_jitter, 0.0, 1.0, 0.01, colors));
|
||||
col = col.push(float_slider(
|
||||
"Jitter",
|
||||
"cellular_jitter",
|
||||
filter_id,
|
||||
cellular_jitter,
|
||||
0.0,
|
||||
1.0,
|
||||
0.01,
|
||||
colors,
|
||||
));
|
||||
}
|
||||
|
||||
col.into()
|
||||
}
|
||||
};
|
||||
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.padding(4)
|
||||
.into()
|
||||
container(content).width(Length::Fill).padding(4).into()
|
||||
}
|
||||
|
||||
@@ -23,65 +23,134 @@ const FILTER_CATEGORIES: &[FilterCategory] = &[
|
||||
FilterCategory {
|
||||
name: "Blur",
|
||||
filters: &[
|
||||
FilterEntry { name: "Box Blur", filter_type: FilterType::BoxBlur },
|
||||
FilterEntry { name: "Gaussian Blur", filter_type: FilterType::GaussianBlur },
|
||||
FilterEntry { name: "Motion Blur", filter_type: FilterType::MotionBlur },
|
||||
FilterEntry { name: "Median Filter", filter_type: FilterType::MedianFilter },
|
||||
FilterEntry {
|
||||
name: "Box Blur",
|
||||
filter_type: FilterType::BoxBlur,
|
||||
},
|
||||
FilterEntry {
|
||||
name: "Gaussian Blur",
|
||||
filter_type: FilterType::GaussianBlur,
|
||||
},
|
||||
FilterEntry {
|
||||
name: "Motion Blur",
|
||||
filter_type: FilterType::MotionBlur,
|
||||
},
|
||||
FilterEntry {
|
||||
name: "Median Filter",
|
||||
filter_type: FilterType::MedianFilter,
|
||||
},
|
||||
FilterEntry {
|
||||
name: "Gamma-aware Box Blur",
|
||||
filter_type: FilterType::BoxBlurGamma,
|
||||
},
|
||||
FilterEntry {
|
||||
name: "Gamma-aware Gaussian Blur",
|
||||
filter_type: FilterType::GaussianBlurGamma,
|
||||
},
|
||||
],
|
||||
},
|
||||
FilterCategory {
|
||||
name: "Sharpen",
|
||||
filters: &[
|
||||
FilterEntry { name: "Unsharp Mask", filter_type: FilterType::UnsharpMask },
|
||||
],
|
||||
filters: &[FilterEntry {
|
||||
name: "Unsharp Mask",
|
||||
filter_type: FilterType::UnsharpMask,
|
||||
}],
|
||||
},
|
||||
FilterCategory {
|
||||
name: "Pixelate",
|
||||
filters: &[
|
||||
FilterEntry { name: "Mosaic", filter_type: FilterType::Mosaic },
|
||||
FilterEntry { name: "Crystallize", filter_type: FilterType::Crystallize },
|
||||
FilterEntry {
|
||||
name: "Mosaic",
|
||||
filter_type: FilterType::Mosaic,
|
||||
},
|
||||
FilterEntry {
|
||||
name: "Crystallize",
|
||||
filter_type: FilterType::Crystallize,
|
||||
},
|
||||
],
|
||||
},
|
||||
FilterCategory {
|
||||
name: "Distort",
|
||||
filters: &[
|
||||
FilterEntry { name: "Pinch", filter_type: FilterType::Pinch },
|
||||
FilterEntry { name: "Twirl", filter_type: FilterType::Twirl },
|
||||
FilterEntry {
|
||||
name: "Pinch",
|
||||
filter_type: FilterType::Pinch,
|
||||
},
|
||||
FilterEntry {
|
||||
name: "Twirl",
|
||||
filter_type: FilterType::Twirl,
|
||||
},
|
||||
],
|
||||
},
|
||||
FilterCategory {
|
||||
name: "Stylize",
|
||||
filters: &[
|
||||
FilterEntry { name: "Oil Paint", filter_type: FilterType::OilPaint },
|
||||
],
|
||||
filters: &[FilterEntry {
|
||||
name: "Oil Paint",
|
||||
filter_type: FilterType::OilPaint,
|
||||
}],
|
||||
},
|
||||
FilterCategory {
|
||||
name: "Color & Light",
|
||||
filters: &[
|
||||
FilterEntry { name: "Levels", filter_type: FilterType::Levels },
|
||||
FilterEntry { name: "Vibrance", filter_type: FilterType::Vibrance },
|
||||
FilterEntry { name: "Exposure", filter_type: FilterType::Exposure },
|
||||
FilterEntry { name: "Posterize", filter_type: FilterType::Posterize },
|
||||
FilterEntry { name: "Threshold", filter_type: FilterType::Threshold },
|
||||
FilterEntry { name: "Channel Mixer", filter_type: FilterType::ChannelMixer },
|
||||
FilterEntry { name: "Black & White", filter_type: FilterType::BlackAndWhite },
|
||||
FilterEntry { name: "Selective Color", filter_type: FilterType::SelectiveColor },
|
||||
FilterEntry { name: "Gamma Correction", filter_type: FilterType::GammaCorrection },
|
||||
FilterEntry { name: "Extract Channel", filter_type: FilterType::ExtractChannel },
|
||||
FilterEntry { name: "Gradient Map", filter_type: FilterType::GradientMap },
|
||||
FilterEntry {
|
||||
name: "Levels",
|
||||
filter_type: FilterType::Levels,
|
||||
},
|
||||
FilterEntry {
|
||||
name: "Vibrance",
|
||||
filter_type: FilterType::Vibrance,
|
||||
},
|
||||
FilterEntry {
|
||||
name: "Exposure",
|
||||
filter_type: FilterType::Exposure,
|
||||
},
|
||||
FilterEntry {
|
||||
name: "Posterize",
|
||||
filter_type: FilterType::Posterize,
|
||||
},
|
||||
FilterEntry {
|
||||
name: "Threshold",
|
||||
filter_type: FilterType::Threshold,
|
||||
},
|
||||
FilterEntry {
|
||||
name: "Channel Mixer",
|
||||
filter_type: FilterType::ChannelMixer,
|
||||
},
|
||||
FilterEntry {
|
||||
name: "Black & White",
|
||||
filter_type: FilterType::BlackAndWhite,
|
||||
},
|
||||
FilterEntry {
|
||||
name: "Selective Color",
|
||||
filter_type: FilterType::SelectiveColor,
|
||||
},
|
||||
FilterEntry {
|
||||
name: "Gamma Correction",
|
||||
filter_type: FilterType::GammaCorrection,
|
||||
},
|
||||
FilterEntry {
|
||||
name: "Extract Channel",
|
||||
filter_type: FilterType::ExtractChannel,
|
||||
},
|
||||
FilterEntry {
|
||||
name: "Gradient Map",
|
||||
filter_type: FilterType::GradientMap,
|
||||
},
|
||||
],
|
||||
},
|
||||
FilterCategory {
|
||||
name: "Restore",
|
||||
filters: &[
|
||||
FilterEntry { name: "Dehaze", filter_type: FilterType::Dehaze },
|
||||
],
|
||||
filters: &[FilterEntry {
|
||||
name: "Dehaze",
|
||||
filter_type: FilterType::Dehaze,
|
||||
}],
|
||||
},
|
||||
FilterCategory {
|
||||
name: "Noise & Pattern",
|
||||
filters: &[
|
||||
FilterEntry { name: "Noise Pattern", filter_type: FilterType::NoisePattern },
|
||||
],
|
||||
filters: &[FilterEntry {
|
||||
name: "Noise Pattern",
|
||||
filter_type: FilterType::NoisePattern,
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -94,14 +163,13 @@ pub fn view(
|
||||
selected_filter: Option<FilterType>,
|
||||
filter_params: &serde_json::Value,
|
||||
filter_preview_active: bool,
|
||||
layer_editable: bool,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'static, Message> {
|
||||
let mut filter_list = column![].spacing(2);
|
||||
|
||||
for category in FILTER_CATEGORIES {
|
||||
let cat_name = text(category.name)
|
||||
.size(12)
|
||||
;
|
||||
let cat_name = text(category.name).size(12);
|
||||
|
||||
let mut cat_filters = column![].spacing(1).padding(iced::Padding::ZERO.left(12));
|
||||
|
||||
@@ -128,8 +196,8 @@ pub fn view(
|
||||
.style(move |_theme| item_style.clone());
|
||||
|
||||
let filter_type = entry.filter_type;
|
||||
let clickable = iced::widget::mouse_area(item)
|
||||
.on_press(Message::FilterSelect(filter_type));
|
||||
let clickable =
|
||||
iced::widget::mouse_area(item).on_press(Message::FilterSelect(filter_type));
|
||||
|
||||
cat_filters = cat_filters.push(clickable);
|
||||
}
|
||||
@@ -138,9 +206,13 @@ pub fn view(
|
||||
filter_list = filter_list.push(cat_filters);
|
||||
}
|
||||
|
||||
let apply_btn = button(text("Apply").size(11))
|
||||
let apply_btn = if layer_editable {
|
||||
button(text("Apply").size(11))
|
||||
.on_press(Message::FilterApply)
|
||||
.padding([6, 12]);
|
||||
.padding([6, 12])
|
||||
} else {
|
||||
button(text("Apply (layer locked/type unsupported)").size(10)).padding([6, 8])
|
||||
};
|
||||
|
||||
let reset_btn = button(text("Reset").size(11))
|
||||
.on_press(Message::FilterReset)
|
||||
@@ -150,13 +222,16 @@ pub fn view(
|
||||
.on_toggle(Message::FilterPreviewToggle)
|
||||
.size(11);
|
||||
|
||||
let buttons = row![preview_check, apply_btn, reset_btn].spacing(8);
|
||||
let buttons = column![preview_check, row![apply_btn, reset_btn].spacing(6)].spacing(3);
|
||||
|
||||
// When a filter is selected, show its parameter panel below the list.
|
||||
let param_section = match selected_filter {
|
||||
Some(filter) => {
|
||||
let params_panel = super::filter_params::view(filter, filter_params, colors);
|
||||
column![horizontal_rule(1), scrollable(params_panel).height(Length::Fill)]
|
||||
column![
|
||||
horizontal_rule(1),
|
||||
scrollable(params_panel).height(Length::Fill)
|
||||
]
|
||||
.spacing(0)
|
||||
}
|
||||
None => column![],
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::color_picker;
|
||||
use crate::color_picker::ColorPickerTarget;
|
||||
use crate::panels::styles;
|
||||
use crate::panels::typography::{BODY, SECTION};
|
||||
use crate::theme::ThemeColors;
|
||||
use hcie_engine_api::{LineCap, VectorBooleanOp, VectorShape};
|
||||
use iced::widget::{
|
||||
button, checkbox, column, container, row, scrollable, slider, text, Space,
|
||||
button, checkbox, column, container, pick_list, row, scrollable, slider, text, Space,
|
||||
};
|
||||
use iced::{Element, Length};
|
||||
|
||||
@@ -38,10 +40,7 @@ fn shape_label(shapes: &[VectorShape], i: usize) -> String {
|
||||
|
||||
/// Build a section header bar with the given label.
|
||||
fn section_header(label: String, colors: ThemeColors) -> Element<'static, Message> {
|
||||
container(
|
||||
row![text(label).size(11)]
|
||||
.spacing(4),
|
||||
)
|
||||
container(row![text(label).size(SECTION)].spacing(4))
|
||||
.width(Length::Fill)
|
||||
.padding([3, 6])
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
@@ -60,9 +59,11 @@ fn labeled_slider<'a>(
|
||||
on_change: fn(f32) -> Message,
|
||||
) -> Element<'a, Message> {
|
||||
row![
|
||||
text(label).size(10).width(30),
|
||||
slider(range, value, on_change).step(0.5).width(Length::Fill),
|
||||
text(format!("{:.0}", value)).size(9).width(32),
|
||||
text(label).size(BODY).width(42),
|
||||
slider(range, value, on_change)
|
||||
.step(0.5)
|
||||
.width(Length::Fill),
|
||||
text(format!("{:.0}", value)).size(BODY).width(38),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center)
|
||||
@@ -94,7 +95,7 @@ pub fn view(
|
||||
container(
|
||||
row![
|
||||
Space::with_width(Length::Fill),
|
||||
button(text("X").size(10))
|
||||
button(text("X").size(BODY))
|
||||
.on_press(Message::VectorShapeDelete)
|
||||
.padding([2, 6]),
|
||||
]
|
||||
@@ -111,12 +112,16 @@ pub fn view(
|
||||
let is_selected = selected_shape == Some(i);
|
||||
let c = colors;
|
||||
|
||||
let item = button(text(label).size(11))
|
||||
.on_press(Message::VectorShapeSelect(if is_selected { None } else { Some(i) }))
|
||||
let item = button(text(label).size(BODY))
|
||||
.on_press(Message::VectorShapeSelect(if is_selected {
|
||||
None
|
||||
} else {
|
||||
Some(i)
|
||||
}))
|
||||
.width(Length::Fill)
|
||||
.padding([4, 6])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
|
||||
iced::widget::button::Status::Active => {
|
||||
if is_selected {
|
||||
iced::widget::button::Style {
|
||||
@@ -132,32 +137,36 @@ pub fn view(
|
||||
}
|
||||
}
|
||||
}
|
||||
iced::widget::button::Status::Hovered => {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(if is_selected { c.accent } else { c.bg_active })),
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(if is_selected {
|
||||
c.accent
|
||||
} else {
|
||||
c.bg_active
|
||||
})),
|
||||
text_color: iced::Color::WHITE,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.bg_hover)),
|
||||
text_color: c.text_primary,
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
shape_list = shape_list.push(item);
|
||||
}
|
||||
|
||||
content = content.push(
|
||||
scrollable(shape_list).height(Length::Fill).width(Length::Fill),
|
||||
scrollable(shape_list)
|
||||
.height(Length::Fixed(132.0))
|
||||
.width(Length::Fill),
|
||||
);
|
||||
} else {
|
||||
content = content.push(
|
||||
column![
|
||||
text("No vector shapes").size(11),
|
||||
text("Use vector tools to create shapes").size(10),
|
||||
text("No vector shapes").size(BODY),
|
||||
text("Use vector tools to create shapes").size(BODY),
|
||||
]
|
||||
.spacing(2),
|
||||
);
|
||||
@@ -169,68 +178,52 @@ pub fn view(
|
||||
content = content.push(section_header("PATH BOOLEAN".to_string(), colors));
|
||||
content = content.push(Space::with_height(4));
|
||||
|
||||
// Shape A selector
|
||||
let a_label = match bool_shape_a {
|
||||
Some(i) if i < shape_count => shape_label(&shapes, i),
|
||||
_ => "Shape A".to_string(),
|
||||
};
|
||||
let mut a_menu = column![].spacing(1);
|
||||
for i in 0..shape_count {
|
||||
let lbl = shape_label(&shapes, i);
|
||||
let idx = i;
|
||||
a_menu = a_menu.push(
|
||||
button(text(lbl).size(10))
|
||||
.on_press(Message::GeometryBoolShapeA(Some(idx)))
|
||||
.width(Length::Fill)
|
||||
.padding([3, 6]),
|
||||
);
|
||||
}
|
||||
|
||||
// Shape B selector
|
||||
let b_label = match bool_shape_b {
|
||||
Some(i) if i < shape_count => shape_label(&shapes, i),
|
||||
_ => "Shape B".to_string(),
|
||||
};
|
||||
let mut b_menu = column![].spacing(1);
|
||||
for i in 0..shape_count {
|
||||
let lbl = shape_label(&shapes, i);
|
||||
let idx = i;
|
||||
b_menu = b_menu.push(
|
||||
button(text(lbl).size(10))
|
||||
.on_press(Message::GeometryBoolShapeB(Some(idx)))
|
||||
.width(Length::Fill)
|
||||
.padding([3, 6]),
|
||||
);
|
||||
}
|
||||
let labels: Vec<String> = (0..shape_count).map(|i| shape_label(&shapes, i)).collect();
|
||||
let a_selected = bool_shape_a.and_then(|i| labels.get(i).cloned());
|
||||
let b_selected = bool_shape_b.and_then(|i| labels.get(i).cloned());
|
||||
let a_labels = labels.clone();
|
||||
let b_labels = labels.clone();
|
||||
|
||||
// Show selectors inline
|
||||
content = content.push(
|
||||
row![
|
||||
container(
|
||||
column![
|
||||
button(text(a_label).size(10))
|
||||
pick_list(labels.clone(), a_selected, move |selected| {
|
||||
Message::GeometryBoolShapeA(
|
||||
a_labels.iter().position(|label| *label == selected),
|
||||
)
|
||||
})
|
||||
.placeholder("Shape A")
|
||||
.text_size(BODY)
|
||||
.width(Length::Fill)
|
||||
.padding([4, 6]),
|
||||
a_menu,
|
||||
]
|
||||
.spacing(2)
|
||||
).width(Length::Fill).style(move |_theme| iced::widget::container::Style {
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_active)),
|
||||
border: iced::Border::default().color(colors.border_high).width(1).rounded(2),
|
||||
border: iced::Border::default()
|
||||
.color(colors.border_high)
|
||||
.width(1)
|
||||
.rounded(2),
|
||||
..Default::default()
|
||||
}),
|
||||
text(" + ").size(10),
|
||||
text(" + ").size(BODY),
|
||||
container(
|
||||
column![
|
||||
button(text(b_label).size(10))
|
||||
pick_list(labels, b_selected, move |selected| {
|
||||
Message::GeometryBoolShapeB(
|
||||
b_labels.iter().position(|label| *label == selected),
|
||||
)
|
||||
})
|
||||
.placeholder("Shape B")
|
||||
.text_size(BODY)
|
||||
.width(Length::Fill)
|
||||
.padding([4, 6]),
|
||||
b_menu,
|
||||
]
|
||||
.spacing(2)
|
||||
).width(Length::Fill).style(move |_theme| iced::widget::container::Style {
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_active)),
|
||||
border: iced::Border::default().color(colors.border_high).width(1).rounded(2),
|
||||
border: iced::Border::default()
|
||||
.color(colors.border_high)
|
||||
.width(1)
|
||||
.rounded(2),
|
||||
..Default::default()
|
||||
}),
|
||||
]
|
||||
@@ -242,10 +235,18 @@ pub fn view(
|
||||
content = content.push(Space::with_height(4));
|
||||
content = content.push(
|
||||
row![
|
||||
button(text("Union").size(10)).on_press(Message::GeometryBooleanOp(VectorBooleanOp::Union)).padding([3, 8]),
|
||||
button(text("Intersect").size(10)).on_press(Message::GeometryBooleanOp(VectorBooleanOp::Intersect)).padding([3, 8]),
|
||||
button(text("Subtract").size(10)).on_press(Message::GeometryBooleanOp(VectorBooleanOp::Subtract)).padding([3, 8]),
|
||||
button(text("Xor").size(10)).on_press(Message::GeometryBooleanOp(VectorBooleanOp::Xor)).padding([3, 8]),
|
||||
button(text("Union").size(BODY))
|
||||
.on_press(Message::GeometryBooleanOp(VectorBooleanOp::Union))
|
||||
.padding([3, 8]),
|
||||
button(text("Intersect").size(BODY))
|
||||
.on_press(Message::GeometryBooleanOp(VectorBooleanOp::Intersect))
|
||||
.padding([3, 8]),
|
||||
button(text("Subtract").size(BODY))
|
||||
.on_press(Message::GeometryBooleanOp(VectorBooleanOp::Subtract))
|
||||
.padding([3, 8]),
|
||||
button(text("Xor").size(BODY))
|
||||
.on_press(Message::GeometryBooleanOp(VectorBooleanOp::Xor))
|
||||
.padding([3, 8]),
|
||||
]
|
||||
.spacing(4)
|
||||
.width(Length::Fill),
|
||||
@@ -264,20 +265,38 @@ pub fn view(
|
||||
let h = (y2 - y1).abs();
|
||||
|
||||
// Bounds sliders
|
||||
content = content.push(labeled_slider("X1", x1, -2000.0..=5000.0, Message::GeometryBoundsX1));
|
||||
content = content.push(labeled_slider("Y1", y1, -2000.0..=5000.0, Message::GeometryBoundsY1));
|
||||
content = content.push(labeled_slider("X2", x2, -2000.0..=5000.0, Message::GeometryBoundsX2));
|
||||
content = content.push(labeled_slider("Y2", y2, -2000.0..=5000.0, Message::GeometryBoundsY2));
|
||||
content = content.push(labeled_slider(
|
||||
"X1",
|
||||
x1,
|
||||
-2000.0..=5000.0,
|
||||
Message::GeometryBoundsX1,
|
||||
));
|
||||
content = content.push(labeled_slider(
|
||||
"Y1",
|
||||
y1,
|
||||
-2000.0..=5000.0,
|
||||
Message::GeometryBoundsY1,
|
||||
));
|
||||
content = content.push(labeled_slider(
|
||||
"X2",
|
||||
x2,
|
||||
-2000.0..=5000.0,
|
||||
Message::GeometryBoundsX2,
|
||||
));
|
||||
content = content.push(labeled_slider(
|
||||
"Y2",
|
||||
y2,
|
||||
-2000.0..=5000.0,
|
||||
Message::GeometryBoundsY2,
|
||||
));
|
||||
|
||||
// Size display
|
||||
content = content.push(
|
||||
text(format!("Size: {:.0} x {:.0}", w, h))
|
||||
.size(10)
|
||||
.style(move |_theme| iced::widget::text::Style {
|
||||
content = content.push(text(format!("Size: {:.0} x {:.0}", w, h)).size(BODY).style(
|
||||
move |_theme| iced::widget::text::Style {
|
||||
color: Some(colors.text_secondary),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
},
|
||||
));
|
||||
|
||||
// Fill controls (not for Line shapes)
|
||||
let is_line = matches!(&shapes[idx], VectorShape::Line { .. });
|
||||
@@ -288,7 +307,7 @@ pub fn view(
|
||||
content = content.push(
|
||||
row![
|
||||
checkbox("", cur_fill).on_toggle(Message::GeometryFillToggled),
|
||||
text("Fill").size(10),
|
||||
text("Fill").size(BODY),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center),
|
||||
@@ -297,10 +316,10 @@ pub fn view(
|
||||
if cur_fill {
|
||||
// Fill color: HSV color picker
|
||||
content = content.push(Space::with_height(2));
|
||||
content = content.push(color_picker::color_popup(
|
||||
content = content.push(color_picker::color_swatch(
|
||||
fc,
|
||||
"Fill",
|
||||
Message::GeometryFillColorChanged,
|
||||
ColorPickerTarget::GeometryFill,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -309,25 +328,37 @@ pub fn view(
|
||||
// Stroke color
|
||||
let stroke_color = shapes[idx].color();
|
||||
content = content.push(Space::with_height(4));
|
||||
content = content.push(color_picker::color_popup(
|
||||
content = content.push(color_picker::color_swatch(
|
||||
stroke_color,
|
||||
"Stroke",
|
||||
Message::GeometryStrokeColorChanged,
|
||||
ColorPickerTarget::GeometryStroke,
|
||||
));
|
||||
|
||||
// Opacity slider
|
||||
let cur_opacity = shapes[idx].shape_opacity();
|
||||
content = content.push(Space::with_height(4));
|
||||
content = content.push(labeled_slider("Opacity", cur_opacity, 0.0..=1.0, Message::GeometryOpacityChanged));
|
||||
content = content.push(labeled_slider(
|
||||
"Opacity",
|
||||
cur_opacity,
|
||||
0.0..=1.0,
|
||||
Message::GeometryOpacityChanged,
|
||||
));
|
||||
|
||||
// Hardness slider
|
||||
let cur_hardness = shapes[idx].hardness();
|
||||
content = content.push(labeled_slider("Hardness", cur_hardness, 0.0..=1.0, Message::GeometryHardnessChanged));
|
||||
content = content.push(labeled_slider(
|
||||
"Hardness",
|
||||
cur_hardness,
|
||||
0.0..=1.0,
|
||||
Message::GeometryHardnessChanged,
|
||||
));
|
||||
|
||||
// Line caps (for Line shapes only)
|
||||
if is_line {
|
||||
let (cs, ce) = match &shapes[idx] {
|
||||
VectorShape::Line { cap_start, cap_end, .. } => (*cap_start, *cap_end),
|
||||
VectorShape::Line {
|
||||
cap_start, cap_end, ..
|
||||
} => (*cap_start, *cap_end),
|
||||
_ => (LineCap::Round, LineCap::Round),
|
||||
};
|
||||
|
||||
@@ -335,71 +366,59 @@ pub fn view(
|
||||
content = content.push(section_header("LINE CAPS".to_string(), colors));
|
||||
content = content.push(Space::with_height(2));
|
||||
|
||||
// Start cap combo
|
||||
let cap_labels: Vec<String> = LineCap::ALL
|
||||
.iter()
|
||||
.map(|cap| cap.label().to_string())
|
||||
.collect();
|
||||
let start_labels = cap_labels.clone();
|
||||
let end_labels = cap_labels.clone();
|
||||
|
||||
// Start cap selector
|
||||
content = content.push(
|
||||
row![
|
||||
text("Start").size(10).width(40),
|
||||
button(text(cs.label()).size(10)).padding([3, 6]).width(Length::Fill),
|
||||
text("Start").size(BODY).width(48),
|
||||
pick_list(
|
||||
cap_labels.clone(),
|
||||
Some(cs.label().to_string()),
|
||||
move |selected| {
|
||||
let index = start_labels
|
||||
.iter()
|
||||
.position(|label| *label == selected)
|
||||
.unwrap_or(0);
|
||||
Message::GeometryLineCapStart(LineCap::ALL[index])
|
||||
}
|
||||
)
|
||||
.text_size(BODY)
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center),
|
||||
);
|
||||
|
||||
// Show cap options when clicked (inline list)
|
||||
let mut cap_start_list = column![].spacing(1);
|
||||
for &cap in LineCap::ALL {
|
||||
let cap_val = cap;
|
||||
let is_active = cs == cap;
|
||||
cap_start_list = cap_start_list.push(
|
||||
button(
|
||||
row![
|
||||
if is_active { text(">").size(10) } else { text(" ").size(10) },
|
||||
text(cap.label()).size(10),
|
||||
]
|
||||
.spacing(4)
|
||||
)
|
||||
.on_press(Message::GeometryLineCapStart(cap_val))
|
||||
.width(Length::Fill)
|
||||
.padding([2, 6]),
|
||||
);
|
||||
}
|
||||
content = content.push(cap_start_list);
|
||||
|
||||
content = content.push(Space::with_height(2));
|
||||
|
||||
// End cap combo
|
||||
// End cap selector
|
||||
content = content.push(
|
||||
row![
|
||||
text("End").size(10).width(40),
|
||||
button(text(ce.label()).size(10)).padding([3, 6]).width(Length::Fill),
|
||||
text("End").size(BODY).width(48),
|
||||
pick_list(cap_labels, Some(ce.label().to_string()), move |selected| {
|
||||
let index = end_labels
|
||||
.iter()
|
||||
.position(|label| *label == selected)
|
||||
.unwrap_or(0);
|
||||
Message::GeometryLineCapEnd(LineCap::ALL[index])
|
||||
})
|
||||
.text_size(BODY)
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center),
|
||||
);
|
||||
|
||||
let mut cap_end_list = column![].spacing(1);
|
||||
for &cap in LineCap::ALL {
|
||||
let cap_val = cap;
|
||||
let is_active = ce == cap;
|
||||
cap_end_list = cap_end_list.push(
|
||||
button(
|
||||
row![
|
||||
if is_active { text(">").size(10) } else { text(" ").size(10) },
|
||||
text(cap.label()).size(10),
|
||||
]
|
||||
.spacing(4)
|
||||
)
|
||||
.on_press(Message::GeometryLineCapEnd(cap_val))
|
||||
.width(Length::Fill)
|
||||
.padding([2, 6]),
|
||||
);
|
||||
}
|
||||
content = content.push(cap_end_list);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
container(content.spacing(0))
|
||||
container(scrollable(content.spacing(0)))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| styles::panel_background(colors))
|
||||
|
||||
@@ -11,15 +11,18 @@ use iced::widget::{column, container, horizontal_rule, scrollable, text};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Build the history panel element.
|
||||
pub fn view<'a>(descriptions: &[(usize, String)], current_idx: i32, colors: ThemeColors) -> Element<'a, Message> {
|
||||
pub fn view<'a>(
|
||||
descriptions: &[(usize, String)],
|
||||
current_idx: i32,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'a, Message> {
|
||||
let mut history_list = column![].spacing(1);
|
||||
|
||||
for (idx, desc) in descriptions {
|
||||
let is_current = *idx as i32 == current_idx;
|
||||
let is_future = *idx as i32 > current_idx;
|
||||
|
||||
let label = text(format!("{}: {}", idx, desc))
|
||||
.size(11);
|
||||
let label = text(format!("{}: {}", idx, desc)).size(11);
|
||||
|
||||
let label = if is_current {
|
||||
label.style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
@@ -44,16 +47,14 @@ pub fn view<'a>(descriptions: &[(usize, String)], current_idx: i32, colors: Them
|
||||
.padding([3, 6])
|
||||
.style(move |_theme| item_style.clone());
|
||||
|
||||
let clickable = iced::widget::mouse_area(item)
|
||||
.on_press(Message::HistoryJumpTo(*idx as i32));
|
||||
let clickable =
|
||||
iced::widget::mouse_area(item).on_press(Message::HistoryJumpTo(*idx as i32));
|
||||
|
||||
history_list = history_list.push(clickable);
|
||||
}
|
||||
|
||||
// Panel title is in the dock tab — no duplicate inside the panel.
|
||||
let panel = column![
|
||||
scrollable(history_list).height(Length::Fill),
|
||||
]
|
||||
let panel = column![scrollable(history_list).height(Length::Fill),]
|
||||
.spacing(0)
|
||||
.padding(4);
|
||||
|
||||
|
||||
@@ -12,7 +12,11 @@ use iced::widget::{column, container, horizontal_rule, row, scrollable, text};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Build the layer details panel.
|
||||
pub fn view<'a>(layers: &'a [LayerInfo], active_layer_id: u64, colors: ThemeColors) -> Element<'a, Message> {
|
||||
pub fn view<'a>(
|
||||
layers: &'a [LayerInfo],
|
||||
active_layer_id: u64,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'a, Message> {
|
||||
let active_layer = layers.iter().find(|l| l.id == active_layer_id);
|
||||
|
||||
let content: Element<'a, Message> = match active_layer {
|
||||
@@ -24,20 +28,59 @@ pub fn view<'a>(layers: &'a [LayerInfo], active_layer_id: u64, colors: ThemeColo
|
||||
row![text("Name:").size(10), text(&layer.name).size(11)].spacing(4),
|
||||
row![text("ID:").size(10), text(format!("{}", layer.id)).size(10)].spacing(4),
|
||||
row![text("Type:").size(10), text(layer_type).size(10)].spacing(4),
|
||||
row![text("Size:").size(10), text(format!("{}x{}", layer.width, layer.height)).size(10)].spacing(4),
|
||||
row![
|
||||
text("Size:").size(10),
|
||||
text(format!("{}x{}", layer.width, layer.height)).size(10)
|
||||
]
|
||||
.spacing(4),
|
||||
horizontal_rule(1),
|
||||
row![text("Blend:").size(10), text(blend_mode).size(10)].spacing(4),
|
||||
row![text("Opacity:").size(10), text(format!("{:.0}%", layer.opacity * 100.0)).size(10)].spacing(4),
|
||||
row![text("Visible:").size(10), text(if layer.visible { "Yes" } else { "No" }).size(10)].spacing(4),
|
||||
row![text("Locked:").size(10), text(if layer.locked { "Yes" } else { "No" }).size(10)].spacing(4),
|
||||
row![text("Clip Mask:").size(10), text(if layer.clipping_mask { "Yes" } else { "No" }).size(10)].spacing(4),
|
||||
row![text("Parent:").size(10), text(format!("{:?}", layer.parent_id)).size(10)].spacing(4),
|
||||
row![
|
||||
text("Opacity:").size(10),
|
||||
text(format!("{:.0}%", layer.opacity * 100.0)).size(10)
|
||||
]
|
||||
.spacing(4),
|
||||
row![
|
||||
text("Visible:").size(10),
|
||||
text(if layer.visible { "Yes" } else { "No" }).size(10)
|
||||
]
|
||||
.spacing(4),
|
||||
row![
|
||||
text("Locked:").size(10),
|
||||
text(if layer.locked { "Yes" } else { "No" }).size(10)
|
||||
]
|
||||
.spacing(4),
|
||||
row![
|
||||
text("Clip Mask:").size(10),
|
||||
text(if layer.clipping_mask { "Yes" } else { "No" }).size(10)
|
||||
]
|
||||
.spacing(4),
|
||||
row![
|
||||
text("Parent:").size(10),
|
||||
text(format!("{:?}", layer.parent_id)).size(10)
|
||||
]
|
||||
.spacing(4),
|
||||
horizontal_rule(1),
|
||||
row![text("Effects:").size(10), text(if layer.has_effects { "Yes" } else { "No" }).size(10)].spacing(4),
|
||||
row![text("Vector:").size(10), text(if layer.has_vector_shapes {
|
||||
row![
|
||||
text("Effects:").size(10),
|
||||
text(if layer.has_effects { "Yes" } else { "No" }).size(10)
|
||||
]
|
||||
.spacing(4),
|
||||
row![
|
||||
text("Vector:").size(10),
|
||||
text(if layer.has_vector_shapes {
|
||||
format!("{} shapes", layer.shape_count)
|
||||
} else { "None".to_string() }).size(10)].spacing(4),
|
||||
row![text("Collapsed:").size(10), text(if layer.collapsed { "Yes" } else { "No" }).size(10)].spacing(4),
|
||||
} else {
|
||||
"None".to_string()
|
||||
})
|
||||
.size(10)
|
||||
]
|
||||
.spacing(4),
|
||||
row![
|
||||
text("Collapsed:").size(10),
|
||||
text(if layer.collapsed { "Yes" } else { "No" }).size(10)
|
||||
]
|
||||
.spacing(4),
|
||||
]
|
||||
.spacing(3)
|
||||
.into()
|
||||
@@ -46,9 +89,7 @@ pub fn view<'a>(layers: &'a [LayerInfo], active_layer_id: u64, colors: ThemeColo
|
||||
};
|
||||
|
||||
// Panel title is in the dock tab — no duplicate inside the panel.
|
||||
let panel = column![
|
||||
scrollable(content).height(Length::Fill),
|
||||
]
|
||||
let panel = column![scrollable(content).height(Length::Fill),]
|
||||
.spacing(0)
|
||||
.padding(4);
|
||||
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
//! Includes a clickable color swatch with HSL color picker popup.
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::color_picker::{self, ColorPickerTarget};
|
||||
use crate::panels::styles;
|
||||
use crate::theme::ThemeColors;
|
||||
use hcie_engine_api::LayerStyle;
|
||||
use iced::widget::{button, column, container, horizontal_rule, pick_list, row, scrollable, slider, text, Space};
|
||||
use iced::widget::{
|
||||
button, column, container, horizontal_rule, pick_list, row, scrollable, slider, text, Space,
|
||||
};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// All 10 standard Photoshop layer style types.
|
||||
@@ -28,33 +31,67 @@ const STYLE_DEFS: &[(&str, &str, [f32; 3])] = &[
|
||||
|
||||
/// Blend mode options for the dropdown.
|
||||
const BLEND_MODES: &[&str] = &[
|
||||
"Normal", "Dissolve", "Darken", "Multiply", "Color Burn", "Linear Burn",
|
||||
"Darker Color", "Lighten", "Screen", "Color Dodge", "Linear Dodge",
|
||||
"Lighter Color", "Overlay", "Soft Light", "Hard Light", "Vivid Light",
|
||||
"Linear Light", "Pin Light", "Hard Mix", "Difference", "Exclusion",
|
||||
"Subtract", "Divide", "Hue", "Saturation", "Color", "Luminosity",
|
||||
"Normal",
|
||||
"Dissolve",
|
||||
"Darken",
|
||||
"Multiply",
|
||||
"Color Burn",
|
||||
"Linear Burn",
|
||||
"Darker Color",
|
||||
"Lighten",
|
||||
"Screen",
|
||||
"Color Dodge",
|
||||
"Linear Dodge",
|
||||
"Lighter Color",
|
||||
"Overlay",
|
||||
"Soft Light",
|
||||
"Hard Light",
|
||||
"Vivid Light",
|
||||
"Linear Light",
|
||||
"Pin Light",
|
||||
"Hard Mix",
|
||||
"Difference",
|
||||
"Exclusion",
|
||||
"Subtract",
|
||||
"Divide",
|
||||
"Hue",
|
||||
"Saturation",
|
||||
"Color",
|
||||
"Luminosity",
|
||||
];
|
||||
|
||||
/// Bevel & Emboss style options.
|
||||
const BEVEL_STYLES: &[&str] = &[
|
||||
"Inner Bevel", "Outer Bevel", "Emboss", "Pillow Emboss", "Stroke Emboss",
|
||||
"Inner Bevel",
|
||||
"Outer Bevel",
|
||||
"Emboss",
|
||||
"Pillow Emboss",
|
||||
"Stroke Emboss",
|
||||
];
|
||||
|
||||
/// Bevel & Emboss technique options.
|
||||
const BEVEL_TECHNIQUES: &[&str] = &[
|
||||
"Smooth", "Chisel Hard", "Chisel Soft",
|
||||
];
|
||||
const BEVEL_TECHNIQUES: &[&str] = &["Smooth", "Chisel Hard", "Chisel Soft"];
|
||||
|
||||
/// Bevel & Emboss direction options.
|
||||
const BEVEL_DIRECTIONS: &[&str] = &[
|
||||
"Up", "Down",
|
||||
];
|
||||
const BEVEL_DIRECTIONS: &[&str] = &["Up", "Down"];
|
||||
|
||||
/// Bevel & Emboss contour options.
|
||||
const BEVEL_CONTOURS: &[&str] = &[
|
||||
"Linear", "Cone", "Cone-Inverted", "Gaussian", "Half Round",
|
||||
"Round", "Ring", "Ring-Double", "Sawtooth", "Square", "Valley",
|
||||
"Shallow-Slope", "Wave", "Cove", "Washboard",
|
||||
"Linear",
|
||||
"Cone",
|
||||
"Cone-Inverted",
|
||||
"Gaussian",
|
||||
"Half Round",
|
||||
"Round",
|
||||
"Ring",
|
||||
"Ring-Double",
|
||||
"Sawtooth",
|
||||
"Square",
|
||||
"Valley",
|
||||
"Shallow-Slope",
|
||||
"Wave",
|
||||
"Cove",
|
||||
"Washboard",
|
||||
];
|
||||
|
||||
/// Mapping: human-readable BevelEmboss style name -> internal enum string.
|
||||
@@ -120,7 +157,9 @@ fn style_matches(discriminant: &str, style: &LayerStyle) -> bool {
|
||||
|
||||
/// Get enabled state for a style.
|
||||
fn style_enabled(discriminant: &str, styles: &[LayerStyle]) -> bool {
|
||||
styles.iter().find(|s| style_matches(discriminant, s))
|
||||
styles
|
||||
.iter()
|
||||
.find(|s| style_matches(discriminant, s))
|
||||
.map(|s| match s {
|
||||
LayerStyle::DropShadow { enabled, .. }
|
||||
| LayerStyle::InnerShadow { enabled, .. }
|
||||
@@ -138,7 +177,9 @@ fn style_enabled(discriminant: &str, styles: &[LayerStyle]) -> bool {
|
||||
|
||||
/// Get the blend mode string for a style.
|
||||
fn get_blend_mode(discriminant: &str, styles: &[LayerStyle]) -> String {
|
||||
styles.iter().find(|s| style_matches(discriminant, s))
|
||||
styles
|
||||
.iter()
|
||||
.find(|s| style_matches(discriminant, s))
|
||||
.map(|s| match s {
|
||||
LayerStyle::DropShadow { blend_mode, .. }
|
||||
| LayerStyle::InnerShadow { blend_mode, .. }
|
||||
@@ -155,7 +196,9 @@ fn get_blend_mode(discriminant: &str, styles: &[LayerStyle]) -> String {
|
||||
|
||||
/// Get the color for a style (first color parameter).
|
||||
fn get_style_color(discriminant: &str, styles: &[LayerStyle]) -> [u8; 4] {
|
||||
styles.iter().find(|s| style_matches(discriminant, s))
|
||||
styles
|
||||
.iter()
|
||||
.find(|s| style_matches(discriminant, s))
|
||||
.map(|s| match s {
|
||||
LayerStyle::DropShadow { color, .. }
|
||||
| LayerStyle::InnerShadow { color, .. }
|
||||
@@ -164,41 +207,22 @@ fn get_style_color(discriminant: &str, styles: &[LayerStyle]) -> [u8; 4] {
|
||||
| LayerStyle::ColorOverlay { color, .. }
|
||||
| LayerStyle::Satin { color, .. }
|
||||
| LayerStyle::Stroke { color, .. } => *color,
|
||||
LayerStyle::BevelEmboss { highlight_color, .. } => *highlight_color,
|
||||
LayerStyle::BevelEmboss {
|
||||
highlight_color, ..
|
||||
} => *highlight_color,
|
||||
_ => [0, 0, 0, 255],
|
||||
})
|
||||
.unwrap_or([0, 0, 0, 255])
|
||||
}
|
||||
|
||||
/// Convert HSL (h: 0..360, s: 0..1, l: 0..1) to RGB u8.
|
||||
fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [u8; 3] {
|
||||
let h = h / 360.0;
|
||||
let (r, g, b) = if s == 0.0 {
|
||||
(l, l, l)
|
||||
} else {
|
||||
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
|
||||
let p = 2.0 * l - q;
|
||||
(hue_to_rgb(p, q, h + 1.0 / 3.0), hue_to_rgb(p, q, h), hue_to_rgb(p, q, h - 1.0 / 3.0))
|
||||
};
|
||||
[(r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8]
|
||||
}
|
||||
|
||||
fn hue_to_rgb(p: f32, q: f32, t: f32) -> f32 {
|
||||
let t = if t < 0.0 { t + 1.0 } else if t > 1.0 { t - 1.0 } else { t };
|
||||
if t < 1.0 / 6.0 { p + (q - p) * 6.0 * t }
|
||||
else if t < 1.0 / 2.0 { q }
|
||||
else if t < 2.0 / 3.0 { p + (q - p) * (2.0 / 3.0 - t) * 6.0 }
|
||||
else { p }
|
||||
}
|
||||
|
||||
/// Build the floating Layer Style dialog.
|
||||
pub fn view<'a>(
|
||||
styles_list: &'a [LayerStyle],
|
||||
selected_style: usize,
|
||||
colors: ThemeColors,
|
||||
show_color_picker: bool,
|
||||
color_picker_idx: usize,
|
||||
color_hsl: (f32, f32, f32),
|
||||
_show_color_picker: bool,
|
||||
_color_picker_idx: usize,
|
||||
_color_hsl: (f32, f32, f32),
|
||||
drag_offset: (f32, f32),
|
||||
) -> Element<'a, Message> {
|
||||
// ── Left sidebar: style list ──
|
||||
@@ -214,27 +238,39 @@ pub fn view<'a>(
|
||||
.on_press(Message::LayerStyleToggle(i as u32))
|
||||
.padding([1, 4]);
|
||||
|
||||
let label_text = text(label).size(10).style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
let label_text =
|
||||
text(label)
|
||||
.size(10)
|
||||
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(badge_color),
|
||||
});
|
||||
|
||||
// We wrap only the label in mouse_area(Message::LayerStyleSelect(i)) so it doesn't intercept toggle button clicks.
|
||||
let label_container = container(label_text)
|
||||
.width(Length::Fill)
|
||||
.padding([3, 4]);
|
||||
let label_container = container(label_text).width(Length::Fill).padding([3, 4]);
|
||||
|
||||
let clickable_label = iced::widget::mouse_area(label_container)
|
||||
.on_press(Message::LayerStyleSelect(i))
|
||||
.interaction(iced::mouse::Interaction::Pointer);
|
||||
|
||||
let item_row = row![toggle_btn, clickable_label].spacing(3).align_y(iced::Alignment::Center);
|
||||
let item_row = row![toggle_btn, clickable_label]
|
||||
.spacing(3)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
let item_bg = if is_selected {
|
||||
iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(
|
||||
iced::Color::from_rgba(color_arr[0], color_arr[1], color_arr[2], 0.2),
|
||||
)),
|
||||
border: iced::Border::default().color(iced::Color::from_rgb(color_arr[0], color_arr[1], color_arr[2])).width(1),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
color_arr[0],
|
||||
color_arr[1],
|
||||
color_arr[2],
|
||||
0.2,
|
||||
))),
|
||||
border: iced::Border::default()
|
||||
.color(iced::Color::from_rgb(
|
||||
color_arr[0],
|
||||
color_arr[1],
|
||||
color_arr[2],
|
||||
))
|
||||
.width(1),
|
||||
..Default::default()
|
||||
}
|
||||
} else if enabled {
|
||||
@@ -261,7 +297,7 @@ pub fn view<'a>(
|
||||
.width(Length::Fixed(180.0));
|
||||
|
||||
// ── Right side: parameters ──
|
||||
let params = build_style_params(selected_style, styles_list, colors, show_color_picker, color_picker_idx, color_hsl);
|
||||
let params = build_style_params(selected_style, styles_list, colors);
|
||||
|
||||
// ── Buttons ──
|
||||
let ok_btn = button(text("OK").size(11))
|
||||
@@ -270,18 +306,19 @@ pub fn view<'a>(
|
||||
let cancel_btn = button(text("Cancel").size(11))
|
||||
.on_press(Message::LayerStyleCancel)
|
||||
.padding([6, 20]);
|
||||
let buttons = row![ok_btn, cancel_btn].spacing(8).align_y(iced::Alignment::Center);
|
||||
let buttons = row![ok_btn, cancel_btn]
|
||||
.spacing(8)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
// ── Dialog content ──
|
||||
let content = column![
|
||||
row![
|
||||
let content = column![row![
|
||||
sidebar,
|
||||
column![params, horizontal_rule(1), buttons]
|
||||
.width(Length::Fill)
|
||||
.spacing(4)
|
||||
.padding(8),
|
||||
].spacing(4),
|
||||
]
|
||||
.spacing(4),]
|
||||
.spacing(4)
|
||||
.padding(8);
|
||||
|
||||
@@ -300,15 +337,10 @@ pub fn view<'a>(
|
||||
|
||||
// Make the title bar draggable — on_press starts the drag.
|
||||
// Global mouse move is handled by the subscription in app.rs.
|
||||
let drag_title = iced::widget::mouse_area(title_bar)
|
||||
.on_press(Message::LayerStyleDialogDragStart(0.0, 0.0));
|
||||
let drag_title =
|
||||
iced::widget::mouse_area(title_bar).on_press(Message::LayerStyleDialogDragStart(0.0, 0.0));
|
||||
|
||||
let dialog = column![
|
||||
drag_title,
|
||||
horizontal_rule(1),
|
||||
content,
|
||||
]
|
||||
.spacing(0);
|
||||
let dialog = column![drag_title, horizontal_rule(1), content,].spacing(0);
|
||||
|
||||
// ── Floating panel with offset from center + backdrop ──
|
||||
let ox = drag_offset.0;
|
||||
@@ -319,7 +351,10 @@ pub fn view<'a>(
|
||||
.height(Length::Fixed(420.0))
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_panel)),
|
||||
border: iced::Border::default().color(colors.border_high).width(2).rounded(8),
|
||||
border: iced::Border::default()
|
||||
.color(colors.border_high)
|
||||
.width(2)
|
||||
.rounded(8),
|
||||
shadow: iced::Shadow {
|
||||
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.6),
|
||||
offset: iced::Vector::new(4.0, 4.0),
|
||||
@@ -330,10 +365,7 @@ pub fn view<'a>(
|
||||
|
||||
// Apply horizontal offset using Space and doubling technique
|
||||
let dialog_row = if ox >= 0.0 {
|
||||
row![
|
||||
Space::with_width(Length::Fixed(ox * 2.0)),
|
||||
dialog_container,
|
||||
]
|
||||
row![Space::with_width(Length::Fixed(ox * 2.0)), dialog_container,]
|
||||
} else {
|
||||
row![
|
||||
dialog_container,
|
||||
@@ -343,15 +375,9 @@ pub fn view<'a>(
|
||||
|
||||
// Apply vertical offset using Space and doubling technique
|
||||
let dialog_positioned = if oy >= 0.0 {
|
||||
column![
|
||||
Space::with_height(Length::Fixed(oy * 2.0)),
|
||||
dialog_row,
|
||||
]
|
||||
column![Space::with_height(Length::Fixed(oy * 2.0)), dialog_row,]
|
||||
} else {
|
||||
column![
|
||||
dialog_row,
|
||||
Space::with_height(Length::Fixed(-oy * 2.0)),
|
||||
]
|
||||
column![dialog_row, Space::with_height(Length::Fixed(-oy * 2.0)),]
|
||||
};
|
||||
|
||||
let backdrop = container(dialog_positioned)
|
||||
@@ -368,9 +394,6 @@ fn build_style_params<'a>(
|
||||
selected: usize,
|
||||
styles_list: &[LayerStyle],
|
||||
colors: ThemeColors,
|
||||
show_color_picker: bool,
|
||||
color_picker_idx: usize,
|
||||
color_hsl: (f32, f32, f32),
|
||||
) -> Element<'a, Message> {
|
||||
if selected >= STYLE_DEFS.len() {
|
||||
return text("Select a style from the list").size(11).into();
|
||||
@@ -396,7 +419,11 @@ fn build_style_params<'a>(
|
||||
.width(Length::Fill)
|
||||
.text_size(10);
|
||||
|
||||
params = params.push(row![text("Blend Mode:").size(10), blend_list].spacing(4).align_y(iced::Alignment::Center));
|
||||
params = params.push(
|
||||
row![text("Blend Mode:").size(10), blend_list]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center),
|
||||
);
|
||||
|
||||
// Opacity slider (common to most styles)
|
||||
let opacity = match style {
|
||||
@@ -409,102 +436,210 @@ fn build_style_params<'a>(
|
||||
| Some(LayerStyle::GradientOverlay { opacity, .. })
|
||||
| Some(LayerStyle::PatternOverlay { opacity, .. })
|
||||
| Some(LayerStyle::Stroke { opacity, .. }) => *opacity,
|
||||
Some(LayerStyle::BevelEmboss { highlight_opacity, .. }) => *highlight_opacity,
|
||||
Some(LayerStyle::BevelEmboss {
|
||||
highlight_opacity, ..
|
||||
}) => *highlight_opacity,
|
||||
_ => 1.0,
|
||||
};
|
||||
params = params.push(slider_row("Opacity", opacity, 0.0..=1.0, move |v| Message::LayerStyleUpdateParam(selected, "opacity".to_string(), v), colors));
|
||||
params = params.push(slider_row(
|
||||
"Opacity",
|
||||
opacity,
|
||||
0.0..=1.0,
|
||||
move |v| Message::LayerStyleUpdateParam(selected, "opacity".to_string(), v),
|
||||
colors,
|
||||
));
|
||||
|
||||
// Color swatch — clickable to open color picker
|
||||
let sc = style_color;
|
||||
let swatch_bg = container(text(""))
|
||||
.width(32)
|
||||
.height(24)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
sc[0] as f32 / 255.0, sc[1] as f32 / 255.0, sc[2] as f32 / 255.0, 1.0,
|
||||
))),
|
||||
border: iced::Border::default()
|
||||
.color(iced::Color::from_rgba(1.0, 1.0, 1.0, 0.15))
|
||||
.width(1)
|
||||
.rounded(4),
|
||||
shadow: iced::Shadow {
|
||||
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.6),
|
||||
offset: iced::Vector::new(0.0, 1.0),
|
||||
blur_radius: 4.0,
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
let swatch_clickable = iced::widget::mouse_area(swatch_bg)
|
||||
.on_press(Message::ShowStyleColorPicker(selected))
|
||||
.interaction(iced::mouse::Interaction::Pointer);
|
||||
let color_label = row![text("Color:").size(10), swatch_clickable].spacing(4).align_y(iced::Alignment::Center);
|
||||
params = params.push(color_label);
|
||||
params = params.push(color_picker::color_swatch(
|
||||
style_color,
|
||||
"Color",
|
||||
ColorPickerTarget::StyleMain(selected),
|
||||
));
|
||||
|
||||
// Style-specific sliders
|
||||
match disc {
|
||||
"DropShadow" | "InnerShadow" => {
|
||||
let (angle, distance, spread, size) = match style {
|
||||
Some(LayerStyle::DropShadow { angle, distance, spread, size, .. }) |
|
||||
Some(LayerStyle::InnerShadow { angle, distance, spread, size, .. }) =>
|
||||
(*angle, *distance, *spread, *size),
|
||||
Some(LayerStyle::DropShadow {
|
||||
angle,
|
||||
distance,
|
||||
spread,
|
||||
size,
|
||||
..
|
||||
})
|
||||
| Some(LayerStyle::InnerShadow {
|
||||
angle,
|
||||
distance,
|
||||
spread,
|
||||
size,
|
||||
..
|
||||
}) => (*angle, *distance, *spread, *size),
|
||||
_ => (120.0, 5.0, 0.0, 5.0),
|
||||
};
|
||||
params = params.push(slider_row("Angle", angle, 0.0..=360.0, move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v), colors));
|
||||
params = params.push(slider_row("Distance", distance, 0.0..=100.0, move |v| Message::LayerStyleUpdateParam(selected, "distance".to_string(), v), colors));
|
||||
params = params.push(slider_row("Spread", spread, 0.0..=100.0, move |v| Message::LayerStyleUpdateParam(selected, "spread".to_string(), v), colors));
|
||||
params = params.push(slider_row("Size", size, 0.0..=250.0, move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v), colors));
|
||||
params = params.push(slider_row(
|
||||
"Angle",
|
||||
angle,
|
||||
0.0..=360.0,
|
||||
move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v),
|
||||
colors,
|
||||
));
|
||||
params = params.push(slider_row(
|
||||
"Distance",
|
||||
distance,
|
||||
0.0..=100.0,
|
||||
move |v| Message::LayerStyleUpdateParam(selected, "distance".to_string(), v),
|
||||
colors,
|
||||
));
|
||||
params = params.push(slider_row(
|
||||
"Spread",
|
||||
spread,
|
||||
0.0..=100.0,
|
||||
move |v| Message::LayerStyleUpdateParam(selected, "spread".to_string(), v),
|
||||
colors,
|
||||
));
|
||||
params = params.push(slider_row(
|
||||
"Size",
|
||||
size,
|
||||
0.0..=250.0,
|
||||
move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v),
|
||||
colors,
|
||||
));
|
||||
}
|
||||
"OuterGlow" | "InnerGlow" => {
|
||||
let (spread, size) = match style {
|
||||
Some(LayerStyle::OuterGlow { spread, size, .. }) |
|
||||
Some(LayerStyle::InnerGlow { spread, size, .. }) =>
|
||||
(*spread, *size),
|
||||
Some(LayerStyle::OuterGlow { spread, size, .. })
|
||||
| Some(LayerStyle::InnerGlow { spread, size, .. }) => (*spread, *size),
|
||||
_ => (0.0, 5.0),
|
||||
};
|
||||
params = params.push(slider_row("Spread", spread, 0.0..=100.0, move |v| Message::LayerStyleUpdateParam(selected, "spread".to_string(), v), colors));
|
||||
params = params.push(slider_row("Size", size, 0.0..=250.0, move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v), colors));
|
||||
params = params.push(slider_row(
|
||||
"Spread",
|
||||
spread,
|
||||
0.0..=100.0,
|
||||
move |v| Message::LayerStyleUpdateParam(selected, "spread".to_string(), v),
|
||||
colors,
|
||||
));
|
||||
params = params.push(slider_row(
|
||||
"Size",
|
||||
size,
|
||||
0.0..=250.0,
|
||||
move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v),
|
||||
colors,
|
||||
));
|
||||
}
|
||||
"BevelEmboss" => {
|
||||
let (depth, size, angle, altitude, soften, style_str, technique_str, direction_str,
|
||||
highlight_opacity, shadow_opacity, hl_color, sh_color, hl_blend, sh_blend,
|
||||
contour_str) = match style {
|
||||
let (
|
||||
depth,
|
||||
size,
|
||||
angle,
|
||||
altitude,
|
||||
soften,
|
||||
style_str,
|
||||
technique_str,
|
||||
direction_str,
|
||||
highlight_opacity,
|
||||
shadow_opacity,
|
||||
hl_color,
|
||||
sh_color,
|
||||
hl_blend,
|
||||
sh_blend,
|
||||
contour_str,
|
||||
) = match style {
|
||||
Some(LayerStyle::BevelEmboss {
|
||||
depth, size, angle, altitude, soften, style, technique, direction,
|
||||
highlight_opacity, shadow_opacity, highlight_color, shadow_color,
|
||||
highlight_blend_mode, shadow_blend_mode, contour, ..
|
||||
}) => (*depth, *size, *angle, *altitude, *soften,
|
||||
style.clone(), technique.clone(), direction.clone(),
|
||||
*highlight_opacity, *shadow_opacity, *highlight_color, *shadow_color,
|
||||
highlight_blend_mode.clone(), shadow_blend_mode.clone(),
|
||||
contour.clone()),
|
||||
_ => (100.0, 5.0, 120.0, 30.0, 0.0,
|
||||
"InnerBevel".to_string(), "Smooth".to_string(), "Up".to_string(),
|
||||
0.75, 0.75, [255, 255, 255, 255], [0, 0, 0, 255],
|
||||
"Screen".to_string(), "Multiply".to_string(),
|
||||
"Linear".to_string()),
|
||||
depth,
|
||||
size,
|
||||
angle,
|
||||
altitude,
|
||||
soften,
|
||||
style,
|
||||
technique,
|
||||
direction,
|
||||
highlight_opacity,
|
||||
shadow_opacity,
|
||||
highlight_color,
|
||||
shadow_color,
|
||||
highlight_blend_mode,
|
||||
shadow_blend_mode,
|
||||
contour,
|
||||
..
|
||||
}) => (
|
||||
*depth,
|
||||
*size,
|
||||
*angle,
|
||||
*altitude,
|
||||
*soften,
|
||||
style.clone(),
|
||||
technique.clone(),
|
||||
direction.clone(),
|
||||
*highlight_opacity,
|
||||
*shadow_opacity,
|
||||
*highlight_color,
|
||||
*shadow_color,
|
||||
highlight_blend_mode.clone(),
|
||||
shadow_blend_mode.clone(),
|
||||
contour.clone(),
|
||||
),
|
||||
_ => (
|
||||
100.0,
|
||||
5.0,
|
||||
120.0,
|
||||
30.0,
|
||||
0.0,
|
||||
"InnerBevel".to_string(),
|
||||
"Smooth".to_string(),
|
||||
"Up".to_string(),
|
||||
0.75,
|
||||
0.75,
|
||||
[255, 255, 255, 255],
|
||||
[0, 0, 0, 255],
|
||||
"Screen".to_string(),
|
||||
"Multiply".to_string(),
|
||||
"Linear".to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
// ── Structure section: Style, Technique, Direction ──
|
||||
let style_label_str = bevel_style_to_label(&style_str);
|
||||
let style_static: &'static str = Box::leak(style_label_str.to_string().into_boxed_str());
|
||||
let style_static: &'static str =
|
||||
Box::leak(style_label_str.to_string().into_boxed_str());
|
||||
let style_list = pick_list(BEVEL_STYLES, Some(style_static), move |v: &str| {
|
||||
Message::LayerStyleUpdateString(selected, "style".to_string(), bevel_style_to_internal(v).to_string())
|
||||
}).text_size(10);
|
||||
Message::LayerStyleUpdateString(
|
||||
selected,
|
||||
"style".to_string(),
|
||||
bevel_style_to_internal(v).to_string(),
|
||||
)
|
||||
})
|
||||
.text_size(10);
|
||||
|
||||
let tech_label_str = bevel_technique_to_label(&technique_str);
|
||||
let tech_static: &'static str = Box::leak(tech_label_str.to_string().into_boxed_str());
|
||||
let tech_list = pick_list(BEVEL_TECHNIQUES, Some(tech_static), move |v: &str| {
|
||||
Message::LayerStyleUpdateString(selected, "technique".to_string(), bevel_technique_to_internal(v).to_string())
|
||||
}).text_size(10);
|
||||
Message::LayerStyleUpdateString(
|
||||
selected,
|
||||
"technique".to_string(),
|
||||
bevel_technique_to_internal(v).to_string(),
|
||||
)
|
||||
})
|
||||
.text_size(10);
|
||||
|
||||
let dir_static: &'static str = Box::leak(direction_str.clone().into_boxed_str());
|
||||
let dir_list = pick_list(BEVEL_DIRECTIONS, Some(dir_static), move |v: &str| {
|
||||
Message::LayerStyleUpdateString(selected, "direction".to_string(), v.to_string())
|
||||
}).text_size(10);
|
||||
})
|
||||
.text_size(10);
|
||||
|
||||
params = params.push(row![text("Style:").size(10), style_list, Space::with_width(Length::Fixed(10.0)),
|
||||
text("Tech:").size(10), tech_list, Space::with_width(Length::Fixed(10.0)),
|
||||
text("Dir:").size(10), dir_list].spacing(3).align_y(iced::Alignment::Center));
|
||||
params = params.push(
|
||||
row![
|
||||
text("Style:").size(10),
|
||||
style_list,
|
||||
Space::with_width(Length::Fixed(10.0)),
|
||||
text("Tech:").size(10),
|
||||
tech_list,
|
||||
Space::with_width(Length::Fixed(10.0)),
|
||||
text("Dir:").size(10),
|
||||
dir_list
|
||||
]
|
||||
.spacing(3)
|
||||
.align_y(iced::Alignment::Center),
|
||||
);
|
||||
|
||||
params = params.push(horizontal_rule(1));
|
||||
|
||||
@@ -514,59 +649,67 @@ fn build_style_params<'a>(
|
||||
// Highlight blend mode dropdown
|
||||
let hl_blend_static: &'static str = Box::leak(hl_blend.clone().into_boxed_str());
|
||||
let hl_blend_list = pick_list(BLEND_MODES, Some(hl_blend_static), move |mode: &str| {
|
||||
Message::LayerStyleUpdateString(selected, "highlight_blend".to_string(), mode.to_string())
|
||||
}).width(Length::Fixed(110.0)).text_size(10);
|
||||
Message::LayerStyleUpdateString(
|
||||
selected,
|
||||
"highlight_blend".to_string(),
|
||||
mode.to_string(),
|
||||
)
|
||||
})
|
||||
.width(Length::Fixed(110.0))
|
||||
.text_size(10);
|
||||
|
||||
params = params.push(row![text("Hl Mode:").size(10), hl_blend_list].spacing(3).align_y(iced::Alignment::Center));
|
||||
params = params.push(
|
||||
row![text("Hl Mode:").size(10), hl_blend_list]
|
||||
.spacing(3)
|
||||
.align_y(iced::Alignment::Center),
|
||||
);
|
||||
|
||||
// Highlight opacity slider
|
||||
params = params.push(slider_row("Hl Opacity", highlight_opacity, 0.0..=1.0, move |v| Message::LayerStyleUpdateParam(selected, "highlight_opacity".to_string(), v), colors));
|
||||
params = params.push(slider_row(
|
||||
"Hl Opacity",
|
||||
highlight_opacity,
|
||||
0.0..=1.0,
|
||||
move |v| {
|
||||
Message::LayerStyleUpdateParam(selected, "highlight_opacity".to_string(), v)
|
||||
},
|
||||
colors,
|
||||
));
|
||||
|
||||
// Highlight color swatch
|
||||
let hc = hl_color;
|
||||
let hl_swatch_bg = container(text(""))
|
||||
.width(32).height(24)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
hc[0] as f32 / 255.0, hc[1] as f32 / 255.0, hc[2] as f32 / 255.0, 1.0,
|
||||
))),
|
||||
border: iced::Border::default().color(iced::Color::from_rgba(1.0, 1.0, 1.0, 0.15)).width(1).rounded(4),
|
||||
shadow: iced::Shadow { color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.6), offset: iced::Vector::new(0.0, 1.0), blur_radius: 4.0 },
|
||||
..Default::default()
|
||||
});
|
||||
let hl_swatch_clickable = iced::widget::mouse_area(hl_swatch_bg)
|
||||
.on_press(Message::ShowStyleColorPicker(selected))
|
||||
.interaction(iced::mouse::Interaction::Pointer);
|
||||
params = params.push(row![text("Hl Color:").size(10), hl_swatch_clickable].spacing(4).align_y(iced::Alignment::Center));
|
||||
params = params.push(color_picker::color_swatch(
|
||||
hl_color,
|
||||
"Highlight Color",
|
||||
ColorPickerTarget::StyleHighlight(selected),
|
||||
));
|
||||
|
||||
// ── Shadow ──
|
||||
// Shadow blend mode dropdown
|
||||
let sh_blend_static: &'static str = Box::leak(sh_blend.clone().into_boxed_str());
|
||||
let sh_blend_list = pick_list(BLEND_MODES, Some(sh_blend_static), move |mode: &str| {
|
||||
Message::LayerStyleUpdateShadowBlend(selected, mode.to_string())
|
||||
}).width(Length::Fixed(110.0)).text_size(10);
|
||||
})
|
||||
.width(Length::Fixed(110.0))
|
||||
.text_size(10);
|
||||
|
||||
params = params.push(row![text("Sh Mode:").size(10), sh_blend_list].spacing(3).align_y(iced::Alignment::Center));
|
||||
params = params.push(
|
||||
row![text("Sh Mode:").size(10), sh_blend_list]
|
||||
.spacing(3)
|
||||
.align_y(iced::Alignment::Center),
|
||||
);
|
||||
|
||||
// Shadow opacity slider
|
||||
params = params.push(slider_row("Sh Opacity", shadow_opacity, 0.0..=1.0, move |v| Message::LayerStyleUpdateShadowOpacity(selected, v), colors));
|
||||
params = params.push(slider_row(
|
||||
"Sh Opacity",
|
||||
shadow_opacity,
|
||||
0.0..=1.0,
|
||||
move |v| Message::LayerStyleUpdateShadowOpacity(selected, v),
|
||||
colors,
|
||||
));
|
||||
|
||||
// Shadow color swatch
|
||||
let sc = sh_color;
|
||||
let sh_swatch_bg = container(text(""))
|
||||
.width(32).height(24)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
sc[0] as f32 / 255.0, sc[1] as f32 / 255.0, sc[2] as f32 / 255.0, 1.0,
|
||||
))),
|
||||
border: iced::Border::default().color(iced::Color::from_rgba(1.0, 1.0, 1.0, 0.15)).width(1).rounded(4),
|
||||
shadow: iced::Shadow { color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.6), offset: iced::Vector::new(0.0, 1.0), blur_radius: 4.0 },
|
||||
..Default::default()
|
||||
});
|
||||
let sh_swatch_clickable = iced::widget::mouse_area(sh_swatch_bg)
|
||||
.on_press(Message::ShowStyleShadowColorPicker(selected))
|
||||
.interaction(iced::mouse::Interaction::Pointer);
|
||||
params = params.push(row![text("Sh Color:").size(10), sh_swatch_clickable].spacing(4).align_y(iced::Alignment::Center));
|
||||
params = params.push(color_picker::color_swatch(
|
||||
sh_color,
|
||||
"Shadow Color",
|
||||
ColorPickerTarget::StyleShadow(selected),
|
||||
));
|
||||
|
||||
params = params.push(horizontal_rule(1));
|
||||
|
||||
@@ -575,134 +718,137 @@ fn build_style_params<'a>(
|
||||
|
||||
// Contour dropdown
|
||||
let contour_static: &'static str = Box::leak(contour_str.clone().into_boxed_str());
|
||||
let contour_list: iced::widget::PickList<&str, &[&str], &str, Message> = pick_list(BEVEL_CONTOURS, Some(contour_static), move |v: &str| {
|
||||
let contour_list: iced::widget::PickList<&str, &[&str], &str, Message> =
|
||||
pick_list(BEVEL_CONTOURS, Some(contour_static), move |v: &str| {
|
||||
Message::LayerStyleUpdateString(selected, "contour".to_string(), v.to_string())
|
||||
}).width(Length::Fixed(120.0)).text_size(10);
|
||||
})
|
||||
.width(Length::Fixed(120.0))
|
||||
.text_size(10);
|
||||
params = params.push(row![text("Contour:").size(10), contour_list].spacing(3));
|
||||
|
||||
params = params.push(slider_row("Depth", depth, 0.0..=500.0, move |v| Message::LayerStyleUpdateParam(selected, "depth".to_string(), v), colors));
|
||||
params = params.push(slider_row("Size", size, 0.0..=250.0, move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v), colors));
|
||||
params = params.push(slider_row("Angle", angle, 0.0..=360.0, move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v), colors));
|
||||
params = params.push(slider_row("Altitude", altitude, 0.0..=90.0, move |v| Message::LayerStyleUpdateParam(selected, "altitude".to_string(), v), colors));
|
||||
params = params.push(slider_row("Soften", soften, 0.0..=16.0, move |v| Message::LayerStyleUpdateParam(selected, "soften".to_string(), v), colors));
|
||||
params = params.push(slider_row(
|
||||
"Depth",
|
||||
depth,
|
||||
0.0..=500.0,
|
||||
move |v| Message::LayerStyleUpdateParam(selected, "depth".to_string(), v),
|
||||
colors,
|
||||
));
|
||||
params = params.push(slider_row(
|
||||
"Size",
|
||||
size,
|
||||
0.0..=250.0,
|
||||
move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v),
|
||||
colors,
|
||||
));
|
||||
params = params.push(slider_row(
|
||||
"Angle",
|
||||
angle,
|
||||
0.0..=360.0,
|
||||
move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v),
|
||||
colors,
|
||||
));
|
||||
params = params.push(slider_row(
|
||||
"Altitude",
|
||||
altitude,
|
||||
0.0..=90.0,
|
||||
move |v| Message::LayerStyleUpdateParam(selected, "altitude".to_string(), v),
|
||||
colors,
|
||||
));
|
||||
params = params.push(slider_row(
|
||||
"Soften",
|
||||
soften,
|
||||
0.0..=16.0,
|
||||
move |v| Message::LayerStyleUpdateParam(selected, "soften".to_string(), v),
|
||||
colors,
|
||||
));
|
||||
}
|
||||
"Satin" => {
|
||||
let (angle, distance, size) = match style {
|
||||
Some(LayerStyle::Satin { angle, distance, size, .. }) =>
|
||||
(*angle, *distance, *size),
|
||||
Some(LayerStyle::Satin {
|
||||
angle,
|
||||
distance,
|
||||
size,
|
||||
..
|
||||
}) => (*angle, *distance, *size),
|
||||
_ => (120.0, 11.0, 14.0),
|
||||
};
|
||||
params = params.push(slider_row("Angle", angle, 0.0..=360.0, move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v), colors));
|
||||
params = params.push(slider_row("Distance", distance, 0.0..=100.0, move |v| Message::LayerStyleUpdateParam(selected, "distance".to_string(), v), colors));
|
||||
params = params.push(slider_row("Size", size, 0.0..=250.0, move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v), colors));
|
||||
params = params.push(slider_row(
|
||||
"Angle",
|
||||
angle,
|
||||
0.0..=360.0,
|
||||
move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v),
|
||||
colors,
|
||||
));
|
||||
params = params.push(slider_row(
|
||||
"Distance",
|
||||
distance,
|
||||
0.0..=100.0,
|
||||
move |v| Message::LayerStyleUpdateParam(selected, "distance".to_string(), v),
|
||||
colors,
|
||||
));
|
||||
params = params.push(slider_row(
|
||||
"Size",
|
||||
size,
|
||||
0.0..=250.0,
|
||||
move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v),
|
||||
colors,
|
||||
));
|
||||
}
|
||||
"ColorOverlay" => {
|
||||
// Only opacity — color is via the swatch above
|
||||
}
|
||||
"GradientOverlay" => {
|
||||
let (angle, scale) = match style {
|
||||
Some(LayerStyle::GradientOverlay { angle, scale, .. }) =>
|
||||
(*angle, *scale),
|
||||
Some(LayerStyle::GradientOverlay { angle, scale, .. }) => (*angle, *scale),
|
||||
_ => (90.0, 1.0),
|
||||
};
|
||||
params = params.push(slider_row("Angle", angle, 0.0..=360.0, move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v), colors));
|
||||
params = params.push(slider_row("Scale", scale, 0.0..=10.0, move |v| Message::LayerStyleUpdateParam(selected, "scale".to_string(), v), colors));
|
||||
params = params.push(slider_row(
|
||||
"Angle",
|
||||
angle,
|
||||
0.0..=360.0,
|
||||
move |v| Message::LayerStyleUpdateParam(selected, "angle".to_string(), v),
|
||||
colors,
|
||||
));
|
||||
params = params.push(slider_row(
|
||||
"Scale",
|
||||
scale,
|
||||
0.0..=10.0,
|
||||
move |v| Message::LayerStyleUpdateParam(selected, "scale".to_string(), v),
|
||||
colors,
|
||||
));
|
||||
}
|
||||
"PatternOverlay" => {
|
||||
let scale = match style {
|
||||
Some(LayerStyle::PatternOverlay { scale, .. }) => *scale,
|
||||
_ => 1.0,
|
||||
};
|
||||
params = params.push(slider_row("Scale", scale, 0.0..=10.0, move |v| Message::LayerStyleUpdateParam(selected, "scale".to_string(), v), colors));
|
||||
params = params.push(slider_row(
|
||||
"Scale",
|
||||
scale,
|
||||
0.0..=10.0,
|
||||
move |v| Message::LayerStyleUpdateParam(selected, "scale".to_string(), v),
|
||||
colors,
|
||||
));
|
||||
}
|
||||
"Stroke" => {
|
||||
let size = match style {
|
||||
Some(LayerStyle::Stroke { size, .. }) => *size,
|
||||
_ => 3.0,
|
||||
};
|
||||
params = params.push(slider_row("Size", size, 0.0..=100.0, move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v), colors));
|
||||
params = params.push(slider_row(
|
||||
"Size",
|
||||
size,
|
||||
0.0..=100.0,
|
||||
move |v| Message::LayerStyleUpdateParam(selected, "size".to_string(), v),
|
||||
colors,
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Show color picker popup if active
|
||||
if show_color_picker && color_picker_idx == selected {
|
||||
params = params.push(build_color_picker_popup(color_hsl, selected, colors));
|
||||
}
|
||||
|
||||
scrollable(params).height(Length::Fill).into()
|
||||
}
|
||||
|
||||
/// Build a compact HSL color picker popup for a layer style.
|
||||
fn build_color_picker_popup<'a>(
|
||||
color_hsl: (f32, f32, f32),
|
||||
style_idx: usize,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'a, Message> {
|
||||
let (h, s, l) = color_hsl;
|
||||
let rgb = hsl_to_rgb(h, s, l);
|
||||
|
||||
// Hue slider
|
||||
let h_slider = slider(0.0..=360.0, h, move |v: f32| {
|
||||
let rgb = hsl_to_rgb(v, s, l);
|
||||
Message::LayerStyleUpdateColor(style_idx, [rgb[0], rgb[1], rgb[2], 255])
|
||||
}).step(1.0);
|
||||
|
||||
// Saturation slider
|
||||
let s_slider = slider(0.0..=100.0, s * 100.0, move |v: f32| {
|
||||
let rgb = hsl_to_rgb(h, v / 100.0, l);
|
||||
Message::LayerStyleUpdateColor(style_idx, [rgb[0], rgb[1], rgb[2], 255])
|
||||
}).step(1.0);
|
||||
|
||||
// Lightness slider
|
||||
let l_slider = slider(0.0..=100.0, l * 100.0, move |v: f32| {
|
||||
let rgb = hsl_to_rgb(h, s, v / 100.0);
|
||||
Message::LayerStyleUpdateColor(style_idx, [rgb[0], rgb[1], rgb[2], 255])
|
||||
}).step(1.0);
|
||||
|
||||
// Color preview swatch
|
||||
let preview = container(text(""))
|
||||
.width(Length::Fill)
|
||||
.height(20)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgb(
|
||||
rgb[0] as f32 / 255.0, rgb[1] as f32 / 255.0, rgb[2] as f32 / 255.0,
|
||||
))),
|
||||
border: iced::Border::default().rounded(2).color(iced::Color::from_rgb(0.4, 0.4, 0.4)).width(1),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Hex display
|
||||
let hex = format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]);
|
||||
|
||||
let popup = column![
|
||||
text("Color Picker").size(10),
|
||||
preview,
|
||||
row![text("H").size(9), h_slider, text(format!("{:.0}\u{00B0}", h)).size(9)].spacing(3),
|
||||
row![text("S").size(9), s_slider, text(format!("{:.0}%", s * 100.0)).size(9)].spacing(3),
|
||||
row![text("L").size(9), l_slider, text(format!("{:.0}%", l * 100.0)).size(9)].spacing(3),
|
||||
row![text(hex).size(9),
|
||||
Space::with_width(Length::Fill),
|
||||
button(text("Close").size(9)).on_press(Message::HideStyleColorPicker).padding([2, 8])].spacing(4),
|
||||
]
|
||||
.spacing(3)
|
||||
.padding(6);
|
||||
|
||||
container(popup)
|
||||
.width(Length::Fixed(260.0))
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_panel)),
|
||||
border: iced::Border::default().color(colors.border_high).width(1).rounded(4),
|
||||
shadow: iced::Shadow {
|
||||
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.4),
|
||||
offset: iced::Vector::new(0.0, 2.0),
|
||||
blur_radius: 8.0,
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Build a compact slider row.
|
||||
fn slider_row<'a>(
|
||||
label: &'a str,
|
||||
@@ -711,14 +857,18 @@ fn slider_row<'a>(
|
||||
on_change: impl Fn(f32) -> Message + 'static,
|
||||
_colors: ThemeColors,
|
||||
) -> Element<'a, Message> {
|
||||
let value_label = text(format!("{:.1}", value)).size(9).width(Length::Fixed(40.0));
|
||||
let value_label = text(format!("{:.1}", value))
|
||||
.size(9)
|
||||
.width(Length::Fixed(40.0));
|
||||
let label_text = text(label).size(10).width(Length::Fixed(70.0));
|
||||
|
||||
let step = if *range.end() <= 1.0 { 0.01 } else { 1.0 };
|
||||
|
||||
row![
|
||||
label_text,
|
||||
slider(range, value, on_change).step(step).width(Length::Fill),
|
||||
slider(range, value, on_change)
|
||||
.step(step)
|
||||
.width(Length::Fill),
|
||||
value_label,
|
||||
]
|
||||
.spacing(3)
|
||||
|
||||
@@ -10,9 +10,12 @@
|
||||
use crate::app::Message;
|
||||
use crate::panels::styles;
|
||||
use crate::panels::thumbnails;
|
||||
use crate::panels::typography::BODY;
|
||||
use crate::theme::ThemeColors;
|
||||
use hcie_engine_api::{BlendMode, LayerInfo, LayerType};
|
||||
use iced::widget::{button, column, container, horizontal_rule, pick_list, row, scrollable, slider, text, Space};
|
||||
use iced::widget::{
|
||||
button, column, container, horizontal_rule, pick_list, row, scrollable, slider, text, Space,
|
||||
};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Wrapper for BlendMode to provide Display for pick_list.
|
||||
@@ -26,29 +29,45 @@ impl std::fmt::Display for BlendModeItem {
|
||||
}
|
||||
|
||||
impl From<BlendMode> for BlendModeItem {
|
||||
fn from(m: BlendMode) -> Self { Self(m) }
|
||||
fn from(m: BlendMode) -> Self {
|
||||
Self(m)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BlendModeItem> for BlendMode {
|
||||
fn from(m: BlendModeItem) -> Self { m.0 }
|
||||
fn from(m: BlendModeItem) -> Self {
|
||||
m.0
|
||||
}
|
||||
}
|
||||
|
||||
const BLEND_MODE_ITEMS: &[BlendModeItem] = &[
|
||||
BlendModeItem(BlendMode::Normal), BlendModeItem(BlendMode::Dissolve),
|
||||
BlendModeItem(BlendMode::Darken), BlendModeItem(BlendMode::Multiply),
|
||||
BlendModeItem(BlendMode::ColorBurn), BlendModeItem(BlendMode::LinearBurn),
|
||||
BlendModeItem(BlendMode::Normal),
|
||||
BlendModeItem(BlendMode::Dissolve),
|
||||
BlendModeItem(BlendMode::Darken),
|
||||
BlendModeItem(BlendMode::Multiply),
|
||||
BlendModeItem(BlendMode::ColorBurn),
|
||||
BlendModeItem(BlendMode::LinearBurn),
|
||||
BlendModeItem(BlendMode::DarkerColor),
|
||||
BlendModeItem(BlendMode::Lighten), BlendModeItem(BlendMode::Screen),
|
||||
BlendModeItem(BlendMode::ColorDodge), BlendModeItem(BlendMode::LinearDodge),
|
||||
BlendModeItem(BlendMode::Lighten),
|
||||
BlendModeItem(BlendMode::Screen),
|
||||
BlendModeItem(BlendMode::ColorDodge),
|
||||
BlendModeItem(BlendMode::LinearDodge),
|
||||
BlendModeItem(BlendMode::LighterColor),
|
||||
BlendModeItem(BlendMode::Overlay), BlendModeItem(BlendMode::SoftLight),
|
||||
BlendModeItem(BlendMode::HardLight), BlendModeItem(BlendMode::VividLight),
|
||||
BlendModeItem(BlendMode::LinearLight), BlendModeItem(BlendMode::PinLight),
|
||||
BlendModeItem(BlendMode::Overlay),
|
||||
BlendModeItem(BlendMode::SoftLight),
|
||||
BlendModeItem(BlendMode::HardLight),
|
||||
BlendModeItem(BlendMode::VividLight),
|
||||
BlendModeItem(BlendMode::LinearLight),
|
||||
BlendModeItem(BlendMode::PinLight),
|
||||
BlendModeItem(BlendMode::HardMix),
|
||||
BlendModeItem(BlendMode::Difference), BlendModeItem(BlendMode::Exclusion),
|
||||
BlendModeItem(BlendMode::Subtract), BlendModeItem(BlendMode::Divide),
|
||||
BlendModeItem(BlendMode::Hue), BlendModeItem(BlendMode::Saturation),
|
||||
BlendModeItem(BlendMode::Color), BlendModeItem(BlendMode::Luminosity),
|
||||
BlendModeItem(BlendMode::Difference),
|
||||
BlendModeItem(BlendMode::Exclusion),
|
||||
BlendModeItem(BlendMode::Subtract),
|
||||
BlendModeItem(BlendMode::Divide),
|
||||
BlendModeItem(BlendMode::Hue),
|
||||
BlendModeItem(BlendMode::Saturation),
|
||||
BlendModeItem(BlendMode::Color),
|
||||
BlendModeItem(BlendMode::Luminosity),
|
||||
BlendModeItem(BlendMode::PassThrough),
|
||||
];
|
||||
|
||||
@@ -77,7 +96,11 @@ fn build_tree<'a>(layers: &'a [LayerInfo]) -> Vec<LayerEntry<'a>> {
|
||||
if let Some(children) = children_of.get(&parent_id) {
|
||||
for info in children {
|
||||
let is_group = info.layer_type == LayerType::Group;
|
||||
result.push(LayerEntry { info, depth, is_group });
|
||||
result.push(LayerEntry {
|
||||
info,
|
||||
depth,
|
||||
is_group,
|
||||
});
|
||||
if is_group && !info.collapsed {
|
||||
walk(Some(info.id), depth + 1, children_of, result);
|
||||
}
|
||||
@@ -114,7 +137,8 @@ fn layer_thumbnail<'a>(
|
||||
}
|
||||
|
||||
let info = entry.info;
|
||||
let thumb = engine.get_layer_pixels(info.id)
|
||||
let thumb = engine
|
||||
.get_layer_pixels(info.id)
|
||||
.filter(|px| !px.is_empty() && info.width > 0 && info.height > 0)
|
||||
.map(|px| thumbnails::generate_thumbnail(&px, info.width, info.height, 28));
|
||||
|
||||
@@ -141,17 +165,19 @@ fn layer_thumbnail<'a>(
|
||||
_ => ("\u{25A3}", colors.text_secondary),
|
||||
};
|
||||
|
||||
container(
|
||||
text(icon_char).size(10).style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
container(text(icon_char).size(10).style(move |_theme: &iced::Theme| {
|
||||
iced::widget::text::Style {
|
||||
color: Some(icon_color),
|
||||
})
|
||||
)
|
||||
}
|
||||
}))
|
||||
.width(30)
|
||||
.height(22)
|
||||
.center_x(Length::Fixed(30.0))
|
||||
.center_y(Length::Fixed(22.0))
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(1.0, 1.0, 1.0, 0.05))),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
1.0, 1.0, 1.0, 0.05,
|
||||
))),
|
||||
border: iced::Border::default().color(colors.border_high).width(1),
|
||||
..Default::default()
|
||||
})
|
||||
@@ -169,18 +195,35 @@ pub fn view<'a>(
|
||||
|
||||
// ── Active layer controls ──────────────────────────────
|
||||
let active_info = layers.iter().find(|l| l.id == active_layer_id);
|
||||
let active_blend = active_info.map(|l| l.blend_mode).unwrap_or(BlendMode::Normal);
|
||||
let active_blend = active_info
|
||||
.map(|l| l.blend_mode)
|
||||
.unwrap_or(BlendMode::Normal);
|
||||
let active_opacity = active_info.map(|l| l.opacity).unwrap_or(1.0);
|
||||
let active_locked = active_info.map(|l| l.locked).unwrap_or(false);
|
||||
|
||||
let blend_list = pick_list(BLEND_MODE_ITEMS, Some(BlendModeItem(active_blend)), move |item| {
|
||||
Message::LayerSetBlendMode(active_layer_id, item.into())
|
||||
let active_metadata = active_info
|
||||
.map(|layer| {
|
||||
format!(
|
||||
"{:?} {}x{} parent {}",
|
||||
layer.layer_type,
|
||||
layer.width,
|
||||
layer.height,
|
||||
layer
|
||||
.parent_id
|
||||
.map_or_else(|| "root".to_string(), |id| id.to_string())
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| "No active layer".to_string());
|
||||
|
||||
let blend_list = pick_list(
|
||||
BLEND_MODE_ITEMS,
|
||||
Some(BlendModeItem(active_blend)),
|
||||
move |item| Message::LayerSetBlendMode(active_layer_id, item.into()),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.text_size(10);
|
||||
.text_size(BODY);
|
||||
|
||||
let opacity_label = text(format!("{}%", (active_opacity * 100.0) as u32))
|
||||
.size(10)
|
||||
.size(BODY)
|
||||
.width(Length::Fixed(32.0));
|
||||
let opacity_slider = slider(0.0..=1.0, active_opacity, move |v| {
|
||||
Message::LayerSetOpacity(active_layer_id, v)
|
||||
@@ -188,11 +231,15 @@ pub fn view<'a>(
|
||||
.step(0.01)
|
||||
.width(Length::Fill);
|
||||
|
||||
let opacity_row = row![text("Op").size(9), opacity_slider, opacity_label]
|
||||
let opacity_row = row![text("Op").size(BODY), opacity_slider, opacity_label]
|
||||
.spacing(3)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
let lock_icon = if active_locked { "\u{1F512}" } else { "\u{1F513}" };
|
||||
let lock_icon = if active_locked {
|
||||
"\u{1F512}"
|
||||
} else {
|
||||
"\u{1F513}"
|
||||
};
|
||||
let lock_btn = button(text(lock_icon).size(11))
|
||||
.on_press(Message::LayerToggleLock(active_layer_id))
|
||||
.padding([1, 4])
|
||||
@@ -206,7 +253,15 @@ pub fn view<'a>(
|
||||
let controls = column![
|
||||
row![blend_list].spacing(2),
|
||||
opacity_row,
|
||||
row![lock_btn, fx_btn].spacing(8).align_y(iced::Alignment::Center),
|
||||
text("Fill opacity: unavailable in hcie-engine-api")
|
||||
.size(BODY)
|
||||
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(colors.text_secondary),
|
||||
}),
|
||||
row![lock_btn, fx_btn]
|
||||
.spacing(8)
|
||||
.align_y(iced::Alignment::Center),
|
||||
text(active_metadata).size(BODY),
|
||||
]
|
||||
.spacing(2);
|
||||
|
||||
@@ -219,13 +274,16 @@ pub fn view<'a>(
|
||||
let depth = entry.depth;
|
||||
|
||||
// Indent spacer
|
||||
let indent: Element<'a, Message> = Space::with_width(
|
||||
Length::Fixed((depth as f32) * 14.0)
|
||||
).into();
|
||||
let indent: Element<'a, Message> =
|
||||
Space::with_width(Length::Fixed((depth as f32) * 14.0)).into();
|
||||
|
||||
// Collapse arrow (groups only)
|
||||
let collapse: Element<'a, Message> = if entry.is_group {
|
||||
let arrow = if info.collapsed { "\u{25B6}" } else { "\u{25BC}" };
|
||||
let arrow = if info.collapsed {
|
||||
"\u{25B6}"
|
||||
} else {
|
||||
"\u{25BC}"
|
||||
};
|
||||
button(text(arrow).size(8))
|
||||
.on_press(Message::LayerToggleCollapse(info.id))
|
||||
.padding([0, 1])
|
||||
@@ -237,31 +295,46 @@ pub fn view<'a>(
|
||||
|
||||
// Visibility toggle
|
||||
let vis_char = if info.visible { "\u{25CF}" } else { "\u{25CB}" };
|
||||
let vis_color = if info.visible { colors.text_primary } else { colors.text_secondary };
|
||||
let vis_btn = button(text(vis_char).size(10).style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
let vis_color = if info.visible {
|
||||
colors.text_primary
|
||||
} else {
|
||||
colors.text_secondary
|
||||
};
|
||||
let vis_btn = button(text(vis_char).size(10).style(move |_theme: &iced::Theme| {
|
||||
iced::widget::text::Style {
|
||||
color: Some(vis_color),
|
||||
}
|
||||
}))
|
||||
.on_press(Message::LayerToggleVisibility(info.id))
|
||||
.padding([0, 2])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
|
||||
let row_lock = button(text(if info.locked { "L" } else { "-" }).size(9))
|
||||
.on_press(Message::LayerToggleLock(info.id))
|
||||
.padding([0, 2])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
|
||||
// Thumbnail
|
||||
let thumb_elem = layer_thumbnail(entry, engine, colors);
|
||||
|
||||
// Layer name
|
||||
let c = if is_active { colors.accent } else { colors.text_primary };
|
||||
let c = if is_active {
|
||||
colors.accent
|
||||
} else {
|
||||
colors.text_primary
|
||||
};
|
||||
let name_text = text(&info.name)
|
||||
.size(10)
|
||||
.size(BODY)
|
||||
.width(Length::Fill)
|
||||
.style(move |_theme: &iced::Theme| iced::widget::text::Style { color: Some(c) });
|
||||
|
||||
// "fx" icon for layers with effects (Photoshop-style)
|
||||
let fx_icon: Element<'a, Message> = if info.has_effects {
|
||||
button(
|
||||
text("fx").size(8).style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
button(text("fx").size(8).style(move |_theme: &iced::Theme| {
|
||||
iced::widget::text::Style {
|
||||
color: Some(colors.accent),
|
||||
})
|
||||
)
|
||||
}
|
||||
}))
|
||||
.on_press(Message::LayerStyleToggle(0)) // Opens styles panel
|
||||
.padding([0, 2])
|
||||
.style(move |_theme, _status| flat_btn_style(colors))
|
||||
@@ -272,34 +345,36 @@ pub fn view<'a>(
|
||||
|
||||
// Type badge
|
||||
let type_badge: Element<'a, Message> = match info.layer_type {
|
||||
LayerType::Vector => text("V").size(8)
|
||||
LayerType::Vector => text("V")
|
||||
.size(8)
|
||||
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(iced::Color::from_rgb(0.4, 0.8, 0.4)),
|
||||
}).into(),
|
||||
LayerType::Text => text("T").size(8)
|
||||
})
|
||||
.into(),
|
||||
LayerType::Text => text("T")
|
||||
.size(8)
|
||||
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(iced::Color::from_rgb(0.4, 0.6, 1.0)),
|
||||
}).into(),
|
||||
LayerType::Group => text("G").size(8)
|
||||
})
|
||||
.into(),
|
||||
LayerType::Group => text("G")
|
||||
.size(8)
|
||||
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(iced::Color::from_rgb(0.85, 0.7, 0.3)),
|
||||
}).into(),
|
||||
LayerType::Mask => text("M").size(8)
|
||||
})
|
||||
.into(),
|
||||
LayerType::Mask => text("M")
|
||||
.size(8)
|
||||
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(iced::Color::from_rgb(0.8, 0.4, 0.8)),
|
||||
}).into(),
|
||||
})
|
||||
.into(),
|
||||
_ => text("").size(8).into(),
|
||||
};
|
||||
|
||||
// Main layer row
|
||||
let name_row = row![
|
||||
collapse,
|
||||
vis_btn,
|
||||
thumb_elem,
|
||||
name_text,
|
||||
fx_icon,
|
||||
type_badge,
|
||||
]
|
||||
let name_row =
|
||||
row![collapse, vis_btn, row_lock, thumb_elem, name_text, fx_icon, type_badge,]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
@@ -309,13 +384,21 @@ pub fn view<'a>(
|
||||
.padding([2, 3])
|
||||
.style(move |_theme| {
|
||||
let accent_bg = iced::Color::from_rgba(
|
||||
colors.accent.r, colors.accent.g, colors.accent.b, 0.18,
|
||||
colors.accent.r,
|
||||
colors.accent.g,
|
||||
colors.accent.b,
|
||||
0.18,
|
||||
);
|
||||
iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(accent_bg)),
|
||||
border: iced::Border::default().color(
|
||||
iced::Color::from_rgba(colors.accent.r, colors.accent.g, colors.accent.b, 0.4)
|
||||
).width(1),
|
||||
border: iced::Border::default()
|
||||
.color(iced::Color::from_rgba(
|
||||
colors.accent.r,
|
||||
colors.accent.g,
|
||||
colors.accent.b,
|
||||
0.4,
|
||||
))
|
||||
.width(1),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
@@ -326,8 +409,8 @@ pub fn view<'a>(
|
||||
.style(move |_theme| styles::inactive_item_bg(colors))
|
||||
};
|
||||
|
||||
let clickable = iced::widget::mouse_area(layer_item)
|
||||
.on_press(Message::LayerSelect(info.id));
|
||||
let clickable =
|
||||
iced::widget::mouse_area(layer_item).on_press(Message::LayerSelect(info.id));
|
||||
|
||||
let row_with_indent = row![indent, clickable].align_y(iced::Alignment::Center);
|
||||
layer_list = layer_list.push(row_with_indent);
|
||||
@@ -341,15 +424,18 @@ pub fn view<'a>(
|
||||
if *enabled {
|
||||
let effect_name = effect_full_name(code);
|
||||
let effect_color = fx_color(code);
|
||||
let effect_indent = Space::with_width(
|
||||
Length::Fixed((effects_depth as f32) * 14.0)
|
||||
);
|
||||
let effect_indent =
|
||||
Space::with_width(Length::Fixed((effects_depth as f32) * 14.0));
|
||||
let effect_row = row![
|
||||
effect_indent,
|
||||
text("\u{25B8}").size(8).style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
text("\u{25B8}").size(8).style(move |_theme: &iced::Theme| {
|
||||
iced::widget::text::Style {
|
||||
color: Some(colors.text_secondary),
|
||||
}
|
||||
}),
|
||||
text(effect_name).size(9).style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
text(effect_name)
|
||||
.size(BODY)
|
||||
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(effect_color),
|
||||
}),
|
||||
]
|
||||
@@ -371,6 +457,9 @@ pub fn view<'a>(
|
||||
.on_press(Message::LayerAddGroup)
|
||||
.padding([3, 6])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
let duplicate_btn = button(text("Dup").size(9)).padding([3, 6]);
|
||||
let mask_btn = button(text("Mask").size(9)).padding([3, 6]);
|
||||
let rasterize_btn = button(text("Raster").size(9)).padding([3, 6]);
|
||||
let flatten_btn = button(text("Flatten").size(10))
|
||||
.on_press(Message::LayerFlatten)
|
||||
.padding([3, 6])
|
||||
@@ -392,17 +481,19 @@ pub fn view<'a>(
|
||||
.on_press(Message::OpenLayerStyleDialog)
|
||||
.padding([3, 6])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
let toolbar = row![
|
||||
add_btn, group_btn, flatten_btn,
|
||||
iced::widget::horizontal_rule(1),
|
||||
fx_btn,
|
||||
iced::widget::horizontal_rule(1),
|
||||
move_up_btn, move_down_btn,
|
||||
iced::widget::horizontal_rule(1),
|
||||
del_btn,
|
||||
]
|
||||
let toolbar = column![
|
||||
text("Disabled (engine API): duplicate / mask / rasterize").size(8),
|
||||
row![duplicate_btn, mask_btn, rasterize_btn]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center);
|
||||
.align_y(iced::Alignment::Center),
|
||||
row![add_btn, group_btn, flatten_btn, fx_btn]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center),
|
||||
row![move_up_btn, move_down_btn, del_btn]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center),
|
||||
]
|
||||
.spacing(2);
|
||||
|
||||
// ── Panel layout (no duplicate title — dock tab provides it) ──
|
||||
let panel = column![
|
||||
|
||||
@@ -15,38 +15,145 @@
|
||||
use crate::app::Message;
|
||||
use crate::dock::state::{DockState, PaneType};
|
||||
use crate::theme::ThemeColors;
|
||||
use hcie_engine_api::Tool;
|
||||
use iced::widget::{column, container, horizontal_rule, mouse_area, row, text};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Menu item definition.
|
||||
/// Identifies one implemented menu operation independently of menu position or label.
|
||||
///
|
||||
/// The menu builder stores this value on enabled leaves, and `HcieApp::update`
|
||||
/// exhaustively translates it into application behavior. Dynamic recent-file entries
|
||||
/// carry their path so reordering the recent list cannot change the selected file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MenuCommand {
|
||||
NewDocument,
|
||||
OpenFile,
|
||||
Save,
|
||||
SaveAs,
|
||||
OpenRecent(String),
|
||||
ClearRecent,
|
||||
Undo,
|
||||
Redo,
|
||||
Copy,
|
||||
Cut,
|
||||
ClearPixels,
|
||||
Paste,
|
||||
CanvasSize,
|
||||
ImageSize,
|
||||
AddLayer,
|
||||
DeleteLayer,
|
||||
LayerStyles,
|
||||
MergeDown,
|
||||
Flatten,
|
||||
SelectAll,
|
||||
Deselect,
|
||||
InvertSelection,
|
||||
GrowSelection,
|
||||
ShrinkSelection,
|
||||
FeatherSelection,
|
||||
BorderSelection,
|
||||
SmoothSelection,
|
||||
SelectionMode(crate::selection::SelectionMode),
|
||||
SaveSelection,
|
||||
LoadSelection,
|
||||
ToggleQuickMask,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
FitArea,
|
||||
ActualPixels,
|
||||
SelectCropTool,
|
||||
GaussianBlur,
|
||||
Mosaic,
|
||||
UnsharpMask,
|
||||
TogglePane(PaneType),
|
||||
SetTheme(crate::theme::ThemePreset),
|
||||
}
|
||||
|
||||
/// Describes one rendered menu row and its optional semantic operation.
|
||||
#[derive(Clone)]
|
||||
struct MenuItem {
|
||||
label: String,
|
||||
shortcut: String,
|
||||
enabled: bool,
|
||||
has_submenu: bool,
|
||||
command: Option<MenuCommand>,
|
||||
}
|
||||
|
||||
impl MenuItem {
|
||||
/// Creates a disabled placeholder leaf for an operation that is not implemented.
|
||||
fn new(label: &str) -> Self {
|
||||
Self { label: label.to_string(), shortcut: String::new(), enabled: true, has_submenu: false }
|
||||
Self {
|
||||
label: label.to_string(),
|
||||
shortcut: String::new(),
|
||||
enabled: false,
|
||||
has_submenu: false,
|
||||
command: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a disabled placeholder leaf while retaining its documented shortcut.
|
||||
fn with_shortcut(label: &str, shortcut: &str) -> Self {
|
||||
Self { label: label.to_string(), shortcut: shortcut.to_string(), enabled: true, has_submenu: false }
|
||||
Self {
|
||||
label: label.to_string(),
|
||||
shortcut: shortcut.to_string(),
|
||||
enabled: false,
|
||||
has_submenu: false,
|
||||
command: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an enabled leaf bound to a stable semantic command.
|
||||
fn command(label: &str, command: MenuCommand) -> Self {
|
||||
Self {
|
||||
label: label.to_string(),
|
||||
shortcut: String::new(),
|
||||
enabled: true,
|
||||
has_submenu: false,
|
||||
command: Some(command),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an enabled leaf with a shortcut and a stable semantic command.
|
||||
fn command_with_shortcut(label: &str, shortcut: &str, command: MenuCommand) -> Self {
|
||||
Self {
|
||||
label: label.to_string(),
|
||||
shortcut: shortcut.to_string(),
|
||||
enabled: true,
|
||||
has_submenu: false,
|
||||
command: Some(command),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a non-dispatching submenu placeholder.
|
||||
fn submenu(label: &str) -> Self {
|
||||
Self { label: label.to_string(), shortcut: String::new(), enabled: true, has_submenu: true }
|
||||
Self {
|
||||
label: label.to_string(),
|
||||
shortcut: String::new(),
|
||||
enabled: false,
|
||||
has_submenu: true,
|
||||
command: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a visual separator with no interaction.
|
||||
fn separator() -> Self {
|
||||
Self { label: "─".to_string(), shortcut: String::new(), enabled: false, has_submenu: false }
|
||||
Self {
|
||||
label: "─".to_string(),
|
||||
shortcut: String::new(),
|
||||
enabled: false,
|
||||
has_submenu: false,
|
||||
command: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a disabled informational row.
|
||||
fn disabled(label: &str) -> Self {
|
||||
Self { label: label.to_string(), shortcut: String::new(), enabled: false, has_submenu: false }
|
||||
Self {
|
||||
label: label.to_string(),
|
||||
shortcut: String::new(),
|
||||
enabled: false,
|
||||
has_submenu: false,
|
||||
command: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,15 +167,15 @@ struct MenuDef {
|
||||
fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
// ── File menu (Photopea-style) ──
|
||||
let mut file_items = vec![
|
||||
MenuItem::with_shortcut("New...", "Alt+Ctrl+N"),
|
||||
MenuItem::with_shortcut("Open...", "Alt+Ctrl+O"),
|
||||
MenuItem::command_with_shortcut("New...", "Alt+Ctrl+N", MenuCommand::NewDocument),
|
||||
MenuItem::command_with_shortcut("Open...", "Alt+Ctrl+O", MenuCommand::OpenFile),
|
||||
MenuItem::new("Open & Place..."),
|
||||
MenuItem::submenu("Open More"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Share"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::with_shortcut("Save", "Ctrl+S"),
|
||||
MenuItem::new("Save as PSD"),
|
||||
MenuItem::command_with_shortcut("Save", "Ctrl+S", MenuCommand::Save),
|
||||
MenuItem::command("Save as PSD", MenuCommand::SaveAs),
|
||||
MenuItem::submenu("Save More"),
|
||||
MenuItem::submenu("Export as"),
|
||||
MenuItem::with_shortcut("Print...", "Ctrl+P"),
|
||||
@@ -85,29 +192,35 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
file_items.push(MenuItem::separator());
|
||||
file_items.push(MenuItem::disabled("Open Recent"));
|
||||
for entry in recent_files.iter().take(10) {
|
||||
file_items.push(MenuItem::new(&entry.name));
|
||||
file_items.push(MenuItem::command(
|
||||
&entry.name,
|
||||
MenuCommand::OpenRecent(entry.path.clone()),
|
||||
));
|
||||
}
|
||||
file_items.push(MenuItem::separator());
|
||||
file_items.push(MenuItem::new("Clear Recent"));
|
||||
file_items.push(MenuItem::command("Clear Recent", MenuCommand::ClearRecent));
|
||||
}
|
||||
|
||||
vec![
|
||||
MenuDef { label: "File".to_string(), items: file_items },
|
||||
MenuDef {
|
||||
label: "File".to_string(),
|
||||
items: file_items,
|
||||
},
|
||||
// ── Edit menu (Photopea-style) ──
|
||||
MenuDef {
|
||||
label: "Edit".to_string(),
|
||||
items: vec![
|
||||
MenuItem::new("Undo / Redo"),
|
||||
MenuItem::with_shortcut("Step Forward", "Shift+Ctrl+Z"),
|
||||
MenuItem::with_shortcut("Step Backward", "Ctrl+Z"),
|
||||
MenuItem::command("Undo / Redo", MenuCommand::Undo),
|
||||
MenuItem::command_with_shortcut("Step Forward", "Shift+Ctrl+Z", MenuCommand::Redo),
|
||||
MenuItem::command_with_shortcut("Step Backward", "Ctrl+Z", MenuCommand::Undo),
|
||||
MenuItem::separator(),
|
||||
MenuItem::with_shortcut("Fade...", "Shift+Ctrl+F"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::with_shortcut("Cut", "Ctrl+X"),
|
||||
MenuItem::with_shortcut("Copy", "Ctrl+C"),
|
||||
MenuItem::command_with_shortcut("Cut", "Ctrl+X", MenuCommand::Cut),
|
||||
MenuItem::command_with_shortcut("Copy", "Ctrl+C", MenuCommand::Copy),
|
||||
MenuItem::with_shortcut("Copy Merged", "Shift+Ctrl+C"),
|
||||
MenuItem::with_shortcut("Paste", "Ctrl+V"),
|
||||
MenuItem::new("Clear"),
|
||||
MenuItem::command_with_shortcut("Paste", "Ctrl+V", MenuCommand::Paste),
|
||||
MenuItem::command("Clear", MenuCommand::ClearPixels),
|
||||
MenuItem::separator(),
|
||||
MenuItem::with_shortcut("Fill...", "Shift+F5"),
|
||||
MenuItem::new("Stroke..."),
|
||||
@@ -143,8 +256,16 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
MenuItem::new("Vectorize Bitmap..."),
|
||||
MenuItem::new("Wavelet Decompose..."),
|
||||
MenuItem::separator(),
|
||||
MenuItem::with_shortcut("Canvas Size...", "Alt+Ctrl+C"),
|
||||
MenuItem::with_shortcut("Image Size...", "Alt+Ctrl+I"),
|
||||
MenuItem::command_with_shortcut(
|
||||
"Canvas Size...",
|
||||
"Alt+Ctrl+C",
|
||||
MenuCommand::CanvasSize,
|
||||
),
|
||||
MenuItem::command_with_shortcut(
|
||||
"Image Size...",
|
||||
"Alt+Ctrl+I",
|
||||
MenuCommand::ImageSize,
|
||||
),
|
||||
MenuItem::submenu("Transform"),
|
||||
MenuItem::new("Crop"),
|
||||
MenuItem::with_shortcut("Trim...", "Ctrl+."),
|
||||
@@ -160,13 +281,13 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
MenuDef {
|
||||
label: "Layer".to_string(),
|
||||
items: vec![
|
||||
MenuItem::submenu("New"),
|
||||
MenuItem::command("New", MenuCommand::AddLayer),
|
||||
MenuItem::new("Duplicate Layer"),
|
||||
MenuItem::new("Duplicate Into ..."),
|
||||
MenuItem::new("Delete"),
|
||||
MenuItem::command("Delete", MenuCommand::DeleteLayer),
|
||||
MenuItem::separator(),
|
||||
MenuItem::submenu("Text"),
|
||||
MenuItem::submenu("Layer Style"),
|
||||
MenuItem::command("Layer Style", MenuCommand::LayerStyles),
|
||||
MenuItem::separator(),
|
||||
MenuItem::submenu("New Fill Layer"),
|
||||
MenuItem::submenu("New Adjustment Layer"),
|
||||
@@ -184,8 +305,8 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
MenuItem::submenu("Combine Shapes"),
|
||||
MenuItem::submenu("Animation"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::with_shortcut("Merge Down", "Ctrl+E"),
|
||||
MenuItem::new("Flatten Image"),
|
||||
MenuItem::command_with_shortcut("Merge Down", "Ctrl+E", MenuCommand::MergeDown),
|
||||
MenuItem::command("Flatten Image", MenuCommand::Flatten),
|
||||
MenuItem::new("Defringe"),
|
||||
],
|
||||
},
|
||||
@@ -193,9 +314,13 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
MenuDef {
|
||||
label: "Select".to_string(),
|
||||
items: vec![
|
||||
MenuItem::with_shortcut("All", "Ctrl+A"),
|
||||
MenuItem::with_shortcut("Deselect", "Ctrl+D"),
|
||||
MenuItem::with_shortcut("Inverse", "Shift+Ctrl+I"),
|
||||
MenuItem::command_with_shortcut("All", "Ctrl+A", MenuCommand::SelectAll),
|
||||
MenuItem::command_with_shortcut("Deselect", "Ctrl+D", MenuCommand::Deselect),
|
||||
MenuItem::command_with_shortcut(
|
||||
"Inverse",
|
||||
"Shift+Ctrl+I",
|
||||
MenuCommand::InvertSelection,
|
||||
),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Remove BG"),
|
||||
MenuItem::new("Color Range..."),
|
||||
@@ -204,19 +329,52 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Refine Edge..."),
|
||||
MenuItem::submenu("Modify"),
|
||||
MenuItem::with_shortcut("Grow...", "Alt+Ctrl+G"),
|
||||
MenuItem::with_shortcut("Shrink...", "Alt+Ctrl+S"),
|
||||
MenuItem::with_shortcut("Feather...", "Alt+Ctrl+F"),
|
||||
MenuItem::new("Border..."),
|
||||
MenuItem::new("Smooth..."),
|
||||
MenuItem::command_with_shortcut(
|
||||
"Grow...",
|
||||
"Alt+Ctrl+G",
|
||||
MenuCommand::GrowSelection,
|
||||
),
|
||||
MenuItem::command_with_shortcut(
|
||||
"Shrink...",
|
||||
"Alt+Ctrl+S",
|
||||
MenuCommand::ShrinkSelection,
|
||||
),
|
||||
MenuItem::command_with_shortcut(
|
||||
"Feather...",
|
||||
"Alt+Ctrl+F",
|
||||
MenuCommand::FeatherSelection,
|
||||
),
|
||||
MenuItem::command("Border...", MenuCommand::BorderSelection),
|
||||
MenuItem::command("Smooth...", MenuCommand::SmoothSelection),
|
||||
MenuItem::separator(),
|
||||
MenuItem::command(
|
||||
"New Selection",
|
||||
MenuCommand::SelectionMode(crate::selection::SelectionMode::Replace),
|
||||
),
|
||||
MenuItem::command(
|
||||
"Add to Selection",
|
||||
MenuCommand::SelectionMode(crate::selection::SelectionMode::Add),
|
||||
),
|
||||
MenuItem::command(
|
||||
"Subtract from Selection",
|
||||
MenuCommand::SelectionMode(crate::selection::SelectionMode::Subtract),
|
||||
),
|
||||
MenuItem::command(
|
||||
"Intersect Selection",
|
||||
MenuCommand::SelectionMode(crate::selection::SelectionMode::Intersect),
|
||||
),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Similar"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Transform Selection"),
|
||||
MenuItem::with_shortcut("Quick Mask Mode", "Q"),
|
||||
MenuItem::command_with_shortcut(
|
||||
"Quick Mask Mode",
|
||||
"Q",
|
||||
MenuCommand::ToggleQuickMask,
|
||||
),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Load Selection"),
|
||||
MenuItem::new("Save Selection"),
|
||||
MenuItem::command("Load Selection", MenuCommand::LoadSelection),
|
||||
MenuItem::command("Save Selection", MenuCommand::SaveSelection),
|
||||
],
|
||||
},
|
||||
// ── Filter menu (Photopea-style) ──
|
||||
@@ -248,10 +406,14 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
MenuDef {
|
||||
label: "View".to_string(),
|
||||
items: vec![
|
||||
MenuItem::with_shortcut("Zoom In", "Ctrl++"),
|
||||
MenuItem::with_shortcut("Zoom Out", "Ctrl+-"),
|
||||
MenuItem::with_shortcut("Fit The Area", "Ctrl+0"),
|
||||
MenuItem::with_shortcut("Pixel to Pixel", "Ctrl+1"),
|
||||
MenuItem::command_with_shortcut("Zoom In", "Ctrl++", MenuCommand::ZoomIn),
|
||||
MenuItem::command_with_shortcut("Zoom Out", "Ctrl+-", MenuCommand::ZoomOut),
|
||||
MenuItem::command_with_shortcut("Fit The Area", "Ctrl+0", MenuCommand::FitArea),
|
||||
MenuItem::command_with_shortcut(
|
||||
"Pixel to Pixel",
|
||||
"Ctrl+1",
|
||||
MenuCommand::ActualPixels,
|
||||
),
|
||||
MenuItem::new("Pattern Preview"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::submenu("Mode"),
|
||||
@@ -275,24 +437,51 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
MenuDef {
|
||||
label: "Window".to_string(),
|
||||
items: vec![
|
||||
MenuItem::new("Layers"),
|
||||
MenuItem::new("History"),
|
||||
MenuItem::new("Brushes & Tips"),
|
||||
MenuItem::new("Filters"),
|
||||
MenuItem::new("Color Palette"),
|
||||
MenuItem::new("Properties"),
|
||||
MenuItem::new("AI Assistant"),
|
||||
MenuItem::new("Geometry"),
|
||||
MenuItem::new("Script"),
|
||||
MenuItem::new("Layer Details"),
|
||||
MenuItem::new("AI Script Generator"),
|
||||
MenuItem::command("Layers", MenuCommand::TogglePane(PaneType::Layers)),
|
||||
MenuItem::command("History", MenuCommand::TogglePane(PaneType::History)),
|
||||
MenuItem::command("Brushes & Tips", MenuCommand::TogglePane(PaneType::Brushes)),
|
||||
MenuItem::command("Filters", MenuCommand::TogglePane(PaneType::Filters)),
|
||||
MenuItem::command(
|
||||
"Color Palette",
|
||||
MenuCommand::TogglePane(PaneType::ColorPicker),
|
||||
),
|
||||
MenuItem::command("Properties", MenuCommand::TogglePane(PaneType::Properties)),
|
||||
MenuItem::command("AI Assistant", MenuCommand::TogglePane(PaneType::AiChat)),
|
||||
MenuItem::command("Geometry", MenuCommand::TogglePane(PaneType::Geometry)),
|
||||
MenuItem::command("Script", MenuCommand::TogglePane(PaneType::Script)),
|
||||
MenuItem::command(
|
||||
"Layer Details",
|
||||
MenuCommand::TogglePane(PaneType::LayerDetails),
|
||||
),
|
||||
MenuItem::command(
|
||||
"AI Script Generator",
|
||||
MenuCommand::TogglePane(PaneType::AiScript),
|
||||
),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Theme: Photopea"),
|
||||
MenuItem::new("Theme: Photoshop"),
|
||||
MenuItem::new("Theme: ProDark (Monokai)"),
|
||||
MenuItem::new("Theme: Amoled (Dracula)"),
|
||||
MenuItem::new("Theme: PhotoshopLight (Solarized)"),
|
||||
MenuItem::new("Theme: ProLight (Nord)"),
|
||||
MenuItem::command(
|
||||
"Theme: Photopea",
|
||||
MenuCommand::SetTheme(crate::theme::ThemePreset::Photopea),
|
||||
),
|
||||
MenuItem::command(
|
||||
"Theme: Photoshop",
|
||||
MenuCommand::SetTheme(crate::theme::ThemePreset::Photoshop),
|
||||
),
|
||||
MenuItem::command(
|
||||
"Theme: ProDark (Monokai)",
|
||||
MenuCommand::SetTheme(crate::theme::ThemePreset::ProDark),
|
||||
),
|
||||
MenuItem::command(
|
||||
"Theme: Amoled (Dracula)",
|
||||
MenuCommand::SetTheme(crate::theme::ThemePreset::Amoled),
|
||||
),
|
||||
MenuItem::command(
|
||||
"Theme: PhotoshopLight (Solarized)",
|
||||
MenuCommand::SetTheme(crate::theme::ThemePreset::PhotoshopLight),
|
||||
),
|
||||
MenuItem::command(
|
||||
"Theme: ProLight (Nord)",
|
||||
MenuCommand::SetTheme(crate::theme::ThemePreset::ProLight),
|
||||
),
|
||||
],
|
||||
},
|
||||
// ── More menu (Photopea-style) ──
|
||||
@@ -335,6 +524,7 @@ pub fn dropdown_overlay(
|
||||
recent_files: &[crate::app::RecentFileEntry],
|
||||
colors: ThemeColors,
|
||||
dock: Option<&DockState>,
|
||||
viewport_width: f32,
|
||||
) -> Option<Element<'static, Message>> {
|
||||
let menu_idx = active_menu?;
|
||||
let menus = all_menus(recent_files);
|
||||
@@ -350,75 +540,18 @@ pub fn dropdown_overlay(
|
||||
container(horizontal_rule(1))
|
||||
.width(Length::Fill)
|
||||
.padding([2, 8])
|
||||
.into()
|
||||
.into(),
|
||||
);
|
||||
} else if item.label == "Open Recent" {
|
||||
let lbl = item.label.clone();
|
||||
let disabled_item = container(
|
||||
text(lbl).size(13)
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding([4, 16])
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.menu_bg)),
|
||||
..Default::default()
|
||||
});
|
||||
items.push(disabled_item.into());
|
||||
} else if item_idx >= 12 && item_idx < 12 + recent_files.len().min(10) {
|
||||
let file_idx = item_idx - 12;
|
||||
let lbl = item.label.clone();
|
||||
let c = colors;
|
||||
let btn = iced::widget::button(
|
||||
container(text(lbl).size(13)).width(Length::Fill).padding([4, 16])
|
||||
)
|
||||
.on_press(Message::OpenRecentFile(file_idx))
|
||||
.padding(0)
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.menu_hover)),
|
||||
text_color: c.menu_text,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.menu_bg)),
|
||||
text_color: c.menu_text,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
items.push(btn.into());
|
||||
} else if item.label == "Clear Recent" {
|
||||
let lbl = item.label.clone();
|
||||
let c = colors;
|
||||
let btn = iced::widget::button(
|
||||
container(text(lbl).size(13)).width(Length::Fill).padding([4, 16])
|
||||
)
|
||||
.on_press(Message::ClearRecentFiles)
|
||||
.padding(0)
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.menu_hover)),
|
||||
text_color: c.menu_text,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.menu_bg)),
|
||||
text_color: c.menu_text,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
items.push(btn.into());
|
||||
} else {
|
||||
let lbl = item.label.clone();
|
||||
let sht = item.shortcut.clone();
|
||||
let has_sub = item.has_submenu;
|
||||
let enabled = item.enabled;
|
||||
let label_color = if enabled {
|
||||
colors.menu_text
|
||||
} else {
|
||||
colors.menu_shortcut
|
||||
};
|
||||
|
||||
// Check if this is a Window menu item that should show a checkmark
|
||||
let is_window_panel = menu_idx == 7 && item_idx <= 10;
|
||||
@@ -437,7 +570,9 @@ pub fn dropdown_overlay(
|
||||
10 => Some(PaneType::AiScript),
|
||||
_ => None,
|
||||
};
|
||||
pane_type.and_then(|pt| dock.map(|d| d.is_pane_open(pt))).unwrap_or(false)
|
||||
pane_type
|
||||
.and_then(|pt| dock.map(|d| d.is_pane_open(pt)))
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
@@ -447,38 +582,65 @@ pub fn dropdown_overlay(
|
||||
let label_row: Element<'static, Message> = {
|
||||
let mut row_items = vec![];
|
||||
if is_checked {
|
||||
row_items.push(text("✓ ").size(13).color(iced::Color::from_rgba(0.2, 0.6, 0.2, 1.0)).into());
|
||||
row_items.push(
|
||||
text("✓ ")
|
||||
.size(13)
|
||||
.color(iced::Color::from_rgba(0.2, 0.6, 0.2, 1.0))
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
row_items.push(text(lbl).size(13).width(Length::Fill).into());
|
||||
row_items.push(
|
||||
text(lbl)
|
||||
.size(13)
|
||||
.font(iced::Font {
|
||||
weight: iced::font::Weight::Semibold,
|
||||
..iced::Font::default()
|
||||
})
|
||||
.color(label_color)
|
||||
.width(Length::Fill)
|
||||
.into(),
|
||||
);
|
||||
if !sht.is_empty() {
|
||||
row_items.push(text(sht).size(12).color(iced::Color::from_rgba(0.4, 0.4, 0.4, 1.0)).into());
|
||||
row_items.push(
|
||||
text(sht)
|
||||
.size(12)
|
||||
.font(iced::Font {
|
||||
weight: iced::font::Weight::Medium,
|
||||
..iced::Font::default()
|
||||
})
|
||||
.color(colors.menu_shortcut)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if has_sub {
|
||||
row_items.push(text(">").size(12).color(iced::Color::from_rgba(0.4, 0.4, 0.4, 1.0)).into());
|
||||
row_items.push(text(">").size(12).color(colors.menu_shortcut).into());
|
||||
}
|
||||
row(row_items).spacing(16).into()
|
||||
};
|
||||
|
||||
let enabled = item.enabled;
|
||||
let m_idx = menu_idx;
|
||||
let i_idx = item_idx;
|
||||
let c = colors;
|
||||
|
||||
if enabled {
|
||||
let btn = iced::widget::button(
|
||||
container(label_row).width(Length::Fill).padding([4, 16])
|
||||
)
|
||||
.on_press(Message::MenuAction(m_idx, i_idx))
|
||||
let command = item
|
||||
.command
|
||||
.clone()
|
||||
.expect("enabled menu leaves must have commands");
|
||||
let btn =
|
||||
iced::widget::button(container(label_row).width(Length::Fill).padding([6, 16]))
|
||||
.on_press(Message::MenuCommand(command))
|
||||
.padding(0)
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
// Photopea-style hover: light blue highlight
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
iced::widget::button::Status::Hovered => {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.menu_hover)),
|
||||
text_color: c.menu_text,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.menu_bg)),
|
||||
text_color: c.menu_text,
|
||||
@@ -486,16 +648,17 @@ pub fn dropdown_overlay(
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
items.push(btn.into());
|
||||
} else {
|
||||
// Photopea-style disabled: lighter text
|
||||
let disabled_item = container(label_row)
|
||||
.width(Length::Fill)
|
||||
.padding([4, 16])
|
||||
.padding([6, 16])
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.menu_bg)),
|
||||
text_color: Some(iced::Color::from_rgba(0.6, 0.6, 0.6, 1.0)),
|
||||
text_color: Some(colors.menu_shortcut),
|
||||
..Default::default()
|
||||
});
|
||||
items.push(disabled_item.into());
|
||||
@@ -511,9 +674,7 @@ pub fn dropdown_overlay(
|
||||
.width(260)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.menu_bg)),
|
||||
border: iced::Border::default()
|
||||
.color(colors.border_high)
|
||||
.width(1),
|
||||
border: iced::Border::default().color(colors.border_high).width(1),
|
||||
shadow: iced::Shadow {
|
||||
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.4),
|
||||
offset: iced::Vector::new(2.0, 4.0),
|
||||
@@ -522,25 +683,20 @@ pub fn dropdown_overlay(
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Position dropdown below the clicked menu item.
|
||||
// Compute the x-offset by summing the approximate widths of preceding menu
|
||||
// bar items. Each button = label_chars * 7px (at 13px font) + 16px padding,
|
||||
// plus the 4px initial left padding from the title bar.
|
||||
const MENU_LABELS: &[&str] = &["File", "Edit", "Image", "Layer", "Select", "Filter", "View", "Window", "More"];
|
||||
let mut left_offset: f32 = 4.0;
|
||||
for i in 0..menu_idx.min(MENU_LABELS.len()) {
|
||||
left_offset += MENU_LABELS[i].len() as f32 * 7.0 + 16.0;
|
||||
}
|
||||
let overlay = mouse_area(
|
||||
container(dropdown)
|
||||
.padding(iced::Padding {
|
||||
top: 24.0,
|
||||
let left_offset = crate::panels::title_bar::menu_anchor_x(menu_idx, viewport_width, 260.0);
|
||||
let backdrop = mouse_area(container(text("")).width(Length::Fill).height(Length::Fill))
|
||||
.on_press(Message::MenuClose);
|
||||
let positioned = container(dropdown).padding(iced::Padding {
|
||||
top: crate::panels::title_bar::MENU_BAR_HEIGHT,
|
||||
bottom: 0.0,
|
||||
left: left_offset,
|
||||
right: 0.0,
|
||||
})
|
||||
)
|
||||
.on_press(Message::MenuClose);
|
||||
});
|
||||
let overlay = iced::widget::Stack::new()
|
||||
.push(backdrop)
|
||||
.push(positioned)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
Some(overlay.into())
|
||||
}
|
||||
@@ -554,91 +710,112 @@ pub fn canvas_context_menu<'a>(
|
||||
) -> Element<'a, Message> {
|
||||
let mut items = vec![];
|
||||
|
||||
items.push(MenuItem::with_shortcut("Copy", "Ctrl+C"));
|
||||
items.push(MenuItem::command_with_shortcut(
|
||||
"Copy",
|
||||
"Ctrl+C",
|
||||
MenuCommand::Copy,
|
||||
));
|
||||
if doc.selection_bounds.is_some() {
|
||||
items.push(MenuItem::with_shortcut("Cut", "Ctrl+X"));
|
||||
items.push(MenuItem::command_with_shortcut(
|
||||
"Cut",
|
||||
"Ctrl+X",
|
||||
MenuCommand::Cut,
|
||||
));
|
||||
}
|
||||
items.push(MenuItem::with_shortcut("Paste", "Ctrl+V"));
|
||||
items.push(MenuItem::command_with_shortcut(
|
||||
"Paste",
|
||||
"Ctrl+V",
|
||||
MenuCommand::Paste,
|
||||
));
|
||||
items.push(MenuItem::separator());
|
||||
items.push(MenuItem::new("New Layer"));
|
||||
items.push(MenuItem::command("New Layer", MenuCommand::AddLayer));
|
||||
|
||||
if doc.selection_bounds.is_some() {
|
||||
items.push(MenuItem::with_shortcut("Deselect", "Ctrl+D"));
|
||||
items.push(MenuItem::new("Crop Image"));
|
||||
items.push(MenuItem::command_with_shortcut(
|
||||
"Deselect",
|
||||
"Ctrl+D",
|
||||
MenuCommand::Deselect,
|
||||
));
|
||||
items.push(MenuItem::command("Crop Image", MenuCommand::SelectCropTool));
|
||||
}
|
||||
|
||||
items.push(MenuItem::separator());
|
||||
items.push(MenuItem::new("Gaussian Blur (5px)"));
|
||||
items.push(MenuItem::new("Mosaic / Pixelate (8px)"));
|
||||
items.push(MenuItem::new("Unsharp Mask"));
|
||||
items.push(MenuItem::command(
|
||||
"Gaussian Blur (5px)",
|
||||
MenuCommand::GaussianBlur,
|
||||
));
|
||||
items.push(MenuItem::command(
|
||||
"Mosaic / Pixelate (8px)",
|
||||
MenuCommand::Mosaic,
|
||||
));
|
||||
items.push(MenuItem::command("Unsharp Mask", MenuCommand::UnsharpMask));
|
||||
|
||||
let mut col_items = vec![];
|
||||
for item in items {
|
||||
if item.label == "─" {
|
||||
col_items.push(
|
||||
container(horizontal_rule(1).style(move |_theme| iced::widget::rule::Style {
|
||||
container(
|
||||
horizontal_rule(1).style(move |_theme| iced::widget::rule::Style {
|
||||
color: colors.border_high,
|
||||
width: 1,
|
||||
radius: 0.0.into(),
|
||||
fill_mode: iced::widget::rule::FillMode::Full,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
.padding([4, 0])
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
let label = text(item.label.clone()).size(14).style(move |_theme| iced::widget::text::Style {
|
||||
color: Some(colors.text_primary),
|
||||
let label = text(item.label.clone())
|
||||
.size(13)
|
||||
.font(iced::Font {
|
||||
weight: iced::font::Weight::Semibold,
|
||||
..iced::Font::default()
|
||||
})
|
||||
.style(move |_theme| iced::widget::text::Style {
|
||||
color: Some(colors.menu_text),
|
||||
});
|
||||
let mut label_row = row![label].align_y(iced::Alignment::Center);
|
||||
|
||||
if !item.shortcut.is_empty() {
|
||||
label_row = label_row.push(iced::widget::Space::with_width(Length::Fill));
|
||||
label_row = label_row.push(
|
||||
text(item.shortcut)
|
||||
.size(13)
|
||||
.style(move |_theme| iced::widget::text::Style {
|
||||
color: Some(colors.text_secondary),
|
||||
}),
|
||||
);
|
||||
label_row = label_row.push(text(item.shortcut).size(12).style(move |_theme| {
|
||||
iced::widget::text::Style {
|
||||
color: Some(colors.menu_shortcut),
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let msg = match item.label.as_str() {
|
||||
"Copy" => Message::CopyImage,
|
||||
"Cut" => Message::CopyImage, // TODO: CutImage
|
||||
"Paste" => Message::PasteImage,
|
||||
"New Layer" => Message::LayerAdd,
|
||||
"Deselect" => Message::Deselect,
|
||||
"Crop Image" => Message::ToolSelected(Tool::Crop),
|
||||
"Gaussian Blur (5px)" => Message::FilterSelect(hcie_engine_api::FilterType::GaussianBlur),
|
||||
"Mosaic / Pixelate (8px)" => Message::FilterSelect(hcie_engine_api::FilterType::Mosaic),
|
||||
"Unsharp Mask" => Message::FilterSelect(hcie_engine_api::FilterType::UnsharpMask),
|
||||
_ => Message::CanvasContextMenuClose,
|
||||
};
|
||||
|
||||
let c = colors;
|
||||
let menu_item = iced::widget::button(
|
||||
container(label_row)
|
||||
.width(Length::Fill)
|
||||
.padding([6, 16])
|
||||
)
|
||||
let menu_item =
|
||||
iced::widget::button(container(label_row).width(Length::Fill).padding([7, 16]))
|
||||
.padding(0)
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
iced::widget::button::Status::Hovered => {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.menu_hover)),
|
||||
text_color: c.text_primary,
|
||||
text_color: c.menu_text,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.menu_bg)),
|
||||
text_color: c.text_primary,
|
||||
text_color: c.menu_text,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
})
|
||||
.on_press(msg);
|
||||
},
|
||||
);
|
||||
|
||||
let menu_item = if let Some(command) = item.command {
|
||||
menu_item.on_press(Message::MenuCommand(command))
|
||||
} else {
|
||||
menu_item
|
||||
};
|
||||
|
||||
col_items.push(menu_item.into());
|
||||
}
|
||||
@@ -659,16 +836,148 @@ pub fn canvas_context_menu<'a>(
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let overlay = mouse_area(
|
||||
container(dropdown)
|
||||
.padding(iced::Padding {
|
||||
let overlay = mouse_area(container(dropdown).padding(iced::Padding {
|
||||
top: y,
|
||||
bottom: 0.0,
|
||||
left: x,
|
||||
right: 0.0,
|
||||
})
|
||||
)
|
||||
}))
|
||||
.on_press(Message::CanvasContextMenuClose);
|
||||
|
||||
overlay.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Audits every static and dynamic menu row so enabled leaves cannot drift away
|
||||
/// from semantic dispatch, and non-leaf rows can never become dispatchable.
|
||||
#[test]
|
||||
fn enabled_menu_leaves_have_commands() {
|
||||
let recent = vec![crate::app::RecentFileEntry {
|
||||
path: "/tmp/recent.png".to_string(),
|
||||
name: "recent.png".to_string(),
|
||||
}];
|
||||
|
||||
for menu in all_menus(&recent) {
|
||||
for item in menu.items {
|
||||
assert_eq!(
|
||||
item.enabled,
|
||||
item.command.is_some(),
|
||||
"menu item: {} / {}",
|
||||
menu.label,
|
||||
item.label
|
||||
);
|
||||
assert!(
|
||||
!item.has_submenu || item.command.is_none(),
|
||||
"submenu dispatched: {} / {}",
|
||||
menu.label,
|
||||
item.label
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies recent-file commands own stable paths instead of positional indices.
|
||||
#[test]
|
||||
fn recent_file_commands_are_path_based() {
|
||||
let recent = vec![crate::app::RecentFileEntry {
|
||||
path: "/tmp/stable.png".to_string(),
|
||||
name: "stable.png".to_string(),
|
||||
}];
|
||||
let file = &all_menus(&recent)[0];
|
||||
let item = file
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.label == "stable.png")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
item.command,
|
||||
Some(MenuCommand::OpenRecent("/tmp/stable.png".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
/// Locks the implemented static leaf set so menu-definition edits require an
|
||||
/// explicit audit update rather than silently changing application behavior.
|
||||
#[test]
|
||||
fn implemented_static_menu_leaves_match_audited_set() {
|
||||
let actual: Vec<String> = all_menus(&[])
|
||||
.into_iter()
|
||||
.flat_map(|menu| {
|
||||
menu.items.into_iter().filter_map(move |item| {
|
||||
item.enabled
|
||||
.then(|| format!("{}/{}", menu.label, item.label))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let expected = vec![
|
||||
"File/New...",
|
||||
"File/Open...",
|
||||
"File/Save",
|
||||
"File/Save as PSD",
|
||||
"Edit/Undo / Redo",
|
||||
"Edit/Step Forward",
|
||||
"Edit/Step Backward",
|
||||
"Edit/Cut",
|
||||
"Edit/Copy",
|
||||
"Edit/Paste",
|
||||
"Edit/Clear",
|
||||
"Image/Canvas Size...",
|
||||
"Image/Image Size...",
|
||||
"Layer/New",
|
||||
"Layer/Delete",
|
||||
"Layer/Layer Style",
|
||||
"Layer/Merge Down",
|
||||
"Layer/Flatten Image",
|
||||
"Select/All",
|
||||
"Select/Deselect",
|
||||
"Select/Inverse",
|
||||
"Select/Grow...",
|
||||
"Select/Shrink...",
|
||||
"Select/Feather...",
|
||||
"Select/Border...",
|
||||
"Select/Smooth...",
|
||||
"Select/New Selection",
|
||||
"Select/Add to Selection",
|
||||
"Select/Subtract from Selection",
|
||||
"Select/Intersect Selection",
|
||||
"Select/Quick Mask Mode",
|
||||
"Select/Load Selection",
|
||||
"Select/Save Selection",
|
||||
"View/Zoom In",
|
||||
"View/Zoom Out",
|
||||
"View/Fit The Area",
|
||||
"View/Pixel to Pixel",
|
||||
"Window/Layers",
|
||||
"Window/History",
|
||||
"Window/Brushes & Tips",
|
||||
"Window/Filters",
|
||||
"Window/Color Palette",
|
||||
"Window/Properties",
|
||||
"Window/AI Assistant",
|
||||
"Window/Geometry",
|
||||
"Window/Script",
|
||||
"Window/Layer Details",
|
||||
"Window/AI Script Generator",
|
||||
"Window/Theme: Photopea",
|
||||
"Window/Theme: Photoshop",
|
||||
"Window/Theme: ProDark (Monokai)",
|
||||
"Window/Theme: Amoled (Dracula)",
|
||||
"Window/Theme: PhotoshopLight (Solarized)",
|
||||
"Window/Theme: ProLight (Nord)",
|
||||
];
|
||||
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
|
||||
/// Confirms context-menu Cut uses its distinct semantic command.
|
||||
#[test]
|
||||
fn context_cut_is_enabled_and_distinct_from_copy() {
|
||||
let cut = MenuItem::command_with_shortcut("Cut", "Ctrl+X", MenuCommand::Cut);
|
||||
|
||||
assert!(cut.enabled);
|
||||
assert_eq!(cut.command, Some(MenuCommand::Cut));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,3 +22,4 @@ pub mod title_bar;
|
||||
pub mod tool_options;
|
||||
pub mod tool_settings;
|
||||
pub mod toolbar;
|
||||
pub mod typography;
|
||||
|
||||
@@ -6,10 +6,14 @@
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::color_picker;
|
||||
use crate::color_picker::ColorPickerTarget;
|
||||
use crate::panels::styles;
|
||||
use crate::panels::typography::{BODY, SECTION};
|
||||
use crate::theme::ThemeColors;
|
||||
use hcie_engine_api::{BlendMode, LayerType, Tool, VectorShape};
|
||||
use iced::widget::{button, checkbox, column, container, horizontal_rule, row, slider, text};
|
||||
use iced::widget::{
|
||||
button, checkbox, column, container, horizontal_rule, row, scrollable, slider, text,
|
||||
};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Human-readable label for a shape at a given index.
|
||||
@@ -104,6 +108,8 @@ pub fn view<'a>(
|
||||
vector_points: u32,
|
||||
vector_sides: u32,
|
||||
active_layer_id: u64,
|
||||
foreground_color: [u8; 4],
|
||||
background_color: [u8; 4],
|
||||
) -> Element<'a, Message> {
|
||||
// Build layer info section at the top
|
||||
let layer_section = layer_info_section(
|
||||
@@ -141,8 +147,7 @@ pub fn view<'a>(
|
||||
| Tool::VectorCross
|
||||
| Tool::VectorCrescent
|
||||
| Tool::VectorBolt
|
||||
| Tool::VectorArrow4 => {
|
||||
vector_tool_properties(
|
||||
| Tool::VectorArrow4 => vector_tool_properties(
|
||||
active_tool,
|
||||
vector_stroke,
|
||||
vector_opacity,
|
||||
@@ -152,29 +157,30 @@ pub fn view<'a>(
|
||||
vector_sides,
|
||||
vector_draw,
|
||||
colors,
|
||||
)
|
||||
}
|
||||
foreground_color,
|
||||
background_color,
|
||||
),
|
||||
Tool::Text => text_properties(colors),
|
||||
Tool::Eyedropper => column![
|
||||
text("Eyedropper").size(11),
|
||||
text("Click on canvas to pick color").size(10),
|
||||
text("Eyedropper").size(SECTION),
|
||||
text("Click on canvas to pick color").size(BODY),
|
||||
]
|
||||
.spacing(4)
|
||||
.into(),
|
||||
Tool::FloodFill => column![
|
||||
text("Flood Fill").size(11),
|
||||
text("Click on canvas to fill area").size(10),
|
||||
text("Flood Fill").size(SECTION),
|
||||
text("Click on canvas to fill area").size(BODY),
|
||||
]
|
||||
.spacing(4)
|
||||
.into(),
|
||||
_ => text(format!("{:?}", active_tool)).size(11).into(),
|
||||
_ => text(format!("{:?}", active_tool)).size(SECTION).into(),
|
||||
};
|
||||
|
||||
let panel = column![layer_section, horizontal_rule(1), tool_section,]
|
||||
.spacing(4)
|
||||
.padding(4);
|
||||
|
||||
container(panel)
|
||||
container(scrollable(panel))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| styles::panel_background(colors))
|
||||
@@ -193,25 +199,29 @@ fn layer_info_section<'a>(
|
||||
layer_id: u64,
|
||||
_colors: ThemeColors,
|
||||
) -> Element<'a, Message> {
|
||||
let opacity_slider = slider(0.0..=1.0, opacity, move |v| Message::LayerSetOpacity(layer_id, v))
|
||||
let opacity_slider = slider(0.0..=1.0, opacity, move |v| {
|
||||
Message::LayerSetOpacity(layer_id, v)
|
||||
})
|
||||
.step(0.01)
|
||||
.width(Length::Fill);
|
||||
|
||||
column![
|
||||
text("Layer Info").size(11),
|
||||
text("Layer Info").size(SECTION),
|
||||
row![
|
||||
text("Name").size(10).width(Length::Fixed(50.0)),
|
||||
text(name).size(10),
|
||||
text("Name").size(BODY).width(Length::Fixed(58.0)),
|
||||
text(name).size(BODY),
|
||||
]
|
||||
.spacing(4),
|
||||
row![
|
||||
text("Opacity").size(10).width(Length::Fixed(50.0)),
|
||||
text(format!("{:.0}%", opacity * 100.0)).size(10).width(Length::Fixed(36.0)),
|
||||
text("Opacity").size(BODY).width(Length::Fixed(58.0)),
|
||||
text(format!("{:.0}%", opacity * 100.0))
|
||||
.size(BODY)
|
||||
.width(Length::Fixed(36.0)),
|
||||
]
|
||||
.spacing(4),
|
||||
opacity_slider,
|
||||
row![
|
||||
text("Visible").size(10).width(Length::Fixed(50.0)),
|
||||
text("Visible").size(BODY).width(Length::Fixed(58.0)),
|
||||
checkbox("", visible)
|
||||
.on_toggle(move |v| Message::LayerToggleVisibility(layer_id))
|
||||
.size(11),
|
||||
@@ -219,18 +229,18 @@ fn layer_info_section<'a>(
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center),
|
||||
row![
|
||||
text("Blend").size(10).width(Length::Fixed(50.0)),
|
||||
text(blend_mode_label(blend_mode)).size(10),
|
||||
text("Blend").size(BODY).width(Length::Fixed(58.0)),
|
||||
text(blend_mode_label(blend_mode)).size(BODY),
|
||||
]
|
||||
.spacing(4),
|
||||
row![
|
||||
text("Type").size(10).width(Length::Fixed(50.0)),
|
||||
text(layer_type_label(layer_type)).size(10),
|
||||
text("Type").size(BODY).width(Length::Fixed(58.0)),
|
||||
text(layer_type_label(layer_type)).size(BODY),
|
||||
]
|
||||
.spacing(4),
|
||||
row![
|
||||
text("Size").size(10).width(Length::Fixed(50.0)),
|
||||
text(format!("{}×{}", width, height)).size(10),
|
||||
text("Size").size(BODY).width(Length::Fixed(58.0)),
|
||||
text(format!("{}×{}", width, height)).size(BODY),
|
||||
]
|
||||
.spacing(4),
|
||||
]
|
||||
@@ -274,23 +284,39 @@ fn vector_select_properties<'a>(
|
||||
};
|
||||
|
||||
column![
|
||||
text("Shape Properties").size(11),
|
||||
row![text("Shape").size(10), text(label).size(10)].spacing(4),
|
||||
row![text("Type").size(10), text(shape_type).size(10)].spacing(4),
|
||||
row![text("Stroke").size(10), text(format!("{:.1}", stroke)).size(10)].spacing(4),
|
||||
text("Shape Properties").size(SECTION),
|
||||
row![text("Shape").size(BODY), text(label).size(BODY)].spacing(4),
|
||||
row![text("Type").size(BODY), text(shape_type).size(BODY)].spacing(4),
|
||||
row![
|
||||
text("Stroke").size(BODY),
|
||||
text(format!("{:.1}", stroke)).size(BODY)
|
||||
]
|
||||
.spacing(4),
|
||||
slider(0.0..=50.0, stroke, move |v| Message::VectorStrokeChanged(v))
|
||||
.step(0.5)
|
||||
.width(Length::Fill),
|
||||
row![text("Opacity").size(10), text(format!("{:.0}%", opacity * 100.0)).size(10)].spacing(4),
|
||||
slider(0.0..=1.0, opacity, move |v| Message::VectorOpacityChanged(v))
|
||||
.step(0.01)
|
||||
.width(Length::Fill),
|
||||
row![text("Hardness").size(10), text(format!("{:.0}%", hardness * 100.0)).size(10)].spacing(4),
|
||||
slider(0.0..=1.0, hardness, move |v| Message::GeometryHardnessChanged(v))
|
||||
row![
|
||||
text("Opacity").size(BODY),
|
||||
text(format!("{:.0}%", opacity * 100.0)).size(BODY)
|
||||
]
|
||||
.spacing(4),
|
||||
slider(0.0..=1.0, opacity, move |v| Message::VectorOpacityChanged(
|
||||
v
|
||||
))
|
||||
.step(0.01)
|
||||
.width(Length::Fill),
|
||||
row![
|
||||
text("Fill").size(10),
|
||||
text("Hardness").size(BODY),
|
||||
text(format!("{:.0}%", hardness * 100.0)).size(BODY)
|
||||
]
|
||||
.spacing(4),
|
||||
slider(0.0..=1.0, hardness, move |v| {
|
||||
Message::GeometryHardnessChanged(v)
|
||||
})
|
||||
.step(0.01)
|
||||
.width(Length::Fill),
|
||||
row![
|
||||
text("Fill").size(BODY),
|
||||
checkbox("", filled)
|
||||
.on_toggle(Message::VectorFillToggled)
|
||||
.size(11),
|
||||
@@ -299,27 +325,27 @@ fn vector_select_properties<'a>(
|
||||
.align_y(iced::Alignment::Center),
|
||||
// Fill color picker (only if fill is enabled)
|
||||
if filled {
|
||||
color_picker::color_popup(
|
||||
color_picker::color_swatch(
|
||||
fill_color,
|
||||
"Fill Color",
|
||||
Message::GeometryFillColorChanged,
|
||||
ColorPickerTarget::GeometryFill,
|
||||
)
|
||||
} else {
|
||||
text("").into()
|
||||
},
|
||||
// Stroke color picker
|
||||
color_picker::color_popup(
|
||||
color_picker::color_swatch(
|
||||
stroke_color,
|
||||
"Stroke Color",
|
||||
Message::GeometryStrokeColorChanged,
|
||||
ColorPickerTarget::GeometryStroke,
|
||||
),
|
||||
]
|
||||
.spacing(4)
|
||||
.into()
|
||||
}
|
||||
None => column![
|
||||
text("Vector Select").size(11),
|
||||
text("Click a shape on canvas").size(10),
|
||||
text("Vector Select").size(SECTION),
|
||||
text("Click a shape on canvas").size(BODY),
|
||||
]
|
||||
.spacing(4)
|
||||
.into(),
|
||||
@@ -337,19 +363,24 @@ fn vector_tool_properties<'a>(
|
||||
sides: u32,
|
||||
vector_draw: Option<((f32, f32), (f32, f32))>,
|
||||
_colors: ThemeColors,
|
||||
foreground_color: [u8; 4],
|
||||
background_color: [u8; 4],
|
||||
) -> Element<'a, Message> {
|
||||
// Get current fg/bg colors from app state (we need to pass them through)
|
||||
// For now, use default colors - the actual colors will come from the app state
|
||||
let default_color = [0, 0, 0, 255];
|
||||
let default_fill_color = [255, 255, 255, 255];
|
||||
|
||||
let mut col = column![
|
||||
text("Vector Shape").size(11),
|
||||
row![text("Stroke").size(10), text(format!("{:.1}", stroke)).size(10)].spacing(4),
|
||||
row![
|
||||
text("Stroke").size(10),
|
||||
text(format!("{:.1}", stroke)).size(10)
|
||||
]
|
||||
.spacing(4),
|
||||
slider(0.0..=50.0, stroke, |v| Message::VectorStrokeChanged(v))
|
||||
.step(0.5)
|
||||
.width(Length::Fill),
|
||||
row![text("Opacity").size(10), text(format!("{:.0}%", opacity * 100.0)).size(10)].spacing(4),
|
||||
row![
|
||||
text("Opacity").size(10),
|
||||
text(format!("{:.0}%", opacity * 100.0)).size(10)
|
||||
]
|
||||
.spacing(4),
|
||||
slider(0.0..=1.0, opacity, |v| Message::VectorOpacityChanged(v))
|
||||
.step(0.01)
|
||||
.width(Length::Fill),
|
||||
@@ -363,19 +394,19 @@ fn vector_tool_properties<'a>(
|
||||
.align_y(iced::Alignment::Center),
|
||||
// Fill color picker (only if fill is enabled)
|
||||
if fill {
|
||||
color_picker::color_popup(
|
||||
default_fill_color,
|
||||
color_picker::color_swatch(
|
||||
background_color,
|
||||
"Fill Color",
|
||||
Message::GeometryFillColorChanged,
|
||||
ColorPickerTarget::Background,
|
||||
)
|
||||
} else {
|
||||
text("").into()
|
||||
},
|
||||
// Stroke color picker
|
||||
color_picker::color_popup(
|
||||
default_color,
|
||||
color_picker::color_swatch(
|
||||
foreground_color,
|
||||
"Stroke Color",
|
||||
Message::GeometryStrokeColorChanged,
|
||||
ColorPickerTarget::Foreground,
|
||||
),
|
||||
]
|
||||
.spacing(4);
|
||||
@@ -384,7 +415,11 @@ fn vector_tool_properties<'a>(
|
||||
match tool {
|
||||
Tool::VectorRect => {
|
||||
col = col.push(
|
||||
row![text("Radius").size(10), text(format!("{:.1}", radius)).size(10)].spacing(4),
|
||||
row![
|
||||
text("Radius").size(10),
|
||||
text(format!("{:.1}", radius)).size(10)
|
||||
]
|
||||
.spacing(4),
|
||||
);
|
||||
col = col.push(
|
||||
slider(0.0..=100.0, radius, |v| Message::VectorRadiusChanged(v))
|
||||
@@ -394,20 +429,27 @@ fn vector_tool_properties<'a>(
|
||||
}
|
||||
Tool::VectorStar => {
|
||||
col = col.push(
|
||||
row![text("Points").size(10), text(format!("{}", points)).size(10)].spacing(4),
|
||||
row![
|
||||
text("Points").size(10),
|
||||
text(format!("{}", points)).size(10)
|
||||
]
|
||||
.spacing(4),
|
||||
);
|
||||
col = col.push(
|
||||
slider(3.0..=20.0, points as f32, |v| Message::VectorPointsChanged(v as u32))
|
||||
slider(3.0..=20.0, points as f32, |v| {
|
||||
Message::VectorPointsChanged(v as u32)
|
||||
})
|
||||
.step(1.0)
|
||||
.width(Length::Fill),
|
||||
);
|
||||
}
|
||||
Tool::VectorPolygon => {
|
||||
col = col
|
||||
.push(row![text("Sides").size(10), text(format!("{}", sides)).size(10)].spacing(4));
|
||||
col = col.push(
|
||||
row![text("Sides").size(10), text(format!("{}", sides)).size(10)].spacing(4),
|
||||
);
|
||||
col = col.push(
|
||||
slider(3.0..=12.0, sides as f32, |v| Message::VectorSidesChanged(v as u32))
|
||||
slider(3.0..=12.0, sides as f32, |v| {
|
||||
Message::VectorSidesChanged(v as u32)
|
||||
})
|
||||
.step(1.0)
|
||||
.width(Length::Fill),
|
||||
);
|
||||
@@ -420,16 +462,20 @@ fn vector_tool_properties<'a>(
|
||||
let w = (x1 - x0).abs() as u32;
|
||||
let h = (y1 - y0).abs() as u32;
|
||||
col = col.push(horizontal_rule(1));
|
||||
col = col.push(
|
||||
row![text("Size").size(10), text(format!("{}×{}", w, h)).size(10)].spacing(4),
|
||||
);
|
||||
col =
|
||||
col.push(row![text("Size").size(10), text(format!("{}×{}", w, h)).size(10)].spacing(4));
|
||||
}
|
||||
|
||||
col.into()
|
||||
}
|
||||
|
||||
/// Brush tool properties (Pen, Brush, Eraser, Spray).
|
||||
fn brush_properties<'a>(size: f32, opacity: f32, hardness: f32, _colors: ThemeColors) -> Element<'a, Message> {
|
||||
fn brush_properties<'a>(
|
||||
size: f32,
|
||||
opacity: f32,
|
||||
hardness: f32,
|
||||
_colors: ThemeColors,
|
||||
) -> Element<'a, Message> {
|
||||
let size_slider = slider(1.0..=200.0, size, |v| Message::BrushSizeChanged(v))
|
||||
.step(1.0)
|
||||
.width(Length::Fill);
|
||||
@@ -444,9 +490,17 @@ fn brush_properties<'a>(size: f32, opacity: f32, hardness: f32, _colors: ThemeCo
|
||||
text("Brush Properties").size(11),
|
||||
row![text("Size").size(10), text(format!("{:.0}", size)).size(10)].spacing(4),
|
||||
size_slider,
|
||||
row![text("Opacity").size(10), text(format!("{:.0}%", opacity * 100.0)).size(10)].spacing(4),
|
||||
row![
|
||||
text("Opacity").size(10),
|
||||
text(format!("{:.0}%", opacity * 100.0)).size(10)
|
||||
]
|
||||
.spacing(4),
|
||||
opacity_slider,
|
||||
row![text("Hardness").size(10), text(format!("{:.0}%", hardness * 100.0)).size(10)].spacing(4),
|
||||
row![
|
||||
text("Hardness").size(10),
|
||||
text(format!("{:.0}%", hardness * 100.0)).size(10)
|
||||
]
|
||||
.spacing(4),
|
||||
hardness_slider,
|
||||
]
|
||||
.spacing(4)
|
||||
@@ -454,7 +508,10 @@ fn brush_properties<'a>(size: f32, opacity: f32, hardness: f32, _colors: ThemeCo
|
||||
}
|
||||
|
||||
/// Selection tool properties.
|
||||
fn selection_properties<'a>(rect: Option<(f32, f32, f32, f32)>, _colors: ThemeColors) -> Element<'a, Message> {
|
||||
fn selection_properties<'a>(
|
||||
rect: Option<(f32, f32, f32, f32)>,
|
||||
_colors: ThemeColors,
|
||||
) -> Element<'a, Message> {
|
||||
match rect {
|
||||
Some((x0, y0, x1, y1)) => {
|
||||
let w = (x1 - x0).abs() as u32;
|
||||
@@ -466,7 +523,9 @@ fn selection_properties<'a>(rect: Option<(f32, f32, f32, f32)>, _colors: ThemeCo
|
||||
.spacing(4)
|
||||
.into()
|
||||
}
|
||||
None => column![text("Selection").size(11), text("Drag on canvas").size(10)].spacing(4).into(),
|
||||
None => column![text("Selection").size(11), text("Drag on canvas").size(10)]
|
||||
.spacing(4)
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,10 +536,17 @@ fn text_properties<'a>(_colors: ThemeColors) -> Element<'a, Message> {
|
||||
text("Click on canvas to add text").size(10),
|
||||
horizontal_rule(1),
|
||||
row![
|
||||
button(text("Left").size(10)).on_press(Message::TextAlignChanged("Left".to_string())).padding([4, 8]),
|
||||
button(text("Center").size(10)).on_press(Message::TextAlignChanged("Center".to_string())).padding([4, 8]),
|
||||
button(text("Right").size(10)).on_press(Message::TextAlignChanged("Right".to_string())).padding([4, 8]),
|
||||
].spacing(4),
|
||||
button(text("Left").size(10))
|
||||
.on_press(Message::TextAlignChanged("Left".to_string()))
|
||||
.padding([4, 8]),
|
||||
button(text("Center").size(10))
|
||||
.on_press(Message::TextAlignChanged("Center".to_string()))
|
||||
.padding([4, 8]),
|
||||
button(text("Right").size(10))
|
||||
.on_press(Message::TextAlignChanged("Right".to_string()))
|
||||
.padding([4, 8]),
|
||||
]
|
||||
.spacing(4),
|
||||
]
|
||||
.spacing(4)
|
||||
.into()
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
use crate::app::Message;
|
||||
use crate::panels::styles;
|
||||
use crate::theme::ThemeColors;
|
||||
use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, text_editor};
|
||||
use iced::widget::{
|
||||
button, column, container, horizontal_rule, row, scrollable, text, text_editor,
|
||||
};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Build the full script editor panel.
|
||||
@@ -43,7 +45,12 @@ pub fn view<'a>(
|
||||
.fold(column![].spacing(0), |col, t| col.push(t));
|
||||
|
||||
let gutter = container(scrollable(gutter_lines).height(Length::Fill))
|
||||
.padding(iced::Padding { top: 8.0, right: 4.0, bottom: 8.0, left: 8.0 })
|
||||
.padding(iced::Padding {
|
||||
top: 8.0,
|
||||
right: 4.0,
|
||||
bottom: 8.0,
|
||||
left: 8.0,
|
||||
})
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_app)),
|
||||
border: iced::Border::default().rounded(4),
|
||||
@@ -69,9 +76,13 @@ pub fn view<'a>(
|
||||
.padding(iced::Padding::from([6.0, 12.0]));
|
||||
|
||||
let status_text = if script_is_running {
|
||||
text("⟳ Executing...").size(11).color(iced::Color::from_rgb(0.9, 0.7, 0.2))
|
||||
text("⟳ Executing...")
|
||||
.size(11)
|
||||
.color(iced::Color::from_rgb(0.9, 0.7, 0.2))
|
||||
} else {
|
||||
text("Ready").size(11).color(iced::Color::from_rgb(0.4, 0.7, 0.4))
|
||||
text("Ready")
|
||||
.size(11)
|
||||
.color(iced::Color::from_rgb(0.4, 0.7, 0.4))
|
||||
};
|
||||
|
||||
let buttons = row![run_btn, clear_btn, status_text].spacing(6);
|
||||
@@ -148,7 +159,7 @@ pub fn view<'a>(
|
||||
// ── Command Reference ───────────────────────────────────
|
||||
let help_text = [
|
||||
("Layer:", "layer Name — create raster layer"),
|
||||
("", "vector_layer Name — create vector layer"),
|
||||
("", "vector_layer — unavailable (public engine API)"),
|
||||
("", "select_layer Name — switch active layer"),
|
||||
("Brush & Color:", "brush round — set brush style"),
|
||||
("", "color #FF0000 — set foreground color"),
|
||||
@@ -162,7 +173,10 @@ pub fn view<'a>(
|
||||
("", "circle cx,cy — single dab"),
|
||||
("", "triangle x1,y1 x2,y2 x3,y3 — vector triangle"),
|
||||
("", "bezier x1,y1 cx1,cy1 cx2,cy2 x2,y2 [steps]"),
|
||||
("Organic:", "scatter N x,y w,h [size] — golden-angle scatter"),
|
||||
(
|
||||
"Organic:",
|
||||
"scatter N x,y w,h [size] — golden-angle scatter",
|
||||
),
|
||||
("", "gradient x1,y1 x2,y2 #hex1 #hex2 ... N"),
|
||||
("", "wiggle N — per-point jitter"),
|
||||
("", "pressure / pressure_start / pressure_end"),
|
||||
@@ -172,7 +186,9 @@ pub fn view<'a>(
|
||||
];
|
||||
|
||||
let mut reference = column![
|
||||
text("Command Reference").size(11).font(iced::Font::MONOSPACE),
|
||||
text("Command Reference")
|
||||
.size(11)
|
||||
.font(iced::Font::MONOSPACE),
|
||||
horizontal_rule(1),
|
||||
]
|
||||
.spacing(2);
|
||||
@@ -193,16 +209,20 @@ pub fn view<'a>(
|
||||
);
|
||||
}
|
||||
|
||||
let reference_container = container(scrollable(reference).height(Length::Fill))
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
let reference_container =
|
||||
container(scrollable(reference).height(Length::Fill)).style(move |_theme| {
|
||||
iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_app)),
|
||||
border: iced::Border::default().rounded(4),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
|
||||
// ── Assemble the panel ──────────────────────────────────
|
||||
let panel = column![
|
||||
text("HCIE Script — Organic DSL").size(13).font(iced::Font::MONOSPACE),
|
||||
text("HCIE Script — Organic DSL")
|
||||
.size(13)
|
||||
.font(iced::Font::MONOSPACE),
|
||||
horizontal_rule(1),
|
||||
editor_row,
|
||||
buttons,
|
||||
|
||||
@@ -42,9 +42,9 @@ pub fn active_item_bg(colors: ThemeColors) -> iced::widget::container::Style {
|
||||
#[allow(dead_code, unused_variables)]
|
||||
pub fn inactive_item_bg(_colors: ThemeColors) -> iced::widget::container::Style {
|
||||
iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(
|
||||
iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0)
|
||||
)),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.0, 0.0, 0.0, 0.0,
|
||||
))),
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
}
|
||||
|
||||
@@ -20,9 +20,15 @@ pub fn view(
|
||||
let g = font_color[1];
|
||||
let b = font_color[2];
|
||||
|
||||
let color_r = slider(0..=255, r, move |v| Message::TextColorChanged([v, g, b, 255]));
|
||||
let color_g = slider(0..=255, g, move |v| Message::TextColorChanged([r, v, b, 255]));
|
||||
let color_b = slider(0..=255, b, move |v| Message::TextColorChanged([r, g, v, 255]));
|
||||
let color_r = slider(0..=255, r, move |v| {
|
||||
Message::TextColorChanged([v, g, b, 255])
|
||||
});
|
||||
let color_g = slider(0..=255, g, move |v| {
|
||||
Message::TextColorChanged([r, v, b, 255])
|
||||
});
|
||||
let color_b = slider(0..=255, b, move |v| {
|
||||
Message::TextColorChanged([r, g, v, 255])
|
||||
});
|
||||
|
||||
let align_left = button(text("Left").size(10))
|
||||
.on_press(Message::TextAlignChanged("Left".to_string()))
|
||||
@@ -64,8 +70,12 @@ pub fn view(
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgb(0.18, 0.18, 0.18))),
|
||||
border: iced::Border::default().color(iced::Color::from_rgb(0.3, 0.3, 0.3)).width(1),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgb(
|
||||
0.18, 0.18, 0.18,
|
||||
))),
|
||||
border: iced::Border::default()
|
||||
.color(iced::Color::from_rgb(0.3, 0.3, 0.3))
|
||||
.width(1),
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
|
||||
@@ -34,7 +34,11 @@ pub fn generate_thumbnail(pixels: &[u8], width: u32, height: u32, max_dim: u32)
|
||||
let a = pixels[si + 3] as f32 / 255.0;
|
||||
|
||||
// Checkerboard: light (#f0f0f0) and dark (#c0c0c0)
|
||||
let cb = if (tx / 4 + ty / 4) % 2 == 0 { 240.0 } else { 192.0 };
|
||||
let cb = if (tx / 4 + ty / 4) % 2 == 0 {
|
||||
240.0
|
||||
} else {
|
||||
192.0
|
||||
};
|
||||
let bg = cb / 255.0;
|
||||
|
||||
out[di] = ((r * a + bg * (1.0 - a)) * 255.0).round() as u8;
|
||||
@@ -53,7 +57,11 @@ fn checkerboard(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut out = vec![0u8; (w * h * 4) as usize];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let cb = if (x / 4 + y / 4) % 2 == 0 { 240u8 } else { 192u8 };
|
||||
let cb = if (x / 4 + y / 4) % 2 == 0 {
|
||||
240u8
|
||||
} else {
|
||||
192u8
|
||||
};
|
||||
let i = ((y * w + x) * 4) as usize;
|
||||
out[i] = cb;
|
||||
out[i + 1] = cb;
|
||||
|
||||
@@ -14,17 +14,44 @@ use iced::widget::{button, container, mouse_area, row, text};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Photopea-style menu labels matching the reference screenshots.
|
||||
const MENU_LABELS: &[&str] = &[
|
||||
"File", "Edit", "Image", "Layer", "Select",
|
||||
"Filter", "View", "Window", "More",
|
||||
pub(crate) const MENU_LABELS: &[&str] = &[
|
||||
"File", "Edit", "Image", "Layer", "Select", "Filter", "View", "Window", "More",
|
||||
];
|
||||
|
||||
/// Horizontal title-menu metrics shared by button layout and dropdown placement.
|
||||
///
|
||||
/// **Purpose:** Keeps menu buttons and dropdown anchors in one deterministic layout model.
|
||||
/// **Logic & Workflow:** Widths include the measured 13px semibold label advance plus the
|
||||
/// title button's 16px horizontal padding. [`menu_anchor_x`] sums these exact rendered widths.
|
||||
/// **Side Effects / Dependencies:** Values depend on the title bar font and padding below.
|
||||
pub(crate) const MENU_BUTTON_WIDTHS: &[f32] =
|
||||
&[42.0, 42.0, 52.0, 56.0, 60.0, 51.0, 47.0, 67.0, 50.0];
|
||||
pub(crate) const MENU_LEFT_INSET: f32 = 4.0;
|
||||
/// Unified menu/title bar height shared with dropdown placement.
|
||||
pub(crate) const MENU_BAR_HEIGHT: f32 = 28.0;
|
||||
|
||||
/// Returns the clamped left edge for a menu dropdown.
|
||||
///
|
||||
/// **Arguments:** `menu_index` selects a title button, `viewport_width` is the available width,
|
||||
/// and `dropdown_width` is the popup width. **Returns:** A viewport-safe X coordinate.
|
||||
/// **Logic & Workflow:** Sums the same explicit widths assigned to preceding buttons, then clamps
|
||||
/// the result so the dropdown remains visible on narrow windows. **Side Effects:** None.
|
||||
pub(crate) fn menu_anchor_x(menu_index: usize, viewport_width: f32, dropdown_width: f32) -> f32 {
|
||||
let natural = MENU_LEFT_INSET
|
||||
+ MENU_BUTTON_WIDTHS
|
||||
.iter()
|
||||
.take(menu_index.min(MENU_BUTTON_WIDTHS.len()))
|
||||
.sum::<f32>();
|
||||
natural.min((viewport_width - dropdown_width).max(0.0))
|
||||
}
|
||||
|
||||
/// Build the Photopea-style unified title/menu bar element.
|
||||
///
|
||||
/// Layout: [File Edit Image ... More] [doc info] [─ □ ×]
|
||||
/// The left portion (before window controls) is draggable.
|
||||
pub fn view<'a>(
|
||||
doc_name: &'a str,
|
||||
modified: bool,
|
||||
zoom: f32,
|
||||
canvas_w: u32,
|
||||
canvas_h: u32,
|
||||
@@ -36,10 +63,15 @@ pub fn view<'a>(
|
||||
for (idx, &label) in MENU_LABELS.iter().enumerate() {
|
||||
let is_open = active_menu == Some(idx);
|
||||
let c = colors;
|
||||
let btn = button(text(label).size(13).font(iced::Font { weight: iced::font::Weight::Semibold, ..iced::Font::default() }))
|
||||
let btn = button(text(label).size(13).font(iced::Font {
|
||||
weight: iced::font::Weight::Semibold,
|
||||
..iced::Font::default()
|
||||
}))
|
||||
.on_press(Message::MenuOpen(idx))
|
||||
.padding([3, 8])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
.width(MENU_BUTTON_WIDTHS[idx])
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
if is_open {
|
||||
// Active menu: light background with dark text (Photopea style)
|
||||
iced::widget::button::Style {
|
||||
@@ -57,19 +89,26 @@ pub fn view<'a>(
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.0, 0.0, 0.0, 0.0,
|
||||
))),
|
||||
text_color: c.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
menu_items = menu_items.push(btn);
|
||||
}
|
||||
|
||||
// ── Document info — Photopea style: "New Project.psd *" ──
|
||||
let doc_info = text(format!("{} *", doc_name))
|
||||
let doc_info = text(if modified {
|
||||
format!("{} *", doc_name)
|
||||
} else {
|
||||
doc_name.to_string()
|
||||
})
|
||||
.size(11)
|
||||
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(colors.text_secondary),
|
||||
@@ -81,8 +120,8 @@ pub fn view<'a>(
|
||||
let minimize_btn = button(text("─").size(12))
|
||||
.on_press(Message::WindowMinimize)
|
||||
.padding([2, 8])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.bg_hover)),
|
||||
text_color: c.text_primary,
|
||||
@@ -90,19 +129,21 @@ pub fn view<'a>(
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.0, 0.0, 0.0, 0.0,
|
||||
))),
|
||||
text_color: c.text_secondary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
let maximize_btn = button(text("□").size(12))
|
||||
.on_press(Message::WindowMaximize)
|
||||
.padding([2, 8])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.bg_hover)),
|
||||
text_color: c.text_primary,
|
||||
@@ -110,19 +151,21 @@ pub fn view<'a>(
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.0, 0.0, 0.0, 0.0,
|
||||
))),
|
||||
text_color: c.text_secondary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
let close_btn = button(text("×").size(13))
|
||||
.on_press(Message::WindowClose)
|
||||
.padding([2, 10])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.danger)),
|
||||
text_color: iced::Color::WHITE,
|
||||
@@ -130,37 +173,41 @@ pub fn view<'a>(
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.0, 0.0, 0.0, 0.0,
|
||||
))),
|
||||
text_color: c.text_secondary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
let window_controls = row![minimize_btn, maximize_btn, close_btn].spacing(0);
|
||||
|
||||
// ── Left side (draggable): menus + doc info ──
|
||||
let left_side = row![
|
||||
menu_items,
|
||||
iced::widget::Space::with_width(Length::Fill),
|
||||
doc_info,
|
||||
]
|
||||
.spacing(0)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
// Only the empty title region is draggable. Interactive menus are siblings,
|
||||
// preventing their presses from also initiating a native window drag.
|
||||
let draggable_left = mouse_area(
|
||||
container(left_side)
|
||||
container(row![
|
||||
iced::widget::Space::with_width(Length::Fill),
|
||||
doc_info
|
||||
])
|
||||
.width(Length::Fill)
|
||||
.height(24)
|
||||
.height(MENU_BAR_HEIGHT)
|
||||
.align_y(iced::Alignment::Center)
|
||||
.padding([0, 4])
|
||||
.padding([0, 4]),
|
||||
)
|
||||
.on_press(Message::WindowDrag)
|
||||
.interaction(iced::mouse::Interaction::Grab);
|
||||
|
||||
// ── Full bar ──
|
||||
let bar = row![
|
||||
container(menu_items).padding(iced::Padding {
|
||||
left: MENU_LEFT_INSET,
|
||||
right: 0.0,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
}),
|
||||
draggable_left,
|
||||
window_controls,
|
||||
]
|
||||
@@ -168,7 +215,7 @@ pub fn view<'a>(
|
||||
|
||||
container(bar)
|
||||
.width(Length::Fill)
|
||||
.height(24)
|
||||
.height(MENU_BAR_HEIGHT)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_panel)),
|
||||
border: iced::Border::default().color(colors.border_low).width(1),
|
||||
@@ -176,3 +223,18 @@ pub fn view<'a>(
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::menu_anchor_x;
|
||||
|
||||
/// Verifies natural anchors and narrow-viewport clamping use rendered button metrics.
|
||||
#[test]
|
||||
fn menu_anchors_are_deterministic_across_viewport_widths() {
|
||||
assert_eq!(menu_anchor_x(0, 1280.0, 260.0), 4.0);
|
||||
assert_eq!(menu_anchor_x(3, 1280.0, 260.0), 140.0);
|
||||
assert_eq!(menu_anchor_x(8, 1280.0, 260.0), 421.0);
|
||||
assert_eq!(menu_anchor_x(8, 500.0, 260.0), 240.0);
|
||||
assert_eq!(menu_anchor_x(8, 180.0, 260.0), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ use iced::{Element, Length};
|
||||
pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, Message> {
|
||||
let tool_name = text(tool_name_label(tool_state.active_tool))
|
||||
.size(11)
|
||||
|
||||
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(colors.text_primary),
|
||||
});
|
||||
@@ -58,7 +57,10 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
|
||||
.step(0.01)
|
||||
.width(Length::Fixed(100.0));
|
||||
|
||||
let hardness_label = text(format!("Hardness: {:.0}%", tool_state.brush_hardness * 100.0))
|
||||
let hardness_label = text(format!(
|
||||
"Hardness: {:.0}%",
|
||||
tool_state.brush_hardness * 100.0
|
||||
))
|
||||
.size(10)
|
||||
.width(Length::Fixed(75.0));
|
||||
let hardness_slider = slider(
|
||||
@@ -70,11 +72,14 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
|
||||
.width(Length::Fixed(80.0));
|
||||
|
||||
row![
|
||||
size_label, size_slider,
|
||||
size_label,
|
||||
size_slider,
|
||||
text(" ").size(10),
|
||||
opacity_label, opacity_slider,
|
||||
opacity_label,
|
||||
opacity_slider,
|
||||
text(" ").size(10),
|
||||
hardness_label, hardness_slider,
|
||||
hardness_label,
|
||||
hardness_slider,
|
||||
]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center)
|
||||
@@ -82,15 +87,12 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
|
||||
|
||||
// ── Magic Wand ──
|
||||
Tool::MagicWand => {
|
||||
let tolerance_label = text("Tolerance:")
|
||||
.size(10)
|
||||
.width(Length::Fixed(55.0));
|
||||
let tolerance_input = text_input("16", "16")
|
||||
.width(Length::Fixed(40.0))
|
||||
.size(10);
|
||||
let tolerance_label = text("Tolerance:").size(10).width(Length::Fixed(55.0));
|
||||
let tolerance_input = text_input("16", "16").width(Length::Fixed(40.0)).size(10);
|
||||
|
||||
row![
|
||||
tolerance_label, tolerance_input,
|
||||
tolerance_label,
|
||||
tolerance_input,
|
||||
text(" ").size(10),
|
||||
checkbox_label("Anti-alias", true),
|
||||
text(" ").size(10),
|
||||
@@ -102,15 +104,12 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
|
||||
|
||||
// ── Select tools ──
|
||||
Tool::Select | Tool::Lasso => {
|
||||
let feather_label = text("Feather:")
|
||||
.size(10)
|
||||
.width(Length::Fixed(50.0));
|
||||
let feather_input = text_input("0 px", "0")
|
||||
.width(Length::Fixed(50.0))
|
||||
.size(10);
|
||||
let feather_label = text("Feather:").size(10).width(Length::Fixed(50.0));
|
||||
let feather_input = text_input("0 px", "0").width(Length::Fixed(50.0)).size(10);
|
||||
|
||||
row![
|
||||
feather_label, feather_input,
|
||||
feather_label,
|
||||
feather_input,
|
||||
text(" ").size(10),
|
||||
small_button("Refine Edge", colors),
|
||||
text(" ").size(10),
|
||||
@@ -121,30 +120,25 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
|
||||
}
|
||||
|
||||
// ── Move tool ──
|
||||
Tool::Move => {
|
||||
row![
|
||||
Tool::Move => row![
|
||||
checkbox_label("Auto-Select", true),
|
||||
text(" ").size(10),
|
||||
text("Layer").size(10),
|
||||
]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center)
|
||||
}
|
||||
.align_y(iced::Alignment::Center),
|
||||
|
||||
// ── Eyedropper ──
|
||||
Tool::Eyedropper => {
|
||||
row![
|
||||
Tool::Eyedropper => row![
|
||||
text("Sample Size: Point Sample").size(10),
|
||||
text(" ").size(10),
|
||||
checkbox_label("Sample All Layers", false),
|
||||
]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center)
|
||||
}
|
||||
.align_y(iced::Alignment::Center),
|
||||
|
||||
// ── Gradient / FloodFill ──
|
||||
Tool::Gradient | Tool::FloodFill => {
|
||||
row![
|
||||
Tool::Gradient | Tool::FloodFill => row![
|
||||
text("Mode:").size(10),
|
||||
text("Normal").size(10),
|
||||
text(" ").size(10),
|
||||
@@ -152,12 +146,10 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
|
||||
text("100%").size(10),
|
||||
]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center)
|
||||
}
|
||||
.align_y(iced::Alignment::Center),
|
||||
|
||||
// ── Text ──
|
||||
Tool::Text => {
|
||||
row![
|
||||
Tool::Text => row![
|
||||
text("Font:").size(10),
|
||||
text("Arial").size(10),
|
||||
text(" ").size(10),
|
||||
@@ -165,24 +157,20 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
|
||||
text("24px").size(10),
|
||||
]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center)
|
||||
}
|
||||
.align_y(iced::Alignment::Center),
|
||||
|
||||
// ── Crop ──
|
||||
Tool::Crop => {
|
||||
row![
|
||||
Tool::Crop => row![
|
||||
text("Ratio:").size(10),
|
||||
text("Original").size(10),
|
||||
text(" ").size(10),
|
||||
small_button("Straighten", colors),
|
||||
]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center)
|
||||
}
|
||||
.align_y(iced::Alignment::Center),
|
||||
|
||||
// ── Vector tools ──
|
||||
Tool::VectorLine | Tool::VectorRect | Tool::VectorCircle => {
|
||||
row![
|
||||
Tool::VectorLine | Tool::VectorRect | Tool::VectorCircle => row![
|
||||
text("Fill:").size(10),
|
||||
text("None").size(10),
|
||||
text(" ").size(10),
|
||||
@@ -190,13 +178,10 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
|
||||
text("1px").size(10),
|
||||
]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center)
|
||||
}
|
||||
.align_y(iced::Alignment::Center),
|
||||
|
||||
// ── Default (no specific options) ──
|
||||
_ => {
|
||||
row![].spacing(0)
|
||||
}
|
||||
_ => row![].spacing(0),
|
||||
};
|
||||
|
||||
let bar = row![
|
||||
@@ -244,10 +229,7 @@ fn tool_name_label(tool: Tool) -> &'static str {
|
||||
fn checkbox_label(label: &str, checked: bool) -> Element<'static, Message> {
|
||||
let mark = if checked { "☑" } else { "☐" };
|
||||
let label_owned = label.to_string();
|
||||
row![
|
||||
text(mark).size(10),
|
||||
text(label_owned).size(10),
|
||||
]
|
||||
row![text(mark).size(10), text(label_owned).size(10),]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center)
|
||||
.into()
|
||||
@@ -265,8 +247,8 @@ fn small_button(label: &str, colors: ThemeColors) -> Element<'static, Message> {
|
||||
button(text(label_owned).size(10))
|
||||
.on_press(Message::NoOp)
|
||||
.padding([2, 6])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(bg_hover)),
|
||||
text_color: text_primary,
|
||||
@@ -279,7 +261,7 @@ fn small_button(label: &str, colors: ThemeColors) -> Element<'static, Message> {
|
||||
border: iced::Border::default().color(border_low).width(1),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ use crate::panels::styles;
|
||||
use crate::settings::AppSettings;
|
||||
use crate::theme::ThemeColors;
|
||||
use hcie_engine_api::Tool;
|
||||
use iced::widget::{button, checkbox, column, container, horizontal_rule, row, scrollable, slider, text, text_input};
|
||||
use iced::widget::{
|
||||
button, checkbox, column, container, horizontal_rule, row, scrollable, slider, text, text_input,
|
||||
};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Build the tool settings panel for the active tool.
|
||||
@@ -74,38 +76,134 @@ pub fn view<'a>(
|
||||
.into()
|
||||
}
|
||||
|
||||
fn brush_settings(ts: &ToolState, settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
|
||||
fn brush_settings(
|
||||
ts: &ToolState,
|
||||
settings: &AppSettings,
|
||||
c: ThemeColors,
|
||||
) -> iced::widget::Column<'static, Message> {
|
||||
let ts2 = settings.tool_settings.clone();
|
||||
column![
|
||||
section_header("Brush Settings", c),
|
||||
prop_slider("Size", ts.brush_size, 1.0, 500.0, Message::BrushSizeChanged, c),
|
||||
prop_slider("Opacity", ts.brush_opacity * 100.0, 0.0, 100.0, |v| Message::BrushOpacityChanged(v / 100.0), c),
|
||||
prop_slider("Hardness", ts.brush_hardness * 100.0, 0.0, 100.0, |v| Message::BrushHardnessChanged(v / 100.0), c),
|
||||
prop_slider("Flow", ts2.brush_flow * 100.0, 0.0, 100.0, |v| Message::BrushFlowChanged(v / 100.0), c),
|
||||
prop_slider("Spacing", ts.brush_spacing * 100.0, 1.0, 100.0, |v| Message::BrushSpacingChanged(v / 100.0), c),
|
||||
prop_slider(
|
||||
"Size",
|
||||
ts.brush_size,
|
||||
1.0,
|
||||
500.0,
|
||||
Message::BrushSizeChanged,
|
||||
c
|
||||
),
|
||||
prop_slider(
|
||||
"Opacity",
|
||||
ts.brush_opacity * 100.0,
|
||||
0.0,
|
||||
100.0,
|
||||
|v| Message::BrushOpacityChanged(v / 100.0),
|
||||
c
|
||||
),
|
||||
prop_slider(
|
||||
"Hardness",
|
||||
ts.brush_hardness * 100.0,
|
||||
0.0,
|
||||
100.0,
|
||||
|v| Message::BrushHardnessChanged(v / 100.0),
|
||||
c
|
||||
),
|
||||
prop_slider(
|
||||
"Flow",
|
||||
ts2.brush_flow * 100.0,
|
||||
0.0,
|
||||
100.0,
|
||||
|v| Message::BrushFlowChanged(v / 100.0),
|
||||
c
|
||||
),
|
||||
prop_slider(
|
||||
"Spacing",
|
||||
ts.brush_spacing * 100.0,
|
||||
1.0,
|
||||
100.0,
|
||||
|v| Message::BrushSpacingChanged(v / 100.0),
|
||||
c
|
||||
),
|
||||
]
|
||||
.spacing(4)
|
||||
}
|
||||
|
||||
fn spray_settings(ts: &ToolState, settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
|
||||
fn spray_settings(
|
||||
ts: &ToolState,
|
||||
settings: &AppSettings,
|
||||
c: ThemeColors,
|
||||
) -> iced::widget::Column<'static, Message> {
|
||||
let ts2 = settings.tool_settings.clone();
|
||||
column![
|
||||
section_header("Spray Settings", c),
|
||||
prop_slider("Size", ts.brush_size, 1.0, 500.0, Message::BrushSizeChanged, c),
|
||||
prop_slider("Particle Size", ts2.spray_particle_size, 1.0, 20.0, Message::SprayParticleSizeChanged, c),
|
||||
prop_slider("Density", ts2.spray_density as f32, 10.0, 1000.0, |v| Message::SprayDensityChanged(v as u32), c),
|
||||
prop_slider("Opacity", ts.brush_opacity * 100.0, 0.0, 100.0, |v| Message::BrushOpacityChanged(v / 100.0), c),
|
||||
prop_slider("Hardness", ts.brush_hardness * 100.0, 0.0, 100.0, |v| Message::BrushHardnessChanged(v / 100.0), c),
|
||||
prop_slider(
|
||||
"Size",
|
||||
ts.brush_size,
|
||||
1.0,
|
||||
500.0,
|
||||
Message::BrushSizeChanged,
|
||||
c
|
||||
),
|
||||
prop_slider(
|
||||
"Particle Size",
|
||||
ts2.spray_particle_size,
|
||||
1.0,
|
||||
20.0,
|
||||
Message::SprayParticleSizeChanged,
|
||||
c
|
||||
),
|
||||
prop_slider(
|
||||
"Density",
|
||||
ts2.spray_density as f32,
|
||||
10.0,
|
||||
1000.0,
|
||||
|v| Message::SprayDensityChanged(v as u32),
|
||||
c
|
||||
),
|
||||
prop_slider(
|
||||
"Opacity",
|
||||
ts.brush_opacity * 100.0,
|
||||
0.0,
|
||||
100.0,
|
||||
|v| Message::BrushOpacityChanged(v / 100.0),
|
||||
c
|
||||
),
|
||||
prop_slider(
|
||||
"Hardness",
|
||||
ts.brush_hardness * 100.0,
|
||||
0.0,
|
||||
100.0,
|
||||
|v| Message::BrushHardnessChanged(v / 100.0),
|
||||
c
|
||||
),
|
||||
]
|
||||
.spacing(4)
|
||||
}
|
||||
|
||||
fn vector_settings(ts: &ToolState, settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
|
||||
fn vector_settings(
|
||||
ts: &ToolState,
|
||||
settings: &AppSettings,
|
||||
c: ThemeColors,
|
||||
) -> iced::widget::Column<'static, Message> {
|
||||
let ts2 = settings.tool_settings.clone();
|
||||
let mut col = column![
|
||||
section_header("Vector Settings", c),
|
||||
prop_slider("Stroke Width", ts2.vector_stroke_width, 0.0, 50.0, Message::VectorStrokeChanged, c),
|
||||
prop_slider("Opacity", ts2.vector_opacity * 100.0, 0.0, 100.0, |v| Message::VectorOpacityChanged(v / 100.0), c),
|
||||
prop_slider(
|
||||
"Stroke Width",
|
||||
ts2.vector_stroke_width,
|
||||
0.0,
|
||||
50.0,
|
||||
Message::VectorStrokeChanged,
|
||||
c
|
||||
),
|
||||
prop_slider(
|
||||
"Opacity",
|
||||
ts2.vector_opacity * 100.0,
|
||||
0.0,
|
||||
100.0,
|
||||
|v| Message::VectorOpacityChanged(v / 100.0),
|
||||
c
|
||||
),
|
||||
iced::widget::checkbox("Fill", ts2.vector_fill)
|
||||
.on_toggle(Message::VectorFillToggled)
|
||||
.size(11),
|
||||
@@ -115,13 +213,34 @@ fn vector_settings(ts: &ToolState, settings: &AppSettings, c: ThemeColors) -> ic
|
||||
// Shape-specific controls.
|
||||
match ts.active_tool {
|
||||
Tool::VectorRect => {
|
||||
col = col.push(prop_slider("Corner Radius", ts2.vector_radius, 0.0, 100.0, Message::VectorRadiusChanged, c));
|
||||
col = col.push(prop_slider(
|
||||
"Corner Radius",
|
||||
ts2.vector_radius,
|
||||
0.0,
|
||||
100.0,
|
||||
Message::VectorRadiusChanged,
|
||||
c,
|
||||
));
|
||||
}
|
||||
Tool::VectorStar => {
|
||||
col = col.push(prop_slider("Points", ts2.vector_points as f32, 3.0, 20.0, |v| Message::VectorPointsChanged(v as u32), c));
|
||||
col = col.push(prop_slider(
|
||||
"Points",
|
||||
ts2.vector_points as f32,
|
||||
3.0,
|
||||
20.0,
|
||||
|v| Message::VectorPointsChanged(v as u32),
|
||||
c,
|
||||
));
|
||||
}
|
||||
Tool::VectorPolygon => {
|
||||
col = col.push(prop_slider("Sides", ts2.vector_sides as f32, 3.0, 12.0, |v| Message::VectorSidesChanged(v as u32), c));
|
||||
col = col.push(prop_slider(
|
||||
"Sides",
|
||||
ts2.vector_sides as f32,
|
||||
3.0,
|
||||
12.0,
|
||||
|v| Message::VectorSidesChanged(v as u32),
|
||||
c,
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -129,28 +248,48 @@ fn vector_settings(ts: &ToolState, settings: &AppSettings, c: ThemeColors) -> ic
|
||||
col
|
||||
}
|
||||
|
||||
fn select_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
|
||||
fn select_settings(
|
||||
settings: &AppSettings,
|
||||
c: ThemeColors,
|
||||
) -> iced::widget::Column<'static, Message> {
|
||||
let ts2 = settings.tool_settings.clone();
|
||||
column![
|
||||
section_header("Selection Settings", c),
|
||||
prop_slider("Feather", ts2.select_feather, 0.0, 250.0, Message::SelectionFeatherChanged, c),
|
||||
prop_slider(
|
||||
"Feather",
|
||||
ts2.select_feather,
|
||||
0.0,
|
||||
250.0,
|
||||
Message::SelectionFeatherChanged,
|
||||
c
|
||||
),
|
||||
iced::widget::checkbox("Anti-alias", ts2.selection_anti_alias)
|
||||
.on_toggle(|_v| Message::NoOp)
|
||||
.on_toggle(Message::SelectionAntiAliasToggled)
|
||||
.size(11),
|
||||
]
|
||||
.spacing(4)
|
||||
}
|
||||
|
||||
fn magic_wand_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
|
||||
fn magic_wand_settings(
|
||||
settings: &AppSettings,
|
||||
c: ThemeColors,
|
||||
) -> iced::widget::Column<'static, Message> {
|
||||
let ts2 = settings.tool_settings.clone();
|
||||
column![
|
||||
section_header("Magic Wand Settings", c),
|
||||
prop_slider("Tolerance", ts2.magic_wand_tolerance, 0.0, 255.0, |v| Message::SelectionToleranceChanged(v as u8), c),
|
||||
prop_slider(
|
||||
"Tolerance",
|
||||
ts2.magic_wand_tolerance,
|
||||
0.0,
|
||||
255.0,
|
||||
|v| Message::SelectionToleranceChanged(v as u8),
|
||||
c
|
||||
),
|
||||
iced::widget::checkbox("Contiguous", ts2.selection_contiguous)
|
||||
.on_toggle(Message::SelectionContiguousToggled)
|
||||
.size(11),
|
||||
iced::widget::checkbox("Anti-alias", ts2.selection_anti_alias)
|
||||
.on_toggle(|_v| Message::NoOp)
|
||||
.on_toggle(Message::SelectionAntiAliasToggled)
|
||||
.size(11),
|
||||
]
|
||||
.spacing(4)
|
||||
@@ -163,7 +302,9 @@ fn text_settings(
|
||||
c: ThemeColors,
|
||||
) -> iced::widget::Column<'static, Message> {
|
||||
let ts2 = settings.tool_settings.clone();
|
||||
let draft_font = text_draft.map(|d| d.font.clone()).unwrap_or_else(|| ts2.text_font.clone());
|
||||
let draft_font = text_draft
|
||||
.map(|d| d.font.clone())
|
||||
.unwrap_or_else(|| ts2.text_font.clone());
|
||||
let draft_size = text_draft.map(|d| d.size).unwrap_or(ts2.text_size);
|
||||
let draft_angle = text_draft.map(|d| d.angle).unwrap_or(ts2.text_angle);
|
||||
let draft_color = text_draft.map(|d| d.color).unwrap_or(fg_color);
|
||||
@@ -187,7 +328,14 @@ fn text_settings(
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center),
|
||||
prop_slider("Size", draft_size, 4.0, 200.0, Message::TextSizeChanged, c),
|
||||
prop_slider("Angle", draft_angle, -180.0, 180.0, Message::TextAngleChanged, c),
|
||||
prop_slider(
|
||||
"Angle",
|
||||
draft_angle,
|
||||
-180.0,
|
||||
180.0,
|
||||
Message::TextAngleChanged,
|
||||
c
|
||||
),
|
||||
color_row("Color:", draft_color, c),
|
||||
row![
|
||||
text("Content:").size(10).width(Length::Fixed(50.0)),
|
||||
@@ -200,20 +348,34 @@ fn text_settings(
|
||||
.align_y(iced::Alignment::Center),
|
||||
row![
|
||||
text("Align:").size(10).width(Length::Fixed(50.0)),
|
||||
button(text("Left").size(9)).on_press(Message::TextAlignChanged("Left".to_string())).padding([2, 4]),
|
||||
button(text("Center").size(9)).on_press(Message::TextAlignChanged("Center".to_string())).padding([2, 4]),
|
||||
button(text("Right").size(9)).on_press(Message::TextAlignChanged("Right".to_string())).padding([2, 4]),
|
||||
button(text("Left").size(9))
|
||||
.on_press(Message::TextAlignChanged("Left".to_string()))
|
||||
.padding([2, 4]),
|
||||
button(text("Center").size(9))
|
||||
.on_press(Message::TextAlignChanged("Center".to_string()))
|
||||
.padding([2, 4]),
|
||||
button(text("Right").size(9))
|
||||
.on_press(Message::TextAlignChanged("Right".to_string()))
|
||||
.padding([2, 4]),
|
||||
]
|
||||
.spacing(3),
|
||||
row![
|
||||
text("Orient:").size(10).width(Length::Fixed(50.0)),
|
||||
button(text("H").size(9)).on_press(Message::TextOrientationChanged("Horizontal".to_string())).padding([2, 6]),
|
||||
button(text("V").size(9)).on_press(Message::TextOrientationChanged("Vertical".to_string())).padding([2, 6]),
|
||||
button(text("H").size(9))
|
||||
.on_press(Message::TextOrientationChanged("Horizontal".to_string()))
|
||||
.padding([2, 6]),
|
||||
button(text("V").size(9))
|
||||
.on_press(Message::TextOrientationChanged("Vertical".to_string()))
|
||||
.padding([2, 6]),
|
||||
]
|
||||
.spacing(3),
|
||||
row![
|
||||
button(text("Commit").size(10)).on_press(Message::TextCommit).padding([4, 10]),
|
||||
button(text("Cancel").size(10)).on_press(Message::TextCancel).padding([4, 10]),
|
||||
button(text("Commit").size(10))
|
||||
.on_press(Message::TextCommit)
|
||||
.padding([4, 10]),
|
||||
button(text("Cancel").size(10))
|
||||
.on_press(Message::TextCancel)
|
||||
.padding([4, 10]),
|
||||
]
|
||||
.spacing(6),
|
||||
]
|
||||
@@ -231,7 +393,10 @@ fn move_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column
|
||||
.spacing(4)
|
||||
}
|
||||
|
||||
fn gradient_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
|
||||
fn gradient_settings(
|
||||
settings: &AppSettings,
|
||||
c: ThemeColors,
|
||||
) -> iced::widget::Column<'static, Message> {
|
||||
let ts2 = settings.tool_settings.clone();
|
||||
let gtype = ts2.gradient_type;
|
||||
column![
|
||||
@@ -246,17 +411,34 @@ fn gradient_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Co
|
||||
.padding([2, 6]),
|
||||
]
|
||||
.spacing(3),
|
||||
prop_slider("Opacity", ts2.gradient_opacity * 100.0, 0.0, 100.0, |v| Message::BrushOpacityChanged(v / 100.0), c),
|
||||
prop_slider(
|
||||
"Opacity",
|
||||
ts2.gradient_opacity * 100.0,
|
||||
0.0,
|
||||
100.0,
|
||||
|v| Message::BrushOpacityChanged(v / 100.0),
|
||||
c
|
||||
),
|
||||
text(if gtype == 1 { "Radial" } else { "Linear" }).size(9),
|
||||
]
|
||||
.spacing(4)
|
||||
}
|
||||
|
||||
fn flood_fill_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column<'static, Message> {
|
||||
fn flood_fill_settings(
|
||||
settings: &AppSettings,
|
||||
c: ThemeColors,
|
||||
) -> iced::widget::Column<'static, Message> {
|
||||
let ts2 = settings.tool_settings.clone();
|
||||
column![
|
||||
section_header("Flood Fill Settings", c),
|
||||
prop_slider("Tolerance", ts2.flood_fill_tolerance, 0.0, 255.0, |v| Message::SelectionToleranceChanged(v as u8), c),
|
||||
prop_slider(
|
||||
"Tolerance",
|
||||
ts2.flood_fill_tolerance,
|
||||
0.0,
|
||||
255.0,
|
||||
|v| Message::SelectionToleranceChanged(v as u8),
|
||||
c
|
||||
),
|
||||
iced::widget::checkbox("Contiguous", ts2.selection_contiguous)
|
||||
.on_toggle(Message::SelectionContiguousToggled)
|
||||
.size(11),
|
||||
@@ -269,14 +451,25 @@ fn section_header(label: &str, _c: ThemeColors) -> Element<'static, Message> {
|
||||
row![text(l).size(11)].spacing(4).into()
|
||||
}
|
||||
|
||||
fn prop_slider<F>(label: &str, value: f32, min: f32, max: f32, msg_fn: F, _c: ThemeColors) -> Element<'static, Message>
|
||||
fn prop_slider<F>(
|
||||
label: &str,
|
||||
value: f32,
|
||||
min: f32,
|
||||
max: f32,
|
||||
msg_fn: F,
|
||||
_c: ThemeColors,
|
||||
) -> Element<'static, Message>
|
||||
where
|
||||
F: Fn(f32) -> Message + 'static,
|
||||
{
|
||||
let l = label.to_string();
|
||||
let label_text = text(l).size(10).width(Length::Fixed(70.0));
|
||||
let val_text = text(format!("{:.1}", value)).size(10).width(Length::Fixed(40.0));
|
||||
let sl = slider(min..=max, value, msg_fn).step(0.1).width(Length::Fill);
|
||||
let val_text = text(format!("{:.1}", value))
|
||||
.size(10)
|
||||
.width(Length::Fixed(40.0));
|
||||
let sl = slider(min..=max, value, msg_fn)
|
||||
.step(0.1)
|
||||
.width(Length::Fill);
|
||||
|
||||
row![label_text, sl, val_text]
|
||||
.spacing(4)
|
||||
@@ -296,14 +489,14 @@ fn color_row(label: &str, color: [u8; 4], _c: ThemeColors) -> Element<'static, M
|
||||
color[2] as f32 / 255.0,
|
||||
color[3] as f32 / 255.0,
|
||||
))),
|
||||
border: iced::Border::default().rounded(2).color(iced::Color::from_rgb(0.3, 0.3, 0.3)).width(1),
|
||||
border: iced::Border::default()
|
||||
.rounded(2)
|
||||
.color(iced::Color::from_rgb(0.3, 0.3, 0.3))
|
||||
.width(1),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
row![
|
||||
text(l).size(10).width(Length::Fixed(50.0)),
|
||||
swatch,
|
||||
]
|
||||
row![text(l).size(10).width(Length::Fixed(50.0)), swatch,]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center)
|
||||
.into()
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! Icons are 16px SVGs loaded from assets/icons/.
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::panels::menus::MenuCommand;
|
||||
use crate::panels::styles;
|
||||
use crate::theme::ThemeColors;
|
||||
use hcie_engine_api::Tool;
|
||||
@@ -12,13 +13,32 @@ use iced::widget::{button, container, row, slider, svg, text};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Build the Photopea-style toolbar: SVG icon buttons + blend mode/opacity/flow in one row.
|
||||
pub fn view<'a>(tool_state: &'a crate::app::ToolState, settings: &'a crate::settings::AppSettings, colors: ThemeColors) -> Element<'a, Message> {
|
||||
pub fn view<'a>(
|
||||
tool_state: &'a crate::app::ToolState,
|
||||
settings: &'a crate::settings::AppSettings,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'a, Message> {
|
||||
let c = colors;
|
||||
|
||||
// ── SVG icon buttons (no text) ──
|
||||
let new_btn = svg_btn("icons/new_file.svg", "New", Message::MenuAction(0, 0), c);
|
||||
let open_btn = svg_btn("icons/open_file.svg", "Open", Message::MenuAction(0, 1), c);
|
||||
let save_btn = svg_btn("icons/save_file.svg", "Save", Message::MenuAction(0, 7), c);
|
||||
let new_btn = svg_btn(
|
||||
"icons/new_file.svg",
|
||||
"New",
|
||||
Message::MenuCommand(MenuCommand::NewDocument),
|
||||
c,
|
||||
);
|
||||
let open_btn = svg_btn(
|
||||
"icons/open_file.svg",
|
||||
"Open",
|
||||
Message::MenuCommand(MenuCommand::OpenFile),
|
||||
c,
|
||||
);
|
||||
let save_btn = svg_btn(
|
||||
"icons/save_file.svg",
|
||||
"Save",
|
||||
Message::MenuCommand(MenuCommand::Save),
|
||||
c,
|
||||
);
|
||||
let undo_btn = svg_btn("icons/undo.svg", "Undo", Message::Undo, c);
|
||||
let redo_btn = svg_btn("icons/redo.svg", "Redo", Message::Redo, c);
|
||||
|
||||
@@ -27,32 +47,45 @@ pub fn view<'a>(tool_state: &'a crate::app::ToolState, settings: &'a crate::sett
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
// ── Blend Mode, Opacity, Flow, Smooth (Photopea-style) ──
|
||||
let blend_label = text("Blend Mode:").size(10).color(c.text_secondary);
|
||||
let blend_value = text("Normal").size(10).color(c.text_primary);
|
||||
|
||||
let opacity_label = text("Opacity:").size(10).color(c.text_secondary);
|
||||
let opacity_value = text(format!("{:.0}%", tool_state.brush_opacity * 100.0)).size(10).color(c.text_primary);
|
||||
let opacity_sl = slider(0.0..=1.0, tool_state.brush_opacity, Message::BrushOpacityChanged)
|
||||
let blend_value = compact_value("Normal".to_string(), "Blend mode: Normal", c);
|
||||
let opacity_value = compact_value(
|
||||
format!("O {:.0}%", tool_state.brush_opacity * 100.0),
|
||||
"Brush opacity",
|
||||
c,
|
||||
);
|
||||
let opacity_sl = slider(
|
||||
0.0..=1.0,
|
||||
tool_state.brush_opacity,
|
||||
Message::BrushOpacityChanged,
|
||||
)
|
||||
.step(0.01)
|
||||
.width(Length::Fixed(60.0));
|
||||
.width(Length::Fixed(52.0));
|
||||
|
||||
let flow_label = text("Flow:").size(10).color(c.text_secondary);
|
||||
let flow_value = text(format!("{:.0}%", settings.tool_settings.brush_flow * 100.0)).size(10).color(c.text_primary);
|
||||
let flow_sl = slider(0.0..=1.0, settings.tool_settings.brush_flow, Message::BrushFlowChanged)
|
||||
let flow_value = compact_value(
|
||||
format!("F {:.0}%", settings.tool_settings.brush_flow * 100.0),
|
||||
"Brush flow",
|
||||
c,
|
||||
);
|
||||
let flow_sl = slider(
|
||||
0.0..=1.0,
|
||||
settings.tool_settings.brush_flow,
|
||||
Message::BrushFlowChanged,
|
||||
)
|
||||
.step(0.01)
|
||||
.width(Length::Fixed(60.0));
|
||||
.width(Length::Fixed(52.0));
|
||||
|
||||
let smooth_label = text("Smooth:").size(10).color(c.text_secondary);
|
||||
let smooth_value = text("0%").size(10).color(c.text_primary);
|
||||
let smooth_value = compact_value("S 0%".to_string(), "Stroke smoothing", c);
|
||||
|
||||
let tool_options = row![
|
||||
blend_label, blend_value,
|
||||
blend_value,
|
||||
sep(c),
|
||||
opacity_label, opacity_value, opacity_sl,
|
||||
opacity_value,
|
||||
opacity_sl,
|
||||
sep(c),
|
||||
flow_label, flow_value, flow_sl,
|
||||
flow_value,
|
||||
flow_sl,
|
||||
sep(c),
|
||||
smooth_label, smooth_value,
|
||||
smooth_value,
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center);
|
||||
@@ -60,52 +93,67 @@ pub fn view<'a>(tool_state: &'a crate::app::ToolState, settings: &'a crate::sett
|
||||
// ── Tool-specific options (right side) ──
|
||||
let tool_options_extra = match tool_state.active_tool {
|
||||
Tool::Brush | Tool::Eraser | Tool::Pen | Tool::Spray => {
|
||||
let sz = text(format!("Size: {:.0}", tool_state.brush_size))
|
||||
.size(10)
|
||||
.width(Length::Fixed(55.0));
|
||||
let sz_sl = slider(1.0..=200.0, tool_state.brush_size, Message::BrushSizeChanged)
|
||||
let sz = compact_value(format!("{:.0}px", tool_state.brush_size), "Brush size", c);
|
||||
let sz_sl = slider(
|
||||
1.0..=200.0,
|
||||
tool_state.brush_size,
|
||||
Message::BrushSizeChanged,
|
||||
)
|
||||
.step(1.0)
|
||||
.width(Length::Fixed(80.0));
|
||||
let ha = text(format!("Hardness: {:.0}%", tool_state.brush_hardness * 100.0))
|
||||
.size(10)
|
||||
.width(Length::Fixed(70.0));
|
||||
let ha_sl = slider(0.0..=1.0, tool_state.brush_hardness, Message::BrushHardnessChanged)
|
||||
.width(Length::Fixed(58.0));
|
||||
let ha = compact_value(
|
||||
format!("H {:.0}%", tool_state.brush_hardness * 100.0),
|
||||
"Brush hardness",
|
||||
c,
|
||||
);
|
||||
let ha_sl = slider(
|
||||
0.0..=1.0,
|
||||
tool_state.brush_hardness,
|
||||
Message::BrushHardnessChanged,
|
||||
)
|
||||
.step(0.01)
|
||||
.width(Length::Fixed(80.0));
|
||||
row![sz, sz_sl, ha, ha_sl].spacing(4).align_y(iced::Alignment::Center)
|
||||
.width(Length::Fixed(58.0));
|
||||
row![sz, sz_sl, ha, ha_sl]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center)
|
||||
}
|
||||
Tool::MagicWand => {
|
||||
let tol = settings.tool_settings.magic_wand_tolerance;
|
||||
row![
|
||||
text("Tolerance:").size(10),
|
||||
slider(0.0..=255.0, tol, |v| Message::SelectionToleranceChanged(v as u8))
|
||||
compact_value(format!("T {:.0}", tol), "Selection tolerance", c),
|
||||
slider(0.0..=255.0, tol, |v| Message::SelectionToleranceChanged(
|
||||
v as u8
|
||||
))
|
||||
.step(1.0)
|
||||
.width(Length::Fixed(80.0)),
|
||||
text(format!("{:.0}", tol)).size(10).width(Length::Fixed(30.0)),
|
||||
].spacing(6).align_y(iced::Alignment::Center)
|
||||
.width(Length::Fixed(64.0)),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Alignment::Center)
|
||||
}
|
||||
Tool::Select | Tool::Lasso => {
|
||||
let feather = settings.tool_settings.select_feather;
|
||||
row![
|
||||
text("Feather:").size(10),
|
||||
compact_value(format!("F {:.0}px", feather), "Selection feather", c),
|
||||
slider(0.0..=250.0, feather, Message::SelectionFeatherChanged)
|
||||
.step(1.0)
|
||||
.width(Length::Fixed(80.0)),
|
||||
text(format!("{:.0}px", feather)).size(10).width(Length::Fixed(40.0)),
|
||||
].spacing(6).align_y(iced::Alignment::Center)
|
||||
}
|
||||
Tool::Move => {
|
||||
row![text("Auto-Select").size(10)].spacing(4).align_y(iced::Alignment::Center)
|
||||
.width(Length::Fixed(64.0)),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Alignment::Center)
|
||||
}
|
||||
Tool::Move => row![compact_value("Auto".to_string(), "Auto-select layer", c)]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center),
|
||||
Tool::VectorLine | Tool::VectorRect | Tool::VectorCircle => {
|
||||
let stroke = settings.tool_settings.vector_stroke_width;
|
||||
row![
|
||||
text("Stroke:").size(10),
|
||||
compact_value(format!("{:.1}px", stroke), "Vector stroke width", c),
|
||||
slider(0.0..=50.0, stroke, Message::VectorStrokeChanged)
|
||||
.step(0.5)
|
||||
.width(Length::Fixed(60.0)),
|
||||
text(format!("{:.1}px", stroke)).size(10).width(Length::Fixed(40.0)),
|
||||
].spacing(4).align_y(iced::Alignment::Center)
|
||||
.width(Length::Fixed(58.0)),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center)
|
||||
}
|
||||
_ => row![].spacing(0),
|
||||
};
|
||||
@@ -129,7 +177,12 @@ pub fn view<'a>(tool_state: &'a crate::app::ToolState, settings: &'a crate::sett
|
||||
}
|
||||
|
||||
/// SVG icon button (16px icon, no text).
|
||||
fn svg_btn(icon_path: &str, tip: &str, msg: Message, colors: ThemeColors) -> Element<'static, Message> {
|
||||
fn svg_btn(
|
||||
icon_path: &str,
|
||||
tip: &str,
|
||||
msg: Message,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'static, Message> {
|
||||
let c = colors;
|
||||
let tip_owned = tip.to_string();
|
||||
|
||||
@@ -144,9 +197,11 @@ fn svg_btn(icon_path: &str, tip: &str, msg: Message, colors: ThemeColors) -> Ele
|
||||
svg(handle)
|
||||
.width(16)
|
||||
.height(16)
|
||||
.style(move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style {
|
||||
color: Some(c.text_primary),
|
||||
})
|
||||
},
|
||||
)
|
||||
.into()
|
||||
} else {
|
||||
// Fallback to text
|
||||
@@ -154,11 +209,8 @@ fn svg_btn(icon_path: &str, tip: &str, msg: Message, colors: ThemeColors) -> Ele
|
||||
};
|
||||
|
||||
iced::widget::tooltip(
|
||||
button(icon)
|
||||
.on_press(msg)
|
||||
.padding([2, 4])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
button(icon).on_press(msg).padding([2, 4]).style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.bg_hover)),
|
||||
text_color: c.text_primary,
|
||||
@@ -171,8 +223,8 @@ fn svg_btn(icon_path: &str, tip: &str, msg: Message, colors: ThemeColors) -> Ele
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}),
|
||||
},
|
||||
),
|
||||
text(tip_owned).size(11),
|
||||
iced::widget::tooltip::Position::Bottom,
|
||||
)
|
||||
@@ -185,8 +237,8 @@ fn small_btn(label: &str, colors: ThemeColors) -> Element<'static, Message> {
|
||||
button(text(l).size(10))
|
||||
.on_press(Message::NoOp)
|
||||
.padding([2, 6])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.bg_hover)),
|
||||
text_color: c.text_primary,
|
||||
@@ -199,8 +251,8 @@ fn small_btn(label: &str, colors: ThemeColors) -> Element<'static, Message> {
|
||||
border: iced::Border::default().color(c.border_low).width(1),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -213,3 +265,13 @@ fn sep(colors: ThemeColors) -> Element<'static, Message> {
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Displays a compact toolbar value while moving its full meaning into a tooltip.
|
||||
fn compact_value(value: String, tip: &str, colors: ThemeColors) -> Element<'static, Message> {
|
||||
iced::widget::tooltip(
|
||||
container(text(value).size(10).color(colors.text_primary)).padding([1, 3]),
|
||||
text(tip.to_string()).size(11),
|
||||
iced::widget::tooltip::Position::Bottom,
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Shared typography metrics for docked panels.
|
||||
//!
|
||||
//! Purpose: Keep panel body, section, and dock-title text readable and consistent.
|
||||
//! Logic & Workflow: Panel views use these constants instead of local pixel sizes so narrow
|
||||
//! layouts can increase readability without diverging. Side Effects / Dependencies: None.
|
||||
|
||||
/// Standard readable body text size used by panel labels and values.
|
||||
pub const BODY: u16 = 12;
|
||||
/// Emphasized section heading size used inside panel content.
|
||||
pub const SECTION: u16 = 13;
|
||||
/// Dock title size used by pane title bars.
|
||||
pub const TITLE: u16 = 13;
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Pure raster operations owned by the Iced GUI layer.
|
||||
//!
|
||||
//! **Purpose:** Implements GUI-side pixel workflows that can be submitted through the public
|
||||
//! engine pixel API without importing engine internals. **Logic & Workflow:** Functions transform
|
||||
//! caller-owned RGBA buffers deterministically and leave history/commit policy to the application.
|
||||
//! **Side Effects / Dependencies:** None beyond mutation of explicitly supplied buffers.
|
||||
|
||||
/// Applies a linear or radial two-color gradient to an RGBA canvas.
|
||||
///
|
||||
/// **Arguments:** `pixels` is canvas-sized RGBA data; dimensions and endpoints are canvas-space;
|
||||
/// `radial` selects distance-from-start instead of projection; `start_color`/`end_color` are RGBA;
|
||||
/// `opacity` blends the gradient over existing pixels; `mask` optionally limits affected pixels.
|
||||
/// **Returns:** Nothing. **Side Effects:** Mutates `pixels` only.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn apply_gradient(
|
||||
pixels: &mut [u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
start: (f32, f32),
|
||||
end: (f32, f32),
|
||||
radial: bool,
|
||||
start_color: [u8; 4],
|
||||
end_color: [u8; 4],
|
||||
opacity: f32,
|
||||
mask: Option<&[u8]>,
|
||||
) {
|
||||
if pixels.len() != (width * height * 4) as usize {
|
||||
return;
|
||||
}
|
||||
let dx = end.0 - start.0;
|
||||
let dy = end.1 - start.1;
|
||||
let length_sq = dx * dx + dy * dy;
|
||||
if length_sq <= 0.000_1 {
|
||||
return;
|
||||
}
|
||||
let length = length_sq.sqrt();
|
||||
let opacity = opacity.clamp(0.0, 1.0);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let index = (y * width + x) as usize;
|
||||
if mask.is_some_and(|mask| mask.get(index).copied().unwrap_or(0) == 0) {
|
||||
continue;
|
||||
}
|
||||
let px = x as f32 + 0.5 - start.0;
|
||||
let py = y as f32 + 0.5 - start.1;
|
||||
let t = if radial {
|
||||
px.hypot(py) / length
|
||||
} else {
|
||||
(px * dx + py * dy) / length_sq
|
||||
}
|
||||
.clamp(0.0, 1.0);
|
||||
let destination = index * 4;
|
||||
for channel in 0..4 {
|
||||
let gradient =
|
||||
start_color[channel] as f32 * (1.0 - t) + end_color[channel] as f32 * t;
|
||||
pixels[destination + channel] =
|
||||
(pixels[destination + channel] as f32 * (1.0 - opacity) + gradient * opacity)
|
||||
.round() as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Native Iced screenshot persistence helpers.
|
||||
//!
|
||||
//! **Purpose:** Saves renderer-provided RGBA screenshots as PNG files without interacting with
|
||||
//! document pixels or the protected canvas texture pipeline.
|
||||
//! **Logic & Workflow:** The output directory is created when needed, byte dimensions are
|
||||
//! validated by the image crate, and the RGBA buffer is encoded directly to PNG.
|
||||
//! **Side Effects / Dependencies:** Creates parent directories and writes one output file.
|
||||
|
||||
use iced::window::Screenshot;
|
||||
use iced::{Rectangle, Size};
|
||||
use std::path::Path;
|
||||
|
||||
const TITLE_BAR_HEIGHT: f32 = crate::panels::title_bar::MENU_BAR_HEIGHT;
|
||||
const TOOLBAR_HEIGHT: f32 = 28.0;
|
||||
const STATUS_BAR_HEIGHT: f32 = 20.0;
|
||||
const TOOLBOX_DOCK_SPACING: f32 = 2.0;
|
||||
const PANE_SPACING: f32 = 4.0;
|
||||
|
||||
/// Saves a native Iced window screenshot as an RGBA PNG.
|
||||
///
|
||||
/// **Arguments:** `screenshot` contains physical-pixel RGBA bytes; `output` is the destination.
|
||||
/// **Returns:** `Ok(())` after a complete write, or a descriptive filesystem/encoding error.
|
||||
/// **Side Effects / Dependencies:** May create `output`'s parent directory and overwrite the PNG.
|
||||
pub fn save_png(screenshot: &Screenshot, output: &Path) -> Result<(), String> {
|
||||
if let Some(parent) = output
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
{
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("failed to create screenshot directory: {error}"))?;
|
||||
}
|
||||
image::save_buffer(
|
||||
output,
|
||||
screenshot.as_ref(),
|
||||
screenshot.size.width,
|
||||
screenshot.size.height,
|
||||
image::ColorType::Rgba8,
|
||||
)
|
||||
.map_err(|error| format!("failed to save screenshot: {error}"))
|
||||
}
|
||||
|
||||
/// Crops a screenshot to the current region of an exact named dock pane.
|
||||
///
|
||||
/// **Arguments:** `screenshot` is the complete physical viewport, `dock` contains the live split
|
||||
/// tree, `panel` is a case-sensitive pane label, and `sidebar_expanded` selects toolbox width.
|
||||
/// **Returns:** A physical-pixel screenshot containing the complete pane, or a descriptive error.
|
||||
/// **Logic & Workflow:** PaneGrid computes logical regions from its live ratios; shell offsets are
|
||||
/// added before converting outward to physical pixels with the screenshot scale factor.
|
||||
/// **Side Effects / Dependencies:** None; this does not mutate UI or renderer state.
|
||||
pub fn crop_panel(
|
||||
screenshot: &Screenshot,
|
||||
dock: &crate::dock::state::DockState,
|
||||
panel: &str,
|
||||
sidebar_expanded: bool,
|
||||
) -> Result<Screenshot, String> {
|
||||
let pane_type = crate::dock::state::PaneType::from_label(panel)
|
||||
.ok_or_else(|| format!("unknown screenshot panel: {panel}"))?;
|
||||
let pane = dock
|
||||
.pane_grid
|
||||
.iter()
|
||||
.find_map(|(pane, current)| (*current == pane_type).then_some(*pane))
|
||||
.ok_or_else(|| format!("screenshot panel is not open: {panel}"))?;
|
||||
let scale = screenshot.scale_factor as f32;
|
||||
let viewport = Size::new(
|
||||
screenshot.size.width as f32 / scale,
|
||||
screenshot.size.height as f32 / scale,
|
||||
);
|
||||
let dock_x = (if sidebar_expanded { 72.0 } else { 36.0 }) + TOOLBOX_DOCK_SPACING;
|
||||
let dock_y = TITLE_BAR_HEIGHT + TOOLBAR_HEIGHT;
|
||||
let dock_size = Size::new(
|
||||
(viewport.width - dock_x).max(0.0),
|
||||
(viewport.height - dock_y - STATUS_BAR_HEIGHT).max(0.0),
|
||||
);
|
||||
let logical = dock
|
||||
.pane_grid
|
||||
.layout()
|
||||
.pane_regions(PANE_SPACING, dock_size)
|
||||
.remove(&pane)
|
||||
.ok_or_else(|| format!("no layout region found for screenshot panel: {panel}"))?;
|
||||
let physical = physical_crop_rect(
|
||||
Rectangle {
|
||||
x: logical.x + dock_x,
|
||||
y: logical.y + dock_y,
|
||||
..logical
|
||||
},
|
||||
scale,
|
||||
screenshot.size,
|
||||
)?;
|
||||
screenshot
|
||||
.crop(physical)
|
||||
.map_err(|error| format!("failed to crop screenshot panel {panel}: {error}"))
|
||||
}
|
||||
|
||||
/// Converts logical widget bounds to a clamped physical-pixel rectangle.
|
||||
fn physical_crop_rect(
|
||||
logical: Rectangle,
|
||||
scale: f32,
|
||||
viewport: Size<u32>,
|
||||
) -> Result<Rectangle<u32>, String> {
|
||||
let x = (logical.x * scale).floor().max(0.0) as u32;
|
||||
let y = (logical.y * scale).floor().max(0.0) as u32;
|
||||
let right = ((logical.x + logical.width) * scale)
|
||||
.ceil()
|
||||
.clamp(0.0, viewport.width as f32) as u32;
|
||||
let bottom = ((logical.y + logical.height) * scale)
|
||||
.ceil()
|
||||
.clamp(0.0, viewport.height as f32) as u32;
|
||||
if right <= x || bottom <= y {
|
||||
return Err("screenshot panel has an empty visible region".to_string());
|
||||
}
|
||||
Ok(Rectangle {
|
||||
x,
|
||||
y,
|
||||
width: right - x,
|
||||
height: bottom - y,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::physical_crop_rect;
|
||||
use iced::{Rectangle, Size};
|
||||
|
||||
/// Confirms that scaling rounds outward and clamps to the viewport.
|
||||
#[test]
|
||||
fn physical_crop_scales_and_clamps() {
|
||||
let crop = physical_crop_rect(
|
||||
Rectangle {
|
||||
x: 10.25,
|
||||
y: 5.5,
|
||||
width: 100.5,
|
||||
height: 50.25,
|
||||
},
|
||||
2.0,
|
||||
Size::new(200, 100),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
crop,
|
||||
Rectangle {
|
||||
x: 20,
|
||||
y: 11,
|
||||
width: 180,
|
||||
height: 89
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,10 @@ fn find_layer_id(engine: &mut Engine, name_or_index: &str) -> Option<u64> {
|
||||
pub fn execute_actions(engine: &mut Engine, actions: &[ScriptAction]) {
|
||||
for action in actions {
|
||||
match action {
|
||||
ScriptAction::CreateLayer { name, layer_type: _ } => {
|
||||
ScriptAction::CreateLayer {
|
||||
name,
|
||||
layer_type: _,
|
||||
} => {
|
||||
engine.add_layer(name);
|
||||
}
|
||||
ScriptAction::SelectLayer { name_or_index } => {
|
||||
@@ -106,12 +109,18 @@ pub fn execute_actions(engine: &mut Engine, actions: &[ScriptAction]) {
|
||||
engine.set_active_layer(id);
|
||||
}
|
||||
}
|
||||
ScriptAction::SetLayerOpacity { name_or_index, opacity } => {
|
||||
ScriptAction::SetLayerOpacity {
|
||||
name_or_index,
|
||||
opacity,
|
||||
} => {
|
||||
if let Some(id) = find_layer_id(engine, name_or_index) {
|
||||
engine.set_layer_opacity(id, *opacity);
|
||||
}
|
||||
}
|
||||
ScriptAction::SetLayerBlendMode { name_or_index, blend_mode } => {
|
||||
ScriptAction::SetLayerBlendMode {
|
||||
name_or_index,
|
||||
blend_mode,
|
||||
} => {
|
||||
if let Some(id) = find_layer_id(engine, name_or_index) {
|
||||
let bm = parse_blend_mode(blend_mode);
|
||||
engine.set_layer_blend_mode(id, bm);
|
||||
@@ -166,10 +175,8 @@ pub fn execute_actions(engine: &mut Engine, actions: &[ScriptAction]) {
|
||||
tip.hardness = *hardness;
|
||||
engine.set_brush_tip(tip);
|
||||
|
||||
let stroke_points: Vec<(f32, f32, f32)> = points
|
||||
.iter()
|
||||
.map(|p| (p[0], p[1], 1.0))
|
||||
.collect();
|
||||
let stroke_points: Vec<(f32, f32, f32)> =
|
||||
points.iter().map(|p| (p[0], p[1], 1.0)).collect();
|
||||
engine.draw_stroke(&stroke_points);
|
||||
}
|
||||
ScriptAction::FillRasterRect {
|
||||
@@ -224,7 +231,10 @@ pub fn execute_actions(engine: &mut Engine, actions: &[ScriptAction]) {
|
||||
engine.delete_layer(id);
|
||||
}
|
||||
}
|
||||
ScriptAction::RenameLayer { name_or_index, new_name } => {
|
||||
ScriptAction::RenameLayer {
|
||||
name_or_index,
|
||||
new_name,
|
||||
} => {
|
||||
if let Some(id) = find_layer_id(engine, name_or_index) {
|
||||
engine.rename_layer(id, new_name);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,11 @@ fn tokenize(s: &str) -> Result<Vec<Token>, String> {
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
fn parse_expression(tokens: &[Token], pos: usize, vars: &std::collections::HashMap<String, f32>) -> Result<(f32, usize), String> {
|
||||
fn parse_expression(
|
||||
tokens: &[Token],
|
||||
pos: usize,
|
||||
vars: &std::collections::HashMap<String, f32>,
|
||||
) -> Result<(f32, usize), String> {
|
||||
let (mut left, mut pos) = parse_term(tokens, pos, vars)?;
|
||||
while pos < tokens.len() {
|
||||
match &tokens[pos] {
|
||||
@@ -103,7 +107,11 @@ fn parse_expression(tokens: &[Token], pos: usize, vars: &std::collections::HashM
|
||||
Ok((left, pos))
|
||||
}
|
||||
|
||||
fn parse_term(tokens: &[Token], pos: usize, vars: &std::collections::HashMap<String, f32>) -> Result<(f32, usize), String> {
|
||||
fn parse_term(
|
||||
tokens: &[Token],
|
||||
pos: usize,
|
||||
vars: &std::collections::HashMap<String, f32>,
|
||||
) -> Result<(f32, usize), String> {
|
||||
let (mut left, mut pos) = parse_factor(tokens, pos, vars)?;
|
||||
while pos < tokens.len() {
|
||||
match &tokens[pos] {
|
||||
@@ -126,7 +134,11 @@ fn parse_term(tokens: &[Token], pos: usize, vars: &std::collections::HashMap<Str
|
||||
Ok((left, pos))
|
||||
}
|
||||
|
||||
fn parse_factor(tokens: &[Token], pos: usize, vars: &std::collections::HashMap<String, f32>) -> Result<(f32, usize), String> {
|
||||
fn parse_factor(
|
||||
tokens: &[Token],
|
||||
pos: usize,
|
||||
vars: &std::collections::HashMap<String, f32>,
|
||||
) -> Result<(f32, usize), String> {
|
||||
if pos >= tokens.len() {
|
||||
return Err("unexpected end of expression".to_string());
|
||||
}
|
||||
@@ -188,12 +200,28 @@ fn eval_expr(expr: &str, vars: &std::collections::HashMap<String, f32>) -> Resul
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub enum ScriptAction {
|
||||
CreateLayer { name: String, layer_type: String },
|
||||
DeleteLayer { name_or_index: String },
|
||||
SelectLayer { name_or_index: String },
|
||||
RenameLayer { name_or_index: String, new_name: String },
|
||||
SetLayerOpacity { name_or_index: String, opacity: f32 },
|
||||
SetLayerBlendMode { name_or_index: String, blend_mode: String },
|
||||
CreateLayer {
|
||||
name: String,
|
||||
layer_type: String,
|
||||
},
|
||||
DeleteLayer {
|
||||
name_or_index: String,
|
||||
},
|
||||
SelectLayer {
|
||||
name_or_index: String,
|
||||
},
|
||||
RenameLayer {
|
||||
name_or_index: String,
|
||||
new_name: String,
|
||||
},
|
||||
SetLayerOpacity {
|
||||
name_or_index: String,
|
||||
opacity: f32,
|
||||
},
|
||||
SetLayerBlendMode {
|
||||
name_or_index: String,
|
||||
blend_mode: String,
|
||||
},
|
||||
PaintStroke {
|
||||
points: Vec<[f32; 2]>,
|
||||
color: String,
|
||||
@@ -210,7 +238,9 @@ pub enum ScriptAction {
|
||||
spacing: Option<f32>,
|
||||
hardness: Option<f32>,
|
||||
},
|
||||
SetForegroundColor { hex: String },
|
||||
SetForegroundColor {
|
||||
hex: String,
|
||||
},
|
||||
FillRasterRect {
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
@@ -225,7 +255,9 @@ pub enum ScriptAction {
|
||||
stroke_color: String,
|
||||
stroke_width: f32,
|
||||
},
|
||||
Finish { summary: String },
|
||||
Finish {
|
||||
summary: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Helper functions ────────────────────────────────────────────────────
|
||||
@@ -234,7 +266,11 @@ fn to_norm(coord: f32, canvas_dim: f32) -> f32 {
|
||||
(coord / canvas_dim).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn parse_coord(s: &str, vars: &std::collections::HashMap<String, f32>, canvas: f32) -> Result<f32, String> {
|
||||
fn parse_coord(
|
||||
s: &str,
|
||||
vars: &std::collections::HashMap<String, f32>,
|
||||
canvas: f32,
|
||||
) -> Result<f32, String> {
|
||||
let val = eval_expr(s, vars)?;
|
||||
Ok(to_norm(val, canvas))
|
||||
}
|
||||
@@ -263,13 +299,25 @@ fn interpolate_hex(hex1: &str, hex2: &str, t: f32) -> Result<String, String> {
|
||||
Ok(format!("#{:02X}{:02X}{:02X}", r, g, b))
|
||||
}
|
||||
|
||||
fn bezier_sample(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], p3: [f32; 2], steps: usize) -> Vec<[f32; 2]> {
|
||||
fn bezier_sample(
|
||||
p0: [f32; 2],
|
||||
p1: [f32; 2],
|
||||
p2: [f32; 2],
|
||||
p3: [f32; 2],
|
||||
steps: usize,
|
||||
) -> Vec<[f32; 2]> {
|
||||
let mut pts = Vec::with_capacity(steps + 1);
|
||||
for i in 0..=steps {
|
||||
let t = i as f32 / steps as f32;
|
||||
let u = 1.0 - t;
|
||||
let x = u * u * u * p0[0] + 3.0 * u * u * t * p1[0] + 3.0 * u * t * t * p2[0] + t * t * t * p3[0];
|
||||
let y = u * u * u * p0[1] + 3.0 * u * u * t * p1[1] + 3.0 * u * t * t * p2[1] + t * t * t * p3[1];
|
||||
let x = u * u * u * p0[0]
|
||||
+ 3.0 * u * u * t * p1[0]
|
||||
+ 3.0 * u * t * t * p2[0]
|
||||
+ t * t * t * p3[0];
|
||||
let y = u * u * u * p0[1]
|
||||
+ 3.0 * u * u * t * p1[1]
|
||||
+ 3.0 * u * t * t * p2[1]
|
||||
+ t * t * t * p3[1];
|
||||
pts.push([x, y]);
|
||||
}
|
||||
pts
|
||||
@@ -370,7 +418,11 @@ fn strip_brackets(s: &str) -> &str {
|
||||
|
||||
// ── Line Parser ──────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -> Result<Vec<ScriptAction>, String> {
|
||||
fn parse_single_line(
|
||||
state: &mut ScriptParserState,
|
||||
line: &str,
|
||||
line_num: u64,
|
||||
) -> Result<Vec<ScriptAction>, String> {
|
||||
let err = |msg: &str| format!("Line {}: {}", line_num, msg);
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.is_empty() {
|
||||
@@ -387,15 +439,9 @@ fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -
|
||||
layer_type: "raster".to_string(),
|
||||
}])
|
||||
}
|
||||
"vector_layer" => {
|
||||
if parts.len() < 2 {
|
||||
return Err(err("vector layer name missing"));
|
||||
}
|
||||
Ok(vec![ScriptAction::CreateLayer {
|
||||
name: parts[1..].join(" "),
|
||||
layer_type: "vector".to_string(),
|
||||
}])
|
||||
}
|
||||
"vector_layer" => Err(err(
|
||||
"vector_layer is unavailable in the public engine API; use layer",
|
||||
)),
|
||||
"select_layer" => {
|
||||
if parts.len() < 2 {
|
||||
return Err(err("layer name missing"));
|
||||
@@ -405,7 +451,8 @@ fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -
|
||||
}])
|
||||
}
|
||||
"layer_opacity" => {
|
||||
let val = eval_expr(parts.get(1).unwrap_or(&"1.0"), &state.vars).map_err(|e| err(&e))?;
|
||||
let val =
|
||||
eval_expr(parts.get(1).unwrap_or(&"1.0"), &state.vars).map_err(|e| err(&e))?;
|
||||
state.opacity = val.clamp(0.0, 1.0);
|
||||
Ok(vec![ScriptAction::SetLayerOpacity {
|
||||
name_or_index: "0".to_string(),
|
||||
@@ -439,8 +486,11 @@ fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -
|
||||
if parts.len() < 2 {
|
||||
return Err(err("color hex missing"));
|
||||
}
|
||||
hex_to_rgba_components(parts[1]).map_err(|error| err(&error))?;
|
||||
state.color = parts[1].to_string();
|
||||
Ok(vec![ScriptAction::SetForegroundColor { hex: state.color.clone() }])
|
||||
Ok(vec![ScriptAction::SetForegroundColor {
|
||||
hex: state.color.clone(),
|
||||
}])
|
||||
}
|
||||
"size" => {
|
||||
state.size = eval_expr(parts.get(1).unwrap_or(&"20"), &state.vars)
|
||||
@@ -530,12 +580,16 @@ fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -
|
||||
"set" => {
|
||||
let mut actions = Vec::new();
|
||||
for kv in &parts[1..] {
|
||||
let (key, val) =
|
||||
kv.split_once('=').ok_or_else(|| err("set syntax: key=value"))?;
|
||||
let (key, val) = kv
|
||||
.split_once('=')
|
||||
.ok_or_else(|| err("set syntax: key=value"))?;
|
||||
match key.to_lowercase().as_str() {
|
||||
"color" => {
|
||||
hex_to_rgba_components(val).map_err(|error| err(&error))?;
|
||||
state.color = val.to_string();
|
||||
actions.push(ScriptAction::SetForegroundColor { hex: state.color.clone() });
|
||||
actions.push(ScriptAction::SetForegroundColor {
|
||||
hex: state.color.clone(),
|
||||
});
|
||||
}
|
||||
"brush" | "style" => {
|
||||
state.style = val.to_string();
|
||||
@@ -548,8 +602,7 @@ fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -
|
||||
});
|
||||
}
|
||||
"size" => {
|
||||
state.size =
|
||||
eval_expr(val, &state.vars).map_err(|e| err(&e))?.max(1.0);
|
||||
state.size = eval_expr(val, &state.vars).map_err(|e| err(&e))?.max(1.0);
|
||||
actions.push(ScriptAction::SetBrush {
|
||||
size: Some(state.size),
|
||||
style: None,
|
||||
@@ -726,7 +779,7 @@ fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -
|
||||
}])
|
||||
}
|
||||
"bezier" | "curve" => {
|
||||
if parts.len() < 6 {
|
||||
if parts.len() < 5 {
|
||||
return Err(err("bezier expects x1,y1 cx1,cy1 cx2,cy2 x2,y2 [steps]"));
|
||||
}
|
||||
let parsed: Result<Vec<[f32; 2]>, String> = parts[1..5]
|
||||
@@ -797,7 +850,10 @@ fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -
|
||||
}
|
||||
|
||||
let points = pseudo_scatter(count, bx, by, bw, bh);
|
||||
let mut npts: Vec<[f32; 2]> = points.iter().map(|pt| [to_norm(pt[0], 800.0), to_norm(pt[1], 600.0)]).collect();
|
||||
let mut npts: Vec<[f32; 2]> = points
|
||||
.iter()
|
||||
.map(|pt| [to_norm(pt[0], 800.0), to_norm(pt[1], 600.0)])
|
||||
.collect();
|
||||
apply_wiggle(&mut npts, state.wiggle);
|
||||
|
||||
Ok(vec![ScriptAction::PaintStroke {
|
||||
@@ -825,7 +881,8 @@ fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -
|
||||
let y2 = parse_coord_abs(p2[1], &state.vars)?;
|
||||
|
||||
let steps = eval_expr(strip_brackets(parts[parts.len() - 1]), &state.vars)
|
||||
.map_err(|e| err(&format!("gradient step count: {}", e)))? as usize;
|
||||
.map_err(|e| err(&format!("gradient step count: {}", e)))?
|
||||
as usize;
|
||||
|
||||
let color_stops: Vec<&str> = parts[3..parts.len() - 1].to_vec();
|
||||
if color_stops.len() < 2 {
|
||||
@@ -930,7 +987,8 @@ pub fn parse_dsl_script(script_text: &str) -> Result<Vec<ScriptAction>, String>
|
||||
rest
|
||||
};
|
||||
let count = eval_expr(count_str.trim(), &state.vars)
|
||||
.map_err(|e| format!("Line {}: repeat count: {}", line_num, e))? as usize;
|
||||
.map_err(|e| format!("Line {}: repeat count: {}", line_num, e))?
|
||||
as usize;
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
@@ -1010,7 +1068,9 @@ pub fn suggest_fix(err: &str) -> Option<&'static str> {
|
||||
Some("bezier x1,y1 cx1,cy1 cx2,cy2 x2,y2 [steps] — 4 control points")
|
||||
} else if err.contains("draw needs at least 2") {
|
||||
Some("draw x1,y1 x2,y2 ... — provide at least 2 coordinate pairs")
|
||||
} else if err.contains("invalid point") || (err.contains("invalid") && err.contains("coordinates")) {
|
||||
} else if err.contains("invalid point")
|
||||
|| (err.contains("invalid") && err.contains("coordinates"))
|
||||
{
|
||||
Some("Coordinates must be comma-separated without spaces: 100,200 not '100, 200'")
|
||||
} else if err.contains("set syntax") {
|
||||
Some("set key=value key=value — e.g. set brush=oil size=30 opacity=0.8")
|
||||
|
||||
@@ -124,11 +124,46 @@ pub fn composite_floating_to_layer(
|
||||
let src_a = floating.pixels[src_idx + 3] as f32 / 255.0;
|
||||
let inv_a = 1.0 - src_a;
|
||||
|
||||
layer_pixels[dst_idx] = (floating.pixels[src_idx] as f32 * src_a + layer_pixels[dst_idx] as f32 * inv_a) as u8;
|
||||
layer_pixels[dst_idx + 1] = (floating.pixels[src_idx + 1] as f32 * src_a + layer_pixels[dst_idx + 1] as f32 * inv_a) as u8;
|
||||
layer_pixels[dst_idx + 2] = (floating.pixels[src_idx + 2] as f32 * src_a + layer_pixels[dst_idx + 2] as f32 * inv_a) as u8;
|
||||
layer_pixels[dst_idx + 3] = ((src_a * 255.0 + layer_pixels[dst_idx + 3] as f32 * inv_a).min(255.0)) as u8;
|
||||
layer_pixels[dst_idx] = (floating.pixels[src_idx] as f32 * src_a
|
||||
+ layer_pixels[dst_idx] as f32 * inv_a)
|
||||
as u8;
|
||||
layer_pixels[dst_idx + 1] = (floating.pixels[src_idx + 1] as f32 * src_a
|
||||
+ layer_pixels[dst_idx + 1] as f32 * inv_a)
|
||||
as u8;
|
||||
layer_pixels[dst_idx + 2] = (floating.pixels[src_idx + 2] as f32 * src_a
|
||||
+ layer_pixels[dst_idx + 2] as f32 * inv_a)
|
||||
as u8;
|
||||
layer_pixels[dst_idx + 3] =
|
||||
((src_a * 255.0 + layer_pixels[dst_idx + 3] as f32 * inv_a).min(255.0)) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Places clipboard pixels as a floating transform centered in the current canvas.
|
||||
///
|
||||
/// **Arguments:** `pixels`, `width`, and `height` describe RGBA clipboard content; `canvas_w` and
|
||||
/// `canvas_h` describe the destination. **Returns:** A transform clipped only during final apply,
|
||||
/// allowing oversized clipboard images to remain movable. **Side Effects / Dependencies:** None.
|
||||
pub fn centered_transform(
|
||||
pixels: Vec<u8>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
canvas_w: u32,
|
||||
canvas_h: u32,
|
||||
) -> Option<SelectionTransform> {
|
||||
if width == 0 || height == 0 || pixels.len() != (width * height * 4) as usize {
|
||||
return None;
|
||||
}
|
||||
Some(SelectionTransform {
|
||||
pixels,
|
||||
width,
|
||||
height,
|
||||
pos: iced::Vector::new(
|
||||
(canvas_w as f32 - width as f32) * 0.5,
|
||||
(canvas_h as f32 - height as f32) * 0.5,
|
||||
),
|
||||
size: iced::Vector::new(width as f32, height as f32),
|
||||
rotation: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,9 +14,17 @@ pub struct CropState {
|
||||
pub drag_start: Option<(f32, f32)>,
|
||||
/// Whether crop overlay is active
|
||||
pub active: bool,
|
||||
/// Canvas dimensions used to clamp the crop and restore a valid lifecycle.
|
||||
pub canvas_size: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
impl CropState {
|
||||
/// Starts a crop constrained to the supplied canvas dimensions.
|
||||
pub fn start_drag_clamped(&mut self, x: f32, y: f32, width: u32, height: u32) {
|
||||
self.canvas_size = Some((width, height));
|
||||
self.start_drag(x.clamp(0.0, width as f32), y.clamp(0.0, height as f32));
|
||||
}
|
||||
|
||||
/// Begin a new crop drag at the given canvas position.
|
||||
pub fn start_drag(&mut self, x: f32, y: f32) {
|
||||
self.is_dragging = true;
|
||||
@@ -28,6 +36,9 @@ impl CropState {
|
||||
/// Update the crop rectangle during a drag.
|
||||
pub fn update_drag(&mut self, x: f32, y: f32) {
|
||||
if let Some((start_x, start_y)) = self.drag_start {
|
||||
let (x, y) = self.canvas_size.map_or((x, y), |(width, height)| {
|
||||
(x.clamp(0.0, width as f32), y.clamp(0.0, height as f32))
|
||||
});
|
||||
let min_x = start_x.min(x);
|
||||
let min_y = start_y.min(y);
|
||||
let w = (x - start_x).abs();
|
||||
@@ -62,5 +73,6 @@ impl CropState {
|
||||
self.rect = None;
|
||||
self.is_dragging = false;
|
||||
self.drag_start = None;
|
||||
self.canvas_size = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
|
||||
pub mod clipboard;
|
||||
pub mod crop;
|
||||
pub mod state;
|
||||
pub mod transform;
|
||||
|
||||
pub use crop::CropState;
|
||||
pub use state::SelectionMode;
|
||||
pub use transform::{SelectionTransform, TransformHandle};
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Pure selection-mask state operations used by the Iced GUI.
|
||||
//!
|
||||
//! **Purpose:** Keeps selection combination, bounds, and irregular fill extraction independent
|
||||
//! from widgets and the engine implementation. **Logic & Workflow:** New masks are combined with
|
||||
//! the previous mask according to the active mode, then scanned for exact non-zero bounds and
|
||||
//! horizontal spans. **Side Effects / Dependencies:** None; all functions are deterministic and
|
||||
//! operate only on caller-owned slices.
|
||||
|
||||
/// Controls how a newly created selection modifies the existing selection mask.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum SelectionMode {
|
||||
/// Replace the old selection with the new mask.
|
||||
#[default]
|
||||
Replace,
|
||||
/// Add newly selected coverage to existing coverage.
|
||||
Add,
|
||||
/// Remove newly selected coverage from existing coverage.
|
||||
Subtract,
|
||||
/// Keep only coverage shared by both masks.
|
||||
Intersect,
|
||||
}
|
||||
|
||||
/// Combines a newly generated mask with an optional previous mask.
|
||||
///
|
||||
/// **Arguments:** `previous` is the mask active before the operation, `new_mask` is the engine
|
||||
/// result, and `mode` selects replace/add/subtract/intersect semantics. **Returns:** A mask with
|
||||
/// the same length as `new_mask`. **Side Effects / Dependencies:** None.
|
||||
pub fn combine_masks(previous: Option<&[u8]>, new_mask: &[u8], mode: SelectionMode) -> Vec<u8> {
|
||||
let Some(previous) = previous.filter(|mask| mask.len() == new_mask.len()) else {
|
||||
return new_mask.to_vec();
|
||||
};
|
||||
new_mask
|
||||
.iter()
|
||||
.zip(previous)
|
||||
.map(|(&new, &old)| match mode {
|
||||
SelectionMode::Replace => new,
|
||||
SelectionMode::Add => old.max(new),
|
||||
SelectionMode::Subtract => old.saturating_sub(new),
|
||||
SelectionMode::Intersect => old.min(new),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Finds the exact non-zero bounding rectangle of a selection mask.
|
||||
///
|
||||
/// **Arguments:** `mask` is one alpha byte per pixel and `width`/`height` are its dimensions.
|
||||
/// **Returns:** `(x, y, width, height)`, or `None` for invalid/empty masks. **Side Effects:** None.
|
||||
pub fn mask_bounds(mask: &[u8], width: u32, height: u32) -> Option<(u32, u32, u32, u32)> {
|
||||
if width == 0 || height == 0 || mask.len() != (width * height) as usize {
|
||||
return None;
|
||||
}
|
||||
let mut min_x = width;
|
||||
let mut min_y = height;
|
||||
let mut max_x = 0;
|
||||
let mut max_y = 0;
|
||||
let mut found = false;
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
if mask[(y * width + x) as usize] != 0 {
|
||||
min_x = min_x.min(x);
|
||||
min_y = min_y.min(y);
|
||||
max_x = max_x.max(x);
|
||||
max_y = max_y.max(y);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
found.then_some((min_x, min_y, max_x - min_x + 1, max_y - min_y + 1))
|
||||
}
|
||||
|
||||
/// Compresses selected pixels into horizontal spans for efficient irregular overlay filling.
|
||||
///
|
||||
/// **Arguments:** `mask` is one alpha byte per pixel and `width`/`height` are its dimensions.
|
||||
/// **Returns:** Inclusive-exclusive `(x_start, y, x_end)` spans for alpha values above 127.
|
||||
/// **Side Effects / Dependencies:** None.
|
||||
pub fn selected_spans(mask: &[u8], width: u32, height: u32) -> Vec<(u32, u32, u32)> {
|
||||
if width == 0 || height == 0 || mask.len() != (width * height) as usize {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut spans = Vec::new();
|
||||
for y in 0..height {
|
||||
let mut x = 0;
|
||||
while x < width {
|
||||
while x < width && mask[(y * width + x) as usize] <= 127 {
|
||||
x += 1;
|
||||
}
|
||||
let start = x;
|
||||
while x < width && mask[(y * width + x) as usize] > 127 {
|
||||
x += 1;
|
||||
}
|
||||
if start < x {
|
||||
spans.push((start, y, x));
|
||||
}
|
||||
}
|
||||
}
|
||||
spans
|
||||
}
|
||||
@@ -70,7 +70,11 @@ impl SelectionTransform {
|
||||
|
||||
if src_idx + 3 < comp.len() && dst_idx + 3 < pixels.len() {
|
||||
let mask_idx = ((src_y * canvas_w + src_x) * 4 + 3) as usize;
|
||||
let alpha = if mask_idx < mask.len() { mask[mask_idx] } else { 0 };
|
||||
let alpha = if mask_idx < mask.len() {
|
||||
mask[mask_idx]
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
pixels[dst_idx] = comp[src_idx];
|
||||
pixels[dst_idx + 1] = comp[src_idx + 1];
|
||||
@@ -106,6 +110,102 @@ impl SelectionTransform {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.pixels.is_empty() || self.width == 0 || self.height == 0
|
||||
}
|
||||
|
||||
/// Hit-tests transform handles in canvas coordinates with screen-constant targets.
|
||||
///
|
||||
/// **Arguments:** `x`/`y` are canvas coordinates and `zoom` is the viewport scale.
|
||||
/// **Returns:** The nearest resize/rotate/move handle or `None`. **Side Effects:** None.
|
||||
pub fn hit_test(&self, x: f32, y: f32, zoom: f32) -> TransformHandle {
|
||||
let half = TransformHandle::HANDLE_SIZE / zoom.max(0.01) * 0.5;
|
||||
let points = [
|
||||
(TransformHandle::TopLeft, self.pos.x, self.pos.y),
|
||||
(
|
||||
TransformHandle::TopRight,
|
||||
self.pos.x + self.size.x,
|
||||
self.pos.y,
|
||||
),
|
||||
(
|
||||
TransformHandle::BottomLeft,
|
||||
self.pos.x,
|
||||
self.pos.y + self.size.y,
|
||||
),
|
||||
(
|
||||
TransformHandle::BottomRight,
|
||||
self.pos.x + self.size.x,
|
||||
self.pos.y + self.size.y,
|
||||
),
|
||||
(
|
||||
TransformHandle::Top,
|
||||
self.pos.x + self.size.x * 0.5,
|
||||
self.pos.y,
|
||||
),
|
||||
(
|
||||
TransformHandle::Bottom,
|
||||
self.pos.x + self.size.x * 0.5,
|
||||
self.pos.y + self.size.y,
|
||||
),
|
||||
(
|
||||
TransformHandle::Left,
|
||||
self.pos.x,
|
||||
self.pos.y + self.size.y * 0.5,
|
||||
),
|
||||
(
|
||||
TransformHandle::Right,
|
||||
self.pos.x + self.size.x,
|
||||
self.pos.y + self.size.y * 0.5,
|
||||
),
|
||||
];
|
||||
let rotate_x = self.pos.x + self.size.x * 0.5;
|
||||
let rotate_y = self.pos.y - TransformHandle::ROTATE_OFFSET / zoom.max(0.01);
|
||||
if (x - rotate_x).hypot(y - rotate_y) <= half {
|
||||
return TransformHandle::Rotate;
|
||||
}
|
||||
for (handle, hx, hy) in points {
|
||||
if (x - hx).abs() <= half && (y - hy).abs() <= half {
|
||||
return handle;
|
||||
}
|
||||
}
|
||||
if x >= self.pos.x
|
||||
&& x <= self.pos.x + self.size.x
|
||||
&& y >= self.pos.y
|
||||
&& y <= self.pos.y + self.size.y
|
||||
{
|
||||
TransformHandle::Move
|
||||
} else {
|
||||
TransformHandle::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Enforces positive finite geometry and optionally preserves source aspect ratio.
|
||||
///
|
||||
/// **Arguments:** `lock_aspect` applies the source pixel ratio when true. **Returns:** Nothing.
|
||||
/// **Side Effects:** Mutates only transform position/size and prevents inverted/NaN geometry.
|
||||
pub fn sanitize_geometry(&mut self, lock_aspect: bool) {
|
||||
if !self.pos.x.is_finite() || !self.pos.y.is_finite() {
|
||||
self.pos = Vector::ZERO;
|
||||
}
|
||||
if !self.size.x.is_finite() || !self.size.y.is_finite() {
|
||||
self.size = Vector::new(self.width.max(1) as f32, self.height.max(1) as f32);
|
||||
}
|
||||
if self.size.x < 0.0 {
|
||||
self.pos.x += self.size.x;
|
||||
self.size.x = -self.size.x;
|
||||
}
|
||||
if self.size.y < 0.0 {
|
||||
self.pos.y += self.size.y;
|
||||
self.size.y = -self.size.y;
|
||||
}
|
||||
self.size.x = self.size.x.max(1.0);
|
||||
self.size.y = self.size.y.max(1.0);
|
||||
if lock_aspect && self.width > 0 && self.height > 0 {
|
||||
let ratio = self.width as f32 / self.height as f32;
|
||||
if self.size.x / self.size.y > ratio {
|
||||
self.size.y = self.size.x / ratio;
|
||||
} else {
|
||||
self.size.x = self.size.y * ratio;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle for interacting with a selection transform.
|
||||
|
||||
@@ -36,6 +36,9 @@ pub struct AppSettings {
|
||||
/// Saved dock layout profiles.
|
||||
#[serde(default)]
|
||||
pub dock_profiles: Vec<DockProfile>,
|
||||
/// Selected application color preset.
|
||||
#[serde(default)]
|
||||
pub theme_preset: crate::theme::ThemePreset,
|
||||
}
|
||||
|
||||
/// Tool-specific settings that persist between sessions.
|
||||
@@ -98,21 +101,42 @@ pub struct ToolSettings {
|
||||
pub last_active_tool: String,
|
||||
}
|
||||
|
||||
fn default_vector_fill() -> bool { true }
|
||||
fn default_vector_points() -> u32 { 5 }
|
||||
fn default_vector_sides() -> u32 { 6 }
|
||||
fn default_spray_particle_size() -> f32 { 4.0 }
|
||||
fn default_spray_density() -> u32 { 80 }
|
||||
fn default_contiguous() -> bool { true }
|
||||
fn default_anti_alias() -> bool { true }
|
||||
fn default_text_font() -> String { "Arial".to_string() }
|
||||
fn default_apply_to_new_layer() -> bool { true }
|
||||
fn default_vector_fill() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_vector_points() -> u32 {
|
||||
5
|
||||
}
|
||||
fn default_vector_sides() -> u32 {
|
||||
6
|
||||
}
|
||||
fn default_spray_particle_size() -> f32 {
|
||||
4.0
|
||||
}
|
||||
fn default_spray_density() -> u32 {
|
||||
80
|
||||
}
|
||||
fn default_contiguous() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_anti_alias() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_text_font() -> String {
|
||||
"Arial".to_string()
|
||||
}
|
||||
fn default_apply_to_new_layer() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Panel layout positions and visibility.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PanelLayout {
|
||||
/// Left sidebar width in pixels.
|
||||
pub sidebar_width: f32,
|
||||
/// Whether the fixed toolbox sidebar shows labels as well as icons.
|
||||
#[serde(default)]
|
||||
pub sidebar_expanded: bool,
|
||||
/// Right tool settings panel width in pixels.
|
||||
pub tool_settings_width: f32,
|
||||
/// Which panels are visible.
|
||||
@@ -136,6 +160,7 @@ impl Default for AppSettings {
|
||||
panel_layout: PanelLayout::default(),
|
||||
window: WindowSettings::default(),
|
||||
dock_profiles: Vec::new(),
|
||||
theme_preset: crate::theme::ThemePreset::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,6 +207,7 @@ impl Default for PanelLayout {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sidebar_width: 36.0,
|
||||
sidebar_expanded: false,
|
||||
tool_settings_width: 200.0,
|
||||
visible_panels: vec![
|
||||
"Brushes & Tips".to_string(),
|
||||
@@ -223,15 +249,13 @@ impl AppSettings {
|
||||
let path = Self::file_path();
|
||||
if path.exists() {
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(content) => {
|
||||
match serde_json::from_str(&content) {
|
||||
Ok(content) => match serde_json::from_str(&content) {
|
||||
Ok(settings) => settings,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to parse settings: {}, using defaults", e);
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::warn!("Failed to read settings: {}, using defaults", e);
|
||||
Self::default()
|
||||
@@ -255,8 +279,7 @@ impl AppSettings {
|
||||
let content = serde_json::to_string_pretty(self)
|
||||
.map_err(|e| format!("Failed to serialize settings: {}", e))?;
|
||||
|
||||
std::fs::write(&path, content)
|
||||
.map_err(|e| format!("Failed to write settings: {}", e))?;
|
||||
std::fs::write(&path, content).map_err(|e| format!("Failed to write settings: {}", e))?;
|
||||
|
||||
log::info!("Settings saved to {}", path.display());
|
||||
Ok(())
|
||||
@@ -282,3 +305,27 @@ impl AppSettings {
|
||||
tool_state.brush_hardness = self.tool_settings.brush_hardness;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AppSettings;
|
||||
use crate::theme::ThemePreset;
|
||||
|
||||
/// Proves settings written before Cycle 1 acquire safe theme and sidebar defaults.
|
||||
#[test]
|
||||
fn legacy_settings_default_new_cycle_one_fields() {
|
||||
let mut legacy = serde_json::to_value(AppSettings::default()).expect("serialize defaults");
|
||||
let object = legacy.as_object_mut().expect("settings object");
|
||||
object.remove("theme_preset");
|
||||
object
|
||||
.get_mut("panel_layout")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
.expect("panel layout object")
|
||||
.remove("sidebar_expanded");
|
||||
|
||||
let settings: AppSettings =
|
||||
serde_json::from_value(legacy).expect("deserialize legacy settings");
|
||||
assert_eq!(settings.theme_preset, ThemePreset::Photopea);
|
||||
assert!(!settings.panel_layout.sidebar_expanded);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,23 @@ pub fn sync_shape_from_tool(app: &mut HcieIcedApp) {
|
||||
Some(i) => i,
|
||||
None => return,
|
||||
};
|
||||
if !app.documents[app.active_doc]
|
||||
.engine
|
||||
.active_vector_shapes()
|
||||
.is_some_and(|shapes| shape_idx < shapes.len())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot for undo (before first modification in this edit session)
|
||||
if app.vector_shapes_snapshot.is_none() {
|
||||
let snapshot = app.documents[app.active_doc].engine.active_vector_shapes();
|
||||
app.vector_shapes_snapshot = snapshot;
|
||||
let doc = &app.documents[app.active_doc];
|
||||
if let Some(shapes) = doc.engine.active_vector_shapes() {
|
||||
app.vector_shapes_snapshot = Some(crate::vector_edit::VectorEditSnapshot {
|
||||
layer_id: doc.engine.active_layer_id(),
|
||||
shapes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let layer_id = app.documents[app.active_doc].engine.active_layer_id();
|
||||
@@ -49,12 +61,16 @@ pub fn sync_shape_from_tool(app: &mut HcieIcedApp) {
|
||||
if let Some(shapes) = app.documents[app.active_doc].engine.active_vector_shapes() {
|
||||
if let Some(shape) = shapes.get(shape_idx) {
|
||||
if shape.fill_color().is_some() || shape.is_filled() != ts.vector_fill {
|
||||
app.documents[app.active_doc]
|
||||
.engine
|
||||
.set_vector_shape_fill(layer_id, shape_idx, ts.vector_fill);
|
||||
app.documents[app.active_doc].engine.set_vector_shape_fill(
|
||||
layer_id,
|
||||
shape_idx,
|
||||
ts.vector_fill,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
app.documents[app.active_doc].modified = true;
|
||||
app.documents[app.active_doc].engine.mark_composite_dirty();
|
||||
|
||||
// Opacity
|
||||
app.documents[app.active_doc]
|
||||
@@ -107,9 +123,6 @@ pub fn sync_shape_from_tool(app: &mut HcieIcedApp) {
|
||||
/// Called when the user clicks/selects a shape in VectorSelect mode
|
||||
/// so the properties panel and tool state reflect the shape's actual values.
|
||||
pub fn sync_tool_from_shape(app: &mut HcieIcedApp) {
|
||||
if app.tool_state.active_tool != Tool::VectorSelect {
|
||||
return;
|
||||
}
|
||||
let shape_idx = match app.documents[app.active_doc].selected_vector_shape {
|
||||
Some(i) => i,
|
||||
None => return,
|
||||
@@ -156,6 +169,7 @@ pub fn sync_tool_from_shape(app: &mut HcieIcedApp) {
|
||||
if let Some(fc) = shape.fill_color() {
|
||||
app.bg_color = fc;
|
||||
}
|
||||
app.colors_dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::app::{Message, ToolState};
|
||||
use crate::panels::styles;
|
||||
use crate::theme::ThemeColors;
|
||||
use hcie_engine_api::Tool;
|
||||
use iced::widget::{button, column, container, row, svg, text};
|
||||
use iced::widget::{button, column, container, row, scrollable, svg, text};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Tool slot with sub-tools — matches egui's TOOL_SLOTS structure.
|
||||
@@ -28,36 +28,121 @@ pub struct ToolSlot {
|
||||
/// All tool slots matching egui's TOOL_SLOTS structure exactly.
|
||||
/// Each slot can have multiple tools (sub-tools shown on click).
|
||||
pub const TOOL_SLOTS: &[ToolSlot] = &[
|
||||
ToolSlot { tools: &[Tool::Move], label: "Move", icon: "icons/move.svg" },
|
||||
ToolSlot { tools: &[Tool::VectorSelect], label: "Vector Select", icon: "icons/vector_select.svg" },
|
||||
ToolSlot { tools: &[Tool::Select], label: "Rectangle Select", icon: "icons/rect_select.svg" },
|
||||
ToolSlot { tools: &[Tool::Lasso, Tool::PolygonSelect], label: "Lasso", icon: "icons/lasso.svg" },
|
||||
ToolSlot { tools: &[Tool::MagicWand], label: "Magic Wand", icon: "icons/magic_wand.svg" },
|
||||
ToolSlot { tools: &[Tool::SmartSelect, Tool::VisionSelect], label: "Smart Select", icon: "icons/smart_select.svg" },
|
||||
ToolSlot { tools: &[Tool::Crop], label: "Crop", icon: "icons/crop.svg" },
|
||||
ToolSlot { tools: &[Tool::Eyedropper], label: "Eyedropper", icon: "icons/eyedropper.svg" },
|
||||
ToolSlot { tools: &[Tool::Brush], label: "Brush", icon: "icons/brush.svg" },
|
||||
ToolSlot { tools: &[Tool::Pen], label: "Pen", icon: "icons/pen.svg" },
|
||||
ToolSlot { tools: &[Tool::Spray], label: "Spray", icon: "icons/spray.svg" },
|
||||
ToolSlot { tools: &[Tool::Eraser], label: "Eraser", icon: "icons/eraser.svg" },
|
||||
ToolSlot { tools: &[Tool::FloodFill], label: "Flood Fill", icon: "icons/bucket.svg" },
|
||||
ToolSlot { tools: &[Tool::Gradient], label: "Gradient", icon: "icons/gradient.svg" },
|
||||
ToolSlot { tools: &[Tool::Text], label: "Text", icon: "icons/text.svg" },
|
||||
ToolSlot { tools: &[Tool::VectorLine], label: "Vector Line", icon: "icons/line.svg" },
|
||||
ToolSlot { tools: &[Tool::VectorRect], label: "Vector Rectangle", icon: "icons/vector_rect.svg" },
|
||||
ToolSlot { tools: &[Tool::VectorCircle], label: "Vector Circle", icon: "icons/circle.svg" },
|
||||
ToolSlot {
|
||||
tools: &[Tool::Move],
|
||||
label: "Move",
|
||||
icon: "icons/move.svg",
|
||||
},
|
||||
ToolSlot {
|
||||
tools: &[Tool::VectorSelect],
|
||||
label: "Vector Select",
|
||||
icon: "icons/vector_select.svg",
|
||||
},
|
||||
ToolSlot {
|
||||
tools: &[Tool::Select],
|
||||
label: "Rectangle Select",
|
||||
icon: "icons/rect_select.svg",
|
||||
},
|
||||
ToolSlot {
|
||||
tools: &[Tool::Lasso, Tool::PolygonSelect],
|
||||
label: "Lasso",
|
||||
icon: "icons/lasso.svg",
|
||||
},
|
||||
ToolSlot {
|
||||
tools: &[Tool::MagicWand],
|
||||
label: "Magic Wand",
|
||||
icon: "icons/magic_wand.svg",
|
||||
},
|
||||
ToolSlot {
|
||||
tools: &[Tool::SmartSelect, Tool::VisionSelect],
|
||||
label: "Smart Select",
|
||||
icon: "icons/smart_select.svg",
|
||||
},
|
||||
ToolSlot {
|
||||
tools: &[Tool::Crop],
|
||||
label: "Crop",
|
||||
icon: "icons/crop.svg",
|
||||
},
|
||||
ToolSlot {
|
||||
tools: &[Tool::Eyedropper],
|
||||
label: "Eyedropper",
|
||||
icon: "icons/eyedropper.svg",
|
||||
},
|
||||
ToolSlot {
|
||||
tools: &[Tool::Brush],
|
||||
label: "Brush",
|
||||
icon: "icons/brush.svg",
|
||||
},
|
||||
ToolSlot {
|
||||
tools: &[Tool::Pen],
|
||||
label: "Pen",
|
||||
icon: "icons/pen.svg",
|
||||
},
|
||||
ToolSlot {
|
||||
tools: &[Tool::Spray],
|
||||
label: "Spray",
|
||||
icon: "icons/spray.svg",
|
||||
},
|
||||
ToolSlot {
|
||||
tools: &[Tool::Eraser],
|
||||
label: "Eraser",
|
||||
icon: "icons/eraser.svg",
|
||||
},
|
||||
ToolSlot {
|
||||
tools: &[Tool::FloodFill],
|
||||
label: "Flood Fill",
|
||||
icon: "icons/bucket.svg",
|
||||
},
|
||||
ToolSlot {
|
||||
tools: &[Tool::Gradient],
|
||||
label: "Gradient",
|
||||
icon: "icons/gradient.svg",
|
||||
},
|
||||
ToolSlot {
|
||||
tools: &[Tool::Text],
|
||||
label: "Text",
|
||||
icon: "icons/text.svg",
|
||||
},
|
||||
ToolSlot {
|
||||
tools: &[Tool::VectorLine],
|
||||
label: "Vector Line",
|
||||
icon: "icons/line.svg",
|
||||
},
|
||||
ToolSlot {
|
||||
tools: &[Tool::VectorRect],
|
||||
label: "Vector Rectangle",
|
||||
icon: "icons/vector_rect.svg",
|
||||
},
|
||||
ToolSlot {
|
||||
tools: &[Tool::VectorCircle],
|
||||
label: "Vector Circle",
|
||||
icon: "icons/circle.svg",
|
||||
},
|
||||
ToolSlot {
|
||||
tools: &[
|
||||
Tool::VectorArrow, Tool::VectorStar, Tool::VectorPolygon,
|
||||
Tool::VectorRhombus, Tool::VectorCylinder, Tool::VectorHeart,
|
||||
Tool::VectorBubble, Tool::VectorGear, Tool::VectorCross,
|
||||
Tool::VectorCrescent, Tool::VectorBolt, Tool::VectorArrow4,
|
||||
Tool::VectorArrow,
|
||||
Tool::VectorStar,
|
||||
Tool::VectorPolygon,
|
||||
Tool::VectorRhombus,
|
||||
Tool::VectorCylinder,
|
||||
Tool::VectorHeart,
|
||||
Tool::VectorBubble,
|
||||
Tool::VectorGear,
|
||||
Tool::VectorCross,
|
||||
Tool::VectorCrescent,
|
||||
Tool::VectorBolt,
|
||||
Tool::VectorArrow4,
|
||||
],
|
||||
label: "Vector Shapes",
|
||||
icon: "icons/star.svg",
|
||||
},
|
||||
ToolSlot {
|
||||
tools: &[Tool::SpotRemoval, Tool::RedEyeRemoval, Tool::SmartPatch, Tool::AiObjectRemoval],
|
||||
tools: &[
|
||||
Tool::SpotRemoval,
|
||||
Tool::RedEyeRemoval,
|
||||
Tool::SmartPatch,
|
||||
Tool::AiObjectRemoval,
|
||||
],
|
||||
label: "Retouch",
|
||||
icon: "icons/spot.svg",
|
||||
},
|
||||
@@ -82,21 +167,55 @@ pub fn view<'a>(
|
||||
// Tool buttons — single column or 2-column grid depending on expanded state
|
||||
let mut tool_list = column![].spacing(1);
|
||||
if expanded {
|
||||
// 2-column layout: show tools in pairs
|
||||
// The open subtool slot owns a full row: 34px parent plus 34px popup.
|
||||
// Other slots continue to use the regular two-column grid.
|
||||
let mut row_children = iced::widget::Row::new().spacing(1);
|
||||
let mut row_items = 0;
|
||||
for (idx, slot) in TOOL_SLOTS.iter().enumerate() {
|
||||
let btn = tool_button(slot, tool_state, colors, idx, active_slot, sub_tools_open, slot_last_used);
|
||||
row_children = row_children.push(btn);
|
||||
// Every 2 tools, start a new row
|
||||
if idx % 2 == 1 || idx == TOOL_SLOTS.len() - 1 {
|
||||
let btn = tool_button(
|
||||
slot,
|
||||
tool_state,
|
||||
colors,
|
||||
idx,
|
||||
active_slot,
|
||||
sub_tools_open,
|
||||
slot_last_used,
|
||||
);
|
||||
|
||||
let popup_row = sub_tools_open && active_slot == idx && slot.tools.len() > 1;
|
||||
if popup_row {
|
||||
if row_items > 0 {
|
||||
tool_list = tool_list.push(row_children);
|
||||
row_children = iced::widget::Row::new().spacing(1);
|
||||
row_items = 0;
|
||||
}
|
||||
tool_list = tool_list.push(btn);
|
||||
continue;
|
||||
}
|
||||
|
||||
row_children = row_children.push(btn);
|
||||
row_items += 1;
|
||||
if row_items == 2 {
|
||||
tool_list = tool_list.push(row_children);
|
||||
row_children = iced::widget::Row::new().spacing(1);
|
||||
row_items = 0;
|
||||
}
|
||||
}
|
||||
if row_items > 0 {
|
||||
tool_list = tool_list.push(row_children);
|
||||
}
|
||||
} else {
|
||||
// Single column layout (default)
|
||||
for (idx, slot) in TOOL_SLOTS.iter().enumerate() {
|
||||
let btn = tool_button(slot, tool_state, colors, idx, active_slot, sub_tools_open, slot_last_used);
|
||||
let btn = tool_button(
|
||||
slot,
|
||||
tool_state,
|
||||
colors,
|
||||
idx,
|
||||
active_slot,
|
||||
sub_tools_open,
|
||||
slot_last_used,
|
||||
);
|
||||
tool_list = tool_list.push(btn);
|
||||
}
|
||||
}
|
||||
@@ -107,40 +226,63 @@ pub fn view<'a>(
|
||||
let bg = bg_color;
|
||||
|
||||
let fg_swatch = container(text(""))
|
||||
.width(18)
|
||||
.height(18)
|
||||
.width(24)
|
||||
.height(24)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
fg[0] as f32 / 255.0, fg[1] as f32 / 255.0, fg[2] as f32 / 255.0, fg[3] as f32 / 255.0,
|
||||
fg[0] as f32 / 255.0,
|
||||
fg[1] as f32 / 255.0,
|
||||
fg[2] as f32 / 255.0,
|
||||
fg[3] as f32 / 255.0,
|
||||
))),
|
||||
border: iced::Border::default().rounded(2).color(colors.border_high).width(1),
|
||||
border: iced::Border::default()
|
||||
.rounded(2)
|
||||
.color(colors.border_high)
|
||||
.width(2),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let bg_swatch = container(text(""))
|
||||
.width(18)
|
||||
.height(18)
|
||||
.width(24)
|
||||
.height(24)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
bg[0] as f32 / 255.0, bg[1] as f32 / 255.0, bg[2] as f32 / 255.0, bg[3] as f32 / 255.0,
|
||||
bg[0] as f32 / 255.0,
|
||||
bg[1] as f32 / 255.0,
|
||||
bg[2] as f32 / 255.0,
|
||||
bg[3] as f32 / 255.0,
|
||||
))),
|
||||
border: iced::Border::default().rounded(2).color(colors.border_high).width(1),
|
||||
border: iced::Border::default()
|
||||
.rounded(2)
|
||||
.color(colors.border_high)
|
||||
.width(2),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let fg_color_val = *fg_color;
|
||||
let fg_clickable = iced::widget::tooltip(
|
||||
iced::widget::mouse_area(fg_swatch).on_press(Message::FgColorChanged(fg_color_val)),
|
||||
text("Foreground color - click to apply").size(11),
|
||||
iced::widget::tooltip::Position::Right,
|
||||
);
|
||||
|
||||
// Make the background swatch clickable so a click promotes the background
|
||||
// color to the foreground (egui: toolbox.rs:355-358).
|
||||
let bg_color_val = *bg_color;
|
||||
let bg_clickable = iced::widget::mouse_area(bg_swatch)
|
||||
.on_press(Message::FgColorChanged(bg_color_val));
|
||||
let bg_clickable = iced::widget::tooltip(
|
||||
iced::widget::mouse_area(bg_swatch).on_press(Message::FgColorChanged(bg_color_val)),
|
||||
text("Background color - click to promote to foreground").size(11),
|
||||
iced::widget::tooltip::Position::Right,
|
||||
);
|
||||
|
||||
// Small reset-to-defaults button (black/white) below the swatches
|
||||
// (egui: toolbox.rs:351-354 resets fg=black/bg=white on a bottom-left click).
|
||||
let reset_btn = button(text("D").size(7).color(colors.text_secondary))
|
||||
let reset_btn = iced::widget::tooltip(
|
||||
button(text("D").size(10).color(colors.text_secondary))
|
||||
.on_press(Message::ResetColors)
|
||||
.padding(1)
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
.padding([1, 3])
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_hover)),
|
||||
text_color: colors.text_primary,
|
||||
@@ -148,54 +290,78 @@ pub fn view<'a>(
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.0, 0.0, 0.0, 0.0,
|
||||
))),
|
||||
text_color: colors.text_secondary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
text("Reset foreground/background colors").size(11),
|
||||
iced::widget::tooltip::Position::Right,
|
||||
);
|
||||
|
||||
// Photopea-style: fg at top-left, bg at bottom-right (diagonal overlap)
|
||||
// with a swap arrow button between them
|
||||
let color_section = container(
|
||||
iced::widget::Stack::new()
|
||||
.push(container(bg_clickable).padding(iced::Padding {
|
||||
top: 10.0,
|
||||
bottom: 0.0,
|
||||
left: 8.0,
|
||||
right: 0.0,
|
||||
}))
|
||||
.push(container(fg_clickable).padding(iced::Padding {
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
left: 0.0,
|
||||
right: 0.0,
|
||||
}))
|
||||
.push(
|
||||
container(fg_swatch)
|
||||
.padding(iced::Padding { top: 0.0, bottom: 0.0, left: 4.0, right: 0.0 })
|
||||
)
|
||||
.push(
|
||||
container(bg_clickable)
|
||||
.padding(iced::Padding { top: 10.0, bottom: 0.0, left: 16.0, right: 0.0 })
|
||||
)
|
||||
.push(
|
||||
container(
|
||||
button(text("⇄").size(8).color(colors.text_primary))
|
||||
container(iced::widget::tooltip(
|
||||
button(text("⇄").size(10).color(colors.text_primary))
|
||||
.on_press(Message::SwapColors)
|
||||
.padding(1)
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_hover)),
|
||||
iced::widget::button::Status::Hovered => {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(
|
||||
colors.bg_hover,
|
||||
)),
|
||||
text_color: colors.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
background: Some(iced::Background::Color(
|
||||
iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0),
|
||||
)),
|
||||
text_color: colors.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
.padding(iced::Padding { top: 6.0, bottom: 0.0, left: 10.0, right: 0.0 })
|
||||
)
|
||||
},
|
||||
),
|
||||
text("Swap foreground/background colors").size(11),
|
||||
iced::widget::tooltip::Position::Right,
|
||||
))
|
||||
.padding(iced::Padding {
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
left: 21.0,
|
||||
right: 0.0,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.padding([4, 2])
|
||||
.width(sidebar_width)
|
||||
.height(32);
|
||||
.height(40);
|
||||
|
||||
// Separator line
|
||||
let sep_style = move |_theme: &iced::Theme| iced::widget::container::Style {
|
||||
@@ -209,8 +375,8 @@ pub fn view<'a>(
|
||||
.on_press(Message::SidebarToggleExpanded)
|
||||
.padding(2)
|
||||
.width(sidebar_width)
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_hover)),
|
||||
text_color: colors.text_primary,
|
||||
@@ -218,17 +384,21 @@ pub fn view<'a>(
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.0, 0.0, 0.0, 0.0,
|
||||
))),
|
||||
text_color: colors.text_secondary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
let sidebar = column![
|
||||
container(tool_list).padding([2, 0]),
|
||||
container(text("").height(1)).width(Length::Fill).style(sep_style),
|
||||
scrollable(container(tool_list).padding([2, 0])).height(Length::Fill),
|
||||
container(text("").height(1))
|
||||
.width(Length::Fill)
|
||||
.style(sep_style),
|
||||
color_section,
|
||||
container(reset_btn).padding([2, 4]),
|
||||
container(toggle_btn).padding([2, 0]),
|
||||
@@ -266,22 +436,33 @@ fn tool_button<'a>(
|
||||
let represented_tool = slot.tools.get(last_idx).copied().unwrap_or(slot.tools[0]);
|
||||
|
||||
// Try to load SVG icon - search multiple paths
|
||||
let represented_icon = tool_icon(represented_tool);
|
||||
let icon_paths = [
|
||||
std::path::PathBuf::from(slot.icon),
|
||||
std::path::Path::new("hcie-iced-app/assets").join(slot.icon),
|
||||
std::path::PathBuf::from(format!("/mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-iced-app/assets/{}", slot.icon)),
|
||||
std::path::PathBuf::from(represented_icon),
|
||||
std::path::Path::new("hcie-iced-app/assets").join(represented_icon),
|
||||
std::path::PathBuf::from(format!(
|
||||
"/mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-iced-app/assets/{}",
|
||||
represented_icon
|
||||
)),
|
||||
];
|
||||
|
||||
let icon_color = if is_active { colors.accent } else { colors.text_primary };
|
||||
let icon_color = if is_active {
|
||||
colors.accent
|
||||
} else {
|
||||
colors.text_primary
|
||||
};
|
||||
|
||||
let icon_element: Element<'_, Message> = if let Some(icon_path) = icon_paths.iter().find(|p| p.exists()) {
|
||||
let icon_element: Element<'_, Message> =
|
||||
if let Some(icon_path) = icon_paths.iter().find(|p| p.exists()) {
|
||||
let handle = svg::Handle::from_path(icon_path);
|
||||
svg(handle)
|
||||
.width(20)
|
||||
.height(20)
|
||||
.style(move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style {
|
||||
color: Some(icon_color),
|
||||
})
|
||||
},
|
||||
)
|
||||
.into()
|
||||
} else {
|
||||
text(slot.label.chars().next().unwrap_or('?').to_string())
|
||||
@@ -300,15 +481,15 @@ fn tool_button<'a>(
|
||||
|
||||
let btn = iced::widget::button(
|
||||
container(icon_element)
|
||||
.width(28)
|
||||
.width(34)
|
||||
.height(28)
|
||||
.center_y(20)
|
||||
.center_x(20)
|
||||
.center_x(20),
|
||||
)
|
||||
.on_press(Message::ToolSlotClicked(slot_idx, primary_tool))
|
||||
.padding(0)
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
|
||||
iced::widget::button::Status::Active | iced::widget::button::Status::Disabled => {
|
||||
if active {
|
||||
iced::widget::button::Style {
|
||||
@@ -319,7 +500,9 @@ fn tool_button<'a>(
|
||||
}
|
||||
} else {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.0, 0.0, 0.0, 0.0,
|
||||
))),
|
||||
border: iced::Border::default(),
|
||||
text_color: c.text_primary,
|
||||
..Default::default()
|
||||
@@ -343,96 +526,236 @@ fn tool_button<'a>(
|
||||
}
|
||||
}
|
||||
}
|
||||
iced::widget::button::Status::Pressed => {
|
||||
iced::widget::button::Style {
|
||||
iced::widget::button::Status::Pressed => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.accent_green)),
|
||||
border: iced::Border::default(),
|
||||
text_color: iced::Color::WHITE,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let popup_affordance: Element<'a, Message> = if has_sub_tools {
|
||||
iced::widget::tooltip(
|
||||
button(text("▸").size(12).color(colors.text_primary))
|
||||
.on_press(Message::SubtoolsToggle(slot_idx))
|
||||
.padding(0)
|
||||
.width(12)
|
||||
.height(14)
|
||||
.style(move |_theme, _status| iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
colors.bg_app.r,
|
||||
colors.bg_app.g,
|
||||
colors.bg_app.b,
|
||||
0.82,
|
||||
))),
|
||||
text_color: colors.text_primary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
}),
|
||||
text(format!("Open {} sub-tools", slot.label)).size(11),
|
||||
iced::widget::tooltip::Position::Right,
|
||||
)
|
||||
.into()
|
||||
} else {
|
||||
iced::widget::Space::with_width(0).into()
|
||||
};
|
||||
|
||||
let btn_element: Element<'a, Message> = iced::widget::Stack::new()
|
||||
.push(btn)
|
||||
.push(container(popup_affordance).padding(iced::Padding {
|
||||
top: 14.0,
|
||||
bottom: 0.0,
|
||||
left: 22.0,
|
||||
right: 0.0,
|
||||
}))
|
||||
.width(34)
|
||||
.height(28)
|
||||
.into();
|
||||
|
||||
// Build the tool button with optional sub-tools popup
|
||||
if show_popup {
|
||||
let mut popup_items = column![].spacing(0);
|
||||
for &tool in slot.tools {
|
||||
let mut popup_grid = column![].spacing(1);
|
||||
let mut popup_row = row![].spacing(1);
|
||||
for (tool_index, &tool) in slot.tools.iter().enumerate() {
|
||||
let tool_name = tool.label();
|
||||
let is_tool_active = tool == tool_state.active_tool;
|
||||
let c = colors;
|
||||
|
||||
let item = button(
|
||||
container(
|
||||
row![
|
||||
text(tool_name).size(11).width(Length::Fill),
|
||||
text(tool_shortcut(tool)).size(10),
|
||||
]
|
||||
.spacing(8)
|
||||
)
|
||||
.width(150)
|
||||
.padding([4, 8])
|
||||
)
|
||||
let popup_icon: Element<'_, Message> =
|
||||
svg(svg::Handle::from_path(resolve_icon_path(tool_icon(tool))))
|
||||
.width(18)
|
||||
.height(18)
|
||||
.style(move |_theme, _status| svg::Style {
|
||||
color: Some(if is_tool_active {
|
||||
iced::Color::WHITE
|
||||
} else {
|
||||
c.text_primary
|
||||
}),
|
||||
})
|
||||
.into();
|
||||
|
||||
let item = button(container(popup_icon).width(32).height(28).center(18))
|
||||
.on_press(Message::ToolSelected(tool))
|
||||
.padding(0)
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
.width(32)
|
||||
.height(28)
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.menu_hover)),
|
||||
text_color: c.menu_text,
|
||||
border: iced::Border::default(),
|
||||
background: Some(iced::Background::Color(if is_tool_active {
|
||||
c.accent_green
|
||||
} else {
|
||||
c.bg_hover
|
||||
})),
|
||||
text_color: c.text_primary,
|
||||
border: iced::Border::default()
|
||||
.color(if is_tool_active {
|
||||
c.accent
|
||||
} else {
|
||||
c.border_high
|
||||
})
|
||||
.width(if is_tool_active { 1 } else { 0 }),
|
||||
..Default::default()
|
||||
},
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(c.bg_hover)),
|
||||
text_color: if is_tool_active { c.accent } else { c.text_primary },
|
||||
border: iced::Border::default(),
|
||||
background: Some(iced::Background::Color(if is_tool_active {
|
||||
c.accent_green
|
||||
} else {
|
||||
c.bg_active
|
||||
})),
|
||||
text_color: c.text_primary,
|
||||
border: iced::Border::default()
|
||||
.color(if is_tool_active {
|
||||
c.accent
|
||||
} else {
|
||||
c.border_high
|
||||
})
|
||||
.width(if is_tool_active { 1 } else { 0 }),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
popup_items = popup_items.push(item);
|
||||
let shortcut = tool_shortcut(tool);
|
||||
let tooltip_text = if shortcut.is_empty() {
|
||||
tool_name.to_string()
|
||||
} else {
|
||||
format!("{tool_name} ({shortcut})")
|
||||
};
|
||||
popup_row = popup_row.push(iced::widget::tooltip(
|
||||
item,
|
||||
text(tooltip_text).size(11),
|
||||
iced::widget::tooltip::Position::Right,
|
||||
));
|
||||
if (tool_index + 1) % 2 == 0 || tool_index + 1 == slot.tools.len() {
|
||||
popup_grid = popup_grid.push(popup_row);
|
||||
popup_row = row![].spacing(1);
|
||||
}
|
||||
}
|
||||
|
||||
let popup = container(popup_items)
|
||||
.padding(2)
|
||||
let popup = container(popup_grid)
|
||||
.padding(1)
|
||||
.width(68)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_active)),
|
||||
border: iced::Border::default().color(colors.border_high).width(1),
|
||||
shadow: iced::Shadow {
|
||||
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.4),
|
||||
offset: iced::Vector::new(2.0, 0.0),
|
||||
blur_radius: 8.0,
|
||||
offset: iced::Vector::new(1.0, 0.0),
|
||||
blur_radius: 5.0,
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Position popup to the right of the button
|
||||
let btn_element: Element<'a, Message> = container(
|
||||
row![btn, popup].spacing(0).align_y(iced::Alignment::Center)
|
||||
).into();
|
||||
|
||||
let parent = container(iced::widget::tooltip(
|
||||
btn_element,
|
||||
iced::widget::text(format!("{} (sub-tools open)", slot.label)).size(11),
|
||||
iced::widget::tooltip::Position::Right,
|
||||
))
|
||||
.width(68);
|
||||
container(column![parent, popup].spacing(1))
|
||||
.width(68)
|
||||
.into()
|
||||
} else {
|
||||
// Tooltip on hover
|
||||
let tooltip_text = format!("{} (click for sub-tools)", slot.label);
|
||||
let tooltip_text = slot.label.to_string();
|
||||
iced::widget::tooltip(
|
||||
btn_element,
|
||||
iced::widget::text(tooltip_text).size(11),
|
||||
iced::widget::tooltip::Position::Right,
|
||||
)
|
||||
.into()
|
||||
} else {
|
||||
// Tooltip on hover
|
||||
let tooltip_text = slot.label.to_string();
|
||||
iced::widget::tooltip(
|
||||
btn,
|
||||
iced::widget::text(tooltip_text).size(11),
|
||||
iced::widget::tooltip::Position::Right,
|
||||
)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the SVG asset associated with an individual tool, including subtools.
|
||||
///
|
||||
/// **Arguments:** `tool` is the concrete tool represented by a toolbox slot or popup row.
|
||||
/// **Returns:** The crate-relative SVG path for the concrete tool.
|
||||
/// **Side Effects / Dependencies:** None; callers resolve the returned path against assets.
|
||||
fn tool_icon(tool: Tool) -> &'static str {
|
||||
match tool {
|
||||
Tool::Move => "icons/move.svg",
|
||||
Tool::VectorSelect => "icons/vector_select.svg",
|
||||
Tool::Select => "icons/rect_select.svg",
|
||||
Tool::Lasso | Tool::PolygonSelect => "icons/lasso.svg",
|
||||
Tool::MagicWand => "icons/magic_wand.svg",
|
||||
Tool::SmartSelect | Tool::VisionSelect => "icons/smart_select.svg",
|
||||
Tool::Crop => "icons/crop.svg",
|
||||
Tool::Eyedropper => "icons/eyedropper.svg",
|
||||
Tool::Brush => "icons/brush.svg",
|
||||
Tool::Pen => "icons/pen.svg",
|
||||
Tool::Spray => "icons/spray.svg",
|
||||
Tool::Eraser => "icons/eraser.svg",
|
||||
Tool::FloodFill => "icons/bucket.svg",
|
||||
Tool::Gradient => "icons/gradient.svg",
|
||||
Tool::Text => "icons/text.svg",
|
||||
Tool::VectorLine => "icons/line.svg",
|
||||
Tool::VectorRect => "icons/vector_rect.svg",
|
||||
Tool::VectorCircle => "icons/circle.svg",
|
||||
Tool::VectorArrow => "icons/vector_arrow.svg",
|
||||
Tool::VectorStar => "icons/star.svg",
|
||||
Tool::VectorPolygon => "icons/vector_polygon.svg",
|
||||
Tool::VectorRhombus => "icons/vector_rhombus.svg",
|
||||
Tool::VectorCylinder => "icons/vector_cylinder.svg",
|
||||
Tool::VectorHeart => "icons/vector_heart.svg",
|
||||
Tool::VectorBubble => "icons/vector_bubble.svg",
|
||||
Tool::VectorGear => "icons/vector_gear.svg",
|
||||
Tool::VectorCross => "icons/vector_cross.svg",
|
||||
Tool::VectorCrescent => "icons/vector_crescent.svg",
|
||||
Tool::VectorBolt => "icons/vector_bolt.svg",
|
||||
Tool::VectorArrow4 => "icons/vector_arrow4.svg",
|
||||
Tool::SpotRemoval => "icons/spot.svg",
|
||||
Tool::RedEyeRemoval => "icons/redeye.svg",
|
||||
Tool::SmartPatch | Tool::AiObjectRemoval => "icons/patch.svg",
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves an SVG path across supported repository and application working directories.
|
||||
///
|
||||
/// **Arguments:** `icon` is the asset-relative SVG path.
|
||||
/// **Returns:** The first existing path, or the application-relative fallback.
|
||||
/// **Side Effects / Dependencies:** Reads filesystem metadata without modifying it.
|
||||
fn resolve_icon_path(icon: &str) -> std::path::PathBuf {
|
||||
let candidates = [
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../assets")
|
||||
.join(icon),
|
||||
std::path::PathBuf::from(icon),
|
||||
std::path::Path::new("assets").join(icon),
|
||||
std::path::Path::new("hcie-iced-app/assets").join(icon),
|
||||
];
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|path| path.exists())
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../assets")
|
||||
.join(icon)
|
||||
})
|
||||
}
|
||||
|
||||
/// Get keyboard shortcut display for a tool.
|
||||
fn tool_shortcut(tool: Tool) -> &'static str {
|
||||
match tool {
|
||||
@@ -480,3 +803,24 @@ pub fn last_used_tool(slot_idx: usize, last_used: &[usize]) -> Tool {
|
||||
.and_then(|s| s.tools.get(tidx).copied())
|
||||
.unwrap_or(Tool::Brush)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_icon_path, tool_icon, TOOL_SLOTS};
|
||||
|
||||
/// Verifies every concrete popup choice resolves to an existing SVG asset.
|
||||
#[test]
|
||||
fn every_subtool_has_an_svg_icon() {
|
||||
for tool in TOOL_SLOTS
|
||||
.iter()
|
||||
.flat_map(|slot| slot.tools.iter().copied())
|
||||
{
|
||||
let path = resolve_icon_path(tool_icon(tool));
|
||||
assert!(
|
||||
path.exists(),
|
||||
"missing icon for {tool:?}: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,31 +13,59 @@ use std::sync::{Arc, Mutex};
|
||||
/// Shared theme state for the application.
|
||||
#[derive(Clone)]
|
||||
pub struct ThemeState {
|
||||
colors: Arc<Mutex<ThemeColors>>,
|
||||
state: Arc<Mutex<(ThemePreset, ThemeColors)>>,
|
||||
}
|
||||
|
||||
impl ThemeState {
|
||||
/// Create a new theme state with the default preset.
|
||||
pub fn new() -> Self {
|
||||
/// Create theme state from a selected preset.
|
||||
///
|
||||
/// **Arguments:** `preset` identifies the persisted palette to resolve.
|
||||
/// **Returns:** Shared state containing both the preset and its color tokens.
|
||||
/// **Logic & Workflow:** Resolves colors once and stores them atomically with the preset.
|
||||
/// **Side Effects / Dependencies:** Allocates synchronized shared state; performs no I/O.
|
||||
pub fn new(preset: ThemePreset) -> Self {
|
||||
Self {
|
||||
colors: Arc::new(Mutex::new(ThemeColors::get(ThemePreset::default()))),
|
||||
state: Arc::new(Mutex::new((preset, ThemeColors::get(preset)))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current theme colors.
|
||||
pub fn colors(&self) -> ThemeColors {
|
||||
*self.colors.lock().unwrap()
|
||||
self.state.lock().unwrap().1
|
||||
}
|
||||
|
||||
/// Report the currently selected preset.
|
||||
///
|
||||
/// **Returns:** The preset used to produce the current color tokens.
|
||||
/// **Logic & Workflow:** Reads the preset from the same lock as its resolved colors.
|
||||
/// **Side Effects / Dependencies:** Briefly locks shared theme state.
|
||||
pub fn preset(&self) -> ThemePreset {
|
||||
self.state.lock().unwrap().0
|
||||
}
|
||||
|
||||
/// Switch to a different theme preset.
|
||||
#[allow(dead_code)]
|
||||
pub fn set_preset(&self, preset: ThemePreset) {
|
||||
*self.colors.lock().unwrap() = ThemeColors::get(preset);
|
||||
*self.state.lock().unwrap() = (preset, ThemeColors::get(preset));
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ThemeState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
Self::new(ThemePreset::default())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ThemePreset, ThemeState};
|
||||
|
||||
/// Verifies preset mutation updates only the synchronized theme payload.
|
||||
#[test]
|
||||
fn theme_state_retains_and_reports_selected_preset() {
|
||||
let state = ThemeState::new(ThemePreset::Photopea);
|
||||
state.set_preset(ThemePreset::ProLight);
|
||||
|
||||
assert_eq!(state.preset(), ThemePreset::ProLight);
|
||||
assert!(state.colors().is_light);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
//! Pure vector-selection and transform calculations for the Iced GUI.
|
||||
//!
|
||||
//! Purpose: Centralize overlap cycling, screen-constant rotated handle hit testing, and safe
|
||||
//! move/resize/rotate calculations. Logic & Workflow: Drag calculations always start from an
|
||||
//! immutable shape snapshot, convert pointer movement into the shape's local axes, apply optional
|
||||
//! aspect/center constraints, and reject non-finite or inverted bounds. Side Effects / Dependencies:
|
||||
//! This module has no I/O or engine side effects; callers commit returned shapes through
|
||||
//! `hcie-engine-api`.
|
||||
|
||||
use hcie_engine_api::{VectorEditHandle, VectorShape};
|
||||
|
||||
/// Smallest permitted vector dimension in canvas pixels.
|
||||
pub const MIN_VECTOR_SIZE: f32 = 1.0;
|
||||
/// Resize handle hit-box half extent in screen pixels.
|
||||
pub const HANDLE_HIT_HALF_PX: f32 = 7.0;
|
||||
/// Rotation handle distance above the shape in screen pixels.
|
||||
pub const ROTATE_OFFSET_PX: f32 = 24.0;
|
||||
/// Rotation handle hit radius in screen pixels.
|
||||
pub const ROTATE_HIT_RADIUS_PX: f32 = 7.0;
|
||||
|
||||
/// Immutable edit-session snapshot tied to the layer from which it was captured.
|
||||
///
|
||||
/// Purpose: Prevents Cancel from restoring shapes into a different active layer. Logic & Workflow:
|
||||
/// the app captures this once on selection and restores `shapes` by `layer_id`. Side Effects: None.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct VectorEditSnapshot {
|
||||
pub layer_id: u64,
|
||||
pub shapes: Vec<VectorShape>,
|
||||
}
|
||||
|
||||
/// Immutable basis for one pointer drag.
|
||||
///
|
||||
/// Purpose: Makes every move event deterministic and prevents cumulative floating-point drift.
|
||||
/// Logic & Workflow: `transform_shape` derives the current shape from `original` and the total
|
||||
/// pointer displacement since `pointer_start`. Side Effects: None.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct VectorDragSession {
|
||||
pub layer_id: u64,
|
||||
pub shape_index: usize,
|
||||
pub handle: VectorEditHandle,
|
||||
pub pointer_start: (f32, f32),
|
||||
pub original: VectorShape,
|
||||
}
|
||||
|
||||
/// Chooses the next selected index for a point containing `hits` in bottom-to-top order.
|
||||
///
|
||||
/// Arguments: `hits` contains shape indices under the pointer and `current` is the prior selection.
|
||||
/// Returns: The topmost hit initially, then the next lower overlapping shape on repeated clicks.
|
||||
/// Side Effects: None.
|
||||
pub fn cycle_selection(hits: &[usize], current: Option<usize>) -> Option<usize> {
|
||||
if hits.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match current.and_then(|index| hits.iter().position(|hit| *hit == index)) {
|
||||
Some(position) => Some(hits[(position + hits.len() - 1) % hits.len()]),
|
||||
None => hits.last().copied(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hit-tests rotated vector handles using screen-constant target sizes.
|
||||
///
|
||||
/// Arguments: `bounds` are normalized canvas bounds, `angle` is radians, `point` is canvas-space,
|
||||
/// and `zoom` maps canvas pixels to screen pixels. Returns: The matching handle, preferring rotate
|
||||
/// and resize targets over the move interior. Side Effects: None.
|
||||
pub fn hit_test_handle(
|
||||
bounds: (f32, f32, f32, f32),
|
||||
angle: f32,
|
||||
point: (f32, f32),
|
||||
zoom: f32,
|
||||
) -> VectorEditHandle {
|
||||
use VectorEditHandle as H;
|
||||
|
||||
if !finite4(bounds) || !angle.is_finite() || !point.0.is_finite() || !point.1.is_finite() {
|
||||
return H::None;
|
||||
}
|
||||
let (left, top, right, bottom) = bounds;
|
||||
if right - left < MIN_VECTOR_SIZE || bottom - top < MIN_VECTOR_SIZE {
|
||||
return H::None;
|
||||
}
|
||||
let zoom = zoom.max(0.000_1);
|
||||
let center = ((left + right) * 0.5, (top + bottom) * 0.5);
|
||||
let local = inverse_rotate(point, center, angle);
|
||||
let rotate_offset = ROTATE_OFFSET_PX / zoom;
|
||||
let rotate_radius = ROTATE_HIT_RADIUS_PX / zoom;
|
||||
let rotate_delta = (local.0 - center.0, local.1 - (top - rotate_offset));
|
||||
if rotate_delta.0 * rotate_delta.0 + rotate_delta.1 * rotate_delta.1
|
||||
<= rotate_radius * rotate_radius
|
||||
{
|
||||
return H::Rotate;
|
||||
}
|
||||
|
||||
let half = HANDLE_HIT_HALF_PX / zoom;
|
||||
let points = [
|
||||
(H::TopLeft, left, top),
|
||||
(H::Top, center.0, top),
|
||||
(H::TopRight, right, top),
|
||||
(H::Left, left, center.1),
|
||||
(H::Right, right, center.1),
|
||||
(H::BottomLeft, left, bottom),
|
||||
(H::Bottom, center.0, bottom),
|
||||
(H::BottomRight, right, bottom),
|
||||
];
|
||||
for (handle, x, y) in points {
|
||||
if (local.0 - x).abs() <= half && (local.1 - y).abs() <= half {
|
||||
return handle;
|
||||
}
|
||||
}
|
||||
if local.0 >= left && local.0 <= right && local.1 >= top && local.1 <= bottom {
|
||||
H::Move
|
||||
} else {
|
||||
H::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Derives a safe shape for the current pointer position in a drag.
|
||||
///
|
||||
/// Arguments: `session` is the immutable drag basis, `pointer` is canvas-space, `lock_aspect`
|
||||
/// corresponds to Shift, and `from_center` corresponds to Alt. Returns: A finite shape with
|
||||
/// dimensions at least `MIN_VECTOR_SIZE`; invalid pointer input returns the original shape.
|
||||
/// Side Effects: None.
|
||||
pub fn transform_shape(
|
||||
session: &VectorDragSession,
|
||||
pointer: (f32, f32),
|
||||
lock_aspect: bool,
|
||||
from_center: bool,
|
||||
) -> VectorShape {
|
||||
use VectorEditHandle as H;
|
||||
|
||||
if !pointer.0.is_finite() || !pointer.1.is_finite() {
|
||||
return session.original.clone();
|
||||
}
|
||||
let mut result = session.original.clone();
|
||||
let delta = (
|
||||
pointer.0 - session.pointer_start.0,
|
||||
pointer.1 - session.pointer_start.1,
|
||||
);
|
||||
if !delta.0.is_finite() || !delta.1.is_finite() {
|
||||
return result;
|
||||
}
|
||||
if session.handle == H::Move {
|
||||
result.apply_handle(H::Move, delta.0, delta.1, None);
|
||||
return finite_shape_or_original(result, &session.original);
|
||||
}
|
||||
|
||||
let bounds = session.original.normalized_bounds();
|
||||
let center = ((bounds.0 + bounds.2) * 0.5, (bounds.1 + bounds.3) * 0.5);
|
||||
if session.handle == H::Rotate {
|
||||
if matches!(session.original, VectorShape::FreePath { .. }) {
|
||||
return result;
|
||||
}
|
||||
let start_angle =
|
||||
(session.pointer_start.1 - center.1).atan2(session.pointer_start.0 - center.0);
|
||||
let current_angle = (pointer.1 - center.1).atan2(pointer.0 - center.0);
|
||||
let angle = session.original.angle() + normalize_angle(current_angle - start_angle);
|
||||
if angle.is_finite() {
|
||||
result.set_angle(normalize_angle(angle));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if session.handle == H::None {
|
||||
return result;
|
||||
}
|
||||
|
||||
let local_delta = inverse_rotate_delta(delta, session.original.angle());
|
||||
let resized = resize_bounds(
|
||||
bounds,
|
||||
session.handle,
|
||||
local_delta,
|
||||
lock_aspect,
|
||||
from_center,
|
||||
);
|
||||
result.set_bounds(resized.0, resized.1, resized.2, resized.3);
|
||||
finite_shape_or_original(result, &session.original)
|
||||
}
|
||||
|
||||
/// Calculates normalized, minimum-sized resize bounds.
|
||||
///
|
||||
/// Arguments: Original normalized `bounds`, active resize `handle`, pointer `delta` in local shape
|
||||
/// axes, and Shift/Alt constraints. Returns: Normalized finite bounds without edge inversion.
|
||||
/// Side Effects: None.
|
||||
pub fn resize_bounds(
|
||||
bounds: (f32, f32, f32, f32),
|
||||
handle: VectorEditHandle,
|
||||
delta: (f32, f32),
|
||||
lock_aspect: bool,
|
||||
from_center: bool,
|
||||
) -> (f32, f32, f32, f32) {
|
||||
use VectorEditHandle as H;
|
||||
|
||||
if !finite4(bounds) || !delta.0.is_finite() || !delta.1.is_finite() {
|
||||
return bounds;
|
||||
}
|
||||
let (left, top, right, bottom) = bounds;
|
||||
let center = ((left + right) * 0.5, (top + bottom) * 0.5);
|
||||
let affects_left = matches!(handle, H::TopLeft | H::Left | H::BottomLeft);
|
||||
let affects_right = matches!(handle, H::TopRight | H::Right | H::BottomRight);
|
||||
let affects_top = matches!(handle, H::TopLeft | H::Top | H::TopRight);
|
||||
let affects_bottom = matches!(handle, H::BottomLeft | H::Bottom | H::BottomRight);
|
||||
if !(affects_left || affects_right || affects_top || affects_bottom) {
|
||||
return bounds;
|
||||
}
|
||||
|
||||
let mut new_left = left;
|
||||
let mut new_right = right;
|
||||
let mut new_top = top;
|
||||
let mut new_bottom = bottom;
|
||||
let scale = if from_center { 2.0 } else { 1.0 };
|
||||
if affects_left {
|
||||
new_left = (left + delta.0 * scale).min(right - MIN_VECTOR_SIZE);
|
||||
if from_center {
|
||||
new_right = 2.0 * center.0 - new_left;
|
||||
}
|
||||
}
|
||||
if affects_right {
|
||||
new_right = (right + delta.0 * scale).max(left + MIN_VECTOR_SIZE);
|
||||
if from_center {
|
||||
new_left = 2.0 * center.0 - new_right;
|
||||
}
|
||||
}
|
||||
if affects_top {
|
||||
new_top = (top + delta.1 * scale).min(bottom - MIN_VECTOR_SIZE);
|
||||
if from_center {
|
||||
new_bottom = 2.0 * center.1 - new_top;
|
||||
}
|
||||
}
|
||||
if affects_bottom {
|
||||
new_bottom = (bottom + delta.1 * scale).max(top + MIN_VECTOR_SIZE);
|
||||
if from_center {
|
||||
new_top = 2.0 * center.1 - new_bottom;
|
||||
}
|
||||
}
|
||||
|
||||
if lock_aspect {
|
||||
let ratio = ((right - left) / (bottom - top).max(MIN_VECTOR_SIZE)).max(0.000_1);
|
||||
let mut width = (new_right - new_left).max(MIN_VECTOR_SIZE);
|
||||
let mut height = (new_bottom - new_top).max(MIN_VECTOR_SIZE);
|
||||
if affects_left || affects_right {
|
||||
height = (width / ratio).max(MIN_VECTOR_SIZE);
|
||||
} else {
|
||||
width = (height * ratio).max(MIN_VECTOR_SIZE);
|
||||
}
|
||||
let anchor_x = if from_center {
|
||||
center.0
|
||||
} else if affects_left {
|
||||
right
|
||||
} else if affects_right {
|
||||
left
|
||||
} else {
|
||||
center.0
|
||||
};
|
||||
let anchor_y = if from_center {
|
||||
center.1
|
||||
} else if affects_top {
|
||||
bottom
|
||||
} else if affects_bottom {
|
||||
top
|
||||
} else {
|
||||
center.1
|
||||
};
|
||||
if from_center || !(affects_left || affects_right) {
|
||||
new_left = anchor_x - width * 0.5;
|
||||
new_right = anchor_x + width * 0.5;
|
||||
} else if affects_left {
|
||||
new_left = anchor_x - width;
|
||||
new_right = anchor_x;
|
||||
} else {
|
||||
new_left = anchor_x;
|
||||
new_right = anchor_x + width;
|
||||
}
|
||||
if from_center || !(affects_top || affects_bottom) {
|
||||
new_top = anchor_y - height * 0.5;
|
||||
new_bottom = anchor_y + height * 0.5;
|
||||
} else if affects_top {
|
||||
new_top = anchor_y - height;
|
||||
new_bottom = anchor_y;
|
||||
} else {
|
||||
new_top = anchor_y;
|
||||
new_bottom = anchor_y + height;
|
||||
}
|
||||
}
|
||||
|
||||
let result = (new_left, new_top, new_right, new_bottom);
|
||||
if finite4(result)
|
||||
&& result.2 - result.0 >= MIN_VECTOR_SIZE
|
||||
&& result.3 - result.1 >= MIN_VECTOR_SIZE
|
||||
{
|
||||
result
|
||||
} else {
|
||||
bounds
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalizes an angle to `[-PI, PI]` while preserving finite values.
|
||||
///
|
||||
/// Arguments: `angle` in radians. Returns: A wrapped angle, or zero for non-finite input.
|
||||
/// Side Effects: None.
|
||||
pub fn normalize_angle(angle: f32) -> f32 {
|
||||
if !angle.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
let two_pi = std::f32::consts::TAU;
|
||||
(angle + std::f32::consts::PI).rem_euclid(two_pi) - std::f32::consts::PI
|
||||
}
|
||||
|
||||
/// Inverse-rotates a point around a center. Arguments and return are canvas-space coordinates.
|
||||
/// Side Effects: None.
|
||||
fn inverse_rotate(point: (f32, f32), center: (f32, f32), angle: f32) -> (f32, f32) {
|
||||
let delta = (point.0 - center.0, point.1 - center.1);
|
||||
let local = inverse_rotate_delta(delta, angle);
|
||||
(center.0 + local.0, center.1 + local.1)
|
||||
}
|
||||
|
||||
/// Inverse-rotates a displacement by `angle`. Returns the shape-local displacement.
|
||||
/// Side Effects: None.
|
||||
fn inverse_rotate_delta(delta: (f32, f32), angle: f32) -> (f32, f32) {
|
||||
let (sin, cos) = angle.sin_cos();
|
||||
(
|
||||
delta.0 * cos + delta.1 * sin,
|
||||
-delta.0 * sin + delta.1 * cos,
|
||||
)
|
||||
}
|
||||
|
||||
/// Checks four floats for finiteness. Returns true only when all values are finite.
|
||||
/// Side Effects: None.
|
||||
fn finite4(values: (f32, f32, f32, f32)) -> bool {
|
||||
values.0.is_finite() && values.1.is_finite() && values.2.is_finite() && values.3.is_finite()
|
||||
}
|
||||
|
||||
/// Rejects shapes whose resulting bounds contain NaN/infinity or violate minimum size.
|
||||
/// Returns: `candidate` when valid, otherwise `original`. Side Effects: None.
|
||||
fn finite_shape_or_original(candidate: VectorShape, original: &VectorShape) -> VectorShape {
|
||||
let bounds = candidate.normalized_bounds();
|
||||
if finite4(bounds)
|
||||
&& bounds.2 - bounds.0 >= MIN_VECTOR_SIZE
|
||||
&& bounds.3 - bounds.1 >= MIN_VECTOR_SIZE
|
||||
{
|
||||
candidate
|
||||
} else {
|
||||
original.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Creates a stable rectangle fixture for transform invariant tests.
|
||||
fn rect() -> VectorShape {
|
||||
VectorShape::Rect {
|
||||
x1: 10.0,
|
||||
y1: 20.0,
|
||||
x2: 110.0,
|
||||
y2: 70.0,
|
||||
stroke: 2.0,
|
||||
color: [1, 2, 3, 255],
|
||||
fill_color: [4, 5, 6, 255],
|
||||
fill: true,
|
||||
radius: 0.0,
|
||||
angle: 0.0,
|
||||
opacity: 1.0,
|
||||
hardness: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies topmost-first overlap selection and deterministic repeated-click cycling.
|
||||
#[test]
|
||||
fn overlap_selection_cycles_without_external_counter() {
|
||||
let hits = [1, 3, 8];
|
||||
assert_eq!(cycle_selection(&hits, None), Some(8));
|
||||
assert_eq!(cycle_selection(&hits, Some(8)), Some(3));
|
||||
assert_eq!(cycle_selection(&hits, Some(3)), Some(1));
|
||||
assert_eq!(cycle_selection(&hits, Some(1)), Some(8));
|
||||
assert_eq!(cycle_selection(&[], Some(1)), None);
|
||||
}
|
||||
|
||||
/// Verifies handle targets occupy the same screen radius at low and high zoom.
|
||||
#[test]
|
||||
fn handle_hit_area_is_screen_constant_across_zoom() {
|
||||
let bounds = (10.0, 20.0, 110.0, 70.0);
|
||||
assert_eq!(
|
||||
hit_test_handle(bounds, 0.0, (16.0, 20.0), 1.0),
|
||||
VectorEditHandle::TopLeft
|
||||
);
|
||||
assert_eq!(
|
||||
hit_test_handle(bounds, 0.0, (10.6, 20.0), 10.0),
|
||||
VectorEditHandle::TopLeft
|
||||
);
|
||||
assert_eq!(
|
||||
hit_test_handle(bounds, 0.0, (10.8, 20.0), 10.0),
|
||||
VectorEditHandle::Move
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifies the visible rotation handle and its hit target remain 24 screen pixels above the
|
||||
/// selected shape at low zoom, where the previous drawing path incorrectly moved it closer.
|
||||
#[test]
|
||||
fn rotation_handle_offset_is_screen_constant_at_low_zoom() {
|
||||
let bounds = (10.0, 20.0, 110.0, 70.0);
|
||||
let zoom = 0.25;
|
||||
let rotation_point = (60.0, 20.0 - ROTATE_OFFSET_PX / zoom);
|
||||
|
||||
assert_eq!(
|
||||
hit_test_handle(bounds, 0.0, rotation_point, zoom),
|
||||
VectorEditHandle::Rotate
|
||||
);
|
||||
assert_eq!((20.0 - rotation_point.1) * zoom, ROTATE_OFFSET_PX);
|
||||
}
|
||||
|
||||
/// Verifies all eight handles preserve normalized, minimum-sized bounds under extreme drags.
|
||||
#[test]
|
||||
fn every_resize_handle_prevents_inversion_and_nan() {
|
||||
let handles = [
|
||||
VectorEditHandle::TopLeft,
|
||||
VectorEditHandle::Top,
|
||||
VectorEditHandle::TopRight,
|
||||
VectorEditHandle::Left,
|
||||
VectorEditHandle::Right,
|
||||
VectorEditHandle::BottomLeft,
|
||||
VectorEditHandle::Bottom,
|
||||
VectorEditHandle::BottomRight,
|
||||
];
|
||||
for handle in handles {
|
||||
for delta in [
|
||||
(10_000.0, 10_000.0),
|
||||
(-10_000.0, -10_000.0),
|
||||
(f32::NAN, 1.0),
|
||||
] {
|
||||
let bounds = resize_bounds((10.0, 20.0, 110.0, 70.0), handle, delta, false, false);
|
||||
assert!(finite4(bounds), "{handle:?}: {bounds:?}");
|
||||
assert!(
|
||||
bounds.2 - bounds.0 >= MIN_VECTOR_SIZE,
|
||||
"{handle:?}: {bounds:?}"
|
||||
);
|
||||
assert!(
|
||||
bounds.3 - bounds.1 >= MIN_VECTOR_SIZE,
|
||||
"{handle:?}: {bounds:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies Shift preserves aspect and Alt keeps the original center fixed.
|
||||
#[test]
|
||||
fn shift_aspect_and_alt_center_constraints_hold() {
|
||||
let original = (10.0, 20.0, 110.0, 70.0);
|
||||
let resized = resize_bounds(
|
||||
original,
|
||||
VectorEditHandle::BottomRight,
|
||||
(50.0, 10.0),
|
||||
true,
|
||||
true,
|
||||
);
|
||||
let ratio = (resized.2 - resized.0) / (resized.3 - resized.1);
|
||||
assert!((ratio - 2.0).abs() < 0.0001);
|
||||
assert!(((resized.0 + resized.2) * 0.5 - 60.0).abs() < 0.0001);
|
||||
assert!(((resized.1 + resized.3) * 0.5 - 45.0).abs() < 0.0001);
|
||||
}
|
||||
|
||||
/// Verifies drag output is based on the original snapshot and invalid pointers are harmless.
|
||||
#[test]
|
||||
fn drag_is_deterministic_and_rejects_non_finite_input() {
|
||||
let session = VectorDragSession {
|
||||
layer_id: 7,
|
||||
shape_index: 0,
|
||||
handle: VectorEditHandle::Move,
|
||||
pointer_start: (5.0, 5.0),
|
||||
original: rect(),
|
||||
};
|
||||
let moved = transform_shape(&session, (15.0, 25.0), false, false);
|
||||
assert_eq!(moved.normalized_bounds(), (20.0, 40.0, 120.0, 90.0));
|
||||
assert_eq!(
|
||||
transform_shape(&session, (f32::NAN, 0.0), false, false),
|
||||
rect()
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifies rotated handle hit testing operates in shape-local coordinates.
|
||||
#[test]
|
||||
fn rotated_handle_hit_test_uses_rotated_position() {
|
||||
let bounds = (10.0, 20.0, 110.0, 70.0);
|
||||
let center = (60.0, 45.0);
|
||||
let angle = std::f32::consts::FRAC_PI_2;
|
||||
let local = (10.0, 20.0);
|
||||
let rotated = (
|
||||
center.0 - (local.1 - center.1),
|
||||
center.1 + (local.0 - center.0),
|
||||
);
|
||||
assert_eq!(
|
||||
hit_test_handle(bounds, angle, rotated, 2.0),
|
||||
VectorEditHandle::TopLeft
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -129,8 +129,7 @@ fn draw_tree_node(
|
||||
let mut children_col = column![].spacing(1);
|
||||
for child in &subdirs {
|
||||
if let Some(child_name) = child.file_name().and_then(|n| n.to_str()) {
|
||||
let child_node =
|
||||
draw_tree_node(child, child_name, ">", state, colors, depth + 1);
|
||||
let child_node = draw_tree_node(child, child_name, ">", state, colors, depth + 1);
|
||||
children_col = children_col.push(child_node);
|
||||
}
|
||||
}
|
||||
@@ -138,7 +137,8 @@ fn draw_tree_node(
|
||||
let btn = button(label_text)
|
||||
.on_press(Message::ViewerNavigate(path.to_path_buf()))
|
||||
.width(Length::Fill)
|
||||
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(if is_selected {
|
||||
color_alpha(colors.accent, 0.15)
|
||||
@@ -149,14 +149,16 @@ fn draw_tree_node(
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
column![btn, children_col].spacing(1).into()
|
||||
} else {
|
||||
button(label_text)
|
||||
.on_press(Message::ViewerNavigate(path.to_path_buf()))
|
||||
.width(Length::Fill)
|
||||
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(if is_selected {
|
||||
color_alpha(colors.accent, 0.15)
|
||||
@@ -167,7 +169,8 @@ fn draw_tree_node(
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
.into()
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ pub mod thumbnail_grid;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::theme::ThemeColors;
|
||||
use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, Space};
|
||||
use iced::widget::{button, column, container, horizontal_rule, row, text, Space};
|
||||
use iced::{Element, Length};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -56,6 +56,8 @@ pub struct ViewerState {
|
||||
pub preview_pixels: Option<(Vec<u8>, u32, u32)>,
|
||||
/// Preview load error message.
|
||||
pub load_error: Option<String>,
|
||||
/// Directory enumeration error shown without pretending the folder is empty.
|
||||
pub directory_error: Option<String>,
|
||||
/// Zoom level for the viewer panel.
|
||||
pub zoom: f32,
|
||||
/// Pan offset for the viewer panel.
|
||||
@@ -75,6 +77,7 @@ impl Default for ViewerState {
|
||||
failed_thumbnails: HashSet::new(),
|
||||
preview_pixels: None,
|
||||
load_error: None,
|
||||
directory_error: None,
|
||||
zoom: 1.0,
|
||||
pan_offset: (0.0, 0.0),
|
||||
}
|
||||
@@ -99,8 +102,11 @@ impl ViewerState {
|
||||
/// Refresh the images list in the current directory.
|
||||
pub fn refresh_images(&mut self) {
|
||||
log::debug!("Refreshing image list for: {}", self.current_dir.display());
|
||||
let selected = self.images.get(self.active_image_idx).cloned();
|
||||
self.images.clear();
|
||||
if let Ok(entries) = std::fs::read_dir(&self.current_dir) {
|
||||
self.directory_error = None;
|
||||
match std::fs::read_dir(&self.current_dir) {
|
||||
Ok(entries) => {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() && is_supported_image(&path) {
|
||||
@@ -108,8 +114,20 @@ impl ViewerState {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.images.sort();
|
||||
self.active_image_idx = 0;
|
||||
Err(error) => {
|
||||
self.directory_error = Some(format!(
|
||||
"Cannot read {}: {error}",
|
||||
self.current_dir.display()
|
||||
))
|
||||
}
|
||||
}
|
||||
self.images.sort_by_cached_key(|path| {
|
||||
path.file_name()
|
||||
.map(|name| name.to_string_lossy().to_lowercase())
|
||||
});
|
||||
self.active_image_idx = selected
|
||||
.and_then(|path| self.images.iter().position(|item| item == &path))
|
||||
.unwrap_or(0);
|
||||
self.preview_pixels = None;
|
||||
self.load_error = None;
|
||||
log::debug!("Found {} supported images", self.images.len());
|
||||
@@ -220,7 +238,8 @@ impl ViewerState {
|
||||
let w = rgba.width();
|
||||
let h = rgba.height();
|
||||
let pixels = rgba.into_raw();
|
||||
self.thumbnail_cache.insert(path.to_path_buf(), (pixels, w, h));
|
||||
self.thumbnail_cache
|
||||
.insert(path.to_path_buf(), (pixels, w, h));
|
||||
}
|
||||
Err(_) => {
|
||||
self.failed_thumbnails.insert(path.to_path_buf());
|
||||
@@ -247,10 +266,7 @@ fn _fit_size(texture_w: f32, texture_h: f32, avail_w: f32, avail_h: f32) -> (f32
|
||||
}
|
||||
|
||||
/// Build the viewer panel UI element.
|
||||
pub fn view(
|
||||
state: &ViewerState,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'static, Message> {
|
||||
pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> {
|
||||
let header = view_header(state, colors);
|
||||
let body = match state.view_mode {
|
||||
ViewerMode::Browser => view_browser(state, colors),
|
||||
@@ -266,31 +282,40 @@ pub fn view(
|
||||
fn view_header(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> {
|
||||
let exit_btn = button(text(" Exit Viewer "))
|
||||
.on_press(Message::ViewerToggle)
|
||||
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.accent)),
|
||||
text_color: iced::Color::WHITE,
|
||||
border: iced::Border::default().color(colors.accent).width(1),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
let fs_label = if state.fullscreen { "Windowed" } else { "Fullscreen" };
|
||||
let fs_label = if state.fullscreen {
|
||||
"Windowed"
|
||||
} else {
|
||||
"Fullscreen"
|
||||
};
|
||||
let fs_btn = button(text(format!(" {} ", fs_label)))
|
||||
.on_press(Message::ViewerFullscreen(!state.fullscreen))
|
||||
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_panel)),
|
||||
text_color: colors.text_primary,
|
||||
border: iced::Border::default().color(colors.border_low).width(1),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
let is_browser = state.view_mode == ViewerMode::Browser;
|
||||
let browser_btn = button(text(" Browse "))
|
||||
.on_press(Message::ViewerNavigate(state.current_dir.clone()))
|
||||
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
.on_press(Message::ViewerSetMode(ViewerMode::Browser))
|
||||
.style(
|
||||
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(if is_browser {
|
||||
colors.accent
|
||||
@@ -305,12 +330,14 @@ fn view_header(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes
|
||||
border: iced::Border::default().color(colors.border_low).width(1),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
let is_viewer = state.view_mode == ViewerMode::Viewer;
|
||||
let viewer_btn = button(text(" Viewer "))
|
||||
.on_press(Message::ViewerNavigate(state.current_dir.clone()))
|
||||
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
.on_press(Message::ViewerSetMode(ViewerMode::Viewer))
|
||||
.style(
|
||||
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(if is_viewer {
|
||||
colors.accent
|
||||
@@ -325,18 +352,21 @@ fn view_header(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes
|
||||
border: iced::Border::default().color(colors.border_low).width(1),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
let refresh_btn = button(text(" Refresh "))
|
||||
.on_press(Message::ViewerRefresh)
|
||||
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_panel)),
|
||||
text_color: colors.text_primary,
|
||||
border: iced::Border::default().color(colors.border_low).width(1),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
row![
|
||||
exit_btn,
|
||||
@@ -407,7 +437,9 @@ fn view_left_sidebar(state: &ViewerState, colors: ThemeColors) -> Element<'stati
|
||||
fn view_preview(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> {
|
||||
let content = if let Some((pixels, w, h)) = &state.preview_pixels {
|
||||
let img = iced::widget::Image::new(iced::widget::image::Handle::from_rgba(
|
||||
*w, *h, pixels.clone(),
|
||||
*w,
|
||||
*h,
|
||||
pixels.clone(),
|
||||
))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Shrink);
|
||||
@@ -438,15 +470,19 @@ fn view_preview(state: &ViewerState, colors: ThemeColors) -> Element<'static, Me
|
||||
.align_x(iced::Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
} else if let Some(err) = &state.load_error {
|
||||
column![text(format!("Error: {}", err)).style(move |_theme| iced::widget::text::Style {
|
||||
column![
|
||||
text(format!("Error: {}", err)).style(move |_theme| iced::widget::text::Style {
|
||||
color: Some(colors.danger),
|
||||
})]
|
||||
})
|
||||
]
|
||||
.align_x(iced::Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
} else {
|
||||
column![text("No image selected").style(move |_theme| iced::widget::text::Style {
|
||||
column![
|
||||
text("No image selected").style(move |_theme| iced::widget::text::Style {
|
||||
color: Some(colors.text_secondary),
|
||||
})]
|
||||
})
|
||||
]
|
||||
.align_x(iced::Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
};
|
||||
@@ -481,23 +517,23 @@ fn view_right_panel(state: &ViewerState, colors: ThemeColors) -> Element<'static
|
||||
let has_parent = state.current_dir.parent().is_some();
|
||||
|
||||
let parent_btn = if has_parent {
|
||||
button(text(" Parent "))
|
||||
.on_press(Message::ViewerNavigate(
|
||||
button(text(" Parent ")).on_press(Message::ViewerNavigate(
|
||||
state.current_dir.parent().unwrap().to_path_buf(),
|
||||
))
|
||||
} else {
|
||||
button(text(" Parent "))
|
||||
};
|
||||
|
||||
let parent_btn = parent_btn
|
||||
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
let parent_btn = parent_btn.style(
|
||||
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_panel)),
|
||||
text_color: colors.text_primary,
|
||||
border: iced::Border::default().color(colors.border_low).width(1),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
let dir_str = state.current_dir.to_string_lossy().to_string();
|
||||
let total_files = state.images.len();
|
||||
@@ -506,7 +542,10 @@ fn view_right_panel(state: &ViewerState, colors: ThemeColors) -> Element<'static
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let stats_str = format!("{} Files | Selected: {} / {}", total_files, selected_num, total_files);
|
||||
let stats_str = format!(
|
||||
"{} Files | Selected: {} / {}",
|
||||
total_files, selected_num, total_files
|
||||
);
|
||||
|
||||
let toolbar = row![
|
||||
parent_btn,
|
||||
@@ -526,16 +565,17 @@ fn view_right_panel(state: &ViewerState, colors: ThemeColors) -> Element<'static
|
||||
color: Some(colors.text_secondary),
|
||||
});
|
||||
|
||||
let grid = thumbnail_grid::view(state, colors);
|
||||
let grid: Element<'static, Message> = if let Some(error) = &state.directory_error {
|
||||
container(text(error.clone()).color(colors.danger))
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
thumbnail_grid::view(state, colors)
|
||||
};
|
||||
|
||||
container(
|
||||
column![
|
||||
toolbar,
|
||||
horizontal_rule(1),
|
||||
stats,
|
||||
horizontal_rule(1),
|
||||
grid,
|
||||
]
|
||||
column![toolbar, horizontal_rule(1), stats, horizontal_rule(1), grid,]
|
||||
.spacing(4)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill),
|
||||
@@ -557,12 +597,13 @@ fn view_right_panel(state: &ViewerState, colors: ThemeColors) -> Element<'static
|
||||
fn view_viewer(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> {
|
||||
if state.images.is_empty() {
|
||||
return column![
|
||||
text("No images found in this folder.").style(move |_theme| iced::widget::text::Style {
|
||||
text("No images found in this folder.").style(move |_theme| {
|
||||
iced::widget::text::Style {
|
||||
color: Some(colors.text_secondary),
|
||||
}
|
||||
}),
|
||||
button(text("Browse Mode")).on_press(Message::ViewerNavigate(
|
||||
state.current_dir.clone(),
|
||||
)),
|
||||
button(text("Browse Mode"))
|
||||
.on_press(Message::ViewerNavigate(state.current_dir.clone(),)),
|
||||
]
|
||||
.align_x(iced::Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
@@ -585,35 +626,38 @@ fn view_viewer(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes
|
||||
color: Some(colors.text_primary),
|
||||
}),
|
||||
Space::with_width(Length::Fill),
|
||||
text(format!("{} / {}", current_idx + 1, total)).style(move |_theme| iced::widget::text::Style {
|
||||
text(format!("{} / {}", current_idx + 1, total)).style(move |_theme| {
|
||||
iced::widget::text::Style {
|
||||
color: Some(colors.text_secondary),
|
||||
}
|
||||
}),
|
||||
text(" "),
|
||||
button(text("Edit")).on_press(Message::ViewerOpenFile(edit_path))
|
||||
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
button(text("Edit"))
|
||||
.on_press(Message::ViewerOpenFile(edit_path))
|
||||
.style(
|
||||
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.accent)),
|
||||
text_color: iced::Color::WHITE,
|
||||
border: iced::Border::default().color(colors.accent).width(1),
|
||||
..Default::default()
|
||||
}
|
||||
}),
|
||||
}
|
||||
),
|
||||
]
|
||||
.align_y(iced::Alignment::Center)
|
||||
.width(Length::Fill);
|
||||
|
||||
let image_area: Element<'static, Message> = if let Some((pixels, w, h)) = &state.preview_pixels {
|
||||
let handle: iced::widget::image::Handle = iced::widget::image::Handle::from_rgba(
|
||||
*w, *h, pixels.clone(),
|
||||
);
|
||||
let image_area: Element<'static, Message> = if let Some((pixels, w, h)) = &state.preview_pixels
|
||||
{
|
||||
let handle: iced::widget::image::Handle =
|
||||
iced::widget::image::Handle::from_rgba(*w, *h, pixels.clone());
|
||||
let img = iced::widget::Image::new(handle)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
let prev_path = state.images[current_idx].clone();
|
||||
let prev_btn = button(text(" < "))
|
||||
.on_press(Message::ViewerNavigate(prev_path))
|
||||
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
let prev_btn = button(text(" < ")).on_press(Message::ViewerPrev).style(
|
||||
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.0, 0.0, 0.0, 0.5,
|
||||
@@ -622,17 +666,11 @@ fn view_viewer(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes
|
||||
border: iced::Border::default().rounded(8),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
let next_idx = if current_idx + 1 < total {
|
||||
current_idx + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let next_path = state.images[next_idx].clone();
|
||||
let next_btn = button(text(" > "))
|
||||
.on_press(Message::ViewerNavigate(next_path))
|
||||
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
let next_btn = button(text(" > ")).on_press(Message::ViewerNext).style(
|
||||
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.0, 0.0, 0.0, 0.5,
|
||||
@@ -641,26 +679,31 @@ fn view_viewer(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes
|
||||
border: iced::Border::default().rounded(8),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
row![prev_btn, img, next_btn]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
} else if let Some(err) = &state.load_error {
|
||||
column![text(format!("Failed to load: {}", err)).style(move |_theme| {
|
||||
column![
|
||||
text(format!("Failed to load: {}", err)).style(move |_theme| {
|
||||
iced::widget::text::Style {
|
||||
color: Some(colors.danger),
|
||||
}
|
||||
})]
|
||||
})
|
||||
]
|
||||
.align_x(iced::Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
column![text("Loading...").style(move |_theme| iced::widget::text::Style {
|
||||
column![
|
||||
text("Loading...").style(move |_theme| iced::widget::text::Style {
|
||||
color: Some(colors.text_secondary),
|
||||
})]
|
||||
})
|
||||
]
|
||||
.align_x(iced::Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
@@ -672,3 +715,24 @@ fn view_viewer(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn navigation_wraps_deterministically() {
|
||||
let mut state = ViewerState::default();
|
||||
state.images = vec![PathBuf::from("a.png"), PathBuf::from("b.png")];
|
||||
state.prev_image();
|
||||
assert_eq!(state.active_image_idx, 1);
|
||||
state.next_image();
|
||||
assert_eq!(state.active_image_idx, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_extensions_are_case_insensitive() {
|
||||
assert!(is_supported_image(Path::new("IMAGE.PNG")));
|
||||
assert!(!is_supported_image(Path::new("notes.txt")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,8 +42,9 @@ pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Messag
|
||||
.into();
|
||||
}
|
||||
|
||||
let avail_w = 600.0_f32;
|
||||
let cols = ((avail_w / (CARD_W + CARD_SPACING)).floor().max(1.0)) as usize;
|
||||
// Four compact columns remain usable at the minimum editor width while the
|
||||
// surrounding scrollable preserves access on narrower windows.
|
||||
let cols = 4;
|
||||
|
||||
let mut grid_rows = column![].spacing(CARD_SPACING);
|
||||
let total = state.images.len();
|
||||
@@ -68,7 +69,11 @@ pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Messag
|
||||
}
|
||||
|
||||
/// Render a single thumbnail card.
|
||||
fn thumbnail_card(state: &ViewerState, index: usize, colors: ThemeColors) -> Element<'static, Message> {
|
||||
fn thumbnail_card(
|
||||
state: &ViewerState,
|
||||
index: usize,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'static, Message> {
|
||||
let img_path = &state.images[index];
|
||||
let is_selected = state.active_image_idx == index;
|
||||
let filename = img_path
|
||||
@@ -82,13 +87,11 @@ fn thumbnail_card(state: &ViewerState, index: usize, colors: ThemeColors) -> Ele
|
||||
filename.clone()
|
||||
};
|
||||
|
||||
let thumb_content: Element<'static, Message> = if let Some((pixels, w, h)) = state.thumbnail_cache.get(img_path) {
|
||||
let handle: iced::widget::image::Handle = iced::widget::image::Handle::from_rgba(
|
||||
*w, *h, pixels.clone(),
|
||||
);
|
||||
let img = iced::widget::Image::new(handle)
|
||||
.width(110)
|
||||
.height(100);
|
||||
let thumb_content: Element<'static, Message> =
|
||||
if let Some((pixels, w, h)) = state.thumbnail_cache.get(img_path) {
|
||||
let handle: iced::widget::image::Handle =
|
||||
iced::widget::image::Handle::from_rgba(*w, *h, pixels.clone());
|
||||
let img = iced::widget::Image::new(handle).width(110).height(100);
|
||||
img.into()
|
||||
} else if state.failed_thumbnails.contains(img_path) {
|
||||
text("Error").size(10).into()
|
||||
@@ -124,17 +127,29 @@ fn thumbnail_card(state: &ViewerState, index: usize, colors: ThemeColors) -> Ele
|
||||
button(card_content)
|
||||
.on_press(Message::ViewerSelectFile(img_path.clone()))
|
||||
.width(CARD_W)
|
||||
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
let background = match status {
|
||||
iced::widget::button::Status::Hovered => colors.bg_hover,
|
||||
iced::widget::button::Status::Pressed => colors.bg_active,
|
||||
iced::widget::button::Status::Disabled => colors.bg_app,
|
||||
iced::widget::button::Status::Active => frame_color,
|
||||
};
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(frame_color)),
|
||||
text_color: colors.text_primary,
|
||||
background: Some(iced::Background::Color(background)),
|
||||
text_color: if status == iced::widget::button::Status::Disabled {
|
||||
colors.text_disabled
|
||||
} else {
|
||||
colors.text_primary
|
||||
},
|
||||
border: iced::Border::default()
|
||||
.color(border_color)
|
||||
.width(if is_selected { 2.0 } else { 1.0 })
|
||||
.rounded(6),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -143,7 +158,9 @@ pub fn count_unloaded(state: &ViewerState) -> usize {
|
||||
state
|
||||
.images
|
||||
.iter()
|
||||
.filter(|p| !state.thumbnail_cache.contains_key(*p) && !state.failed_thumbnails.contains(*p))
|
||||
.filter(|p| {
|
||||
!state.thumbnail_cache.contains_key(*p) && !state.failed_thumbnails.contains(*p)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
@@ -152,7 +169,9 @@ pub fn next_load_batch(state: &ViewerState) -> Vec<std::path::PathBuf> {
|
||||
state
|
||||
.images
|
||||
.iter()
|
||||
.filter(|p| !state.thumbnail_cache.contains_key(*p) && !state.failed_thumbnails.contains(*p))
|
||||
.filter(|p| {
|
||||
!state.thumbnail_cache.contains_key(*p) && !state.failed_thumbnails.contains(*p)
|
||||
})
|
||||
.take(LAZY_LOAD_BUDGET)
|
||||
.cloned()
|
||||
.collect()
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
//! Stable Cycle 0 feature identifiers and source-level regression gates.
|
||||
//!
|
||||
//! **Purpose:** Prevents the highest-risk placeholder behaviors from returning while broader GUI
|
||||
//! parity work proceeds. **Logic & Workflow:** Each test owns a stable feature ID and inspects the
|
||||
//! production source for architectural invariants that complement module-level behavior tests.
|
||||
//! **Side Effects / Dependencies:** Reads source files from the crate at test time; performs no
|
||||
//! writes and does not launch the GUI.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
const MENU_SEMANTIC_DISPATCH: &str = "shell.menu.semantic_dispatch";
|
||||
const MENU_NO_CUT_COPY_ALIAS: &str = "clipboard.cut.no_copy_alias";
|
||||
const SCREENSHOT_NATIVE_CAPTURE: &str = "audit.screenshot.native_capture";
|
||||
const SCREENSHOT_PANEL_CROP: &str = "audit.screenshot.panel_crop";
|
||||
const PANE_FULL_DROP_TARGETS: &str = "cycle1.pane.full_drop_targets";
|
||||
const THEME_SYNC_ISOLATION: &str = "cycle1.theme.sync_isolation";
|
||||
const SIDEBAR_MODE_PERSISTENCE: &str = "cycle1.sidebar.mode_persistence";
|
||||
const VECTOR_SCREEN_CONSTANT_HANDLES: &str = "cycle2.vector.screen_constant_handles";
|
||||
const VECTOR_CONSTRAINED_DRAG: &str = "cycle2.vector.constrained_drag";
|
||||
const VECTOR_LAYER_BOUND_CANCEL: &str = "cycle2.vector.layer_bound_cancel";
|
||||
const VECTOR_BOOLEAN_RESULT: &str = "cycle2.vector.boolean_result_selection";
|
||||
const VECTOR_CLAMPED_CONTROLS: &str = "cycle2.vector.clamped_controls";
|
||||
const SELECTION_IRREGULAR_EDGES: &str = "cycle3.selection.irregular_edges";
|
||||
const CLIPBOARD_DISTINCT_CUT: &str = "cycle3.clipboard.distinct_cut";
|
||||
const RASTER_TRANSFORM_PLACEMENT: &str = "cycle3.raster.transform_placement";
|
||||
const CROP_LIFECYCLE: &str = "cycle3.crop.lifecycle";
|
||||
const TEXT_MULTILINE: &str = "cycle3.text.multiline";
|
||||
const TRUE_GRADIENT: &str = "cycle3.gradient.linear_radial";
|
||||
const CANVAS_INPUT_PARITY: &str = "cycle3.canvas.input_parity";
|
||||
const PEN_SPRAY_DISTINCT: &str = "cycle3.tools.pen_spray_distinct";
|
||||
const LAYERS_HIERARCHY_CONTROLS: &str = "cycle4.layers.hierarchy_controls";
|
||||
const LAYER_STYLES_CANCEL_RESTORE: &str = "cycle4.layer_styles.cancel_restore";
|
||||
const IMMUTABLE_PIXEL_PREVIEW: &str = "cycle4.preview.immutable_baseline";
|
||||
const FILTER_SCHEMA_COVERAGE: &str = "cycle4.filters.schema_coverage";
|
||||
const DOCUMENT_PATH_IDENTITY: &str = "cycle4.documents.path_identity";
|
||||
const CLOSE_SAVE_CONTINUATION: &str = "cycle4.documents.close_save_continuation";
|
||||
const AI_REAL_STREAMING: &str = "cycle5.ai.real_streaming";
|
||||
const AI_REASONING_TRANSCRIPT: &str = "cycle5.ai.reasoning_transcript";
|
||||
const SCRIPT_VALIDATED_OUTPUT: &str = "cycle5.script.validated_output";
|
||||
const VIEWER_DETERMINISTIC_NAV: &str = "cycle5.viewer.deterministic_navigation";
|
||||
const VIEWER_SAFE_EDIT: &str = "cycle5.viewer.safe_edit_document";
|
||||
|
||||
/// Reads a crate-relative source file for structural audit assertions.
|
||||
///
|
||||
/// **Arguments:** `relative` is a path beneath the package manifest directory.
|
||||
/// **Returns:** The complete UTF-8 source text.
|
||||
/// **Side Effects / Dependencies:** Reads one local source file and panics on missing test input.
|
||||
fn source(relative: &str) -> String {
|
||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative);
|
||||
std::fs::read_to_string(&path)
|
||||
.unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_menu_dispatch_has_no_positional_message() {
|
||||
let app = source("src/app.rs");
|
||||
let menus = source("src/panels/menus.rs");
|
||||
assert!(
|
||||
app.contains("Message::MenuCommand(command)"),
|
||||
"{MENU_SEMANTIC_DISPATCH}"
|
||||
);
|
||||
assert!(
|
||||
!app.contains("MenuAction(usize, usize)"),
|
||||
"{MENU_SEMANTIC_DISPATCH}"
|
||||
);
|
||||
assert!(
|
||||
menus.contains("command: Option<MenuCommand>"),
|
||||
"{MENU_SEMANTIC_DISPATCH}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_does_not_dispatch_copy() {
|
||||
let app = source("src/app.rs");
|
||||
let menus = source("src/panels/menus.rs");
|
||||
assert!(
|
||||
app.contains("MenuCommand::Cut => return Task::perform(async {}, |_| Message::CutImage)"),
|
||||
"{MENU_NO_CUT_COPY_ALIAS}"
|
||||
);
|
||||
assert!(
|
||||
menus.contains("MenuCommand::Cut"),
|
||||
"{MENU_NO_CUT_COPY_ALIAS}"
|
||||
);
|
||||
assert!(
|
||||
!menus.contains("MenuItem::with_shortcut(\"Cut\", \"Ctrl+X\")"),
|
||||
"{MENU_NO_CUT_COPY_ALIAS}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_three_canvas_selection_clipboard_crop_text_paths_are_connected() {
|
||||
let app = source("src/app.rs");
|
||||
let canvas = source("src/canvas/mod.rs");
|
||||
let shader = source("src/canvas/shader_canvas.rs");
|
||||
let state = source("src/selection/state.rs");
|
||||
let clipboard = source("src/selection/clipboard.rs");
|
||||
let crop = source("src/selection/crop.rs");
|
||||
let raster = source("src/raster.rs");
|
||||
|
||||
assert!(
|
||||
state.contains("pub fn selected_spans")
|
||||
&& canvas.contains("selection_fill_spans")
|
||||
&& canvas.contains("Fill exact mask runs"),
|
||||
"{SELECTION_IRREGULAR_EDGES}"
|
||||
);
|
||||
assert!(
|
||||
app.contains("Message::CutImage")
|
||||
&& app.contains("Message::ClearPixels")
|
||||
&& app.contains("Some(Message::CutImage)"),
|
||||
"{CLIPBOARD_DISTINCT_CUT}"
|
||||
);
|
||||
assert!(
|
||||
clipboard.contains("pub fn centered_transform")
|
||||
&& app.contains("placed.pos.x += 10.0")
|
||||
&& app.contains("tr.sanitize_geometry(self.modifiers.shift())"),
|
||||
"{RASTER_TRANSFORM_PLACEMENT}"
|
||||
);
|
||||
assert!(
|
||||
crop.contains("start_drag_clamped") && app.contains("Message::CropConfirm"),
|
||||
"{CROP_LIFECYCLE}"
|
||||
);
|
||||
assert!(
|
||||
canvas.contains("iced::widget::text_editor(&draft.editor)")
|
||||
&& app.contains("Message::TextEditorAction(action)"),
|
||||
"{TEXT_MULTILINE}"
|
||||
);
|
||||
assert!(
|
||||
raster.contains("pub fn apply_gradient")
|
||||
&& app.contains("self.settings.tool_settings.gradient_type == 1"),
|
||||
"{TRUE_GRADIENT}"
|
||||
);
|
||||
assert!(
|
||||
app.contains("paint_tool && (self.modifiers.control()")
|
||||
&& app.contains("paint_tool && self.modifiers.shift()")
|
||||
&& app.contains("Message::FitToArea")
|
||||
&& shader.contains("state.space_left_pan = true"),
|
||||
"{CANVAS_INPUT_PARITY}"
|
||||
);
|
||||
assert!(
|
||||
app.contains(".draw_pen_segment(")
|
||||
&& app.contains("tip.spray_particle_size")
|
||||
&& app.contains("tip.spray_density"),
|
||||
"{PEN_SPRAY_DISTINCT}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_cli_uses_native_capture_and_panel_crop() {
|
||||
let app = source("src/app.rs");
|
||||
let screenshot = source("src/screenshot.rs");
|
||||
assert!(
|
||||
app.contains("iced::window::screenshot(id)"),
|
||||
"{SCREENSHOT_NATIVE_CAPTURE}"
|
||||
);
|
||||
assert!(
|
||||
app.contains("crate::screenshot::crop_panel("),
|
||||
"{SCREENSHOT_PANEL_CROP}"
|
||||
);
|
||||
assert!(
|
||||
screenshot.contains(".layout()\n .pane_regions"),
|
||||
"{SCREENSHOT_PANEL_CROP}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_one_state_paths_are_complete_and_persisted() {
|
||||
let app = source("src/app.rs");
|
||||
let settings = source("src/settings.rs");
|
||||
|
||||
assert!(
|
||||
app.contains("self.dock.pane_grid.drop(pane, target)"),
|
||||
"{PANE_FULL_DROP_TARGETS}"
|
||||
);
|
||||
assert!(
|
||||
!app.contains("Task::perform(async move { theme }, Message::ThemeChanged)"),
|
||||
"{THEME_SYNC_ISOLATION}"
|
||||
);
|
||||
assert!(
|
||||
app.contains("self.settings.theme_preset = theme"),
|
||||
"{THEME_SYNC_ISOLATION}"
|
||||
);
|
||||
assert!(
|
||||
settings.contains("pub theme_preset: crate::theme::ThemePreset"),
|
||||
"{THEME_SYNC_ISOLATION}"
|
||||
);
|
||||
assert!(
|
||||
app.contains("settings.panel_layout.sidebar_expanded"),
|
||||
"{SIDEBAR_MODE_PERSISTENCE}"
|
||||
);
|
||||
assert!(
|
||||
app.contains("self.settings.panel_layout.sidebar_expanded = self.sidebar_expanded"),
|
||||
"{SIDEBAR_MODE_PERSISTENCE}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifies the Cycle 2 vector completion paths remain connected to pure calculations and UI.
|
||||
///
|
||||
/// Purpose: Provides stable source-level scorecard evidence in addition to calculation unit tests.
|
||||
/// Logic & Workflow: Checks semantic state/session types and reachable handlers rather than fragile
|
||||
/// line positions. Side Effects / Dependencies: Reads local production sources only.
|
||||
#[test]
|
||||
fn cycle_two_vector_completion_paths_are_connected() {
|
||||
let app = source("src/app.rs");
|
||||
let canvas = source("src/canvas/mod.rs");
|
||||
let edit = source("src/vector_edit.rs");
|
||||
|
||||
assert!(
|
||||
edit.contains("HANDLE_HIT_HALF_PX / zoom") && edit.contains("ROTATE_HIT_RADIUS_PX / zoom"),
|
||||
"{VECTOR_SCREEN_CONSTANT_HANDLES}"
|
||||
);
|
||||
assert!(
|
||||
app.contains("crate::vector_edit::transform_shape(")
|
||||
&& app.contains("self.modifiers.shift()")
|
||||
&& app.contains("self.modifiers.alt()")
|
||||
&& app.contains("engine.set_vector_shape_bounds(")
|
||||
&& app.contains("engine.set_vector_shape_angle(")
|
||||
&& edit.contains("MIN_VECTOR_SIZE"),
|
||||
"{VECTOR_CONSTRAINED_DRAG}"
|
||||
);
|
||||
assert!(
|
||||
app.contains("get_layer_shapes_direct(snapshot.layer_id)")
|
||||
&& app.contains("self.vector_drag_session = None"),
|
||||
"{VECTOR_LAYER_BOUND_CANCEL}"
|
||||
);
|
||||
assert!(
|
||||
app.contains("let succeeded = valid")
|
||||
&& app.contains("selected_vector_shape = Some(result_index)"),
|
||||
"{VECTOR_BOOLEAN_RESULT}"
|
||||
);
|
||||
assert!(
|
||||
canvas.contains("pane_w - controls_width") && canvas.contains("Message::VectorShapeDelete"),
|
||||
"{VECTOR_CLAMPED_CONTROLS}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifies Cycle 4 panel and document-lifecycle paths remain semantic and non-destructive.
|
||||
///
|
||||
/// Purpose: Records stable evidence for immutable previews, style cancellation, layer hierarchy,
|
||||
/// filter schema coverage, and successful-save identity. Logic & Workflow: Inspects the GUI-only
|
||||
/// implementations for their explicit state and engine-API routing. Side Effects / Dependencies:
|
||||
/// Reads production source files only.
|
||||
#[test]
|
||||
fn cycle_four_panel_and_document_paths_are_connected() {
|
||||
let app = source("src/app.rs");
|
||||
let layers = source("src/panels/layers.rs");
|
||||
let filters = source("src/panels/filters.rs");
|
||||
let filter_params = source("src/panels/filter_params.rs");
|
||||
let title_bar = source("src/panels/title_bar.rs");
|
||||
|
||||
assert!(
|
||||
layers.contains("Message::LayerToggleCollapse(info.id)")
|
||||
&& layers.contains("Message::LayerToggleVisibility(info.id)")
|
||||
&& layers.contains("Message::LayerToggleLock(info.id)")
|
||||
&& app.contains("set_layer_parent(active_id, Some(group_id))")
|
||||
&& app.contains("move_layer(id, target_idx)"),
|
||||
"{LAYERS_HIERARCHY_CONTROLS}"
|
||||
);
|
||||
assert!(
|
||||
layers.contains("Fill opacity: unavailable in hcie-engine-api")
|
||||
&& layers.contains("Disabled (engine API): duplicate / mask / rasterize")
|
||||
&& layers.contains("button(text(\"Dup\")")
|
||||
&& layers.contains("button(text(\"Mask\")")
|
||||
&& layers.contains("button(text(\"Raster\")"),
|
||||
"{LAYERS_HIERARCHY_CONTROLS}: unsupported operations must be visibly disabled"
|
||||
);
|
||||
assert!(
|
||||
app.contains("layer_style_baseline: Option<LayerStyleBaseline>")
|
||||
&& app.contains("remove_layer_style_by_index(baseline.layer_id, index)")
|
||||
&& app.contains("doc.engine.update_layer_style(baseline.layer_id, style)"),
|
||||
"{LAYER_STYLES_CANCEL_RESTORE}"
|
||||
);
|
||||
assert!(
|
||||
app.contains("preview_baseline: Option<PreviewBaseline>")
|
||||
&& app.contains("reset_preview_to_baseline()")
|
||||
&& app.contains("set_layer_pixels(baseline.layer_id, baseline.pixels.clone())"),
|
||||
"{IMMUTABLE_PIXEL_PREVIEW}"
|
||||
);
|
||||
for variant in [
|
||||
"BoxBlurGamma",
|
||||
"GaussianBlurGamma",
|
||||
"MedianFilter",
|
||||
"SelectiveColor",
|
||||
"NoisePattern",
|
||||
] {
|
||||
assert!(
|
||||
filters.contains(variant) && filter_params.contains(variant),
|
||||
"{FILTER_SCHEMA_COVERAGE}: missing {variant}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
app.contains("fn save_document_to(")
|
||||
&& app.contains("\"hcie\" => engine.save_native(&path_str)")
|
||||
&& app.contains("finish_successful_save")
|
||||
&& title_bar.contains("if modified"),
|
||||
"{DOCUMENT_PATH_IDENTITY}"
|
||||
);
|
||||
assert!(
|
||||
app.contains("PendingFileOperation::SaveAs { close_after: true }")
|
||||
&& app.contains("continue_close_after_save()")
|
||||
&& app.contains("Some(PendingFileOperation::Open)"),
|
||||
"{CLOSE_SAVE_CONTINUATION}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifies Cycle 5 AI, script, and viewer workflows have semantic handlers and safety gates.
|
||||
#[test]
|
||||
fn cycle_five_practical_paths_are_connected() {
|
||||
let app = source("src/app.rs");
|
||||
let chat = source("src/ai_chat.rs");
|
||||
let ai_script = source("src/ai_script.rs");
|
||||
let viewer = source("src/viewer/mod.rs");
|
||||
|
||||
assert!(
|
||||
chat.contains("iced::stream::channel")
|
||||
&& app.contains("Task::run(")
|
||||
&& app.contains("task.abortable()")
|
||||
&& app.contains("Message::AiChatStreamChunk(chunk)"),
|
||||
"{AI_REAL_STREAMING}"
|
||||
);
|
||||
assert!(
|
||||
app.contains("Message::AiChatReasoningChunk")
|
||||
&& app.contains("Message::AiChatCopyTranscript")
|
||||
&& chat.contains("pub fn transcript_text"),
|
||||
"{AI_REASONING_TRANSCRIPT}"
|
||||
);
|
||||
assert!(
|
||||
ai_script.contains("pub fn validate_script_output")
|
||||
&& ai_script.contains("ANTHROPIC_API_KEY")
|
||||
&& ai_script.contains("OPENAI_API_KEY"),
|
||||
"{SCRIPT_VALIDATED_OUTPUT}"
|
||||
);
|
||||
assert!(
|
||||
viewer.contains("Message::ViewerPrev")
|
||||
&& viewer.contains("Message::ViewerNext")
|
||||
&& app.contains("Message::ViewerSetMode(mode)"),
|
||||
"{VIEWER_DETERMINISTIC_NAV}"
|
||||
);
|
||||
assert!(
|
||||
app.contains("Open into a new editor document")
|
||||
&& app.contains("self.documents.remove(document_index)"),
|
||||
"{VIEWER_SAFE_EDIT}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Pure Cycle 3 raster behavior tests.
|
||||
|
||||
use hcie_iced_gui::raster::apply_gradient;
|
||||
|
||||
#[test]
|
||||
fn linear_gradient_interpolates_and_respects_mask() {
|
||||
let mut pixels = vec![0; 3 * 4];
|
||||
let mask = [255, 255, 0];
|
||||
apply_gradient(
|
||||
&mut pixels,
|
||||
3,
|
||||
1,
|
||||
(0.5, 0.5),
|
||||
(2.5, 0.5),
|
||||
false,
|
||||
[0, 0, 0, 255],
|
||||
[200, 100, 50, 255],
|
||||
1.0,
|
||||
Some(&mask),
|
||||
);
|
||||
assert_eq!(&pixels[0..4], &[0, 0, 0, 255]);
|
||||
assert_eq!(&pixels[4..8], &[100, 50, 25, 255]);
|
||||
assert_eq!(&pixels[8..12], &[0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_gradient_uses_distance_from_origin() {
|
||||
let mut pixels = vec![0; 3 * 4];
|
||||
apply_gradient(
|
||||
&mut pixels,
|
||||
3,
|
||||
1,
|
||||
(0.5, 0.5),
|
||||
(2.5, 0.5),
|
||||
true,
|
||||
[0, 0, 0, 255],
|
||||
[200, 0, 0, 255],
|
||||
1.0,
|
||||
None,
|
||||
);
|
||||
assert_eq!(pixels[0], 0);
|
||||
assert_eq!(pixels[4], 100);
|
||||
assert_eq!(pixels[8], 200);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Tests for selection types: SelectionTransform, TransformHandle, CropState.
|
||||
|
||||
use hcie_iced_gui::selection::{CropState, SelectionTransform, TransformHandle};
|
||||
use hcie_iced_gui::selection::clipboard::centered_transform;
|
||||
use hcie_iced_gui::selection::state::{combine_masks, mask_bounds, selected_spans};
|
||||
use hcie_iced_gui::selection::{CropState, SelectionMode, SelectionTransform, TransformHandle};
|
||||
|
||||
#[test]
|
||||
fn test_selection_transform_is_empty() {
|
||||
@@ -70,3 +72,53 @@ fn test_transform_handle_cursor_hints() {
|
||||
assert_eq!(TransformHandle::TopLeft.cursor_hint(), "nwse-resize");
|
||||
assert_eq!(TransformHandle::Rotate.cursor_hint(), "crosshair");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_modes_combine_alpha_without_losing_soft_edges() {
|
||||
let old = [0, 64, 255, 255];
|
||||
let new = [255, 128, 64, 0];
|
||||
assert_eq!(combine_masks(Some(&old), &new, SelectionMode::Replace), new);
|
||||
assert_eq!(
|
||||
combine_masks(Some(&old), &new, SelectionMode::Add),
|
||||
[255, 128, 255, 255]
|
||||
);
|
||||
assert_eq!(
|
||||
combine_masks(Some(&old), &new, SelectionMode::Subtract),
|
||||
[0, 0, 191, 255]
|
||||
);
|
||||
assert_eq!(
|
||||
combine_masks(Some(&old), &new, SelectionMode::Intersect),
|
||||
[0, 64, 64, 0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn irregular_mask_bounds_and_fill_spans_are_exact() {
|
||||
let mask = [0, 255, 0, 0, 255, 255, 0, 0, 0, 255, 0, 0];
|
||||
assert_eq!(mask_bounds(&mask, 4, 3), Some((0, 0, 2, 3)));
|
||||
assert_eq!(
|
||||
selected_spans(&mask, 4, 3),
|
||||
vec![(1, 0, 2), (0, 1, 2), (1, 2, 2)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_clamps_drag_and_cancel_restores_idle_state() {
|
||||
let mut crop = CropState::default();
|
||||
crop.start_drag_clamped(-20.0, 5.0, 100, 80);
|
||||
crop.update_drag(140.0, 120.0);
|
||||
assert_eq!(crop.rect, Some((0.0, 5.0, 100.0, 75.0)));
|
||||
crop.cancel();
|
||||
assert_eq!(crop.canvas_size, None);
|
||||
assert!(!crop.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_transform_is_centered_and_transform_geometry_is_safe() {
|
||||
let mut transform = centered_transform(vec![255; 8 * 4 * 4], 8, 4, 100, 50).unwrap();
|
||||
assert_eq!(transform.pos, iced::Vector::new(46.0, 23.0));
|
||||
transform.size = iced::Vector::new(-20.0, f32::NAN);
|
||||
transform.sanitize_geometry(true);
|
||||
assert!(transform.size.x > 0.0 && transform.size.y > 0.0);
|
||||
assert!((transform.size.x / transform.size.y - 2.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
@@ -10,3 +10,4 @@ crate-type = ["rlib"]
|
||||
hcie-engine-api = { path = "../../../hcie-engine-api" }
|
||||
iced = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -35,9 +35,9 @@ pub fn render_module_panel(panel: &ModulePanel) -> Element<'_, PanelMessage> {
|
||||
/// Renders a single `WidgetDescription` as an Iced element.
|
||||
pub fn render_widget(widget: &WidgetDescription) -> Element<'_, PanelMessage> {
|
||||
match widget {
|
||||
WidgetDescription::Label { text: label_text, .. } => {
|
||||
text(label_text.as_str()).into()
|
||||
}
|
||||
WidgetDescription::Label {
|
||||
text: label_text, ..
|
||||
} => text(label_text.as_str()).into(),
|
||||
WidgetDescription::Button { id, label } => {
|
||||
let id = id.clone();
|
||||
let label = label.clone();
|
||||
@@ -45,7 +45,14 @@ pub fn render_widget(widget: &WidgetDescription) -> Element<'_, PanelMessage> {
|
||||
.on_press(PanelMessage::ValueChanged(id, serde_json::Value::Null))
|
||||
.into()
|
||||
}
|
||||
WidgetDescription::Slider { id, label, min, max, value, .. } => {
|
||||
WidgetDescription::Slider {
|
||||
id,
|
||||
label,
|
||||
min,
|
||||
max,
|
||||
value,
|
||||
..
|
||||
} => {
|
||||
let id = id.clone();
|
||||
let current = *value;
|
||||
|
||||
@@ -91,7 +98,12 @@ pub fn render_widget(widget: &WidgetDescription) -> Element<'_, PanelMessage> {
|
||||
})
|
||||
.into()
|
||||
}
|
||||
WidgetDescription::Dropdown { id, label, options, selected } => {
|
||||
WidgetDescription::Dropdown {
|
||||
id,
|
||||
label,
|
||||
options,
|
||||
selected,
|
||||
} => {
|
||||
let id = id.clone();
|
||||
let selected_text = options.get(*selected).cloned().unwrap_or_default();
|
||||
|
||||
@@ -102,14 +114,12 @@ pub fn render_widget(widget: &WidgetDescription) -> Element<'_, PanelMessage> {
|
||||
let idx = options.iter().position(|o| *o == choice).unwrap_or(0);
|
||||
PanelMessage::ValueChanged(id.clone(), serde_json::Value::from(idx))
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.spacing(4)
|
||||
.into()
|
||||
}
|
||||
WidgetDescription::Separator => {
|
||||
horizontal_rule(1).into()
|
||||
}
|
||||
WidgetDescription::Separator => horizontal_rule(1).into(),
|
||||
WidgetDescription::Group { label, children } => {
|
||||
let mut inner = iced::widget::Column::new()
|
||||
.spacing(4)
|
||||
|
||||
@@ -16,7 +16,7 @@ use iced::Color;
|
||||
///
|
||||
/// `Photopea` is the default — matches the Photopea image editor's dark UI.
|
||||
/// Other presets are kept as alternatives.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub enum ThemePreset {
|
||||
#[default]
|
||||
Photopea,
|
||||
|
||||
Reference in New Issue
Block a user