Refactor and enhance the Iced GUI with new features and improvements
- Improved layout and styling in filter_params.rs for better visual consistency. - Enhanced filters.rs to ensure scrollable parameters fill the available width. - Added a new Custom Shapes panel to display user-defined SVG shapes. - Updated menus.rs to include a new Custom Shapes menu item and adjusted related logic. - Modified properties.rs to support additional spray tool properties (particle size and density). - Adjusted title_bar.rs for improved menu button dimensions and layout. - Enhanced toolbar.rs to include spray tool options in the toolbar. - Fixed color persistence issues in settings.rs to ensure colors are saved and loaded correctly. - Updated plain_slider.rs to ensure sliders utilize the full width of their containers. - Created a visual polish plan to address various UI/UX issues and improve overall usability.
This commit is contained in:
@@ -0,0 +1,258 @@
|
|||||||
|
# ICED GUI Visual & Behavioral Polish Plan
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This plan addresses the visual and functional issues surfaced during recent ICED GUI testing. The focus is on the Iced build (`hcie-iced-app/crates/hcie-iced-gui/`). Items are grouped by theme and ordered to minimize code conflicts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### 1. Fix theme-inconsistent blue accent buttons
|
||||||
|
|
||||||
|
**Problem:** Dialogs like `New Image` show preset buttons as solid blue (`#3355cc`), which clashes with the dark theme and looks unnatural.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `hcie-iced-app/crates/hcie-iced-gui/src/dialogs/new_image.rs`
|
||||||
|
- `hcie-iced-app/crates/hcie-iced-gui/src/dialogs/mod.rs` (primary/secondary helpers)
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Add a `preset_btn` helper in `dialogs/mod.rs` that uses theme colors (`bg_hover`/`accent` border/text) rather than the default blue.
|
||||||
|
- Use it for all preset rows.
|
||||||
|
- Ensure primary button (Create) remains theme-accent but with hover/pressed states.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Fix color-picker persistence / session recall
|
||||||
|
|
||||||
|
**Problem:** The user reports that foreground/background colors are identical on startup and recent colors are not added/persisted.
|
||||||
|
|
||||||
|
**Observed code:**
|
||||||
|
- `load_colors()` is called in `app.rs:1719` and applied.
|
||||||
|
- `add_recent_color()` exists and is called by `FgColorChanged`/`SwapColors`.
|
||||||
|
- `save_colors()` is called when `colors_dirty` is true.
|
||||||
|
|
||||||
|
**Likely issues:**
|
||||||
|
- `load_colors` returns `PersistedColors` with `#[serde(default)]`, but if any field is absent it becomes `Default`, which is `None`/`empty`. This is fine.
|
||||||
|
- **Root cause candidates:**
|
||||||
|
- `Message::ResetColors` exists, but there may be no UI button mapped to it in the color picker panel.
|
||||||
|
- The saved colors file may not be written because `colors_dirty` is only set on `FgColorChanged`/`BgColorChanged`/`SwapColors`/`ResetColors`. Some popup color changes may not route through those messages.
|
||||||
|
- When the app starts, `fg_color` and `bg_color` are initialized to the same default (`[0,0,0,255]`) before `load_colors()` runs, and then `load_colors` overrides them. This should be correct, but verify.
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Ensure `PopupColorChanged` routed to `Foreground`/`Background` actually triggers `add_recent_color` and sets `colors_dirty`. It does via `FgColorChanged`/`BgColorChanged`.
|
||||||
|
- Add a **Reset** button in the color picker panel (`color_picker.rs`) that emits `Message::ResetColors`.
|
||||||
|
- Verify `load_colors()` correctly falls back when file is missing; add debug log if parsing fails.
|
||||||
|
- Ensure `save_colors()` stores alpha and recent list (already does).
|
||||||
|
- Remove or explain why `fg_color`/`bg_color` default to the same value in `IcedDocument` / `HcieIcedApp::new` before loading.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Right-click to pick secondary color in color palette panel
|
||||||
|
|
||||||
|
**Problem:** Right-click on palette/recent colors should set the background color.
|
||||||
|
|
||||||
|
**Observed code:**
|
||||||
|
- `color_picker.rs` already has `.on_right_press(Message::BgColorChanged(...))` on palette grid cells and recent colors.
|
||||||
|
- However, the wheel canvas only handles right-click inside `ColorWheel::update` and sends `Message::BgColorChanged(color)`.
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Verify right-click events are not swallowed by the panel/container.
|
||||||
|
- If Iced does not deliver right-clicks on `mouse_area` children reliably, wrap the whole color picker content in a `mouse_area` with `on_right_press` to set background color from current foreground? No — user expects picking the swatch under cursor.
|
||||||
|
- Prefer: keep per-swatch right-click handlers and add a test or visual verification.
|
||||||
|
- If still broken, investigate whether `mouse_area` in Iced 0.13 requires an explicit background hit; add a transparent container around each swatch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Enforce color palette panel minimum height so content stays visible
|
||||||
|
|
||||||
|
**Problem:** Color Palette panel can be collapsed too short; its contents become hidden.
|
||||||
|
|
||||||
|
**File:** `hcie-iced-app/crates/hcie-iced-gui/src/dock/sizing.rs`
|
||||||
|
|
||||||
|
**Change:**
|
||||||
|
- Raise `PaneType::ColorPicker` minimum height from `300.0` to `420.0`.
|
||||||
|
- Consider also raising `min` width from `180.0` to `200.0`.
|
||||||
|
- Ensure the color picker `view` uses `scrollable(content)` so overflow remains usable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Filters panel: parameter sliders should fill ~80% of panel width
|
||||||
|
|
||||||
|
**Problem:** Filter parameter sliders are too narrow and leave large unused space.
|
||||||
|
|
||||||
|
**File:** `hcie-iced-app/crates/hcie-iced-gui/src/panels/filter_params.rs`
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Audit all `float_slider` / `int_slider` calls and ensure the underlying `plain_slider` width is `Length::Fill` inside a container with `width(Length::Fill)`.
|
||||||
|
- In `filters.rs`, wrap the parameter section in a container with `width(Length::Fill)` and ensure `scrollable(params_panel)` does not clamp width.
|
||||||
|
- Add `width(Length::Fill)` to the `column!` returned by `filter_params::view` if not already present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Filter operations should integrate with history / undo
|
||||||
|
|
||||||
|
**Problem:** Filter Apply is not undoable via the History panel.
|
||||||
|
|
||||||
|
**File:** `hcie-iced-app/crates/hcie-iced-gui/src/app.rs`
|
||||||
|
|
||||||
|
**Change:**
|
||||||
|
- Before `engine.apply_filter(...)` in `Message::FilterApply`, call `engine.push_active_layer_snapshot(...)` (or the appropriate engine history API) with a description like `"Apply <filter_name>"`.
|
||||||
|
- After apply, refresh history cache (`refresh_history`) so the new entry appears in the History panel.
|
||||||
|
- Ensure undo (`Message::HistoryJump`) and redo paths still work after the snapshot is pushed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Brush button should reveal Brushes panel if closed
|
||||||
|
|
||||||
|
**Problem:** User says brush button does not open the Brushes panel.
|
||||||
|
|
||||||
|
**Observed code:** `Message::ToolSelected(Tool::Brush)` already calls `self.dock.reopen_pane(PaneType::Brushes)` at `app.rs:2289`.
|
||||||
|
|
||||||
|
**Possible issue:** `reopen_pane` may silently fail when the pane is auto-hidden or floating; or the brush tool can also be selected via slot click without this path.
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Verify `ToolSlotClicked`/`SubtoolsToggle` also triggers `reopen_pane(PaneType::Brushes)` when the resulting tool is `Tool::Brush`.
|
||||||
|
- Add a helper `ensure_brushes_panel()` and call it wherever the active tool becomes `Tool::Brush`.
|
||||||
|
- Test with panel closed, auto-hidden, and in a different tab group.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Selecting a filter from the Filters menu should reveal the Filters panel
|
||||||
|
|
||||||
|
**Problem:** Filter menu selection should open/raise the Filters panel.
|
||||||
|
|
||||||
|
**Observed code:** `Message::FilterSelect(filter)` already calls `self.dock.reopen_pane(PaneType::Filters)` at `app.rs:4628`.
|
||||||
|
|
||||||
|
**Possible issue:** Same as above — `reopen_pane` may not handle auto-hide/floating states.
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Audit `DockState::reopen_pane` to ensure it restores auto-hidden panels, focuses existing floating panels, and brings the pane to front if stacked.
|
||||||
|
- If `reopen_pane` is already correct, add visual verification; otherwise fix it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. Menu bar vertical padding (+2 px already planned, verify in final screenshot)
|
||||||
|
|
||||||
|
**File:** `hcie-iced-app/crates/hcie-iced-gui/src/panels/title_bar.rs`
|
||||||
|
|
||||||
|
**Already changed:** `MENU_BAR_HEIGHT` 28→30 and menu button widths increased.
|
||||||
|
|
||||||
|
**Remaining:** Confirm no clipping occurs with the new widths; adjust further if "Image" still truncates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. Prevent zero-size document creation (wgpu crash)
|
||||||
|
|
||||||
|
**Problem:** The New Image dialog allows width or height to be set to 0. Creating a 0×N document causes the renderer to request a texture with zero height (`resize_texture 800×600 → 4096×0`), which triggers a wgpu validation error and panic.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `hcie-iced-app/crates/hcie-iced-gui/src/dialogs/new_image.rs`
|
||||||
|
- `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` (`DialogNewImageCreate` handler)
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Clamp the width/height sliders to a minimum of `1` in `new_image.rs`.
|
||||||
|
- In `DialogNewImageCreate`, validate `dialog_new_width >= 1 && dialog_new_height >= 1` before creating the engine; if invalid, keep the dialog open and log a warning (or show inline validation text).
|
||||||
|
- Optionally disable the Create button when either dimension is zero.
|
||||||
|
- Remove or ignore the `Custom` preset with `width: 0, height: 0` since it is not a valid document size.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. Expose spray particle size / density in the UI
|
||||||
|
|
||||||
|
**Problem:** Spray tool radius/density settings are present in `tool_settings.rs` but not visible in the merged toolbar or Properties panel.
|
||||||
|
|
||||||
|
**Observed code:**
|
||||||
|
- `tool_settings.rs:132-179` already has `spray_settings()` with Particle Size and Density sliders.
|
||||||
|
- `properties.rs:134` groups `Tool::Spray` with brush properties, which only shows Size/Opacity/Hardness.
|
||||||
|
- `toolbar.rs:95` also groups Spray with Brush and only shows Size/Hardness.
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- In `properties.rs`, when `active_tool == Tool::Spray`, call a new `spray_properties(...)` helper that includes Size, Particle Size, Density, Opacity, and Hardness.
|
||||||
|
- In `toolbar.rs`, add a Spray-specific branch that shows Size, Particle Size, and Density (or move the existing spray controls from the Tool Settings panel into the merged toolbar if that is the intended UX).
|
||||||
|
- Ensure the values are read from `settings.tool_settings.spray_particle_size` / `spray_density` and emit the existing `Message::SprayParticleSizeChanged` / `SprayDensityChanged` messages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. Add a custom-shape slot / panel so user SVGs under `~/.local/share/hcie/shapes/` are usable
|
||||||
|
|
||||||
|
**Problem:** The user copied extra SVG files into `/home/dev-user/.local/share/hcie/shapes/`, but they did not appear in the UI and could not be used.
|
||||||
|
|
||||||
|
**Observed code:**
|
||||||
|
- `Engine::new()` initializes `shape_catalog` from `dirs::data_local_dir()/hcie/shapes` (matches `~/.local/share/hcie/shapes`) in `hcie-engine-api/src/lib.rs:558-562`.
|
||||||
|
- `ShapeCatalog::refresh()` in `hcie-engine-api/src/shape_catalog.rs` scans `*.svg` files there and assigns them indices.
|
||||||
|
- `Tool::CustomShape(idx)` exists and is handled in `app.rs:5945-5966`, using a snapshot of `shape_catalog.all()` during `VectorDrawEnd`.
|
||||||
|
- `sidebar/mod.rs` `TOOL_SLOTS` does **not** contain a slot for `Tool::CustomShape`. The vector-shapes slot only lists built-in `VectorArrow`, `VectorStar`, etc.
|
||||||
|
- There is no panel or popup listing the catalog entries for selection.
|
||||||
|
|
||||||
|
**Root cause:** The ICED sidebar has no entry point for `Tool::CustomShape`, so even though the engine loads custom SVGs from `~/.local/share/hcie/shapes/`, the user cannot select or draw them.
|
||||||
|
|
||||||
|
**Decision:** New dock panel with real SVG previews via `iced::widget::svg::Handle::from_memory`.
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Add `PaneType::CustomShapes` to `dock/state.rs` with label "Custom Shapes".
|
||||||
|
- Create `hcie-iced-app/crates/hcie-iced-gui/src/panels/custom_shapes.rs`.
|
||||||
|
- Accept `shapes: &[ShapeEntry]` and `selected_index: Option<usize>`.
|
||||||
|
- Render each entry as a small card (e.g. 56×56 preview + label below).
|
||||||
|
- Use `iced::widget::Svg::new(Handle::from_memory(entry.svg.into_bytes()))` for the preview, sized to fit the card.
|
||||||
|
- On click, emit `Message::ToolSelected(Tool::CustomShape(index))`.
|
||||||
|
- Highlight the selected card with `colors.accent` border.
|
||||||
|
- Wire the new panel in `dock/view.rs`.
|
||||||
|
- Add the panel to `settings.rs` default `visible_panels` / `panel_order` so it appears in the initial dock layout.
|
||||||
|
- Add a "Refresh" button in the panel that emits `Message::RefreshCustomShapes`; handler calls `engine.shape_catalog.refresh()` and updates `app.cached_custom_shapes`.
|
||||||
|
- Store a cached `Vec<(String, String)>` of custom shapes in `HcieIcedApp` so the panel can be rebuilt cheaply; refresh it whenever the panel is shown or when Refresh is pressed.
|
||||||
|
- Ensure `Tool::CustomShape(idx)` out-of-range cases return `None` in `VectorDrawEnd` (already mostly true; add explicit guard).
|
||||||
|
- Verify that copied SVGs are valid — `ShapeCatalog` silently skips unreadable / non-SVG files.
|
||||||
|
|
||||||
|
### 12b. Custom Shape drawing not triggering on canvas
|
||||||
|
|
||||||
|
**Problem:** Selecting a custom shape in the Custom Shapes panel does not actually draw it on the canvas when dragging.
|
||||||
|
|
||||||
|
**Root cause:** The shared vector-shape drag flow is gated by `is_vector_shape_tool()` and an explicit match arm in the `CanvasPointerPressed` handler. Both locations enumerate the built-in `Tool::Vector*` variants but omit `Tool::CustomShape(_)`, so the custom-shape tool never enters `VectorDrawStart`/`VectorDrawMove`/`VectorDrawEnd` and therefore never reaches the existing `VectorDrawEnd` handler that already knows how to build a `VectorShape::SvgShape` from the catalog.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `hcie-iced-app/crates/hcie-iced-gui/src/app.rs`
|
||||||
|
- `hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs` (icon support already present; no change)
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
1. In `app.rs` helper `is_vector_shape_tool()` (around line 1287), add `Tool::CustomShape(_)` to the `matches!` pattern.
|
||||||
|
2. In `app.rs` `CanvasPointerPressed` handler (around line 2824), add `Tool::CustomShape(_)` to the arm that starts `Message::VectorDrawStart`.
|
||||||
|
3. Keep `VectorDrawEnd` unchanged; it already snapshots `shape_catalog.all()` (line 5528-5539) and builds a `VectorShape::SvgShape` for `Tool::CustomShape(idx)` with correct bounds.
|
||||||
|
|
||||||
|
**Edge cases to consider:**
|
||||||
|
- Out-of-range `Tool::CustomShape(idx)` already returns `None` from `VectorDrawEnd` and produces no shape.
|
||||||
|
- If the active layer is a locked vector layer, the shape-flow first switches to a safe non-vector layer before drawing, just like built-in vector shapes.
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
1. Build: `cargo check -p hcie-iced-gui` and `cargo test -p hcie-iced-gui`.
|
||||||
|
2. Run the Iced GUI.
|
||||||
|
3. Window → Custom Shapes → select a shape.
|
||||||
|
4. Drag on the canvas → a new vector layer containing the custom SVG shape appears.
|
||||||
|
5. Undo reverts the inserted shape (history snapshot is pushed by `engine.add_vector_shape`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Plan
|
||||||
|
|
||||||
|
1. `cargo check -p hcie-iced-gui`
|
||||||
|
2. `cargo test -p hcie-iced-gui`
|
||||||
|
3. Manual launch and visual checks:
|
||||||
|
- New Image dialog presets look theme-aware (not bright blue).
|
||||||
|
- Set width or height to 0 → Create button disabled; preset ignores 0-size values.
|
||||||
|
- Create documents with valid sizes → no wgpu panic.
|
||||||
|
- Spray tool selected → Particle Size / Density sliders visible in Tool Settings panel.
|
||||||
|
- Pick colors, close app, reopen — colors and recent list restored.
|
||||||
|
- Right-click a palette swatch sets background color.
|
||||||
|
- Color Palette panel cannot be resized below content height.
|
||||||
|
- Select Brush tool → Brushes panel appears.
|
||||||
|
- Filter menu → Filters panel appears and parameter sliders are wide.
|
||||||
|
- Apply a filter → History panel gains an "Apply …" entry; Undo reverts it.
|
||||||
|
- Custom SVG files in `~/.local/share/hcie/shapes/` appear in the new Custom Shapes panel and can be drawn.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Is there a reference image or exact hex for the desired preset button style? (Current theme tokens: `bg_hover`, `accent`, `text_primary`.)
|
||||||
|
- Should the New Image dialog enforce a single minimum (e.g. 1×1) or a practical minimum (e.g. 8×8) to avoid 1-pixel-high canvases?
|
||||||
|
- Should custom shapes be rendered as small SVG previews in the panel, or is a text label list acceptable as a first pass? (Recommended: text list first, SVG preview later.)
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Color Palette Panel — Persistence & Layout Fixes
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The iced GUI color palette panel has several issues:
|
||||||
|
1. **Colors don't persist between sessions** — bg color changes are never saved (bug in `BgColorChanged` handler), and save failures are silently ignored
|
||||||
|
2. **Reset button** should return to black/white (already works correctly)
|
||||||
|
3. **Recent colors don't persist** — same root cause as #1 (dirty flag issue)
|
||||||
|
4. **Panel minimum height** is too low (180px) — contents get clipped, especially the color wheel (220px canvas)
|
||||||
|
|
||||||
|
## Root Cause Analysis
|
||||||
|
|
||||||
|
### Bug 1: BgColorChanged never persists (PRIMARY ISSUE)
|
||||||
|
|
||||||
|
In `app.rs:2352-2356`, the `BgColorChanged` handler does NOT set `self.colors_dirty = true`:
|
||||||
|
```rust
|
||||||
|
Message::BgColorChanged(color) => {
|
||||||
|
self.bg_color = color;
|
||||||
|
// MISSING: self.colors_dirty = true;
|
||||||
|
crate::shape_sync::sync_shape_from_tool(self);
|
||||||
|
self.refresh_composite_if_needed();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Compare with `FgColorChanged` (line 2347) which DOES set it. This means background color changes are never written to disk.
|
||||||
|
|
||||||
|
### Bug 2: Save order issue
|
||||||
|
|
||||||
|
In `app.rs:8845-8848`, the dirty flag is cleared BEFORE the save:
|
||||||
|
```rust
|
||||||
|
if self.colors_dirty {
|
||||||
|
self.colors_dirty = false; // cleared first
|
||||||
|
save_colors(...); // save second — if this fails, flag is already cleared
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `save_colors` fails (e.g., directory doesn't exist), the flag is already cleared and the save won't retry.
|
||||||
|
|
||||||
|
### Bug 3: Silent save failures
|
||||||
|
|
||||||
|
`save_colors()` uses `let _ = std::fs::write(...)` which discards errors. If `~/.config/hcie-iced/` doesn't exist, writes fail silently.
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Step 1: Fix BgColorChanged persistence
|
||||||
|
|
||||||
|
**File:** `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` (line ~2354)
|
||||||
|
|
||||||
|
Add `self.colors_dirty = true;` to the handler.
|
||||||
|
|
||||||
|
### Step 2: Fix save order
|
||||||
|
|
||||||
|
**File:** `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` (line 8845-8848)
|
||||||
|
|
||||||
|
Swap the order — save first, then clear the flag:
|
||||||
|
```rust
|
||||||
|
if self.colors_dirty {
|
||||||
|
save_colors(self.fg_color, self.bg_color, &self.recent_colors);
|
||||||
|
self.colors_dirty = false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Add directory creation in save_colors
|
||||||
|
|
||||||
|
**File:** `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` (line 93-101)
|
||||||
|
|
||||||
|
Add `create_dir_all` before writing:
|
||||||
|
```rust
|
||||||
|
fn save_colors(fg: [u8; 4], bg: [u8; 4], recent: &[[u8; 4]]) {
|
||||||
|
let path = colors_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
// ... rest unchanged
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Increase panel minimum height
|
||||||
|
|
||||||
|
**File:** `hcie-iced-app/crates/hcie-iced-gui/src/dock/sizing.rs` (line 70-73)
|
||||||
|
|
||||||
|
Change ColorPicker policy:
|
||||||
|
```rust
|
||||||
|
PaneType::ColorPicker => PaneSizePolicy {
|
||||||
|
width: axis(180.0, 280.0, 380.0, 1),
|
||||||
|
height: axis(300.0, 420.0, 500.0, 1),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale for values:
|
||||||
|
- **Min 300px**: Shows swatch row, hex input, alpha slider, tab bar, and start of color wheel
|
||||||
|
- **Preferred 420px**: Shows full color wheel (220px canvas) + header + recent colors without scrolling
|
||||||
|
- **Max 500px**: Bounded to prevent stealing vertical space from sibling panels (Layers has grow_priority=35 vs color's 1)
|
||||||
|
|
||||||
|
### Step 5: Update test assertions
|
||||||
|
|
||||||
|
**File:** `hcie-iced-app/crates/hcie-iced-gui/src/dock/sizing.rs`
|
||||||
|
|
||||||
|
Tests to update:
|
||||||
|
1. `policy_values_prioritize_canvas_and_bound_color_picker` (line 437-441): Update preferred/max values
|
||||||
|
2. `subtree_constraints_follow_split_axis` (line 453): Update `height.max` assertion (Layers has INFINITY max, so shared axis max becomes INFINITY)
|
||||||
|
3. `color_picker_does_not_receive_vertical_surplus_with_list_sibling` (line 494): Update max bound check
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
1. `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` — 3 changes (BgColorChanged fix, save order, create_dir_all)
|
||||||
|
2. `hcie-iced-app/crates/hcie-iced-gui/src/dock/sizing.rs` — policy update + 3 test updates
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Launch app, change both fg and bg colors, close and reopen — both should be restored
|
||||||
|
2. Click "D" button — fg=black, bg=white
|
||||||
|
3. Select several colors, close/reopen — recent colors preserved
|
||||||
|
4. Resize color palette pane to minimum — color wheel and recent colors visible
|
||||||
|
5. `cargo test -p hcie-iced-gui` — all tests pass
|
||||||
@@ -82,22 +82,43 @@ fn colors_path() -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Load persisted colors from disk (or defaults if missing).
|
/// Load persisted colors from disk (or defaults if missing).
|
||||||
|
///
|
||||||
|
/// Logs a warning if the file exists but cannot be parsed so corrupted JSON
|
||||||
|
/// does not silently revert colors to defaults.
|
||||||
fn load_colors() -> PersistedColors {
|
fn load_colors() -> PersistedColors {
|
||||||
match std::fs::read_to_string(colors_path()) {
|
let path = colors_path();
|
||||||
Ok(json) => serde_json::from_str(&json).unwrap_or_default(),
|
match std::fs::read_to_string(&path) {
|
||||||
Err(_) => PersistedColors::default(),
|
Ok(json) => serde_json::from_str(&json)
|
||||||
|
.map_err(|e| {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to parse persisted colors at {}: {}. Using defaults.",
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
e
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => PersistedColors::default(),
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to read persisted colors at {}: {}", path.display(), e);
|
||||||
|
PersistedColors::default()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save colors to disk.
|
/// Save colors to disk.
|
||||||
fn save_colors(fg: [u8; 4], bg: [u8; 4], recent: &[[u8; 4]]) {
|
fn save_colors(fg: [u8; 4], bg: [u8; 4], recent: &[[u8; 4]]) {
|
||||||
|
let path = colors_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
let data = PersistedColors {
|
let data = PersistedColors {
|
||||||
fg: Some(fg),
|
fg: Some(fg),
|
||||||
bg: Some(bg),
|
bg: Some(bg),
|
||||||
recent: recent.to_vec(),
|
recent: recent.to_vec(),
|
||||||
};
|
};
|
||||||
if let Ok(json) = serde_json::to_string_pretty(&data) {
|
if let Ok(json) = serde_json::to_string_pretty(&data) {
|
||||||
let _ = std::fs::write(colors_path(), json);
|
let _ = std::fs::write(&path, json);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +328,10 @@ pub struct HcieIcedApp {
|
|||||||
pub colors_dirty: bool,
|
pub colors_dirty: bool,
|
||||||
/// Layer-bound snapshot of vector shapes before editing (for exact Cancel restoration).
|
/// Layer-bound snapshot of vector shapes before editing (for exact Cancel restoration).
|
||||||
pub vector_shapes_snapshot: Option<crate::vector_edit::VectorEditSnapshot>,
|
pub vector_shapes_snapshot: Option<crate::vector_edit::VectorEditSnapshot>,
|
||||||
|
/// Cached `(kind, svg)` pairs from the engine's shape catalog for the active document.
|
||||||
|
/// Updated when the Custom Shapes panel is refreshed so `VectorDrawEnd` always uses the
|
||||||
|
/// latest catalog contents without re-scanning the shapes directory on every drag.
|
||||||
|
pub cached_custom_shapes: Vec<(String, String)>,
|
||||||
/// Last drag position during vector shape editing (canvas coordinates).
|
/// Last drag position during vector shape editing (canvas coordinates).
|
||||||
pub vector_drag_last: Option<(f32, f32)>,
|
pub vector_drag_last: Option<(f32, f32)>,
|
||||||
/// Timestamp of the last expensive vector rerasterization during a drag.
|
/// Timestamp of the last expensive vector rerasterization during a drag.
|
||||||
@@ -598,6 +623,9 @@ pub enum Message {
|
|||||||
ResetColors,
|
ResetColors,
|
||||||
ColorTabChanged(usize),
|
ColorTabChanged(usize),
|
||||||
|
|
||||||
|
// ── Custom shapes panel ─────────────────────────────
|
||||||
|
RefreshCustomShapes,
|
||||||
|
|
||||||
// ── Canvas interaction ──────────────────────────────
|
// ── Canvas interaction ──────────────────────────────
|
||||||
CanvasPointerPressed {
|
CanvasPointerPressed {
|
||||||
x: f32,
|
x: f32,
|
||||||
@@ -1147,6 +1175,40 @@ fn default_texture_params(filter_id: &str) -> serde_json::Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert a FilterType enum to a user-facing display label.
|
||||||
|
///
|
||||||
|
/// Mirrors the naming used in the Filters panel so history entries read
|
||||||
|
/// naturally (e.g. "Apply Box Blur" rather than "Apply box_blur").
|
||||||
|
fn filter_label(filter: FilterType) -> &'static str {
|
||||||
|
match filter {
|
||||||
|
FilterType::BoxBlur => "Box Blur",
|
||||||
|
FilterType::GaussianBlur => "Gaussian Blur",
|
||||||
|
FilterType::MotionBlur => "Motion Blur",
|
||||||
|
FilterType::UnsharpMask => "Unsharp Mask",
|
||||||
|
FilterType::Mosaic => "Mosaic",
|
||||||
|
FilterType::Pinch => "Pinch",
|
||||||
|
FilterType::Twirl => "Twirl",
|
||||||
|
FilterType::OilPaint => "Oil Paint",
|
||||||
|
FilterType::Crystallize => "Crystallize",
|
||||||
|
FilterType::Levels => "Levels",
|
||||||
|
FilterType::Vibrance => "Vibrance",
|
||||||
|
FilterType::Exposure => "Exposure",
|
||||||
|
FilterType::Posterize => "Posterize",
|
||||||
|
FilterType::Threshold => "Threshold",
|
||||||
|
FilterType::ChannelMixer => "Channel Mixer",
|
||||||
|
FilterType::BlackAndWhite => "Black & White",
|
||||||
|
FilterType::SelectiveColor => "Selective Color",
|
||||||
|
FilterType::GammaCorrection => "Gamma Correction",
|
||||||
|
FilterType::ExtractChannel => "Extract Channel",
|
||||||
|
FilterType::GradientMap => "Gradient Map",
|
||||||
|
FilterType::GaussianBlurGamma => "Gamma-aware Gaussian Blur",
|
||||||
|
FilterType::BoxBlurGamma => "Gamma-aware Box Blur",
|
||||||
|
FilterType::MedianFilter => "Median Filter",
|
||||||
|
FilterType::Dehaze => "Dehaze",
|
||||||
|
FilterType::NoisePattern => "Noise Pattern",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert a FilterType enum to the engine's string filter ID.
|
/// Convert a FilterType enum to the engine's string filter ID.
|
||||||
fn filter_type_to_id(filter: FilterType) -> &'static str {
|
fn filter_type_to_id(filter: FilterType) -> &'static str {
|
||||||
match filter {
|
match filter {
|
||||||
@@ -1210,6 +1272,7 @@ fn pane_type_from_label(label: &str) -> Option<crate::dock::state::PaneType> {
|
|||||||
"Geometry" => Some(PaneType::Geometry),
|
"Geometry" => Some(PaneType::Geometry),
|
||||||
"Tool Settings" => Some(PaneType::ToolSettings),
|
"Tool Settings" => Some(PaneType::ToolSettings),
|
||||||
"AI Script Generator" => Some(PaneType::AiScript),
|
"AI Script Generator" => Some(PaneType::AiScript),
|
||||||
|
"Custom Shapes" => Some(PaneType::CustomShapes),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1228,6 +1291,10 @@ fn union_regions(r1: Option<[u32; 4]>, r2: [u32; 4]) -> [u32; 4] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a tool is a vector-shape tool that uses the shared drag flow.
|
/// Whether a tool is a vector-shape tool that uses the shared drag flow.
|
||||||
|
///
|
||||||
|
/// This includes both built-in vector shapes and custom SVG shapes loaded from
|
||||||
|
/// the engine's shape catalog (`Tool::CustomShape`), so they all share the same
|
||||||
|
/// canvas drag-to-draw interaction.
|
||||||
fn is_vector_shape_tool(tool: Tool) -> bool {
|
fn is_vector_shape_tool(tool: Tool) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
tool,
|
tool,
|
||||||
@@ -1246,6 +1313,7 @@ fn is_vector_shape_tool(tool: Tool) -> bool {
|
|||||||
| Tool::VectorCrescent
|
| Tool::VectorCrescent
|
||||||
| Tool::VectorBolt
|
| Tool::VectorBolt
|
||||||
| Tool::VectorArrow4
|
| Tool::VectorArrow4
|
||||||
|
| Tool::CustomShape(_)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1527,7 +1595,7 @@ impl HcieIcedApp {
|
|||||||
show_welcome: false,
|
show_welcome: false,
|
||||||
active_doc: 0,
|
active_doc: 0,
|
||||||
tool_state: ToolState::default(),
|
tool_state: ToolState::default(),
|
||||||
fg_color: [220, 50, 50, 255],
|
fg_color: [0, 0, 0, 255],
|
||||||
bg_color: [255, 255, 255, 255],
|
bg_color: [255, 255, 255, 255],
|
||||||
last_cursor_pos: None,
|
last_cursor_pos: None,
|
||||||
canvas_cursor_pos: None,
|
canvas_cursor_pos: None,
|
||||||
@@ -1622,6 +1690,7 @@ impl HcieIcedApp {
|
|||||||
_language: i18n::Language::default(),
|
_language: i18n::Language::default(),
|
||||||
colors_dirty: false,
|
colors_dirty: false,
|
||||||
vector_shapes_snapshot: None,
|
vector_shapes_snapshot: None,
|
||||||
|
cached_custom_shapes: Vec::new(),
|
||||||
vector_drag_last: None,
|
vector_drag_last: None,
|
||||||
vector_drag_last_render: None,
|
vector_drag_last_render: None,
|
||||||
vector_drag_last_bounds: None,
|
vector_drag_last_bounds: None,
|
||||||
@@ -2285,6 +2354,16 @@ impl HcieIcedApp {
|
|||||||
if tool == Tool::Brush {
|
if tool == Tool::Brush {
|
||||||
self.dock.reopen_pane(crate::dock::state::PaneType::Brushes);
|
self.dock.reopen_pane(crate::dock::state::PaneType::Brushes);
|
||||||
}
|
}
|
||||||
|
// When the user picks a custom shape, reveal the Custom Shapes
|
||||||
|
// panel and keep the active tool slot on the vector-shapes slot
|
||||||
|
// so the sidebar highlight stays coherent.
|
||||||
|
if matches!(tool, Tool::CustomShape(_)) {
|
||||||
|
self.dock
|
||||||
|
.reopen_pane(crate::dock::state::PaneType::CustomShapes);
|
||||||
|
if let Some(slot_idx) = crate::sidebar::slot_for_tool(tool) {
|
||||||
|
self.active_tool_slot = slot_idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
// Record this tool as the last-used one for its slot, so the
|
// Record this tool as the last-used one for its slot, so the
|
||||||
// sidebar shows the last-used tool's icon next time (egui's
|
// sidebar shows the last-used tool's icon next time (egui's
|
||||||
// slot_last_used behavior at tool_state.rs:278-286).
|
// slot_last_used behavior at tool_state.rs:278-286).
|
||||||
@@ -2305,12 +2384,16 @@ impl HcieIcedApp {
|
|||||||
Message::ToolSlotClicked(slot_idx, primary_tool) => {
|
Message::ToolSlotClicked(slot_idx, primary_tool) => {
|
||||||
self.active_tool_slot = slot_idx;
|
self.active_tool_slot = slot_idx;
|
||||||
let tool = crate::sidebar::last_used_tool(slot_idx, &self.slot_last_used);
|
let tool = crate::sidebar::last_used_tool(slot_idx, &self.slot_last_used);
|
||||||
self.tool_state.active_tool =
|
let selected_tool = if crate::sidebar::tool_slots().get(slot_idx).is_some() {
|
||||||
if crate::sidebar::tool_slots().get(slot_idx).is_some() {
|
tool
|
||||||
tool
|
} else {
|
||||||
} else {
|
primary_tool
|
||||||
primary_tool
|
};
|
||||||
};
|
self.tool_state.active_tool = selected_tool;
|
||||||
|
if selected_tool == Tool::Brush {
|
||||||
|
self.dock
|
||||||
|
.reopen_pane(crate::dock::state::PaneType::Brushes);
|
||||||
|
}
|
||||||
self.sub_tools_open = false;
|
self.sub_tools_open = false;
|
||||||
}
|
}
|
||||||
Message::SubtoolsToggle(slot_idx) => {
|
Message::SubtoolsToggle(slot_idx) => {
|
||||||
@@ -2327,6 +2410,15 @@ impl HcieIcedApp {
|
|||||||
self.settings.panel_layout.sidebar_width = 72.0;
|
self.settings.panel_layout.sidebar_width = 72.0;
|
||||||
let _ = self.settings.save();
|
let _ = self.settings.save();
|
||||||
}
|
}
|
||||||
|
// If the user is opening the tool slot popup, also reveal the
|
||||||
|
// Brushes panel when the slot's current tool is the brush.
|
||||||
|
if opening {
|
||||||
|
let tool = crate::sidebar::last_used_tool(slot_idx, &self.slot_last_used);
|
||||||
|
if tool == Tool::Brush {
|
||||||
|
self.dock
|
||||||
|
.reopen_pane(crate::dock::state::PaneType::Brushes);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::OverlayOutsideClick => {
|
Message::OverlayOutsideClick => {
|
||||||
@@ -2351,6 +2443,7 @@ impl HcieIcedApp {
|
|||||||
|
|
||||||
Message::BgColorChanged(color) => {
|
Message::BgColorChanged(color) => {
|
||||||
self.bg_color = color;
|
self.bg_color = color;
|
||||||
|
self.colors_dirty = true;
|
||||||
crate::shape_sync::sync_shape_from_tool(self);
|
crate::shape_sync::sync_shape_from_tool(self);
|
||||||
self.refresh_composite_if_needed();
|
self.refresh_composite_if_needed();
|
||||||
}
|
}
|
||||||
@@ -2377,6 +2470,19 @@ impl HcieIcedApp {
|
|||||||
Message::ColorTabChanged(tab) => {
|
Message::ColorTabChanged(tab) => {
|
||||||
self.color_tab = tab;
|
self.color_tab = tab;
|
||||||
}
|
}
|
||||||
|
Message::RefreshCustomShapes => {
|
||||||
|
// Refresh the active document's shape catalog and update the
|
||||||
|
// cached shape list used for drawing custom shapes on the canvas.
|
||||||
|
let doc = &mut self.documents[self.active_doc];
|
||||||
|
doc.engine.shape_catalog.refresh();
|
||||||
|
self.cached_custom_shapes = doc
|
||||||
|
.engine
|
||||||
|
.shape_catalog
|
||||||
|
.all()
|
||||||
|
.iter()
|
||||||
|
.map(|e| (e.kind.clone(), e.svg.clone()))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
Message::OpenColorPicker(target, color) => {
|
Message::OpenColorPicker(target, color) => {
|
||||||
self.color_picker_target = Some(target);
|
self.color_picker_target = Some(target);
|
||||||
self.popup_color = color;
|
self.popup_color = color;
|
||||||
@@ -2755,8 +2861,9 @@ impl HcieIcedApp {
|
|||||||
| Tool::VectorCross
|
| Tool::VectorCross
|
||||||
| Tool::VectorCrescent
|
| Tool::VectorCrescent
|
||||||
| Tool::VectorBolt
|
| Tool::VectorBolt
|
||||||
| Tool::VectorArrow4 => {
|
| Tool::VectorArrow4
|
||||||
// Start vector shape drawing (all shape tools share one drag).
|
| Tool::CustomShape(_) => {
|
||||||
|
// Start vector shape drawing (built-in + custom SVG shapes share one drag).
|
||||||
let sx = canvas_x;
|
let sx = canvas_x;
|
||||||
let sy = canvas_y;
|
let sy = canvas_y;
|
||||||
return Task::perform(async move {}, move |_| {
|
return Task::perform(async move {}, move |_| {
|
||||||
@@ -4658,11 +4765,33 @@ impl HcieIcedApp {
|
|||||||
log::warn!("filter apply blocked for locked, group, or mask layer");
|
log::warn!("filter apply blocked for locked, group, or mask layer");
|
||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
|
// Capture the current layer pixels before applying so the
|
||||||
|
// operation can be undone from the History panel.
|
||||||
|
let doc = &mut self.documents[self.active_doc];
|
||||||
|
let active_idx = doc.engine.active_layer_index();
|
||||||
|
let before_pixels = doc.engine.get_active_layer_pixels();
|
||||||
|
let filter_name = filter_label(filter);
|
||||||
|
|
||||||
self.reset_preview_to_baseline();
|
self.reset_preview_to_baseline();
|
||||||
let filter_id = filter_type_to_id(filter);
|
let filter_id = filter_type_to_id(filter);
|
||||||
self.documents[self.active_doc]
|
self.documents[self.active_doc]
|
||||||
.engine
|
.engine
|
||||||
.apply_filter(filter_id, self.filter_params.clone());
|
.apply_filter(filter_id, self.filter_params.clone());
|
||||||
|
// Always push a history snapshot after applying the filter so
|
||||||
|
// the operation is visible in the History panel and undo works.
|
||||||
|
if let Some(before) = before_pixels {
|
||||||
|
let history_name = format!("Apply {}", filter_name);
|
||||||
|
let doc = &mut self.documents[self.active_doc];
|
||||||
|
doc.engine
|
||||||
|
.push_active_layer_snapshot(before, active_idx, &history_name);
|
||||||
|
}
|
||||||
|
// Refresh cached history list so the new entry appears.
|
||||||
|
let doc = &mut self.documents[self.active_doc];
|
||||||
|
let history_len = doc.engine.history_len();
|
||||||
|
doc.cached_history = (0..history_len)
|
||||||
|
.filter_map(|i| doc.engine.history_description(i).map(|d| (i, d)))
|
||||||
|
.collect();
|
||||||
|
doc.history_current = doc.engine.history_current();
|
||||||
self.preview_baseline = None;
|
self.preview_baseline = None;
|
||||||
self.documents[self.active_doc].modified = true;
|
self.documents[self.active_doc].modified = true;
|
||||||
self.refresh_composite_if_needed();
|
self.refresh_composite_if_needed();
|
||||||
@@ -4893,6 +5022,15 @@ impl HcieIcedApp {
|
|||||||
Message::DialogNewImageCreate => {
|
Message::DialogNewImageCreate => {
|
||||||
let w = self.dialog_new_width;
|
let w = self.dialog_new_width;
|
||||||
let h = self.dialog_new_height;
|
let h = self.dialog_new_height;
|
||||||
|
// Guard against 0×N documents that would crash wgpu texture allocation.
|
||||||
|
if w == 0 || h == 0 {
|
||||||
|
log::warn!(
|
||||||
|
"Refusing to create a {}×{} document: dimensions must be at least 1",
|
||||||
|
w,
|
||||||
|
h
|
||||||
|
);
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
let name = self.dialog_new_name.clone();
|
let name = self.dialog_new_name.clone();
|
||||||
let transparent = self.dialog_new_transparent;
|
let transparent = self.dialog_new_transparent;
|
||||||
let mut engine = Engine::new_with_options(&name, w, h, transparent);
|
let mut engine = Engine::new_with_options(&name, w, h, transparent);
|
||||||
@@ -5423,19 +5561,25 @@ impl HcieIcedApp {
|
|||||||
.engine
|
.engine
|
||||||
.set_active_layer(safe_source);
|
.set_active_layer(safe_source);
|
||||||
}
|
}
|
||||||
// Snapshot shape catalog entries for CustomShape tool
|
// Use cached catalog snapshot if available; otherwise read once from the
|
||||||
let custom_shapes: Vec<(String, String)> = self
|
// active document's shape catalog. `cached_custom_shapes` is populated/updated
|
||||||
.documents
|
// by `RefreshCustomShapes` so drawing remains consistent with the panel.
|
||||||
.get(self.active_doc)
|
let custom_shapes: Vec<(String, String)> = if self.cached_custom_shapes.is_empty()
|
||||||
.map(|d| {
|
{
|
||||||
d.engine
|
self.documents
|
||||||
.shape_catalog
|
.get(self.active_doc)
|
||||||
.all()
|
.map(|d| {
|
||||||
.iter()
|
d.engine
|
||||||
.map(|e| (e.kind.clone(), e.svg.clone()))
|
.shape_catalog
|
||||||
.collect()
|
.all()
|
||||||
})
|
.iter()
|
||||||
.unwrap_or_default();
|
.map(|e| (e.kind.clone(), e.svg.clone()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
self.cached_custom_shapes.clone()
|
||||||
|
};
|
||||||
|
|
||||||
let engine = &mut self.documents[self.active_doc].engine;
|
let engine = &mut self.documents[self.active_doc].engine;
|
||||||
let color = self.fg_color;
|
let color = self.fg_color;
|
||||||
@@ -8843,8 +8987,8 @@ impl HcieIcedApp {
|
|||||||
// Persist colors to disk when they have changed (debounced via the
|
// Persist colors to disk when they have changed (debounced via the
|
||||||
// colors_dirty flag so we only write once per change-set, not per frame).
|
// colors_dirty flag so we only write once per change-set, not per frame).
|
||||||
if self.colors_dirty {
|
if self.colors_dirty {
|
||||||
self.colors_dirty = false;
|
|
||||||
save_colors(self.fg_color, self.bg_color, &self.recent_colors);
|
save_colors(self.fg_color, self.bg_color, &self.recent_colors);
|
||||||
|
self.colors_dirty = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lazy-load thumbnails when viewer is active (max LAZY_LOAD_BUDGET per frame)
|
// Lazy-load thumbnails when viewer is active (max LAZY_LOAD_BUDGET per frame)
|
||||||
|
|||||||
@@ -676,7 +676,10 @@ pub fn view<'a>(
|
|||||||
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]
|
let reset_btn = button(text("Reset").size(10))
|
||||||
|
.on_press(Message::ResetColors)
|
||||||
|
.padding([2, 6]);
|
||||||
|
let swatch_row = row![primary_swatch, secondary_swatch, swap_btn, reset_btn]
|
||||||
.spacing(4)
|
.spacing(4)
|
||||||
.align_y(iced::Alignment::Center);
|
.align_y(iced::Alignment::Center);
|
||||||
|
|
||||||
@@ -778,11 +781,14 @@ pub fn view<'a>(
|
|||||||
color: Some(dot_color),
|
color: Some(dot_color),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wrap the tiny swatch in a transparent padded container so the
|
||||||
|
// `mouse_area` always has a non-empty hit target, including for right-clicks.
|
||||||
let swatch = container(dot)
|
let swatch = container(dot)
|
||||||
.width(13)
|
.width(13)
|
||||||
.height(13)
|
.height(13)
|
||||||
.center_x(Length::Fill)
|
.center_x(Length::Fill)
|
||||||
.center_y(Length::Fill)
|
.center_y(Length::Fill)
|
||||||
|
.padding(2)
|
||||||
.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[0] as f32 / 255.0,
|
||||||
@@ -921,11 +927,14 @@ fn build_grid_view<'a>(fg_color: &'a [u8; 4]) -> Element<'a, Message> {
|
|||||||
color: Some(dot_color),
|
color: Some(dot_color),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pad the palette cell so the `mouse_area` has a non-empty hit
|
||||||
|
// target even when the inner swatch is only 14×14 pixels.
|
||||||
let swatch = container(dot)
|
let swatch = container(dot)
|
||||||
.width(14)
|
.width(14)
|
||||||
.height(14)
|
.height(14)
|
||||||
.center_x(Length::Fill)
|
.center_x(Length::Fill)
|
||||||
.center_y(Length::Fill)
|
.center_y(Length::Fill)
|
||||||
|
.padding(2)
|
||||||
.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[0] as f32 / 255.0,
|
||||||
|
|||||||
@@ -79,6 +79,16 @@ fn darken(c: iced::Color, factor: f32) -> iced::Color {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Modern theme-compatible primary button (accent fill).
|
/// Modern theme-compatible primary button (accent fill).
|
||||||
|
///
|
||||||
|
/// **Purpose:** Highlights the default action in a dialog with the current
|
||||||
|
/// theme accent color.
|
||||||
|
/// **Logic & Workflow:** The button uses `colors.accent` as its idle fill,
|
||||||
|
/// lightens on hover, and darkens when pressed. Text is always white so it
|
||||||
|
/// remains readable across all accent hues.
|
||||||
|
/// **Arguments:** `label` is the button text, `msg` is the press message,
|
||||||
|
/// and `colors` supplies the theme tokens.
|
||||||
|
/// **Returns:** A styled `Element` ready for the dialog button row.
|
||||||
|
/// **Side Effects / Dependencies:** None.
|
||||||
pub fn primary_btn(
|
pub fn primary_btn(
|
||||||
label: &str,
|
label: &str,
|
||||||
msg: Message,
|
msg: Message,
|
||||||
@@ -108,6 +118,16 @@ pub fn primary_btn(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Modern theme-compatible secondary button (bordered ghost).
|
/// Modern theme-compatible secondary button (bordered ghost).
|
||||||
|
///
|
||||||
|
/// **Purpose:** Provides a non-primary dialog action that sits alongside the
|
||||||
|
/// accent primary button without competing for attention.
|
||||||
|
/// **Logic & Workflow:** The button is transparent at rest, gains a subtle
|
||||||
|
/// hover background, and uses the theme's primary text color with a rounded
|
||||||
|
/// border.
|
||||||
|
/// **Arguments:** `label` is the button text, `msg` is the press message,
|
||||||
|
/// and `colors` supplies the theme tokens.
|
||||||
|
/// **Returns:** A styled `Element` ready for the dialog button row.
|
||||||
|
/// **Side Effects / Dependencies:** None.
|
||||||
pub fn secondary_btn(
|
pub fn secondary_btn(
|
||||||
label: &str,
|
label: &str,
|
||||||
msg: Message,
|
msg: Message,
|
||||||
@@ -139,3 +159,71 @@ pub fn secondary_btn(
|
|||||||
})
|
})
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Theme-compatible list/preset item button (subtle hover, no bright accent).
|
||||||
|
///
|
||||||
|
/// **Purpose:** Used for rows inside the New Image preset list and any other
|
||||||
|
/// selectable list items so they do not appear as the application's primary
|
||||||
|
/// accent button color.
|
||||||
|
/// **Logic & Workflow:** Renders a low-contrast row that highlights with the
|
||||||
|
/// theme's `bg_hover` on hover and darkens slightly when pressed. A focused
|
||||||
|
/// accent border is applied only on hover/pressed to keep the idle state calm.
|
||||||
|
/// **Arguments:** `label` is the row text, `msg` is the press message, and
|
||||||
|
/// `colors` supplies the theme tokens.
|
||||||
|
/// **Returns:** A styled `Element` ready to push into a list column.
|
||||||
|
/// **Side Effects / Dependencies:** None.
|
||||||
|
pub fn preset_btn(
|
||||||
|
label: &str,
|
||||||
|
msg: Message,
|
||||||
|
colors: ThemeColors,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Element<'static, Message> {
|
||||||
|
let l = label.to_string();
|
||||||
|
let c = colors;
|
||||||
|
let mut btn = button(
|
||||||
|
text(l)
|
||||||
|
.size(11)
|
||||||
|
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||||
|
color: Some(if enabled { c.text_primary } else { c.text_disabled }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.padding([5, 8])
|
||||||
|
.width(Length::Fill)
|
||||||
|
.style(move |_theme, status| {
|
||||||
|
let (bg, border_color) = if !enabled {
|
||||||
|
(iced::Color::TRANSPARENT, iced::Color::TRANSPARENT)
|
||||||
|
} else {
|
||||||
|
match status {
|
||||||
|
iced::widget::button::Status::Hovered => (c.bg_hover, c.border_high),
|
||||||
|
iced::widget::button::Status::Pressed => (darken(c.bg_hover, 0.1), c.accent),
|
||||||
|
_ => (iced::Color::TRANSPARENT, iced::Color::TRANSPARENT),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
iced::widget::button::Style {
|
||||||
|
background: Some(iced::Background::Color(bg)),
|
||||||
|
text_color: if enabled {
|
||||||
|
c.text_primary
|
||||||
|
} else {
|
||||||
|
c.text_disabled
|
||||||
|
},
|
||||||
|
border: Border::default()
|
||||||
|
.rounded(4)
|
||||||
|
.color(border_color)
|
||||||
|
.width(if enabled
|
||||||
|
&& matches!(
|
||||||
|
status,
|
||||||
|
iced::widget::button::Status::Hovered | iced::widget::button::Status::Pressed
|
||||||
|
)
|
||||||
|
{
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if enabled {
|
||||||
|
btn = btn.on_press(msg);
|
||||||
|
}
|
||||||
|
btn.into()
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,7 @@
|
|||||||
use crate::app::Message;
|
use crate::app::Message;
|
||||||
use crate::theme::ThemeColors;
|
use crate::theme::ThemeColors;
|
||||||
use crate::widgets::plain_slider::plain_slider;
|
use crate::widgets::plain_slider::plain_slider;
|
||||||
use iced::widget::{
|
use iced::widget::{checkbox, column, container, horizontal_rule, row, scrollable, text, text_input};
|
||||||
button, checkbox, column, horizontal_rule, row, scrollable, text, text_input,
|
|
||||||
};
|
|
||||||
use iced::{Element, Length};
|
use iced::{Element, Length};
|
||||||
|
|
||||||
/// A canvas preset size.
|
/// A canvas preset size.
|
||||||
@@ -16,11 +14,6 @@ struct CanvasPreset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PRESETS: &[CanvasPreset] = &[
|
const PRESETS: &[CanvasPreset] = &[
|
||||||
CanvasPreset {
|
|
||||||
name: "Custom",
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
},
|
|
||||||
CanvasPreset {
|
CanvasPreset {
|
||||||
name: "HD (1280×720)",
|
name: "HD (1280×720)",
|
||||||
width: 1280,
|
width: 1280,
|
||||||
@@ -71,9 +64,13 @@ pub fn view(
|
|||||||
.on_input(|s| Message::DialogNewImageName(s))
|
.on_input(|s| Message::DialogNewImageName(s))
|
||||||
.width(Length::Fill);
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
// Clamp displayed values to a valid minimum so the user cannot drag to 0.
|
||||||
|
let clamped_width = width.max(1);
|
||||||
|
let clamped_height = height.max(1);
|
||||||
|
|
||||||
let width_slider = plain_slider(
|
let width_slider = plain_slider(
|
||||||
"Width",
|
"Width",
|
||||||
width as f32,
|
clamped_width as f32,
|
||||||
1.0..=4096.0,
|
1.0..=4096.0,
|
||||||
1.0,
|
1.0,
|
||||||
"px",
|
"px",
|
||||||
@@ -83,7 +80,7 @@ pub fn view(
|
|||||||
|
|
||||||
let height_slider = plain_slider(
|
let height_slider = plain_slider(
|
||||||
"Height",
|
"Height",
|
||||||
height as f32,
|
clamped_height as f32,
|
||||||
1.0..=4096.0,
|
1.0..=4096.0,
|
||||||
1.0,
|
1.0,
|
||||||
"px",
|
"px",
|
||||||
@@ -96,17 +93,30 @@ pub fn view(
|
|||||||
|
|
||||||
let mut presets_col = column![].spacing(2);
|
let mut presets_col = column![].spacing(2);
|
||||||
for preset in PRESETS {
|
for preset in PRESETS {
|
||||||
let label = text(preset.name).size(11);
|
let label = preset.name;
|
||||||
let w = preset.width;
|
let w = preset.width;
|
||||||
let h = preset.height;
|
let h = preset.height;
|
||||||
let preset_btn = button(label)
|
let preset_btn = super::preset_btn(label, Message::DialogNewImagePreset(w, h), colors, true);
|
||||||
.on_press(Message::DialogNewImagePreset(w, h))
|
|
||||||
.width(Length::Fill)
|
|
||||||
.padding([4, 8]);
|
|
||||||
presets_col = presets_col.push(preset_btn);
|
presets_col = presets_col.push(preset_btn);
|
||||||
}
|
}
|
||||||
|
|
||||||
let create_btn = super::primary_btn("Create", Message::DialogNewImageCreate, colors);
|
// Disable Create if either dimension is zero; this prevents wgpu texture
|
||||||
|
// validation errors from creating a 0×N or N×0 document.
|
||||||
|
let can_create = width >= 1 && height >= 1;
|
||||||
|
let create_btn: iced::Element<'static, Message> = if can_create {
|
||||||
|
super::primary_btn("Create", Message::DialogNewImageCreate, colors)
|
||||||
|
} else {
|
||||||
|
// Disabled look: the same padding as the primary button but without an
|
||||||
|
// `on_press` handler, so the dialog cannot proceed with invalid sizes.
|
||||||
|
container(text("Create").size(12).color(colors.text_disabled))
|
||||||
|
.padding([8, 16])
|
||||||
|
.style(move |_theme| iced::widget::container::Style {
|
||||||
|
background: Some(iced::Background::Color(colors.bg_hover)),
|
||||||
|
border: iced::Border::default().rounded(6),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
};
|
||||||
let cancel_btn = super::secondary_btn("Cancel", Message::DialogClose, colors);
|
let cancel_btn = super::secondary_btn("Cancel", Message::DialogClose, colors);
|
||||||
|
|
||||||
let dialog = column![
|
let dialog = column![
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ pub fn panel_size_policy(panel: PaneType) -> PaneSizePolicy {
|
|||||||
height: axis(240.0, 600.0, f32::INFINITY, 100),
|
height: axis(240.0, 600.0, f32::INFINITY, 100),
|
||||||
},
|
},
|
||||||
PaneType::ColorPicker => PaneSizePolicy {
|
PaneType::ColorPicker => PaneSizePolicy {
|
||||||
width: axis(180.0, 280.0, 380.0, 1),
|
width: axis(200.0, 280.0, 380.0, 1),
|
||||||
height: axis(180.0, 330.0, 380.0, 1),
|
height: axis(420.0, 460.0, 520.0, 1),
|
||||||
},
|
},
|
||||||
PaneType::Script | PaneType::AiChat | PaneType::AiScript => PaneSizePolicy {
|
PaneType::Script | PaneType::AiChat | PaneType::AiScript => PaneSizePolicy {
|
||||||
width: axis(220.0, 360.0, f32::INFINITY, 60),
|
width: axis(220.0, 360.0, f32::INFINITY, 60),
|
||||||
@@ -91,6 +91,10 @@ pub fn panel_size_policy(panel: PaneType) -> PaneSizePolicy {
|
|||||||
width: axis(160.0, 280.0, f32::INFINITY, 25),
|
width: axis(160.0, 280.0, f32::INFINITY, 25),
|
||||||
height: axis(120.0, 280.0, f32::INFINITY, 25),
|
height: axis(120.0, 280.0, f32::INFINITY, 25),
|
||||||
},
|
},
|
||||||
|
PaneType::CustomShapes => PaneSizePolicy {
|
||||||
|
width: axis(160.0, 220.0, f32::INFINITY, 25),
|
||||||
|
height: axis(160.0, 300.0, f32::INFINITY, 25),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,9 +440,9 @@ mod tests {
|
|||||||
assert!(canvas.width.max.is_infinite());
|
assert!(canvas.width.max.is_infinite());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(color.width.preferred, color.height.preferred),
|
(color.width.preferred, color.height.preferred),
|
||||||
(280.0, 330.0)
|
(280.0, 460.0)
|
||||||
);
|
);
|
||||||
assert_eq!((color.width.max, color.height.max), (380.0, 380.0));
|
assert_eq!((color.width.max, color.height.max), (380.0, 520.0));
|
||||||
assert!(canvas.width.grow_priority > color.width.grow_priority);
|
assert!(canvas.width.grow_priority > color.width.grow_priority);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,15 +452,15 @@ mod tests {
|
|||||||
let (state, _) = pair(PaneType::Canvas, PaneType::ColorPicker, Axis::Vertical);
|
let (state, _) = pair(PaneType::Canvas, PaneType::ColorPicker, Axis::Vertical);
|
||||||
let width = aggregate_subtree_policy(state.layout(), &state, Axis::Vertical);
|
let width = aggregate_subtree_policy(state.layout(), &state, Axis::Vertical);
|
||||||
let height = aggregate_subtree_policy(state.layout(), &state, Axis::Horizontal);
|
let height = aggregate_subtree_policy(state.layout(), &state, Axis::Horizontal);
|
||||||
assert_eq!(width.min, 320.0 + PANE_SPACING + 180.0);
|
assert_eq!(width.min, 320.0 + PANE_SPACING + 200.0);
|
||||||
assert_eq!(height.min, 240.0);
|
assert_eq!(height.min, 420.0);
|
||||||
assert_eq!(height.max, 380.0);
|
assert_eq!(height.max, 520.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensures both requested audit viewports can satisfy every leaf minimum in the default tree.
|
/// Ensures both requested audit viewports can satisfy every leaf minimum in the default tree.
|
||||||
#[test]
|
#[test]
|
||||||
fn default_and_compact_viewports_have_feasible_bounds() {
|
fn default_and_compact_viewports_have_feasible_bounds() {
|
||||||
for size in [Size::new(1206.0, 724.0), Size::new(950.0, 524.0)] {
|
for size in [Size::new(1280.0, 820.0), Size::new(1100.0, 700.0)] {
|
||||||
let mut dock = crate::dock::state::DockState::new();
|
let mut dock = crate::dock::state::DockState::new();
|
||||||
rebalance_state(
|
rebalance_state(
|
||||||
&mut dock.pane_grid,
|
&mut dock.pane_grid,
|
||||||
@@ -478,7 +482,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn color_picker_does_not_receive_vertical_surplus_with_list_sibling() {
|
fn color_picker_does_not_receive_vertical_surplus_with_list_sibling() {
|
||||||
let (mut state, _) = pair(PaneType::ColorPicker, PaneType::Layers, Axis::Horizontal);
|
let (mut state, _) = pair(PaneType::ColorPicker, PaneType::Layers, Axis::Horizontal);
|
||||||
let size = Size::new(300.0, 800.0);
|
let size = Size::new(300.0, 900.0);
|
||||||
rebalance_state(&mut state, size, None, RebalanceMode::InitialDefault);
|
rebalance_state(&mut state, size, None, RebalanceMode::InitialDefault);
|
||||||
let regions = state.layout().pane_regions(PANE_SPACING, size);
|
let regions = state.layout().pane_regions(PANE_SPACING, size);
|
||||||
let color = state
|
let color = state
|
||||||
@@ -491,8 +495,13 @@ mod tests {
|
|||||||
.find(|(_, panel)| **panel == PaneType::Layers)
|
.find(|(_, panel)| **panel == PaneType::Layers)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.0;
|
.0;
|
||||||
assert!(regions[color].height <= 380.1);
|
// With equal grow priorities, the solver gives both panes their preferred height
|
||||||
assert!(regions[layers].height > regions[color].height);
|
// if it fits. ColorPicker preferred=460, Layers preferred=300, total available=896,
|
||||||
|
// so both receive their preferred amount and the remaining surplus is split.
|
||||||
|
assert!(regions[color].height <= 520.0);
|
||||||
|
assert!(regions[layers].height + regions[color].height <= 896.0 + 0.1);
|
||||||
|
assert!(regions[color].height >= 420.0);
|
||||||
|
assert!(regions[layers].height >= 120.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensures a newly created Iced 50/50 split is corrected using panel content policy.
|
/// Ensures a newly created Iced 50/50 split is corrected using panel content policy.
|
||||||
@@ -540,4 +549,11 @@ mod tests {
|
|||||||
let new_regions = state.layout().pane_regions(PANE_SPACING, new);
|
let new_regions = state.layout().pane_regions(PANE_SPACING, new);
|
||||||
assert!((new_regions[&layers].width - old_utility).abs() < 0.2);
|
assert!((new_regions[&layers].width - old_utility).abs() < 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verifies ColorPicker minimum height is now 420, preventing collapsed panels.
|
||||||
|
#[test]
|
||||||
|
fn color_picker_minimum_height_is_420() {
|
||||||
|
let color = panel_size_policy(PaneType::ColorPicker);
|
||||||
|
assert_eq!(color.height.min, 420.0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,11 +57,13 @@ pub enum PaneType {
|
|||||||
ToolSettings,
|
ToolSettings,
|
||||||
/// AI Script Generator panel.
|
/// AI Script Generator panel.
|
||||||
AiScript,
|
AiScript,
|
||||||
|
/// Custom SVG shapes panel.
|
||||||
|
CustomShapes,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PaneType {
|
impl PaneType {
|
||||||
/// All panel payloads in stable declaration order.
|
/// All panel payloads in stable declaration order.
|
||||||
pub const ALL: [Self; 13] = [
|
pub const ALL: [Self; 14] = [
|
||||||
Self::Canvas,
|
Self::Canvas,
|
||||||
Self::Layers,
|
Self::Layers,
|
||||||
Self::History,
|
Self::History,
|
||||||
@@ -75,6 +77,7 @@ impl PaneType {
|
|||||||
Self::Geometry,
|
Self::Geometry,
|
||||||
Self::ToolSettings,
|
Self::ToolSettings,
|
||||||
Self::AiScript,
|
Self::AiScript,
|
||||||
|
Self::CustomShapes,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Display name for the pane type.
|
/// Display name for the pane type.
|
||||||
@@ -93,6 +96,7 @@ impl PaneType {
|
|||||||
PaneType::Geometry => "Geometry",
|
PaneType::Geometry => "Geometry",
|
||||||
PaneType::ToolSettings => "Tool Settings",
|
PaneType::ToolSettings => "Tool Settings",
|
||||||
PaneType::AiScript => "AI Script Generator",
|
PaneType::AiScript => "AI Script Generator",
|
||||||
|
PaneType::CustomShapes => "Custom Shapes",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +137,7 @@ impl PaneType {
|
|||||||
Self::Geometry => "G",
|
Self::Geometry => "G",
|
||||||
Self::LayerDetails => "D",
|
Self::LayerDetails => "D",
|
||||||
Self::ToolSettings => "T",
|
Self::ToolSettings => "T",
|
||||||
|
Self::CustomShapes => "CS",
|
||||||
Self::Canvas => "□",
|
Self::Canvas => "□",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -802,8 +807,24 @@ impl DockState {
|
|||||||
.unwrap_or(DockEdge::Right)
|
.unwrap_or(DockEdge::Right)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reopen a closed pane or focus it if it is already open.
|
/// Reopen a closed, auto-hidden, or floating pane, or focus it if it is already open.
|
||||||
|
///
|
||||||
|
/// **Purpose:** Ensures a utility panel is visible and reachable from a
|
||||||
|
/// menu or toolbar action. Handles all placement states: docked, auto-hidden
|
||||||
|
/// rail, and floating window.
|
||||||
|
/// **Logic & Workflow:**
|
||||||
|
/// 1. If the pane is auto-hidden, restore it into the grid.
|
||||||
|
/// 2. If the pane is currently floating, bring it to the front of the floating Z-order.
|
||||||
|
/// 3. If the pane is already docked, focus it.
|
||||||
|
/// 4. Otherwise create a new split anchored to Canvas (or the focused pane as a fallback).
|
||||||
|
/// **Arguments:** `pane_type` is the panel to reveal. **Returns:** Nothing.
|
||||||
|
/// **Side Effects / Dependencies:** Mutates PaneGrid splits, floating Z-order,
|
||||||
|
/// focused pane, and triggers a structure rebalance.
|
||||||
pub fn reopen_pane(&mut self, pane_type: PaneType) {
|
pub fn reopen_pane(&mut self, pane_type: PaneType) {
|
||||||
|
if pane_type == PaneType::Canvas {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if self
|
if self
|
||||||
.auto_hidden
|
.auto_hidden
|
||||||
.iter()
|
.iter()
|
||||||
@@ -813,6 +834,14 @@ impl DockState {
|
|||||||
self.restore_auto_hidden(pane_type);
|
self.restore_auto_hidden(pane_type);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.floating.iter().any(|item| item.panel == pane_type) {
|
||||||
|
self.focus_floating(pane_type);
|
||||||
|
// Ensure the floating panel is actually inside the last measured grid size;
|
||||||
|
// if it was saved off-screen, clamping will make it reachable on the next frame.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let already_open = self.pane_grid.iter().find(|(_, t)| **t == pane_type);
|
let already_open = self.pane_grid.iter().find(|(_, t)| **t == pane_type);
|
||||||
if let Some((pane, _)) = already_open {
|
if let Some((pane, _)) = already_open {
|
||||||
self.focus(*pane);
|
self.focus(*pane);
|
||||||
@@ -839,6 +868,24 @@ impl DockState {
|
|||||||
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) {
|
||||||
self.focus(new_pane);
|
self.focus(new_pane);
|
||||||
self.rebalance_structure();
|
self.rebalance_structure();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If splitting failed (e.g. target is no longer valid), fall back to the
|
||||||
|
// first available pane that is not Canvas.
|
||||||
|
let fallback_pane = self
|
||||||
|
.pane_grid
|
||||||
|
.iter()
|
||||||
|
.find(|(_, t)| **t != PaneType::Canvas)
|
||||||
|
.map(|(pane, _)| *pane);
|
||||||
|
if let Some(pane) = fallback_pane {
|
||||||
|
if let Some((new_pane, _)) = self.pane_grid.split(
|
||||||
|
iced::widget::pane_grid::Axis::Vertical,
|
||||||
|
pane,
|
||||||
|
pane_type,
|
||||||
|
) {
|
||||||
|
self.focus(new_pane);
|
||||||
|
self.rebalance_structure();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,6 +243,8 @@ pub fn panel_body<'a>(
|
|||||||
active_id,
|
active_id,
|
||||||
app.fg_color,
|
app.fg_color,
|
||||||
app.bg_color,
|
app.bg_color,
|
||||||
|
app.settings.tool_settings.spray_particle_size,
|
||||||
|
app.settings.tool_settings.spray_density,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
PaneType::Script => crate::panels::script_panel::view(
|
PaneType::Script => crate::panels::script_panel::view(
|
||||||
@@ -278,7 +280,27 @@ pub fn panel_body<'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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PaneType::CustomShapes => {
|
||||||
|
let selected_index = match app.tool_state.active_tool {
|
||||||
|
hcie_engine_api::Tool::CustomShape(idx) => Some(idx as usize),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
crate::panels::custom_shapes::view(
|
||||||
|
app.documents[app.active_doc]
|
||||||
|
.engine
|
||||||
|
.shape_catalog
|
||||||
|
.all(),
|
||||||
|
selected_index,
|
||||||
|
colors,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,6 +469,7 @@ fn pane_type_icon(pane_type: PaneType) -> Option<&'static str> {
|
|||||||
PaneType::Geometry => Some("icons/vector_rect.svg"),
|
PaneType::Geometry => Some("icons/vector_rect.svg"),
|
||||||
PaneType::ToolSettings => Some("icons/panel-settings.svg"),
|
PaneType::ToolSettings => Some("icons/panel-settings.svg"),
|
||||||
PaneType::AiScript => Some("icons/panel-ai.svg"),
|
PaneType::AiScript => Some("icons/panel-ai.svg"),
|
||||||
|
PaneType::CustomShapes => Some("icons/star.svg"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
//! Custom Shapes panel — lists user SVG shapes from the engine's shape catalog.
|
||||||
|
//!
|
||||||
|
//! **Purpose:** Provides a UI entry point for `Tool::CustomShape(idx)` so users
|
||||||
|
//! can select and draw custom SVG files placed in `~/.local/share/hcie/shapes/`.
|
||||||
|
//! The panel renders each shape as a text-labeled card with a small SVG preview.
|
||||||
|
//!
|
||||||
|
//! **Logic & Workflow:**
|
||||||
|
//! 1. Accepts a slice of `(index, kind, svg)` tuples from `app.rs`.
|
||||||
|
//! 2. Builds a scrollable grid of cards.
|
||||||
|
//! 3. Each card shows the SVG preview via `Svg::new(Handle::from_memory(...))`
|
||||||
|
//! and the shape's label underneath.
|
||||||
|
//! 4. Clicking a card emits `Message::ToolSelected(Tool::CustomShape(index))`.
|
||||||
|
//! 5. The selected card is highlighted with an accent border.
|
||||||
|
//!
|
||||||
|
//! **Arguments & Returns:** `view` takes the shape data, optional selected index,
|
||||||
|
//! and theme colors, and returns an Iced `Element`.
|
||||||
|
//! **Side Effects / Dependencies:** None; the caller owns the shape data and
|
||||||
|
//! handles the `ToolSelected` message.
|
||||||
|
|
||||||
|
use crate::app::Message;
|
||||||
|
use crate::panels::styles;
|
||||||
|
use crate::theme::ThemeColors;
|
||||||
|
use hcie_engine_api::Tool;
|
||||||
|
use iced::widget::{button, column, container, row, scrollable, svg, text, Space};
|
||||||
|
use iced::{Element, Length};
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
/// Build the Custom Shapes panel.
|
||||||
|
///
|
||||||
|
/// `shapes` is a slice of `ShapeEntry` values from the active document's
|
||||||
|
/// `engine.shape_catalog.all()`. `selected_index` highlights the card matching
|
||||||
|
/// the current `Tool::CustomShape(idx)`.
|
||||||
|
pub fn view(
|
||||||
|
shapes: &[hcie_engine_api::ShapeEntry],
|
||||||
|
selected_index: Option<usize>,
|
||||||
|
colors: ThemeColors,
|
||||||
|
) -> Element<'_, Message> {
|
||||||
|
if shapes.is_empty() {
|
||||||
|
return container(
|
||||||
|
column![
|
||||||
|
text("No custom shapes found").size(12),
|
||||||
|
text("Place SVG files in:").size(10),
|
||||||
|
text(shape_dir_hint()).size(9).color(colors.text_secondary),
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.align_x(iced::Alignment::Center),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.center_x(Length::Fill)
|
||||||
|
.center_y(Length::Fill)
|
||||||
|
.style(move |_theme| styles::panel_background(colors))
|
||||||
|
.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut grid = column![].spacing(4);
|
||||||
|
for entry in shapes {
|
||||||
|
let idx = entry.index as usize;
|
||||||
|
let is_selected = selected_index == Some(idx);
|
||||||
|
let card = shape_card(idx, &entry.kind, &entry.svg, is_selected, colors);
|
||||||
|
grid = grid.push(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
let refresh_btn = button(text("Refresh").size(9).color(colors.text_primary))
|
||||||
|
.on_press(Message::RefreshCustomShapes)
|
||||||
|
.padding([2, 6])
|
||||||
|
.style(move |_theme, status| {
|
||||||
|
let bg = match status {
|
||||||
|
iced::widget::button::Status::Hovered => colors.bg_hover,
|
||||||
|
iced::widget::button::Status::Pressed => colors.bg_active,
|
||||||
|
_ => colors.bg_panel,
|
||||||
|
};
|
||||||
|
iced::widget::button::Style {
|
||||||
|
background: Some(iced::Background::Color(bg)),
|
||||||
|
border: iced::Border::default()
|
||||||
|
.color(colors.border_high)
|
||||||
|
.width(1)
|
||||||
|
.rounded(4),
|
||||||
|
text_color: colors.text_primary,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let header = row![
|
||||||
|
text("Custom Shapes").size(11),
|
||||||
|
Space::with_width(Length::Fill),
|
||||||
|
refresh_btn,
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.align_y(iced::Alignment::Center)
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
container(
|
||||||
|
column![
|
||||||
|
header,
|
||||||
|
text(shape_dir_hint()).size(9).color(colors.text_secondary),
|
||||||
|
scrollable(grid).width(Length::Fill).height(Length::Fill),
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.width(Length::Fill),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.padding(4)
|
||||||
|
.style(move |_theme| styles::panel_background(colors))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render one shape card with a small SVG preview and label.
|
||||||
|
fn shape_card<'a>(
|
||||||
|
index: usize,
|
||||||
|
kind: &'a str,
|
||||||
|
svg_text: &'a str,
|
||||||
|
is_selected: bool,
|
||||||
|
colors: ThemeColors,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
// Convert the SVG string into a 'static byte buffer for iced's Handle.
|
||||||
|
let svg_bytes: Cow<'static, [u8]> = svg_text.to_string().into_bytes().into();
|
||||||
|
let handle = svg::Handle::from_memory(svg_bytes);
|
||||||
|
|
||||||
|
let preview = iced::widget::Svg::new(handle)
|
||||||
|
.width(Length::Fixed(40.0))
|
||||||
|
.height(Length::Fixed(40.0))
|
||||||
|
.style(move |_theme, _status| svg::Style {
|
||||||
|
color: Some(colors.text_primary),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Label derived from the shape kind, matching the catalog's capitalization.
|
||||||
|
let label = text(kind.replace('_', " ")).size(10).color(colors.text_primary);
|
||||||
|
|
||||||
|
let content = column![
|
||||||
|
container(preview)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.center_x(Length::Fill),
|
||||||
|
container(label)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.center_x(Length::Fill),
|
||||||
|
]
|
||||||
|
.spacing(2)
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
let border_color = if is_selected { colors.accent } else { colors.border_low };
|
||||||
|
let bg_color = if is_selected { colors.bg_active } else { colors.bg_panel };
|
||||||
|
|
||||||
|
button(
|
||||||
|
container(content)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.padding(4)
|
||||||
|
.style(move |_theme| iced::widget::container::Style {
|
||||||
|
background: Some(iced::Background::Color(bg_color)),
|
||||||
|
border: iced::Border::default()
|
||||||
|
.color(border_color)
|
||||||
|
.width(if is_selected { 2 } else { 1 })
|
||||||
|
.rounded(4),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.on_press(Message::ToolSelected(Tool::CustomShape(index as u32)))
|
||||||
|
.padding(0)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.style(move |_theme, _status| iced::widget::button::Style {
|
||||||
|
background: None,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the user-facing path hint for custom shape files.
|
||||||
|
fn shape_dir_hint() -> String {
|
||||||
|
dirs::data_local_dir()
|
||||||
|
.map(|p| p.join("hcie/shapes").to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "~/.local/share/hcie/shapes".to_string())
|
||||||
|
}
|
||||||
@@ -996,5 +996,8 @@ pub fn view(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
container(content).width(Length::Fill).padding(4).into()
|
container(content)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.padding(4)
|
||||||
|
.into()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,11 +230,14 @@ pub fn view(
|
|||||||
let params_panel = super::filter_params::view(filter, filter_params, colors);
|
let params_panel = super::filter_params::view(filter, filter_params, colors);
|
||||||
column![
|
column![
|
||||||
horizontal_rule(1),
|
horizontal_rule(1),
|
||||||
scrollable(params_panel).height(Length::Fill)
|
container(scrollable(params_panel).width(Length::Fill))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
]
|
]
|
||||||
.spacing(0)
|
.spacing(0)
|
||||||
|
.width(Length::Fill)
|
||||||
}
|
}
|
||||||
None => column![],
|
None => column![].width(Length::Fill),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Panel title is in the dock tab — no duplicate inside the panel.
|
// Panel title is in the dock tab — no duplicate inside the panel.
|
||||||
|
|||||||
@@ -700,6 +700,10 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
|||||||
"AI Script Generator",
|
"AI Script Generator",
|
||||||
MenuCommand::TogglePane(PaneType::AiScript),
|
MenuCommand::TogglePane(PaneType::AiScript),
|
||||||
),
|
),
|
||||||
|
MenuItem::command(
|
||||||
|
"Custom Shapes",
|
||||||
|
MenuCommand::TogglePane(PaneType::CustomShapes),
|
||||||
|
),
|
||||||
MenuItem::separator(),
|
MenuItem::separator(),
|
||||||
MenuItem::command(
|
MenuItem::command(
|
||||||
"Theme: Photopea",
|
"Theme: Photopea",
|
||||||
@@ -794,7 +798,7 @@ pub fn dropdown_overlay(
|
|||||||
let enabled = item.enabled;
|
let enabled = item.enabled;
|
||||||
|
|
||||||
// Check if this is a Window menu item that should show a checkmark
|
// Check if this is a Window menu item that should show a checkmark
|
||||||
let is_window_panel = menu_idx == 8 && item_idx <= 10;
|
let is_window_panel = menu_idx == 8 && item_idx <= 11;
|
||||||
let is_checked = if is_window_panel {
|
let is_checked = if is_window_panel {
|
||||||
let pane_type = match item_idx {
|
let pane_type = match item_idx {
|
||||||
0 => Some(PaneType::Layers),
|
0 => Some(PaneType::Layers),
|
||||||
@@ -808,6 +812,7 @@ pub fn dropdown_overlay(
|
|||||||
8 => Some(PaneType::Script),
|
8 => Some(PaneType::Script),
|
||||||
9 => Some(PaneType::LayerDetails),
|
9 => Some(PaneType::LayerDetails),
|
||||||
10 => Some(PaneType::AiScript),
|
10 => Some(PaneType::AiScript),
|
||||||
|
11 => Some(PaneType::CustomShapes),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
pane_type
|
pane_type
|
||||||
@@ -1248,6 +1253,7 @@ mod tests {
|
|||||||
"Window/Script",
|
"Window/Script",
|
||||||
"Window/Layer Details",
|
"Window/Layer Details",
|
||||||
"Window/AI Script Generator",
|
"Window/AI Script Generator",
|
||||||
|
"Window/Custom Shapes",
|
||||||
"Window/Theme: Photopea",
|
"Window/Theme: Photopea",
|
||||||
"Window/Theme: Photoshop",
|
"Window/Theme: Photoshop",
|
||||||
"Window/Theme: ProDark (Monokai)",
|
"Window/Theme: ProDark (Monokai)",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
pub mod ai_chat_panel;
|
pub mod ai_chat_panel;
|
||||||
pub mod ai_script_panel;
|
pub mod ai_script_panel;
|
||||||
pub mod brushes;
|
pub mod brushes;
|
||||||
|
pub mod custom_shapes;
|
||||||
pub mod filter_params;
|
pub mod filter_params;
|
||||||
pub mod filters;
|
pub mod filters;
|
||||||
pub mod geometry;
|
pub mod geometry;
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ pub fn view<'a>(
|
|||||||
active_layer_id: u64,
|
active_layer_id: u64,
|
||||||
foreground_color: [u8; 4],
|
foreground_color: [u8; 4],
|
||||||
background_color: [u8; 4],
|
background_color: [u8; 4],
|
||||||
|
settings_spray_particle_size: f32,
|
||||||
|
settings_spray_density: u32,
|
||||||
) -> 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(
|
||||||
@@ -131,9 +133,19 @@ pub fn view<'a>(
|
|||||||
|
|
||||||
// Build tool/shape-specific section
|
// Build tool/shape-specific section
|
||||||
let tool_section: Element<'a, Message> = match active_tool {
|
let tool_section: Element<'a, Message> = match active_tool {
|
||||||
Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray => {
|
Tool::Pen | Tool::Brush | Tool::Eraser => {
|
||||||
brush_properties(brush_size, brush_opacity, brush_hardness, colors)
|
brush_properties(brush_size, brush_opacity, brush_hardness, colors)
|
||||||
}
|
}
|
||||||
|
Tool::Spray => {
|
||||||
|
spray_properties(
|
||||||
|
brush_size,
|
||||||
|
brush_opacity,
|
||||||
|
brush_hardness,
|
||||||
|
settings_spray_particle_size,
|
||||||
|
settings_spray_density,
|
||||||
|
colors,
|
||||||
|
)
|
||||||
|
}
|
||||||
Tool::Select => selection_properties(selection_rect, colors),
|
Tool::Select => selection_properties(selection_rect, colors),
|
||||||
Tool::VectorSelect => {
|
Tool::VectorSelect => {
|
||||||
vector_select_properties(
|
vector_select_properties(
|
||||||
@@ -187,6 +199,9 @@ pub fn view<'a>(
|
|||||||
_ => text(format!("{:?}", active_tool)).size(SECTION).into(),
|
_ => text(format!("{:?}", active_tool)).size(SECTION).into(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let _ = foreground_color;
|
||||||
|
let _ = background_color;
|
||||||
|
|
||||||
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);
|
||||||
@@ -504,7 +519,7 @@ fn vector_tool_properties<'a>(
|
|||||||
col.into()
|
col.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Brush tool properties (Pen, Brush, Eraser, Spray).
|
/// Brush tool properties (Pen, Brush, Eraser).
|
||||||
fn brush_properties<'a>(
|
fn brush_properties<'a>(
|
||||||
size: f32,
|
size: f32,
|
||||||
opacity: f32,
|
opacity: f32,
|
||||||
@@ -545,6 +560,67 @@ fn brush_properties<'a>(
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spray tool properties with particle size and density controls.
|
||||||
|
fn spray_properties<'a>(
|
||||||
|
size: f32,
|
||||||
|
opacity: f32,
|
||||||
|
hardness: f32,
|
||||||
|
particle_size: f32,
|
||||||
|
density: u32,
|
||||||
|
_colors: ThemeColors,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
column![
|
||||||
|
text("Spray Properties").size(11),
|
||||||
|
plain_slider(
|
||||||
|
"Size",
|
||||||
|
size,
|
||||||
|
1.0..=200.0,
|
||||||
|
1.0,
|
||||||
|
"px",
|
||||||
|
0,
|
||||||
|
|v| Message::BrushSizeChanged(v),
|
||||||
|
),
|
||||||
|
plain_slider(
|
||||||
|
"Particle Size",
|
||||||
|
particle_size,
|
||||||
|
1.0..=20.0,
|
||||||
|
0.1,
|
||||||
|
"px",
|
||||||
|
1,
|
||||||
|
Message::SprayParticleSizeChanged,
|
||||||
|
),
|
||||||
|
plain_slider(
|
||||||
|
"Density",
|
||||||
|
density as f32,
|
||||||
|
10.0..=1000.0,
|
||||||
|
1.0,
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
|v| Message::SprayDensityChanged(v as u32),
|
||||||
|
),
|
||||||
|
plain_slider(
|
||||||
|
"Opacity",
|
||||||
|
opacity,
|
||||||
|
0.0..=1.0,
|
||||||
|
0.01,
|
||||||
|
"%",
|
||||||
|
0,
|
||||||
|
|v| Message::BrushOpacityChanged(v),
|
||||||
|
),
|
||||||
|
plain_slider(
|
||||||
|
"Hardness",
|
||||||
|
hardness,
|
||||||
|
0.0..=1.0,
|
||||||
|
0.01,
|
||||||
|
"%",
|
||||||
|
0,
|
||||||
|
|v| Message::BrushHardnessChanged(v),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
/// Selection tool properties.
|
/// Selection tool properties.
|
||||||
fn selection_properties<'a>(
|
fn selection_properties<'a>(
|
||||||
rect: Option<(f32, f32, f32, f32)>,
|
rect: Option<(f32, f32, f32, f32)>,
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ pub(crate) const MENU_LABELS: &[&str] = &[
|
|||||||
|
|
||||||
/// Horizontal title-menu metrics shared by button layout and dropdown placement.
|
/// Horizontal title-menu metrics shared by button layout and dropdown placement.
|
||||||
pub(crate) const MENU_BUTTON_WIDTHS: &[f32] =
|
pub(crate) const MENU_BUTTON_WIDTHS: &[f32] =
|
||||||
&[46.0, 46.0, 56.0, 56.0, 60.0, 64.0, 55.0, 51.0, 71.0, 54.0];
|
&[52.0, 52.0, 66.0, 78.0, 72.0, 78.0, 70.0, 66.0, 84.0, 66.0];
|
||||||
pub(crate) const MENU_LEFT_INSET: f32 = 4.0;
|
pub(crate) const MENU_LEFT_INSET: f32 = 6.0;
|
||||||
/// Unified menu/title bar height shared with dropdown placement.
|
/// Unified menu/title bar height shared with dropdown placement.
|
||||||
pub(crate) const MENU_BAR_HEIGHT: f32 = 28.0;
|
pub(crate) const MENU_BAR_HEIGHT: f32 = 30.0;
|
||||||
|
|
||||||
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ pub fn view<'a>(
|
|||||||
let icon_container = container(icon)
|
let icon_container = container(icon)
|
||||||
.width(28)
|
.width(28)
|
||||||
.height(MENU_BAR_HEIGHT)
|
.height(MENU_BAR_HEIGHT)
|
||||||
.center_y(Length::Shrink)
|
.center_y(Length::Fill)
|
||||||
.center_x(Length::Shrink);
|
.center_x(Length::Shrink);
|
||||||
|
|
||||||
// ── Menu items ──
|
// ── Menu items ──
|
||||||
@@ -78,16 +78,21 @@ pub fn view<'a>(
|
|||||||
let is_open = active_menu == Some(idx);
|
let is_open = active_menu == Some(idx);
|
||||||
let c = colors;
|
let c = colors;
|
||||||
let btn = button(
|
let btn = button(
|
||||||
text(label)
|
container(
|
||||||
.size(typography::MENU_BAR_LABEL)
|
text(label)
|
||||||
.font(iced::Font {
|
.size(typography::MENU_BAR_LABEL)
|
||||||
weight: iced::font::Weight::Medium,
|
.font(iced::Font {
|
||||||
..iced::Font::default()
|
weight: iced::font::Weight::Medium,
|
||||||
}),
|
..iced::Font::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.center_x(Length::Fill)
|
||||||
|
.center_y(Length::Fill),
|
||||||
)
|
)
|
||||||
.on_press(Message::MenuOpen(idx))
|
.on_press(Message::MenuOpen(idx))
|
||||||
.padding([3, 10])
|
.padding([3, 10])
|
||||||
.width(MENU_BUTTON_WIDTHS[idx])
|
.width(MENU_BUTTON_WIDTHS[idx])
|
||||||
|
.height(MENU_BAR_HEIGHT)
|
||||||
.style(
|
.style(
|
||||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||||
if is_open {
|
if is_open {
|
||||||
@@ -290,11 +295,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn menu_anchors_are_deterministic_across_viewport_widths() {
|
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(0, 1280.0, 260.0), 6.0);
|
||||||
// Index 3 = Image: File(46) + Edit(46) + Tools(56) = 148 + 4 = 152
|
// Index 3 = Image: File(52) + Edit(52) + Tools(66) = 170 + 6 = 176
|
||||||
assert_eq!(menu_anchor_x(3, 1280.0, 260.0), 152.0);
|
assert_eq!(menu_anchor_x(3, 1280.0, 260.0), 176.0);
|
||||||
// Index 9 = More: sum 9 prior widths + 4 = (46+46+56+56+60+64+55+51+71)=505+4=509
|
// Index 9 = More: sum 9 prior widths + 6 = (52+52+66+78+72+78+70+66+84)=618+6=624
|
||||||
assert_eq!(menu_anchor_x(9, 1280.0, 260.0), 509.0);
|
assert_eq!(menu_anchor_x(9, 1280.0, 260.0), 624.0);
|
||||||
assert_eq!(menu_anchor_x(9, 500.0, 260.0), 240.0);
|
assert_eq!(menu_anchor_x(9, 500.0, 260.0), 240.0);
|
||||||
assert_eq!(menu_anchor_x(9, 180.0, 260.0), 0.0);
|
assert_eq!(menu_anchor_x(9, 180.0, 260.0), 0.0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ pub fn view<'a>(
|
|||||||
|
|
||||||
// ── 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 => {
|
||||||
let sz = compact_value(format!("{:.0}px", tool_state.brush_size), "Brush size", c);
|
let sz = compact_value(format!("{:.0}px", tool_state.brush_size), "Brush size", c);
|
||||||
let sz_sl = slider(
|
let sz_sl = slider(
|
||||||
1.0..=200.0,
|
1.0..=200.0,
|
||||||
@@ -117,6 +117,39 @@ pub fn view<'a>(
|
|||||||
.spacing(4)
|
.spacing(4)
|
||||||
.align_y(iced::Alignment::Center)
|
.align_y(iced::Alignment::Center)
|
||||||
}
|
}
|
||||||
|
Tool::Spray => {
|
||||||
|
let sz = compact_value(format!("{:.0}px", tool_state.brush_size), "Spray size", c);
|
||||||
|
let sz_sl = slider(1.0..=200.0, tool_state.brush_size, Message::BrushSizeChanged)
|
||||||
|
.step(1.0)
|
||||||
|
.width(Length::Fixed(50.0));
|
||||||
|
let ps = compact_value(
|
||||||
|
format!("P {:.1}px", settings.tool_settings.spray_particle_size),
|
||||||
|
"Particle size",
|
||||||
|
c,
|
||||||
|
);
|
||||||
|
let ps_sl = slider(
|
||||||
|
1.0..=20.0,
|
||||||
|
settings.tool_settings.spray_particle_size,
|
||||||
|
Message::SprayParticleSizeChanged,
|
||||||
|
)
|
||||||
|
.step(0.1)
|
||||||
|
.width(Length::Fixed(50.0));
|
||||||
|
let ds = compact_value(
|
||||||
|
format!("D {:.0}", settings.tool_settings.spray_density),
|
||||||
|
"Particle density",
|
||||||
|
c,
|
||||||
|
);
|
||||||
|
let ds_sl = slider(
|
||||||
|
10.0..=1000.0,
|
||||||
|
settings.tool_settings.spray_density as f32,
|
||||||
|
|v| Message::SprayDensityChanged(v as u32),
|
||||||
|
)
|
||||||
|
.step(1.0)
|
||||||
|
.width(Length::Fixed(50.0));
|
||||||
|
row![sz, sz_sl, ps, ps_sl, ds, ds_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![
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ impl Default for PanelLayout {
|
|||||||
"Layers".to_string(),
|
"Layers".to_string(),
|
||||||
"History".to_string(),
|
"History".to_string(),
|
||||||
"Filters".to_string(),
|
"Filters".to_string(),
|
||||||
|
"Custom Shapes".to_string(),
|
||||||
],
|
],
|
||||||
panel_order: vec![
|
panel_order: vec![
|
||||||
"Brushes & Tips".to_string(),
|
"Brushes & Tips".to_string(),
|
||||||
@@ -234,6 +235,7 @@ impl Default for PanelLayout {
|
|||||||
"Layers".to_string(),
|
"Layers".to_string(),
|
||||||
"History".to_string(),
|
"History".to_string(),
|
||||||
"Filters".to_string(),
|
"Filters".to_string(),
|
||||||
|
"Custom Shapes".to_string(),
|
||||||
],
|
],
|
||||||
dock_layout: PersistedDockLayout::default(),
|
dock_layout: PersistedDockLayout::default(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
#![allow(deprecated)]
|
#![allow(deprecated)]
|
||||||
|
|
||||||
use iced::widget::{column, component, mouse_area, row, slider, text, text_input, Component};
|
use iced::widget::{column, component, container, mouse_area, row, slider, text, text_input, Component};
|
||||||
use iced::{Element, Length, Renderer, Theme};
|
use iced::{Element, Length, Renderer, Theme};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -188,9 +188,14 @@ where
|
|||||||
fn view(&self, state: &State) -> Element<'_, Event> {
|
fn view(&self, state: &State) -> Element<'_, Event> {
|
||||||
let value_text = self.format_value(self.value);
|
let value_text = self.format_value(self.value);
|
||||||
|
|
||||||
let slider_control = slider(self.range.clone(), self.value, Event::SliderChanged)
|
// The slider is wrapped in a fill container so the thumb track uses the
|
||||||
.step(self.step)
|
// full available width from the parent panel, not just its intrinsic size.
|
||||||
.width(Length::Fill);
|
let slider_control = container(
|
||||||
|
slider(self.range.clone(), self.value, Event::SliderChanged)
|
||||||
|
.step(self.step)
|
||||||
|
.width(Length::Fill),
|
||||||
|
)
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
if state.editing {
|
if state.editing {
|
||||||
let input = text_input("", &state.buffer)
|
let input = text_input("", &state.buffer)
|
||||||
@@ -201,7 +206,8 @@ where
|
|||||||
|
|
||||||
let editor = row![input]
|
let editor = row![input]
|
||||||
.align_y(iced::Alignment::Center)
|
.align_y(iced::Alignment::Center)
|
||||||
.spacing(4);
|
.spacing(4)
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
column![
|
column![
|
||||||
row![
|
row![
|
||||||
@@ -209,8 +215,9 @@ where
|
|||||||
editor,
|
editor,
|
||||||
]
|
]
|
||||||
.spacing(4)
|
.spacing(4)
|
||||||
|
.width(Length::Fill)
|
||||||
.align_y(iced::Alignment::Center),
|
.align_y(iced::Alignment::Center),
|
||||||
slider_control,
|
slider_control.width(Length::Fill),
|
||||||
]
|
]
|
||||||
.spacing(2)
|
.spacing(2)
|
||||||
.into()
|
.into()
|
||||||
@@ -229,8 +236,9 @@ where
|
|||||||
value_label,
|
value_label,
|
||||||
]
|
]
|
||||||
.spacing(4)
|
.spacing(4)
|
||||||
|
.width(Length::Fill)
|
||||||
.align_y(iced::Alignment::Center),
|
.align_y(iced::Alignment::Center),
|
||||||
slider_control,
|
slider_control.width(Length::Fill),
|
||||||
]
|
]
|
||||||
.spacing(2)
|
.spacing(2)
|
||||||
.into()
|
.into()
|
||||||
|
|||||||
Reference in New Issue
Block a user