Refactor and enhance the Iced GUI with new features and improvements
- Improved layout and styling in filter_params.rs for better visual consistency. - Enhanced filters.rs to ensure scrollable parameters fill the available width. - Added a new Custom Shapes panel to display user-defined SVG shapes. - Updated menus.rs to include a new Custom Shapes menu item and adjusted related logic. - Modified properties.rs to support additional spray tool properties (particle size and density). - Adjusted title_bar.rs for improved menu button dimensions and layout. - Enhanced toolbar.rs to include spray tool options in the toolbar. - Fixed color persistence issues in settings.rs to ensure colors are saved and loaded correctly. - Updated plain_slider.rs to ensure sliders utilize the full width of their containers. - Created a visual polish plan to address various UI/UX issues and improve overall usability.
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
# ICED GUI Visual & Behavioral Polish Plan
|
||||
|
||||
## Context
|
||||
|
||||
This plan addresses the visual and functional issues surfaced during recent ICED GUI testing. The focus is on the Iced build (`hcie-iced-app/crates/hcie-iced-gui/`). Items are grouped by theme and ordered to minimize code conflicts.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### 1. Fix theme-inconsistent blue accent buttons
|
||||
|
||||
**Problem:** Dialogs like `New Image` show preset buttons as solid blue (`#3355cc`), which clashes with the dark theme and looks unnatural.
|
||||
|
||||
**Files:**
|
||||
- `hcie-iced-app/crates/hcie-iced-gui/src/dialogs/new_image.rs`
|
||||
- `hcie-iced-app/crates/hcie-iced-gui/src/dialogs/mod.rs` (primary/secondary helpers)
|
||||
|
||||
**Changes:**
|
||||
- Add a `preset_btn` helper in `dialogs/mod.rs` that uses theme colors (`bg_hover`/`accent` border/text) rather than the default blue.
|
||||
- Use it for all preset rows.
|
||||
- Ensure primary button (Create) remains theme-accent but with hover/pressed states.
|
||||
|
||||
---
|
||||
|
||||
### 2. Fix color-picker persistence / session recall
|
||||
|
||||
**Problem:** The user reports that foreground/background colors are identical on startup and recent colors are not added/persisted.
|
||||
|
||||
**Observed code:**
|
||||
- `load_colors()` is called in `app.rs:1719` and applied.
|
||||
- `add_recent_color()` exists and is called by `FgColorChanged`/`SwapColors`.
|
||||
- `save_colors()` is called when `colors_dirty` is true.
|
||||
|
||||
**Likely issues:**
|
||||
- `load_colors` returns `PersistedColors` with `#[serde(default)]`, but if any field is absent it becomes `Default`, which is `None`/`empty`. This is fine.
|
||||
- **Root cause candidates:**
|
||||
- `Message::ResetColors` exists, but there may be no UI button mapped to it in the color picker panel.
|
||||
- The saved colors file may not be written because `colors_dirty` is only set on `FgColorChanged`/`BgColorChanged`/`SwapColors`/`ResetColors`. Some popup color changes may not route through those messages.
|
||||
- When the app starts, `fg_color` and `bg_color` are initialized to the same default (`[0,0,0,255]`) before `load_colors()` runs, and then `load_colors` overrides them. This should be correct, but verify.
|
||||
|
||||
**Changes:**
|
||||
- Ensure `PopupColorChanged` routed to `Foreground`/`Background` actually triggers `add_recent_color` and sets `colors_dirty`. It does via `FgColorChanged`/`BgColorChanged`.
|
||||
- Add a **Reset** button in the color picker panel (`color_picker.rs`) that emits `Message::ResetColors`.
|
||||
- Verify `load_colors()` correctly falls back when file is missing; add debug log if parsing fails.
|
||||
- Ensure `save_colors()` stores alpha and recent list (already does).
|
||||
- Remove or explain why `fg_color`/`bg_color` default to the same value in `IcedDocument` / `HcieIcedApp::new` before loading.
|
||||
|
||||
---
|
||||
|
||||
### 3. Right-click to pick secondary color in color palette panel
|
||||
|
||||
**Problem:** Right-click on palette/recent colors should set the background color.
|
||||
|
||||
**Observed code:**
|
||||
- `color_picker.rs` already has `.on_right_press(Message::BgColorChanged(...))` on palette grid cells and recent colors.
|
||||
- However, the wheel canvas only handles right-click inside `ColorWheel::update` and sends `Message::BgColorChanged(color)`.
|
||||
|
||||
**Changes:**
|
||||
- Verify right-click events are not swallowed by the panel/container.
|
||||
- If Iced does not deliver right-clicks on `mouse_area` children reliably, wrap the whole color picker content in a `mouse_area` with `on_right_press` to set background color from current foreground? No — user expects picking the swatch under cursor.
|
||||
- Prefer: keep per-swatch right-click handlers and add a test or visual verification.
|
||||
- If still broken, investigate whether `mouse_area` in Iced 0.13 requires an explicit background hit; add a transparent container around each swatch.
|
||||
|
||||
---
|
||||
|
||||
### 4. Enforce color palette panel minimum height so content stays visible
|
||||
|
||||
**Problem:** Color Palette panel can be collapsed too short; its contents become hidden.
|
||||
|
||||
**File:** `hcie-iced-app/crates/hcie-iced-gui/src/dock/sizing.rs`
|
||||
|
||||
**Change:**
|
||||
- Raise `PaneType::ColorPicker` minimum height from `300.0` to `420.0`.
|
||||
- Consider also raising `min` width from `180.0` to `200.0`.
|
||||
- Ensure the color picker `view` uses `scrollable(content)` so overflow remains usable.
|
||||
|
||||
---
|
||||
|
||||
### 5. Filters panel: parameter sliders should fill ~80% of panel width
|
||||
|
||||
**Problem:** Filter parameter sliders are too narrow and leave large unused space.
|
||||
|
||||
**File:** `hcie-iced-app/crates/hcie-iced-gui/src/panels/filter_params.rs`
|
||||
|
||||
**Changes:**
|
||||
- Audit all `float_slider` / `int_slider` calls and ensure the underlying `plain_slider` width is `Length::Fill` inside a container with `width(Length::Fill)`.
|
||||
- In `filters.rs`, wrap the parameter section in a container with `width(Length::Fill)` and ensure `scrollable(params_panel)` does not clamp width.
|
||||
- Add `width(Length::Fill)` to the `column!` returned by `filter_params::view` if not already present.
|
||||
|
||||
---
|
||||
|
||||
### 6. Filter operations should integrate with history / undo
|
||||
|
||||
**Problem:** Filter Apply is not undoable via the History panel.
|
||||
|
||||
**File:** `hcie-iced-app/crates/hcie-iced-gui/src/app.rs`
|
||||
|
||||
**Change:**
|
||||
- Before `engine.apply_filter(...)` in `Message::FilterApply`, call `engine.push_active_layer_snapshot(...)` (or the appropriate engine history API) with a description like `"Apply <filter_name>"`.
|
||||
- After apply, refresh history cache (`refresh_history`) so the new entry appears in the History panel.
|
||||
- Ensure undo (`Message::HistoryJump`) and redo paths still work after the snapshot is pushed.
|
||||
|
||||
---
|
||||
|
||||
### 7. Brush button should reveal Brushes panel if closed
|
||||
|
||||
**Problem:** User says brush button does not open the Brushes panel.
|
||||
|
||||
**Observed code:** `Message::ToolSelected(Tool::Brush)` already calls `self.dock.reopen_pane(PaneType::Brushes)` at `app.rs:2289`.
|
||||
|
||||
**Possible issue:** `reopen_pane` may silently fail when the pane is auto-hidden or floating; or the brush tool can also be selected via slot click without this path.
|
||||
|
||||
**Changes:**
|
||||
- Verify `ToolSlotClicked`/`SubtoolsToggle` also triggers `reopen_pane(PaneType::Brushes)` when the resulting tool is `Tool::Brush`.
|
||||
- Add a helper `ensure_brushes_panel()` and call it wherever the active tool becomes `Tool::Brush`.
|
||||
- Test with panel closed, auto-hidden, and in a different tab group.
|
||||
|
||||
---
|
||||
|
||||
### 8. Selecting a filter from the Filters menu should reveal the Filters panel
|
||||
|
||||
**Problem:** Filter menu selection should open/raise the Filters panel.
|
||||
|
||||
**Observed code:** `Message::FilterSelect(filter)` already calls `self.dock.reopen_pane(PaneType::Filters)` at `app.rs:4628`.
|
||||
|
||||
**Possible issue:** Same as above — `reopen_pane` may not handle auto-hide/floating states.
|
||||
|
||||
**Changes:**
|
||||
- Audit `DockState::reopen_pane` to ensure it restores auto-hidden panels, focuses existing floating panels, and brings the pane to front if stacked.
|
||||
- If `reopen_pane` is already correct, add visual verification; otherwise fix it.
|
||||
|
||||
---
|
||||
|
||||
### 9. Menu bar vertical padding (+2 px already planned, verify in final screenshot)
|
||||
|
||||
**File:** `hcie-iced-app/crates/hcie-iced-gui/src/panels/title_bar.rs`
|
||||
|
||||
**Already changed:** `MENU_BAR_HEIGHT` 28→30 and menu button widths increased.
|
||||
|
||||
**Remaining:** Confirm no clipping occurs with the new widths; adjust further if "Image" still truncates.
|
||||
|
||||
---
|
||||
|
||||
### 10. Prevent zero-size document creation (wgpu crash)
|
||||
|
||||
**Problem:** The New Image dialog allows width or height to be set to 0. Creating a 0×N document causes the renderer to request a texture with zero height (`resize_texture 800×600 → 4096×0`), which triggers a wgpu validation error and panic.
|
||||
|
||||
**Files:**
|
||||
- `hcie-iced-app/crates/hcie-iced-gui/src/dialogs/new_image.rs`
|
||||
- `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` (`DialogNewImageCreate` handler)
|
||||
|
||||
**Changes:**
|
||||
- Clamp the width/height sliders to a minimum of `1` in `new_image.rs`.
|
||||
- In `DialogNewImageCreate`, validate `dialog_new_width >= 1 && dialog_new_height >= 1` before creating the engine; if invalid, keep the dialog open and log a warning (or show inline validation text).
|
||||
- Optionally disable the Create button when either dimension is zero.
|
||||
- Remove or ignore the `Custom` preset with `width: 0, height: 0` since it is not a valid document size.
|
||||
|
||||
---
|
||||
|
||||
### 11. Expose spray particle size / density in the UI
|
||||
|
||||
**Problem:** Spray tool radius/density settings are present in `tool_settings.rs` but not visible in the merged toolbar or Properties panel.
|
||||
|
||||
**Observed code:**
|
||||
- `tool_settings.rs:132-179` already has `spray_settings()` with Particle Size and Density sliders.
|
||||
- `properties.rs:134` groups `Tool::Spray` with brush properties, which only shows Size/Opacity/Hardness.
|
||||
- `toolbar.rs:95` also groups Spray with Brush and only shows Size/Hardness.
|
||||
|
||||
**Changes:**
|
||||
- In `properties.rs`, when `active_tool == Tool::Spray`, call a new `spray_properties(...)` helper that includes Size, Particle Size, Density, Opacity, and Hardness.
|
||||
- In `toolbar.rs`, add a Spray-specific branch that shows Size, Particle Size, and Density (or move the existing spray controls from the Tool Settings panel into the merged toolbar if that is the intended UX).
|
||||
- Ensure the values are read from `settings.tool_settings.spray_particle_size` / `spray_density` and emit the existing `Message::SprayParticleSizeChanged` / `SprayDensityChanged` messages.
|
||||
|
||||
---
|
||||
|
||||
### 12. Add a custom-shape slot / panel so user SVGs under `~/.local/share/hcie/shapes/` are usable
|
||||
|
||||
**Problem:** The user copied extra SVG files into `/home/dev-user/.local/share/hcie/shapes/`, but they did not appear in the UI and could not be used.
|
||||
|
||||
**Observed code:**
|
||||
- `Engine::new()` initializes `shape_catalog` from `dirs::data_local_dir()/hcie/shapes` (matches `~/.local/share/hcie/shapes`) in `hcie-engine-api/src/lib.rs:558-562`.
|
||||
- `ShapeCatalog::refresh()` in `hcie-engine-api/src/shape_catalog.rs` scans `*.svg` files there and assigns them indices.
|
||||
- `Tool::CustomShape(idx)` exists and is handled in `app.rs:5945-5966`, using a snapshot of `shape_catalog.all()` during `VectorDrawEnd`.
|
||||
- `sidebar/mod.rs` `TOOL_SLOTS` does **not** contain a slot for `Tool::CustomShape`. The vector-shapes slot only lists built-in `VectorArrow`, `VectorStar`, etc.
|
||||
- There is no panel or popup listing the catalog entries for selection.
|
||||
|
||||
**Root cause:** The ICED sidebar has no entry point for `Tool::CustomShape`, so even though the engine loads custom SVGs from `~/.local/share/hcie/shapes/`, the user cannot select or draw them.
|
||||
|
||||
**Decision:** New dock panel with real SVG previews via `iced::widget::svg::Handle::from_memory`.
|
||||
|
||||
**Changes:**
|
||||
- Add `PaneType::CustomShapes` to `dock/state.rs` with label "Custom Shapes".
|
||||
- Create `hcie-iced-app/crates/hcie-iced-gui/src/panels/custom_shapes.rs`.
|
||||
- Accept `shapes: &[ShapeEntry]` and `selected_index: Option<usize>`.
|
||||
- Render each entry as a small card (e.g. 56×56 preview + label below).
|
||||
- Use `iced::widget::Svg::new(Handle::from_memory(entry.svg.into_bytes()))` for the preview, sized to fit the card.
|
||||
- On click, emit `Message::ToolSelected(Tool::CustomShape(index))`.
|
||||
- Highlight the selected card with `colors.accent` border.
|
||||
- Wire the new panel in `dock/view.rs`.
|
||||
- Add the panel to `settings.rs` default `visible_panels` / `panel_order` so it appears in the initial dock layout.
|
||||
- Add a "Refresh" button in the panel that emits `Message::RefreshCustomShapes`; handler calls `engine.shape_catalog.refresh()` and updates `app.cached_custom_shapes`.
|
||||
- Store a cached `Vec<(String, String)>` of custom shapes in `HcieIcedApp` so the panel can be rebuilt cheaply; refresh it whenever the panel is shown or when Refresh is pressed.
|
||||
- Ensure `Tool::CustomShape(idx)` out-of-range cases return `None` in `VectorDrawEnd` (already mostly true; add explicit guard).
|
||||
- Verify that copied SVGs are valid — `ShapeCatalog` silently skips unreadable / non-SVG files.
|
||||
|
||||
### 12b. Custom Shape drawing not triggering on canvas
|
||||
|
||||
**Problem:** Selecting a custom shape in the Custom Shapes panel does not actually draw it on the canvas when dragging.
|
||||
|
||||
**Root cause:** The shared vector-shape drag flow is gated by `is_vector_shape_tool()` and an explicit match arm in the `CanvasPointerPressed` handler. Both locations enumerate the built-in `Tool::Vector*` variants but omit `Tool::CustomShape(_)`, so the custom-shape tool never enters `VectorDrawStart`/`VectorDrawMove`/`VectorDrawEnd` and therefore never reaches the existing `VectorDrawEnd` handler that already knows how to build a `VectorShape::SvgShape` from the catalog.
|
||||
|
||||
**Files:**
|
||||
- `hcie-iced-app/crates/hcie-iced-gui/src/app.rs`
|
||||
- `hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs` (icon support already present; no change)
|
||||
|
||||
**Changes:**
|
||||
1. In `app.rs` helper `is_vector_shape_tool()` (around line 1287), add `Tool::CustomShape(_)` to the `matches!` pattern.
|
||||
2. In `app.rs` `CanvasPointerPressed` handler (around line 2824), add `Tool::CustomShape(_)` to the arm that starts `Message::VectorDrawStart`.
|
||||
3. Keep `VectorDrawEnd` unchanged; it already snapshots `shape_catalog.all()` (line 5528-5539) and builds a `VectorShape::SvgShape` for `Tool::CustomShape(idx)` with correct bounds.
|
||||
|
||||
**Edge cases to consider:**
|
||||
- Out-of-range `Tool::CustomShape(idx)` already returns `None` from `VectorDrawEnd` and produces no shape.
|
||||
- If the active layer is a locked vector layer, the shape-flow first switches to a safe non-vector layer before drawing, just like built-in vector shapes.
|
||||
|
||||
**Validation:**
|
||||
1. Build: `cargo check -p hcie-iced-gui` and `cargo test -p hcie-iced-gui`.
|
||||
2. Run the Iced GUI.
|
||||
3. Window → Custom Shapes → select a shape.
|
||||
4. Drag on the canvas → a new vector layer containing the custom SVG shape appears.
|
||||
5. Undo reverts the inserted shape (history snapshot is pushed by `engine.add_vector_shape`).
|
||||
|
||||
---
|
||||
|
||||
## Validation Plan
|
||||
|
||||
1. `cargo check -p hcie-iced-gui`
|
||||
2. `cargo test -p hcie-iced-gui`
|
||||
3. Manual launch and visual checks:
|
||||
- New Image dialog presets look theme-aware (not bright blue).
|
||||
- Set width or height to 0 → Create button disabled; preset ignores 0-size values.
|
||||
- Create documents with valid sizes → no wgpu panic.
|
||||
- Spray tool selected → Particle Size / Density sliders visible in Tool Settings panel.
|
||||
- Pick colors, close app, reopen — colors and recent list restored.
|
||||
- Right-click a palette swatch sets background color.
|
||||
- Color Palette panel cannot be resized below content height.
|
||||
- Select Brush tool → Brushes panel appears.
|
||||
- Filter menu → Filters panel appears and parameter sliders are wide.
|
||||
- Apply a filter → History panel gains an "Apply …" entry; Undo reverts it.
|
||||
- Custom SVG files in `~/.local/share/hcie/shapes/` appear in the new Custom Shapes panel and can be drawn.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Is there a reference image or exact hex for the desired preset button style? (Current theme tokens: `bg_hover`, `accent`, `text_primary`.)
|
||||
- Should the New Image dialog enforce a single minimum (e.g. 1×1) or a practical minimum (e.g. 8×8) to avoid 1-pixel-high canvases?
|
||||
- Should custom shapes be rendered as small SVG previews in the panel, or is a text label list acceptable as a first pass? (Recommended: text list first, SVG preview later.)
|
||||
@@ -0,0 +1,115 @@
|
||||
# Color Palette Panel — Persistence & Layout Fixes
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The iced GUI color palette panel has several issues:
|
||||
1. **Colors don't persist between sessions** — bg color changes are never saved (bug in `BgColorChanged` handler), and save failures are silently ignored
|
||||
2. **Reset button** should return to black/white (already works correctly)
|
||||
3. **Recent colors don't persist** — same root cause as #1 (dirty flag issue)
|
||||
4. **Panel minimum height** is too low (180px) — contents get clipped, especially the color wheel (220px canvas)
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### Bug 1: BgColorChanged never persists (PRIMARY ISSUE)
|
||||
|
||||
In `app.rs:2352-2356`, the `BgColorChanged` handler does NOT set `self.colors_dirty = true`:
|
||||
```rust
|
||||
Message::BgColorChanged(color) => {
|
||||
self.bg_color = color;
|
||||
// MISSING: self.colors_dirty = true;
|
||||
crate::shape_sync::sync_shape_from_tool(self);
|
||||
self.refresh_composite_if_needed();
|
||||
}
|
||||
```
|
||||
|
||||
Compare with `FgColorChanged` (line 2347) which DOES set it. This means background color changes are never written to disk.
|
||||
|
||||
### Bug 2: Save order issue
|
||||
|
||||
In `app.rs:8845-8848`, the dirty flag is cleared BEFORE the save:
|
||||
```rust
|
||||
if self.colors_dirty {
|
||||
self.colors_dirty = false; // cleared first
|
||||
save_colors(...); // save second — if this fails, flag is already cleared
|
||||
}
|
||||
```
|
||||
|
||||
If `save_colors` fails (e.g., directory doesn't exist), the flag is already cleared and the save won't retry.
|
||||
|
||||
### Bug 3: Silent save failures
|
||||
|
||||
`save_colors()` uses `let _ = std::fs::write(...)` which discards errors. If `~/.config/hcie-iced/` doesn't exist, writes fail silently.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: Fix BgColorChanged persistence
|
||||
|
||||
**File:** `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` (line ~2354)
|
||||
|
||||
Add `self.colors_dirty = true;` to the handler.
|
||||
|
||||
### Step 2: Fix save order
|
||||
|
||||
**File:** `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` (line 8845-8848)
|
||||
|
||||
Swap the order — save first, then clear the flag:
|
||||
```rust
|
||||
if self.colors_dirty {
|
||||
save_colors(self.fg_color, self.bg_color, &self.recent_colors);
|
||||
self.colors_dirty = false;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Add directory creation in save_colors
|
||||
|
||||
**File:** `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` (line 93-101)
|
||||
|
||||
Add `create_dir_all` before writing:
|
||||
```rust
|
||||
fn save_colors(fg: [u8; 4], bg: [u8; 4], recent: &[[u8; 4]]) {
|
||||
let path = colors_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
// ... rest unchanged
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Increase panel minimum height
|
||||
|
||||
**File:** `hcie-iced-app/crates/hcie-iced-gui/src/dock/sizing.rs` (line 70-73)
|
||||
|
||||
Change ColorPicker policy:
|
||||
```rust
|
||||
PaneType::ColorPicker => PaneSizePolicy {
|
||||
width: axis(180.0, 280.0, 380.0, 1),
|
||||
height: axis(300.0, 420.0, 500.0, 1),
|
||||
}
|
||||
```
|
||||
|
||||
Rationale for values:
|
||||
- **Min 300px**: Shows swatch row, hex input, alpha slider, tab bar, and start of color wheel
|
||||
- **Preferred 420px**: Shows full color wheel (220px canvas) + header + recent colors without scrolling
|
||||
- **Max 500px**: Bounded to prevent stealing vertical space from sibling panels (Layers has grow_priority=35 vs color's 1)
|
||||
|
||||
### Step 5: Update test assertions
|
||||
|
||||
**File:** `hcie-iced-app/crates/hcie-iced-gui/src/dock/sizing.rs`
|
||||
|
||||
Tests to update:
|
||||
1. `policy_values_prioritize_canvas_and_bound_color_picker` (line 437-441): Update preferred/max values
|
||||
2. `subtree_constraints_follow_split_axis` (line 453): Update `height.max` assertion (Layers has INFINITY max, so shared axis max becomes INFINITY)
|
||||
3. `color_picker_does_not_receive_vertical_surplus_with_list_sibling` (line 494): Update max bound check
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` — 3 changes (BgColorChanged fix, save order, create_dir_all)
|
||||
2. `hcie-iced-app/crates/hcie-iced-gui/src/dock/sizing.rs` — policy update + 3 test updates
|
||||
|
||||
## Verification
|
||||
|
||||
1. Launch app, change both fg and bg colors, close and reopen — both should be restored
|
||||
2. Click "D" button — fg=black, bg=white
|
||||
3. Select several colors, close/reopen — recent colors preserved
|
||||
4. Resize color palette pane to minimum — color wheel and recent colors visible
|
||||
5. `cargo test -p hcie-iced-gui` — all tests pass
|
||||
@@ -82,22 +82,43 @@ fn colors_path() -> PathBuf {
|
||||
}
|
||||
|
||||
/// Load persisted colors from disk (or defaults if missing).
|
||||
///
|
||||
/// 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<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).
|
||||
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<crate::dock::state::PaneType> {
|
||||
"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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
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.
|
||||
|
||||
@@ -700,6 +700,10 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
"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)",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)>,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user