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:
2026-07-20 20:24:40 +03:00
parent 2fb47520b3
commit 3659b886ff
19 changed files with 1106 additions and 87 deletions
@@ -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
+171 -27
View File
@@ -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()