From 3659b886ff6378bf8c66ef0736972566a0a8d8d8 Mon Sep 17 00:00:00 2001 From: Halit Can Date: Mon, 20 Jul 2026 20:24:40 +0300 Subject: [PATCH] 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. --- ...84456713003-iced-gui-visual-polish-plan.md | 258 ++++++++++++++++++ .../plans/1784552687276-playful-panda.md | 115 ++++++++ hcie-iced-app/crates/hcie-iced-gui/src/app.rs | 198 ++++++++++++-- .../crates/hcie-iced-gui/src/color_picker.rs | 11 +- .../crates/hcie-iced-gui/src/dialogs/mod.rs | 88 ++++++ .../hcie-iced-gui/src/dialogs/new_image.rs | 42 +-- .../crates/hcie-iced-gui/src/dock/sizing.rs | 38 ++- .../crates/hcie-iced-gui/src/dock/state.rs | 51 +++- .../crates/hcie-iced-gui/src/dock/view.rs | 25 +- .../hcie-iced-gui/src/panels/custom_shapes.rs | 172 ++++++++++++ .../hcie-iced-gui/src/panels/filter_params.rs | 5 +- .../hcie-iced-gui/src/panels/filters.rs | 7 +- .../crates/hcie-iced-gui/src/panels/menus.rs | 8 +- .../crates/hcie-iced-gui/src/panels/mod.rs | 1 + .../hcie-iced-gui/src/panels/properties.rs | 80 +++++- .../hcie-iced-gui/src/panels/title_bar.rs | 35 ++- .../hcie-iced-gui/src/panels/toolbar.rs | 35 ++- .../crates/hcie-iced-gui/src/settings.rs | 2 + .../hcie-iced-gui/src/widgets/plain_slider.rs | 22 +- 19 files changed, 1106 insertions(+), 87 deletions(-) create mode 100644 .kilo/plans/1784456713003-iced-gui-visual-polish-plan.md create mode 100644 .mimocode/plans/1784552687276-playful-panda.md create mode 100644 hcie-iced-app/crates/hcie-iced-gui/src/panels/custom_shapes.rs diff --git a/.kilo/plans/1784456713003-iced-gui-visual-polish-plan.md b/.kilo/plans/1784456713003-iced-gui-visual-polish-plan.md new file mode 100644 index 0000000..e792e94 --- /dev/null +++ b/.kilo/plans/1784456713003-iced-gui-visual-polish-plan.md @@ -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 "`. +- 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`. + - 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.) diff --git a/.mimocode/plans/1784552687276-playful-panda.md b/.mimocode/plans/1784552687276-playful-panda.md new file mode 100644 index 0000000..17622b9 --- /dev/null +++ b/.mimocode/plans/1784552687276-playful-panda.md @@ -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 diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs index 3b162fd..194422c 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -82,22 +82,43 @@ fn colors_path() -> PathBuf { } /// 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 { - match std::fs::read_to_string(colors_path()) { - Ok(json) => serde_json::from_str(&json).unwrap_or_default(), - Err(_) => PersistedColors::default(), + let path = colors_path(); + match std::fs::read_to_string(&path) { + 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. 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 { fg: Some(fg), bg: Some(bg), recent: recent.to_vec(), }; 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, /// Layer-bound snapshot of vector shapes before editing (for exact Cancel restoration). pub vector_shapes_snapshot: Option, + /// 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). pub vector_drag_last: Option<(f32, f32)>, /// Timestamp of the last expensive vector rerasterization during a drag. @@ -598,6 +623,9 @@ pub enum Message { ResetColors, ColorTabChanged(usize), + // ── Custom shapes panel ───────────────────────────── + RefreshCustomShapes, + // ── Canvas interaction ────────────────────────────── CanvasPointerPressed { 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. fn filter_type_to_id(filter: FilterType) -> &'static str { match filter { @@ -1210,6 +1272,7 @@ fn pane_type_from_label(label: &str) -> Option { "Geometry" => Some(PaneType::Geometry), "Tool Settings" => Some(PaneType::ToolSettings), "AI Script Generator" => Some(PaneType::AiScript), + "Custom Shapes" => Some(PaneType::CustomShapes), _ => 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. +/// +/// 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 { matches!( tool, @@ -1246,6 +1313,7 @@ fn is_vector_shape_tool(tool: Tool) -> bool { | Tool::VectorCrescent | Tool::VectorBolt | Tool::VectorArrow4 + | Tool::CustomShape(_) ) } @@ -1527,7 +1595,7 @@ impl HcieIcedApp { show_welcome: false, active_doc: 0, tool_state: ToolState::default(), - fg_color: [220, 50, 50, 255], + fg_color: [0, 0, 0, 255], bg_color: [255, 255, 255, 255], last_cursor_pos: None, canvas_cursor_pos: None, @@ -1622,6 +1690,7 @@ impl HcieIcedApp { _language: i18n::Language::default(), colors_dirty: false, vector_shapes_snapshot: None, + cached_custom_shapes: Vec::new(), vector_drag_last: None, vector_drag_last_render: None, vector_drag_last_bounds: None, @@ -2285,6 +2354,16 @@ impl HcieIcedApp { if tool == Tool::Brush { 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 // sidebar shows the last-used tool's icon next time (egui's // slot_last_used behavior at tool_state.rs:278-286). @@ -2305,12 +2384,16 @@ impl HcieIcedApp { Message::ToolSlotClicked(slot_idx, primary_tool) => { self.active_tool_slot = slot_idx; let tool = crate::sidebar::last_used_tool(slot_idx, &self.slot_last_used); - self.tool_state.active_tool = - if crate::sidebar::tool_slots().get(slot_idx).is_some() { - tool - } else { - primary_tool - }; + let selected_tool = if crate::sidebar::tool_slots().get(slot_idx).is_some() { + tool + } else { + 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; } Message::SubtoolsToggle(slot_idx) => { @@ -2327,6 +2410,15 @@ impl HcieIcedApp { self.settings.panel_layout.sidebar_width = 72.0; 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 => { @@ -2351,6 +2443,7 @@ impl HcieIcedApp { Message::BgColorChanged(color) => { self.bg_color = color; + self.colors_dirty = true; crate::shape_sync::sync_shape_from_tool(self); self.refresh_composite_if_needed(); } @@ -2377,6 +2470,19 @@ impl HcieIcedApp { Message::ColorTabChanged(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) => { self.color_picker_target = Some(target); self.popup_color = color; @@ -2755,8 +2861,9 @@ impl HcieIcedApp { | Tool::VectorCross | Tool::VectorCrescent | Tool::VectorBolt - | Tool::VectorArrow4 => { - // Start vector shape drawing (all shape tools share one drag). + | Tool::VectorArrow4 + | Tool::CustomShape(_) => { + // Start vector shape drawing (built-in + custom SVG shapes share one drag). let sx = canvas_x; let sy = canvas_y; return Task::perform(async move {}, move |_| { @@ -4658,11 +4765,33 @@ impl HcieIcedApp { log::warn!("filter apply blocked for locked, group, or mask layer"); 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(); let filter_id = filter_type_to_id(filter); self.documents[self.active_doc] .engine .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.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); @@ -4893,6 +5022,15 @@ impl HcieIcedApp { Message::DialogNewImageCreate => { let w = self.dialog_new_width; 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 transparent = self.dialog_new_transparent; let mut engine = Engine::new_with_options(&name, w, h, transparent); @@ -5423,19 +5561,25 @@ impl HcieIcedApp { .engine .set_active_layer(safe_source); } - // Snapshot shape catalog entries for CustomShape tool - let custom_shapes: Vec<(String, String)> = self - .documents - .get(self.active_doc) - .map(|d| { - d.engine - .shape_catalog - .all() - .iter() - .map(|e| (e.kind.clone(), e.svg.clone())) - .collect() - }) - .unwrap_or_default(); + // Use cached catalog snapshot if available; otherwise read once from the + // active document's shape catalog. `cached_custom_shapes` is populated/updated + // by `RefreshCustomShapes` so drawing remains consistent with the panel. + let custom_shapes: Vec<(String, String)> = if self.cached_custom_shapes.is_empty() + { + self.documents + .get(self.active_doc) + .map(|d| { + d.engine + .shape_catalog + .all() + .iter() + .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 color = self.fg_color; @@ -8843,8 +8987,8 @@ impl HcieIcedApp { // 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). if self.colors_dirty { - self.colors_dirty = false; 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) diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs b/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs index 3daca7e..afea45a 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/color_picker.rs @@ -676,7 +676,10 @@ pub fn view<'a>( let swap_btn = button(text("\u{21C5}").size(11)) .on_press(Message::SwapColors) .padding([2, 4]); - let swatch_row = row![primary_swatch, secondary_swatch, swap_btn] + 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) .align_y(iced::Alignment::Center); @@ -778,11 +781,14 @@ pub fn view<'a>( 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) .width(13) .height(13) .center_x(Length::Fill) .center_y(Length::Fill) + .padding(2) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(iced::Color::from_rgba( 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), }); + // 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) .width(14) .height(14) .center_x(Length::Fill) .center_y(Length::Fill) + .padding(2) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(iced::Color::from_rgb( c[0] as f32 / 255.0, diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/mod.rs index f7ff5fc..90647c7 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/mod.rs @@ -79,6 +79,16 @@ fn darken(c: iced::Color, factor: f32) -> iced::Color { } /// 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( label: &str, msg: Message, @@ -108,6 +118,16 @@ pub fn primary_btn( } /// 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( label: &str, msg: Message, @@ -139,3 +159,71 @@ pub fn secondary_btn( }) .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() +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/new_image.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/new_image.rs index 8597c9f..7f4c444 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/new_image.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dialogs/new_image.rs @@ -3,9 +3,7 @@ use crate::app::Message; use crate::theme::ThemeColors; use crate::widgets::plain_slider::plain_slider; -use iced::widget::{ - button, checkbox, column, horizontal_rule, row, scrollable, text, text_input, -}; +use iced::widget::{checkbox, column, container, horizontal_rule, row, scrollable, text, text_input}; use iced::{Element, Length}; /// A canvas preset size. @@ -16,11 +14,6 @@ struct CanvasPreset { } const PRESETS: &[CanvasPreset] = &[ - CanvasPreset { - name: "Custom", - width: 0, - height: 0, - }, CanvasPreset { name: "HD (1280×720)", width: 1280, @@ -71,9 +64,13 @@ pub fn view( .on_input(|s| Message::DialogNewImageName(s)) .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( "Width", - width as f32, + clamped_width as f32, 1.0..=4096.0, 1.0, "px", @@ -83,7 +80,7 @@ pub fn view( let height_slider = plain_slider( "Height", - height as f32, + clamped_height as f32, 1.0..=4096.0, 1.0, "px", @@ -96,17 +93,30 @@ pub fn view( let mut presets_col = column![].spacing(2); for preset in PRESETS { - let label = text(preset.name).size(11); + let label = preset.name; let w = preset.width; let h = preset.height; - let preset_btn = button(label) - .on_press(Message::DialogNewImagePreset(w, h)) - .width(Length::Fill) - .padding([4, 8]); + let preset_btn = super::preset_btn(label, Message::DialogNewImagePreset(w, h), colors, true); 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 dialog = column![ diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/sizing.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/sizing.rs index 576c373..913e155 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/sizing.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/sizing.rs @@ -68,8 +68,8 @@ pub fn panel_size_policy(panel: PaneType) -> PaneSizePolicy { height: axis(240.0, 600.0, f32::INFINITY, 100), }, PaneType::ColorPicker => PaneSizePolicy { - width: axis(180.0, 280.0, 380.0, 1), - height: axis(180.0, 330.0, 380.0, 1), + width: axis(200.0, 280.0, 380.0, 1), + height: axis(420.0, 460.0, 520.0, 1), }, PaneType::Script | PaneType::AiChat | PaneType::AiScript => PaneSizePolicy { 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), 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_eq!( (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); } @@ -448,15 +452,15 @@ mod tests { let (state, _) = pair(PaneType::Canvas, PaneType::ColorPicker, Axis::Vertical); let width = aggregate_subtree_policy(state.layout(), &state, Axis::Vertical); let height = aggregate_subtree_policy(state.layout(), &state, Axis::Horizontal); - assert_eq!(width.min, 320.0 + PANE_SPACING + 180.0); - assert_eq!(height.min, 240.0); - assert_eq!(height.max, 380.0); + assert_eq!(width.min, 320.0 + PANE_SPACING + 200.0); + assert_eq!(height.min, 420.0); + assert_eq!(height.max, 520.0); } /// Ensures both requested audit viewports can satisfy every leaf minimum in the default tree. #[test] 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(); rebalance_state( &mut dock.pane_grid, @@ -478,7 +482,7 @@ mod tests { #[test] fn color_picker_does_not_receive_vertical_surplus_with_list_sibling() { 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); let regions = state.layout().pane_regions(PANE_SPACING, size); let color = state @@ -491,8 +495,13 @@ mod tests { .find(|(_, panel)| **panel == PaneType::Layers) .unwrap() .0; - assert!(regions[color].height <= 380.1); - assert!(regions[layers].height > regions[color].height); + // With equal grow priorities, the solver gives both panes their preferred 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. @@ -540,4 +549,11 @@ mod tests { let new_regions = state.layout().pane_regions(PANE_SPACING, new); 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); + } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs index 444fd73..64537d7 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/state.rs @@ -57,11 +57,13 @@ pub enum PaneType { ToolSettings, /// AI Script Generator panel. AiScript, + /// Custom SVG shapes panel. + CustomShapes, } impl PaneType { /// All panel payloads in stable declaration order. - pub const ALL: [Self; 13] = [ + pub const ALL: [Self; 14] = [ Self::Canvas, Self::Layers, Self::History, @@ -75,6 +77,7 @@ impl PaneType { Self::Geometry, Self::ToolSettings, Self::AiScript, + Self::CustomShapes, ]; /// Display name for the pane type. @@ -93,6 +96,7 @@ impl PaneType { PaneType::Geometry => "Geometry", PaneType::ToolSettings => "Tool Settings", PaneType::AiScript => "AI Script Generator", + PaneType::CustomShapes => "Custom Shapes", } } @@ -133,6 +137,7 @@ impl PaneType { Self::Geometry => "G", Self::LayerDetails => "D", Self::ToolSettings => "T", + Self::CustomShapes => "CS", Self::Canvas => "□", } } @@ -802,8 +807,24 @@ impl DockState { .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) { + if pane_type == PaneType::Canvas { + return; + } + if self .auto_hidden .iter() @@ -813,6 +834,14 @@ impl DockState { self.restore_auto_hidden(pane_type); 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); if let Some((pane, _)) = already_open { self.focus(*pane); @@ -839,6 +868,24 @@ impl DockState { if let Some((new_pane, _)) = self.pane_grid.split(axis, target, pane_type) { self.focus(new_pane); 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(); } } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs index e6879e7..5370cf5 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs @@ -243,6 +243,8 @@ pub fn panel_body<'a>( active_id, app.fg_color, app.bg_color, + app.settings.tool_settings.spray_particle_size, + app.settings.tool_settings.spray_density, ) } PaneType::Script => crate::panels::script_panel::view( @@ -278,7 +280,27 @@ pub fn panel_body<'a>( PaneType::ToolSettings => { let fg = app.fg_color; let draft = app.documents[app.active_doc].text_draft.as_ref(); - crate::panels::tool_settings::view(&app.tool_state, &app.settings, fg, draft, colors) + crate::panels::tool_settings::view( + &app.tool_state, + &app.settings, + fg, + draft, + colors, + ) + } + 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::ToolSettings => Some("icons/panel-settings.svg"), PaneType::AiScript => Some("icons/panel-ai.svg"), + PaneType::CustomShapes => Some("icons/star.svg"), } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/custom_shapes.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/custom_shapes.rs new file mode 100644 index 0000000..d2ab356 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/custom_shapes.rs @@ -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, + 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()) +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/filter_params.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/filter_params.rs index 2f815c0..88eaf80 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/filter_params.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/filter_params.rs @@ -996,5 +996,8 @@ pub fn view( } }; - container(content).width(Length::Fill).padding(4).into() + container(content) + .width(Length::Fill) + .padding(4) + .into() } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/filters.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/filters.rs index 8c33261..afea9c4 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/filters.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/filters.rs @@ -230,11 +230,14 @@ pub fn view( let params_panel = super::filter_params::view(filter, filter_params, colors); column![ horizontal_rule(1), - scrollable(params_panel).height(Length::Fill) + container(scrollable(params_panel).width(Length::Fill)) + .width(Length::Fill) + .height(Length::Fill) ] .spacing(0) + .width(Length::Fill) } - None => column![], + None => column![].width(Length::Fill), }; // Panel title is in the dock tab — no duplicate inside the panel. diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs index fe0bd21..f5d3585 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs @@ -700,6 +700,10 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { "AI Script Generator", MenuCommand::TogglePane(PaneType::AiScript), ), + MenuItem::command( + "Custom Shapes", + MenuCommand::TogglePane(PaneType::CustomShapes), + ), MenuItem::separator(), MenuItem::command( "Theme: Photopea", @@ -794,7 +798,7 @@ pub fn dropdown_overlay( let enabled = item.enabled; // 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 pane_type = match item_idx { 0 => Some(PaneType::Layers), @@ -808,6 +812,7 @@ pub fn dropdown_overlay( 8 => Some(PaneType::Script), 9 => Some(PaneType::LayerDetails), 10 => Some(PaneType::AiScript), + 11 => Some(PaneType::CustomShapes), _ => None, }; pane_type @@ -1248,6 +1253,7 @@ mod tests { "Window/Script", "Window/Layer Details", "Window/AI Script Generator", + "Window/Custom Shapes", "Window/Theme: Photopea", "Window/Theme: Photoshop", "Window/Theme: ProDark (Monokai)", diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/mod.rs index f6fe423..29d40d3 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/mod.rs @@ -3,6 +3,7 @@ pub mod ai_chat_panel; pub mod ai_script_panel; pub mod brushes; +pub mod custom_shapes; pub mod filter_params; pub mod filters; pub mod geometry; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs index 9c3c03b..b79ac93 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs @@ -115,6 +115,8 @@ pub fn view<'a>( active_layer_id: u64, foreground_color: [u8; 4], background_color: [u8; 4], + settings_spray_particle_size: f32, + settings_spray_density: u32, ) -> Element<'a, Message> { // Build layer info section at the top let layer_section = layer_info_section( @@ -131,9 +133,19 @@ pub fn view<'a>( // Build tool/shape-specific section 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) } + 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::VectorSelect => { vector_select_properties( @@ -187,6 +199,9 @@ pub fn view<'a>( _ => text(format!("{:?}", active_tool)).size(SECTION).into(), }; + let _ = foreground_color; + let _ = background_color; + let panel = column![layer_section, horizontal_rule(1), tool_section,] .spacing(4) .padding(4); @@ -504,7 +519,7 @@ fn vector_tool_properties<'a>( col.into() } -/// Brush tool properties (Pen, Brush, Eraser, Spray). +/// Brush tool properties (Pen, Brush, Eraser). fn brush_properties<'a>( size: f32, opacity: f32, @@ -545,6 +560,67 @@ fn brush_properties<'a>( .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. fn selection_properties<'a>( rect: Option<(f32, f32, f32, f32)>, diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/title_bar.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/title_bar.rs index d9b75d0..a1b906c 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/title_bar.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/title_bar.rs @@ -22,10 +22,10 @@ pub(crate) const MENU_LABELS: &[&str] = &[ /// Horizontal title-menu metrics shared by button layout and dropdown placement. 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]; -pub(crate) const MENU_LEFT_INSET: f32 = 4.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 = 6.0; /// 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"); @@ -69,7 +69,7 @@ pub fn view<'a>( let icon_container = container(icon) .width(28) .height(MENU_BAR_HEIGHT) - .center_y(Length::Shrink) + .center_y(Length::Fill) .center_x(Length::Shrink); // ── Menu items ── @@ -78,16 +78,21 @@ pub fn view<'a>( let is_open = active_menu == Some(idx); let c = colors; let btn = button( - text(label) - .size(typography::MENU_BAR_LABEL) - .font(iced::Font { - weight: iced::font::Weight::Medium, - ..iced::Font::default() - }), + container( + text(label) + .size(typography::MENU_BAR_LABEL) + .font(iced::Font { + weight: iced::font::Weight::Medium, + ..iced::Font::default() + }), + ) + .center_x(Length::Fill) + .center_y(Length::Fill), ) .on_press(Message::MenuOpen(idx)) .padding([3, 10]) .width(MENU_BUTTON_WIDTHS[idx]) + .height(MENU_BAR_HEIGHT) .style( move |_theme: &iced::Theme, status: iced::widget::button::Status| { if is_open { @@ -290,11 +295,11 @@ mod tests { #[test] fn menu_anchors_are_deterministic_across_viewport_widths() { - assert_eq!(menu_anchor_x(0, 1280.0, 260.0), 4.0); - // Index 3 = Image: File(46) + Edit(46) + Tools(56) = 148 + 4 = 152 - assert_eq!(menu_anchor_x(3, 1280.0, 260.0), 152.0); - // Index 9 = More: sum 9 prior widths + 4 = (46+46+56+56+60+64+55+51+71)=505+4=509 - assert_eq!(menu_anchor_x(9, 1280.0, 260.0), 509.0); + assert_eq!(menu_anchor_x(0, 1280.0, 260.0), 6.0); + // Index 3 = Image: File(52) + Edit(52) + Tools(66) = 170 + 6 = 176 + assert_eq!(menu_anchor_x(3, 1280.0, 260.0), 176.0); + // 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), 624.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); } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs index 0cb09c2..7fd1328 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs @@ -92,7 +92,7 @@ pub fn view<'a>( // ── Tool-specific options (right side) ── 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_sl = slider( 1.0..=200.0, @@ -117,6 +117,39 @@ pub fn view<'a>( .spacing(4) .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 => { let tol = settings.tool_settings.magic_wand_tolerance; row![ diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs b/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs index 6bf7d54..0728630 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs @@ -227,6 +227,7 @@ impl Default for PanelLayout { "Layers".to_string(), "History".to_string(), "Filters".to_string(), + "Custom Shapes".to_string(), ], panel_order: vec![ "Brushes & Tips".to_string(), @@ -234,6 +235,7 @@ impl Default for PanelLayout { "Layers".to_string(), "History".to_string(), "Filters".to_string(), + "Custom Shapes".to_string(), ], dock_layout: PersistedDockLayout::default(), } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/widgets/plain_slider.rs b/hcie-iced-app/crates/hcie-iced-gui/src/widgets/plain_slider.rs index fe43806..b35fc1d 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/widgets/plain_slider.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/widgets/plain_slider.rs @@ -9,7 +9,7 @@ #![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 std::sync::Arc; @@ -188,9 +188,14 @@ where fn view(&self, state: &State) -> Element<'_, Event> { let value_text = self.format_value(self.value); - let slider_control = slider(self.range.clone(), self.value, Event::SliderChanged) - .step(self.step) - .width(Length::Fill); + // The slider is wrapped in a fill container so the thumb track uses the + // full available width from the parent panel, not just its intrinsic size. + let slider_control = container( + slider(self.range.clone(), self.value, Event::SliderChanged) + .step(self.step) + .width(Length::Fill), + ) + .width(Length::Fill); if state.editing { let input = text_input("", &state.buffer) @@ -201,7 +206,8 @@ where let editor = row![input] .align_y(iced::Alignment::Center) - .spacing(4); + .spacing(4) + .width(Length::Fill); column![ row![ @@ -209,8 +215,9 @@ where editor, ] .spacing(4) + .width(Length::Fill) .align_y(iced::Alignment::Center), - slider_control, + slider_control.width(Length::Fill), ] .spacing(2) .into() @@ -229,8 +236,9 @@ where value_label, ] .spacing(4) + .width(Length::Fill) .align_y(iced::Alignment::Center), - slider_control, + slider_control.width(Length::Fill), ] .spacing(2) .into()