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

- Add `selection/state.rs` to manage selection masks, including combining masks and calculating bounds.
- Introduce `vector_edit.rs` for vector shape manipulation, handling drag sessions, and hit testing for handles.
- Create `feature_scorecard.rs` to ensure stable feature identifiers and regression gates for GUI components.
- Implement raster behavior tests in `raster_test.rs` to validate gradient application against masks.
This commit is contained in:
2026-07-16 18:41:05 +03:00
parent 026e268a10
commit 1240d25011
70 changed files with 11547 additions and 4357 deletions
@@ -0,0 +1,267 @@
# ICED GUI Parity: Scored Migration and Vector Tool Plan
## Goal
Bring every retained user-facing ICED editor feature to **at least 9/10 relative to egui**. Work proceeds in scored cycles. A cycle is incomplete until the complete scorecard is rerun and touched features reach target without regressing earlier work.
## Constraints
- GUI changes only under `hcie-iced-app/crates/`; `hcie-engine-api` remains the sole engine entry point.
- Preserve persistent wgpu textures, dirty-region uploads, viewport mapping, nearest filtering, and the `RefCell` dirty accumulator.
- Theme changes are UI-only. They must never invoke image/viewer loading, imports, dialogs, document replacement, or pixel mutation.
- Reachable `NoOp`, static fake controls, wrong menu aliases, and materially incorrect fallbacks score at most 2/10.
- Add ICED screenshot capture first; inspect screenshots after every visual change.
- Preserve unrelated working-tree changes.
## Scoring
| Score | Meaning |
|---|---|
| 0 | Absent/unusable |
| 1-2 | Placeholder; no real action |
| 3-4 | Partial, major paths missing |
| 5-6 | Basic workflow, substantial defects |
| 7 | Main workflow works, notable gaps |
| 8 | Near parity, minor gaps |
| 9 | Production parity: correct UI/action/undo/persistence as applicable, tested and visually verified |
| 10 | No known gap under covered scenarios |
## Baseline Inventory
Scores reflect the current working tree; the old vector `2.5/10` assessment is obsolete.
### Shell, Menus, Docking
| Feature | Current | Key gap | Target |
|---|---:|---|---:|
| Shell/title/tabs/window controls | 7 | Document close is `NoOp`; title drag overlaps menu region | 9 |
| Menu typography | 5 | Dropdown labels are thin/mixed contrast | 9 |
| Dropdown placement/event capture | 4 | Character-count X estimate; shifted/click-through risk | 9 |
| Menu command coverage | 3 | Most enabled-looking commands are labels/comments | 9 |
| Theme switching | 6 | Reported image-load side effect untested; no persistence | 9 |
| Dock resize/maximize/close | 8 | Main behavior works | 9 |
| Dock relocation | 6 | Drop only swaps; directional drop regions ignored | 9 |
| Dock profile/layout persistence | 5 | Does not serialize exact split tree/order/ratios | 9 |
| Panel reopen/focus | 7 | Coarse hardcoded placement | 9 |
| Settings persistence | 6 | Theme/toolbox/exact dock state missing | 9 |
| Internationalization | 3 | Most strings bypass i18n | 9 |
| ICED screenshot harness | 1 | F12 only refreshes | 9 |
### Canvas and Tools
| Feature | Current | Key gap | Target |
|---|---:|---|---:|
| GPU canvas/checkerboard/partial upload | 9 | Protected path active | 9 |
| Pan/zoom/100% | 8 | True fit-to-area incomplete | 9 |
| Fit/navigation/pinch/space-pan | 5 | Ctrl+0 sets 1.0; missing expected navigation | 9 |
| Brush/pressure | 8 | Modifier UX/cursor footprint | 9 |
| Pen | 5 | Mostly treated as Brush unlike egui pen segments | 9 |
| Eraser | 8 | Per-tool config/cursor polish | 9 |
| Spray | 7 | Verify density/particle settings reach engine | 9 |
| Flood fill | 7 | Contiguous/AA/history validation | 9 |
| Gradient | 5 | Stroke approximation, not true linear/radial gradient | 9 |
| Eyedropper | 7 | Ctrl-click temporary pick missing | 9 |
| Brush cursor/Shift resize HUD | 3 | Fixed crosshair only | 9 |
| Canvas context menu | 5 | Cut maps to Copy; partial actions | 9 |
| Rulers/grid/guides/snap/pixel grid | 1 | Menu shells only | 9 |
| Status/coordinates/pressure | 7 | Needs live cross-theme verification | 9 |
| Retouch tools | 2 | Selectable toolbox entries, incomplete execution/options | 9 |
### Selection, Transform, Clipboard
| Feature | Current | Key gap | Target |
|---|---:|---|---:|
| Rectangle selection | 8 | Minor parity/tests | 9 |
| Lasso/polygon | 7 | Preview/edge/close behavior | 9 |
| Magic wand | 7 | AA/contiguous partially `NoOp` | 9 |
| SmartSelect/SAM | 3 | Magic-wand fallback, no real backend flow | 9 |
| VisionSelect | 3 | Rectangle fallback, no model/task flow | 9 |
| Marching ants/fill | 5 | Bounds, not irregular mask edges | 9 |
| Raster transform | 7 | Undo/aspect/handles verification | 9 |
| Crop | 8 | Minor parity/tests | 9 |
| Copy/paste | 7 | External/internal selection tests | 9 |
| Cut/clear | 2 | Cut is Copy; Clear incomplete | 9 |
| Selection modify/load/save/quick mask | 4 | Some operations exist; exposed gaps remain | 9 |
### Vector and Text
| Feature | Current | Key gap | Target |
|---|---:|---|---:|
| Creation, 15 shape types | 8 | Verify layer safety/undo | 9 |
| Accurate previews | 7 | Several shapes remain approximate | 9 |
| VectorSelect/cycling | 7 | Implemented but insufficiently tested | 9 |
| Handles/hit testing | 7 | Rotated/hover/zoom behavior needs hardening | 9 |
| Move/8-way resize/rotate | 7 | Constraints, undo, edge cases | 9 |
| Apply/Cancel/snapshot | 7 | Verify exact restoration and overlay UX | 9 |
| Flip H/V | 6 | Verify supported shapes/output | 9 |
| Delete | 8 | Main path exists | 9 |
| Geometry list/selection | 8 | Main path exists | 9 |
| Bounds/fill/stroke/opacity/hardness | 8 | Precision/sync/tests | 9 |
| Line caps | 7 | Always-expanded list, weak selector UX | 9 |
| Boolean operations | 7 | Validation/failure/undo/result selection | 9 |
| Tool↔shape sync | 6 | Properties/Geometry/toolbar can diverge | 9 |
| Text create/inline edit | 6 | Focus/multiline/existing-layer edit/cancel | 9 |
| Text properties/effects | 6 | Full egui parity missing | 9 |
### Panels and Workflows
| Feature | Current | Key gap | Target |
|---|---:|---|---:|
| Layers | 8 | Hierarchy/duplicate/masks/fill opacity verification | 9 |
| History | 7 | Every operation needs correct undo boundary | 9 |
| Brushes & Tips | 7 | Category filter `NoOp`; advanced editor gaps | 9 |
| Color/palette/recent/alpha | 7 | Continuous wheel/recent UX/vector sync | 9 |
| Filters/preview | 8 | Audit every schema/default/apply/reset | 9 |
| Properties | 7 | Duplicate state surfaces may diverge | 9 |
| Tool Settings/toolbar | 7 | AA toggles/buttons remain `NoOp` | 9 |
| Layer Styles | 8 | Add-style placeholder/undo validation | 9 |
| Layer Details | 7 | Field/action parity audit | 9 |
| Script | 8 | Diagnostics/integration tests | 9 |
| AI Chat | 7 | Cancel/error/model discovery tests | 9 |
| AI Script | 7 | Provider/error/validation robustness | 9 |
| Viewer/browser | 7 | Cache/fullscreen/error/open-edit tests | 9 |
| Dialogs | 8 | Focus/validation/modal behavior | 9 |
| Plugins/actions/channels/histogram/navigator exposed by More | 1 | Labels only | 9 |
### Files and Documents
| Feature | Current | Key gap | Target |
|---|---:|---|---:|
| New/open/recent/import | 8 | Error and format coverage | 9 |
| Save/save-as/export | 6 | Unsupported menu claims/export paths | 9 |
| Multi-document tabs/dirty close | 6 | Tab close `NoOp`; dirty coverage | 9 |
| Image/canvas size/crop/merge/flatten | 8 | Regression tests | 9 |
| Layer duplicate/group/masks/rasterize/smart object | 3 | Menu shells/TODOs | 9 |
| Print/share/file-info/automation | 1 | Menu shells | 9 |
## Score Evidence Rules
A score may rise only when:
- UI is reachable and correctly positioned.
- A semantic message and real handler exist; no `NoOp` or wrong alias.
- State/engine output is correct.
- Undo/dirty state and persistence are correct where expected.
- Automated test passes.
- Visual behavior has an inspected screenshot.
Generate after every cycle under `target/iced-audit/`: full score table, failing evidence, test output, and screenshots. Add `tests/feature_scorecard.rs` with stable feature IDs and evidence test names.
## Cycle 0: Audit and Verification Infrastructure
1. Build a canonical registry from egui panes, tools, menus, dialogs, shortcuts and canvas interactions.
2. Add tests that fail for reachable `NoOp`, Cut→Copy aliases, enabled menu items without semantic handlers, or menu definition/index drift.
3. Add ICED screenshot CLI: `--screenshot output.png` and `--screenshot-panel "Panel" output.png`; settle layout/texture then crop from measured pane bounds.
4. Capture baseline full viewport, all menus, collapsed/expanded toolbox, Geometry, Properties, Layers, Color, and selected-vector canvas.
5. Run build/tests and record baseline scores. Do not raise scores without evidence.
6. **Recheck all scores.**
## Cycle 1: Requested UX Fixes
### Menus
Files: `panels/title_bar.rs`, `panels/menus.rs`, `app.rs`.
1. Replace approximate character-width offsets with measured menu-button anchor rectangles or an overlay anchored in the same layout subtree.
2. Place each dropdown at `anchor.x/anchor.bottom`, clamp to viewport, and make its outside-click layer fill/capture the viewport.
3. Separate draggable title space from menu buttons; menu clicks must not emit `WindowDrag` or reach underlying canvas/viewer.
4. Use a bundled readable UI font and Medium/Semibold weight for dropdown labels, shortcuts and arrows; use theme contrast tokens.
5. Replace raw `(menu_idx,item_idx)` dispatch with semantic `MenuCommand` generated from the menu definition.
6. Test all nine anchors at multiple widths/DPI; screenshot every dropdown in dark/light themes.
### Theme side-effect regression
1. Make theme commands synchronous: set colors, persist preset, close menu, repaint only.
2. Instrument file/import/viewer-preview/dialog/document replacement calls.
3. Test all themes preserve document ID/path/layers/composite hash/viewer selection/recent files/file-dialog state.
4. Switch themes while drawing and with viewer active/inactive; prove no image load or texture reset.
### Movable/reconfigurable panels
Files: `dock/state.rs`, `dock/view.rs`, `app.rs`, `settings.rs`, dock-profile dialog.
1. Handle full PaneGrid drop target: center swap; left/right/top/bottom close-and-split in requested side/order.
2. Preserve exactly one Canvas and one instance per utility pane; prevent closing/moving away the last Canvas.
3. Serialize actual dock tree recursively: pane type, split axis, ratio, child order, maximized/focused state.
4. Restore defensively with dedupe, ratio clamps, unknown-pane tolerance and default fallback.
5. Save/load/rename/delete exact profile layouts; add Painting, Vector, AI and Viewer presets.
6. Test every panel move/resize/close/reopen/maximize/save/reload.
### Expandable two-column toolbox
Files: `sidebar/mod.rs`, `app.rs`, `settings.rs`.
1. Persist collapsed 36px one-column and expanded ~72px two-column mode.
2. Add accessible chevron and shortcut.
3. Short click activates last-used subtool; secondary click/long press opens subtools; outside/Escape closes.
4. Expanded mode exposes all tools via a scrollable grouped two-column grid.
5. Every selectable tool must execute a real action or be visibly disabled with explanation.
6. Verify all 15 vector shapes, VectorSelect, Smart/Vision selection and retouch tools.
7. Screenshot both modes and popups at minimum height.
### Cycle 1 gate
Recheck the complete scorecard. Do not proceed until menu readability/placement, theme isolation, docking relocation/persistence and toolbox mode are each ≥9.
## Cycle 2: Vector Completion
1. Test selection, deselection, overlap cycling and active-layer changes.
2. Make handle hit areas screen-constant across zoom/pan. Render rotated geometry handles with hover/active styles and correct cursors.
3. Test move, all eight resize handles and rotation for every shape; prevent NaN/min-size/inversion errors; add Shift aspect and Alt center constraints.
4. Use one undo transaction per drag.
5. Render viewport-clamped Apply, Cancel, Flip H/V and Delete controls. Snapshot once; Apply commits once; Cancel restores exact state.
6. Centralize `sync_tool_from_shape` and `sync_shape_from_tool` so Geometry, Properties, toolbar, Tool Settings and color picker agree.
7. Replace always-expanded Geometry selectors with pick lists/popovers; add precise numeric inputs and shape-specific controls.
8. Validate boolean operands, failures, one undo entry and result selection for Union/Intersect/Subtract/Xor.
9. Share geometry generation between live preview and committed shape for all 15 variants plus FreePath; preserve anti-alias density.
10. Verify safe auto-vector-layer creation on raster/locked layers.
11. **Recheck every vector row; none below 9.**
## Cycle 3: Canvas, Selection, Raster, Text, Clipboard
1. Extract/cache actual mask edges for marching ants and fill irregular selected pixels, not bounds.
2. Complete lasso/polygon previews, MagicWand AA/contiguous and selection modify/load/save/quick-mask.
3. Make raster transform fully undoable with aspect lock and on-canvas Apply/Cancel.
4. Implement real Cut/Clear and selection-aware internal/external clipboard.
5. Add Ctrl-click eyedropper, Shift brush resize, space-pan, true fit-to-area and actual brush footprint cursor.
6. Give Pen distinct egui behavior; prove Spray settings reach engine; implement true linear/radial gradients.
7. Complete inline text focus/multiline/live preview/rotated caret/edit-existing/Accept/Cancel/properties/effects.
8. Wire real SmartSelect/VisionSelect/retouch backends; fake fallbacks cannot score 9. Disable with explanation only as a temporary state.
9. Implement exposed rulers/grid/guides/snap/pixel-grid states and rendering.
10. **Recheck all scores; no Cycle 3 row below 9.**
## Cycle 4: Panels
1. Layers: duplicate, grouping/hierarchy, reorder, fill opacity, masks/rasterize where API exists, context actions and history.
2. Brushes: category filter, presets/editing/imports/thumbnails/per-tool isolation.
3. Color: continuous wheel/SL drag, 40 recent wrapping/collapse/right-click-background, theme style and vector sync.
4. Filters: audit every FilterType schema/default/range/reset/preview/apply/error.
5. Establish one authoritative tool/property state; remove contradictory duplicates and all reachable `NoOp`.
6. Complete Layer Styles/Details/History undo/empty/selection behavior.
7. Complete dialog validation/focus/Enter/Escape/modal capture/responsiveness.
8. Add panel UI tests/screenshots in every theme.
9. **Recheck all scores; no panel row below 9.**
## Cycle 5: Menus, Files, Viewer, Script, AI, Remaining Features
1. Derive menu labels and semantic dispatch from one source; implement every enabled item.
2. Where support truly does not exist, disable with tooltip rather than presenting an enabled no-op. Retained egui features still require implementation.
3. Complete document close/dirty prompts, recent failures, save/export format selection/errors.
4. Complete viewer cache/navigation/fullscreen/open-edit/malformed-file behavior.
5. Script/AI: cancellation, provider/model discovery, network errors, diagnostics, execution confirmation, history/refresh.
6. Add real egui-supported More panels/actions (notably Plugins and Layer Styles); remove unsupported Photopea-only claims.
7. Apply i18n keys to every visible string and persist language.
8. Ensure menu and shortcut dispatch the same semantic command.
9. **Recheck the complete inventory; repeat Cycle 5 for every row below 9.**
## Final Regression Cycle
Run:
- `cargo fmt --check`
- `cargo build -p hcie-iced-gui`
- `cargo test -p hcie-iced-gui`
- scorecard/UI/integration tests
- `cargo test -p hcie-engine-api --test visual_regression` for engine-visible pixels
- `cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture` for stroke/canvas work
Then launch ICED, exercise every scorecard scenario, compare final screenshots to Cycle 0, prove theme composite hashes are unchanged, verify dock restart recovery, and rescore everything. **Stop only when every retained feature is ≥9/10.**
## Mandatory Scenarios
- Every menu anchor, narrow/wide viewport, DPI scales, dark/light themes.
- Six themes with active drawing and viewer active/inactive; no document mutation.
- Every pane moved to every region, resized, closed/reopened, maximized, persisted.
- Toolbox one/two columns, all subtools, minimum height, Escape/outside click.
- Every vector shape create/select/move/8-resize/rotate/apply/cancel/delete/flip/properties/boolean.
- Rect/lasso/polygon/magic/AI selection, irregular ants, transform, crop, clipboard, undo.
- Multi-document open/switch/close/dirty, import/export failures.
- 4K continuous stroke, pan/zoom, vector drag and theme switch without full-upload regressions.
## Risks
- PaneGrid may not support floating windows/tabs directly. Required acceptance is arbitrary split relocation and exact persistence; floating windows are optional unless added without state/event duplication.
- Some menu claims exceed egui/engine support. Honest disabled states are preferable to fake enabled actions, but egui parity features must be implemented.
- Document engine API gaps before any locked-crate proposal; never import internal crates into GUI.
- Re-read files before patches because the working tree has concurrent/uncommitted changes.
## Done Definition
- No retained feature below 9.
- No reachable production `NoOp` or wrong semantic alias.
- No enabled menu item without a tested handler.
- Panels relocate, resize and restore exactly.
- Menus are readable and exactly anchored.
- Theme changes cannot load/mutate images.
- Toolbox has persisted one/two-column modes and all vector tools are connected.
- Vector handles/editing are accurate, undoable and visually verified.
- Protected GPU optimizations remain intact.
Generated
+1
View File
@@ -3054,6 +3054,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"hcie-engine-api", "hcie-engine-api",
"iced", "iced",
"serde",
"serde_json", "serde_json",
] ]
@@ -0,0 +1,136 @@
# Vector Tool Migration — Scoring & Implementation Plan
## All Vector Tool Features — Current ICED Score (0-10)
| # | Feature | egui | ICED | Gap |
|---|---------|------|------|-----|
| 1 | Shape creation (15 types) via drag | 10 | 8 | Previews for 11 shapes are bounding-box fallback |
| 2 | Auto-vector-layer creation | 10 | 10 | None |
| 3 | VectorSelect click-to-select | 10 | 0 | **MISSING** |
| 4 | VectorEditHandle hit-testing | 10 | 0 | **MISSING** |
| 5 | Shape drag-move via handles | 10 | 0 | **MISSING** |
| 6 | Shape resize via handles | 10 | 0 | **MISSING** |
| 7 | Shape rotation via handle | 10 | 0 | **MISSING** |
| 8 | Shape cycling (overlapping) | 10 | 0 | **MISSING** |
| 9 | Geometry panel: shape list | 10 | 2 | Hardcoded `&[]`, no selection |
| 10 | Geometry panel: shape selection | 10 | 0 | **MISSING** |
| 11 | Geometry panel: X1/Y1/X2/Y2 bounds | 10 | 0 | **MISSING** |
| 12 | Geometry panel: fill toggle | 10 | 0 | **MISSING** |
| 13 | Geometry panel: fill color picker | 10 | 0 | **MISSING** |
| 14 | Geometry panel: stroke color picker | 10 | 0 | **MISSING** |
| 15 | Geometry panel: opacity slider | 10 | 0 | **MISSING** |
| 16 | Geometry panel: hardness slider | 10 | 0 | **MISSING** |
| 17 | Geometry panel: line cap controls | 10 | 0 | **MISSING** |
| 18 | Geometry panel: boolean operations | 10 | 0 | **MISSING** |
| 19 | Shape Apply/Cancel overlay | 10 | 0 | **MISSING** |
| 20 | Shape Flip H/V | 10 | 0 | **MISSING** |
| 21 | shape_sync bidirectional sync | 10 | 0 | **MISSING** |
| 22 | Shape deletion | 10 | 0 | **MISSING** |
| 23 | Accurate previews (Arrow, etc.) | 10 | 3 | 11 shapes use bounding-box fallback |
| 24 | Tool settings panel (stroke, fill, etc.) | 10 | 7 | Missing hardness, line cap |
| 25 | Shape-specific preview (Star, Polygon) | 10 | 6 | Only 4 shapes have accurate preview |
**Overall ICED Score: ~2.5/10** — Shape creation works but selection/editing is completely missing.
---
## Implementation Phases
### Phase A: VectorSelect Core (Features 3-8, 21-22) — Score Target: 8/10
**Sub-goal A1: VectorSelect state and click-to-select**
- Add `selected_vector_shape: Option<usize>` to app state
- Add `vector_shapes_snapshot: Option<Vec<VectorShape>>` for undo
- Add `vector_edit_handle: VectorEditHandle` state
- Add `vector_drag_last: Option<(f32, f32)>` tracking
- Implement `VectorSelect` click handling in canvas
- Call `engine.find_vector_shape_at()` for hit-testing
- Support overlapping shape cycling
**Sub-goal A2: VectorEditHandle rendering on canvas**
- Render 8 resize handles (white squares with black stroke)
- Render rotation handle (blue circle above top-center)
- Render selection outline (blue stroke around shape)
- Hit-test handles on click
**Sub-goal A3: Shape drag/resize/rotate via handles**
- Move: drag shape body → `engine.modify_vector_shape()`
- Resize: drag corner/edge handles → `engine.modify_vector_shape()`
- Rotate: drag rotation handle → `engine.set_vector_shape_angle()`
**Sub-goal A4: Apply/Cancel overlay**
- Show Apply (checkmark) and Cancel (X) buttons when shape is selected
- Apply: commit changes, clear snapshot, deselect
- Cancel: restore snapshot, deselect
- Flip H/V buttons for applicable shapes
**Sub-goal A5: Shape deletion**
- Delete key or X button in geometry panel
- `engine.delete_vector_shape()` or equivalent
---
### Phase B: Geometry Panel (Features 9-18) — Score Target: 8/10
**Sub-goal B1: Shape list with selection**
- Render all shapes in active vector layer
- Click to select/deselect
- Delete button per shape
**Sub-goal B2: Bounds editing (X1/Y1/X2/Y2)**
- 4 sliders for shape bounds
- `engine.set_vector_shape_bounds()` on change
**Sub-goal B3: Fill toggle + fill color**
- Checkbox for fill on/off
- Color picker for fill color
- `engine.set_vector_shape_fill()` and `set_vector_shape_fill_color()`
**Sub-goal B4: Stroke color picker**
- Color picker for stroke color
- `engine.set_vector_shape_color()`
**Sub-goal B5: Opacity and Hardness sliders**
- Slider for opacity (0-1)
- Slider for hardness (0-1)
- `engine.set_vector_shape_opacity()` and `set_vector_shape_hardness()`
**Sub-goal B6: Line cap controls**
- Start/End cap combo boxes (for Line shapes)
- `engine.set_vector_shape_line_caps()`
**Sub-goal B7: Boolean operations**
- Shape A/B selectors
- Union/Intersect/Subtract/Xor buttons
- `engine.boolean_vector_shapes()`
---
### Phase C: Shape Sync & Polish (Features 21, 23-25) — Score Target: 9/10
**Sub-goal C1: shape_sync bidirectional sync**
- `sync_shape_from_tool()`: push tool state to selected shape
- `sync_tool_from_shape()`: pull shape properties to tool state
**Sub-goal C2: Accurate previews for all 15 shapes**
- Arrow, Rhombus, Cylinder, Heart, Bubble, Gear, Cross, Crescent, Bolt, Arrow4
- FreePath preview
**Sub-goal C3: Tool settings panel completion**
- Add hardness slider
- Add line cap controls
---
## Execution Order
1. **A1** → A2 → A3 → A4 → A5 (VectorSelect core)
2. **B1** → B2 → B3 → B4 → B5 → B6 → B7 (Geometry panel)
3. **C1** → C2 → C3 (Sync & polish)
## Recheck Points
After each phase, recheck scores:
- After Phase A: VectorSelect features should be 8/10
- After Phase B: Geometry panel features should be 8/10
- After Phase C: All vector features should be 9/10
+228 -80
View File
@@ -7,6 +7,9 @@
use hcie_engine_api::{Engine, EngineCommand}; use hcie_engine_api::{Engine, EngineCommand};
use serde_json::json; 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. /// 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. 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. 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. /// 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 { match name {
"create_layer" | "add_layer" => { "create_layer" | "add_layer" => {
let layer_name = args["name"].as_str().unwrap_or("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" => { "select_layer" => {
let layer_name = args["name"].as_str().unwrap_or(""); 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); 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 { } else {
let id = engine.add_layer(layer_name); let id = engine.add_layer(layer_name);
engine.set_active_layer(id); 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" => { "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 rx = args["rx"].as_f64().unwrap_or(0.0) as f32;
let ry = args["ry"].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); 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" => { "draw_line" => {
let x0 = args["x0"].as_f64().unwrap_or(0.0) as f32; 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 x1 = args["x1"].as_f64().unwrap_or(0.0) as f32;
let y1 = args["y1"].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); 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" => { "add_text" => {
let text = args["text"].as_str().unwrap_or(""); 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 color = parse_hex_color(color_hex).unwrap_or([0, 0, 0, 255]);
let effects = vec![]; let effects = vec![];
engine.add_text( engine.add_text(
text, font, size, x, y, color, text,
font,
size,
x,
y,
color,
0.0, 0.0,
hcie_engine_api::TextAlignment::Left, hcie_engine_api::TextAlignment::Left,
hcie_engine_api::TextOrientation::Horizontal, 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); engine.draw_stroke(&points);
Ok(format!("Drew brush stroke with {} points", points.len())) 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 } => { EngineCommand::DrawStroke { points } => {
engine.draw_stroke(points); engine.draw_stroke(points);
} }
EngineCommand::AddText { text, font, size, x, y, color } => { EngineCommand::AddText {
text,
font,
size,
x,
y,
color,
} => {
let effects = vec![]; let effects = vec![];
engine.add_text( engine.add_text(
text, font, *size, *x, *y, *color, text,
font,
*size,
*x,
*y,
*color,
0.0, 0.0,
hcie_engine_api::TextAlignment::Left, hcie_engine_api::TextAlignment::Left,
hcie_engine_api::TextOrientation::Horizontal, hcie_engine_api::TextOrientation::Horizontal,
@@ -354,7 +394,9 @@ pub fn parse_dsl_script(script: &str, _engine: &Engine) -> Vec<EngineCommand> {
"select_layer" => { "select_layer" => {
if parts.len() >= 2 { if parts.len() >= 2 {
let name = parts[1..].join(" "); 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)) .find(|l| l.name.eq_ignore_ascii_case(&name))
.map(|l| l.id); .map(|l| l.id);
if let Some(id) = id_opt { if let Some(id) = id_opt {
@@ -396,7 +438,12 @@ pub fn parse_dsl_script(script: &str, _engine: &Engine) -> Vec<EngineCommand> {
} else { } else {
brush_size / 2.0 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 { } else {
current_color current_color
}; };
commands.push(EngineCommand::AddText { text, font, size, x, y, color }); commands.push(EngineCommand::AddText {
text,
font,
size,
x,
y,
color,
});
} }
} }
"filter" => { "filter" => {
@@ -438,7 +492,8 @@ pub fn parse_dsl_script(script: &str, _engine: &Engine) -> Vec<EngineCommand> {
let filter_id = parts[1].to_string(); let filter_id = parts[1].to_string();
let params = if parts.len() >= 3 { let params = if parts.len() >= 3 {
let json_str = parts[2..].join(" "); 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 { } else {
serde_json::Value::Null 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. /// 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> { pub fn execute_inline_tools(text: &str, engine: &mut Engine) -> Vec<String> {
let mut results = Vec::new(); 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 start = 0;
let mut executed = 0;
// Parse JSON block tool calls // Parse JSON block tool calls
while let Some(open_idx) = text[start..].find("```json") { 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 val.is_array() {
if let Some(arr) = val.as_array() { if let Some(arr) = val.as_array() {
for item in arr { 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() { if let Some(tool_name) = item["tool"].as_str() {
executed += 1;
let params = &item["parameters"]; let params = &item["parameters"];
match execute_tool_call(engine, tool_name, params) { match execute_tool_call(engine, tool_name, params) {
Ok(msg) => results.push(format!("🔧 {}: {}", tool_name, msg)), 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() { } else if val.is_object() {
if let Some(tool_name) = val["tool"].as_str() { if let Some(tool_name) = val["tool"].as_str() {
executed += 1;
let params = &val["parameters"]; let params = &val["parameters"];
match execute_tool_call(engine, tool_name, params) { match execute_tool_call(engine, tool_name, params) {
Ok(msg) => results.push(format!("🔧 {}: {}", tool_name, msg)), 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 // 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" { if msg.role == "user" || msg.role == "assistant" {
messages_json.push(json!({ messages_json.push(json!({
"role": &msg.role, "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 /// 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 /// an error message on failure. This function is designed to be used with
/// `Task::perform` in the Iced application. /// `Task::perform` in the Iced application.
pub async fn stream_llm_response( pub fn stream_llm_response(
provider: AiProvider,
url: String, url: String,
model: String, model: String,
messages: Vec<serde_json::Value>, messages: Vec<serde_json::Value>,
) -> Result<String, String> { ) -> impl futures_util::Stream<Item = ChatUpdate> {
let body = json!({ iced::stream::channel(32, move |mut output| async move {
"model": model, use futures_util::{SinkExt, StreamExt};
"messages": messages, let result = async {
"stream": true, let (url_endpoint, body) = match provider {
"temperature": 0.7 AiProvider::Ollama => (
}); format!("{}/api/chat", url.trim_end_matches('/')),
json!({"model": model, "messages": messages, "stream": true}),
let url_endpoint = if url.ends_with("/v1/chat/completions") || url.ends_with("/api/chat") { ),
url.clone() AiProvider::Claude => (
} else { format!("{}/v1/messages", url.trim_end_matches('/')),
format!("{}/v1/chat/completions", 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 => (
let client = reqwest::Client::builder() format!("{}/chat/completions", url.trim_end_matches('/')),
.timeout(std::time::Duration::from_secs(120)) json!({"model": model, "messages": messages, "stream": true, "temperature": 0.7}),
.build() ),
.map_err(|e| format!("Failed to create HTTP client: {}", e))?; };
let client = reqwest::Client::builder()
let response = client.post(&url_endpoint).json(&body).send().await .connect_timeout(std::time::Duration::from_secs(15))
.map_err(|e| format!("Network Failure: {}", e))?; .timeout(std::time::Duration::from_secs(120))
.build()
let status = response.status(); .map_err(|e| format!("Failed to create HTTP client: {e}"))?;
if !status.is_success() { let mut request = client.post(&url_endpoint).json(&body);
let err_msg = response.text().await.unwrap_or_else(|_| "Unknown HTTP error".to_string()); match provider {
return Err(format!("HTTP Error {}: {}", status, err_msg)); 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");
let mut full_accumulated = String::new(); }
let mut stream = response.bytes_stream(); AiProvider::OpenAI => {
let key = std::env::var("OPENAI_API_KEY").map_err(|_| "OPENAI_API_KEY is not set".to_string())?;
use futures_util::StreamExt; request = request.bearer_auth(key);
let mut buffer = String::new(); }
AiProvider::Ollama => {}
while let Some(chunk_result) = stream.next().await { }
let response = request.send().await.map_err(|e| format!("Network failure: {e}"))?;
let status = response.status();
if !status.is_success() {
let detail = response.text().await.unwrap_or_else(|_| "response body unavailable".into());
return Err(format!("HTTP {status}: {}", detail.chars().take(1000).collect::<String>()));
}
let mut full_accumulated = String::new();
let mut stream = response.bytes_stream();
let mut buffer = String::new();
while let Some(chunk_result) = stream.next().await {
match chunk_result { match chunk_result {
Ok(chunk) => { Ok(chunk) => {
buffer.push_str(&String::from_utf8_lossy(&chunk)); buffer.push_str(&String::from_utf8_lossy(&chunk));
// Process complete lines
while let Some(newline_pos) = buffer.find('\n') { while let Some(newline_pos) = buffer.find('\n') {
let line = buffer[..newline_pos].trim().to_string(); let line = buffer[..newline_pos].trim().to_string();
buffer = buffer[newline_pos + 1..].to_string(); buffer.drain(..=newline_pos);
if line.is_empty() { if line.is_empty() {
continue; continue;
} }
let json_part = line.strip_prefix("data: ").unwrap_or(&line);
if line.starts_with("data: ") { if line.starts_with("data: ") {
let json_part = &line[6..];
if json_part.trim() == "[DONE]" { if json_part.trim() == "[DONE]" {
break; continue;
} }
if let Ok(v) = serde_json::from_str::<serde_json::Value>(json_part) { if let Ok(v) = serde_json::from_str::<serde_json::Value>(json_part) {
if let Some(choices) = v["choices"].as_array() { let delta = &v["choices"][0]["delta"];
if !choices.is_empty() { if let Some(reasoning) = delta["reasoning_content"].as_str().or_else(|| delta["reasoning"].as_str()) {
let delta = &choices[0]["delta"]; output.send(ChatUpdate::ReasoningChunk(reasoning.to_string())).await.ok();
// Handle reasoning content (deep thinking models) }
if let Some(_reasoning) = delta["reasoning_content"].as_str() { let content = delta["content"].as_str().or_else(|| v["delta"]["text"].as_str());
// Reasoning chunks are logged but not accumulated in this path if let Some(content) = content.filter(|text| !text.is_empty()) {
} full_accumulated.push_str(content);
// Handle regular content output.send(ChatUpdate::Chunk(content.to_string())).await.ok();
if let Some(content) = delta["content"].as_str() {
if !content.is_empty() {
full_accumulated.push_str(content);
}
}
}
} }
} }
} else { } else {
// Fallback to Ollama native chat ND-JSON format
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) { 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 let Some(content) = v["message"]["content"].as_str() {
if !content.is_empty() { if !content.is_empty() {
full_accumulated.push_str(content); 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) => { Err(e) => return Err(format!("Stream read error: {e}")),
return Err(format!("Stream read error: {}", e));
}
} }
}
Ok(full_accumulated)
}.await;
output.send(ChatUpdate::Finished(result)).await.ok();
})
}
/// Format every visible message as a complete plain-text transcript.
pub fn transcript_text(messages: &[ChatMessage]) -> String {
messages
.iter()
.map(|message| {
let role = if message.is_reasoning {
"Reasoning"
} else {
message.role.as_str()
};
format!("{}:\n{}", role, message.content)
})
.collect::<Vec<_>>()
.join("\n\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canvas_context_replaces_only_latest_user_payload() {
let history = vec![
ChatMessage {
role: "user".into(),
content: "old".into(),
is_reasoning: false,
},
ChatMessage {
role: "assistant".into(),
content: "reply".into(),
is_reasoning: false,
},
ChatMessage {
role: "user".into(),
content: "new".into(),
is_reasoning: false,
},
];
let payload = build_messages_json(&history, "new\n[canvas]", "system");
assert_eq!(payload[1]["content"], "old");
assert_eq!(payload[3]["content"], "new\n[canvas]");
} }
Ok(full_accumulated) #[test]
fn transcript_includes_reasoning_and_tool_results() {
let messages = vec![
ChatMessage {
role: "assistant".into(),
content: "answer".into(),
is_reasoning: false,
},
ChatMessage {
role: "assistant".into(),
content: "thought".into(),
is_reasoning: true,
},
ChatMessage {
role: "tool".into(),
content: "result".into(),
is_reasoning: false,
},
];
let transcript = transcript_text(&messages);
assert!(transcript.contains("assistant:\nanswer"));
assert!(transcript.contains("Reasoning:\nthought"));
assert!(transcript.contains("tool:\nresult"));
}
} }
@@ -10,7 +10,6 @@ Output ONLY valid HCIE-Script DSL commands with no explanation, no markdown, no
Available commands: Available commands:
- layer Name — create raster layer - layer Name — create raster layer
- vector_layer Name — create vector layer
- select_layer Name - 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 - 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 - 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())); 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"] let content = json["message"]["content"]
.as_str() .as_str()
.ok_or_else(|| "No content in response".to_string())?; .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() .build()
.map_err(|e| format!("Client error: {}", e))?; .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 let resp = client
.post(&url) .post(&url)
.header("x-api-key", api_key)
.header("anthropic-version", "2023-06-01")
.json(&body) .json(&body)
.send() .send()
.await .await
@@ -123,7 +129,10 @@ pub async fn generate_via_claude(provider: &Provider, prompt: &str) -> Result<St
return Err(format!("API error {}: {}", status, text)); 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"] let content = json["content"][0]["text"]
.as_str() .as_str()
.ok_or_else(|| "No content in response".to_string())?; .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 /// Uses the standard Chat Completions format with the system prompt as the
/// first message. Returns the cleaned DSL script text. /// first message. Returns the cleaned DSL script text.
pub async fn generate_via_openai(provider: &Provider, prompt: &str) -> Result<String, String> { 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!({ let body = serde_json::json!({
"model": provider.model, "model": provider.model,
"messages": [ "messages": [
@@ -151,8 +163,11 @@ pub async fn generate_via_openai(provider: &Provider, prompt: &str) -> Result<St
.build() .build()
.map_err(|e| format!("Client error: {}", e))?; .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 let resp = client
.post(&url) .post(&url)
.bearer_auth(api_key)
.json(&body) .json(&body)
.send() .send()
.await .await
@@ -164,7 +179,10 @@ pub async fn generate_via_openai(provider: &Provider, prompt: &str) -> Result<St
return Err(format!("API error {}: {}", status, text)); 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"] let content = json["choices"][0]["message"]["content"]
.as_str() .as_str()
.ok_or_else(|| "No content in response".to_string())?; .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() 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). /// Generate a script using the given provider type and configuration (async).
/// ///
/// Dispatches to the appropriate provider function based on the `provider_type` /// Dispatches to the appropriate provider function based on the `provider_type`
/// string ("ollama", "claude", or "openai"). Defaults to Ollama. /// 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 { match provider_type {
"claude" => generate_via_claude(provider, prompt).await, "claude" => generate_via_claude(provider, prompt).await,
"openai" => generate_via_openai(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 { } else {
(w, h) (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 { presets.push(BrushPreset {
id: format!("abr_brs2_{}_{}x{}", base_pos + i, final_w, final_h), id: format!("abr_brs2_{}_{}x{}", base_pos + i, final_w, final_h),
name: format!("Brush {}x{}", 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; use std::sync::Arc;
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct SelectionEdgeCache { pub struct SelectionEdgeCache {
mask_ptr: usize, fingerprint: u64,
mask_len: usize, mask_len: usize,
edges: Arc<Vec<(u32, u32, u32, u32)>>, edges: Arc<Vec<(u32, u32, u32, u32)>>,
} }
impl SelectionEdgeCache { 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)>>> { 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)) Some(Arc::clone(&self.edges))
} else { } else {
None 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)>> { 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)); 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.mask_len = mask.len();
self.edges = Arc::clone(&edges); self.edges = Arc::clone(&edges);
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( pub fn extract_selection_edges(
selection_mask: &[u8], selection_mask: &[u8],
canvas_width: u32, canvas_width: u32,
File diff suppressed because it is too large Load Diff
@@ -17,11 +17,11 @@
//! //!
use crate::app::Message; use crate::app::Message;
use iced::widget::shader;
use iced::mouse;
use iced::{Rectangle, Point, Vector};
use iced::advanced::Shell; use iced::advanced::Shell;
use iced::mouse;
use iced::widget::shader;
use iced::widget::shader::wgpu::util::DeviceExt; use iced::widget::shader::wgpu::util::DeviceExt;
use iced::{Point, Rectangle, Vector};
use hcie_engine_api::{ZOOM_MAX, ZOOM_MIN}; 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]. /// Position [0,1] × [0,1], UV [0,1] × [0,1].
const QUAD_VERTICES: [CanvasVertex; 6] = [ const QUAD_VERTICES: [CanvasVertex; 6] = [
// Triangle 1: top-left, bottom-left, bottom-right // Triangle 1: top-left, bottom-left, bottom-right
CanvasVertex { position: [0.0, 0.0], uv: [0.0, 0.0] }, CanvasVertex {
CanvasVertex { position: [0.0, 1.0], uv: [0.0, 1.0] }, position: [0.0, 0.0],
CanvasVertex { position: [1.0, 1.0], uv: [1.0, 1.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 // Triangle 2: top-left, bottom-right, top-right
CanvasVertex { position: [0.0, 0.0], uv: [0.0, 0.0] }, CanvasVertex {
CanvasVertex { position: [1.0, 1.0], uv: [1.0, 1.0] }, position: [0.0, 0.0],
CanvasVertex { position: [1.0, 0.0], uv: [1.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) ────────────────────────────── // ─── wgpu re-export (from iced's shader module) ──────────────────────────────
@@ -153,7 +171,9 @@ impl CanvasShaderPipeline {
) -> Self { ) -> Self {
log::debug!( log::debug!(
"[CanvasShaderPipeline::new] Creating pipeline for {}×{} canvas, format={:?}", "[CanvasShaderPipeline::new] Creating pipeline for {}×{} canvas, format={:?}",
canvas_w, canvas_h, format canvas_w,
canvas_h,
format
); );
// ── Texture ────────────────────────────────────────────────────── // ── Texture ──────────────────────────────────────────────────────
@@ -175,7 +195,8 @@ impl CanvasShaderPipeline {
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
// Upload initial pixel data // 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( queue.write_texture(
wgpu::ImageCopyTexture { wgpu::ImageCopyTexture {
texture: &texture, texture: &texture,
@@ -197,7 +218,9 @@ impl CanvasShaderPipeline {
); );
log::debug!( log::debug!(
"[CanvasShaderPipeline::new] Uploaded initial {}×{} texture ({} bytes)", "[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 ──────────────────────────────────────────────── // ── Shader module ────────────────────────────────────────────────
let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("hcie-canvas-shader"), 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 ───────────────────────────────────────────── // ── Pipeline layout ─────────────────────────────────────────────
@@ -345,7 +370,8 @@ impl CanvasShaderPipeline {
log::info!( log::info!(
"[CanvasShaderPipeline::new] Pipeline created successfully for {}×{} canvas", "[CanvasShaderPipeline::new] Pipeline created successfully for {}×{} canvas",
canvas_w, canvas_h canvas_w,
canvas_h
); );
Self { Self {
@@ -380,7 +406,10 @@ impl CanvasShaderPipeline {
) { ) {
log::info!( log::info!(
"[CanvasShaderPipeline::resize_texture] {}×{} → {}×{}", "[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 { self.texture = device.create_texture(&wgpu::TextureDescriptor {
@@ -398,7 +427,9 @@ impl CanvasShaderPipeline {
view_formats: &[], 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 // Upload full pixel data
if pixels.len() == (canvas_w * canvas_h * 4) as usize { 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 ────────────────── // ── Resize texture if canvas dimensions changed ──────────────────
if pipeline.texture_w != self.canvas_w || pipeline.texture_h != self.canvas_h { if pipeline.texture_w != self.canvas_w || pipeline.texture_h != self.canvas_h {
pipeline.resize_texture( pipeline.resize_texture(
device, queue, device,
self.canvas_w, self.canvas_h, queue,
self.canvas_w,
self.canvas_h,
&self.composite_pixels, &self.composite_pixels,
); );
} else if self.full_upload { } else if self.full_upload {
// Full upload requested (e.g., after loading a file) // Full upload requested (e.g., after loading a file)
pipeline.resize_texture( pipeline.resize_texture(
device, queue, device,
self.canvas_w, self.canvas_h, queue,
self.canvas_w,
self.canvas_h,
&self.composite_pixels, &self.composite_pixels,
); );
} else if let Some(region) = self.dirty_region { } 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 raw_y = (viewport_h - display_h) / 2.0 + self.pan_offset.y;
let min_vis_w = display_w * 0.25; let min_vis_w = display_w * 0.25;
let min_vis_h = display_h * 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_x = raw_x.clamp(
let origin_y = raw_y.clamp(-(display_h - min_vis_h).max(0.0), (viewport_h - min_vis_h).max(0.0)); -(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] // Convert to NDC: viewport [0, viewport_w] → NDC [-1, 1]
// The quad vertices are in [0,1], vertex shader does: // The quad vertices are in [0,1], vertex shader does:
@@ -645,8 +686,10 @@ impl shader::Primitive for CanvasShaderPrimitive {
log::trace!( log::trace!(
"[CanvasShaderPrimitive::prepare] {:.2}ms | viewport={}×{} | canvas={}×{} | zoom={:.2}", "[CanvasShaderPrimitive::prepare] {:.2}ms | viewport={}×{} | canvas={}×{} | zoom={:.2}",
elapsed.as_secs_f64() * 1000.0, elapsed.as_secs_f64() * 1000.0,
viewport_w as u32, viewport_h as u32, viewport_w as u32,
self.canvas_w, self.canvas_h, viewport_h as u32,
self.canvas_w,
self.canvas_h,
self.zoom self.zoom
); );
} }
@@ -723,6 +766,8 @@ pub struct CanvasShaderState {
pub left_pressed: bool, pub left_pressed: bool,
/// Track middle button state. /// Track middle button state.
pub middle_pressed: bool, 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`. /// Canvas shader program — implements `iced::widget::shader::Program`.
@@ -745,6 +790,8 @@ pub struct CanvasShaderProgram {
pub dirty_region: Option<[u32; 4]>, pub dirty_region: Option<[u32; 4]>,
/// Whether a full texture upload is needed (first frame, resize, file load). /// Whether a full texture upload is needed (first frame, resize, file load).
pub full_upload: bool, 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. /// 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 raw_y = (viewport_h - display_h) / 2.0 + pan_offset.y;
let min_vis_w = display_w * 0.25; let min_vis_w = display_w * 0.25;
let min_vis_h = display_h * 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_x = raw_x.clamp(
let origin_y = raw_y.clamp(-(display_h - min_vis_h).max(0.0), (viewport_h - min_vis_h).max(0.0)); -(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_x = (local_pos.x - origin_x) / zoom;
let canvas_y = (local_pos.y - origin_y) / 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 cx = if canvas_x >= 0.0 && canvas_x < engine_w {
let cy = if canvas_y >= 0.0 && canvas_y < engine_h { Some(canvas_y) } else { None }; Some(canvas_x)
} else {
None
};
let cy = if canvas_y >= 0.0 && canvas_y < engine_h {
Some(canvas_y)
} else {
None
};
(cx, cy) (cx, cy)
} }
@@ -798,10 +859,7 @@ impl shader::Program<Message> for CanvasShaderProgram {
shader::Event::Mouse(mouse_event) => { shader::Event::Mouse(mouse_event) => {
match mouse_event { match mouse_event {
mouse::Event::CursorMoved { position } => { mouse::Event::CursorMoved { position } => {
let local_pos = Point::new( let local_pos = Point::new(position.x - bounds.x, position.y - bounds.y);
position.x - bounds.x,
position.y - bounds.y,
);
state.cursor_pos = Some(local_pos); state.cursor_pos = Some(local_pos);
state.is_hovered = bounds.contains(position); state.is_hovered = bounds.contains(position);
@@ -820,7 +878,10 @@ impl shader::Program<Message> for CanvasShaderProgram {
// Left mouse drag (drawing) // Left mouse drag (drawing)
if state.left_pressed { if state.left_pressed {
if let (Some(cx), Some(cy)) = (canvas_x, canvas_y) { 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)); 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), (bounds.height - min_vis_h).max(0.0),
) - default_oy; ) - default_oy;
return (iced::event::Status::Captured, Some(Message::CanvasPanZoom { return (
zoom: self.zoom, iced::event::Status::Captured,
pan_offset: new_pan, Some(Message::CanvasPanZoom {
})); zoom: self.zoom,
pan_offset: new_pan,
}),
);
} }
// Cursor over canvas → status bar coords // Cursor over canvas → status bar coords
if let (Some(cx), Some(cy)) = (canvas_x, canvas_y) { if let (Some(cx), Some(cy)) = (canvas_x, canvas_y) {
return (iced::event::Status::Captured, Some(Message::CanvasCursorPos { return (
x: cx as u32, iced::event::Status::Captured,
y: cy as u32, Some(Message::CanvasCursorPos {
})); x: cx as u32,
y: cy as u32,
}),
);
} }
return (iced::event::Status::Captured, Some(pane_size_msg)); return (iced::event::Status::Captured, Some(pane_size_msg));
} }
mouse::Event::ButtonPressed(button) => { 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 local_pos = {
let p = cursor.position().unwrap_or(Point::ORIGIN); let p = cursor.position().unwrap_or(Point::ORIGIN);
Point::new(p.x - bounds.x, p.y - bounds.y) 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) { if let (Some(cx), Some(cy)) = (cx_opt, cy_opt) {
state.left_pressed = true; state.left_pressed = true;
return (iced::event::Status::Captured, Some(Message::CanvasPointerPressed { return (
x: cx, iced::event::Status::Captured,
y: cy, Some(Message::CanvasPointerPressed { x: cx, y: cy }),
})); );
} }
} else if button == mouse::Button::Middle { } else if button == mouse::Button::Middle {
if let Some(pos) = cursor.position() { if let Some(pos) = cursor.position() {
@@ -904,15 +979,24 @@ impl shader::Program<Message> for CanvasShaderProgram {
if bounds.contains(pos) { if bounds.contains(pos) {
let local_pos = Point::new(pos.x - bounds.x, pos.y - bounds.y); let local_pos = Point::new(pos.x - bounds.x, pos.y - bounds.y);
let (cx_opt, cy_opt) = screen_to_canvas_local( 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) { if let (Some(cx), Some(cy)) = (cx_opt, cy_opt) {
return (iced::event::Status::Captured, Some(Message::CanvasPointerRightClicked { return (
x: cx, iced::event::Status::Captured,
y: cy, Some(Message::CanvasPointerRightClicked {
screen_x: pos.x, x: cx,
screen_y: pos.y, y: cy,
})); screen_x: pos.x,
screen_y: pos.y,
}),
);
} }
} }
} }
@@ -921,8 +1005,17 @@ impl shader::Program<Message> for CanvasShaderProgram {
mouse::Event::ButtonReleased(button) => { mouse::Event::ButtonReleased(button) => {
if button == mouse::Button::Left { 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; 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 { } else if button == mouse::Button::Middle {
state.middle_pressed = false; state.middle_pressed = false;
state.pan_start = None; state.pan_start = None;
@@ -938,10 +1031,8 @@ impl shader::Program<Message> for CanvasShaderProgram {
}; };
if scroll_y != 0.0 { if scroll_y != 0.0 {
let abs_cursor = cursor.position().unwrap_or(Point::ORIGIN); let abs_cursor = cursor.position().unwrap_or(Point::ORIGIN);
let local_cursor = Point::new( let local_cursor =
abs_cursor.x - bounds.x, Point::new(abs_cursor.x - bounds.x, abs_cursor.y - bounds.y);
abs_cursor.y - bounds.y,
);
let old_zoom = self.zoom; let old_zoom = self.zoom;
let factor = if scroll_y > 0.0 { 1.1 } else { 1.0 / 1.1 }; 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_w = engine_w * old_zoom;
let old_display_h = engine_h * 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_x =
let old_origin_y = (bounds.height - old_display_h) / 2.0 + self.pan_offset.y; (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_x = (local_cursor.x - old_origin_x) / old_zoom;
let canvas_y = (local_cursor.y - old_origin_y) / 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), (bounds.height - new_min_vis_h).max(0.0),
) - new_default_oy; ) - new_default_oy;
return (iced::event::Status::Captured, Some(Message::CanvasPanZoom { return (
zoom: new_zoom, iced::event::Status::Captured,
pan_offset: new_pan, 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 //! - HSL sliders
//! - 10x10 palette grid //! - 10x10 palette grid
//! - Recent colors //! - 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 crate::app::Message;
use iced::widget::{button, column, container, horizontal_rule, row, slider, text, text_input}; use crate::theme::ThemeColors;
use iced::{Element, Length, Padding}; 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) /// HSV color representation (Hue, Saturation, Value)
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@@ -34,7 +51,7 @@ pub fn rgb_to_hsv(r: u8, g: u8, b: u8) -> Hsv {
let max = r.max(g).max(b); let max = r.max(g).max(b);
let min = r.min(g).min(b); let min = r.min(g).min(b);
let d = max - min; let d = max - min;
let h = if max == min { let h = if max == min {
0.0 0.0
} else if max == r { } else if max == r {
@@ -44,15 +61,11 @@ pub fn rgb_to_hsv(r: u8, g: u8, b: u8) -> Hsv {
} else { } else {
((r - g) / d + 4.0) / 6.0 ((r - g) / d + 4.0) / 6.0
}; };
let s = if max == 0.0 { 0.0 } else { d / max }; let s = if max == 0.0 { 0.0 } else { d / max };
let v = max; let v = max;
Hsv { Hsv { h: h * 360.0, s, v }
h: h * 360.0,
s,
v,
}
} }
/// Convert HSV to RGB. /// Convert HSV to RGB.
@@ -75,86 +88,39 @@ pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
_ => (v, p, q), _ => (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. /// Builds a compact swatch row that opens the shared application popup.
/// Returns a color swatch that opens a popup when clicked. pub fn color_swatch<'a>(
pub fn color_popup<'a>(
color: [u8; 4], color: [u8; 4],
label: &'a str, label: &'a str,
on_change: fn([u8; 4]) -> Message, target: ColorPickerTarget,
) -> Element<'a, Message> { ) -> 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("")) let swatch = container(text(""))
.width(20) .width(30)
.height(20) .height(22)
.style(move |_theme| iced::widget::container::Style { .style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba( 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() ..Default::default()
}); });
let trigger = iced::widget::mouse_area(swatch)
// HSV sliders .on_press(Message::OpenColorPicker(target, color))
let h_slider = slider(0.0..=360.0, hsv.h, move |v| { .interaction(mouse::Interaction::Pointer);
let (r, g, b) = hsv_to_rgb(v, hsv.s, hsv.v); row![text(label).size(10).width(Length::Fill), trigger]
on_change([r, g, b, a]) .spacing(6)
}).step(1.0); .align_y(iced::Alignment::Center)
.width(Length::Fill)
let s_slider = slider(0.0..=100.0, hsv.s * 100.0, move |v| { .into()
let (r, g, b) = hsv_to_rgb(hsv.h, v / 100.0, hsv.v);
on_change([r, g, b, a])
}).step(1.0);
let v_slider = slider(0.0..=100.0, hsv.v * 100.0, move |v| {
let (r, g, b) = hsv_to_rgb(hsv.h, hsv.s, v / 100.0);
on_change([r, g, b, a])
}).step(1.0);
// Alpha slider
let a_slider = slider(0..=255u8, a, move |v| {
on_change([r, g, b, v])
}).width(Length::Fill);
// Hex input
let hex_str = format!("{:02X}{:02X}{:02X}", r, g, b);
let alpha = a;
let hex_input = text_input("#", &hex_str)
.on_input(move |input| {
let clean = input.trim_start_matches('#').to_uppercase();
if clean.len() == 6 {
if let (Ok(r), Ok(g), Ok(b)) = (
u8::from_str_radix(&clean[0..2], 16),
u8::from_str_radix(&clean[2..4], 16),
u8::from_str_radix(&clean[4..6], 16),
) {
return on_change([r, g, b, alpha]);
}
}
Message::NoOp
})
.width(70)
.font(iced::Font::MONOSPACE)
.size(10);
column![
row![text(label).size(10), swatch].spacing(4).align_y(iced::Alignment::Center),
row![text("H").size(9), h_slider, text(format!("{:.0}°", hsv.h)).size(9)].spacing(2),
row![text("S").size(9), s_slider, text(format!("{:.0}%", hsv.s * 100.0)).size(9)].spacing(2),
row![text("V").size(9), v_slider, text(format!("{:.0}%", hsv.v * 100.0)).size(9)].spacing(2),
row![text("A").size(9), a_slider, text(format!("{:.0}%", a as f32 / 255.0 * 100.0)).size(9)].spacing(2),
row![text("Hex:").size(10), hex_input].spacing(4).align_y(iced::Alignment::Center),
]
.spacing(2)
.into()
} }
/// A predefined color palette (10x10 grid) — generated algorithmically to match egui. /// A predefined color palette (10x10 grid) — generated algorithmically to match egui.
@@ -173,16 +139,22 @@ fn palette_color(row: usize, col: usize) -> [u8; 3] {
} }
/// Convert RGB to HSL. /// 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 r = r as f32 / 255.0;
let g = g as f32 / 255.0; let g = g as f32 / 255.0;
let b = b as f32 / 255.0; let b = b as f32 / 255.0;
let max = r.max(g).max(b); let max = r.max(g).max(b);
let min = r.min(g).min(b); let min = r.min(g).min(b);
let l = (max + min) / 2.0; 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 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 { let h = if max == r {
((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0 ((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0
} else if max == g { } 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. /// 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 h = h / 360.0;
let (r, g, b) = if s == 0.0 { let (r, g, b) = if s == 0.0 {
(l, l, l) (l, l, l)
} else { } else {
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s }; let q = if l < 0.5 {
l * (1.0 + s)
} else {
l + s - l * s
};
let p = 2.0 * l - q; let p = 2.0 * l - q;
(hue_to_rgb(p, q, h + 1.0 / 3.0), hue_to_rgb(p, q, h), hue_to_rgb(p, q, h - 1.0 / 3.0)) (
hue_to_rgb(p, q, h + 1.0 / 3.0),
hue_to_rgb(p, q, h),
hue_to_rgb(p, q, h - 1.0 / 3.0),
)
}; };
((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8) (to_byte(r), to_byte(g), to_byte(b))
}
fn to_byte(value: f32) -> u8 {
(value.clamp(0.0, 1.0) * 255.0).round() as u8
}
/// Parses `RRGGBB` or `RRGGBBAA`, preserving the supplied alpha for six digits.
pub fn parse_hex_color(value: &str, current_alpha: u8) -> Option<[u8; 4]> {
let value = value.trim().trim_start_matches('#');
if value.len() != 6 && value.len() != 8 {
return None;
}
let r = u8::from_str_radix(&value[0..2], 16).ok()?;
let g = u8::from_str_radix(&value[2..4], 16).ok()?;
let b = u8::from_str_radix(&value[4..6], 16).ok()?;
let a = if value.len() == 8 {
u8::from_str_radix(&value[6..8], 16).ok()?
} else {
current_alpha
};
Some([r, g, b, a])
} }
fn hue_to_rgb(p: f32, q: f32, t: f32) -> f32 { 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 }; let t = if t < 0.0 {
if t < 1.0 / 6.0 { p + (q - p) * 6.0 * t } t + 1.0
else if t < 1.0 / 2.0 { q } } else if t > 1.0 {
else if t < 2.0 / 3.0 { p + (q - p) * (2.0 / 3.0 - t) * 6.0 } t - 1.0
else { p } } 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. /// Build the full color picker panel.
@@ -234,9 +619,15 @@ pub fn view<'a>(
.height(20) .height(20)
.style(move |_theme| iced::widget::container::Style { .style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba( 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() ..Default::default()
}); });
let secondary_swatch = container(text("")) let secondary_swatch = container(text(""))
@@ -244,15 +635,23 @@ pub fn view<'a>(
.height(20) .height(20)
.style(move |_theme| iced::widget::container::Style { .style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba( 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() ..Default::default()
}); });
let swap_btn = button(text("\u{21C5}").size(11)) let swap_btn = button(text("\u{21C5}").size(11))
.on_press(Message::SwapColors) .on_press(Message::SwapColors)
.padding([2, 4]); .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 ── // ── 2. Hex input ──
// Preserve the current alpha channel: the hex field only edits RGB, but the // Preserve the current alpha channel: the hex field only edits RGB, but the
@@ -277,7 +676,9 @@ pub fn view<'a>(
.width(70) .width(70)
.font(iced::Font::MONOSPACE) .font(iced::Font::MONOSPACE)
.size(10); .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 ── // ── 2b. Alpha slider ──
// Allows transparency editing, which egui supports in the layer-style panel // Allows transparency editing, which egui supports in the layer-style panel
@@ -300,13 +701,18 @@ pub fn view<'a>(
// ── 3. Tab bar ── // ── 3. Tab bar ──
let tab_w = 30; let tab_w = 30;
let make_tab = |label: &'static str, idx: usize, color_tab: usize| { 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)) .on_press(Message::ColorTabChanged(idx))
.style(move |_theme, _status| iced::widget::button::Style { .style(move |_theme, _status| iced::widget::button::Style {
background: if color_tab == idx { 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 { } 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), text_color: iced::Color::from_rgb(0.8, 0.8, 0.8),
border: iced::Border::default().rounded(2), border: iced::Border::default().rounded(2),
@@ -317,7 +723,8 @@ pub fn view<'a>(
make_tab("W", 0, color_tab), make_tab("W", 0, color_tab),
make_tab("H", 1, color_tab), make_tab("H", 1, color_tab),
make_tab("G", 2, color_tab), make_tab("G", 2, color_tab),
].spacing(1); ]
.spacing(1);
// ── 4. Tab content ── // ── 4. Tab content ──
let tab_content: Element<'_, Message> = match color_tab { let tab_content: Element<'_, Message> = match color_tab {
@@ -332,7 +739,11 @@ pub fn view<'a>(
let c = color; let c = color;
let is_selected = c == *fg_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 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 { "" }) let dot = text(if is_selected { "\u{2022}" } else { "" })
.size(10) .size(10)
@@ -347,10 +758,16 @@ pub fn view<'a>(
.center_y(Length::Fill) .center_y(Length::Fill)
.style(move |_theme| iced::widget::container::Style { .style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba( 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 { 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 { } else {
iced::Border::default().rounded(2) 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 { for i in 0..36 {
let hue = i as f32 * 10.0; let hue = i as f32 * 10.0;
let (r, g, b) = hsl_to_rgb(hue, 1.0, 0.5); 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_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 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 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 { "" }) let dot = text(if is_selected { "\u{2022}" } else { "" })
.size(8) .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 sat = col_idx as f32 / 11.0;
let (r, g, b) = hsl_to_rgb(h, sat, lightness); let (r, g, b) = hsl_to_rgb(h, sat, lightness);
let is_current = (s - sat).abs() < 0.05 && (l - lightness).abs() < 0.05; 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 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 { "" }) let dot = text(if is_current { "\u{2022}" } else { "" })
.size(8) .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 // HSL readout
let readout = text(format!("H:{:.0}\u{00B0} S:{:.0}% L:{:.0}%", h, s * 100.0, l * 100.0)) let readout = text(format!(
.size(9).font(iced::Font::MONOSPACE); "H:{:.0}\u{00B0} S:{:.0}% L:{:.0}%",
h,
s * 100.0,
l * 100.0
))
.size(9)
.font(iced::Font::MONOSPACE);
column![ column![
text("Hue").size(9), 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 h_slider = slider(0.0..=360.0, h, move |v: f32| {
let (r, g, b) = hsl_to_rgb(v, s, l); let (r, g, b) = hsl_to_rgb(v, s, l);
Message::FgColorChanged([r, g, b, 255]) 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 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); let (r, g, b) = hsl_to_rgb(h, v / 100.0, l);
Message::FgColorChanged([r, g, b, 255]) 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 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); let (r, g, b) = hsl_to_rgb(h, s, v / 100.0);
Message::FgColorChanged([r, g, b, 255]) Message::FgColorChanged([r, g, b, 255])
}).step(1.0); })
.step(1.0);
column![ column![
row![text("H").size(9), h_slider, text(format!("{:.0}\u{00B0}", h)).size(9)].spacing(2), row![
row![text("S").size(9), s_slider, text(format!("{:.0}%", s * 100.0)).size(9)].spacing(2), text("H").size(9),
row![text("L").size(9), l_slider, text(format!("{:.0}%", l * 100.0)).size(9)].spacing(2), 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) .spacing(2)
.into() .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 c = palette_color(row_idx, col_idx);
let is_selected = [c[0], c[1], c[2], 255] == *fg_color; 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 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 { "" }) let dot = text(if is_selected { "\u{2022}" } else { "" })
.size(10) .size(10)
@@ -543,10 +998,15 @@ fn build_grid_view<'a>(fg_color: &'a [u8; 4]) -> Element<'a, Message> {
.center_y(Length::Fill) .center_y(Length::Fill)
.style(move |_theme| iced::widget::container::Style { .style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgb( 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 { 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 { } else {
iced::Border::default().rounded(1) 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); palette_grid = palette_grid.push(row_widgets);
} }
column![ column![text("Palette").size(10), palette_grid,]
text("Palette").size(10), .spacing(2)
palette_grid, .into()
] }
.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. //! About dialog — displays application name, version, and credits.
use crate::app::Message; use crate::app::Message;
use iced::widget::{button, column, container, horizontal_rule, text}; use crate::theme::ThemeColors;
use iced::{Element, Length}; use iced::widget::{button, column, horizontal_rule, text};
use iced::Element;
/// Build the About dialog. /// 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)) let ok_btn = button(text("OK").size(12))
.on_press(Message::DialogClose) .on_press(Message::DialogClose)
.padding([8, 16]); .padding([8, 16]);
@@ -25,14 +26,5 @@ pub fn view() -> Element<'static, Message> {
.width(320) .width(320)
.align_x(iced::Alignment::Center); .align_x(iced::Alignment::Center);
container(dialog) super::modal(dialog, colors)
.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()
} }
@@ -1,13 +1,15 @@
//! Adjustments dialog — brightness/contrast and HSL adjustments. //! Adjustments dialog — brightness/contrast and HSL adjustments.
use crate::app::Message; 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}; use iced::{Element, Length};
/// Build the Brightness/Contrast adjustment dialog. /// Build the Brightness/Contrast adjustment dialog.
pub fn brightness_contrast_view( pub fn brightness_contrast_view(
brightness: f32, brightness: f32,
contrast: f32, contrast: f32,
colors: ThemeColors,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let brightness_slider = slider(-100.0..=100.0, brightness, |v| Message::AdjBrightness(v)) let brightness_slider = slider(-100.0..=100.0, brightness, |v| Message::AdjBrightness(v))
.step(1.0) .step(1.0)
@@ -26,7 +28,9 @@ pub fn brightness_contrast_view(
.padding([6, 12]); .padding([6, 12]);
let dialog = column![ let dialog = column![
text("Brightness / Contrast").size(14).font(iced::Font::MONOSPACE), text("Brightness / Contrast")
.size(14)
.font(iced::Font::MONOSPACE),
horizontal_rule(1), horizontal_rule(1),
text(format!("Brightness: {:.0}", brightness)).size(11), text(format!("Brightness: {:.0}", brightness)).size(11),
brightness_slider, brightness_slider,
@@ -39,16 +43,7 @@ pub fn brightness_contrast_view(
.padding(16) .padding(16)
.width(350); .width(350);
container(dialog) super::modal(dialog, colors)
.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()
} }
/// Build the Hue/Saturation/Lightness adjustment dialog. /// Build the Hue/Saturation/Lightness adjustment dialog.
@@ -56,6 +51,7 @@ pub fn hsl_view(
hue: f32, hue: f32,
saturation: f32, saturation: f32,
lightness: f32, lightness: f32,
colors: ThemeColors,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let hue_slider = slider(-180.0..=180.0, hue, |v| Message::AdjHue(v)) let hue_slider = slider(-180.0..=180.0, hue, |v| Message::AdjHue(v))
.step(1.0) .step(1.0)
@@ -78,7 +74,9 @@ pub fn hsl_view(
.padding([6, 12]); .padding([6, 12]);
let dialog = column![ 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), horizontal_rule(1),
text(format!("Hue: {:.0}°", hue)).size(11), text(format!("Hue: {:.0}°", hue)).size(11),
hue_slider, hue_slider,
@@ -93,14 +91,5 @@ pub fn hsl_view(
.padding(16) .padding(16)
.width(350); .width(350);
container(dialog) super::modal(dialog, colors)
.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()
} }
@@ -5,15 +5,12 @@
//! when the canvas is resized. //! when the canvas is resized.
use crate::app::Message; use crate::app::Message;
use iced::widget::{button, column, container, horizontal_rule, row, text, text_input}; use crate::theme::ThemeColors;
use iced::{Element, Length}; use iced::widget::{button, column, horizontal_rule, row, text, text_input};
use iced::Element;
/// Anchor position labels for the 9-point grid (row-major). /// Anchor position labels for the 9-point grid (row-major).
const ANCHOR_LABELS: [[&str; 3]; 3] = [ const ANCHOR_LABELS: [[&str; 3]; 3] = [["TL", "TC", "TR"], ["ML", "MC", "MR"], ["BL", "BC", "BR"]];
["TL", "TC", "TR"],
["ML", "MC", "MR"],
["BL", "BC", "BR"],
];
/// Build the Canvas Size dialog. /// Build the Canvas Size dialog.
pub fn view( pub fn view(
@@ -21,6 +18,7 @@ pub fn view(
height: u32, height: u32,
anchor_row: usize, anchor_row: usize,
anchor_col: usize, anchor_col: usize,
colors: ThemeColors,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let width_str = width.to_string(); let width_str = width.to_string();
let height_str = height.to_string(); let height_str = height.to_string();
@@ -86,14 +84,5 @@ pub fn view(
.padding(16) .padding(16)
.width(360); .width(360);
container(dialog) super::modal(dialog, colors)
.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()
} }
@@ -1,11 +1,12 @@
//! Confirm dialog — save/discard/cancel confirmation. //! Confirm dialog — save/discard/cancel confirmation.
use crate::app::Message; 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}; use iced::{Element, Length};
/// Build a confirmation dialog with save/discard/cancel. /// 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 msg = format!("\"{}\" has unsaved changes.", doc_name);
let save_btn = button(text("Save").size(12)) 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) .padding(16)
.width(400); .width(400);
container(dialog) super::modal(dialog, colors)
.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()
} }
/// Build a selection operation dialog (grow/shrink/feather). /// Build a selection operation dialog (grow/shrink/feather).
@@ -49,6 +41,7 @@ pub fn selection_op_view(
value: f32, value: f32,
min: f32, min: f32,
max: f32, max: f32,
colors: ThemeColors,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let op_owned = op_name.to_string(); let op_owned = op_name.to_string();
let value_slider = slider(min..=max, value, |v| Message::SelectionOpValue(v)) let value_slider = slider(min..=max, value, |v| Message::SelectionOpValue(v))
@@ -75,14 +68,5 @@ pub fn selection_op_view(
.padding(16) .padding(16)
.width(350); .width(350);
container(dialog) super::modal(dialog, colors)
.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()
} }
@@ -1,7 +1,8 @@
//! Dock Profile dialog — save, load, rename, and delete dock layout profiles. //! Dock Profile dialog — save, load, rename, and delete dock layout profiles.
use crate::app::Message; 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}; use iced::{Element, Length};
/// Build the dock profile management dialog. /// Build the dock profile management dialog.
@@ -17,6 +18,7 @@ pub fn view(
profiles: &[crate::settings::DockProfile], profiles: &[crate::settings::DockProfile],
active_profile: Option<&str>, active_profile: Option<&str>,
new_name: &str, new_name: &str,
colors: ThemeColors,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let new_name_owned = new_name.to_string(); let new_name_owned = new_name.to_string();
@@ -24,9 +26,7 @@ pub fn view(
let mut profile_list = column![].spacing(4); let mut profile_list = column![].spacing(4);
if profiles.is_empty() { if profiles.is_empty() {
profile_list = profile_list.push( profile_list = profile_list.push(text("No saved profiles yet.").size(11));
text("No saved profiles yet.").size(11),
);
} else { } else {
for profile in profiles { for profile in profiles {
let name = profile.name.clone(); let name = profile.name.clone();
@@ -45,9 +45,7 @@ pub fn view(
.on_press(Message::DockProfileDelete(name.clone())) .on_press(Message::DockProfileDelete(name.clone()))
.padding([4, 8]); .padding([4, 8]);
let name_text = text(name.clone()) let name_text = text(name.clone()).size(12).width(Length::Fill);
.size(12)
.width(Length::Fill);
profile_list = profile_list.push( profile_list = profile_list.push(
row![apply_btn, name_text, rename_btn, delete_btn] 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())) .on_press(Message::DockProfileSave(new_name_owned.trim().to_string()))
.padding([6, 12]) .padding([6, 12])
} else { } else {
button(text("Save Current").size(11)) button(text("Save Current").size(11)).padding([6, 12])
.padding([6, 12])
}; };
// Scrollable profile list with fixed max height // Scrollable profile list with fixed max height
@@ -88,20 +85,11 @@ pub fn view(
row![button(text("Close").size(11)) row![button(text("Close").size(11))
.on_press(Message::DockProfileDialogHide) .on_press(Message::DockProfileDialogHide)
.padding([6, 12])] .padding([6, 12])]
.width(Length::Fill), .width(Length::Fill),
] ]
.spacing(8) .spacing(8)
.padding(16) .padding(16)
.width(420); .width(420);
container(dialog) super::modal(dialog, colors)
.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()
} }
@@ -5,14 +5,16 @@
//! updates the other to maintain the current aspect ratio. //! updates the other to maintain the current aspect ratio.
use crate::app::Message; use crate::app::Message;
use iced::widget::{button, checkbox, column, container, horizontal_rule, row, text, text_input}; use crate::theme::ThemeColors;
use iced::{Element, Length}; use iced::widget::{button, checkbox, column, horizontal_rule, row, text, text_input};
use iced::Element;
/// Build the Image Size dialog. /// Build the Image Size dialog.
pub fn view( pub fn view(
width: u32, width: u32,
height: u32, height: u32,
constrain: bool, constrain: bool,
colors: ThemeColors,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let width_str = width.to_string(); let width_str = width.to_string();
let height_str = height.to_string(); let height_str = height.to_string();
@@ -60,14 +62,5 @@ pub fn view(
.padding(16) .padding(16)
.width(360); .width(360);
container(dialog) super::modal(dialog, colors)
.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()
} }
@@ -1,9 +1,57 @@
//! Dialog modules — modal dialogs for the Iced GUI. //! 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 adjustments;
pub mod canvas_size;
pub mod confirm; pub mod confirm;
pub mod dock_profile; pub mod dock_profile;
pub mod image_size; pub mod image_size;
pub mod canvas_size; pub mod new_image;
pub mod about;
/// 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. //! New Image dialog — create a new document with preset sizes.
use crate::app::Message; 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}; use iced::{Element, Length};
/// A canvas preset size. /// A canvas preset size.
@@ -12,14 +15,46 @@ struct CanvasPreset {
} }
const PRESETS: &[CanvasPreset] = &[ const PRESETS: &[CanvasPreset] = &[
CanvasPreset { name: "Custom", width: 0, height: 0 }, CanvasPreset {
CanvasPreset { name: "HD (1280×720)", width: 1280, height: 720 }, name: "Custom",
CanvasPreset { name: "Full HD (1920×1080)", width: 1920, height: 1080 }, width: 0,
CanvasPreset { name: "4K (3840×2160)", width: 3840, height: 2160 }, height: 0,
CanvasPreset { name: "Square (1024×1024)", width: 1024, height: 1024 }, },
CanvasPreset { name: "A4 (2480×3508)", width: 2480, height: 3508 }, CanvasPreset {
CanvasPreset { name: "800×600", width: 800, height: 600 }, name: "HD (1280×720)",
CanvasPreset { name: "640×480", width: 640, height: 480 }, 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. /// Build the New Image dialog.
@@ -28,6 +63,7 @@ pub fn view(
width: u32, width: u32,
height: u32, height: u32,
transparent: bool, transparent: bool,
colors: ThemeColors,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let name_owned = name.to_string(); let name_owned = name.to_string();
let name_input = text_input("Document name", &name_owned) let name_input = text_input("Document name", &name_owned)
@@ -88,24 +124,5 @@ pub fn view(
.padding(16) .padding(16)
.width(400); .width(400);
// Center the dialog on screen with a semi-transparent backdrop super::modal(dialog, colors)
// 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()
} }
@@ -67,6 +67,93 @@ impl PaneType {
PaneType::AiScript => "AI Script Generator", 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. /// The dock layout state, wrapping iced's PaneGrid state.
@@ -154,7 +241,8 @@ impl DockState {
let pane_grid = State::with_configuration(root_config); let pane_grid = State::with_configuration(root_config);
// Find the Canvas pane (it's the center pane we want as root) // 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) .find(|(_, pane_type)| **pane_type == PaneType::Canvas)
.map(|(pane, _)| *pane) .map(|(pane, _)| *pane)
.unwrap_or_else(|| pane_grid.iter().next().map(|(p, _)| *p).unwrap()); .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. /// Split a pane horizontally with a new panel type.
#[allow(dead_code)] #[allow(dead_code)]
pub fn split_horizontal(&mut self, target: Pane, pane_type: PaneType) -> Option<Pane> { pub fn split_horizontal(&mut self, target: Pane, pane_type: PaneType) -> Option<Pane> {
let (new_pane, _) = self.pane_grid.split( let (new_pane, _) =
iced::widget::pane_grid::Axis::Horizontal, self.pane_grid
target, .split(iced::widget::pane_grid::Axis::Horizontal, target, pane_type)?;
pane_type,
)?;
Some(new_pane) Some(new_pane)
} }
/// Split a pane vertically with a new panel type. /// Split a pane vertically with a new panel type.
#[allow(dead_code)] #[allow(dead_code)]
pub fn split_vertical(&mut self, target: Pane, pane_type: PaneType) -> Option<Pane> { pub fn split_vertical(&mut self, target: Pane, pane_type: PaneType) -> Option<Pane> {
let (new_pane, _) = self.pane_grid.split( let (new_pane, _) =
iced::widget::pane_grid::Axis::Vertical, self.pane_grid
target, .split(iced::widget::pane_grid::Axis::Vertical, target, pane_type)?;
pane_type,
)?;
Some(new_pane) Some(new_pane)
} }
@@ -226,7 +310,9 @@ impl DockState {
return; return;
} }
let target_pane = self.pane_grid.iter() let target_pane = self
.pane_grid
.iter()
.find(|(_, t)| **t == PaneType::Canvas) .find(|(_, t)| **t == PaneType::Canvas)
.map(|(p, _)| *p) .map(|(p, _)| *p)
.or_else(|| self.focused_pane) .or_else(|| self.focused_pane)
@@ -234,9 +320,11 @@ impl DockState {
if let Some(target) = target_pane { if let Some(target) = target_pane {
let axis = match pane_type { let axis = match pane_type {
PaneType::Layers | PaneType::Properties | PaneType::History | PaneType::LayerDetails | PaneType::AiScript => { PaneType::Layers
iced::widget::pane_grid::Axis::Horizontal | PaneType::Properties
} | PaneType::History
| PaneType::LayerDetails
| PaneType::AiScript => iced::widget::pane_grid::Axis::Horizontal,
_ => iced::widget::pane_grid::Axis::Vertical, _ => iced::widget::pane_grid::Axis::Vertical,
}; };
if let Some((new_pane, _)) = self.pane_grid.split(axis, target, pane_type) { 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. /// Toggle a pane: close it if open, reopen it if closed.
pub fn toggle_pane(&mut self, pane_type: PaneType) { pub fn toggle_pane(&mut self, pane_type: PaneType) {
let is_open = self.pane_grid.iter().any(|(_, t)| *t == pane_type); 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 { if is_open {
// Don't close the canvas pane // Don't close the canvas pane
@@ -8,9 +8,10 @@
use crate::app::Message; use crate::app::Message;
use crate::dock::state::{DockState, PaneType}; use crate::dock::state::{DockState, PaneType};
use crate::panels::styles; use crate::panels::styles;
use iced::widget::svg; use crate::panels::typography::TITLE;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use iced::widget::pane_grid::{Content, TitleBar}; use iced::widget::pane_grid::{Content, TitleBar};
use iced::widget::svg;
use iced::widget::{button, column, container, pane_grid, row, text}; use iced::widget::{button, column, container, pane_grid, row, text};
use iced::{Element, Length}; use iced::{Element, Length};
@@ -40,9 +41,21 @@ pub fn dock_view<'a>(
let doc_tab_bar = document_tab_bar(app, colors); let doc_tab_bar = document_tab_bar(app, colors);
let canvas = { let canvas = {
let doc = &app.documents[app.active_doc]; 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; 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] column![doc_tab_bar, canvas]
.width(Length::Fill) .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 // Tools is no longer in the dock — toolbox is a fixed strip on the left
PaneType::Layers => { PaneType::Layers => {
let doc = &app.documents[app.active_doc]; 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 => { PaneType::History => {
let doc = &app.documents[app.active_doc]; let doc = &app.documents[app.active_doc];
crate::panels::history::view(&doc.cached_history, doc.history_current, colors) crate::panels::history::view(&doc.cached_history, doc.history_current, colors)
} }
PaneType::Brushes => { PaneType::Brushes => crate::panels::brushes::view(
crate::panels::brushes::view( app.tool_state.brush_style,
app.tool_state.brush_style, app.tool_state.brush_size,
app.tool_state.brush_size, app.tool_state.brush_opacity,
app.tool_state.brush_opacity, app.tool_state.brush_hardness,
app.tool_state.brush_hardness, colors,
colors, ),
) PaneType::Filters => crate::panels::filters::view(
} app.selected_filter,
PaneType::Filters => { &app.filter_params,
crate::panels::filters::view(app.selected_filter, &app.filter_params, app.filter_preview_active, colors) app.filter_preview_active,
} app.documents[app.active_doc]
PaneType::ColorPicker => { .engine
crate::color_picker::view(&app.fg_color, &app.bg_color, &app.recent_colors, app.color_tab) .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 => { PaneType::Properties => {
let doc = &app.documents[app.active_doc]; let doc = &app.documents[app.active_doc];
let active_id = doc.engine.active_layer_id(); 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_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_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_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_blend_mode = layer_info
let layer_type = layer_info.map(|l| l.layer_type).unwrap_or(hcie_engine_api::LayerType::Raster); .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_width = layer_info.map(|l| l.width).unwrap_or(0);
let layer_height = layer_info.map(|l| l.height).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(); 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_points,
ts2.vector_sides, ts2.vector_sides,
active_id, active_id,
app.fg_color,
app.bg_color,
) )
} }
PaneType::Script => { PaneType::Script => crate::panels::script_panel::view(
crate::panels::script_panel::view( &app.script_content,
&app.script_content, app.script_error.as_deref(),
app.script_error.as_deref(), app.script_error_line,
app.script_error_line, app.script_is_running,
app.script_is_running, colors,
),
PaneType::AiChat => crate::panels::ai_chat_panel::view(&app.ai_chat_state, colors),
PaneType::AiScript => crate::panels::ai_script_panel::view(app),
PaneType::LayerDetails => {
let doc = &app.documents[app.active_doc];
crate::panels::layer_details::view(
&doc.cached_layers,
doc.engine.active_layer_id(),
colors, colors,
) )
} }
PaneType::AiChat => {
crate::panels::ai_chat_panel::view(&app.ai_chat_state, colors)
}
PaneType::AiScript => {
crate::panels::ai_script_panel::view(app)
}
PaneType::LayerDetails => {
let doc = &app.documents[app.active_doc];
crate::panels::layer_details::view(&doc.cached_layers, doc.engine.active_layer_id(), colors)
}
PaneType::Geometry => { 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( crate::panels::geometry::view(
shapes, shapes,
colors, colors,
@@ -144,7 +176,13 @@ pub fn dock_view<'a>(
PaneType::ToolSettings => { PaneType::ToolSettings => {
let fg = app.fg_color; let fg = app.fg_color;
let draft = app.documents[app.active_doc].text_draft.as_ref(); 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 { .style(move |_theme| pane_grid::Style {
hovered_region: pane_grid::Highlight { hovered_region: pane_grid::Highlight {
background: iced::Background::Color(iced::Color::from_rgba( 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), 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 /// Shows a horizontal tab for each open document. The active document tab
/// is highlighted. Each tab shows the document name and a close button. /// is highlighted. Each tab shows the document name and a close button.
/// Modified documents show an asterisk (*). Tabs have hover feedback. /// 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); let mut tabs = row![].spacing(0);
for (idx, doc) in app.documents.iter().enumerate() { 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( let tab = button(
row![ row![
tab_text, tab_text,
button(text("×").size(10)) iced::widget::tooltip(
.on_press(Message::NoOp) button(text("×").size(10))
.padding([0, 4]) .on_press(Message::DocClose(idx))
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { .padding([0, 4])
match status { .style(
iced::widget::button::Status::Hovered => iced::widget::button::Style { move |_theme: &iced::Theme, status: iced::widget::button::Status| {
background: Some(iced::Background::Color(c.bg_active)), match status {
text_color: c.text_primary, iced::widget::button::Status::Hovered => {
border: iced::Border::default(), iced::widget::button::Style {
..Default::default() background: Some(iced::Background::Color(c.bg_active)),
}, text_color: c.text_primary,
_ => iced::widget::button::Style { border: iced::Border::default(),
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))), ..Default::default()
text_color: c.text_secondary, }
border: iced::Border::default(), }
..Default::default() _ => iced::widget::button::Style {
}, background: Some(iced::Background::Color(
} iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0),
}), )),
text_color: c.text_secondary,
border: iced::Border::default(),
..Default::default()
},
}
}
),
text(format!("Close {}", doc.name)).size(11),
iced::widget::tooltip::Position::Bottom,
),
] ]
.spacing(4) .spacing(4)
.align_y(iced::Alignment::Center) .align_y(iced::Alignment::Center),
) )
.on_press(Message::DocSwitch(idx)) .on_press(Message::DocSwitch(idx))
.padding([4, 8]) .padding([4, 8])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { .style(
match status { move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
iced::widget::button::Status::Active => { iced::widget::button::Status::Active => {
if active { if active {
iced::widget::button::Style { iced::widget::button::Style {
@@ -286,8 +340,8 @@ fn document_tab_bar<'a>(app: &'a crate::app::HcieIcedApp, colors: ThemeColors) -
text_color: c.accent, text_color: c.accent,
..Default::default() ..Default::default()
}, },
} },
}); );
tabs = tabs.push(tab); 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)) let new_btn = button(text("+").size(12))
.on_press(Message::DialogOpen(crate::app::ActiveDialog::NewImage)) .on_press(Message::DialogOpen(crate::app::ActiveDialog::NewImage))
.padding([4, 8]) .padding([4, 8])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { .style(
match status { 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(colors.bg_hover)), background: Some(iced::Background::Color(colors.bg_hover)),
text_color: colors.text_primary, text_color: colors.text_primary,
@@ -305,13 +359,15 @@ fn document_tab_bar<'a>(app: &'a crate::app::HcieIcedApp, colors: ThemeColors) -
..Default::default() ..Default::default()
}, },
_ => iced::widget::button::Style { _ => 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, text_color: colors.text_secondary,
border: iced::Border::default(), border: iced::Border::default(),
..Default::default() ..Default::default()
}, },
} },
}); );
tabs = tabs.push(new_btn); 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. /// Shows a panel type icon + label with close and maximize buttons.
/// Uses `panel_header` style from the theme for consistent header appearance. /// Uses `panel_header` style from the theme for consistent header appearance.
/// Buttons have hover feedback: transparent by default, bg_hover on hover. /// 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 // Panel type icon
let icon_paths = [ let icon_paths = [
std::path::PathBuf::from("hcie-iced-app/assets"), 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) { 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()) { 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 handle = svg::Handle::from_path(base.join(icon_file));
let icon = svg(handle) let icon = svg(handle).width(14).height(14).style(
.width(14) move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style {
.height(14)
.style(move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style {
color: Some(colors.text_secondary), color: Some(colors.text_secondary),
}); },
row![icon, text(pane_type.label()).size(11).color(colors.text_secondary)] );
.spacing(4) row![
.align_y(iced::Alignment::Center) icon,
.into() text(pane_type.label())
.size(TITLE)
.color(colors.text_secondary)
]
.spacing(4)
.align_y(iced::Alignment::Center)
.into()
} else { } else {
text(pane_type.label()).size(11).color(colors.text_secondary).into() text(pane_type.label())
.size(TITLE)
.color(colors.text_secondary)
.into()
} }
} else { } 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 // Close button with hover state
let close_btn = button(text("×").size(11)) let close_btn = button(text("×").size(11))
.on_press(Message::PaneClose(pane)) .on_press(Message::PaneClose(pane))
.padding([2, 6]) .padding([2, 6])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { .style(
match status { 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(colors.bg_hover)), background: Some(iced::Background::Color(colors.bg_hover)),
text_color: colors.text_primary, 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() ..Default::default()
}, },
_ => iced::widget::button::Style { _ => 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, text_color: colors.text_secondary,
border: iced::Border::default(), border: iced::Border::default(),
..Default::default() ..Default::default()
}, },
} },
}); );
// Maximize button with hover state // Maximize button with hover state
let maximize_btn = button(text("").size(11)) let maximize_btn = button(text("").size(11))
.on_press(Message::PaneMaximize(pane)) .on_press(Message::PaneMaximize(pane))
.padding([2, 6]) .padding([2, 6])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { .style(
match status { 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(colors.bg_hover)), background: Some(iced::Background::Color(colors.bg_hover)),
text_color: colors.text_primary, 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() ..Default::default()
}, },
_ => iced::widget::button::Style { _ => 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, text_color: colors.text_secondary,
border: iced::Border::default(), border: iced::Border::default(),
..Default::default() ..Default::default()
}, },
} },
}); );
TitleBar::new( TitleBar::new(
row![title_content, iced::widget::Space::with_width(Length::Fill), maximize_btn, close_btn] row![
.spacing(2) title_content,
.align_y(iced::Alignment::Center) iced::widget::Space::with_width(Length::Fill),
maximize_btn,
close_btn
]
.spacing(2)
.align_y(iced::Alignment::Center),
) )
.style(move |_theme| styles::panel_header(colors)) .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]; let pr = state[abs_pressure.0 as usize];
log::info!( log::info!(
"[tablet-evdev] ABS_X=[{},{}], ABS_Y=[{},{}], ABS_PRESSURE=[{},{}]", "[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), (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 selection;
pub mod vector_edit;
+33 -39
View File
@@ -7,18 +7,22 @@ mod ai_script;
mod app; mod app;
mod brush_import; mod brush_import;
mod canvas; mod canvas;
mod cli;
mod color_picker; mod color_picker;
mod dialogs; mod dialogs;
mod dock; mod dock;
mod i18n; mod i18n;
mod io; mod io;
mod panels; mod panels;
mod raster;
mod screenshot;
mod script; mod script;
mod selection; mod selection;
mod settings; mod settings;
mod shape_sync; mod shape_sync;
mod sidebar; mod sidebar;
mod theme; mod theme;
mod vector_edit;
mod viewer; mod viewer;
use app::HcieIcedApp; use app::HcieIcedApp;
@@ -29,49 +33,39 @@ fn main() {
log::info!("Starting HCIE Iced GUI"); log::info!("Starting HCIE Iced GUI");
let args: Vec<String> = std::env::args().collect(); let args = match cli::parse_args(std::env::args().skip(1)) {
let mut load_path: Option<std::path::PathBuf> = None; Ok(args) => args,
Err(error) => {
let mut i = 1; eprintln!("{error}\n\n{}", cli::help_text());
while i < args.len() { std::process::exit(2);
match args[i].as_str() {
"--help" | "-h" => {
println!("Usage: hcie-iced [OPTIONS] [FILE]");
println!();
println!("HCIE — Iced Edition");
println!();
println!("Options:");
println!(" -h, --help Print this help message");
println!();
println!("Arguments:");
println!(" FILE Image file to open on startup");
std::process::exit(0);
}
other if !other.starts_with('-') => {
load_path = Some(std::path::PathBuf::from(other));
}
unknown => {
eprintln!("Unknown argument: {}", unknown);
std::process::exit(1);
}
} }
i += 1; };
if args.help {
println!("{}", cli::help_text());
return;
} }
// Start tablet evdev listener // Start tablet evdev listener
let tablet_state = std::sync::Arc::new(std::sync::Mutex::new( let tablet_state = std::sync::Arc::new(std::sync::Mutex::new(io::tablet::TabletState::new()));
io::tablet::TabletState::new(),
));
io::tablet::start_evdev_listener(tablet_state.clone()); io::tablet::start_evdev_listener(tablet_state.clone());
let _ = iced::application("HCIE — Iced Edition", HcieIcedApp::update, HcieIcedApp::view) let load_path = args.load_path;
.subscription(HcieIcedApp::subscription) let screenshot = args.screenshot;
.theme(HcieIcedApp::theme) if let Err(error) = iced::application(
.window_size((1280.0, 800.0)) "HCIE — Iced Edition",
.decorations(false) HcieIcedApp::update,
.run_with(move || { HcieIcedApp::view,
let (mut app, init_task) = HcieIcedApp::new(load_path); )
app.tablet_state = tablet_state; .subscription(HcieIcedApp::subscription)
(app, init_task) .theme(HcieIcedApp::theme)
}); .window_size((1280.0, 800.0))
.decorations(false)
.run_with(move || {
let (mut app, init_task) = HcieIcedApp::new(load_path, screenshot);
app.tablet_state = tablet_state;
(app, init_task)
}) {
eprintln!("failed to run HCIE Iced GUI: {error}");
std::process::exit(1);
}
} }
@@ -15,9 +15,21 @@ use iced::{Element, Length};
pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Message> { pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Message> {
// ── Provider selector row ── // ── Provider selector row ──
let provider_label = text("Provider:").size(10).color(colors.text_secondary); 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 ollama_btn = make_provider_btn(
let claude_btn = make_provider_btn("Claude", state.config.provider == crate::ai_chat::AiProvider::Claude, colors); "Ollama",
let openai_btn = make_provider_btn("OpenAI", state.config.provider == crate::ai_chat::AiProvider::OpenAI, colors); 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] let provider_row = row![provider_label, ollama_btn, claude_btn, openai_btn]
.spacing(4) .spacing(4)
.align_y(iced::Alignment::Center); .align_y(iced::Alignment::Center);
@@ -35,28 +47,37 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
.size(10) .size(10)
.width(Length::Fill); .width(Length::Fill);
let config_row = row![ let config_row = row![url_label, url_input, model_label, model_input,]
url_label, .spacing(4)
url_input, .align_y(iced::Alignment::Center);
model_label,
model_input,
]
.spacing(4)
.align_y(iced::Alignment::Center);
// ── Toggles row ── // ── Toggles row ──
let reasoning_label = text("Reasoning").size(9).color(colors.text_secondary); let reasoning_label = text("Reasoning").size(9).color(colors.text_secondary);
let reasoning_btn = button( 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]); .padding([2, 6]);
let canvas_label = text("Canvas ctx").size(9).color(colors.text_secondary); let canvas_label = text("Canvas ctx").size(9).color(colors.text_secondary);
let canvas_btn = button( 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]); .padding([2, 6]);
let toggles_row = row![ let toggles_row = row![
@@ -78,12 +99,18 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
column![ column![
text("AI Creative Assistant").size(13).color(colors.accent), text("AI Creative Assistant").size(13).color(colors.accent),
text("").size(4), 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("").size(4),
text("Ask me to paint landscapes, add details,").size(10).color(colors.text_disabled), text("Ask me to paint landscapes, add details,")
text("apply blur filters, or manipulate layers!").size(10).color(colors.text_disabled), .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) .padding(12)
.width(Length::Fill) .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() { if state.config.reasoning_enabled && !state.current_reasoning.is_empty() {
let reasoning_bubble = container( let reasoning_bubble = container(
column![ column![
text("Deep Thoughts:").size(10).color(iced::Color::from_rgb(0.85, 0.47, 0.02)), text("Deep Thoughts:")
text(state.current_reasoning.clone()).size(10).color(colors.text_secondary), .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) .padding(8)
.width(Length::Fill) .width(Length::Fill)
.style(move |_theme| iced::widget::container::Style { .style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(0.15, 0.12, 0.08, 1.0))), background: Some(iced::Background::Color(iced::Color::from_rgba(
border: iced::Border::default().rounded(6).color(iced::Color::from_rgb(0.85, 0.47, 0.02)), 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() ..Default::default()
}); });
@@ -124,13 +159,19 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
// Render streaming response if in progress // Render streaming response if in progress
if state.is_streaming && !state.current_response.is_empty() { if state.is_streaming && !state.current_response.is_empty() {
let streaming_bubble = container( 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) .padding(8)
.width(Length::Fill) .width(Length::Fill)
.style(move |_theme| iced::widget::container::Style { .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(
border: iced::Border::default().rounded(6).color(iced::Color::from_rgba(0.29, 0.62, 1.0, 0.5)), 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() ..Default::default()
}); });
@@ -141,7 +182,9 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
if state.is_streaming { if state.is_streaming {
let indicator = row![ let indicator = row![
text("...").size(10).color(colors.text_disabled), 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); .spacing(4);
@@ -155,15 +198,24 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
.size(11) .size(11)
.width(Length::Fill); .width(Length::Fill);
let send_btn = button(text("Send").size(10)) let send_btn = action_button(
.on_press(Message::AiChatSend) "Send",
.padding([6, 10]); (!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)) let input_row = row![input_field, send_btn, stream_btn, copy_btn, clear_btn]
.on_press(Message::AiChatClear)
.padding([6, 10]);
let input_row = row![input_field, send_btn, clear_btn]
.spacing(4) .spacing(4)
.align_y(iced::Alignment::Center); .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), text("AI Assistant").size(13).color(colors.text_primary),
provider_row, provider_row,
config_row, config_row,
text("Model discovery is unavailable; enter the exact provider model ID.")
.size(9)
.color(colors.text_disabled),
toggles_row, toggles_row,
horizontal_rule(1), horizontal_rule(1),
scrollable(msg_list).height(Length::Fill), scrollable(msg_list).height(Length::Fill),
@@ -188,26 +243,82 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
.into() .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. /// Render a single chat message as a styled bubble.
fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'static, Message> { 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() { match msg.role.as_str() {
"user" => { "user" => {
// User bubble — right-aligned, blue tint // User bubble — right-aligned, blue tint
let label = text("You").size(9).color(colors.accent); let label = text("You").size(9).color(colors.accent);
let content = text(msg.content.clone()).size(11).color(colors.text_primary); let content = text(msg.content.clone())
let bubble = container( .size(11)
column![label, content].spacing(2) .color(colors.text_primary);
) let bubble = container(column![label, content].spacing(2))
.padding(8) .padding(8)
.max_width(400.0) .max_width(400.0)
.style(move |_theme| iced::widget::container::Style { .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(
border: iced::Border::default().rounded(6), 0.16, 0.33, 0.58, 1.0,
..Default::default() ))),
}); border: iced::Border::default().rounded(6),
..Default::default()
});
row![iced::widget::Space::with_width(Length::Fill), bubble] row![iced::widget::Space::with_width(Length::Fill), bubble].into()
.into()
} }
"tool" => { "tool" => {
// Tool result — monospace pill // Tool result — monospace pill
@@ -224,40 +335,45 @@ fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'sta
_ => { _ => {
// Assistant bubble — left-aligned, slate tint // Assistant bubble — left-aligned, slate tint
let label = text("AI").size(9).color(colors.accent_green); let label = text("AI").size(9).color(colors.accent_green);
let content = text(msg.content.clone()).size(11).color(colors.text_primary); let content = text(msg.content.clone())
let bubble = container( .size(11)
column![label, content].spacing(2) .color(colors.text_primary);
) let bubble = container(column![label, content].spacing(2))
.padding(8) .padding(8)
.max_width(400.0) .max_width(400.0)
.style(move |_theme| iced::widget::container::Style { .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(
border: iced::Border::default().rounded(6), 0.18, 0.22, 0.28, 1.0,
..Default::default() ))),
}); border: iced::Border::default().rounded(6),
..Default::default()
});
row![bubble, iced::widget::Space::with_width(Length::Fill)] row![bubble, iced::widget::Space::with_width(Length::Fill)].into()
.into()
} }
} }
} }
/// Create a provider selection button with active state highlighting. /// 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 label_owned = label.to_string();
let msg = match label { let msg = match label {
"Ollama" => Message::AiChatProviderChanged("Ollama".to_string()), "Ollama" => Message::AiChatProviderChanged("Ollama".to_string()),
"Claude" => Message::AiChatProviderChanged("Claude".to_string()), "Claude" => Message::AiChatProviderChanged("Claude".to_string()),
"OpenAI" => Message::AiChatProviderChanged("OpenAI".to_string()), "OpenAI" => Message::AiChatProviderChanged("OpenAI".to_string()),
_ => Message::NoOp, _ => unreachable!("provider buttons are built from known labels"),
}; };
button(text(label_owned).size(10)) button(text(label_owned).size(10))
.on_press(msg) .on_press(msg)
.padding([4, 8]) .padding([4, 8])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { .style(
match status { move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
iced::widget::button::Status::Active | iced::widget::button::Status::Hovered => { iced::widget::button::Status::Active => {
if is_active { if is_active {
iced::widget::button::Style { iced::widget::button::Style {
background: Some(iced::Background::Color(colors.accent)), 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 { iced::widget::button::Status::Hovered => iced::widget::button::Style {
background: Some(iced::Background::Color(colors.bg_hover)), background: Some(iced::Background::Color(if is_active {
text_color: colors.text_secondary, 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), border: iced::Border::default().rounded(4),
..Default::default() ..Default::default()
}, },
} },
}) )
.into() .into()
} }
@@ -14,6 +14,7 @@ use iced::{Element, Length};
/// action buttons (Generate, Clear, Run, Copy), and the generated script /// action buttons (Generate, Clear, Run, Copy), and the generated script
/// output with error display. /// output with error display.
pub fn view(app: &HcieIcedApp) -> Element<'static, Message> { pub fn view(app: &HcieIcedApp) -> Element<'static, Message> {
let colors = app.theme_state.colors();
// ── Provider selector row ──────────────────────────── // ── Provider selector row ────────────────────────────
let provider_label = text("Provider").size(10); let provider_label = text("Provider").size(10);
let ollama_btn = button(text("Ollama").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]) .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 {
.on_press(Message::AiScriptRun) button(text("Run Script").size(11)).padding([6, 12])
.padding([6, 12]); } else {
button(text("Run Script").size(11))
.on_press(Message::AiScriptRun)
.padding([6, 12])
};
let copy_btn = button(text("Copy to Editor").size(11)) let copy_btn = if app.ai_script_output.is_empty() {
.on_press(Message::AiScriptCopy) button(text("To Editor").size(11)).padding([6, 12])
.padding([6, 12]); } else {
button(text("To Editor").size(11))
.on_press(Message::AiScriptCopy)
.padding([6, 12])
};
let copy_output_btn = if app.ai_script_output.is_empty() {
button(text("Copy Output").size(11)).padding([6, 12])
} else {
button(text("Copy Output").size(11))
.on_press(Message::AiScriptCopyOutput)
.padding([6, 12])
};
let clear_btn = button(text("Clear").size(11)) let clear_btn = button(text("Clear").size(11))
.on_press(Message::AiScriptClear) .on_press(Message::AiScriptClear)
@@ -69,24 +85,30 @@ pub fn view(app: &HcieIcedApp) -> Element<'static, Message> {
text(app.ai_script_output.clone()).size(10) text(app.ai_script_output.clone()).size(10)
}; };
let script_area = container( let script_area = container(scrollable(script_content).height(Length::Fill))
scrollable(script_content).height(Length::Fill) .padding(8)
) .style(move |_theme| iced::widget::container::Style {
.padding(8) background: Some(iced::Background::Color(colors.bg_app)),
.style(move |_theme| iced::widget::container::Style { border: iced::Border::default().rounded(4).color(colors.border_high),
background: Some(iced::Background::Color(iced::Color::from_rgb(0.12, 0.12, 0.12))), ..Default::default()
border: iced::Border::default().rounded(4), });
..Default::default()
});
// ── Error display ──────────────────────────────────── // ── Error display ────────────────────────────────────
let error_section: Element<'static, Message> = if let Some(ref err) = app.ai_script_error { let error_section: Element<'static, Message> = if let Some(ref err) = app.ai_script_error {
text(format!("Error: {}", err)) text(format!("Error: {}", err))
.size(10) .size(10)
.color(iced::Color::from_rgb(0.9, 0.3, 0.3)) .color(colors.danger)
.into() .into()
} else { } 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 ─────────────────────────────────── // ── Assemble panel ───────────────────────────────────
@@ -97,6 +119,9 @@ pub fn view(app: &HcieIcedApp) -> Element<'static, Message> {
provider_selector, provider_selector,
url_input, url_input,
model_input, model_input,
text("Model discovery is unavailable; enter the exact provider model ID.")
.size(9)
.color(colors.text_disabled),
horizontal_rule(1), horizontal_rule(1),
prompt_input, prompt_input,
row![generate_btn, clear_btn].spacing(4), row![generate_btn, clear_btn].spacing(4),
@@ -104,7 +129,7 @@ pub fn view(app: &HcieIcedApp) -> Element<'static, Message> {
script_area, script_area,
error_section, error_section,
horizontal_rule(1), horizontal_rule(1),
row![run_btn, copy_btn].spacing(4), row![run_btn, copy_btn, copy_output_btn].spacing(4),
] ]
.spacing(4) .spacing(4)
.padding(8); .padding(8);
@@ -113,8 +138,8 @@ pub fn view(app: &HcieIcedApp) -> Element<'static, Message> {
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.style(move |_theme| iced::widget::container::Style { .style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgb(0.18, 0.18, 0.18))), background: Some(iced::Background::Color(colors.bg_panel)),
border: iced::Border::default().color(iced::Color::from_rgb(0.3, 0.3, 0.3)).width(1), border: iced::Border::default().color(colors.border_high).width(1),
..Default::default() ..Default::default()
}) })
.into() .into()
@@ -12,7 +12,9 @@ use crate::app::Message;
use crate::panels::styles; use crate::panels::styles;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use hcie_engine_api::BrushStyle; 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 iced::{Element, Length};
use std::sync::OnceLock; use std::sync::OnceLock;
@@ -35,30 +37,118 @@ enum BrushCategory {
const BRUSH_PRESETS: &[BrushPreset] = &[ const BRUSH_PRESETS: &[BrushPreset] = &[
// Drawing // Drawing
BrushPreset { name: "Default", style: BrushStyle::Default, category: BrushCategory::Drawing }, BrushPreset {
BrushPreset { name: "Pencil", style: BrushStyle::Pencil, category: BrushCategory::Drawing }, name: "Default",
BrushPreset { name: "Pen", style: BrushStyle::Pen, category: BrushCategory::Drawing }, style: BrushStyle::Default,
BrushPreset { name: "Ink Pen", style: BrushStyle::InkPen, category: BrushCategory::Drawing }, category: BrushCategory::Drawing,
BrushPreset { name: "Calligraphy", style: BrushStyle::Calligraphy, category: BrushCategory::Drawing }, },
BrushPreset { name: "Sketch", style: BrushStyle::Sketch, category: BrushCategory::Drawing }, BrushPreset {
BrushPreset { name: "Marker", style: BrushStyle::Marker, category: BrushCategory::Drawing }, name: "Pencil",
BrushPreset { name: "Hatch", style: BrushStyle::Hatch, category: BrushCategory::Drawing }, 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 // Painting
BrushPreset { name: "Oil", style: BrushStyle::Oil, category: BrushCategory::Painting }, BrushPreset {
BrushPreset { name: "Charcoal", style: BrushStyle::Charcoal, category: BrushCategory::Painting }, name: "Oil",
BrushPreset { name: "Watercolor", style: BrushStyle::Watercolor, category: BrushCategory::Painting }, style: BrushStyle::Oil,
BrushPreset { name: "Crayon", style: BrushStyle::Crayon, category: BrushCategory::Painting }, category: BrushCategory::Painting,
BrushPreset { name: "Airbrush", style: BrushStyle::Airbrush, category: BrushCategory::Painting }, },
BrushPreset { name: "Spray", style: BrushStyle::Spray, category: BrushCategory::Painting }, BrushPreset {
BrushPreset { name: "Soft Round", style: BrushStyle::SoftRound, category: BrushCategory::Painting }, name: "Charcoal",
BrushPreset { name: "Hard Round", style: BrushStyle::HardRound, category: BrushCategory::Painting }, 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 // Effects
BrushPreset { name: "Star", style: BrushStyle::Star, category: BrushCategory::Effects }, BrushPreset {
BrushPreset { name: "Noise", style: BrushStyle::Noise, category: BrushCategory::Effects }, name: "Star",
BrushPreset { name: "Texture", style: BrushStyle::Texture, category: BrushCategory::Effects }, style: BrushStyle::Star,
BrushPreset { name: "Glow", style: BrushStyle::Glow, category: BrushCategory::Effects }, category: BrushCategory::Effects,
BrushPreset { name: "Leaf", style: BrushStyle::Leaf, category: BrushCategory::Effects }, },
BrushPreset { name: "Clouds", style: BrushStyle::Clouds, 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). /// 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 (x2, y2, _) = points[i + 1];
let thickness = match style { let thickness = match style {
BrushStyle::Pencil | BrushStyle::Sketch => 1.0 + 2.0 * (t1 * std::f32::consts::PI).sin().abs(), BrushStyle::Pencil | BrushStyle::Sketch => {
BrushStyle::Pen | BrushStyle::InkPen => 2.0 + 3.0 * (t1 * std::f32::consts::PI).sin().abs(), 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::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::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(), BrushStyle::Glow => 3.0 + 7.0 * (t1 * std::f32::consts::PI).sin().abs(),
_ => 2.0 + 5.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 dst_a = pixels[idx + 3] as f32 / 255.0;
let out_a = src_a + dst_a * (1.0 - src_a); let out_a = src_a + dst_a * (1.0 - src_a);
if out_a > 0.0 { 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] = ((color[0] as f32 * src_a
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] as f32 * dst_a * (1.0 - src_a))
pixels[idx + 2] = ((color[2] as f32 * src_a + pixels[idx + 2] as f32 * dst_a * (1.0 - src_a)) / out_a) as u8; / out_a) as u8;
pixels[idx + 1] = ((color[1] as f32 * src_a
+ pixels[idx + 1] as f32 * dst_a * (1.0 - src_a))
/ out_a) as u8;
pixels[idx + 2] = ((color[2] as f32 * src_a
+ pixels[idx + 2] as f32 * dst_a * (1.0 - src_a))
/ out_a) as u8;
pixels[idx + 3] = (out_a * 255.0) as u8; pixels[idx + 3] = (out_a * 255.0) as u8;
} }
} }
@@ -195,37 +299,65 @@ pub fn view(
) -> Element<'static, Message> { ) -> Element<'static, Message> {
// Category tabs // Category tabs
let categories = [ let categories = [
(BrushCategory::All, "All"), (BrushCategory::All, "All brushes", "icons/panel-brush.svg"),
(BrushCategory::Drawing, "Drawing"), (BrushCategory::Drawing, "Drawing", "icons/pen.svg"),
(BrushCategory::Painting, "Painting"), (BrushCategory::Painting, "Painting", "icons/brush.svg"),
(BrushCategory::Effects, "Effects"), (BrushCategory::Effects, "Effects", "icons/glow.svg"),
]; ];
let mut tabs = row![].spacing(2); let mut tabs = row![].spacing(2);
for (_cat, label) in categories { for (cat, label, icon_path) in categories {
let btn = button(text(label).size(10)) let active = cat == BrushCategory::All;
.on_press(Message::NoOp) // TODO: category filter let icon = svg(svg::Handle::from_path(
.padding([4, 8]) std::path::Path::new("hcie-iced-app/assets").join(icon_path),
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { ))
match status { .width(16)
iced::widget::button::Status::Hovered => iced::widget::button::Style { .height(16)
background: Some(iced::Background::Color(colors.bg_hover)), .style(move |_theme, _status| svg::Style {
text_color: colors.text_primary, color: Some(if active {
border: iced::Border::default(), colors.accent
..Default::default() } else {
colors.text_secondary
}),
});
let btn = button(icon).padding([3, 6]).style(
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
iced::widget::button::Status::Hovered => iced::widget::button::Style {
background: Some(iced::Background::Color(colors.bg_hover)),
text_color: colors.text_primary,
border: iced::Border::default(),
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(if active {
colors.bg_active
} else {
iced::Color::TRANSPARENT
})),
text_color: if active {
colors.accent
} else {
colors.text_secondary
}, },
_ => iced::widget::button::Style { border: iced::Border::default()
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))), .color(if active {
text_color: colors.text_secondary, colors.accent
border: iced::Border::default(), } else {
..Default::default() 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 grid_rows = column![].spacing(4);
let mut current_row = row![].spacing(4); let mut current_row = row![].spacing(4);
@@ -235,42 +367,40 @@ pub fn view(
let c = colors; let c = colors;
let style = preset.style; let style = preset.style;
let thumb = container( let thumb = container(iced::widget::image(preview).width(48).height(48))
iced::widget::image(preview) .width(56)
.width(48) .height(56)
.height(48) .center_x(48)
) .center_y(48)
.width(56) .style(move |_theme| {
.height(56) if is_selected {
.center_x(48) iced::widget::container::Style {
.center_y(48) background: Some(iced::Background::Color(c.bg_active)),
.style(move |_theme| { border: iced::Border::default().color(c.accent).width(2),
if is_selected { ..Default::default()
iced::widget::container::Style { }
background: Some(iced::Background::Color(c.bg_active)), } else {
border: iced::Border::default().color(c.accent).width(2), iced::widget::container::Style {
..Default::default() background: Some(iced::Background::Color(c.bg_panel)),
border: iced::Border::default().color(c.border_low).width(1),
..Default::default()
}
} }
} else { });
iced::widget::container::Style {
background: Some(iced::Background::Color(c.bg_panel)),
border: iced::Border::default().color(c.border_low).width(1),
..Default::default()
}
}
});
let label = container(text(preset.name).size(9)) let label = container(text(preset.name).size(9))
.width(Length::Fill) .width(Length::Fill)
.center_x(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) let clickable = button(cell)
.on_press(Message::BrushStyleSelected(style)) .on_press(Message::BrushStyleSelected(style))
.padding(2) .padding(2)
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { .style(
match status { 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_hover)), background: Some(iced::Background::Color(c.bg_hover)),
text_color: c.text_primary, text_color: c.text_primary,
@@ -278,18 +408,19 @@ pub fn view(
..Default::default() ..Default::default()
}, },
_ => iced::widget::button::Style { _ => 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, text_color: c.text_primary,
border: iced::Border::default(), border: iced::Border::default(),
..Default::default() ..Default::default()
}, },
} },
}); );
current_row = current_row.push(clickable); current_row = current_row.push(clickable);
// Wrap row every 3 items if (idx + 1) % 2 == 0 || idx == BRUSH_PRESETS.len() - 1 {
if (idx + 1) % 3 == 0 || idx == BRUSH_PRESETS.len() - 1 {
grid_rows = grid_rows.push(current_row); grid_rows = grid_rows.push(current_row);
current_row = row![].spacing(4); current_row = row![].spacing(4);
} }
@@ -300,30 +431,30 @@ pub fn view(
.step(1.0) .step(1.0)
.width(Length::Fill); .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| {
.step(0.01) Message::BrushOpacityChanged(v)
.width(Length::Fill); })
.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| {
.step(0.01) Message::BrushHardnessChanged(v)
.width(Length::Fill); })
.step(0.01)
.width(Length::Fill);
// Live preview — show current brush tip // Live preview — show current brush tip
let preview_handle = get_brush_preview(current_style); let preview_handle = get_brush_preview(current_style);
let live_preview = container( let live_preview = container(iced::widget::image(preview_handle).width(64).height(64))
iced::widget::image(preview_handle) .width(Length::Fill)
.width(64) .height(80)
.height(64) .center_x(Length::Fill)
) .center_y(Length::Fill)
.width(Length::Fill) .style(move |_theme| iced::widget::container::Style {
.height(80) background: Some(iced::Background::Color(colors.bg_active)),
.center_x(Length::Fill) border: iced::Border::default().color(colors.accent).width(1),
.center_y(Length::Fill) ..Default::default()
.style(move |_theme| iced::widget::container::Style { });
background: Some(iced::Background::Color(colors.bg_active)),
border: iced::Border::default().color(colors.accent).width(1),
..Default::default()
});
// Panel title is in the dock tab — no duplicate inside the panel. // Panel title is in the dock tab — no duplicate inside the panel.
let panel = column![ let panel = column![
@@ -331,11 +462,11 @@ pub fn view(
horizontal_rule(1), horizontal_rule(1),
scrollable(grid_rows).height(Length::Fill), scrollable(grid_rows).height(Length::Fill),
horizontal_rule(1), horizontal_rule(1),
row![ row![button(text("Import ABR").size(10))
button(text("Import ABR").size(10)) .on_press(Message::BrushImportAbr)
.on_press(Message::BrushImportAbr) .padding([4, 8])
.padding([4, 8]) .style(
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { move |_theme: &iced::Theme, status: iced::widget::button::Status| {
match 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(colors.accent)), background: Some(iced::Background::Color(colors.accent)),
@@ -350,8 +481,9 @@ pub fn view(
..Default::default() ..Default::default()
}, },
} }
}) }
].spacing(4), )]
.spacing(4),
text("Preview").size(10), text("Preview").size(10),
live_preview, live_preview,
text(format!("Size: {:.0}", current_size)).size(10), 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. /// 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 { fn param_f64(params: &serde_json::Value, key: &str, default: f64) -> f64 {
params params.get(key).and_then(|v| v.as_f64()).unwrap_or(default)
.get(key)
.and_then(|v| v.as_f64())
.unwrap_or(default)
} }
/// Read an integer parameter from the JSON params, falling back to a 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 { fn param_u64(params: &serde_json::Value, key: &str, default: u64) -> u64 {
params params.get(key).and_then(|v| v.as_u64()).unwrap_or(default)
.get(key)
.and_then(|v| v.as_u64())
.unwrap_or(default)
} }
/// Read a boolean parameter from the JSON params, falling back to a 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 { fn param_bool(params: &serde_json::Value, key: &str, default: bool) -> bool {
params params.get(key).and_then(|v| v.as_bool()).unwrap_or(default)
.get(key)
.and_then(|v| v.as_bool())
.unwrap_or(default)
} }
/// Read a string parameter from the JSON params, falling back to a 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 { fn param_str<'a>(params: &'a serde_json::Value, key: &str, default: &'a str) -> &'a str {
params params.get(key).and_then(|v| v.as_str()).unwrap_or(default)
.get(key)
.and_then(|v| v.as_str())
.unwrap_or(default)
} }
/// Build a labeled float slider row. /// Build a labeled float slider row.
@@ -71,14 +59,13 @@ fn float_slider(
let label_owned = label.to_string(); let label_owned = label.to_string();
let lbl = text(label_owned).size(11).width(Length::Fixed(80.0)); 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 s = slider(min_f..=max_f, val, move |v| {
let rounded = (v as f64 / step).round() * step; let rounded = (v as f64 / step).round() * step;
Message::FilterParamChanged( Message::FilterParamChanged(filter_owned.clone(), json!({ key_owned.clone(): rounded }))
filter_owned.clone(),
json!({ key_owned.clone(): rounded }),
)
}) })
.step(step_f) .step(step_f)
.width(Length::Fill); .width(Length::Fill);
@@ -104,7 +91,9 @@ fn int_slider(
let label_owned = label.to_string(); let label_owned = label.to_string();
let lbl = text(label_owned).size(11).width(Length::Fixed(80.0)); 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| { let s = slider(min_f..=max_f, val, move |v| {
Message::FilterParamChanged( Message::FilterParamChanged(
@@ -121,9 +110,13 @@ fn int_slider(
/// Build a section header for a group of parameters. /// Build a section header for a group of parameters.
fn section_header(label: &str, colors: ThemeColors) -> Element<'static, Message> { fn section_header(label: &str, colors: ThemeColors) -> Element<'static, Message> {
let label_owned = label.to_string(); let label_owned = label.to_string();
container(text(label_owned).size(11).style(move |_theme: &iced::Theme| iced::widget::text::Style { container(
color: Some(colors.text_secondary), 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)) .padding(iced::Padding::default().top(4.0).bottom(2.0))
.into() .into()
} }
@@ -173,8 +166,25 @@ pub fn view(
let distance = param_u64(params, "distance", 10); let distance = param_u64(params, "distance", 10);
column![ column![
section_header("Motion Blur", colors), section_header("Motion Blur", colors),
float_slider("Angle", "angle", "motion_blur", angle, 0.0, 360.0, 1.0, colors), float_slider(
int_slider("Distance", "distance", "motion_blur", distance, 1, 500, colors), "Angle",
"angle",
"motion_blur",
angle,
0.0,
360.0,
1.0,
colors
),
int_slider(
"Distance",
"distance",
"motion_blur",
distance,
1,
500,
colors
),
] ]
.spacing(2) .spacing(2)
.into() .into()
@@ -264,9 +274,36 @@ pub fn view(
section_header("Levels", colors), section_header("Levels", colors),
float_slider("Shadows", "shadows", "levels", shadows, 0.0, 255.0, 1.0, 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("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(
float_slider("Out Min", "output_min", "levels", out_min, 0.0, 255.0, 1.0, colors), "Highlights",
float_slider("Out Max", "output_max", "levels", out_max, 0.0, 255.0, 1.0, colors), "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) .spacing(2)
.into() .into()
@@ -275,7 +312,9 @@ pub fn view(
let vibrance = param_f64(params, "vibrance", 0.0); let vibrance = param_f64(params, "vibrance", 0.0);
column![ column![
section_header("Vibrance", colors), 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) .spacing(2)
.into() .into()
@@ -288,7 +327,16 @@ pub fn view(
section_header("Exposure", colors), section_header("Exposure", colors),
float_slider("Exposure", "exposure", "exposure", exposure, -5.0, 5.0, 0.01, 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("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) .spacing(2)
.into() .into()
@@ -307,8 +355,26 @@ pub fn view(
let max_lum = param_f64(params, "max_luminance", 100.0); let max_lum = param_f64(params, "max_luminance", 100.0);
column![ column![
section_header("Threshold", colors), section_header("Threshold", colors),
float_slider("Min Luminance", "min_luminance", "threshold", min_lum, 0.0, 100.0, 1.0, colors), float_slider(
float_slider("Max Luminance", "max_luminance", "threshold", max_lum, 0.0, 100.0, 1.0, colors), "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) .spacing(2)
.into() .into()
@@ -319,10 +385,7 @@ pub fn view(
let mono_g = param_f64(params, "monochrome_g", 40.0); let mono_g = param_f64(params, "monochrome_g", 40.0);
let mono_b = param_f64(params, "monochrome_b", 20.0); let mono_b = param_f64(params, "monochrome_b", 20.0);
let mono_c = param_f64(params, "monochrome_constant", 0.0); let mono_c = param_f64(params, "monochrome_constant", 0.0);
let mut col = column![ let mut col = column![section_header("Channel Mixer", colors),].spacing(2);
section_header("Channel Mixer", colors),
]
.spacing(2);
// Monochrome toggle // Monochrome toggle
let filter_id = "channel_mixer"; let filter_id = "channel_mixer";
@@ -340,10 +403,46 @@ pub fn view(
} }
if mono { 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(
col = col.push(float_slider("Monochrome G", "monochrome_g", filter_id, mono_g, -200.0, 200.0, 1.0, colors)); "Monochrome R",
col = col.push(float_slider("Monochrome B", "monochrome_b", filter_id, mono_b, -200.0, 200.0, 1.0, colors)); "monochrome_r",
col = col.push(float_slider("Constant", "monochrome_constant", filter_id, mono_c, -200.0, 200.0, 1.0, colors)); 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 { } else {
// RGB channels // RGB channels
let red_r = param_f64(params, "red_r", 100.0); 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); let blue_c = param_f64(params, "blue_constant", 0.0);
col = col.push(section_header("Red Channel", colors)); 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(
col = col.push(float_slider("Red→G", "red_g", filter_id, red_g, -200.0, 200.0, 1.0, colors)); "Red→R", "red_r", filter_id, red_r, -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→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(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(
col = col.push(float_slider("Green→G", "green_g", filter_id, green_g, -200.0, 200.0, 1.0, colors)); "Green→R",
col = col.push(float_slider("Green→B", "green_b", filter_id, green_b, -200.0, 200.0, 1.0, colors)); "green_r",
col = col.push(float_slider("Green Const", "green_constant", filter_id, green_c, -200.0, 200.0, 1.0, colors)); 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(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(
col = col.push(float_slider("Blue→G", "blue_g", filter_id, blue_g, -200.0, 200.0, 1.0, colors)); "Blue→R", "blue_r", filter_id, blue_r, -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→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() col.into()
@@ -392,16 +557,97 @@ pub fn view(
let tint_b = param_f64(params, "tint_b", 255.0); let tint_b = param_f64(params, "tint_b", 255.0);
column![ column![
section_header("Black & White", colors), section_header("Black & White", colors),
float_slider("Reds", "reds", "black_and_white", reds, 0.0, 100.0, 1.0, colors), float_slider(
float_slider("Yellows", "yellows", "black_and_white", yellows, 0.0, 100.0, 1.0, colors), "Reds",
float_slider("Greens", "greens", "black_and_white", greens, 0.0, 100.0, 1.0, colors), "reds",
float_slider("Cyans", "cyans", "black_and_white", cyans, 0.0, 100.0, 1.0, colors), "black_and_white",
float_slider("Blues", "blues", "black_and_white", blues, 0.0, 100.0, 1.0, colors), reds,
float_slider("Magentas", "magentas", "black_and_white", magentas, 0.0, 100.0, 1.0, colors), 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), 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(
float_slider("Tint G", "tint_g", "black_and_white", tint_g, 0.0, 255.0, 1.0, colors), "Tint R",
float_slider("Tint B", "tint_b", "black_and_white", tint_b, 0.0, 255.0, 1.0, colors), "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) .spacing(2)
.into() .into()
@@ -412,10 +658,7 @@ pub fn view(
let mode = param_str(params, "mode", "relative"); let mode = param_str(params, "mode", "relative");
let is_absolute = mode == "absolute"; let is_absolute = mode == "absolute";
let mut col = column![ let mut col = column![section_header("Selective Color", colors),].spacing(2);
section_header("Selective Color", colors),
]
.spacing(2);
// Mode toggle // Mode toggle
{ {
@@ -441,7 +684,12 @@ pub fn view(
("Neutrals", "neutrals"), ("Neutrals", "neutrals"),
("Blacks", "blacks"), ("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 { for (range_name, range_key) in &ranges {
col = col.push(section_header(range_name, colors)); col = col.push(section_header(range_name, colors));
@@ -449,14 +697,7 @@ pub fn view(
let param_key = format!("{}_{}", range_key, cmyk_key); let param_key = format!("{}_{}", range_key, cmyk_key);
let val = param_f64(params, &param_key, 0.0); let val = param_f64(params, &param_key, 0.0);
col = col.push(float_slider( col = col.push(float_slider(
cmyk_name, cmyk_name, &param_key, filter_id, val, -100.0, 100.0, 1.0, colors,
&param_key,
filter_id,
val,
-100.0,
100.0,
1.0,
colors,
)); ));
} }
} }
@@ -468,7 +709,16 @@ pub fn view(
let inverse = param_bool(params, "inverse", false); let inverse = param_bool(params, "inverse", false);
let mut col = column![ let mut col = column![
section_header("Gamma Correction", colors), 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); .spacing(2);
@@ -489,21 +739,25 @@ pub fn view(
FilterType::ExtractChannel => { FilterType::ExtractChannel => {
let channel = param_str(params, "channel", "red"); let channel = param_str(params, "channel", "red");
let channels = ["red", "green", "blue", "alpha"]; let channels = ["red", "green", "blue", "alpha"];
let mut col = column![ let mut col = column![section_header("Extract Channel", colors),].spacing(2);
section_header("Extract Channel", colors),
]
.spacing(2);
for ch in &channels { for ch in &channels {
let is_active = channel == *ch; let is_active = channel == *ch;
let filter_id = "extract_channel"; let filter_id = "extract_channel";
let ch_label = text(ch.to_string()).size(11).width(Length::Fixed(80.0)); 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(
.on_press(Message::FilterParamChanged( text(if is_active {
filter_id.to_string(), format!(">> {}", ch)
json!({ "channel": ch }), } else {
)) ch.to_string()
.padding([2, 8]); })
.size(11),
)
.on_press(Message::FilterParamChanged(
filter_id.to_string(),
json!({ "channel": ch }),
))
.padding([2, 8]);
col = col.push(row![ch_label, btn].spacing(4)); col = col.push(row![ch_label, btn].spacing(4));
} }
@@ -524,9 +778,7 @@ pub fn view(
.padding([2, 8]); .padding([2, 8]);
row![lbl, toggle_btn].spacing(4) 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) .spacing(2)
.into() .into()
@@ -585,7 +837,12 @@ pub fn view(
for nt in &noise_types { for nt in &noise_types {
let is_active = noise_type == *nt; let is_active = noise_type == *nt;
let btn = iced::widget::button( 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( .on_press(Message::FilterParamChanged(
filter_id.to_string(), filter_id.to_string(),
@@ -603,7 +860,12 @@ pub fn view(
for ft in &fractal_types { for ft in &fractal_types {
let is_active = fractal_type == *ft; let is_active = fractal_type == *ft;
let btn = iced::widget::button( 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( .on_press(Message::FilterParamChanged(
filter_id.to_string(), filter_id.to_string(),
@@ -615,9 +877,35 @@ pub fn view(
} }
if fractal_type != "none" { if fractal_type != "none" {
col = col.push(int_slider("Octaves", "fractal_octaves", filter_id, fractal_octaves, 1, 10, colors)); col = col.push(int_slider(
col = col.push(float_slider("Lacunarity", "fractal_lacunarity", filter_id, fractal_lacunarity, 1.0, 4.0, 0.1, colors)); "Octaves",
col = col.push(float_slider("Gain", "fractal_gain", filter_id, fractal_gain, 0.0, 2.0, 0.01, colors)); "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 // Domain warp selector
@@ -627,7 +915,12 @@ pub fn view(
for wt in &warp_types { for wt in &warp_types {
let is_active = domain_warp_type == *wt; let is_active = domain_warp_type == *wt;
let btn = iced::widget::button( 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( .on_press(Message::FilterParamChanged(
filter_id.to_string(), filter_id.to_string(),
@@ -639,7 +932,16 @@ pub fn view(
} }
if domain_warp_type != "none" { 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") // Cellular settings (shown when noise_type is "cellular")
@@ -650,7 +952,12 @@ pub fn view(
for df in &dist_funcs { for df in &dist_funcs {
let is_active = cellular_distance == *df; let is_active = cellular_distance == *df;
let btn = iced::widget::button( 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( .on_press(Message::FilterParamChanged(
filter_id.to_string(), filter_id.to_string(),
@@ -665,7 +972,12 @@ pub fn view(
for rt in &ret_types { for rt in &ret_types {
let is_active = cellular_return == *rt; let is_active = cellular_return == *rt;
let btn = iced::widget::button( 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( .on_press(Message::FilterParamChanged(
filter_id.to_string(), filter_id.to_string(),
@@ -674,15 +986,21 @@ pub fn view(
.padding([2, 8]); .padding([2, 8]);
col = col.push(btn); 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() col.into()
} }
}; };
container(content) container(content).width(Length::Fill).padding(4).into()
.width(Length::Fill)
.padding(4)
.into()
} }
@@ -23,65 +23,134 @@ const FILTER_CATEGORIES: &[FilterCategory] = &[
FilterCategory { FilterCategory {
name: "Blur", name: "Blur",
filters: &[ filters: &[
FilterEntry { name: "Box Blur", filter_type: FilterType::BoxBlur }, FilterEntry {
FilterEntry { name: "Gaussian Blur", filter_type: FilterType::GaussianBlur }, name: "Box Blur",
FilterEntry { name: "Motion Blur", filter_type: FilterType::MotionBlur }, filter_type: FilterType::BoxBlur,
FilterEntry { name: "Median Filter", filter_type: FilterType::MedianFilter }, },
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 { FilterCategory {
name: "Sharpen", name: "Sharpen",
filters: &[ filters: &[FilterEntry {
FilterEntry { name: "Unsharp Mask", filter_type: FilterType::UnsharpMask }, name: "Unsharp Mask",
], filter_type: FilterType::UnsharpMask,
}],
}, },
FilterCategory { FilterCategory {
name: "Pixelate", name: "Pixelate",
filters: &[ filters: &[
FilterEntry { name: "Mosaic", filter_type: FilterType::Mosaic }, FilterEntry {
FilterEntry { name: "Crystallize", filter_type: FilterType::Crystallize }, name: "Mosaic",
filter_type: FilterType::Mosaic,
},
FilterEntry {
name: "Crystallize",
filter_type: FilterType::Crystallize,
},
], ],
}, },
FilterCategory { FilterCategory {
name: "Distort", name: "Distort",
filters: &[ filters: &[
FilterEntry { name: "Pinch", filter_type: FilterType::Pinch }, FilterEntry {
FilterEntry { name: "Twirl", filter_type: FilterType::Twirl }, name: "Pinch",
filter_type: FilterType::Pinch,
},
FilterEntry {
name: "Twirl",
filter_type: FilterType::Twirl,
},
], ],
}, },
FilterCategory { FilterCategory {
name: "Stylize", name: "Stylize",
filters: &[ filters: &[FilterEntry {
FilterEntry { name: "Oil Paint", filter_type: FilterType::OilPaint }, name: "Oil Paint",
], filter_type: FilterType::OilPaint,
}],
}, },
FilterCategory { FilterCategory {
name: "Color & Light", name: "Color & Light",
filters: &[ filters: &[
FilterEntry { name: "Levels", filter_type: FilterType::Levels }, FilterEntry {
FilterEntry { name: "Vibrance", filter_type: FilterType::Vibrance }, name: "Levels",
FilterEntry { name: "Exposure", filter_type: FilterType::Exposure }, filter_type: FilterType::Levels,
FilterEntry { name: "Posterize", filter_type: FilterType::Posterize }, },
FilterEntry { name: "Threshold", filter_type: FilterType::Threshold }, FilterEntry {
FilterEntry { name: "Channel Mixer", filter_type: FilterType::ChannelMixer }, name: "Vibrance",
FilterEntry { name: "Black & White", filter_type: FilterType::BlackAndWhite }, filter_type: FilterType::Vibrance,
FilterEntry { name: "Selective Color", filter_type: FilterType::SelectiveColor }, },
FilterEntry { name: "Gamma Correction", filter_type: FilterType::GammaCorrection }, FilterEntry {
FilterEntry { name: "Extract Channel", filter_type: FilterType::ExtractChannel }, name: "Exposure",
FilterEntry { name: "Gradient Map", filter_type: FilterType::GradientMap }, 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 { FilterCategory {
name: "Restore", name: "Restore",
filters: &[ filters: &[FilterEntry {
FilterEntry { name: "Dehaze", filter_type: FilterType::Dehaze }, name: "Dehaze",
], filter_type: FilterType::Dehaze,
}],
}, },
FilterCategory { FilterCategory {
name: "Noise & Pattern", name: "Noise & Pattern",
filters: &[ filters: &[FilterEntry {
FilterEntry { name: "Noise Pattern", filter_type: FilterType::NoisePattern }, name: "Noise Pattern",
], filter_type: FilterType::NoisePattern,
}],
}, },
]; ];
@@ -94,14 +163,13 @@ pub fn view(
selected_filter: Option<FilterType>, selected_filter: Option<FilterType>,
filter_params: &serde_json::Value, filter_params: &serde_json::Value,
filter_preview_active: bool, filter_preview_active: bool,
layer_editable: bool,
colors: ThemeColors, colors: ThemeColors,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let mut filter_list = column![].spacing(2); let mut filter_list = column![].spacing(2);
for category in FILTER_CATEGORIES { for category in FILTER_CATEGORIES {
let cat_name = text(category.name) let cat_name = text(category.name).size(12);
.size(12)
;
let mut cat_filters = column![].spacing(1).padding(iced::Padding::ZERO.left(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()); .style(move |_theme| item_style.clone());
let filter_type = entry.filter_type; let filter_type = entry.filter_type;
let clickable = iced::widget::mouse_area(item) let clickable =
.on_press(Message::FilterSelect(filter_type)); iced::widget::mouse_area(item).on_press(Message::FilterSelect(filter_type));
cat_filters = cat_filters.push(clickable); cat_filters = cat_filters.push(clickable);
} }
@@ -138,9 +206,13 @@ pub fn view(
filter_list = filter_list.push(cat_filters); filter_list = filter_list.push(cat_filters);
} }
let apply_btn = button(text("Apply").size(11)) let apply_btn = if layer_editable {
.on_press(Message::FilterApply) button(text("Apply").size(11))
.padding([6, 12]); .on_press(Message::FilterApply)
.padding([6, 12])
} else {
button(text("Apply (layer locked/type unsupported)").size(10)).padding([6, 8])
};
let reset_btn = button(text("Reset").size(11)) let reset_btn = button(text("Reset").size(11))
.on_press(Message::FilterReset) .on_press(Message::FilterReset)
@@ -150,14 +222,17 @@ pub fn view(
.on_toggle(Message::FilterPreviewToggle) .on_toggle(Message::FilterPreviewToggle)
.size(11); .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. // When a filter is selected, show its parameter panel below the list.
let param_section = match selected_filter { let param_section = match selected_filter {
Some(filter) => { Some(filter) => {
let params_panel = super::filter_params::view(filter, filter_params, colors); let params_panel = super::filter_params::view(filter, filter_params, colors);
column![horizontal_rule(1), scrollable(params_panel).height(Length::Fill)] column![
.spacing(0) horizontal_rule(1),
scrollable(params_panel).height(Length::Fill)
]
.spacing(0)
} }
None => column![], None => column![],
}; };
@@ -6,11 +6,13 @@
use crate::app::Message; use crate::app::Message;
use crate::color_picker; use crate::color_picker;
use crate::color_picker::ColorPickerTarget;
use crate::panels::styles; use crate::panels::styles;
use crate::panels::typography::{BODY, SECTION};
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use hcie_engine_api::{LineCap, VectorBooleanOp, VectorShape}; use hcie_engine_api::{LineCap, VectorBooleanOp, VectorShape};
use iced::widget::{ 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}; use iced::{Element, Length};
@@ -38,18 +40,15 @@ fn shape_label(shapes: &[VectorShape], i: usize) -> String {
/// Build a section header bar with the given label. /// Build a section header bar with the given label.
fn section_header(label: String, colors: ThemeColors) -> Element<'static, Message> { fn section_header(label: String, colors: ThemeColors) -> Element<'static, Message> {
container( container(row![text(label).size(SECTION)].spacing(4))
row![text(label).size(11)] .width(Length::Fill)
.spacing(4), .padding([3, 6])
) .style(move |_theme| iced::widget::container::Style {
.width(Length::Fill) background: Some(iced::Background::Color(colors.bg_hover)),
.padding([3, 6]) border: iced::Border::default().color(colors.border_high).width(1),
.style(move |_theme| iced::widget::container::Style { ..Default::default()
background: Some(iced::Background::Color(colors.bg_hover)), })
border: iced::Border::default().color(colors.border_high).width(1), .into()
..Default::default()
})
.into()
} }
/// Build a labeled row with a slider. /// Build a labeled row with a slider.
@@ -60,9 +59,11 @@ fn labeled_slider<'a>(
on_change: fn(f32) -> Message, on_change: fn(f32) -> Message,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
row![ row![
text(label).size(10).width(30), text(label).size(BODY).width(42),
slider(range, value, on_change).step(0.5).width(Length::Fill), slider(range, value, on_change)
text(format!("{:.0}", value)).size(9).width(32), .step(0.5)
.width(Length::Fill),
text(format!("{:.0}", value)).size(BODY).width(38),
] ]
.spacing(4) .spacing(4)
.align_y(iced::Alignment::Center) .align_y(iced::Alignment::Center)
@@ -94,7 +95,7 @@ pub fn view(
container( container(
row![ row![
Space::with_width(Length::Fill), Space::with_width(Length::Fill),
button(text("X").size(10)) button(text("X").size(BODY))
.on_press(Message::VectorShapeDelete) .on_press(Message::VectorShapeDelete)
.padding([2, 6]), .padding([2, 6]),
] ]
@@ -111,12 +112,16 @@ pub fn view(
let is_selected = selected_shape == Some(i); let is_selected = selected_shape == Some(i);
let c = colors; let c = colors;
let item = button(text(label).size(11)) let item = button(text(label).size(BODY))
.on_press(Message::VectorShapeSelect(if is_selected { None } else { Some(i) })) .on_press(Message::VectorShapeSelect(if is_selected {
None
} else {
Some(i)
}))
.width(Length::Fill) .width(Length::Fill)
.padding([4, 6]) .padding([4, 6])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { .style(
match status { move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
iced::widget::button::Status::Active => { iced::widget::button::Status::Active => {
if is_selected { if is_selected {
iced::widget::button::Style { iced::widget::button::Style {
@@ -132,32 +137,36 @@ pub fn view(
} }
} }
} }
iced::widget::button::Status::Hovered => { iced::widget::button::Status::Hovered => iced::widget::button::Style {
iced::widget::button::Style { background: Some(iced::Background::Color(if is_selected {
background: Some(iced::Background::Color(if is_selected { c.accent } else { c.bg_active })), c.accent
text_color: iced::Color::WHITE, } else {
..Default::default() c.bg_active
} })),
} text_color: iced::Color::WHITE,
..Default::default()
},
_ => iced::widget::button::Style { _ => iced::widget::button::Style {
background: Some(iced::Background::Color(c.bg_hover)), background: Some(iced::Background::Color(c.bg_hover)),
text_color: c.text_primary, text_color: c.text_primary,
..Default::default() ..Default::default()
}, },
} },
}); );
shape_list = shape_list.push(item); shape_list = shape_list.push(item);
} }
content = content.push( content = content.push(
scrollable(shape_list).height(Length::Fill).width(Length::Fill), scrollable(shape_list)
.height(Length::Fixed(132.0))
.width(Length::Fill),
); );
} else { } else {
content = content.push( content = content.push(
column![ column![
text("No vector shapes").size(11), text("No vector shapes").size(BODY),
text("Use vector tools to create shapes").size(10), text("Use vector tools to create shapes").size(BODY),
] ]
.spacing(2), .spacing(2),
); );
@@ -169,68 +178,52 @@ pub fn view(
content = content.push(section_header("PATH BOOLEAN".to_string(), colors)); content = content.push(section_header("PATH BOOLEAN".to_string(), colors));
content = content.push(Space::with_height(4)); content = content.push(Space::with_height(4));
// Shape A selector let labels: Vec<String> = (0..shape_count).map(|i| shape_label(&shapes, i)).collect();
let a_label = match bool_shape_a { let a_selected = bool_shape_a.and_then(|i| labels.get(i).cloned());
Some(i) if i < shape_count => shape_label(&shapes, i), let b_selected = bool_shape_b.and_then(|i| labels.get(i).cloned());
_ => "Shape A".to_string(), let a_labels = labels.clone();
}; let b_labels = labels.clone();
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]),
);
}
// Show selectors inline // Show selectors inline
content = content.push( content = content.push(
row![ row![
container( container(
column![ pick_list(labels.clone(), a_selected, move |selected| {
button(text(a_label).size(10)) Message::GeometryBoolShapeA(
.width(Length::Fill) a_labels.iter().position(|label| *label == selected),
.padding([4, 6]), )
a_menu, })
] .placeholder("Shape A")
.spacing(2) .text_size(BODY)
).width(Length::Fill).style(move |_theme| iced::widget::container::Style { .width(Length::Fill)
)
.width(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_active)), 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() ..Default::default()
}), }),
text(" + ").size(10), text(" + ").size(BODY),
container( container(
column![ pick_list(labels, b_selected, move |selected| {
button(text(b_label).size(10)) Message::GeometryBoolShapeB(
.width(Length::Fill) b_labels.iter().position(|label| *label == selected),
.padding([4, 6]), )
b_menu, })
] .placeholder("Shape B")
.spacing(2) .text_size(BODY)
).width(Length::Fill).style(move |_theme| iced::widget::container::Style { .width(Length::Fill)
)
.width(Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_active)), 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() ..Default::default()
}), }),
] ]
@@ -242,10 +235,18 @@ pub fn view(
content = content.push(Space::with_height(4)); content = content.push(Space::with_height(4));
content = content.push( content = content.push(
row![ row![
button(text("Union").size(10)).on_press(Message::GeometryBooleanOp(VectorBooleanOp::Union)).padding([3, 8]), button(text("Union").size(BODY))
button(text("Intersect").size(10)).on_press(Message::GeometryBooleanOp(VectorBooleanOp::Intersect)).padding([3, 8]), .on_press(Message::GeometryBooleanOp(VectorBooleanOp::Union))
button(text("Subtract").size(10)).on_press(Message::GeometryBooleanOp(VectorBooleanOp::Subtract)).padding([3, 8]), .padding([3, 8]),
button(text("Xor").size(10)).on_press(Message::GeometryBooleanOp(VectorBooleanOp::Xor)).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) .spacing(4)
.width(Length::Fill), .width(Length::Fill),
@@ -264,20 +265,38 @@ pub fn view(
let h = (y2 - y1).abs(); let h = (y2 - y1).abs();
// Bounds sliders // Bounds sliders
content = content.push(labeled_slider("X1", x1, -2000.0..=5000.0, Message::GeometryBoundsX1)); content = content.push(labeled_slider(
content = content.push(labeled_slider("Y1", y1, -2000.0..=5000.0, Message::GeometryBoundsY1)); "X1",
content = content.push(labeled_slider("X2", x2, -2000.0..=5000.0, Message::GeometryBoundsX2)); x1,
content = content.push(labeled_slider("Y2", y2, -2000.0..=5000.0, Message::GeometryBoundsY2)); -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 // Size display
content = content.push( content = content.push(text(format!("Size: {:.0} x {:.0}", w, h)).size(BODY).style(
text(format!("Size: {:.0} x {:.0}", w, h)) move |_theme| iced::widget::text::Style {
.size(10) color: Some(colors.text_secondary),
.style(move |_theme| iced::widget::text::Style { ..Default::default()
color: Some(colors.text_secondary), },
..Default::default() ));
}),
);
// Fill controls (not for Line shapes) // Fill controls (not for Line shapes)
let is_line = matches!(&shapes[idx], VectorShape::Line { .. }); let is_line = matches!(&shapes[idx], VectorShape::Line { .. });
@@ -288,7 +307,7 @@ pub fn view(
content = content.push( content = content.push(
row![ row![
checkbox("", cur_fill).on_toggle(Message::GeometryFillToggled), checkbox("", cur_fill).on_toggle(Message::GeometryFillToggled),
text("Fill").size(10), text("Fill").size(BODY),
] ]
.spacing(4) .spacing(4)
.align_y(iced::Alignment::Center), .align_y(iced::Alignment::Center),
@@ -297,10 +316,10 @@ pub fn view(
if cur_fill { if cur_fill {
// Fill color: HSV color picker // Fill color: HSV color picker
content = content.push(Space::with_height(2)); content = content.push(Space::with_height(2));
content = content.push(color_picker::color_popup( content = content.push(color_picker::color_swatch(
fc, fc,
"Fill", "Fill",
Message::GeometryFillColorChanged, ColorPickerTarget::GeometryFill,
)); ));
} }
} }
@@ -309,25 +328,37 @@ pub fn view(
// Stroke color // Stroke color
let stroke_color = shapes[idx].color(); let stroke_color = shapes[idx].color();
content = content.push(Space::with_height(4)); content = content.push(Space::with_height(4));
content = content.push(color_picker::color_popup( content = content.push(color_picker::color_swatch(
stroke_color, stroke_color,
"Stroke", "Stroke",
Message::GeometryStrokeColorChanged, ColorPickerTarget::GeometryStroke,
)); ));
// Opacity slider // Opacity slider
let cur_opacity = shapes[idx].shape_opacity(); let cur_opacity = shapes[idx].shape_opacity();
content = content.push(Space::with_height(4)); 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 // Hardness slider
let cur_hardness = shapes[idx].hardness(); 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) // Line caps (for Line shapes only)
if is_line { if is_line {
let (cs, ce) = match &shapes[idx] { 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), _ => (LineCap::Round, LineCap::Round),
}; };
@@ -335,71 +366,59 @@ pub fn view(
content = content.push(section_header("LINE CAPS".to_string(), colors)); content = content.push(section_header("LINE CAPS".to_string(), colors));
content = content.push(Space::with_height(2)); 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( content = content.push(
row![ row![
text("Start").size(10).width(40), text("Start").size(BODY).width(48),
button(text(cs.label()).size(10)).padding([3, 6]).width(Length::Fill), 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) .spacing(4)
.align_y(iced::Alignment::Center), .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)); content = content.push(Space::with_height(2));
// End cap combo // End cap selector
content = content.push( content = content.push(
row![ row![
text("End").size(10).width(40), text("End").size(BODY).width(48),
button(text(ce.label()).size(10)).padding([3, 6]).width(Length::Fill), 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) .spacing(4)
.align_y(iced::Alignment::Center), .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) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.style(move |_theme| styles::panel_background(colors)) .style(move |_theme| styles::panel_background(colors))
@@ -11,15 +11,18 @@ use iced::widget::{column, container, horizontal_rule, scrollable, text};
use iced::{Element, Length}; use iced::{Element, Length};
/// Build the history panel element. /// 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); let mut history_list = column![].spacing(1);
for (idx, desc) in descriptions { for (idx, desc) in descriptions {
let is_current = *idx as i32 == current_idx; let is_current = *idx as i32 == current_idx;
let is_future = *idx as i32 > current_idx; let is_future = *idx as i32 > current_idx;
let label = text(format!("{}: {}", idx, desc)) let label = text(format!("{}: {}", idx, desc)).size(11);
.size(11);
let label = if is_current { let label = if is_current {
label.style(move |_theme: &iced::Theme| iced::widget::text::Style { label.style(move |_theme: &iced::Theme| iced::widget::text::Style {
@@ -44,18 +47,16 @@ pub fn view<'a>(descriptions: &[(usize, String)], current_idx: i32, colors: Them
.padding([3, 6]) .padding([3, 6])
.style(move |_theme| item_style.clone()); .style(move |_theme| item_style.clone());
let clickable = iced::widget::mouse_area(item) let clickable =
.on_press(Message::HistoryJumpTo(*idx as i32)); iced::widget::mouse_area(item).on_press(Message::HistoryJumpTo(*idx as i32));
history_list = history_list.push(clickable); history_list = history_list.push(clickable);
} }
// Panel title is in the dock tab — no duplicate inside the panel. // Panel title is in the dock tab — no duplicate inside the panel.
let panel = column![ let panel = column![scrollable(history_list).height(Length::Fill),]
scrollable(history_list).height(Length::Fill), .spacing(0)
] .padding(4);
.spacing(0)
.padding(4);
container(panel) container(panel)
.width(Length::Fill) .width(Length::Fill)
@@ -12,7 +12,11 @@ use iced::widget::{column, container, horizontal_rule, row, scrollable, text};
use iced::{Element, Length}; use iced::{Element, Length};
/// Build the layer details panel. /// 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 active_layer = layers.iter().find(|l| l.id == active_layer_id);
let content: Element<'a, Message> = match active_layer { 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("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("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("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), horizontal_rule(1),
row![text("Blend:").size(10), text(blend_mode).size(10)].spacing(4), 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![
row![text("Visible:").size(10), text(if layer.visible { "Yes" } else { "No" }).size(10)].spacing(4), text("Opacity:").size(10),
row![text("Locked:").size(10), text(if layer.locked { "Yes" } else { "No" }).size(10)].spacing(4), text(format!("{:.0}%", layer.opacity * 100.0)).size(10)
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), .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), horizontal_rule(1),
row![text("Effects:").size(10), text(if layer.has_effects { "Yes" } else { "No" }).size(10)].spacing(4), row![
row![text("Vector:").size(10), text(if layer.has_vector_shapes { text("Effects:").size(10),
format!("{} shapes", layer.shape_count) text(if layer.has_effects { "Yes" } else { "No" }).size(10)
} 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(4),
row![
text("Vector:").size(10),
text(if layer.has_vector_shapes {
format!("{} shapes", layer.shape_count)
} else {
"None".to_string()
})
.size(10)
]
.spacing(4),
row![
text("Collapsed:").size(10),
text(if layer.collapsed { "Yes" } else { "No" }).size(10)
]
.spacing(4),
] ]
.spacing(3) .spacing(3)
.into() .into()
@@ -46,11 +89,9 @@ pub fn view<'a>(layers: &'a [LayerInfo], active_layer_id: u64, colors: ThemeColo
}; };
// Panel title is in the dock tab — no duplicate inside the panel. // Panel title is in the dock tab — no duplicate inside the panel.
let panel = column![ let panel = column![scrollable(content).height(Length::Fill),]
scrollable(content).height(Length::Fill), .spacing(0)
] .padding(4);
.spacing(0)
.padding(4);
container(panel) container(panel)
.width(Length::Fill) .width(Length::Fill)
File diff suppressed because it is too large Load Diff
@@ -10,9 +10,12 @@
use crate::app::Message; use crate::app::Message;
use crate::panels::styles; use crate::panels::styles;
use crate::panels::thumbnails; use crate::panels::thumbnails;
use crate::panels::typography::BODY;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use hcie_engine_api::{BlendMode, LayerInfo, LayerType}; 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}; use iced::{Element, Length};
/// Wrapper for BlendMode to provide Display for pick_list. /// Wrapper for BlendMode to provide Display for pick_list.
@@ -26,29 +29,45 @@ impl std::fmt::Display for BlendModeItem {
} }
impl From<BlendMode> 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 { impl From<BlendModeItem> for BlendMode {
fn from(m: BlendModeItem) -> Self { m.0 } fn from(m: BlendModeItem) -> Self {
m.0
}
} }
const BLEND_MODE_ITEMS: &[BlendModeItem] = &[ const BLEND_MODE_ITEMS: &[BlendModeItem] = &[
BlendModeItem(BlendMode::Normal), BlendModeItem(BlendMode::Dissolve), BlendModeItem(BlendMode::Normal),
BlendModeItem(BlendMode::Darken), BlendModeItem(BlendMode::Multiply), BlendModeItem(BlendMode::Dissolve),
BlendModeItem(BlendMode::ColorBurn), BlendModeItem(BlendMode::LinearBurn), BlendModeItem(BlendMode::Darken),
BlendModeItem(BlendMode::Multiply),
BlendModeItem(BlendMode::ColorBurn),
BlendModeItem(BlendMode::LinearBurn),
BlendModeItem(BlendMode::DarkerColor), BlendModeItem(BlendMode::DarkerColor),
BlendModeItem(BlendMode::Lighten), BlendModeItem(BlendMode::Screen), BlendModeItem(BlendMode::Lighten),
BlendModeItem(BlendMode::ColorDodge), BlendModeItem(BlendMode::LinearDodge), BlendModeItem(BlendMode::Screen),
BlendModeItem(BlendMode::ColorDodge),
BlendModeItem(BlendMode::LinearDodge),
BlendModeItem(BlendMode::LighterColor), BlendModeItem(BlendMode::LighterColor),
BlendModeItem(BlendMode::Overlay), BlendModeItem(BlendMode::SoftLight), BlendModeItem(BlendMode::Overlay),
BlendModeItem(BlendMode::HardLight), BlendModeItem(BlendMode::VividLight), BlendModeItem(BlendMode::SoftLight),
BlendModeItem(BlendMode::LinearLight), BlendModeItem(BlendMode::PinLight), BlendModeItem(BlendMode::HardLight),
BlendModeItem(BlendMode::VividLight),
BlendModeItem(BlendMode::LinearLight),
BlendModeItem(BlendMode::PinLight),
BlendModeItem(BlendMode::HardMix), BlendModeItem(BlendMode::HardMix),
BlendModeItem(BlendMode::Difference), BlendModeItem(BlendMode::Exclusion), BlendModeItem(BlendMode::Difference),
BlendModeItem(BlendMode::Subtract), BlendModeItem(BlendMode::Divide), BlendModeItem(BlendMode::Exclusion),
BlendModeItem(BlendMode::Hue), BlendModeItem(BlendMode::Saturation), BlendModeItem(BlendMode::Subtract),
BlendModeItem(BlendMode::Color), BlendModeItem(BlendMode::Luminosity), BlendModeItem(BlendMode::Divide),
BlendModeItem(BlendMode::Hue),
BlendModeItem(BlendMode::Saturation),
BlendModeItem(BlendMode::Color),
BlendModeItem(BlendMode::Luminosity),
BlendModeItem(BlendMode::PassThrough), 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) { if let Some(children) = children_of.get(&parent_id) {
for info in children { for info in children {
let is_group = info.layer_type == LayerType::Group; 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 { if is_group && !info.collapsed {
walk(Some(info.id), depth + 1, children_of, result); walk(Some(info.id), depth + 1, children_of, result);
} }
@@ -114,7 +137,8 @@ fn layer_thumbnail<'a>(
} }
let info = entry.info; 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) .filter(|px| !px.is_empty() && info.width > 0 && info.height > 0)
.map(|px| thumbnails::generate_thumbnail(&px, info.width, info.height, 28)); .map(|px| thumbnails::generate_thumbnail(&px, info.width, info.height, 28));
@@ -141,17 +165,19 @@ fn layer_thumbnail<'a>(
_ => ("\u{25A3}", colors.text_secondary), _ => ("\u{25A3}", colors.text_secondary),
}; };
container( container(text(icon_char).size(10).style(move |_theme: &iced::Theme| {
text(icon_char).size(10).style(move |_theme: &iced::Theme| iced::widget::text::Style { iced::widget::text::Style {
color: Some(icon_color), color: Some(icon_color),
}) }
) }))
.width(30) .width(30)
.height(22) .height(22)
.center_x(Length::Fixed(30.0)) .center_x(Length::Fixed(30.0))
.center_y(Length::Fixed(22.0)) .center_y(Length::Fixed(22.0))
.style(move |_theme| iced::widget::container::Style { .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), border: iced::Border::default().color(colors.border_high).width(1),
..Default::default() ..Default::default()
}) })
@@ -169,18 +195,35 @@ pub fn view<'a>(
// ── Active layer controls ────────────────────────────── // ── Active layer controls ──────────────────────────────
let active_info = layers.iter().find(|l| l.id == active_layer_id); 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_opacity = active_info.map(|l| l.opacity).unwrap_or(1.0);
let active_locked = active_info.map(|l| l.locked).unwrap_or(false); let active_locked = active_info.map(|l| l.locked).unwrap_or(false);
let active_metadata = active_info
.map(|layer| {
format!(
"{:?} {}x{} parent {}",
layer.layer_type,
layer.width,
layer.height,
layer
.parent_id
.map_or_else(|| "root".to_string(), |id| id.to_string())
)
})
.unwrap_or_else(|| "No active layer".to_string());
let blend_list = pick_list(BLEND_MODE_ITEMS, Some(BlendModeItem(active_blend)), move |item| { let blend_list = pick_list(
Message::LayerSetBlendMode(active_layer_id, item.into()) BLEND_MODE_ITEMS,
}) Some(BlendModeItem(active_blend)),
move |item| Message::LayerSetBlendMode(active_layer_id, item.into()),
)
.width(Length::Fill) .width(Length::Fill)
.text_size(10); .text_size(BODY);
let opacity_label = text(format!("{}%", (active_opacity * 100.0) as u32)) let opacity_label = text(format!("{}%", (active_opacity * 100.0) as u32))
.size(10) .size(BODY)
.width(Length::Fixed(32.0)); .width(Length::Fixed(32.0));
let opacity_slider = slider(0.0..=1.0, active_opacity, move |v| { let opacity_slider = slider(0.0..=1.0, active_opacity, move |v| {
Message::LayerSetOpacity(active_layer_id, v) Message::LayerSetOpacity(active_layer_id, v)
@@ -188,11 +231,15 @@ pub fn view<'a>(
.step(0.01) .step(0.01)
.width(Length::Fill); .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) .spacing(3)
.align_y(iced::Alignment::Center); .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)) let lock_btn = button(text(lock_icon).size(11))
.on_press(Message::LayerToggleLock(active_layer_id)) .on_press(Message::LayerToggleLock(active_layer_id))
.padding([1, 4]) .padding([1, 4])
@@ -206,7 +253,15 @@ pub fn view<'a>(
let controls = column![ let controls = column![
row![blend_list].spacing(2), row![blend_list].spacing(2),
opacity_row, 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); .spacing(2);
@@ -219,13 +274,16 @@ pub fn view<'a>(
let depth = entry.depth; let depth = entry.depth;
// Indent spacer // Indent spacer
let indent: Element<'a, Message> = Space::with_width( let indent: Element<'a, Message> =
Length::Fixed((depth as f32) * 14.0) Space::with_width(Length::Fixed((depth as f32) * 14.0)).into();
).into();
// Collapse arrow (groups only) // Collapse arrow (groups only)
let collapse: Element<'a, Message> = if entry.is_group { 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)) button(text(arrow).size(8))
.on_press(Message::LayerToggleCollapse(info.id)) .on_press(Message::LayerToggleCollapse(info.id))
.padding([0, 1]) .padding([0, 1])
@@ -237,31 +295,46 @@ pub fn view<'a>(
// Visibility toggle // Visibility toggle
let vis_char = if info.visible { "\u{25CF}" } else { "\u{25CB}" }; 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_color = if info.visible {
let vis_btn = button(text(vis_char).size(10).style(move |_theme: &iced::Theme| iced::widget::text::Style { colors.text_primary
color: Some(vis_color), } 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)) .on_press(Message::LayerToggleVisibility(info.id))
.padding([0, 2]) .padding([0, 2])
.style(move |_theme, _status| flat_btn_style(colors)); .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 // Thumbnail
let thumb_elem = layer_thumbnail(entry, engine, colors); let thumb_elem = layer_thumbnail(entry, engine, colors);
// Layer name // 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) let name_text = text(&info.name)
.size(10) .size(BODY)
.width(Length::Fill) .width(Length::Fill)
.style(move |_theme: &iced::Theme| iced::widget::text::Style { color: Some(c) }); .style(move |_theme: &iced::Theme| iced::widget::text::Style { color: Some(c) });
// "fx" icon for layers with effects (Photoshop-style) // "fx" icon for layers with effects (Photoshop-style)
let fx_icon: Element<'a, Message> = if info.has_effects { let fx_icon: Element<'a, Message> = if info.has_effects {
button( button(text("fx").size(8).style(move |_theme: &iced::Theme| {
text("fx").size(8).style(move |_theme: &iced::Theme| iced::widget::text::Style { iced::widget::text::Style {
color: Some(colors.accent), color: Some(colors.accent),
}) }
) }))
.on_press(Message::LayerStyleToggle(0)) // Opens styles panel .on_press(Message::LayerStyleToggle(0)) // Opens styles panel
.padding([0, 2]) .padding([0, 2])
.style(move |_theme, _status| flat_btn_style(colors)) .style(move |_theme, _status| flat_btn_style(colors))
@@ -272,36 +345,38 @@ pub fn view<'a>(
// Type badge // Type badge
let type_badge: Element<'a, Message> = match info.layer_type { 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 { .style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(iced::Color::from_rgb(0.4, 0.8, 0.4)), 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 { .style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(iced::Color::from_rgb(0.4, 0.6, 1.0)), 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 { .style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(iced::Color::from_rgb(0.85, 0.7, 0.3)), 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 { .style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(iced::Color::from_rgb(0.8, 0.4, 0.8)), color: Some(iced::Color::from_rgb(0.8, 0.4, 0.8)),
}).into(), })
.into(),
_ => text("").size(8).into(), _ => text("").size(8).into(),
}; };
// Main layer row // Main layer row
let name_row = row![ let name_row =
collapse, row![collapse, vis_btn, row_lock, thumb_elem, name_text, fx_icon, type_badge,]
vis_btn, .spacing(2)
thumb_elem, .align_y(iced::Alignment::Center);
name_text,
fx_icon,
type_badge,
]
.spacing(2)
.align_y(iced::Alignment::Center);
let layer_item = if is_active { let layer_item = if is_active {
container(name_row) container(name_row)
@@ -309,13 +384,21 @@ pub fn view<'a>(
.padding([2, 3]) .padding([2, 3])
.style(move |_theme| { .style(move |_theme| {
let accent_bg = iced::Color::from_rgba( 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 { iced::widget::container::Style {
background: Some(iced::Background::Color(accent_bg)), background: Some(iced::Background::Color(accent_bg)),
border: iced::Border::default().color( border: iced::Border::default()
iced::Color::from_rgba(colors.accent.r, colors.accent.g, colors.accent.b, 0.4) .color(iced::Color::from_rgba(
).width(1), colors.accent.r,
colors.accent.g,
colors.accent.b,
0.4,
))
.width(1),
..Default::default() ..Default::default()
} }
}) })
@@ -326,8 +409,8 @@ pub fn view<'a>(
.style(move |_theme| styles::inactive_item_bg(colors)) .style(move |_theme| styles::inactive_item_bg(colors))
}; };
let clickable = iced::widget::mouse_area(layer_item) let clickable =
.on_press(Message::LayerSelect(info.id)); iced::widget::mouse_area(layer_item).on_press(Message::LayerSelect(info.id));
let row_with_indent = row![indent, clickable].align_y(iced::Alignment::Center); let row_with_indent = row![indent, clickable].align_y(iced::Alignment::Center);
layer_list = layer_list.push(row_with_indent); layer_list = layer_list.push(row_with_indent);
@@ -341,17 +424,20 @@ pub fn view<'a>(
if *enabled { if *enabled {
let effect_name = effect_full_name(code); let effect_name = effect_full_name(code);
let effect_color = fx_color(code); let effect_color = fx_color(code);
let effect_indent = Space::with_width( let effect_indent =
Length::Fixed((effects_depth as f32) * 14.0) Space::with_width(Length::Fixed((effects_depth as f32) * 14.0));
);
let effect_row = row![ let effect_row = row![
effect_indent, 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| {
color: Some(colors.text_secondary), iced::widget::text::Style {
}), color: Some(colors.text_secondary),
text(effect_name).size(9).style(move |_theme: &iced::Theme| iced::widget::text::Style { }
color: Some(effect_color),
}), }),
text(effect_name)
.size(BODY)
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(effect_color),
}),
] ]
.spacing(2) .spacing(2)
.align_y(iced::Alignment::Center); .align_y(iced::Alignment::Center);
@@ -371,6 +457,9 @@ pub fn view<'a>(
.on_press(Message::LayerAddGroup) .on_press(Message::LayerAddGroup)
.padding([3, 6]) .padding([3, 6])
.style(move |_theme, _status| flat_btn_style(colors)); .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)) let flatten_btn = button(text("Flatten").size(10))
.on_press(Message::LayerFlatten) .on_press(Message::LayerFlatten)
.padding([3, 6]) .padding([3, 6])
@@ -392,17 +481,19 @@ pub fn view<'a>(
.on_press(Message::OpenLayerStyleDialog) .on_press(Message::OpenLayerStyleDialog)
.padding([3, 6]) .padding([3, 6])
.style(move |_theme, _status| flat_btn_style(colors)); .style(move |_theme, _status| flat_btn_style(colors));
let toolbar = row![ let toolbar = column![
add_btn, group_btn, flatten_btn, text("Disabled (engine API): duplicate / mask / rasterize").size(8),
iced::widget::horizontal_rule(1), row![duplicate_btn, mask_btn, rasterize_btn]
fx_btn, .spacing(2)
iced::widget::horizontal_rule(1), .align_y(iced::Alignment::Center),
move_up_btn, move_down_btn, row![add_btn, group_btn, flatten_btn, fx_btn]
iced::widget::horizontal_rule(1), .spacing(2)
del_btn, .align_y(iced::Alignment::Center),
row![move_up_btn, move_down_btn, del_btn]
.spacing(2)
.align_y(iced::Alignment::Center),
] ]
.spacing(2) .spacing(2);
.align_y(iced::Alignment::Center);
// ── Panel layout (no duplicate title — dock tab provides it) ── // ── Panel layout (no duplicate title — dock tab provides it) ──
let panel = column![ let panel = column![
File diff suppressed because it is too large Load Diff
@@ -22,3 +22,4 @@ pub mod title_bar;
pub mod tool_options; pub mod tool_options;
pub mod tool_settings; pub mod tool_settings;
pub mod toolbar; pub mod toolbar;
pub mod typography;
@@ -6,10 +6,14 @@
use crate::app::Message; use crate::app::Message;
use crate::color_picker; use crate::color_picker;
use crate::color_picker::ColorPickerTarget;
use crate::panels::styles; use crate::panels::styles;
use crate::panels::typography::{BODY, SECTION};
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use hcie_engine_api::{BlendMode, LayerType, Tool, VectorShape}; 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}; use iced::{Element, Length};
/// Human-readable label for a shape at a given index. /// Human-readable label for a shape at a given index.
@@ -104,6 +108,8 @@ pub fn view<'a>(
vector_points: u32, vector_points: u32,
vector_sides: u32, vector_sides: u32,
active_layer_id: u64, active_layer_id: u64,
foreground_color: [u8; 4],
background_color: [u8; 4],
) -> Element<'a, Message> { ) -> Element<'a, Message> {
// Build layer info section at the top // Build layer info section at the top
let layer_section = layer_info_section( let layer_section = layer_info_section(
@@ -141,40 +147,40 @@ pub fn view<'a>(
| Tool::VectorCross | Tool::VectorCross
| Tool::VectorCrescent | Tool::VectorCrescent
| Tool::VectorBolt | Tool::VectorBolt
| Tool::VectorArrow4 => { | Tool::VectorArrow4 => vector_tool_properties(
vector_tool_properties( active_tool,
active_tool, vector_stroke,
vector_stroke, vector_opacity,
vector_opacity, vector_fill,
vector_fill, vector_radius,
vector_radius, vector_points,
vector_points, vector_sides,
vector_sides, vector_draw,
vector_draw, colors,
colors, foreground_color,
) background_color,
} ),
Tool::Text => text_properties(colors), Tool::Text => text_properties(colors),
Tool::Eyedropper => column![ Tool::Eyedropper => column![
text("Eyedropper").size(11), text("Eyedropper").size(SECTION),
text("Click on canvas to pick color").size(10), text("Click on canvas to pick color").size(BODY),
] ]
.spacing(4) .spacing(4)
.into(), .into(),
Tool::FloodFill => column![ Tool::FloodFill => column![
text("Flood Fill").size(11), text("Flood Fill").size(SECTION),
text("Click on canvas to fill area").size(10), text("Click on canvas to fill area").size(BODY),
] ]
.spacing(4) .spacing(4)
.into(), .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,] let panel = column![layer_section, horizontal_rule(1), tool_section,]
.spacing(4) .spacing(4)
.padding(4); .padding(4);
container(panel) container(scrollable(panel))
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.style(move |_theme| styles::panel_background(colors)) .style(move |_theme| styles::panel_background(colors))
@@ -193,25 +199,29 @@ fn layer_info_section<'a>(
layer_id: u64, layer_id: u64,
_colors: ThemeColors, _colors: ThemeColors,
) -> Element<'a, Message> { ) -> 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| {
.step(0.01) Message::LayerSetOpacity(layer_id, v)
.width(Length::Fill); })
.step(0.01)
.width(Length::Fill);
column![ column![
text("Layer Info").size(11), text("Layer Info").size(SECTION),
row![ row![
text("Name").size(10).width(Length::Fixed(50.0)), text("Name").size(BODY).width(Length::Fixed(58.0)),
text(name).size(10), text(name).size(BODY),
] ]
.spacing(4), .spacing(4),
row![ row![
text("Opacity").size(10).width(Length::Fixed(50.0)), text("Opacity").size(BODY).width(Length::Fixed(58.0)),
text(format!("{:.0}%", opacity * 100.0)).size(10).width(Length::Fixed(36.0)), text(format!("{:.0}%", opacity * 100.0))
.size(BODY)
.width(Length::Fixed(36.0)),
] ]
.spacing(4), .spacing(4),
opacity_slider, opacity_slider,
row![ row![
text("Visible").size(10).width(Length::Fixed(50.0)), text("Visible").size(BODY).width(Length::Fixed(58.0)),
checkbox("", visible) checkbox("", visible)
.on_toggle(move |v| Message::LayerToggleVisibility(layer_id)) .on_toggle(move |v| Message::LayerToggleVisibility(layer_id))
.size(11), .size(11),
@@ -219,18 +229,18 @@ fn layer_info_section<'a>(
.spacing(4) .spacing(4)
.align_y(iced::Alignment::Center), .align_y(iced::Alignment::Center),
row![ row![
text("Blend").size(10).width(Length::Fixed(50.0)), text("Blend").size(BODY).width(Length::Fixed(58.0)),
text(blend_mode_label(blend_mode)).size(10), text(blend_mode_label(blend_mode)).size(BODY),
] ]
.spacing(4), .spacing(4),
row![ row![
text("Type").size(10).width(Length::Fixed(50.0)), text("Type").size(BODY).width(Length::Fixed(58.0)),
text(layer_type_label(layer_type)).size(10), text(layer_type_label(layer_type)).size(BODY),
] ]
.spacing(4), .spacing(4),
row![ row![
text("Size").size(10).width(Length::Fixed(50.0)), text("Size").size(BODY).width(Length::Fixed(58.0)),
text(format!("{}×{}", width, height)).size(10), text(format!("{}×{}", width, height)).size(BODY),
] ]
.spacing(4), .spacing(4),
] ]
@@ -274,23 +284,39 @@ fn vector_select_properties<'a>(
}; };
column![ column![
text("Shape Properties").size(11), text("Shape Properties").size(SECTION),
row![text("Shape").size(10), text(label).size(10)].spacing(4), row![text("Shape").size(BODY), text(label).size(BODY)].spacing(4),
row![text("Type").size(10), text(shape_type).size(10)].spacing(4), row![text("Type").size(BODY), text(shape_type).size(BODY)].spacing(4),
row![text("Stroke").size(10), text(format!("{:.1}", stroke)).size(10)].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)) slider(0.0..=50.0, stroke, move |v| Message::VectorStrokeChanged(v))
.step(0.5) .step(0.5)
.width(Length::Fill), .width(Length::Fill),
row![text("Opacity").size(10), text(format!("{:.0}%", opacity * 100.0)).size(10)].spacing(4),
slider(0.0..=1.0, opacity, move |v| Message::VectorOpacityChanged(v))
.step(0.01)
.width(Length::Fill),
row![text("Hardness").size(10), text(format!("{:.0}%", hardness * 100.0)).size(10)].spacing(4),
slider(0.0..=1.0, hardness, move |v| Message::GeometryHardnessChanged(v))
.step(0.01)
.width(Length::Fill),
row![ row![
text("Fill").size(10), text("Opacity").size(BODY),
text(format!("{:.0}%", opacity * 100.0)).size(BODY)
]
.spacing(4),
slider(0.0..=1.0, opacity, move |v| Message::VectorOpacityChanged(
v
))
.step(0.01)
.width(Length::Fill),
row![
text("Hardness").size(BODY),
text(format!("{:.0}%", hardness * 100.0)).size(BODY)
]
.spacing(4),
slider(0.0..=1.0, hardness, move |v| {
Message::GeometryHardnessChanged(v)
})
.step(0.01)
.width(Length::Fill),
row![
text("Fill").size(BODY),
checkbox("", filled) checkbox("", filled)
.on_toggle(Message::VectorFillToggled) .on_toggle(Message::VectorFillToggled)
.size(11), .size(11),
@@ -299,27 +325,27 @@ fn vector_select_properties<'a>(
.align_y(iced::Alignment::Center), .align_y(iced::Alignment::Center),
// Fill color picker (only if fill is enabled) // Fill color picker (only if fill is enabled)
if filled { if filled {
color_picker::color_popup( color_picker::color_swatch(
fill_color, fill_color,
"Fill Color", "Fill Color",
Message::GeometryFillColorChanged, ColorPickerTarget::GeometryFill,
) )
} else { } else {
text("").into() text("").into()
}, },
// Stroke color picker // Stroke color picker
color_picker::color_popup( color_picker::color_swatch(
stroke_color, stroke_color,
"Stroke Color", "Stroke Color",
Message::GeometryStrokeColorChanged, ColorPickerTarget::GeometryStroke,
), ),
] ]
.spacing(4) .spacing(4)
.into() .into()
} }
None => column![ None => column![
text("Vector Select").size(11), text("Vector Select").size(SECTION),
text("Click a shape on canvas").size(10), text("Click a shape on canvas").size(BODY),
] ]
.spacing(4) .spacing(4)
.into(), .into(),
@@ -337,19 +363,24 @@ fn vector_tool_properties<'a>(
sides: u32, sides: u32,
vector_draw: Option<((f32, f32), (f32, f32))>, vector_draw: Option<((f32, f32), (f32, f32))>,
_colors: ThemeColors, _colors: ThemeColors,
foreground_color: [u8; 4],
background_color: [u8; 4],
) -> Element<'a, Message> { ) -> 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![ let mut col = column![
text("Vector Shape").size(11), 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)) slider(0.0..=50.0, stroke, |v| Message::VectorStrokeChanged(v))
.step(0.5) .step(0.5)
.width(Length::Fill), .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)) slider(0.0..=1.0, opacity, |v| Message::VectorOpacityChanged(v))
.step(0.01) .step(0.01)
.width(Length::Fill), .width(Length::Fill),
@@ -363,19 +394,19 @@ fn vector_tool_properties<'a>(
.align_y(iced::Alignment::Center), .align_y(iced::Alignment::Center),
// Fill color picker (only if fill is enabled) // Fill color picker (only if fill is enabled)
if fill { if fill {
color_picker::color_popup( color_picker::color_swatch(
default_fill_color, background_color,
"Fill Color", "Fill Color",
Message::GeometryFillColorChanged, ColorPickerTarget::Background,
) )
} else { } else {
text("").into() text("").into()
}, },
// Stroke color picker // Stroke color picker
color_picker::color_popup( color_picker::color_swatch(
default_color, foreground_color,
"Stroke Color", "Stroke Color",
Message::GeometryStrokeColorChanged, ColorPickerTarget::Foreground,
), ),
] ]
.spacing(4); .spacing(4);
@@ -384,7 +415,11 @@ fn vector_tool_properties<'a>(
match tool { match tool {
Tool::VectorRect => { Tool::VectorRect => {
col = col.push( 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( col = col.push(
slider(0.0..=100.0, radius, |v| Message::VectorRadiusChanged(v)) slider(0.0..=100.0, radius, |v| Message::VectorRadiusChanged(v))
@@ -394,22 +429,29 @@ fn vector_tool_properties<'a>(
} }
Tool::VectorStar => { Tool::VectorStar => {
col = col.push( 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( 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| {
.step(1.0) Message::VectorPointsChanged(v as u32)
.width(Length::Fill), })
.step(1.0)
.width(Length::Fill),
); );
} }
Tool::VectorPolygon => { Tool::VectorPolygon => {
col = col
.push(row![text("Sides").size(10), text(format!("{}", sides)).size(10)].spacing(4));
col = col.push( col = col.push(
row![text("Sides").size(10), text(format!("{}", sides)).size(10)].spacing(4), slider(3.0..=12.0, sides as f32, |v| {
); Message::VectorSidesChanged(v as u32)
col = col.push( })
slider(3.0..=12.0, sides as f32, |v| Message::VectorSidesChanged(v as u32)) .step(1.0)
.step(1.0) .width(Length::Fill),
.width(Length::Fill),
); );
} }
_ => {} _ => {}
@@ -420,16 +462,20 @@ fn vector_tool_properties<'a>(
let w = (x1 - x0).abs() as u32; let w = (x1 - x0).abs() as u32;
let h = (y1 - y0).abs() as u32; let h = (y1 - y0).abs() as u32;
col = col.push(horizontal_rule(1)); col = col.push(horizontal_rule(1));
col = col.push( col =
row![text("Size").size(10), text(format!("{}×{}", w, h)).size(10)].spacing(4), col.push(row![text("Size").size(10), text(format!("{}×{}", w, h)).size(10)].spacing(4));
);
} }
col.into() col.into()
} }
/// Brush tool properties (Pen, Brush, Eraser, Spray). /// 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)) let size_slider = slider(1.0..=200.0, size, |v| Message::BrushSizeChanged(v))
.step(1.0) .step(1.0)
.width(Length::Fill); .width(Length::Fill);
@@ -444,9 +490,17 @@ fn brush_properties<'a>(size: f32, opacity: f32, hardness: f32, _colors: ThemeCo
text("Brush Properties").size(11), text("Brush Properties").size(11),
row![text("Size").size(10), text(format!("{:.0}", size)).size(10)].spacing(4), row![text("Size").size(10), text(format!("{:.0}", size)).size(10)].spacing(4),
size_slider, 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, 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, hardness_slider,
] ]
.spacing(4) .spacing(4)
@@ -454,7 +508,10 @@ fn brush_properties<'a>(size: f32, opacity: f32, hardness: f32, _colors: ThemeCo
} }
/// Selection tool properties. /// 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 { match rect {
Some((x0, y0, x1, y1)) => { Some((x0, y0, x1, y1)) => {
let w = (x1 - x0).abs() as u32; 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) .spacing(4)
.into() .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), text("Click on canvas to add text").size(10),
horizontal_rule(1), horizontal_rule(1),
row![ row![
button(text("Left").size(10)).on_press(Message::TextAlignChanged("Left".to_string())).padding([4, 8]), button(text("Left").size(10))
button(text("Center").size(10)).on_press(Message::TextAlignChanged("Center".to_string())).padding([4, 8]), .on_press(Message::TextAlignChanged("Left".to_string()))
button(text("Right").size(10)).on_press(Message::TextAlignChanged("Right".to_string())).padding([4, 8]), .padding([4, 8]),
].spacing(4), 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) .spacing(4)
.into() .into()
@@ -7,7 +7,9 @@
use crate::app::Message; use crate::app::Message;
use crate::panels::styles; use crate::panels::styles;
use crate::theme::ThemeColors; 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}; use iced::{Element, Length};
/// Build the full script editor panel. /// Build the full script editor panel.
@@ -43,7 +45,12 @@ pub fn view<'a>(
.fold(column![].spacing(0), |col, t| col.push(t)); .fold(column![].spacing(0), |col, t| col.push(t));
let gutter = container(scrollable(gutter_lines).height(Length::Fill)) 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 { .style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_app)), background: Some(iced::Background::Color(colors.bg_app)),
border: iced::Border::default().rounded(4), border: iced::Border::default().rounded(4),
@@ -69,9 +76,13 @@ pub fn view<'a>(
.padding(iced::Padding::from([6.0, 12.0])); .padding(iced::Padding::from([6.0, 12.0]));
let status_text = if script_is_running { 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 { } 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); let buttons = row![run_btn, clear_btn, status_text].spacing(6);
@@ -148,7 +159,7 @@ pub fn view<'a>(
// ── Command Reference ─────────────────────────────────── // ── Command Reference ───────────────────────────────────
let help_text = [ let help_text = [
("Layer:", "layer Name — create raster layer"), ("Layer:", "layer Name — create raster layer"),
("", "vector_layer Name — create vector layer"), ("", "vector_layer — unavailable (public engine API)"),
("", "select_layer Name — switch active layer"), ("", "select_layer Name — switch active layer"),
("Brush & Color:", "brush round — set brush style"), ("Brush & Color:", "brush round — set brush style"),
("", "color #FF0000 — set foreground color"), ("", "color #FF0000 — set foreground color"),
@@ -162,7 +173,10 @@ pub fn view<'a>(
("", "circle cx,cy — single dab"), ("", "circle cx,cy — single dab"),
("", "triangle x1,y1 x2,y2 x3,y3 — vector triangle"), ("", "triangle x1,y1 x2,y2 x3,y3 — vector triangle"),
("", "bezier x1,y1 cx1,cy1 cx2,cy2 x2,y2 [steps]"), ("", "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"), ("", "gradient x1,y1 x2,y2 #hex1 #hex2 ... N"),
("", "wiggle N — per-point jitter"), ("", "wiggle N — per-point jitter"),
("", "pressure / pressure_start / pressure_end"), ("", "pressure / pressure_start / pressure_end"),
@@ -172,7 +186,9 @@ pub fn view<'a>(
]; ];
let mut reference = column![ 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), horizontal_rule(1),
] ]
.spacing(2); .spacing(2);
@@ -193,16 +209,20 @@ pub fn view<'a>(
); );
} }
let reference_container = container(scrollable(reference).height(Length::Fill)) let reference_container =
.style(move |_theme| iced::widget::container::Style { container(scrollable(reference).height(Length::Fill)).style(move |_theme| {
background: Some(iced::Background::Color(colors.bg_app)), iced::widget::container::Style {
border: iced::Border::default().rounded(4), background: Some(iced::Background::Color(colors.bg_app)),
..Default::default() border: iced::Border::default().rounded(4),
..Default::default()
}
}); });
// ── Assemble the panel ────────────────────────────────── // ── Assemble the panel ──────────────────────────────────
let panel = column![ 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), horizontal_rule(1),
editor_row, editor_row,
buttons, buttons,
@@ -42,9 +42,9 @@ pub fn active_item_bg(colors: ThemeColors) -> iced::widget::container::Style {
#[allow(dead_code, unused_variables)] #[allow(dead_code, unused_variables)]
pub fn inactive_item_bg(_colors: ThemeColors) -> iced::widget::container::Style { pub fn inactive_item_bg(_colors: ThemeColors) -> iced::widget::container::Style {
iced::widget::container::Style { iced::widget::container::Style {
background: Some(iced::Background::Color( background: Some(iced::Background::Color(iced::Color::from_rgba(
iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0) 0.0, 0.0, 0.0, 0.0,
)), ))),
border: iced::Border::default(), border: iced::Border::default(),
..Default::default() ..Default::default()
} }
@@ -20,9 +20,15 @@ pub fn view(
let g = font_color[1]; let g = font_color[1];
let b = font_color[2]; let b = font_color[2];
let color_r = slider(0..=255, r, move |v| Message::TextColorChanged([v, g, b, 255])); let color_r = slider(0..=255, r, move |v| {
let color_g = slider(0..=255, g, move |v| Message::TextColorChanged([r, v, b, 255])); Message::TextColorChanged([v, g, b, 255])
let color_b = slider(0..=255, b, move |v| Message::TextColorChanged([r, g, v, 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)) let align_left = button(text("Left").size(10))
.on_press(Message::TextAlignChanged("Left".to_string())) .on_press(Message::TextAlignChanged("Left".to_string()))
@@ -64,8 +70,12 @@ pub fn view(
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.style(move |_theme| iced::widget::container::Style { .style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgb(0.18, 0.18, 0.18))), background: Some(iced::Background::Color(iced::Color::from_rgb(
border: iced::Border::default().color(iced::Color::from_rgb(0.3, 0.3, 0.3)).width(1), 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() ..Default::default()
}) })
.into() .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; let a = pixels[si + 3] as f32 / 255.0;
// Checkerboard: light (#f0f0f0) and dark (#c0c0c0) // 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; let bg = cb / 255.0;
out[di] = ((r * a + bg * (1.0 - a)) * 255.0).round() as u8; 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]; let mut out = vec![0u8; (w * h * 4) as usize];
for y in 0..h { for y in 0..h {
for x in 0..w { 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; let i = ((y * w + x) * 4) as usize;
out[i] = cb; out[i] = cb;
out[i + 1] = cb; out[i + 1] = cb;
@@ -14,17 +14,44 @@ use iced::widget::{button, container, mouse_area, row, text};
use iced::{Element, Length}; use iced::{Element, Length};
/// Photopea-style menu labels matching the reference screenshots. /// Photopea-style menu labels matching the reference screenshots.
const MENU_LABELS: &[&str] = &[ pub(crate) const MENU_LABELS: &[&str] = &[
"File", "Edit", "Image", "Layer", "Select", "File", "Edit", "Image", "Layer", "Select", "Filter", "View", "Window", "More",
"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. /// Build the Photopea-style unified title/menu bar element.
/// ///
/// Layout: [File Edit Image ... More] [doc info] [─ □ ×] /// Layout: [File Edit Image ... More] [doc info] [─ □ ×]
/// The left portion (before window controls) is draggable. /// The left portion (before window controls) is draggable.
pub fn view<'a>( pub fn view<'a>(
doc_name: &'a str, doc_name: &'a str,
modified: bool,
zoom: f32, zoom: f32,
canvas_w: u32, canvas_w: u32,
canvas_h: u32, canvas_h: u32,
@@ -36,10 +63,15 @@ pub fn view<'a>(
for (idx, &label) in MENU_LABELS.iter().enumerate() { for (idx, &label) in MENU_LABELS.iter().enumerate() {
let is_open = active_menu == Some(idx); let is_open = active_menu == Some(idx);
let c = colors; 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 {
.on_press(Message::MenuOpen(idx)) weight: iced::font::Weight::Semibold,
.padding([3, 8]) ..iced::Font::default()
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { }))
.on_press(Message::MenuOpen(idx))
.padding([3, 8])
.width(MENU_BUTTON_WIDTHS[idx])
.style(
move |_theme: &iced::Theme, status: iced::widget::button::Status| {
if is_open { if is_open {
// Active menu: light background with dark text (Photopea style) // Active menu: light background with dark text (Photopea style)
iced::widget::button::Style { iced::widget::button::Style {
@@ -57,23 +89,30 @@ pub fn view<'a>(
..Default::default() ..Default::default()
}, },
_ => iced::widget::button::Style { _ => 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, text_color: c.text_primary,
border: iced::Border::default(), border: iced::Border::default(),
..Default::default() ..Default::default()
}, },
} }
} }
}); },
);
menu_items = menu_items.push(btn); menu_items = menu_items.push(btn);
} }
// ── Document info — Photopea style: "New Project.psd *" ── // ── Document info — Photopea style: "New Project.psd *" ──
let doc_info = text(format!("{} *", doc_name)) let doc_info = text(if modified {
.size(11) format!("{} *", doc_name)
.style(move |_theme: &iced::Theme| iced::widget::text::Style { } else {
color: Some(colors.text_secondary), doc_name.to_string()
}); })
.size(11)
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(colors.text_secondary),
});
// ── Window controls — Photopea style: ─ □ × ── // ── Window controls — Photopea style: ─ □ × ──
let c = colors; let c = colors;
@@ -81,8 +120,8 @@ pub fn view<'a>(
let minimize_btn = button(text("").size(12)) let minimize_btn = button(text("").size(12))
.on_press(Message::WindowMinimize) .on_press(Message::WindowMinimize)
.padding([2, 8]) .padding([2, 8])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { .style(
match status { 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_hover)), background: Some(iced::Background::Color(c.bg_hover)),
text_color: c.text_primary, text_color: c.text_primary,
@@ -90,19 +129,21 @@ pub fn view<'a>(
..Default::default() ..Default::default()
}, },
_ => iced::widget::button::Style { _ => 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, text_color: c.text_secondary,
border: iced::Border::default(), border: iced::Border::default(),
..Default::default() ..Default::default()
}, },
} },
}); );
let maximize_btn = button(text("").size(12)) let maximize_btn = button(text("").size(12))
.on_press(Message::WindowMaximize) .on_press(Message::WindowMaximize)
.padding([2, 8]) .padding([2, 8])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { .style(
match status { 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_hover)), background: Some(iced::Background::Color(c.bg_hover)),
text_color: c.text_primary, text_color: c.text_primary,
@@ -110,19 +151,21 @@ pub fn view<'a>(
..Default::default() ..Default::default()
}, },
_ => iced::widget::button::Style { _ => 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, text_color: c.text_secondary,
border: iced::Border::default(), border: iced::Border::default(),
..Default::default() ..Default::default()
}, },
} },
}); );
let close_btn = button(text("×").size(13)) let close_btn = button(text("×").size(13))
.on_press(Message::WindowClose) .on_press(Message::WindowClose)
.padding([2, 10]) .padding([2, 10])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { .style(
match status { 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.danger)), background: Some(iced::Background::Color(c.danger)),
text_color: iced::Color::WHITE, text_color: iced::Color::WHITE,
@@ -130,37 +173,41 @@ pub fn view<'a>(
..Default::default() ..Default::default()
}, },
_ => iced::widget::button::Style { _ => 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, text_color: c.text_secondary,
border: iced::Border::default(), border: iced::Border::default(),
..Default::default() ..Default::default()
}, },
} },
}); );
let window_controls = row![minimize_btn, maximize_btn, close_btn].spacing(0); let window_controls = row![minimize_btn, maximize_btn, close_btn].spacing(0);
// ── Left side (draggable): menus + doc info ── // Only the empty title region is draggable. Interactive menus are siblings,
let left_side = row![ // preventing their presses from also initiating a native window drag.
menu_items,
iced::widget::Space::with_width(Length::Fill),
doc_info,
]
.spacing(0)
.align_y(iced::Alignment::Center);
let draggable_left = mouse_area( let draggable_left = mouse_area(
container(left_side) container(row![
.width(Length::Fill) iced::widget::Space::with_width(Length::Fill),
.height(24) doc_info
.align_y(iced::Alignment::Center) ])
.padding([0, 4]) .width(Length::Fill)
.height(MENU_BAR_HEIGHT)
.align_y(iced::Alignment::Center)
.padding([0, 4]),
) )
.on_press(Message::WindowDrag) .on_press(Message::WindowDrag)
.interaction(iced::mouse::Interaction::Grab); .interaction(iced::mouse::Interaction::Grab);
// ── Full bar ── // ── Full bar ──
let bar = row![ let bar = row![
container(menu_items).padding(iced::Padding {
left: MENU_LEFT_INSET,
right: 0.0,
top: 0.0,
bottom: 0.0,
}),
draggable_left, draggable_left,
window_controls, window_controls,
] ]
@@ -168,7 +215,7 @@ pub fn view<'a>(
container(bar) container(bar)
.width(Length::Fill) .width(Length::Fill)
.height(24) .height(MENU_BAR_HEIGHT)
.style(move |_theme| iced::widget::container::Style { .style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_panel)), background: Some(iced::Background::Color(colors.bg_panel)),
border: iced::Border::default().color(colors.border_low).width(1), border: iced::Border::default().color(colors.border_low).width(1),
@@ -176,3 +223,18 @@ pub fn view<'a>(
}) })
.into() .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> { pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, Message> {
let tool_name = text(tool_name_label(tool_state.active_tool)) let tool_name = text(tool_name_label(tool_state.active_tool))
.size(11) .size(11)
.style(move |_theme: &iced::Theme| iced::widget::text::Style { .style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(colors.text_primary), color: Some(colors.text_primary),
}); });
@@ -58,9 +57,12 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
.step(0.01) .step(0.01)
.width(Length::Fixed(100.0)); .width(Length::Fixed(100.0));
let hardness_label = text(format!("Hardness: {:.0}%", tool_state.brush_hardness * 100.0)) let hardness_label = text(format!(
.size(10) "Hardness: {:.0}%",
.width(Length::Fixed(75.0)); tool_state.brush_hardness * 100.0
))
.size(10)
.width(Length::Fixed(75.0));
let hardness_slider = slider( let hardness_slider = slider(
0.0..=1.0, 0.0..=1.0,
tool_state.brush_hardness, tool_state.brush_hardness,
@@ -70,11 +72,14 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
.width(Length::Fixed(80.0)); .width(Length::Fixed(80.0));
row![ row![
size_label, size_slider, size_label,
size_slider,
text(" ").size(10), text(" ").size(10),
opacity_label, opacity_slider, opacity_label,
opacity_slider,
text(" ").size(10), text(" ").size(10),
hardness_label, hardness_slider, hardness_label,
hardness_slider,
] ]
.spacing(2) .spacing(2)
.align_y(iced::Alignment::Center) .align_y(iced::Alignment::Center)
@@ -82,15 +87,12 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
// ── Magic Wand ── // ── Magic Wand ──
Tool::MagicWand => { Tool::MagicWand => {
let tolerance_label = text("Tolerance:") let tolerance_label = text("Tolerance:").size(10).width(Length::Fixed(55.0));
.size(10) let tolerance_input = text_input("16", "16").width(Length::Fixed(40.0)).size(10);
.width(Length::Fixed(55.0));
let tolerance_input = text_input("16", "16")
.width(Length::Fixed(40.0))
.size(10);
row![ row![
tolerance_label, tolerance_input, tolerance_label,
tolerance_input,
text(" ").size(10), text(" ").size(10),
checkbox_label("Anti-alias", true), checkbox_label("Anti-alias", true),
text(" ").size(10), text(" ").size(10),
@@ -102,15 +104,12 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
// ── Select tools ── // ── Select tools ──
Tool::Select | Tool::Lasso => { Tool::Select | Tool::Lasso => {
let feather_label = text("Feather:") let feather_label = text("Feather:").size(10).width(Length::Fixed(50.0));
.size(10) let feather_input = text_input("0 px", "0").width(Length::Fixed(50.0)).size(10);
.width(Length::Fixed(50.0));
let feather_input = text_input("0 px", "0")
.width(Length::Fixed(50.0))
.size(10);
row![ row![
feather_label, feather_input, feather_label,
feather_input,
text(" ").size(10), text(" ").size(10),
small_button("Refine Edge", colors), small_button("Refine Edge", colors),
text(" ").size(10), text(" ").size(10),
@@ -121,82 +120,68 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
} }
// ── Move tool ── // ── Move tool ──
Tool::Move => { Tool::Move => row![
row![ checkbox_label("Auto-Select", true),
checkbox_label("Auto-Select", true), text(" ").size(10),
text(" ").size(10), text("Layer").size(10),
text("Layer").size(10), ]
] .spacing(2)
.spacing(2) .align_y(iced::Alignment::Center),
.align_y(iced::Alignment::Center)
}
// ── Eyedropper ── // ── Eyedropper ──
Tool::Eyedropper => { Tool::Eyedropper => row![
row![ text("Sample Size: Point Sample").size(10),
text("Sample Size: Point Sample").size(10), text(" ").size(10),
text(" ").size(10), checkbox_label("Sample All Layers", false),
checkbox_label("Sample All Layers", false), ]
] .spacing(2)
.spacing(2) .align_y(iced::Alignment::Center),
.align_y(iced::Alignment::Center)
}
// ── Gradient / FloodFill ── // ── Gradient / FloodFill ──
Tool::Gradient | Tool::FloodFill => { Tool::Gradient | Tool::FloodFill => row![
row![ text("Mode:").size(10),
text("Mode:").size(10), text("Normal").size(10),
text("Normal").size(10), text(" ").size(10),
text(" ").size(10), text("Opacity:").size(10),
text("Opacity:").size(10), text("100%").size(10),
text("100%").size(10), ]
] .spacing(2)
.spacing(2) .align_y(iced::Alignment::Center),
.align_y(iced::Alignment::Center)
}
// ── Text ── // ── Text ──
Tool::Text => { Tool::Text => row![
row![ text("Font:").size(10),
text("Font:").size(10), text("Arial").size(10),
text("Arial").size(10), text(" ").size(10),
text(" ").size(10), text("Size:").size(10),
text("Size:").size(10), text("24px").size(10),
text("24px").size(10), ]
] .spacing(2)
.spacing(2) .align_y(iced::Alignment::Center),
.align_y(iced::Alignment::Center)
}
// ── Crop ── // ── Crop ──
Tool::Crop => { Tool::Crop => row![
row![ text("Ratio:").size(10),
text("Ratio:").size(10), text("Original").size(10),
text("Original").size(10), text(" ").size(10),
text(" ").size(10), small_button("Straighten", colors),
small_button("Straighten", colors), ]
] .spacing(2)
.spacing(2) .align_y(iced::Alignment::Center),
.align_y(iced::Alignment::Center)
}
// ── Vector tools ── // ── Vector tools ──
Tool::VectorLine | Tool::VectorRect | Tool::VectorCircle => { Tool::VectorLine | Tool::VectorRect | Tool::VectorCircle => row![
row![ text("Fill:").size(10),
text("Fill:").size(10), text("None").size(10),
text("None").size(10), text(" ").size(10),
text(" ").size(10), text("Stroke:").size(10),
text("Stroke:").size(10), text("1px").size(10),
text("1px").size(10), ]
] .spacing(2)
.spacing(2) .align_y(iced::Alignment::Center),
.align_y(iced::Alignment::Center)
}
// ── Default (no specific options) ── // ── Default (no specific options) ──
_ => { _ => row![].spacing(0),
row![].spacing(0)
}
}; };
let bar = row![ let bar = row![
@@ -244,13 +229,10 @@ fn tool_name_label(tool: Tool) -> &'static str {
fn checkbox_label(label: &str, checked: bool) -> Element<'static, Message> { fn checkbox_label(label: &str, checked: bool) -> Element<'static, Message> {
let mark = if checked { "" } else { "" }; let mark = if checked { "" } else { "" };
let label_owned = label.to_string(); let label_owned = label.to_string();
row![ row![text(mark).size(10), text(label_owned).size(10),]
text(mark).size(10), .spacing(2)
text(label_owned).size(10), .align_y(iced::Alignment::Center)
] .into()
.spacing(2)
.align_y(iced::Alignment::Center)
.into()
} }
/// A small flat button for tool options. /// A small flat button for tool options.
@@ -265,8 +247,8 @@ fn small_button(label: &str, colors: ThemeColors) -> Element<'static, Message> {
button(text(label_owned).size(10)) button(text(label_owned).size(10))
.on_press(Message::NoOp) .on_press(Message::NoOp)
.padding([2, 6]) .padding([2, 6])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { .style(
match status { 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(bg_hover)), background: Some(iced::Background::Color(bg_hover)),
text_color: text_primary, 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), border: iced::Border::default().color(border_low).width(1),
..Default::default() ..Default::default()
}, },
} },
}) )
.into() .into()
} }
@@ -16,7 +16,9 @@ use crate::panels::styles;
use crate::settings::AppSettings; use crate::settings::AppSettings;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use hcie_engine_api::Tool; 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}; use iced::{Element, Length};
/// Build the tool settings panel for the active tool. /// Build the tool settings panel for the active tool.
@@ -74,38 +76,134 @@ pub fn view<'a>(
.into() .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(); let ts2 = settings.tool_settings.clone();
column![ column![
section_header("Brush Settings", c), section_header("Brush Settings", c),
prop_slider("Size", ts.brush_size, 1.0, 500.0, Message::BrushSizeChanged, c), prop_slider(
prop_slider("Opacity", ts.brush_opacity * 100.0, 0.0, 100.0, |v| Message::BrushOpacityChanged(v / 100.0), c), "Size",
prop_slider("Hardness", ts.brush_hardness * 100.0, 0.0, 100.0, |v| Message::BrushHardnessChanged(v / 100.0), c), ts.brush_size,
prop_slider("Flow", ts2.brush_flow * 100.0, 0.0, 100.0, |v| Message::BrushFlowChanged(v / 100.0), c), 1.0,
prop_slider("Spacing", ts.brush_spacing * 100.0, 1.0, 100.0, |v| Message::BrushSpacingChanged(v / 100.0), c), 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) .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(); let ts2 = settings.tool_settings.clone();
column![ column![
section_header("Spray Settings", c), section_header("Spray Settings", c),
prop_slider("Size", ts.brush_size, 1.0, 500.0, Message::BrushSizeChanged, c), prop_slider(
prop_slider("Particle Size", ts2.spray_particle_size, 1.0, 20.0, Message::SprayParticleSizeChanged, c), "Size",
prop_slider("Density", ts2.spray_density as f32, 10.0, 1000.0, |v| Message::SprayDensityChanged(v as u32), c), ts.brush_size,
prop_slider("Opacity", ts.brush_opacity * 100.0, 0.0, 100.0, |v| Message::BrushOpacityChanged(v / 100.0), c), 1.0,
prop_slider("Hardness", ts.brush_hardness * 100.0, 0.0, 100.0, |v| Message::BrushHardnessChanged(v / 100.0), c), 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) .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 ts2 = settings.tool_settings.clone();
let mut col = column![ let mut col = column![
section_header("Vector Settings", c), section_header("Vector Settings", c),
prop_slider("Stroke Width", ts2.vector_stroke_width, 0.0, 50.0, Message::VectorStrokeChanged, c), prop_slider(
prop_slider("Opacity", ts2.vector_opacity * 100.0, 0.0, 100.0, |v| Message::VectorOpacityChanged(v / 100.0), c), "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) iced::widget::checkbox("Fill", ts2.vector_fill)
.on_toggle(Message::VectorFillToggled) .on_toggle(Message::VectorFillToggled)
.size(11), .size(11),
@@ -115,13 +213,34 @@ fn vector_settings(ts: &ToolState, settings: &AppSettings, c: ThemeColors) -> ic
// Shape-specific controls. // Shape-specific controls.
match ts.active_tool { match ts.active_tool {
Tool::VectorRect => { 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 => { 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 => { 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 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(); let ts2 = settings.tool_settings.clone();
column![ column![
section_header("Selection Settings", c), 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) iced::widget::checkbox("Anti-alias", ts2.selection_anti_alias)
.on_toggle(|_v| Message::NoOp) .on_toggle(Message::SelectionAntiAliasToggled)
.size(11), .size(11),
] ]
.spacing(4) .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(); let ts2 = settings.tool_settings.clone();
column![ column![
section_header("Magic Wand Settings", c), 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) iced::widget::checkbox("Contiguous", ts2.selection_contiguous)
.on_toggle(Message::SelectionContiguousToggled) .on_toggle(Message::SelectionContiguousToggled)
.size(11), .size(11),
iced::widget::checkbox("Anti-alias", ts2.selection_anti_alias) iced::widget::checkbox("Anti-alias", ts2.selection_anti_alias)
.on_toggle(|_v| Message::NoOp) .on_toggle(Message::SelectionAntiAliasToggled)
.size(11), .size(11),
] ]
.spacing(4) .spacing(4)
@@ -163,7 +302,9 @@ fn text_settings(
c: ThemeColors, c: ThemeColors,
) -> iced::widget::Column<'static, Message> { ) -> iced::widget::Column<'static, Message> {
let ts2 = settings.tool_settings.clone(); 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_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_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); let draft_color = text_draft.map(|d| d.color).unwrap_or(fg_color);
@@ -187,7 +328,14 @@ fn text_settings(
.spacing(4) .spacing(4)
.align_y(iced::Alignment::Center), .align_y(iced::Alignment::Center),
prop_slider("Size", draft_size, 4.0, 200.0, Message::TextSizeChanged, c), 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), color_row("Color:", draft_color, c),
row![ row![
text("Content:").size(10).width(Length::Fixed(50.0)), text("Content:").size(10).width(Length::Fixed(50.0)),
@@ -200,20 +348,34 @@ fn text_settings(
.align_y(iced::Alignment::Center), .align_y(iced::Alignment::Center),
row![ row![
text("Align:").size(10).width(Length::Fixed(50.0)), 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("Left").size(9))
button(text("Center").size(9)).on_press(Message::TextAlignChanged("Center".to_string())).padding([2, 4]), .on_press(Message::TextAlignChanged("Left".to_string()))
button(text("Right").size(9)).on_press(Message::TextAlignChanged("Right".to_string())).padding([2, 4]), .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), .spacing(3),
row![ row![
text("Orient:").size(10).width(Length::Fixed(50.0)), 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("H").size(9))
button(text("V").size(9)).on_press(Message::TextOrientationChanged("Vertical".to_string())).padding([2, 6]), .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), .spacing(3),
row![ row![
button(text("Commit").size(10)).on_press(Message::TextCommit).padding([4, 10]), button(text("Commit").size(10))
button(text("Cancel").size(10)).on_press(Message::TextCancel).padding([4, 10]), .on_press(Message::TextCommit)
.padding([4, 10]),
button(text("Cancel").size(10))
.on_press(Message::TextCancel)
.padding([4, 10]),
] ]
.spacing(6), .spacing(6),
] ]
@@ -231,7 +393,10 @@ fn move_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Column
.spacing(4) .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 ts2 = settings.tool_settings.clone();
let gtype = ts2.gradient_type; let gtype = ts2.gradient_type;
column![ column![
@@ -246,17 +411,34 @@ fn gradient_settings(settings: &AppSettings, c: ThemeColors) -> iced::widget::Co
.padding([2, 6]), .padding([2, 6]),
] ]
.spacing(3), .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), text(if gtype == 1 { "Radial" } else { "Linear" }).size(9),
] ]
.spacing(4) .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(); let ts2 = settings.tool_settings.clone();
column![ column![
section_header("Flood Fill Settings", c), 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) iced::widget::checkbox("Contiguous", ts2.selection_contiguous)
.on_toggle(Message::SelectionContiguousToggled) .on_toggle(Message::SelectionContiguousToggled)
.size(11), .size(11),
@@ -269,14 +451,25 @@ fn section_header(label: &str, _c: ThemeColors) -> Element<'static, Message> {
row![text(l).size(11)].spacing(4).into() 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 where
F: Fn(f32) -> Message + 'static, F: Fn(f32) -> Message + 'static,
{ {
let l = label.to_string(); let l = label.to_string();
let label_text = text(l).size(10).width(Length::Fixed(70.0)); 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 val_text = text(format!("{:.1}", value))
let sl = slider(min..=max, value, msg_fn).step(0.1).width(Length::Fill); .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] row![label_text, sl, val_text]
.spacing(4) .spacing(4)
@@ -296,15 +489,15 @@ fn color_row(label: &str, color: [u8; 4], _c: ThemeColors) -> Element<'static, M
color[2] as f32 / 255.0, color[2] as f32 / 255.0,
color[3] 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() ..Default::default()
}); });
row![ row![text(l).size(10).width(Length::Fixed(50.0)), swatch,]
text(l).size(10).width(Length::Fixed(50.0)), .spacing(4)
swatch, .align_y(iced::Alignment::Center)
] .into()
.spacing(4) }
.align_y(iced::Alignment::Center)
.into()
}
@@ -5,6 +5,7 @@
//! Icons are 16px SVGs loaded from assets/icons/. //! Icons are 16px SVGs loaded from assets/icons/.
use crate::app::Message; use crate::app::Message;
use crate::panels::menus::MenuCommand;
use crate::panels::styles; use crate::panels::styles;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use hcie_engine_api::Tool; use hcie_engine_api::Tool;
@@ -12,13 +13,32 @@ use iced::widget::{button, container, row, slider, svg, text};
use iced::{Element, Length}; use iced::{Element, Length};
/// Build the Photopea-style toolbar: SVG icon buttons + blend mode/opacity/flow in one row. /// 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; let c = colors;
// ── SVG icon buttons (no text) ── // ── SVG icon buttons (no text) ──
let new_btn = svg_btn("icons/new_file.svg", "New", Message::MenuAction(0, 0), c); let new_btn = svg_btn(
let open_btn = svg_btn("icons/open_file.svg", "Open", Message::MenuAction(0, 1), c); "icons/new_file.svg",
let save_btn = svg_btn("icons/save_file.svg", "Save", Message::MenuAction(0, 7), c); "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 undo_btn = svg_btn("icons/undo.svg", "Undo", Message::Undo, c);
let redo_btn = svg_btn("icons/redo.svg", "Redo", Message::Redo, 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); .align_y(iced::Alignment::Center);
// ── Blend Mode, Opacity, Flow, Smooth (Photopea-style) ── // ── Blend Mode, Opacity, Flow, Smooth (Photopea-style) ──
let blend_label = text("Blend Mode:").size(10).color(c.text_secondary); let blend_value = compact_value("Normal".to_string(), "Blend mode: Normal", c);
let blend_value = text("Normal").size(10).color(c.text_primary); let opacity_value = compact_value(
format!("O {:.0}%", tool_state.brush_opacity * 100.0),
"Brush opacity",
c,
);
let opacity_sl = slider(
0.0..=1.0,
tool_state.brush_opacity,
Message::BrushOpacityChanged,
)
.step(0.01)
.width(Length::Fixed(52.0));
let opacity_label = text("Opacity:").size(10).color(c.text_secondary); let flow_value = compact_value(
let opacity_value = text(format!("{:.0}%", tool_state.brush_opacity * 100.0)).size(10).color(c.text_primary); format!("F {:.0}%", settings.tool_settings.brush_flow * 100.0),
let opacity_sl = slider(0.0..=1.0, tool_state.brush_opacity, Message::BrushOpacityChanged) "Brush flow",
.step(0.01) c,
.width(Length::Fixed(60.0)); );
let flow_sl = slider(
0.0..=1.0,
settings.tool_settings.brush_flow,
Message::BrushFlowChanged,
)
.step(0.01)
.width(Length::Fixed(52.0));
let flow_label = text("Flow:").size(10).color(c.text_secondary); let smooth_value = compact_value("S 0%".to_string(), "Stroke smoothing", c);
let flow_value = text(format!("{:.0}%", settings.tool_settings.brush_flow * 100.0)).size(10).color(c.text_primary);
let flow_sl = slider(0.0..=1.0, settings.tool_settings.brush_flow, Message::BrushFlowChanged)
.step(0.01)
.width(Length::Fixed(60.0));
let smooth_label = text("Smooth:").size(10).color(c.text_secondary);
let smooth_value = text("0%").size(10).color(c.text_primary);
let tool_options = row![ let tool_options = row![
blend_label, blend_value, blend_value,
sep(c), sep(c),
opacity_label, opacity_value, opacity_sl, opacity_value,
opacity_sl,
sep(c), sep(c),
flow_label, flow_value, flow_sl, flow_value,
flow_sl,
sep(c), sep(c),
smooth_label, smooth_value, smooth_value,
] ]
.spacing(4) .spacing(4)
.align_y(iced::Alignment::Center); .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) ── // ── Tool-specific options (right side) ──
let tool_options_extra = match tool_state.active_tool { let tool_options_extra = match tool_state.active_tool {
Tool::Brush | Tool::Eraser | Tool::Pen | Tool::Spray => { Tool::Brush | Tool::Eraser | Tool::Pen | Tool::Spray => {
let sz = text(format!("Size: {:.0}", tool_state.brush_size)) let sz = compact_value(format!("{:.0}px", tool_state.brush_size), "Brush size", c);
.size(10) let sz_sl = slider(
.width(Length::Fixed(55.0)); 1.0..=200.0,
let sz_sl = slider(1.0..=200.0, tool_state.brush_size, Message::BrushSizeChanged) tool_state.brush_size,
.step(1.0) Message::BrushSizeChanged,
.width(Length::Fixed(80.0)); )
let ha = text(format!("Hardness: {:.0}%", tool_state.brush_hardness * 100.0)) .step(1.0)
.size(10) .width(Length::Fixed(58.0));
.width(Length::Fixed(70.0)); let ha = compact_value(
let ha_sl = slider(0.0..=1.0, tool_state.brush_hardness, Message::BrushHardnessChanged) format!("H {:.0}%", tool_state.brush_hardness * 100.0),
.step(0.01) "Brush hardness",
.width(Length::Fixed(80.0)); c,
row![sz, sz_sl, ha, ha_sl].spacing(4).align_y(iced::Alignment::Center) );
let ha_sl = slider(
0.0..=1.0,
tool_state.brush_hardness,
Message::BrushHardnessChanged,
)
.step(0.01)
.width(Length::Fixed(58.0));
row![sz, sz_sl, ha, ha_sl]
.spacing(4)
.align_y(iced::Alignment::Center)
} }
Tool::MagicWand => { Tool::MagicWand => {
let tol = settings.tool_settings.magic_wand_tolerance; let tol = settings.tool_settings.magic_wand_tolerance;
row![ row![
text("Tolerance:").size(10), compact_value(format!("T {:.0}", tol), "Selection tolerance", c),
slider(0.0..=255.0, tol, |v| Message::SelectionToleranceChanged(v as u8)) slider(0.0..=255.0, tol, |v| Message::SelectionToleranceChanged(
.step(1.0) v as u8
.width(Length::Fixed(80.0)), ))
text(format!("{:.0}", tol)).size(10).width(Length::Fixed(30.0)), .step(1.0)
].spacing(6).align_y(iced::Alignment::Center) .width(Length::Fixed(64.0)),
]
.spacing(6)
.align_y(iced::Alignment::Center)
} }
Tool::Select | Tool::Lasso => { Tool::Select | Tool::Lasso => {
let feather = settings.tool_settings.select_feather; let feather = settings.tool_settings.select_feather;
row![ row![
text("Feather:").size(10), compact_value(format!("F {:.0}px", feather), "Selection feather", c),
slider(0.0..=250.0, feather, Message::SelectionFeatherChanged) slider(0.0..=250.0, feather, Message::SelectionFeatherChanged)
.step(1.0) .step(1.0)
.width(Length::Fixed(80.0)), .width(Length::Fixed(64.0)),
text(format!("{:.0}px", feather)).size(10).width(Length::Fixed(40.0)), ]
].spacing(6).align_y(iced::Alignment::Center) .spacing(6)
} .align_y(iced::Alignment::Center)
Tool::Move => {
row![text("Auto-Select").size(10)].spacing(4).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 => { Tool::VectorLine | Tool::VectorRect | Tool::VectorCircle => {
let stroke = settings.tool_settings.vector_stroke_width; let stroke = settings.tool_settings.vector_stroke_width;
row![ row![
text("Stroke:").size(10), compact_value(format!("{:.1}px", stroke), "Vector stroke width", c),
slider(0.0..=50.0, stroke, Message::VectorStrokeChanged) slider(0.0..=50.0, stroke, Message::VectorStrokeChanged)
.step(0.5) .step(0.5)
.width(Length::Fixed(60.0)), .width(Length::Fixed(58.0)),
text(format!("{:.1}px", stroke)).size(10).width(Length::Fixed(40.0)), ]
].spacing(4).align_y(iced::Alignment::Center) .spacing(4)
.align_y(iced::Alignment::Center)
} }
_ => row![].spacing(0), _ => 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). /// 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 c = colors;
let tip_owned = tip.to_string(); 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) svg(handle)
.width(16) .width(16)
.height(16) .height(16)
.style(move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style { .style(
color: Some(c.text_primary), move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style {
}) color: Some(c.text_primary),
},
)
.into() .into()
} else { } else {
// Fallback to text // Fallback to text
@@ -154,25 +209,22 @@ fn svg_btn(icon_path: &str, tip: &str, msg: Message, colors: ThemeColors) -> Ele
}; };
iced::widget::tooltip( iced::widget::tooltip(
button(icon) button(icon).on_press(msg).padding([2, 4]).style(
.on_press(msg) move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
.padding([2, 4]) iced::widget::button::Status::Hovered => iced::widget::button::Style {
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { background: Some(iced::Background::Color(c.bg_hover)),
match status { text_color: c.text_primary,
iced::widget::button::Status::Hovered => iced::widget::button::Style { border: iced::Border::default().color(c.border_low).width(1),
background: Some(iced::Background::Color(c.bg_hover)), ..Default::default()
text_color: c.text_primary, },
border: iced::Border::default().color(c.border_low).width(1), _ => iced::widget::button::Style {
..Default::default() background: Some(iced::Background::Color(iced::Color::TRANSPARENT)),
}, text_color: c.text_primary,
_ => iced::widget::button::Style { border: iced::Border::default(),
background: Some(iced::Background::Color(iced::Color::TRANSPARENT)), ..Default::default()
text_color: c.text_primary, },
border: iced::Border::default(), },
..Default::default() ),
},
}
}),
text(tip_owned).size(11), text(tip_owned).size(11),
iced::widget::tooltip::Position::Bottom, iced::widget::tooltip::Position::Bottom,
) )
@@ -185,8 +237,8 @@ fn small_btn(label: &str, colors: ThemeColors) -> Element<'static, Message> {
button(text(l).size(10)) button(text(l).size(10))
.on_press(Message::NoOp) .on_press(Message::NoOp)
.padding([2, 6]) .padding([2, 6])
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { .style(
match status { 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_hover)), background: Some(iced::Background::Color(c.bg_hover)),
text_color: c.text_primary, 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), border: iced::Border::default().color(c.border_low).width(1),
..Default::default() ..Default::default()
}, },
} },
}) )
.into() .into()
} }
@@ -213,3 +265,13 @@ fn sep(colors: ThemeColors) -> Element<'static, Message> {
}) })
.into() .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]) { pub fn execute_actions(engine: &mut Engine, actions: &[ScriptAction]) {
for action in actions { for action in actions {
match action { match action {
ScriptAction::CreateLayer { name, layer_type: _ } => { ScriptAction::CreateLayer {
name,
layer_type: _,
} => {
engine.add_layer(name); engine.add_layer(name);
} }
ScriptAction::SelectLayer { name_or_index } => { ScriptAction::SelectLayer { name_or_index } => {
@@ -106,12 +109,18 @@ pub fn execute_actions(engine: &mut Engine, actions: &[ScriptAction]) {
engine.set_active_layer(id); 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) { if let Some(id) = find_layer_id(engine, name_or_index) {
engine.set_layer_opacity(id, *opacity); 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) { if let Some(id) = find_layer_id(engine, name_or_index) {
let bm = parse_blend_mode(blend_mode); let bm = parse_blend_mode(blend_mode);
engine.set_layer_blend_mode(id, bm); engine.set_layer_blend_mode(id, bm);
@@ -166,10 +175,8 @@ pub fn execute_actions(engine: &mut Engine, actions: &[ScriptAction]) {
tip.hardness = *hardness; tip.hardness = *hardness;
engine.set_brush_tip(tip); engine.set_brush_tip(tip);
let stroke_points: Vec<(f32, f32, f32)> = points let stroke_points: Vec<(f32, f32, f32)> =
.iter() points.iter().map(|p| (p[0], p[1], 1.0)).collect();
.map(|p| (p[0], p[1], 1.0))
.collect();
engine.draw_stroke(&stroke_points); engine.draw_stroke(&stroke_points);
} }
ScriptAction::FillRasterRect { ScriptAction::FillRasterRect {
@@ -224,7 +231,10 @@ pub fn execute_actions(engine: &mut Engine, actions: &[ScriptAction]) {
engine.delete_layer(id); 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) { if let Some(id) = find_layer_id(engine, name_or_index) {
engine.rename_layer(id, new_name); engine.rename_layer(id, new_name);
} }
@@ -83,7 +83,11 @@ fn tokenize(s: &str) -> Result<Vec<Token>, String> {
Ok(tokens) 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)?; let (mut left, mut pos) = parse_term(tokens, pos, vars)?;
while pos < tokens.len() { while pos < tokens.len() {
match &tokens[pos] { match &tokens[pos] {
@@ -103,7 +107,11 @@ fn parse_expression(tokens: &[Token], pos: usize, vars: &std::collections::HashM
Ok((left, pos)) 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)?; let (mut left, mut pos) = parse_factor(tokens, pos, vars)?;
while pos < tokens.len() { while pos < tokens.len() {
match &tokens[pos] { match &tokens[pos] {
@@ -126,7 +134,11 @@ fn parse_term(tokens: &[Token], pos: usize, vars: &std::collections::HashMap<Str
Ok((left, pos)) 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() { if pos >= tokens.len() {
return Err("unexpected end of expression".to_string()); 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)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum ScriptAction { pub enum ScriptAction {
CreateLayer { name: String, layer_type: String }, CreateLayer {
DeleteLayer { name_or_index: String }, name: String,
SelectLayer { name_or_index: String }, layer_type: String,
RenameLayer { name_or_index: String, new_name: String }, },
SetLayerOpacity { name_or_index: String, opacity: f32 }, DeleteLayer {
SetLayerBlendMode { name_or_index: String, blend_mode: String }, 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 { PaintStroke {
points: Vec<[f32; 2]>, points: Vec<[f32; 2]>,
color: String, color: String,
@@ -210,7 +238,9 @@ pub enum ScriptAction {
spacing: Option<f32>, spacing: Option<f32>,
hardness: Option<f32>, hardness: Option<f32>,
}, },
SetForegroundColor { hex: String }, SetForegroundColor {
hex: String,
},
FillRasterRect { FillRasterRect {
x1: f32, x1: f32,
y1: f32, y1: f32,
@@ -225,7 +255,9 @@ pub enum ScriptAction {
stroke_color: String, stroke_color: String,
stroke_width: f32, stroke_width: f32,
}, },
Finish { summary: String }, Finish {
summary: String,
},
} }
// ── Helper functions ──────────────────────────────────────────────────── // ── Helper functions ────────────────────────────────────────────────────
@@ -234,7 +266,11 @@ fn to_norm(coord: f32, canvas_dim: f32) -> f32 {
(coord / canvas_dim).clamp(0.0, 1.0) (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)?; let val = eval_expr(s, vars)?;
Ok(to_norm(val, canvas)) 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)) 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); let mut pts = Vec::with_capacity(steps + 1);
for i in 0..=steps { for i in 0..=steps {
let t = i as f32 / steps as f32; let t = i as f32 / steps as f32;
let u = 1.0 - t; 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 x = u * u * u * p0[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]; + 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.push([x, y]);
} }
pts pts
@@ -370,7 +418,11 @@ fn strip_brackets(s: &str) -> &str {
// ── Line Parser ────────────────────────────────────────────────────────── // ── 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 err = |msg: &str| format!("Line {}: {}", line_num, msg);
let parts: Vec<&str> = line.split_whitespace().collect(); let parts: Vec<&str> = line.split_whitespace().collect();
if parts.is_empty() { 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(), layer_type: "raster".to_string(),
}]) }])
} }
"vector_layer" => { "vector_layer" => Err(err(
if parts.len() < 2 { "vector_layer is unavailable in the public engine API; use layer",
return Err(err("vector layer name missing")); )),
}
Ok(vec![ScriptAction::CreateLayer {
name: parts[1..].join(" "),
layer_type: "vector".to_string(),
}])
}
"select_layer" => { "select_layer" => {
if parts.len() < 2 { if parts.len() < 2 {
return Err(err("layer name missing")); return Err(err("layer name missing"));
@@ -405,7 +451,8 @@ fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -
}]) }])
} }
"layer_opacity" => { "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); state.opacity = val.clamp(0.0, 1.0);
Ok(vec![ScriptAction::SetLayerOpacity { Ok(vec![ScriptAction::SetLayerOpacity {
name_or_index: "0".to_string(), 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 { if parts.len() < 2 {
return Err(err("color hex missing")); return Err(err("color hex missing"));
} }
hex_to_rgba_components(parts[1]).map_err(|error| err(&error))?;
state.color = parts[1].to_string(); state.color = parts[1].to_string();
Ok(vec![ScriptAction::SetForegroundColor { hex: state.color.clone() }]) Ok(vec![ScriptAction::SetForegroundColor {
hex: state.color.clone(),
}])
} }
"size" => { "size" => {
state.size = eval_expr(parts.get(1).unwrap_or(&"20"), &state.vars) 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" => { "set" => {
let mut actions = Vec::new(); let mut actions = Vec::new();
for kv in &parts[1..] { for kv in &parts[1..] {
let (key, val) = let (key, val) = kv
kv.split_once('=').ok_or_else(|| err("set syntax: key=value"))?; .split_once('=')
.ok_or_else(|| err("set syntax: key=value"))?;
match key.to_lowercase().as_str() { match key.to_lowercase().as_str() {
"color" => { "color" => {
hex_to_rgba_components(val).map_err(|error| err(&error))?;
state.color = val.to_string(); state.color = val.to_string();
actions.push(ScriptAction::SetForegroundColor { hex: state.color.clone() }); actions.push(ScriptAction::SetForegroundColor {
hex: state.color.clone(),
});
} }
"brush" | "style" => { "brush" | "style" => {
state.style = val.to_string(); state.style = val.to_string();
@@ -548,8 +602,7 @@ fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -
}); });
} }
"size" => { "size" => {
state.size = state.size = eval_expr(val, &state.vars).map_err(|e| err(&e))?.max(1.0);
eval_expr(val, &state.vars).map_err(|e| err(&e))?.max(1.0);
actions.push(ScriptAction::SetBrush { actions.push(ScriptAction::SetBrush {
size: Some(state.size), size: Some(state.size),
style: None, style: None,
@@ -726,7 +779,7 @@ fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -
}]) }])
} }
"bezier" | "curve" => { "bezier" | "curve" => {
if parts.len() < 6 { if parts.len() < 5 {
return Err(err("bezier expects x1,y1 cx1,cy1 cx2,cy2 x2,y2 [steps]")); return Err(err("bezier expects x1,y1 cx1,cy1 cx2,cy2 x2,y2 [steps]"));
} }
let parsed: Result<Vec<[f32; 2]>, String> = parts[1..5] 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 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); apply_wiggle(&mut npts, state.wiggle);
Ok(vec![ScriptAction::PaintStroke { 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 y2 = parse_coord_abs(p2[1], &state.vars)?;
let steps = eval_expr(strip_brackets(parts[parts.len() - 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(); let color_stops: Vec<&str> = parts[3..parts.len() - 1].to_vec();
if color_stops.len() < 2 { if color_stops.len() < 2 {
@@ -930,7 +987,8 @@ pub fn parse_dsl_script(script_text: &str) -> Result<Vec<ScriptAction>, String>
rest rest
}; };
let count = eval_expr(count_str.trim(), &state.vars) 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 { if count == 0 {
continue; 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") Some("bezier x1,y1 cx1,cy1 cx2,cy2 x2,y2 [steps] — 4 control points")
} else if err.contains("draw needs at least 2") { } else if err.contains("draw needs at least 2") {
Some("draw x1,y1 x2,y2 ... — provide at least 2 coordinate pairs") 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'") Some("Coordinates must be comma-separated without spaces: 100,200 not '100, 200'")
} else if err.contains("set syntax") { } else if err.contains("set syntax") {
Some("set key=value key=value — e.g. set brush=oil size=30 opacity=0.8") 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 src_a = floating.pixels[src_idx + 3] as f32 / 255.0;
let inv_a = 1.0 - src_a; 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] = (floating.pixels[src_idx] as f32 * src_a
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] as f32 * inv_a)
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; 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 + 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)>, pub drag_start: Option<(f32, f32)>,
/// Whether crop overlay is active /// Whether crop overlay is active
pub active: bool, pub active: bool,
/// Canvas dimensions used to clamp the crop and restore a valid lifecycle.
pub canvas_size: Option<(u32, u32)>,
} }
impl CropState { 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. /// Begin a new crop drag at the given canvas position.
pub fn start_drag(&mut self, x: f32, y: f32) { pub fn start_drag(&mut self, x: f32, y: f32) {
self.is_dragging = true; self.is_dragging = true;
@@ -28,6 +36,9 @@ impl CropState {
/// Update the crop rectangle during a drag. /// Update the crop rectangle during a drag.
pub fn update_drag(&mut self, x: f32, y: f32) { pub fn update_drag(&mut self, x: f32, y: f32) {
if let Some((start_x, start_y)) = self.drag_start { 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_x = start_x.min(x);
let min_y = start_y.min(y); let min_y = start_y.min(y);
let w = (x - start_x).abs(); let w = (x - start_x).abs();
@@ -62,5 +73,6 @@ impl CropState {
self.rect = None; self.rect = None;
self.is_dragging = false; self.is_dragging = false;
self.drag_start = None; self.drag_start = None;
self.canvas_size = None;
} }
} }
@@ -8,7 +8,9 @@
pub mod clipboard; pub mod clipboard;
pub mod crop; pub mod crop;
pub mod state;
pub mod transform; pub mod transform;
pub use crop::CropState; pub use crop::CropState;
pub use state::SelectionMode;
pub use transform::{SelectionTransform, TransformHandle}; 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() { 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 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] = comp[src_idx];
pixels[dst_idx + 1] = comp[src_idx + 1]; pixels[dst_idx + 1] = comp[src_idx + 1];
@@ -106,6 +110,102 @@ impl SelectionTransform {
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.pixels.is_empty() || self.width == 0 || self.height == 0 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. /// Handle for interacting with a selection transform.
@@ -36,6 +36,9 @@ pub struct AppSettings {
/// Saved dock layout profiles. /// Saved dock layout profiles.
#[serde(default)] #[serde(default)]
pub dock_profiles: Vec<DockProfile>, pub dock_profiles: Vec<DockProfile>,
/// Selected application color preset.
#[serde(default)]
pub theme_preset: crate::theme::ThemePreset,
} }
/// Tool-specific settings that persist between sessions. /// Tool-specific settings that persist between sessions.
@@ -98,21 +101,42 @@ pub struct ToolSettings {
pub last_active_tool: String, pub last_active_tool: String,
} }
fn default_vector_fill() -> bool { true } fn default_vector_fill() -> bool {
fn default_vector_points() -> u32 { 5 } true
fn default_vector_sides() -> u32 { 6 } }
fn default_spray_particle_size() -> f32 { 4.0 } fn default_vector_points() -> u32 {
fn default_spray_density() -> u32 { 80 } 5
fn default_contiguous() -> bool { true } }
fn default_anti_alias() -> bool { true } fn default_vector_sides() -> u32 {
fn default_text_font() -> String { "Arial".to_string() } 6
fn default_apply_to_new_layer() -> bool { true } }
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. /// Panel layout positions and visibility.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PanelLayout { pub struct PanelLayout {
/// Left sidebar width in pixels. /// Left sidebar width in pixels.
pub sidebar_width: f32, 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. /// Right tool settings panel width in pixels.
pub tool_settings_width: f32, pub tool_settings_width: f32,
/// Which panels are visible. /// Which panels are visible.
@@ -136,6 +160,7 @@ impl Default for AppSettings {
panel_layout: PanelLayout::default(), panel_layout: PanelLayout::default(),
window: WindowSettings::default(), window: WindowSettings::default(),
dock_profiles: Vec::new(), dock_profiles: Vec::new(),
theme_preset: crate::theme::ThemePreset::default(),
} }
} }
} }
@@ -182,6 +207,7 @@ impl Default for PanelLayout {
fn default() -> Self { fn default() -> Self {
Self { Self {
sidebar_width: 36.0, sidebar_width: 36.0,
sidebar_expanded: false,
tool_settings_width: 200.0, tool_settings_width: 200.0,
visible_panels: vec![ visible_panels: vec![
"Brushes & Tips".to_string(), "Brushes & Tips".to_string(),
@@ -223,15 +249,13 @@ impl AppSettings {
let path = Self::file_path(); let path = Self::file_path();
if path.exists() { if path.exists() {
match std::fs::read_to_string(&path) { match std::fs::read_to_string(&path) {
Ok(content) => { Ok(content) => match serde_json::from_str(&content) {
match serde_json::from_str(&content) { Ok(settings) => settings,
Ok(settings) => settings, Err(e) => {
Err(e) => { log::warn!("Failed to parse settings: {}, using defaults", e);
log::warn!("Failed to parse settings: {}, using defaults", e); Self::default()
Self::default()
}
} }
} },
Err(e) => { Err(e) => {
log::warn!("Failed to read settings: {}, using defaults", e); log::warn!("Failed to read settings: {}, using defaults", e);
Self::default() Self::default()
@@ -245,7 +269,7 @@ impl AppSettings {
/// Save settings to disk. /// Save settings to disk.
pub fn save(&self) -> Result<(), String> { pub fn save(&self) -> Result<(), String> {
let path = Self::file_path(); let path = Self::file_path();
// Create directory if it doesn't exist // Create directory if it doesn't exist
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent)
@@ -255,8 +279,7 @@ impl AppSettings {
let content = serde_json::to_string_pretty(self) let content = serde_json::to_string_pretty(self)
.map_err(|e| format!("Failed to serialize settings: {}", e))?; .map_err(|e| format!("Failed to serialize settings: {}", e))?;
std::fs::write(&path, content) std::fs::write(&path, content).map_err(|e| format!("Failed to write settings: {}", e))?;
.map_err(|e| format!("Failed to write settings: {}", e))?;
log::info!("Settings saved to {}", path.display()); log::info!("Settings saved to {}", path.display());
Ok(()) Ok(())
@@ -282,3 +305,27 @@ impl AppSettings {
tool_state.brush_hardness = self.tool_settings.brush_hardness; 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, Some(i) => i,
None => return, 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) // Snapshot for undo (before first modification in this edit session)
if app.vector_shapes_snapshot.is_none() { if app.vector_shapes_snapshot.is_none() {
let snapshot = app.documents[app.active_doc].engine.active_vector_shapes(); let doc = &app.documents[app.active_doc];
app.vector_shapes_snapshot = snapshot; 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(); 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(shapes) = app.documents[app.active_doc].engine.active_vector_shapes() {
if let Some(shape) = shapes.get(shape_idx) { if let Some(shape) = shapes.get(shape_idx) {
if shape.fill_color().is_some() || shape.is_filled() != ts.vector_fill { if shape.fill_color().is_some() || shape.is_filled() != ts.vector_fill {
app.documents[app.active_doc] app.documents[app.active_doc].engine.set_vector_shape_fill(
.engine layer_id,
.set_vector_shape_fill(layer_id, shape_idx, ts.vector_fill); shape_idx,
ts.vector_fill,
);
} }
} }
} }
app.documents[app.active_doc].modified = true;
app.documents[app.active_doc].engine.mark_composite_dirty();
// Opacity // Opacity
app.documents[app.active_doc] 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 /// Called when the user clicks/selects a shape in VectorSelect mode
/// so the properties panel and tool state reflect the shape's actual values. /// so the properties panel and tool state reflect the shape's actual values.
pub fn sync_tool_from_shape(app: &mut HcieIcedApp) { 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 { let shape_idx = match app.documents[app.active_doc].selected_vector_shape {
Some(i) => i, Some(i) => i,
None => return, None => return,
@@ -156,6 +169,7 @@ pub fn sync_tool_from_shape(app: &mut HcieIcedApp) {
if let Some(fc) = shape.fill_color() { if let Some(fc) = shape.fill_color() {
app.bg_color = fc; app.bg_color = fc;
} }
app.colors_dirty = true;
} }
} }
} }
@@ -15,7 +15,7 @@ use crate::app::{Message, ToolState};
use crate::panels::styles; use crate::panels::styles;
use crate::theme::ThemeColors; use crate::theme::ThemeColors;
use hcie_engine_api::Tool; 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}; use iced::{Element, Length};
/// Tool slot with sub-tools — matches egui's TOOL_SLOTS structure. /// 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. /// All tool slots matching egui's TOOL_SLOTS structure exactly.
/// Each slot can have multiple tools (sub-tools shown on click). /// Each slot can have multiple tools (sub-tools shown on click).
pub const TOOL_SLOTS: &[ToolSlot] = &[ pub const TOOL_SLOTS: &[ToolSlot] = &[
ToolSlot { tools: &[Tool::Move], label: "Move", icon: "icons/move.svg" }, ToolSlot {
ToolSlot { tools: &[Tool::VectorSelect], label: "Vector Select", icon: "icons/vector_select.svg" }, tools: &[Tool::Move],
ToolSlot { tools: &[Tool::Select], label: "Rectangle Select", icon: "icons/rect_select.svg" }, label: "Move",
ToolSlot { tools: &[Tool::Lasso, Tool::PolygonSelect], label: "Lasso", icon: "icons/lasso.svg" }, icon: "icons/move.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 {
ToolSlot { tools: &[Tool::Crop], label: "Crop", icon: "icons/crop.svg" }, tools: &[Tool::VectorSelect],
ToolSlot { tools: &[Tool::Eyedropper], label: "Eyedropper", icon: "icons/eyedropper.svg" }, label: "Vector Select",
ToolSlot { tools: &[Tool::Brush], label: "Brush", icon: "icons/brush.svg" }, icon: "icons/vector_select.svg",
ToolSlot { tools: &[Tool::Pen], label: "Pen", icon: "icons/pen.svg" }, },
ToolSlot { tools: &[Tool::Spray], label: "Spray", icon: "icons/spray.svg" }, ToolSlot {
ToolSlot { tools: &[Tool::Eraser], label: "Eraser", icon: "icons/eraser.svg" }, tools: &[Tool::Select],
ToolSlot { tools: &[Tool::FloodFill], label: "Flood Fill", icon: "icons/bucket.svg" }, label: "Rectangle Select",
ToolSlot { tools: &[Tool::Gradient], label: "Gradient", icon: "icons/gradient.svg" }, icon: "icons/rect_select.svg",
ToolSlot { tools: &[Tool::Text], label: "Text", icon: "icons/text.svg" }, },
ToolSlot { tools: &[Tool::VectorLine], label: "Vector Line", icon: "icons/line.svg" }, ToolSlot {
ToolSlot { tools: &[Tool::VectorRect], label: "Vector Rectangle", icon: "icons/vector_rect.svg" }, tools: &[Tool::Lasso, Tool::PolygonSelect],
ToolSlot { tools: &[Tool::VectorCircle], label: "Vector Circle", icon: "icons/circle.svg" }, 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 { ToolSlot {
tools: &[ tools: &[
Tool::VectorArrow, Tool::VectorStar, Tool::VectorPolygon, Tool::VectorArrow,
Tool::VectorRhombus, Tool::VectorCylinder, Tool::VectorHeart, Tool::VectorStar,
Tool::VectorBubble, Tool::VectorGear, Tool::VectorCross, Tool::VectorPolygon,
Tool::VectorCrescent, Tool::VectorBolt, Tool::VectorArrow4, Tool::VectorRhombus,
Tool::VectorCylinder,
Tool::VectorHeart,
Tool::VectorBubble,
Tool::VectorGear,
Tool::VectorCross,
Tool::VectorCrescent,
Tool::VectorBolt,
Tool::VectorArrow4,
], ],
label: "Vector Shapes", label: "Vector Shapes",
icon: "icons/star.svg", icon: "icons/star.svg",
}, },
ToolSlot { ToolSlot {
tools: &[Tool::SpotRemoval, Tool::RedEyeRemoval, Tool::SmartPatch, Tool::AiObjectRemoval], tools: &[
Tool::SpotRemoval,
Tool::RedEyeRemoval,
Tool::SmartPatch,
Tool::AiObjectRemoval,
],
label: "Retouch", label: "Retouch",
icon: "icons/spot.svg", icon: "icons/spot.svg",
}, },
@@ -82,21 +167,55 @@ pub fn view<'a>(
// Tool buttons — single column or 2-column grid depending on expanded state // Tool buttons — single column or 2-column grid depending on expanded state
let mut tool_list = column![].spacing(1); let mut tool_list = column![].spacing(1);
if expanded { 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_children = iced::widget::Row::new().spacing(1);
let mut row_items = 0;
for (idx, slot) in TOOL_SLOTS.iter().enumerate() { for (idx, slot) in TOOL_SLOTS.iter().enumerate() {
let btn = tool_button(slot, tool_state, colors, idx, active_slot, sub_tools_open, slot_last_used); let btn = tool_button(
slot,
tool_state,
colors,
idx,
active_slot,
sub_tools_open,
slot_last_used,
);
let popup_row = sub_tools_open && active_slot == idx && slot.tools.len() > 1;
if popup_row {
if row_items > 0 {
tool_list = tool_list.push(row_children);
row_children = iced::widget::Row::new().spacing(1);
row_items = 0;
}
tool_list = tool_list.push(btn);
continue;
}
row_children = row_children.push(btn); row_children = row_children.push(btn);
// Every 2 tools, start a new row row_items += 1;
if idx % 2 == 1 || idx == TOOL_SLOTS.len() - 1 { if row_items == 2 {
tool_list = tool_list.push(row_children); tool_list = tool_list.push(row_children);
row_children = iced::widget::Row::new().spacing(1); row_children = iced::widget::Row::new().spacing(1);
row_items = 0;
} }
} }
if row_items > 0 {
tool_list = tool_list.push(row_children);
}
} else { } else {
// Single column layout (default) // Single column layout (default)
for (idx, slot) in TOOL_SLOTS.iter().enumerate() { 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); tool_list = tool_list.push(btn);
} }
} }
@@ -107,95 +226,142 @@ pub fn view<'a>(
let bg = bg_color; let bg = bg_color;
let fg_swatch = container(text("")) let fg_swatch = container(text(""))
.width(18) .width(24)
.height(18) .height(24)
.style(move |_theme| iced::widget::container::Style { .style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba( 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() ..Default::default()
}); });
let bg_swatch = container(text("")) let bg_swatch = container(text(""))
.width(18) .width(24)
.height(18) .height(24)
.style(move |_theme| iced::widget::container::Style { .style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba( 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() ..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 // Make the background swatch clickable so a click promotes the background
// color to the foreground (egui: toolbox.rs:355-358). // color to the foreground (egui: toolbox.rs:355-358).
let bg_color_val = *bg_color; let bg_color_val = *bg_color;
let bg_clickable = iced::widget::mouse_area(bg_swatch) let bg_clickable = iced::widget::tooltip(
.on_press(Message::FgColorChanged(bg_color_val)); 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 // 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). // (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(
.on_press(Message::ResetColors) button(text("D").size(10).color(colors.text_secondary))
.padding(1) .on_press(Message::ResetColors)
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { .padding([1, 3])
match status { .style(
iced::widget::button::Status::Hovered => iced::widget::button::Style { move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
background: Some(iced::Background::Color(colors.bg_hover)), iced::widget::button::Status::Hovered => iced::widget::button::Style {
text_color: colors.text_primary, background: Some(iced::Background::Color(colors.bg_hover)),
border: iced::Border::default(), text_color: colors.text_primary,
..Default::default() border: iced::Border::default(),
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.0,
))),
text_color: colors.text_secondary,
border: iced::Border::default(),
..Default::default()
},
}, },
_ => iced::widget::button::Style { ),
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))), text("Reset foreground/background colors").size(11),
text_color: colors.text_secondary, iced::widget::tooltip::Position::Right,
border: iced::Border::default(), );
..Default::default()
},
}
});
// Photopea-style: fg at top-left, bg at bottom-right (diagonal overlap) // Photopea-style: fg at top-left, bg at bottom-right (diagonal overlap)
// with a swap arrow button between them // with a swap arrow button between them
let color_section = container( let color_section = container(
iced::widget::Stack::new() 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( .push(
container(fg_swatch) container(iced::widget::tooltip(
.padding(iced::Padding { top: 0.0, bottom: 0.0, left: 4.0, right: 0.0 }) button(text("").size(10).color(colors.text_primary))
)
.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))
.on_press(Message::SwapColors) .on_press(Message::SwapColors)
.padding(1) .padding(1)
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { .style(
match status { move |_theme: &iced::Theme, status: iced::widget::button::Status| {
iced::widget::button::Status::Hovered => iced::widget::button::Style { match status {
background: Some(iced::Background::Color(colors.bg_hover)), iced::widget::button::Status::Hovered => {
text_color: colors.text_primary, iced::widget::button::Style {
border: iced::Border::default(), background: Some(iced::Background::Color(
..Default::default() colors.bg_hover,
}, )),
_ => iced::widget::button::Style { text_color: colors.text_primary,
background: Some(iced::Background::Color(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.0))), border: iced::Border::default(),
text_color: colors.text_primary, ..Default::default()
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),
) )),
.padding(iced::Padding { top: 6.0, bottom: 0.0, left: 10.0, right: 0.0 }) text_color: colors.text_primary,
) border: iced::Border::default(),
..Default::default()
},
}
},
),
text("Swap foreground/background colors").size(11),
iced::widget::tooltip::Position::Right,
))
.padding(iced::Padding {
top: 0.0,
bottom: 0.0,
left: 21.0,
right: 0.0,
}),
),
) )
.padding([4, 2]) .padding([4, 2])
.width(sidebar_width) .width(sidebar_width)
.height(32); .height(40);
// Separator line // Separator line
let sep_style = move |_theme: &iced::Theme| iced::widget::container::Style { let sep_style = move |_theme: &iced::Theme| iced::widget::container::Style {
@@ -209,8 +375,8 @@ pub fn view<'a>(
.on_press(Message::SidebarToggleExpanded) .on_press(Message::SidebarToggleExpanded)
.padding(2) .padding(2)
.width(sidebar_width) .width(sidebar_width)
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { .style(
match status { 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(colors.bg_hover)), background: Some(iced::Background::Color(colors.bg_hover)),
text_color: colors.text_primary, text_color: colors.text_primary,
@@ -218,17 +384,21 @@ pub fn view<'a>(
..Default::default() ..Default::default()
}, },
_ => iced::widget::button::Style { _ => 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, text_color: colors.text_secondary,
border: iced::Border::default(), border: iced::Border::default(),
..Default::default() ..Default::default()
}, },
} },
}); );
let sidebar = column![ let sidebar = column![
container(tool_list).padding([2, 0]), scrollable(container(tool_list).padding([2, 0])).height(Length::Fill),
container(text("").height(1)).width(Length::Fill).style(sep_style), container(text("").height(1))
.width(Length::Fill)
.style(sep_style),
color_section, color_section,
container(reset_btn).padding([2, 4]), container(reset_btn).padding([2, 4]),
container(toggle_btn).padding([2, 0]), container(toggle_btn).padding([2, 0]),
@@ -266,30 +436,41 @@ fn tool_button<'a>(
let represented_tool = slot.tools.get(last_idx).copied().unwrap_or(slot.tools[0]); let represented_tool = slot.tools.get(last_idx).copied().unwrap_or(slot.tools[0]);
// Try to load SVG icon - search multiple paths // Try to load SVG icon - search multiple paths
let represented_icon = tool_icon(represented_tool);
let icon_paths = [ let icon_paths = [
std::path::PathBuf::from(slot.icon), std::path::PathBuf::from(represented_icon),
std::path::Path::new("hcie-iced-app/assets").join(slot.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/{}", slot.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
let icon_element: Element<'_, Message> = if let Some(icon_path) = icon_paths.iter().find(|p| p.exists()) {
let handle = svg::Handle::from_path(icon_path);
svg(handle)
.width(20)
.height(20)
.style(move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style {
color: Some(icon_color),
})
.into()
} else { } else {
text(slot.label.chars().next().unwrap_or('?').to_string()) colors.text_primary
.size(14)
.color(icon_color)
.into()
}; };
let icon_element: Element<'_, Message> =
if let Some(icon_path) = icon_paths.iter().find(|p| p.exists()) {
let handle = svg::Handle::from_path(icon_path);
svg(handle)
.width(20)
.height(20)
.style(
move |_theme: &iced::Theme, _status: iced::widget::svg::Status| svg::Style {
color: Some(icon_color),
},
)
.into()
} else {
text(slot.label.chars().next().unwrap_or('?').to_string())
.size(14)
.color(icon_color)
.into()
};
// If slot has sub-tools and is active, show sub-tools popup // If slot has sub-tools and is active, show sub-tools popup
let has_sub_tools = slot.tools.len() > 1; let has_sub_tools = slot.tools.len() > 1;
let show_popup = has_sub_tools && sub_tools_open && active_slot == slot_idx; let show_popup = has_sub_tools && sub_tools_open && active_slot == slot_idx;
@@ -300,15 +481,15 @@ fn tool_button<'a>(
let btn = iced::widget::button( let btn = iced::widget::button(
container(icon_element) container(icon_element)
.width(28) .width(34)
.height(28) .height(28)
.center_y(20) .center_y(20)
.center_x(20) .center_x(20),
) )
.on_press(Message::ToolSlotClicked(slot_idx, primary_tool)) .on_press(Message::ToolSlotClicked(slot_idx, primary_tool))
.padding(0) .padding(0)
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { .style(
match status { move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
iced::widget::button::Status::Active | iced::widget::button::Status::Disabled => { iced::widget::button::Status::Active | iced::widget::button::Status::Disabled => {
if active { if active {
iced::widget::button::Style { iced::widget::button::Style {
@@ -319,7 +500,9 @@ fn tool_button<'a>(
} }
} else { } else {
iced::widget::button::Style { 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(), border: iced::Border::default(),
text_color: c.text_primary, text_color: c.text_primary,
..Default::default() ..Default::default()
@@ -343,96 +526,236 @@ fn tool_button<'a>(
} }
} }
} }
iced::widget::button::Status::Pressed => { iced::widget::button::Status::Pressed => iced::widget::button::Style {
iced::widget::button::Style { background: Some(iced::Background::Color(c.accent_green)),
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(), border: iced::Border::default(),
text_color: iced::Color::WHITE,
..Default::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 // Build the tool button with optional sub-tools popup
if show_popup { if show_popup {
let mut popup_items = column![].spacing(0); let mut popup_grid = column![].spacing(1);
for &tool in slot.tools { let mut popup_row = row![].spacing(1);
for (tool_index, &tool) in slot.tools.iter().enumerate() {
let tool_name = tool.label(); let tool_name = tool.label();
let is_tool_active = tool == tool_state.active_tool; let is_tool_active = tool == tool_state.active_tool;
let c = colors; let c = colors;
let item = button( let popup_icon: Element<'_, Message> =
container( svg(svg::Handle::from_path(resolve_icon_path(tool_icon(tool))))
row![ .width(18)
text(tool_name).size(11).width(Length::Fill), .height(18)
text(tool_shortcut(tool)).size(10), .style(move |_theme, _status| svg::Style {
] color: Some(if is_tool_active {
.spacing(8) iced::Color::WHITE
) } else {
.width(150) c.text_primary
.padding([4, 8]) }),
) })
.on_press(Message::ToolSelected(tool)) .into();
.padding(0)
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
match status {
iced::widget::button::Status::Hovered => iced::widget::button::Style {
background: Some(iced::Background::Color(c.menu_hover)),
text_color: c.menu_text,
border: iced::Border::default(),
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(c.bg_hover)),
text_color: if is_tool_active { c.accent } else { c.text_primary },
border: iced::Border::default(),
..Default::default()
},
}
});
popup_items = popup_items.push(item); let item = button(container(popup_icon).width(32).height(28).center(18))
.on_press(Message::ToolSelected(tool))
.padding(0)
.width(32)
.height(28)
.style(
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
iced::widget::button::Status::Hovered => iced::widget::button::Style {
background: Some(iced::Background::Color(if is_tool_active {
c.accent_green
} else {
c.bg_hover
})),
text_color: c.text_primary,
border: iced::Border::default()
.color(if is_tool_active {
c.accent
} else {
c.border_high
})
.width(if is_tool_active { 1 } else { 0 }),
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(if is_tool_active {
c.accent_green
} else {
c.bg_active
})),
text_color: c.text_primary,
border: iced::Border::default()
.color(if is_tool_active {
c.accent
} else {
c.border_high
})
.width(if is_tool_active { 1 } else { 0 }),
..Default::default()
},
},
);
let shortcut = tool_shortcut(tool);
let tooltip_text = if shortcut.is_empty() {
tool_name.to_string()
} else {
format!("{tool_name} ({shortcut})")
};
popup_row = popup_row.push(iced::widget::tooltip(
item,
text(tooltip_text).size(11),
iced::widget::tooltip::Position::Right,
));
if (tool_index + 1) % 2 == 0 || tool_index + 1 == slot.tools.len() {
popup_grid = popup_grid.push(popup_row);
popup_row = row![].spacing(1);
}
} }
let popup = container(popup_items) let popup = container(popup_grid)
.padding(2) .padding(1)
.width(68)
.style(move |_theme| iced::widget::container::Style { .style(move |_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_active)), background: Some(iced::Background::Color(colors.bg_active)),
border: iced::Border::default().color(colors.border_high).width(1), border: iced::Border::default().color(colors.border_high).width(1),
shadow: iced::Shadow { shadow: iced::Shadow {
color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.4), color: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.4),
offset: iced::Vector::new(2.0, 0.0), offset: iced::Vector::new(1.0, 0.0),
blur_radius: 8.0, blur_radius: 5.0,
}, },
..Default::default() ..Default::default()
}); });
// Position popup to the right of the button let parent = container(iced::widget::tooltip(
let btn_element: Element<'a, Message> = container( btn_element,
row![btn, popup].spacing(0).align_y(iced::Alignment::Center) iced::widget::text(format!("{} (sub-tools open)", slot.label)).size(11),
).into(); iced::widget::tooltip::Position::Right,
))
.width(68);
container(column![parent, popup].spacing(1))
.width(68)
.into()
} else {
// Tooltip on hover // Tooltip on hover
let tooltip_text = format!("{} (click for sub-tools)", slot.label); let tooltip_text = slot.label.to_string();
iced::widget::tooltip( iced::widget::tooltip(
btn_element, btn_element,
iced::widget::text(tooltip_text).size(11), iced::widget::text(tooltip_text).size(11),
iced::widget::tooltip::Position::Right, iced::widget::tooltip::Position::Right,
) )
.into() .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. /// Get keyboard shortcut display for a tool.
fn tool_shortcut(tool: Tool) -> &'static str { fn tool_shortcut(tool: Tool) -> &'static str {
match tool { 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()) .and_then(|s| s.tools.get(tidx).copied())
.unwrap_or(Tool::Brush) .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. /// Shared theme state for the application.
#[derive(Clone)] #[derive(Clone)]
pub struct ThemeState { pub struct ThemeState {
colors: Arc<Mutex<ThemeColors>>, state: Arc<Mutex<(ThemePreset, ThemeColors)>>,
} }
impl ThemeState { impl ThemeState {
/// Create a new theme state with the default preset. /// Create theme state from a selected preset.
pub fn new() -> Self { ///
/// **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 { Self {
colors: Arc::new(Mutex::new(ThemeColors::get(ThemePreset::default()))), state: Arc::new(Mutex::new((preset, ThemeColors::get(preset)))),
} }
} }
/// Get the current theme colors. /// Get the current theme colors.
pub fn colors(&self) -> ThemeColors { 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. /// Switch to a different theme preset.
#[allow(dead_code)]
pub fn set_preset(&self, preset: ThemePreset) { 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 { impl Default for ThemeState {
fn default() -> Self { 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); let mut children_col = column![].spacing(1);
for child in &subdirs { for child in &subdirs {
if let Some(child_name) = child.file_name().and_then(|n| n.to_str()) { if let Some(child_name) = child.file_name().and_then(|n| n.to_str()) {
let child_node = let child_node = draw_tree_node(child, child_name, ">", state, colors, depth + 1);
draw_tree_node(child, child_name, ">", state, colors, depth + 1);
children_col = children_col.push(child_node); children_col = children_col.push(child_node);
} }
} }
@@ -138,36 +137,40 @@ fn draw_tree_node(
let btn = button(label_text) let btn = button(label_text)
.on_press(Message::ViewerNavigate(path.to_path_buf())) .on_press(Message::ViewerNavigate(path.to_path_buf()))
.width(Length::Fill) .width(Length::Fill)
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { .style(
iced::widget::button::Style { move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
background: Some(iced::Background::Color(if is_selected { iced::widget::button::Style {
color_alpha(colors.accent, 0.15) background: Some(iced::Background::Color(if is_selected {
} else { color_alpha(colors.accent, 0.15)
iced::Color::TRANSPARENT } else {
})), iced::Color::TRANSPARENT
text_color: label_color, })),
border: iced::Border::default(), text_color: label_color,
..Default::default() border: iced::Border::default(),
} ..Default::default()
}); }
},
);
column![btn, children_col].spacing(1).into() column![btn, children_col].spacing(1).into()
} else { } else {
button(label_text) button(label_text)
.on_press(Message::ViewerNavigate(path.to_path_buf())) .on_press(Message::ViewerNavigate(path.to_path_buf()))
.width(Length::Fill) .width(Length::Fill)
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { .style(
iced::widget::button::Style { move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
background: Some(iced::Background::Color(if is_selected { iced::widget::button::Style {
color_alpha(colors.accent, 0.15) background: Some(iced::Background::Color(if is_selected {
} else { color_alpha(colors.accent, 0.15)
iced::Color::TRANSPARENT } else {
})), iced::Color::TRANSPARENT
text_color: label_color, })),
border: iced::Border::default(), text_color: label_color,
..Default::default() border: iced::Border::default(),
} ..Default::default()
}) }
},
)
.into() .into()
}; };
@@ -14,7 +14,7 @@ pub mod thumbnail_grid;
use crate::app::Message; use crate::app::Message;
use crate::theme::ThemeColors; 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 iced::{Element, Length};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -56,6 +56,8 @@ pub struct ViewerState {
pub preview_pixels: Option<(Vec<u8>, u32, u32)>, pub preview_pixels: Option<(Vec<u8>, u32, u32)>,
/// Preview load error message. /// Preview load error message.
pub load_error: Option<String>, 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. /// Zoom level for the viewer panel.
pub zoom: f32, pub zoom: f32,
/// Pan offset for the viewer panel. /// Pan offset for the viewer panel.
@@ -75,6 +77,7 @@ impl Default for ViewerState {
failed_thumbnails: HashSet::new(), failed_thumbnails: HashSet::new(),
preview_pixels: None, preview_pixels: None,
load_error: None, load_error: None,
directory_error: None,
zoom: 1.0, zoom: 1.0,
pan_offset: (0.0, 0.0), pan_offset: (0.0, 0.0),
} }
@@ -99,17 +102,32 @@ impl ViewerState {
/// Refresh the images list in the current directory. /// Refresh the images list in the current directory.
pub fn refresh_images(&mut self) { pub fn refresh_images(&mut self) {
log::debug!("Refreshing image list for: {}", self.current_dir.display()); log::debug!("Refreshing image list for: {}", self.current_dir.display());
let selected = self.images.get(self.active_image_idx).cloned();
self.images.clear(); self.images.clear();
if let Ok(entries) = std::fs::read_dir(&self.current_dir) { self.directory_error = None;
for entry in entries.flatten() { match std::fs::read_dir(&self.current_dir) {
let path = entry.path(); Ok(entries) => {
if path.is_file() && is_supported_image(&path) { for entry in entries.flatten() {
self.images.push(path); let path = entry.path();
if path.is_file() && is_supported_image(&path) {
self.images.push(path);
}
} }
} }
Err(error) => {
self.directory_error = Some(format!(
"Cannot read {}: {error}",
self.current_dir.display()
))
}
} }
self.images.sort(); self.images.sort_by_cached_key(|path| {
self.active_image_idx = 0; 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.preview_pixels = None;
self.load_error = None; self.load_error = None;
log::debug!("Found {} supported images", self.images.len()); log::debug!("Found {} supported images", self.images.len());
@@ -220,7 +238,8 @@ impl ViewerState {
let w = rgba.width(); let w = rgba.width();
let h = rgba.height(); let h = rgba.height();
let pixels = rgba.into_raw(); 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(_) => { Err(_) => {
self.failed_thumbnails.insert(path.to_path_buf()); 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. /// Build the viewer panel UI element.
pub fn view( pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> {
state: &ViewerState,
colors: ThemeColors,
) -> Element<'static, Message> {
let header = view_header(state, colors); let header = view_header(state, colors);
let body = match state.view_mode { let body = match state.view_mode {
ViewerMode::Browser => view_browser(state, colors), ViewerMode::Browser => view_browser(state, colors),
@@ -266,77 +282,91 @@ pub fn view(
fn view_header(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> { fn view_header(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> {
let exit_btn = button(text(" Exit Viewer ")) let exit_btn = button(text(" Exit Viewer "))
.on_press(Message::ViewerToggle) .on_press(Message::ViewerToggle)
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { .style(
iced::widget::button::Style { move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
background: Some(iced::Background::Color(colors.accent)), iced::widget::button::Style {
text_color: iced::Color::WHITE, background: Some(iced::Background::Color(colors.accent)),
border: iced::Border::default().color(colors.accent).width(1), text_color: iced::Color::WHITE,
..Default::default() 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))) let fs_btn = button(text(format!(" {} ", fs_label)))
.on_press(Message::ViewerFullscreen(!state.fullscreen)) .on_press(Message::ViewerFullscreen(!state.fullscreen))
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { .style(
iced::widget::button::Style { move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
background: Some(iced::Background::Color(colors.bg_panel)), iced::widget::button::Style {
text_color: colors.text_primary, background: Some(iced::Background::Color(colors.bg_panel)),
border: iced::Border::default().color(colors.border_low).width(1), text_color: colors.text_primary,
..Default::default() border: iced::Border::default().color(colors.border_low).width(1),
} ..Default::default()
}); }
},
);
let is_browser = state.view_mode == ViewerMode::Browser; let is_browser = state.view_mode == ViewerMode::Browser;
let browser_btn = button(text(" Browse ")) let browser_btn = button(text(" Browse "))
.on_press(Message::ViewerNavigate(state.current_dir.clone())) .on_press(Message::ViewerSetMode(ViewerMode::Browser))
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { .style(
iced::widget::button::Style { move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
background: Some(iced::Background::Color(if is_browser { iced::widget::button::Style {
colors.accent background: Some(iced::Background::Color(if is_browser {
} else { colors.accent
colors.bg_panel } else {
})), colors.bg_panel
text_color: if is_browser { })),
iced::Color::WHITE text_color: if is_browser {
} else { iced::Color::WHITE
colors.text_primary } else {
}, colors.text_primary
border: iced::Border::default().color(colors.border_low).width(1), },
..Default::default() border: iced::Border::default().color(colors.border_low).width(1),
} ..Default::default()
}); }
},
);
let is_viewer = state.view_mode == ViewerMode::Viewer; let is_viewer = state.view_mode == ViewerMode::Viewer;
let viewer_btn = button(text(" Viewer ")) let viewer_btn = button(text(" Viewer "))
.on_press(Message::ViewerNavigate(state.current_dir.clone())) .on_press(Message::ViewerSetMode(ViewerMode::Viewer))
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { .style(
iced::widget::button::Style { move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
background: Some(iced::Background::Color(if is_viewer { iced::widget::button::Style {
colors.accent background: Some(iced::Background::Color(if is_viewer {
} else { colors.accent
colors.bg_panel } else {
})), colors.bg_panel
text_color: if is_viewer { })),
iced::Color::WHITE text_color: if is_viewer {
} else { iced::Color::WHITE
colors.text_primary } else {
}, colors.text_primary
border: iced::Border::default().color(colors.border_low).width(1), },
..Default::default() border: iced::Border::default().color(colors.border_low).width(1),
} ..Default::default()
}); }
},
);
let refresh_btn = button(text(" Refresh ")) let refresh_btn = button(text(" Refresh "))
.on_press(Message::ViewerRefresh) .on_press(Message::ViewerRefresh)
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { .style(
iced::widget::button::Style { move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
background: Some(iced::Background::Color(colors.bg_panel)), iced::widget::button::Style {
text_color: colors.text_primary, background: Some(iced::Background::Color(colors.bg_panel)),
border: iced::Border::default().color(colors.border_low).width(1), text_color: colors.text_primary,
..Default::default() border: iced::Border::default().color(colors.border_low).width(1),
} ..Default::default()
}); }
},
);
row![ row![
exit_btn, 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> { fn view_preview(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> {
let content = if let Some((pixels, w, h)) = &state.preview_pixels { let content = if let Some((pixels, w, h)) = &state.preview_pixels {
let img = iced::widget::Image::new(iced::widget::image::Handle::from_rgba( let img = iced::widget::Image::new(iced::widget::image::Handle::from_rgba(
*w, *h, pixels.clone(), *w,
*h,
pixels.clone(),
)) ))
.width(Length::Fill) .width(Length::Fill)
.height(Length::Shrink); .height(Length::Shrink);
@@ -438,15 +470,19 @@ fn view_preview(state: &ViewerState, colors: ThemeColors) -> Element<'static, Me
.align_x(iced::Alignment::Center) .align_x(iced::Alignment::Center)
.width(Length::Fill) .width(Length::Fill)
} else if let Some(err) = &state.load_error { } else if let Some(err) = &state.load_error {
column![text(format!("Error: {}", err)).style(move |_theme| iced::widget::text::Style { column![
color: Some(colors.danger), text(format!("Error: {}", err)).style(move |_theme| iced::widget::text::Style {
})] color: Some(colors.danger),
})
]
.align_x(iced::Alignment::Center) .align_x(iced::Alignment::Center)
.width(Length::Fill) .width(Length::Fill)
} else { } else {
column![text("No image selected").style(move |_theme| iced::widget::text::Style { column![
color: Some(colors.text_secondary), text("No image selected").style(move |_theme| iced::widget::text::Style {
})] color: Some(colors.text_secondary),
})
]
.align_x(iced::Alignment::Center) .align_x(iced::Alignment::Center)
.width(Length::Fill) .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 has_parent = state.current_dir.parent().is_some();
let parent_btn = if has_parent { let parent_btn = if has_parent {
button(text(" Parent ")) button(text(" Parent ")).on_press(Message::ViewerNavigate(
.on_press(Message::ViewerNavigate( state.current_dir.parent().unwrap().to_path_buf(),
state.current_dir.parent().unwrap().to_path_buf(), ))
))
} else { } else {
button(text(" Parent ")) button(text(" Parent "))
}; };
let parent_btn = parent_btn let parent_btn = parent_btn.style(
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style { iced::widget::button::Style {
background: Some(iced::Background::Color(colors.bg_panel)), background: Some(iced::Background::Color(colors.bg_panel)),
text_color: colors.text_primary, text_color: colors.text_primary,
border: iced::Border::default().color(colors.border_low).width(1), border: iced::Border::default().color(colors.border_low).width(1),
..Default::default() ..Default::default()
} }
}); },
);
let dir_str = state.current_dir.to_string_lossy().to_string(); let dir_str = state.current_dir.to_string_lossy().to_string();
let total_files = state.images.len(); let total_files = state.images.len();
@@ -506,7 +542,10 @@ fn view_right_panel(state: &ViewerState, colors: ThemeColors) -> Element<'static
} else { } else {
0 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![ let toolbar = row![
parent_btn, parent_btn,
@@ -526,19 +565,20 @@ fn view_right_panel(state: &ViewerState, colors: ThemeColors) -> Element<'static
color: Some(colors.text_secondary), 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( container(
column![ column![toolbar, horizontal_rule(1), stats, horizontal_rule(1), grid,]
toolbar, .spacing(4)
horizontal_rule(1), .width(Length::Fill)
stats, .height(Length::Fill),
horizontal_rule(1),
grid,
]
.spacing(4)
.width(Length::Fill)
.height(Length::Fill),
) )
.width(Length::Fill) .width(Length::Fill)
.height(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> { fn view_viewer(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> {
if state.images.is_empty() { if state.images.is_empty() {
return column![ 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| {
color: Some(colors.text_secondary), iced::widget::text::Style {
color: Some(colors.text_secondary),
}
}), }),
button(text("Browse Mode")).on_press(Message::ViewerNavigate( button(text("Browse Mode"))
state.current_dir.clone(), .on_press(Message::ViewerNavigate(state.current_dir.clone(),)),
)),
] ]
.align_x(iced::Alignment::Center) .align_x(iced::Alignment::Center)
.width(Length::Fill) .width(Length::Fill)
@@ -585,35 +626,38 @@ fn view_viewer(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes
color: Some(colors.text_primary), color: Some(colors.text_primary),
}), }),
Space::with_width(Length::Fill), 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| {
color: Some(colors.text_secondary), iced::widget::text::Style {
color: Some(colors.text_secondary),
}
}), }),
text(" "), text(" "),
button(text("Edit")).on_press(Message::ViewerOpenFile(edit_path)) button(text("Edit"))
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { .on_press(Message::ViewerOpenFile(edit_path))
iced::widget::button::Style { .style(
background: Some(iced::Background::Color(colors.accent)), move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
text_color: iced::Color::WHITE, iced::widget::button::Style {
border: iced::Border::default().color(colors.accent).width(1), background: Some(iced::Background::Color(colors.accent)),
..Default::default() text_color: iced::Color::WHITE,
border: iced::Border::default().color(colors.accent).width(1),
..Default::default()
}
} }
}), ),
] ]
.align_y(iced::Alignment::Center) .align_y(iced::Alignment::Center)
.width(Length::Fill); .width(Length::Fill);
let image_area: Element<'static, Message> = if let Some((pixels, w, h)) = &state.preview_pixels { 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 handle: iced::widget::image::Handle =
); iced::widget::image::Handle::from_rgba(*w, *h, pixels.clone());
let img = iced::widget::Image::new(handle) let img = iced::widget::Image::new(handle)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill); .height(Length::Fill);
let prev_path = state.images[current_idx].clone(); let prev_btn = button(text(" < ")).on_press(Message::ViewerPrev).style(
let prev_btn = button(text(" < ")) move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
.on_press(Message::ViewerNavigate(prev_path))
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
iced::widget::button::Style { iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba( background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.5, 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), border: iced::Border::default().rounded(8),
..Default::default() ..Default::default()
} }
}); },
);
let next_idx = if current_idx + 1 < total { let next_btn = button(text(" > ")).on_press(Message::ViewerNext).style(
current_idx + 1 move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
} 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| {
iced::widget::button::Style { iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba( background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.5, 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), border: iced::Border::default().rounded(8),
..Default::default() ..Default::default()
} }
}); },
);
row![prev_btn, img, next_btn] row![prev_btn, img, next_btn]
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.into() .into()
} else if let Some(err) = &state.load_error { } else if let Some(err) = &state.load_error {
column![text(format!("Failed to load: {}", err)).style(move |_theme| { column![
iced::widget::text::Style { text(format!("Failed to load: {}", err)).style(move |_theme| {
color: Some(colors.danger), iced::widget::text::Style {
} color: Some(colors.danger),
})] }
})
]
.align_x(iced::Alignment::Center) .align_x(iced::Alignment::Center)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.into() .into()
} else { } else {
column![text("Loading...").style(move |_theme| iced::widget::text::Style { column![
color: Some(colors.text_secondary), text("Loading...").style(move |_theme| iced::widget::text::Style {
})] color: Some(colors.text_secondary),
})
]
.align_x(iced::Alignment::Center) .align_x(iced::Alignment::Center)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
@@ -672,3 +715,24 @@ fn view_viewer(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes
.height(Length::Fill) .height(Length::Fill)
.into() .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(); .into();
} }
let avail_w = 600.0_f32; // Four compact columns remain usable at the minimum editor width while the
let cols = ((avail_w / (CARD_W + CARD_SPACING)).floor().max(1.0)) as usize; // surrounding scrollable preserves access on narrower windows.
let cols = 4;
let mut grid_rows = column![].spacing(CARD_SPACING); let mut grid_rows = column![].spacing(CARD_SPACING);
let total = state.images.len(); let total = state.images.len();
@@ -68,7 +69,11 @@ pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Messag
} }
/// Render a single thumbnail card. /// 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 img_path = &state.images[index];
let is_selected = state.active_image_idx == index; let is_selected = state.active_image_idx == index;
let filename = img_path let filename = img_path
@@ -82,19 +87,17 @@ fn thumbnail_card(state: &ViewerState, index: usize, colors: ThemeColors) -> Ele
filename.clone() filename.clone()
}; };
let thumb_content: Element<'static, Message> = if let Some((pixels, w, h)) = state.thumbnail_cache.get(img_path) { let thumb_content: Element<'static, Message> =
let handle: iced::widget::image::Handle = iced::widget::image::Handle::from_rgba( if let Some((pixels, w, h)) = state.thumbnail_cache.get(img_path) {
*w, *h, pixels.clone(), let handle: iced::widget::image::Handle =
); iced::widget::image::Handle::from_rgba(*w, *h, pixels.clone());
let img = iced::widget::Image::new(handle) let img = iced::widget::Image::new(handle).width(110).height(100);
.width(110) img.into()
.height(100); } else if state.failed_thumbnails.contains(img_path) {
img.into() text("Error").size(10).into()
} else if state.failed_thumbnails.contains(img_path) { } else {
text("Error").size(10).into() text("...").size(10).into()
} else { };
text("...").size(10).into()
};
let card_content = column![ let card_content = column![
Space::with_height(4), Space::with_height(4),
@@ -124,17 +127,29 @@ fn thumbnail_card(state: &ViewerState, index: usize, colors: ThemeColors) -> Ele
button(card_content) button(card_content)
.on_press(Message::ViewerSelectFile(img_path.clone())) .on_press(Message::ViewerSelectFile(img_path.clone()))
.width(CARD_W) .width(CARD_W)
.style(move |_theme: &iced::Theme, _status: iced::widget::button::Status| { .style(
iced::widget::button::Style { move |_theme: &iced::Theme, status: iced::widget::button::Status| {
background: Some(iced::Background::Color(frame_color)), let background = match status {
text_color: colors.text_primary, iced::widget::button::Status::Hovered => colors.bg_hover,
border: iced::Border::default() iced::widget::button::Status::Pressed => colors.bg_active,
.color(border_color) iced::widget::button::Status::Disabled => colors.bg_app,
.width(if is_selected { 2.0 } else { 1.0 }) iced::widget::button::Status::Active => frame_color,
.rounded(6), };
..Default::default() iced::widget::button::Style {
} background: Some(iced::Background::Color(background)),
}) text_color: if status == iced::widget::button::Status::Disabled {
colors.text_disabled
} else {
colors.text_primary
},
border: iced::Border::default()
.color(border_color)
.width(if is_selected { 2.0 } else { 1.0 })
.rounded(6),
..Default::default()
}
},
)
.into() .into()
} }
@@ -143,7 +158,9 @@ pub fn count_unloaded(state: &ViewerState) -> usize {
state state
.images .images
.iter() .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() .count()
} }
@@ -152,7 +169,9 @@ pub fn next_load_batch(state: &ViewerState) -> Vec<std::path::PathBuf> {
state state
.images .images
.iter() .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) .take(LAZY_LOAD_BUDGET)
.cloned() .cloned()
.collect() .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. //! 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] #[test]
fn test_selection_transform_is_empty() { 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::TopLeft.cursor_hint(), "nwse-resize");
assert_eq!(TransformHandle::Rotate.cursor_hint(), "crosshair"); 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" } hcie-engine-api = { path = "../../../hcie-engine-api" }
iced = { workspace = true } iced = { workspace = true }
serde_json = { 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. /// Renders a single `WidgetDescription` as an Iced element.
pub fn render_widget(widget: &WidgetDescription) -> Element<'_, PanelMessage> { pub fn render_widget(widget: &WidgetDescription) -> Element<'_, PanelMessage> {
match widget { match widget {
WidgetDescription::Label { text: label_text, .. } => { WidgetDescription::Label {
text(label_text.as_str()).into() text: label_text, ..
} } => text(label_text.as_str()).into(),
WidgetDescription::Button { id, label } => { WidgetDescription::Button { id, label } => {
let id = id.clone(); let id = id.clone();
let label = label.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)) .on_press(PanelMessage::ValueChanged(id, serde_json::Value::Null))
.into() .into()
} }
WidgetDescription::Slider { id, label, min, max, value, .. } => { WidgetDescription::Slider {
id,
label,
min,
max,
value,
..
} => {
let id = id.clone(); let id = id.clone();
let current = *value; let current = *value;
@@ -91,7 +98,12 @@ pub fn render_widget(widget: &WidgetDescription) -> Element<'_, PanelMessage> {
}) })
.into() .into()
} }
WidgetDescription::Dropdown { id, label, options, selected } => { WidgetDescription::Dropdown {
id,
label,
options,
selected,
} => {
let id = id.clone(); let id = id.clone();
let selected_text = options.get(*selected).cloned().unwrap_or_default(); 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); let idx = options.iter().position(|o| *o == choice).unwrap_or(0);
PanelMessage::ValueChanged(id.clone(), serde_json::Value::from(idx)) PanelMessage::ValueChanged(id.clone(), serde_json::Value::from(idx))
}) })
.width(Length::Fill) .width(Length::Fill),
) )
.spacing(4) .spacing(4)
.into() .into()
} }
WidgetDescription::Separator => { WidgetDescription::Separator => horizontal_rule(1).into(),
horizontal_rule(1).into()
}
WidgetDescription::Group { label, children } => { WidgetDescription::Group { label, children } => {
let mut inner = iced::widget::Column::new() let mut inner = iced::widget::Column::new()
.spacing(4) .spacing(4)
@@ -16,7 +16,7 @@ use iced::Color;
/// ///
/// `Photopea` is the default — matches the Photopea image editor's dark UI. /// `Photopea` is the default — matches the Photopea image editor's dark UI.
/// Other presets are kept as alternatives. /// 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 { pub enum ThemePreset {
#[default] #[default]
Photopea, Photopea,
@@ -82,127 +82,127 @@ impl ThemeColors {
// ── Photopea (Default) ───────────────────────────────────── // ── Photopea (Default) ─────────────────────────────────────
// Exact colors from 93 Photopea screenshots. // Exact colors from 93 Photopea screenshots.
ThemePreset::Photopea => Self { ThemePreset::Photopea => Self {
bg_app: Color::from_rgb(0.118, 0.118, 0.118), // #1e1e1e - canvas bg_app: Color::from_rgb(0.118, 0.118, 0.118), // #1e1e1e - canvas
bg_panel: Color::from_rgb(0.165, 0.165, 0.165), // #2a2a2a - panels bg_panel: Color::from_rgb(0.165, 0.165, 0.165), // #2a2a2a - panels
bg_hover: Color::from_rgb(0.227, 0.227, 0.227), // #3a3a3a - hover bg_hover: Color::from_rgb(0.227, 0.227, 0.227), // #3a3a3a - hover
bg_active: Color::from_rgb(0.200, 0.200, 0.200), // #333333 - active tab bg_active: Color::from_rgb(0.200, 0.200, 0.200), // #333333 - active tab
accent: Color::from_rgb(0.290, 0.620, 1.000), // #4a9eff - selections accent: Color::from_rgb(0.290, 0.620, 1.000), // #4a9eff - selections
accent_hover: Color::from_rgb(0.350, 0.680, 1.000), accent_hover: Color::from_rgb(0.350, 0.680, 1.000),
accent_green: Color::from_rgb(0.298, 0.686, 0.314), // #4caf50 - tool active accent_green: Color::from_rgb(0.298, 0.686, 0.314), // #4caf50 - tool active
danger: Color::from_rgb(0.906, 0.298, 0.235), // #e74c3c - account btn danger: Color::from_rgb(0.906, 0.298, 0.235), // #e74c3c - account btn
border_low: Color::from_rgb(0.200, 0.200, 0.200), // #333333 - dividers border_low: Color::from_rgb(0.200, 0.200, 0.200), // #333333 - dividers
border_high: Color::from_rgb(0.267, 0.267, 0.267), // #444444 - borders border_high: Color::from_rgb(0.267, 0.267, 0.267), // #444444 - borders
text_primary: Color::from_rgb(0.878, 0.878, 0.878), // #e0e0e0 - primary text_primary: Color::from_rgb(0.878, 0.878, 0.878), // #e0e0e0 - primary
text_secondary:Color::from_rgb(0.600, 0.600, 0.600), // #999999 - muted text_secondary: Color::from_rgb(0.600, 0.600, 0.600), // #999999 - muted
text_disabled: Color::from_rgb(0.400, 0.400, 0.400), // #666666 - disabled text_disabled: Color::from_rgb(0.400, 0.400, 0.400), // #666666 - disabled
menu_bg: Color::from_rgb(0.910, 0.910, 0.910), // #e8e8e8 - menu bg menu_bg: Color::from_rgb(0.910, 0.910, 0.910), // #e8e8e8 - menu bg
menu_hover: Color::from_rgb(0.702, 0.851, 1.000), // #b3d9ff - menu hover menu_hover: Color::from_rgb(0.702, 0.851, 1.000), // #b3d9ff - menu hover
menu_text: Color::from_rgb(0.000, 0.000, 0.000), // #000000 - menu text menu_text: Color::from_rgb(0.000, 0.000, 0.000), // #000000 - menu text
menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400), // #666666 - shortcuts menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400), // #666666 - shortcuts
is_light: false, is_light: false,
}, },
// ── Photoshop (Legacy) ───────────────────────────────────── // ── Photoshop (Legacy) ─────────────────────────────────────
ThemePreset::Photoshop => Self { ThemePreset::Photoshop => Self {
bg_app: Color::from_rgb(0.110, 0.110, 0.118), bg_app: Color::from_rgb(0.110, 0.110, 0.118),
bg_panel: Color::from_rgb(0.196, 0.196, 0.212), bg_panel: Color::from_rgb(0.196, 0.196, 0.212),
bg_hover: Color::from_rgb(0.243, 0.243, 0.259), bg_hover: Color::from_rgb(0.243, 0.243, 0.259),
bg_active: Color::from_rgb(0.227, 0.227, 0.243), bg_active: Color::from_rgb(0.227, 0.227, 0.243),
accent: Color::from_rgb(0.220, 0.471, 0.863), accent: Color::from_rgb(0.220, 0.471, 0.863),
accent_hover: Color::from_rgb(0.260, 0.520, 0.900), accent_hover: Color::from_rgb(0.260, 0.520, 0.900),
accent_green: Color::from_rgb(0.298, 0.686, 0.314), accent_green: Color::from_rgb(0.298, 0.686, 0.314),
danger: Color::from_rgb(0.800, 0.200, 0.200), danger: Color::from_rgb(0.800, 0.200, 0.200),
border_low: Color::from_rgb(0.157, 0.157, 0.165), border_low: Color::from_rgb(0.157, 0.157, 0.165),
border_high: Color::from_rgb(0.251, 0.251, 0.267), border_high: Color::from_rgb(0.251, 0.251, 0.267),
text_primary: Color::from_rgb(0.878, 0.878, 0.878), text_primary: Color::from_rgb(0.878, 0.878, 0.878),
text_secondary:Color::from_rgb(0.580, 0.580, 0.596), text_secondary: Color::from_rgb(0.580, 0.580, 0.596),
text_disabled: Color::from_rgb(0.350, 0.350, 0.360), text_disabled: Color::from_rgb(0.350, 0.350, 0.360),
menu_bg: Color::from_rgb(0.910, 0.910, 0.910), menu_bg: Color::from_rgb(0.910, 0.910, 0.910),
menu_hover: Color::from_rgb(0.702, 0.851, 1.000), menu_hover: Color::from_rgb(0.702, 0.851, 1.000),
menu_text: Color::from_rgb(0.000, 0.000, 0.000), menu_text: Color::from_rgb(0.000, 0.000, 0.000),
menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400), menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400),
is_light: false, is_light: false,
}, },
// ── Monokai ──────────────────────────────────────────────── // ── Monokai ────────────────────────────────────────────────
ThemePreset::ProDark => Self { ThemePreset::ProDark => Self {
bg_app: Color::from_rgb(0.086, 0.082, 0.071), bg_app: Color::from_rgb(0.086, 0.082, 0.071),
bg_panel: Color::from_rgb(0.153, 0.157, 0.133), bg_panel: Color::from_rgb(0.153, 0.157, 0.133),
bg_hover: Color::from_rgb(0.212, 0.220, 0.188), bg_hover: Color::from_rgb(0.212, 0.220, 0.188),
bg_active: Color::from_rgb(0.180, 0.184, 0.160), bg_active: Color::from_rgb(0.180, 0.184, 0.160),
accent: Color::from_rgb(0.902, 0.588, 0.196), accent: Color::from_rgb(0.902, 0.588, 0.196),
accent_hover: Color::from_rgb(0.940, 0.640, 0.260), accent_hover: Color::from_rgb(0.940, 0.640, 0.260),
accent_green: Color::from_rgb(0.298, 0.686, 0.314), accent_green: Color::from_rgb(0.298, 0.686, 0.314),
danger: Color::from_rgb(0.780, 0.220, 0.267), danger: Color::from_rgb(0.780, 0.220, 0.267),
border_low: Color::from_rgb(0.110, 0.106, 0.086), border_low: Color::from_rgb(0.110, 0.106, 0.086),
border_high: Color::from_rgb(0.243, 0.251, 0.212), border_high: Color::from_rgb(0.243, 0.251, 0.212),
text_primary: Color::from_rgb(0.973, 0.973, 0.941), text_primary: Color::from_rgb(0.973, 0.973, 0.941),
text_secondary:Color::from_rgb(0.580, 0.588, 0.533), text_secondary: Color::from_rgb(0.580, 0.588, 0.533),
text_disabled: Color::from_rgb(0.350, 0.350, 0.330), text_disabled: Color::from_rgb(0.350, 0.350, 0.330),
menu_bg: Color::from_rgb(0.910, 0.910, 0.910), menu_bg: Color::from_rgb(0.910, 0.910, 0.910),
menu_hover: Color::from_rgb(0.702, 0.851, 1.000), menu_hover: Color::from_rgb(0.702, 0.851, 1.000),
menu_text: Color::from_rgb(0.000, 0.000, 0.000), menu_text: Color::from_rgb(0.000, 0.000, 0.000),
menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400), menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400),
is_light: false, is_light: false,
}, },
// ── Dracula ──────────────────────────────────────────────── // ── Dracula ────────────────────────────────────────────────
ThemePreset::Amoled => Self { ThemePreset::Amoled => Self {
bg_app: Color::from_rgb(0.094, 0.086, 0.133), bg_app: Color::from_rgb(0.094, 0.086, 0.133),
bg_panel: Color::from_rgb(0.157, 0.165, 0.212), bg_panel: Color::from_rgb(0.157, 0.165, 0.212),
bg_hover: Color::from_rgb(0.220, 0.227, 0.290), bg_hover: Color::from_rgb(0.220, 0.227, 0.290),
bg_active: Color::from_rgb(0.184, 0.192, 0.243), bg_active: Color::from_rgb(0.184, 0.192, 0.243),
accent: Color::from_rgb(1.000, 0.475, 0.776), accent: Color::from_rgb(1.000, 0.475, 0.776),
accent_hover: Color::from_rgb(1.000, 0.530, 0.820), accent_hover: Color::from_rgb(1.000, 0.530, 0.820),
accent_green: Color::from_rgb(0.298, 0.686, 0.314), accent_green: Color::from_rgb(0.298, 0.686, 0.314),
danger: Color::from_rgb(0.753, 0.220, 0.294), danger: Color::from_rgb(0.753, 0.220, 0.294),
border_low: Color::from_rgb(0.118, 0.110, 0.165), border_low: Color::from_rgb(0.118, 0.110, 0.165),
border_high: Color::from_rgb(0.267, 0.275, 0.345), border_high: Color::from_rgb(0.267, 0.275, 0.345),
text_primary: Color::from_rgb(0.973, 0.973, 0.949), text_primary: Color::from_rgb(0.973, 0.973, 0.949),
text_secondary:Color::from_rgb(0.588, 0.596, 0.667), text_secondary: Color::from_rgb(0.588, 0.596, 0.667),
text_disabled: Color::from_rgb(0.350, 0.350, 0.380), text_disabled: Color::from_rgb(0.350, 0.350, 0.380),
menu_bg: Color::from_rgb(0.910, 0.910, 0.910), menu_bg: Color::from_rgb(0.910, 0.910, 0.910),
menu_hover: Color::from_rgb(0.702, 0.851, 1.000), menu_hover: Color::from_rgb(0.702, 0.851, 1.000),
menu_text: Color::from_rgb(0.000, 0.000, 0.000), menu_text: Color::from_rgb(0.000, 0.000, 0.000),
menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400), menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400),
is_light: false, is_light: false,
}, },
// ── Solarized Light ──────────────────────────────────────── // ── Solarized Light ────────────────────────────────────────
ThemePreset::PhotoshopLight => Self { ThemePreset::PhotoshopLight => Self {
bg_app: Color::from_rgb(0.992, 0.965, 0.890), bg_app: Color::from_rgb(0.992, 0.965, 0.890),
bg_panel: Color::from_rgb(1.000, 0.980, 0.922), bg_panel: Color::from_rgb(1.000, 0.980, 0.922),
bg_hover: Color::from_rgb(0.933, 0.910, 0.835), bg_hover: Color::from_rgb(0.933, 0.910, 0.835),
bg_active: Color::from_rgb(0.878, 0.855, 0.780), bg_active: Color::from_rgb(0.878, 0.855, 0.780),
accent: Color::from_rgb(0.796, 0.294, 0.086), accent: Color::from_rgb(0.796, 0.294, 0.086),
accent_hover: Color::from_rgb(0.840, 0.340, 0.140), accent_hover: Color::from_rgb(0.840, 0.340, 0.140),
accent_green: Color::from_rgb(0.298, 0.686, 0.314), accent_green: Color::from_rgb(0.298, 0.686, 0.314),
danger: Color::from_rgb(0.800, 0.200, 0.200), danger: Color::from_rgb(0.800, 0.200, 0.200),
border_low: Color::from_rgb(0.871, 0.847, 0.769), border_low: Color::from_rgb(0.871, 0.847, 0.769),
border_high: Color::from_rgb(0.784, 0.753, 0.667), border_high: Color::from_rgb(0.784, 0.753, 0.667),
text_primary: Color::from_rgb(0.196, 0.196, 0.157), text_primary: Color::from_rgb(0.196, 0.196, 0.157),
text_secondary:Color::from_rgb(0.471, 0.463, 0.392), text_secondary: Color::from_rgb(0.471, 0.463, 0.392),
text_disabled: Color::from_rgb(0.650, 0.640, 0.580), text_disabled: Color::from_rgb(0.650, 0.640, 0.580),
menu_bg: Color::from_rgb(0.910, 0.910, 0.910), menu_bg: Color::from_rgb(0.910, 0.910, 0.910),
menu_hover: Color::from_rgb(0.702, 0.851, 1.000), menu_hover: Color::from_rgb(0.702, 0.851, 1.000),
menu_text: Color::from_rgb(0.000, 0.000, 0.000), menu_text: Color::from_rgb(0.000, 0.000, 0.000),
menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400), menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400),
is_light: true, is_light: true,
}, },
// ── Nord ─────────────────────────────────────────────────── // ── Nord ───────────────────────────────────────────────────
ThemePreset::ProLight => Self { ThemePreset::ProLight => Self {
bg_app: Color::from_rgb(0.894, 0.910, 0.933), bg_app: Color::from_rgb(0.894, 0.910, 0.933),
bg_panel: Color::from_rgb(0.925, 0.937, 0.957), bg_panel: Color::from_rgb(0.925, 0.937, 0.957),
bg_hover: Color::from_rgb(0.847, 0.871, 0.902), bg_hover: Color::from_rgb(0.847, 0.871, 0.902),
bg_active: Color::from_rgb(0.816, 0.839, 0.871), bg_active: Color::from_rgb(0.816, 0.839, 0.871),
accent: Color::from_rgb(0.369, 0.506, 0.675), accent: Color::from_rgb(0.369, 0.506, 0.675),
accent_hover: Color::from_rgb(0.410, 0.540, 0.710), accent_hover: Color::from_rgb(0.410, 0.540, 0.710),
accent_green: Color::from_rgb(0.298, 0.686, 0.314), accent_green: Color::from_rgb(0.298, 0.686, 0.314),
danger: Color::from_rgb(0.820, 0.220, 0.220), danger: Color::from_rgb(0.820, 0.220, 0.220),
border_low: Color::from_rgb(0.831, 0.855, 0.886), border_low: Color::from_rgb(0.831, 0.855, 0.886),
border_high: Color::from_rgb(0.706, 0.737, 0.784), border_high: Color::from_rgb(0.706, 0.737, 0.784),
text_primary: Color::from_rgb(0.180, 0.204, 0.251), text_primary: Color::from_rgb(0.180, 0.204, 0.251),
text_secondary:Color::from_rgb(0.392, 0.431, 0.502), text_secondary: Color::from_rgb(0.392, 0.431, 0.502),
text_disabled: Color::from_rgb(0.580, 0.610, 0.650), text_disabled: Color::from_rgb(0.580, 0.610, 0.650),
menu_bg: Color::from_rgb(0.910, 0.910, 0.910), menu_bg: Color::from_rgb(0.910, 0.910, 0.910),
menu_hover: Color::from_rgb(0.702, 0.851, 1.000), menu_hover: Color::from_rgb(0.702, 0.851, 1.000),
menu_text: Color::from_rgb(0.000, 0.000, 0.000), menu_text: Color::from_rgb(0.000, 0.000, 0.000),
menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400), menu_shortcut: Color::from_rgb(0.400, 0.400, 0.400),
is_light: true, is_light: true,
}, },