feat: Enhance brush settings and add new layer paste functionality

- Added brush color variant and variant amount settings to the brush panel.
- Introduced a checkbox for color variant and a slider for variant amount in the brush settings.
- Implemented "Paste as New Layer" functionality, allowing users to paste clipboard images directly onto a new layer.
- Updated menus to include the new paste option with a shortcut.
- Improved layer panel iconography by replacing text buttons with SVG icons for visibility and lock toggles.
- Created new SVG assets for closed eye and watercolor icons.
- Enhanced history panel to provide richer descriptions for brush strokes and vector shape modifications.
- Fixed the issue where the first brush stroke was missing from the history panel.
This commit is contained in:
2026-07-21 17:40:41 +03:00
parent e5d899d72d
commit 7b4073e55b
18 changed files with 1732 additions and 173 deletions
@@ -0,0 +1,224 @@
# Iced GUI UX Improvements Plan
## Goal
Implement five requested UI/UX and functional improvements in the `hcie-iced-gui` workspace, plus fix the reported regression where the first brush stroke does not appear in the History panel.
## Scope & Boundaries
- **Open layer:** `hcie-iced-app/crates/hcie-iced-gui/`, plus the new SVG asset.
- **Locked layers touched only for history fixes:** `hcie-engine-api` and `hcie-document` must be unlocked with the repo scripts, edited, and re-locked.
- All other improvements are GUI-only.
- Preserve protected GPU/dirty-region, partial-upload, and shader-layering behavior.
## Out of Scope
- egui GUI changes (except if a trivial parity icon is needed, noted below).
- New brush engine styles or engine-side brush changes.
- Layer duplicate / mask / rasterize functionality (toolbar placeholders stay as-is; only iconography changes).
---
## 1. History Panel Enhancements + First-Brush-Stroke Fix
### 1.1 Bug: first brush stroke missing from History
**Root cause:** `end_stroke()` creates the undo snapshot on a background thread and stores it in `pending_history`. The GUI calls `commit_pending_history()` once immediately after `end_stroke()`, but the thread may not have finished, so the snapshot is never committed and `cached_history` is never refreshed.
**Fix (engine side):**
- In `hcie-engine-api/src/stroke_cache.rs`:
- Change `commit_pending_history()` to return `bool``true` if it committed at least one pending snapshot, `false` otherwise.
- Add `pub fn has_pending_history(&self) -> bool` that returns whether the `pending_history` mutex still has uncommitted items.
- In `hcie-document/src/lib.rs`:
- Ensure `push_draw_snapshot_subrect` and `push_draw_snapshot` still skip no-change snapshots (preserve existing behaviour); no new logic needed.
**Fix (GUI side):**
- In `hcie-iced-gui/src/app.rs` `Message::CanvasPointerReleased` brush-stroke cleanup block:
- After `end_stroke`, call `commit_pending_history()`.
- If `commit_pending_history()` returned `false` **and** `engine.has_pending_history()` is true, schedule a delayed `CompositeRefresh` chain (e.g. 8 ms, 16 ms, 32 ms) using `tokio::time::sleep` inside `Task::perform`, each time re-checking and re-committing until pending is drained.
- Always finish with `refresh_composite_if_needed()` so `cached_history` is updated.
**Regression test:**
- Add a new engine test `hcie-engine-api/tests/brush_stroke_history.rs`:
- Create a 16×16 engine, set a Round brush, `begin_stroke`, `stroke_to` to a different pixel, `end_stroke`, `commit_pending_history()`.
- Assert `history_len()` increased by exactly one and `history_description(...)` is `"Brush Stroke"` (or the enhanced description after 1.2).
- Assert undo restores the blank layer.
- Add an iced-level test in `hcie-iced-app/crates/hcie-iced-gui/tests/` (or extend `feature_scorecard.rs`) that simulates the message sequence `CanvasPointerPressed``CanvasPointerMoved``CanvasPointerReleased` for a brush stroke and asserts `cached_history` grows.
### 1.2 Rich history descriptions
**Engine side:**
- In `hcie-engine-api/src/stroke_brush.rs`:
- `draw_stroke()` currently pushes `"Brush Stroke"`. Change it to include the brush style label, e.g. `"Brush Stroke (Round)"`, `"Brush Stroke (Watercolor)"`, etc.
- `draw_line()``"Line"`, `draw_rect()``"Rectangle"`, `draw_filled_rect_rgba()``"Fill Rectangle"`, `draw_ellipse()``"Ellipse"`.
- In `hcie-engine-api/src/stroke_cache.rs` background thread:
- The interactive `end_stroke()` path currently hard-codes `"Brush Stroke"`. Replace with a description that includes `self.current_tip.style` label (e.g. `"Brush Stroke (Oil)"`).
- In `hcie-engine-api/src/lib.rs` vector mutators:
- `add_vector_shape`: keep `"Add Vector Shape"` but append the shape kind/name when known: `"Add Vector Shape (Rect)"`, `"Add Vector Shape (Path 12 pts)"`, etc.
- `delete_vector_shape`: push a vector snapshot with description `"Delete Vector Shape (<kind>)"`.
- `set_vector_shape_bounds`: `"Resize Vector Shape (<kind>)"`.
- `set_vector_shape_angle`: `"Rotate Vector Shape (<kind>)"`.
- `set_vector_shape_color`: `"Set Stroke Color"`.
- `set_vector_shape_fill_color`: `"Set Fill Color"`.
- `set_vector_shape_fill`: `"Toggle Fill"`.
- `set_vector_shape_stroke`: `"Set Stroke Width"`.
- `set_vector_shape_opacity`: `"Set Shape Opacity"`.
- `set_vector_shape_hardness`: `"Set Shape Hardness"`.
- `reorder_vector_shape`: `"Reorder Vector Shape"`.
- `boolean_vector_shapes`: already has `"Path Boolean ({op})"`; keep.
**Helper to add in `hcie-engine-api/src/lib.rs`:**
- `fn vector_shape_kind(shape: &VectorShape) -> &'static str` mapping each `VectorShape` variant to a short label.
- `fn vector_shape_name_or_kind(shape: &VectorShape) -> String` returning `shape.name()` if non-empty, else the kind label.
**Note:** vector property mutators currently do **not** push snapshots, so undo does not cover them. Adding snapshots here fixes both the history description and makes these operations undoable, which is required for a 9/10 History score.
**GUI side:**
- In `hcie-iced-gui/src/panels/history.rs`:
- Keep the list layout but widen the text column so longer descriptions are not clipped. Reduce index prefix formatting noise: keep `"{}: {}"` but ensure the container does not hard-wrap at 30 chars.
- Add a subtle muted style for future entries and accent style for current (already exists); verify contrast.
---
## 2. Clipboard: Paste as New Layer
### Goal
Add a workflow to paste clipboard pixels directly as a new raster layer, instead of always entering floating transform mode.
### Changes
**Menu / command:**
- In `hcie-iced-gui/src/panels/menus.rs`:
- Add `MenuCommand::PasteAsNewLayer`.
- Add a menu item under Edit: `"Paste as New Layer"` with shortcut `Ctrl+Shift+V` (replace the existing `Paste Special` shortcut mapping; keep `Paste Special` label as a placeholder or map it to the same command if preferred).
- Add the same item to `canvas_context_menu` between `Paste` and `New Layer`.
**Message:**
- In `hcie-iced-gui/src/app.rs` `Message` enum:
- Add `PasteAsNewLayer`.
**Handler (in `Message` dispatch):**
- On `PasteAsNewLayer`:
1. Prefer `internal_clipboard` if present; otherwise read system clipboard via `io/clipboard::paste_image_from_clipboard()`.
2. If clipboard image exceeds canvas, show the existing `ExpandCanvas` dialog first, then proceed.
3. Compute target size:
- If oversized and user accepted expand: use clipboard width/height as new canvas size.
- Otherwise: keep current canvas size.
4. Create a new layer via `engine.add_layer("Pasted Layer")`.
5. Center the clipboard pixels in the new layer using `engine.set_layer_pixels(new_layer_id, centered_pixels)`.
6. Push an undo snapshot on the new layer with description `"Paste as New Layer"`.
7. Set the new layer as active.
8. Call `refresh_composite_if_needed()` and return `CompositeRefresh`.
**Helper to add:**
- In `hcie-iced-gui/src/selection/clipboard.rs` or a new `hcie-iced-gui/src/clipboard.rs`:
- `fn centered_layer_pixels(pixels: &[u8], img_w: u32, img_h: u32, canvas_w: u32, canvas_h: u32) -> Vec<u8>` that returns a full canvas-sized RGBA buffer with the image centered.
**Note:** avoid changing the existing `PasteImage` floating-transform path; add the new path in parallel.
---
## 3. Layers Panel Iconography
### Goal
Replace text-only controls in the Layers panel with intuitive icon buttons, and specifically fix the visibility toggle to use a standard eye/eye-slash icon.
### Changes
**New SVG asset:**
- Create `hcie-iced-app/assets/icons/eye_closed.svg` for hidden layers.
- Existing `hcie-iced-app/assets/icons/visible.svg` can be reused as the open eye.
**Helper for small SVG buttons:**
- In `hcie-iced-gui/src/panels/layers.rs` (or `styles.rs`):
- Add a helper `fn svg_icon_btn<'a>(icon_path: &str, tip: &str, msg: Message, colors: ThemeColors, enabled: bool) -> Element<'a, Message>` that loads the SVG from the absolute assets path (falling back to text) and wraps it in a small flat button with a tooltip.
**Row controls:**
- Replace the visibility text button (`"\u{1F441}"`) with `visible.svg` / `eye_closed.svg` based on `info.visible`.
- Replace the lock text (`"L"` / `"-"`) with `lock.svg` for locked and a subtle open-lock or empty placeholder for unlocked.
- Keep the same `Message::LayerToggleVisibility` / `LayerToggleLock` messages.
**Toolbar controls:**
- Replace text toolbar buttons with icon buttons:
- `+Layer``new_file.svg` or `panel-layers.svg` plus tooltip
- `+Group` → a folder/group SVG (use `panel-layers.svg` if no group icon exists)
- `Flatten` → a merge/layers SVG (keep text if no icon)
- `▲` / `▼` move arrows keep unicode arrows but use consistent size
- `X` delete keep unicode but ensure visible
- `fx` keep as text because it's a convention
- Wrap each in the same `svg_icon_btn` helper where an icon exists; for missing icons keep compact text buttons.
**No engine changes.**
---
## 4. Vector Transform Preview Performance
### Goal
Eliminate latency during vector rotate/scale so the preview updates in real time.
### Root cause
`VectorSelectDragMove` throttles preview updates to ~60 FPS and draws only the rotated bounding box of the preview shape, not the actual shape geometry. The 60 FPS throttle intentionally drops pointer events, which feels laggy, and the wireframe box is a weak visual proxy.
### Changes (all in GUI)
**Remove throttle:**
- In `hcie-iced-gui/src/app.rs` `Message::VectorSelectDragMove`:
- Remove the `vector_drag_last_render` 60 FPS throttle block. Update `vector_drag_preview` on every `DragMove`.
- Keep `vector_drag_last` for pointer tracking.
**Draw actual shape geometry in overlay:**
- In `hcie-iced-gui/src/canvas/mod.rs` `OverlayProgram::draw()`:
- Where `vector_drag_preview` is rendered (around line 1297), replace the simple rotated rectangle stroke with a shape-specific renderer:
- For `VectorShape::Rect`, `Circle`, `Line`, `Arrow`, `Star`, `Polygon`, `Rhombus`, `Cylinder`, `Heart`, `Bubble`, `Gear`, `Cross`, `Crescent`, `Bolt`, `Arrow4`, `SvgShape`:
- Draw the same geometry as `vector_draw` preview already does for in-progress shapes (ellipse, line, star, polygon, rounded rect paths).
- Apply the preview's `angle` and `normalized_bounds()` transform so the preview exactly matches the committed shape.
- For `VectorShape::FreePath`:
- Draw the path points transformed by the preview bounds/angle.
- Use the same bright green stroke style (`[0.2, 0.9, 0.4, 0.95]`) and keep the edit handles drawn over it.
**Shared preview renderer:**
- Extract the existing vector-shape preview drawing code from `vector_draw` block in `canvas/mod.rs` into a helper `fn draw_vector_shape_preview(frame: &mut Frame, shape: &VectorShape, origin_x: f32, origin_y: f32, zoom: f32, stroke_color: iced::Color, line_width: f32)`.
- Reuse it both for in-progress `vector_draw` and for `vector_drag_preview`.
**No engine changes.**
---
## 5. Brush Panel: Distinct Watercolor Icon
### Goal
Replace the Watercolor category tab icon so it is not identical to the Painting tab.
### Changes
**New SVG asset:**
- Create `hcie-iced-app/assets/icons/watercolor.svg` — a droplet/splat shape, using `fill="currentColor"` like the other icons.
**GUI change:**
- In `hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs` category tabs array:
- Change Watercolor tab from `"icons/brush.svg"` to `"icons/watercolor.svg"`.
**No engine changes.**
---
## Implementation Order
1. **Vector transform preview** (#4) — pure GUI, safe to start.
2. **Layers panel icons** (#3) — pure GUI.
3. **Watercolor icon** (#5) — pure asset + one-line GUI change.
4. **Paste as new layer** (#2) — pure GUI, can be done in parallel with #1#3.
5. **History enhancements + first-stroke fix** (#1) — requires unlocking engine crates; do this last to minimize locked-crate exposure.
## Validation
- `cargo check -p hcie-iced-gui`
- `cargo test -p hcie-iced-gui`
- `cargo test -p hcie-engine-api --test visual_regression`
- `cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture`
- `cargo test --test feature_scorecard`
- Manual / screenshot verification:
- Draw one brush dab with no drag → History panel shows the entry.
- Draw a second stroke with a different brush style → description includes style name.
- Create a vector shape, change its fill/color/rotate/resize → each appears in History with shape kind and action.
- Copy a selection, use "Paste as New Layer" → new layer appears with pasted pixels.
- Toggle layer visibility → eye icon changes between open/closed.
- Rotate/scale a vector shape → preview follows pointer without frame drops.
- Open Brushes panel, select Watercolor category → new droplet icon is visible.
## Risks & Guardrails
- **Locked crates:** Only unlock `hcie-engine-api` and `hcie-document` for history changes. Re-lock immediately after.
- **Background thread race:** The first-stroke fix relies on delayed `CompositeRefresh` tasks; ensure tasks are cancelled or no-op if the document/engine state changes before they fire.
- **Performance:** Removing the vector drag throttle increases overlay redraws, but overlay drawing is cheap vector geometry on a small canvas; verify no regression on 4K docs.
- **Icon fallback:** SVG icon helper must fall back to text if the icon file is missing, matching `toolbar.rs` behaviour.
- **No protocol changes:** Do not add new `VectorShape` variants or new `BrushStyle` variants.
+200 -20
View File
@@ -1222,21 +1222,120 @@ pub fn draw_watercolor_brush(
is_eraser: bool,
mask: Option<&[u8]>,
) {
let mut rng = rand::thread_rng();
let effective_size = size * pressure;
let particle_opacity = opacity * 0.2 * pressure;
draw_dab(
pixels,
width,
height,
cx,
cy,
effective_size,
hardness,
color,
particle_opacity,
is_eraser,
mask,
);
if effective_size < 0.5 {
return;
}
let base_opacity = opacity * 0.2 * pressure;
let center_color = color;
// ── Central irregular core ──────────────────────────────────────────────
// Build the main mark from several overlapping soft dabs so the boundary
// is lumpy instead of a perfect circle.
let core_dabs = rng.gen_range(2..=4);
for _ in 0..core_dabs {
let angle = rng.gen_range(0.0..std::f32::consts::TAU);
let offset = effective_size * rng.gen_range(0.0..0.12);
let dx = angle.cos() * offset;
let dy = angle.sin() * offset;
let s = effective_size * rng.gen_range(0.85..1.15);
let a = base_opacity * rng.gen_range(0.7..1.3);
draw_dab(
pixels,
width,
height,
cx + dx,
cy + dy,
s,
hardness,
center_color,
a,
is_eraser,
mask,
);
}
// ── Satellite puddles ───────────────────────────────────────────────────
// Secondary dabs pulled away from the stroke path by water tension,
// giving the edge its characteristic ragged bloom.
let satellite_count = (effective_size / 6.0).clamp(2.0, 8.0) as i32;
for _ in 0..satellite_count {
let angle = rng.gen_range(0.0..std::f32::consts::TAU);
let dist = effective_size * rng.gen_range(0.25..0.65);
let dx = angle.cos() * dist;
let dy = angle.sin() * dist;
let s = effective_size * rng.gen_range(0.2..0.55);
let a = base_opacity * rng.gen_range(0.25..0.75);
draw_dab(
pixels,
width,
height,
cx + dx,
cy + dy,
s,
hardness * rng.gen_range(0.5..1.0),
center_color,
a,
is_eraser,
mask,
);
}
// ── Tiny splatter particles ───────────────────────────────────────────
// Small droplets flung outward, visible on high-resolution strokes.
let splatter_count = (effective_size * 1.2).clamp(3.0, 22.0) as i32;
for _ in 0..splatter_count {
let angle = rng.gen_range(0.0..std::f32::consts::TAU);
let dist = effective_size * rng.gen_range(0.45..1.15);
let dx = angle.cos() * dist;
let dy = angle.sin() * dist;
let s = effective_size * rng.gen_range(0.03..0.14);
let a = base_opacity * rng.gen_range(0.4..1.3);
draw_dab(
pixels,
width,
height,
cx + dx,
cy + dy,
s,
rng.gen_range(0.0..0.4),
center_color,
a,
is_eraser,
mask,
);
}
// ── Back-run / blossom bursts ───────────────────────────────────────────
// A few very large, faint "blooms" that extend beyond the main stroke,
// simulating water pushing pigment to the edges.
let bloom_count = if effective_size > 25.0 {
rng.gen_range(1..=3)
} else {
0
};
for _ in 0..bloom_count {
let angle = rng.gen_range(0.0..std::f32::consts::TAU);
let dist = effective_size * rng.gen_range(0.5..0.9);
let dx = angle.cos() * dist;
let dy = angle.sin() * dist;
let s = effective_size * rng.gen_range(0.6..1.0);
let a = base_opacity * rng.gen_range(0.15..0.35);
draw_dab(
pixels,
width,
height,
cx + dx,
cy + dy,
s,
0.0,
center_color,
a,
is_eraser,
mask,
);
}
}
/// Apply calligraphy brush - multiple parallel dabs
@@ -2432,20 +2531,95 @@ fn quadratic_bezier(p0: f32, p1: f32, p2: f32, t: f32) -> f32 {
/// `amount` is expected in [0, 1]. Hue is shifted by ±(amount*60°),
/// lightness by ±(amount*0.4). Saturation is preserved to keep the
/// color within the same family (green stays green, purple stays purple).
/// Per-thread remembered HSL walk state for one base color.
///
/// Keeps the last hue/lightness/saturation offsets so successive dabs move smoothly
/// instead of jumping to independent random values. The state is keyed by the
/// base color so unrelated colors do not interfere.
thread_local! {
static LAST_HSL_WALK: std::cell::RefCell<std::collections::HashMap<u64, (f32, f32, f32)>> =
std::cell::RefCell::new(std::collections::HashMap::new());
}
/// Hash helper for the base color used as thread-local state key.
fn color_hash(color: [u8; 4]) -> u64 {
((color[0] as u64) << 24)
| ((color[1] as u64) << 16)
| ((color[2] as u64) << 8)
| (color[3] as u64)
}
/// Vary the base color smoothly in HSL space and avoid harsh sequential contrasts.
///
/// **Logic & Workflow:**
/// 1. Loads the previous HSL offset for this base color from thread-local state.
/// 2. Takes a small random step so the transition from the previous dab is gradual.
/// 3. Rejects offsets that land in the complementary hue zone (150°..210° from base),
/// which is where high-contrast color combinations occur.
/// 4. Stores the new offset back into thread-local state.
///
/// **Arguments:**
/// * `color` — base RGBA color.
/// * `amount` — 0.0..1.0 overall variation strength.
/// * `rng` — random source.
pub fn vary_color_hsl<R: Rng>(color: [u8; 4], amount: f32, rng: &mut R) -> [u8; 4] {
let clamped = amount.clamp(0.0, 1.0);
if clamped <= 0.0 {
return color;
}
let (h, s, l) = rgb_to_hsl(color[0], color[1], color[2]);
let hue_shift = rng.gen_range(-1.0..=1.0) * clamped * 60.0;
let light_shift = rng.gen_range(-1.0..=1.0) * clamped * 0.4;
let new_h = (h + hue_shift).rem_euclid(360.0);
let new_l = (l + light_shift).clamp(0.0, 1.0);
let (r, g, b) = hsl_to_rgb(new_h, s, new_l);
let (base_h, base_s, base_l) = rgb_to_hsl(color[0], color[1], color[2]);
let key = color_hash(color);
let (mut hue_off, mut light_off, mut sat_off) = LAST_HSL_WALK
.with_borrow(|m| m.get(&key).copied().unwrap_or((0.0, 0.0, 0.0)));
// Maximum per-dab step: keeps transitions smooth.
let max_hue_step = clamped * 18.0; // ±18° per dab at amount=1
let max_light_step = clamped * 0.12;
let max_sat_step = clamped * 0.10;
// Total range stays within a harmonious neighbourhood of the base color.
let max_hue_off = clamped * 60.0;
let max_light_off = clamped * 0.35;
let max_sat_off = clamped * 0.25;
for _ in 0..8 {
let step_h = rng.gen_range(-1.0..=1.0) * max_hue_step;
hue_off = (hue_off + step_h).clamp(-max_hue_off, max_hue_off);
let step_l = rng.gen_range(-1.0..=1.0) * max_light_step;
light_off = (light_off + step_l).clamp(-max_light_off, max_light_off);
let step_s = rng.gen_range(-1.0..=1.0) * max_sat_step;
sat_off = (sat_off + step_s).clamp(-max_sat_off, max_sat_off);
// Reject complementary (high-contrast) hue offsets.
// The complementary zone is 150°..210° away from the base hue.
let abs_hue = hue_off.abs();
if abs_hue < 150.0 || abs_hue > 210.0 {
break;
}
// If we land in the complementary zone, nudge back toward base and retry.
hue_off *= 0.7;
}
LAST_HSL_WALK.with_borrow_mut(|m| {
m.insert(key, (hue_off, light_off, sat_off));
});
let new_h = (base_h + hue_off).rem_euclid(360.0);
let new_s = (base_s + sat_off).clamp(0.0, 1.0);
let new_l = (base_l + light_off).clamp(0.0, 1.0);
let (r, g, b) = hsl_to_rgb(new_h, new_s, new_l);
[r, g, b, color[3]]
}
/// Reset the smooth color-walk state. Useful when starting a fresh stroke or test.
pub fn reset_color_variant_walk() {
LAST_HSL_WALK.with_borrow_mut(|m| m.clear());
}
fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
let rf = r as f32 / 255.0;
let gf = g as f32 / 255.0;
@@ -2975,6 +3149,12 @@ pub fn draw_specialized_stroke(
return;
}
// Start each stroke from the base color so successive strokes do not inherit
// a strong leftover offset from the previous stroke.
if color_variant && variant_amount > 0.0 {
reset_color_variant_walk();
}
let spacing = brush_spacing_pixels(size, spacing_ratio);
for i in 0..points.len() {
+292 -6
View File
@@ -1325,6 +1325,10 @@ impl Engine {
} else {
None
};
let desc = format!(
"Add Vector Shape ({})",
vector_shape_name_or_kind(&shape)
);
if let Some(layer) = self.document.active_layer_mut() {
if let LayerData::Vector { ref mut shapes } = layer.data {
shapes.push(shape);
@@ -1333,21 +1337,18 @@ impl Engine {
self.document.modified = true;
}
}
self.document.push_vector_snapshot(
layer_idx,
before_shapes,
"Add Vector Shape".to_string(),
);
self.document.push_vector_snapshot(layer_idx, before_shapes, desc);
return;
}
}
// Create the final vector layer before recording history. This keeps the first shape and
// its auto-created layer in one atomic Undo/Redo entry instead of capturing an empty
// intermediate raster layer.
let desc = format!("Add Vector Shape ({})", vector_shape_name_or_kind(&shape));
self.document.add_vector_layer_with_shape(
format!("Vector {}", self.document.layers.len()),
shape,
"Add Vector Shape",
&desc,
);
}
@@ -1395,6 +1396,14 @@ impl Engine {
}
pub fn set_vector_shape_color(&mut self, layer_id: u64, shape_idx: usize, color: [u8; 4]) {
let layer_idx = self.document.layer_index_by_id(layer_id);
let before_shapes = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => Some(shapes.clone()),
_ => None,
});
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data {
if let Some(shape) = shapes.get_mut(shape_idx) {
@@ -1405,9 +1414,33 @@ impl Engine {
}
}
}
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
if let Some(shape) = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => shapes.get(shape_idx),
_ => None,
})
{
self.document.push_vector_snapshot(
idx,
Some(before),
format!("Set Stroke Color ({})", vector_shape_name_or_kind(shape)),
);
}
}
}
pub fn set_vector_shape_fill(&mut self, layer_id: u64, shape_idx: usize, fill: bool) {
let layer_idx = self.document.layer_index_by_id(layer_id);
let before_shapes = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => Some(shapes.clone()),
_ => None,
});
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data {
if let Some(shape) = shapes.get_mut(shape_idx) {
@@ -1420,9 +1453,33 @@ impl Engine {
}
}
}
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
if let Some(shape) = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => shapes.get(shape_idx),
_ => None,
})
{
self.document.push_vector_snapshot(
idx,
Some(before),
format!("Toggle Fill ({})", vector_shape_name_or_kind(shape)),
);
}
}
}
pub fn set_vector_shape_fill_color(&mut self, layer_id: u64, shape_idx: usize, color: [u8; 4]) {
let layer_idx = self.document.layer_index_by_id(layer_id);
let before_shapes = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => Some(shapes.clone()),
_ => None,
});
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data {
if let Some(shape) = shapes.get_mut(shape_idx) {
@@ -1435,9 +1492,33 @@ impl Engine {
}
}
}
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
if let Some(shape) = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => shapes.get(shape_idx),
_ => None,
})
{
self.document.push_vector_snapshot(
idx,
Some(before),
format!("Set Fill Color ({})", vector_shape_name_or_kind(shape)),
);
}
}
}
pub fn set_vector_shape_stroke(&mut self, layer_id: u64, shape_idx: usize, stroke: f32) {
let layer_idx = self.document.layer_index_by_id(layer_id);
let before_shapes = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => Some(shapes.clone()),
_ => None,
});
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data {
if let Some(shape) = shapes.get_mut(shape_idx) {
@@ -1448,9 +1529,33 @@ impl Engine {
}
}
}
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
if let Some(shape) = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => shapes.get(shape_idx),
_ => None,
})
{
self.document.push_vector_snapshot(
idx,
Some(before),
format!("Set Stroke Width ({})", vector_shape_name_or_kind(shape)),
);
}
}
}
pub fn set_vector_shape_opacity(&mut self, layer_id: u64, shape_idx: usize, opacity: f32) {
let layer_idx = self.document.layer_index_by_id(layer_id);
let before_shapes = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => Some(shapes.clone()),
_ => None,
});
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data {
if let Some(shape) = shapes.get_mut(shape_idx) {
@@ -1461,6 +1566,22 @@ impl Engine {
}
}
}
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
if let Some(shape) = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => shapes.get(shape_idx),
_ => None,
})
{
self.document.push_vector_snapshot(
idx,
Some(before),
format!("Set Shape Opacity ({})", vector_shape_name_or_kind(shape)),
);
}
}
}
pub fn set_vector_shape_bounds(
@@ -1472,6 +1593,14 @@ impl Engine {
x2: f32,
y2: f32,
) {
let layer_idx = self.document.layer_index_by_id(layer_id);
let before_shapes = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => Some(shapes.clone()),
_ => None,
});
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data {
if let Some(shape) = shapes.get_mut(shape_idx) {
@@ -1482,9 +1611,36 @@ impl Engine {
}
}
}
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
if let Some(shape) = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => shapes.get(shape_idx),
_ => None,
})
{
self.document.push_vector_snapshot(
idx,
Some(before),
format!(
"Resize Vector Shape ({})",
vector_shape_name_or_kind(shape)
),
);
}
}
}
pub fn set_vector_shape_hardness(&mut self, layer_id: u64, shape_idx: usize, hardness: f32) {
let layer_idx = self.document.layer_index_by_id(layer_id);
let before_shapes = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => Some(shapes.clone()),
_ => None,
});
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data {
if let Some(shape) = shapes.get_mut(shape_idx) {
@@ -1495,6 +1651,25 @@ impl Engine {
}
}
}
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
if let Some(shape) = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => shapes.get(shape_idx),
_ => None,
})
{
self.document.push_vector_snapshot(
idx,
Some(before),
format!(
"Set Shape Hardness ({})",
vector_shape_name_or_kind(shape)
),
);
}
}
}
pub fn set_vector_shape_line_caps(
@@ -1575,6 +1750,14 @@ impl Engine {
}
pub fn set_vector_shape_angle(&mut self, layer_id: u64, shape_idx: usize, angle: f32) {
let layer_idx = self.document.layer_index_by_id(layer_id);
let before_shapes = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => Some(shapes.clone()),
_ => None,
});
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data {
if let Some(shape) = shapes.get_mut(shape_idx) {
@@ -1585,6 +1768,25 @@ impl Engine {
}
}
}
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
if let Some(shape) = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => shapes.get(shape_idx),
_ => None,
})
{
self.document.push_vector_snapshot(
idx,
Some(before),
format!(
"Rotate Vector Shape ({})",
vector_shape_name_or_kind(shape)
),
);
}
}
}
pub fn set_vector_shape_svg(&mut self, layer_id: u64, shape_idx: usize, new_svg: &str) {
@@ -1619,6 +1821,14 @@ impl Engine {
}
pub fn reorder_vector_shape(&mut self, layer_id: u64, from_idx: usize, to_idx: usize) {
let layer_idx = self.document.layer_index_by_id(layer_id);
let before_shapes = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => Some(shapes.clone()),
_ => None,
});
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data {
if from_idx < shapes.len() && to_idx < shapes.len() {
@@ -1630,9 +1840,27 @@ impl Engine {
}
}
}
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
self.document.push_vector_snapshot(
idx,
Some(before),
"Reorder Vector Shape".to_string(),
);
}
}
pub fn delete_vector_shape(&mut self, layer_id: u64, shape_idx: usize) {
let layer_idx = self.document.layer_index_by_id(layer_id);
let before_shapes = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => Some(shapes.clone()),
_ => None,
});
let removed_kind = before_shapes.as_ref().and_then(|shapes| {
shapes.get(shape_idx).map(vector_shape_name_or_kind)
});
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data {
if shape_idx < shapes.len() {
@@ -1643,6 +1871,13 @@ impl Engine {
}
}
}
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
let desc = removed_kind.map_or_else(
|| "Delete Vector Shape".to_string(),
|kind| format!("Delete Vector Shape ({})", kind),
);
self.document.push_vector_snapshot(idx, Some(before), desc);
}
}
pub fn boolean_vector_shapes(
@@ -3996,6 +4231,57 @@ impl Engine {
}
}
/// Returns a short label for the shape kind, used in history descriptions.
pub fn vector_shape_kind(shape: &VectorShape) -> String {
match shape {
VectorShape::Line { .. } => "Line".to_string(),
VectorShape::Rect { .. } => "Rect".to_string(),
VectorShape::Circle { .. } => "Circle".to_string(),
VectorShape::Arrow { .. } => "Arrow".to_string(),
VectorShape::Star { .. } => "Star".to_string(),
VectorShape::Polygon { .. } => "Polygon".to_string(),
VectorShape::Rhombus { .. } => "Rhombus".to_string(),
VectorShape::Cylinder { .. } => "Cylinder".to_string(),
VectorShape::Heart { .. } => "Heart".to_string(),
VectorShape::Bubble { .. } => "Bubble".to_string(),
VectorShape::Gear { .. } => "Gear".to_string(),
VectorShape::Cross { .. } => "Cross".to_string(),
VectorShape::Crescent { .. } => "Crescent".to_string(),
VectorShape::Bolt { .. } => "Bolt".to_string(),
VectorShape::Arrow4 { .. } => "Arrow4".to_string(),
VectorShape::SvgShape { kind, .. } => kind.clone(),
VectorShape::FreePath { .. } => "Path".to_string(),
}
}
/// Returns the shape's name if non-empty, otherwise its kind label.
pub fn vector_shape_name_or_kind(shape: &VectorShape) -> String {
let name = match shape {
VectorShape::Line { name, .. }
| VectorShape::Rect { name, .. }
| VectorShape::Circle { name, .. }
| VectorShape::Arrow { name, .. }
| VectorShape::Star { name, .. }
| VectorShape::Polygon { name, .. }
| VectorShape::Rhombus { name, .. }
| VectorShape::Cylinder { name, .. }
| VectorShape::Heart { name, .. }
| VectorShape::Bubble { name, .. }
| VectorShape::Gear { name, .. }
| VectorShape::Cross { name, .. }
| VectorShape::Crescent { name, .. }
| VectorShape::Bolt { name, .. }
| VectorShape::Arrow4 { name, .. }
| VectorShape::SvgShape { name, .. }
| VectorShape::FreePath { name, .. } => name.as_str(),
};
if name.is_empty() {
vector_shape_kind(shape).to_string()
} else {
name.to_string()
}
}
fn parse_hex_color(hex: &str) -> [u8; 4] {
let h = hex.trim_start_matches('#');
if h.len() >= 6 {
+48 -3
View File
@@ -318,8 +318,11 @@ impl Engine {
}
if let Some(before) = before_pixels {
self.document
.push_draw_snapshot(layer_idx, before, None, "Brush Stroke".to_string());
let desc = format!(
"Brush Stroke ({})",
crate::stroke_brush::brush_style_label(tip.style)
);
self.document.push_draw_snapshot(layer_idx, before, None, desc);
}
}
@@ -382,7 +385,7 @@ impl Engine {
}
if let Some(before) = before_pixels {
self.document
.push_draw_snapshot(layer_idx, before, None, "Fill Rect".to_string());
.push_draw_snapshot(layer_idx, before, None, "Fill Rectangle".to_string());
}
}
@@ -474,3 +477,45 @@ impl Engine {
)
}
}
/// Returns a short human-readable label for a built-in brush style.
///
/// Mirrors the names used in the GUI brush preset list so history entries
/// can include the active style, e.g. "Brush Stroke (Round)".
pub fn brush_style_label(style: BrushStyle) -> &'static str {
match style {
BrushStyle::Round | BrushStyle::Default => "Round",
BrushStyle::Square => "Square",
BrushStyle::HardRound => "Hard Round",
BrushStyle::SoftRound => "Soft Round",
BrushStyle::Star => "Star",
BrushStyle::Noise => "Noise",
BrushStyle::Texture => "Texture",
BrushStyle::Spray => "Spray",
BrushStyle::Pencil => "Pencil",
BrushStyle::Pen => "Pen",
BrushStyle::Calligraphy => "Calligraphy",
BrushStyle::Oil => "Oil",
BrushStyle::Charcoal => "Charcoal",
BrushStyle::Leaf => "Leaf",
BrushStyle::Rock => "Rock",
BrushStyle::Meadow => "Meadow",
BrushStyle::Wood => "Wood",
BrushStyle::Watercolor => "Watercolor",
BrushStyle::Marker => "Marker",
BrushStyle::Sketch => "Sketch",
BrushStyle::Hatch => "Hatch",
BrushStyle::Glow => "Glow",
BrushStyle::Airbrush => "Airbrush",
BrushStyle::Crayon => "Crayon",
BrushStyle::WetPaint => "Wet Paint",
BrushStyle::InkPen => "Ink Pen",
BrushStyle::Clouds => "Clouds",
BrushStyle::Dirt => "Dirt",
BrushStyle::Tree => "Tree",
BrushStyle::Bristle => "Bristle",
BrushStyle::Mixer => "Mixer",
BrushStyle::Blender => "Blender",
BrushStyle::Bitmap => "Bitmap",
}
}
+20 -3
View File
@@ -62,16 +62,28 @@ pub struct PendingHistoryItem {
}
impl Engine {
/// Returns true if at least one background-thread history snapshot is still
/// waiting to be committed to the document history.
///
/// This is used by the GUI to decide whether to schedule an additional
/// delayed `commit_pending_history` poll after a brush stroke ends.
pub fn has_pending_history(&self) -> bool {
!self.pending_history.lock().unwrap().is_empty()
}
/// Commit any pending history items computed in the background thread.
/// This should be polled on the UI thread.
///
/// Also recycles returned buffers back into the engine's pool to avoid
/// allocations on subsequent strokes.
pub fn commit_pending_history(&mut self) {
///
/// Returns `true` if at least one pending snapshot was committed on this
/// call, `false` if the queue was empty.
pub fn commit_pending_history(&mut self) -> bool {
let items = {
let mut pending = self.pending_history.lock().unwrap();
if pending.is_empty() {
return;
return false;
}
std::mem::take(&mut *pending)
};
@@ -106,6 +118,7 @@ impl Engine {
);
}
}
true
}
/// Begin a new brush stroke on the given layer.
@@ -259,6 +272,7 @@ impl Engine {
None
};
let style = self.current_tip.style;
let bounds = self.last_stroke_bounds;
let pending_history = self.pending_history.clone();
@@ -275,7 +289,10 @@ impl Engine {
bounds: None,
before_shapes,
after_shapes,
description: "Brush Stroke".to_string(),
description: format!(
"Brush Stroke ({})",
crate::stroke_brush::brush_style_label(style)
),
return_before: None,
return_after: None,
};
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor">
<g transform="translate(40.96,40.96) scale(17.920000) translate(-0.00,-0.00)">
<path d="M12 6C5.6 6 1 12 1 12s4.6 6 11 6 11-6 11-6-4.6-6-11-6zm0 10c-2.2 0-4-1.8-4-4s1.8-4 4-4 4 1.8 4 4-1.8 4-4 4zm0-6.5c-1.4 0-2.5 1.1-2.5 2.5s1.1 2.5 2.5 2.5 2.5-1.1 2.5-2.5S13.4 9.5 12 9.5z"/>
<line x1="2" y1="2" x2="22" y2="22" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 525 B

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Water droplet / splat icon -->
<path d="M12 2C12 2 5 9.5 5 14a7 7 0 0 0 14 0c0-4.5-7-12-7-12z" fill="currentColor" opacity="0.3"/>
<path d="M12 2C12 2 5 9.5 5 14a7 7 0 0 0 14 0c0-4.5-7-12-7-12z"/>
<!-- Small splat drops -->
<circle cx="7" cy="18" r="1.2" fill="currentColor" opacity="0.5"/>
<circle cx="17" cy="17" r="0.9" fill="currentColor" opacity="0.4"/>
<circle cx="12" cy="20" r="1.0" fill="currentColor" opacity="0.35"/>
</svg>

After

Width:  |  Height:  |  Size: 613 B

+171 -28
View File
@@ -349,8 +349,6 @@ pub struct HcieIcedApp {
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.
pub vector_drag_last_render: Option<std::time::Instant>,
/// Last composite bounds during drag — used to skip redundant re-renders.
pub vector_drag_last_bounds: Option<(f32, f32, f32, f32)>,
/// Last composite angle during drag — used to skip redundant re-renders.
@@ -465,6 +463,9 @@ pub struct IcedDocument {
/// Stashed paste pixels when the paste exceeds the canvas, awaiting
/// the ExpandCanvas dialog decision. Consumed on Expand or Cancel.
pub pending_paste: Option<(Vec<u8>, u32, u32)>,
/// When true, the pending_paste pixels should be placed as a new layer
/// instead of entering transform mode (set by "Paste as New Layer").
pub pending_paste_as_new_layer: bool,
/// Selection mask (alpha channel) for the current selection
pub selection_mask: Option<Vec<u8>>,
/// Whether the selection mask has changed since last GPU upload
@@ -580,6 +581,10 @@ pub struct ToolState {
pub brush_style: BrushStyle,
/// Current brush spacing.
pub brush_spacing: f32,
/// True when each brush dab should vary its color slightly.
pub brush_color_variant: bool,
/// Magnitude of the per-dab color variation (0.0..1.0).
pub brush_variant_amount: f32,
/// Timestamp of the last composite refresh for throttling during strokes.
pub last_composite_refresh: std::time::Instant,
/// Timestamp of the last update() call for frame timing.
@@ -614,6 +619,8 @@ impl Default for ToolState {
brush_hardness: 0.8,
brush_style: BrushStyle::Round,
brush_spacing: 1.0,
brush_color_variant: false,
brush_variant_amount: 0.0,
last_composite_refresh: std::time::Instant::now(),
last_update_instant: std::time::Instant::now(),
imported_brushes: Vec::new(),
@@ -688,6 +695,9 @@ pub enum Message {
// ── Engine operations ───────────────────────────────
CompositeRefresh,
/// Polled after end_stroke to commit any pending history snapshots
/// that the background thread hadn't finished yet.
CompositeRefreshPending,
Undo,
Redo,
@@ -753,6 +763,8 @@ pub enum Message {
BrushHardnessChanged(f32),
BrushFlowChanged(f32),
BrushSpacingChanged(f32),
BrushColorVariantToggled(bool),
BrushVariantAmountChanged(f32),
BrushImportAbr,
BrushImportAbrFile(Result<Vec<hcie_engine_api::BrushPreset>, String>),
ResetToolDefaults,
@@ -955,11 +967,13 @@ pub enum Message {
CopyImage,
CutImage,
PasteImage,
PasteAsNewLayer,
CopyText(String),
PasteText,
ClipboardResult(Result<Option<String>, String>),
ImageCopied(Result<(), String>),
ImagePasted(Result<Option<(Vec<u8>, u32, u32)>, String>),
ImagePastedAsNewLayer(Result<Option<(Vec<u8>, u32, u32)>, String>),
// ── File I/O (with rfd) ─────────────────────────────
OpenFileRfd,
@@ -1282,6 +1296,8 @@ fn build_brush_tip(state: &ToolState) -> BrushTip {
opacity: state.brush_opacity,
hardness: state.brush_hardness,
spacing: state.brush_spacing,
color_variant: state.brush_color_variant,
variant_amount: state.brush_variant_amount,
..BrushTip::default()
}
}
@@ -1666,6 +1682,7 @@ impl HcieIcedApp {
transform_preview_base: None,
internal_clipboard: None,
pending_paste: None,
pending_paste_as_new_layer: false,
selection_mask: None,
selection_mask_dirty: std::cell::Cell::new(false),
selection_bounds: None,
@@ -1804,7 +1821,6 @@ impl HcieIcedApp {
vector_shapes_snapshot: None,
cached_custom_shapes: Vec::new(),
vector_drag_last: None,
vector_drag_last_render: None,
vector_drag_last_bounds: None,
vector_drag_last_angle: None,
vector_drag_session: None,
@@ -2194,7 +2210,6 @@ impl HcieIcedApp {
self.vector_shapes_snapshot = None;
self.vector_drag_session = None;
self.vector_drag_last = None;
self.vector_drag_last_render = None;
self.vector_drag_last_bounds = None;
self.vector_drag_last_angle = None;
if let Some(doc) = self.documents.get_mut(self.active_doc) {
@@ -3331,6 +3346,17 @@ impl HcieIcedApp {
self.documents[self.active_doc].modified = true;
self.refresh_composite_if_needed();
// If the background thread hasn't finished yet, schedule
// a delayed poll so the history entry is committed.
if self.documents[self.active_doc].engine.has_pending_history() {
return Task::perform(
async {
tokio::time::sleep(std::time::Duration::from_millis(8)).await;
},
|_| Message::CompositeRefreshPending,
);
}
} // end if !is_selection_tool
}
@@ -3445,6 +3471,32 @@ impl HcieIcedApp {
self.refresh_composite_if_needed();
}
// Poll pending background history commits. The brush engine's
// end_stroke runs a background thread that may not finish before
// the first commit_pending_history() call. This message retries
// at 16 ms intervals until the queue drains.
Message::CompositeRefreshPending => {
let doc = &mut self.documents[self.active_doc];
let committed = doc.engine.commit_pending_history();
if committed && doc.engine.has_pending_history() {
// Background thread still producing — schedule another poll.
self.refresh_composite_if_needed();
return Task::perform(
async {
tokio::time::sleep(std::time::Duration::from_millis(16)).await;
},
|_| Message::CompositeRefreshPending,
);
}
// All done — refresh composite and update cached history list.
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.refresh_composite_if_needed();
}
// ── Layer operations ──────────────────────────
Message::LayerSelect(id) => {
if self.preview_baseline.is_some() {
@@ -4688,6 +4740,16 @@ impl HcieIcedApp {
self.settings.tool_settings.brush_spacing = spacing;
let _ = self.settings.save();
}
Message::BrushColorVariantToggled(variant) => {
self.tool_state.brush_color_variant = variant;
self.settings.update_from_tool_state(&self.tool_state);
let _ = self.settings.save();
}
Message::BrushVariantAmountChanged(amount) => {
self.tool_state.brush_variant_amount = amount;
self.settings.update_from_tool_state(&self.tool_state);
let _ = self.settings.save();
}
Message::SelectionToleranceChanged(tol) => {
self.settings.tool_settings.magic_wand_tolerance = tol as f32;
self.settings.tool_settings.flood_fill_tolerance = tol as f32;
@@ -5076,9 +5138,26 @@ impl HcieIcedApp {
// EGUI's "paste proceeds as-is" cancel behavior).
if matches!(self.active_dialog, ActiveDialog::ExpandCanvas { .. }) {
let doc = &mut self.documents[self.active_doc];
let as_new_layer = doc.pending_paste_as_new_layer;
if let Some((pixels, w, h)) = doc.pending_paste.take() {
doc.pending_paste_as_new_layer = false;
let canvas_w = doc.engine.canvas_width();
let canvas_h = doc.engine.canvas_height();
if as_new_layer {
// Paste as new layer: create layer with centered (possibly clipped) pixels.
let centered = selection::clipboard::centered_layer_pixels(
&pixels, w, h, canvas_w, canvas_h,
);
let id = doc.engine.add_layer("Pasted Layer");
doc.engine.set_layer_pixels(id, centered);
doc.engine.set_active_layer(id);
doc.modified = true;
self.refresh_composite_if_needed();
self.active_dialog = ActiveDialog::None;
self.pending_document_close = None;
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
// Standard paste: enter transform mode.
doc.transform_layer_baseline = doc.engine.get_active_layer_pixels();
doc.transform_selection_baseline =
doc.engine.get_selection_mask().map(ToOwned::to_owned);
@@ -5130,19 +5209,33 @@ impl HcieIcedApp {
} = self.active_dialog
{
let doc = &mut self.documents[self.active_doc];
let as_new_layer = doc.pending_paste_as_new_layer;
doc.engine.resize_canvas(paste_w, paste_h);
let canvas_w = doc.engine.canvas_width();
let canvas_h = doc.engine.canvas_height();
if let Some((pixels, w, h)) = doc.pending_paste.take() {
doc.transform_layer_baseline = doc.engine.get_active_layer_pixels();
doc.transform_selection_baseline =
doc.engine.get_selection_mask().map(ToOwned::to_owned);
doc.transform_source = None;
doc.transform_preview_base = Some(doc.composite_raw.clone());
doc.selection_transform = selection::clipboard::centered_transform(
pixels, w, h, canvas_w, canvas_h,
);
render_transform_preview(doc, None);
doc.pending_paste_as_new_layer = false;
if as_new_layer {
// Paste as new layer: create layer with centered pixels on expanded canvas.
let centered = selection::clipboard::centered_layer_pixels(
&pixels, w, h, canvas_w, canvas_h,
);
let id = doc.engine.add_layer("Pasted Layer");
doc.engine.set_layer_pixels(id, centered);
doc.engine.set_active_layer(id);
doc.modified = true;
} else {
// Standard paste: enter transform mode.
doc.transform_layer_baseline = doc.engine.get_active_layer_pixels();
doc.transform_selection_baseline =
doc.engine.get_selection_mask().map(ToOwned::to_owned);
doc.transform_source = None;
doc.transform_preview_base = Some(doc.composite_raw.clone());
doc.selection_transform = selection::clipboard::centered_transform(
pixels, w, h, canvas_w, canvas_h,
);
render_transform_preview(doc, None);
}
}
self.active_dialog = ActiveDialog::None;
self.refresh_composite_if_needed();
@@ -5261,6 +5354,7 @@ impl HcieIcedApp {
transform_preview_base: None,
internal_clipboard: None,
pending_paste: None,
pending_paste_as_new_layer: false,
selection_mask: None,
selection_mask_dirty: std::cell::Cell::new(false),
selection_bounds: None,
@@ -6392,19 +6486,6 @@ impl HcieIcedApp {
return Task::none();
}
// Frame-skip throttle: skip processing if less than ~16ms since
// the last update to keep the overlay drawing at ~60 FPS and avoid
// flooding the message queue.
let now = std::time::Instant::now();
let min_interval = std::time::Duration::from_secs_f32(1.0 / 60.0);
if let Some(last) = self.vector_drag_last_render {
if now.duration_since(last) < min_interval {
// Still update pointer tracking even when skipping preview
self.vector_drag_last = Some((x, y));
return Task::none();
}
}
let shape = crate::vector_edit::transform_shape(
&session,
(x, y),
@@ -6422,7 +6503,6 @@ impl HcieIcedApp {
self.documents[self.active_doc].vector_drag_preview = Some(shape);
self.documents[self.active_doc].modified = true;
self.vector_drag_last = Some((x, y));
self.vector_drag_last_render = Some(now);
self.vector_drag_last_bounds = Some((x1, y1, x2, y2));
self.vector_drag_last_angle = Some(angle);
self.settings.tool_settings.vector_angle = angle.to_degrees();
@@ -6474,7 +6554,6 @@ impl HcieIcedApp {
}
// Commit the move/resize/rotate: if shapes changed, push history snapshot.
self.vector_drag_last = None;
self.vector_drag_last_render = None;
self.vector_drag_last_bounds = None;
self.vector_drag_last_angle = None;
self.vector_drag_session = None;
@@ -7534,6 +7613,32 @@ impl HcieIcedApp {
Message::ImagePasted,
);
}
Message::PasteAsNewLayer => {
// Check internal clipboard first
if let Some(transform) = self.documents[self.active_doc].internal_clipboard.clone() {
let doc = &mut self.documents[self.active_doc];
let canvas_w = doc.engine.canvas_width();
let canvas_h = doc.engine.canvas_height();
let centered = selection::clipboard::centered_layer_pixels(
&transform.pixels,
transform.width,
transform.height,
canvas_w,
canvas_h,
);
let id = doc.engine.add_layer("Pasted Layer");
doc.engine.set_layer_pixels(id, centered);
doc.engine.set_active_layer(id);
doc.modified = true;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
// Fall back to system clipboard
return Task::perform(
async { crate::io::clipboard::paste_image_from_clipboard() },
Message::ImagePastedAsNewLayer,
);
}
Message::CopyText(text) => {
let text = text.clone();
return Task::perform(
@@ -7597,6 +7702,40 @@ impl HcieIcedApp {
Message::ImagePasted(Err(e)) => {
log::error!("Failed to paste image: {}", e);
}
Message::ImagePastedAsNewLayer(Ok(Some((pixels, w, h)))) => {
log::info!("Paste as new layer: {}x{}", w, h);
let doc = &mut self.documents[self.active_doc];
let canvas_w = doc.engine.canvas_width();
let canvas_h = doc.engine.canvas_height();
if w > canvas_w || h > canvas_h {
// Stash and ask before pasting — need canvas expansion.
doc.pending_paste = Some((pixels, w, h));
doc.pending_paste_as_new_layer = true;
self.active_dialog = ActiveDialog::ExpandCanvas {
paste_w: w,
paste_h: h,
canvas_w,
canvas_h,
};
} else {
// Fits — create new layer with centered pixels directly.
let centered = selection::clipboard::centered_layer_pixels(
&pixels, w, h, canvas_w, canvas_h,
);
let id = doc.engine.add_layer("Pasted Layer");
doc.engine.set_layer_pixels(id, centered);
doc.engine.set_active_layer(id);
doc.modified = true;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
Message::ImagePastedAsNewLayer(Ok(None)) => {
log::info!("No image in clipboard for paste-as-new-layer");
}
Message::ImagePastedAsNewLayer(Err(e)) => {
log::error!("Failed to paste as new layer: {}", e);
}
// ── File I/O (with rfd) ───────────────────────
Message::OpenFileRfd => {
@@ -8090,6 +8229,7 @@ impl HcieIcedApp {
transform_preview_base: None,
internal_clipboard: None,
pending_paste: None,
pending_paste_as_new_layer: false,
selection_mask: None,
selection_mask_dirty: std::cell::Cell::new(false),
selection_bounds: None,
@@ -8470,6 +8610,9 @@ impl HcieIcedApp {
MenuCommand::PasteSpecial => {
return Task::perform(async {}, |_| Message::PasteImage)
}
MenuCommand::PasteAsNewLayer => {
return Task::perform(async {}, |_| Message::PasteAsNewLayer)
}
MenuCommand::Fill => {
let doc = &mut self.documents[self.active_doc];
let fg = self.fg_color;
@@ -543,6 +543,497 @@ impl OverlayProgram {
(cx + dx * cos - dy * sin, cy + dx * sin + dy * cos)
}
/// Draws the actual shape geometry for a vector shape preview during a drag.
///
/// Instead of drawing just the rotated bounding box, this renders the real
/// shape outline (ellipse, star, polygon, heart, etc.) with the shape's
/// rotation applied, giving the user an accurate preview of the final result.
///
/// Used by both the in-progress `vector_draw` creation preview and the
/// `vector_drag_preview` transform overlay.
fn draw_vector_shape_preview(
&self,
frame: &mut Frame,
shape: &hcie_engine_api::VectorShape,
origin_x: f32,
origin_y: f32,
zoom: f32,
stroke_style: Stroke,
) {
let (px1, py1, px2, py2) = shape.normalized_bounds();
let pang = shape.angle();
let cx = (px1 + px2) * 0.5;
let cy = (py1 + py2) * 0.5;
let bw = (px2 - px1).abs();
let bh = (py2 - py1).abs();
let cos_a = pang.cos();
let sin_a = pang.sin();
let rotate = |x: f32, y: f32| -> (f32, f32) {
let dx = x - cx;
let dy = y - cy;
(cx + dx * cos_a - dy * sin_a, cy + dx * sin_a + dy * cos_a)
};
let sx = |x: f32, y: f32| -> Point {
let (rx, ry) = rotate(x, y);
Point::new(origin_x + rx * zoom, origin_y + ry * zoom)
};
match shape {
hcie_engine_api::VectorShape::Circle { .. } => {
let segments = (((bw + bh) * 0.5).max(1.0) * 6.0).max(32.0).ceil() as i32;
let ellipse_path = Path::new(|b| {
for i in 0..=segments {
let t = i as f32 / segments as f32 * std::f32::consts::TAU;
let ex = cx + (bw / 2.0) * t.cos();
let ey = cy + (bh / 2.0) * t.sin();
let (rex, rey) = rotate(ex, ey);
let px = origin_x + rex * zoom;
let py = origin_y + rey * zoom;
if i == 0 {
b.move_to(Point::new(px, py));
} else {
b.line_to(Point::new(px, py));
}
}
b.close();
});
frame.stroke(&ellipse_path, stroke_style);
}
hcie_engine_api::VectorShape::Line { x1, y1, x2, y2, .. } => {
let p0 = sx(*x1, *y1);
let p1 = sx(*x2, *y2);
frame.stroke(&Path::line(p0, p1), stroke_style);
}
hcie_engine_api::VectorShape::Star {
points,
inner_radius,
..
} => {
let outer_r = (bw.min(bh) / 2.0).max(1.0);
let inner_r = outer_r * inner_radius.max(0.1);
let n = (*points).max(3);
let star_path = Path::new(|b| {
for i in 0..(n * 2) {
let t = std::f32::consts::PI * 2.0 * i as f32 / (n * 2) as f32
- std::f32::consts::PI / 2.0;
let r = if i % 2 == 0 { outer_r } else { inner_r };
let px = cx + r * t.cos();
let py = cy + r * t.sin();
let (rpx, rpy) = rotate(px, py);
let spx = origin_x + rpx * zoom;
let spy = origin_y + rpy * zoom;
if i == 0 {
b.move_to(Point::new(spx, spy));
} else {
b.line_to(Point::new(spx, spy));
}
}
b.close();
});
frame.stroke(&star_path, stroke_style);
}
hcie_engine_api::VectorShape::Polygon { sides, .. } => {
let r = (bw.min(bh) / 2.0).max(1.0);
let n = (*sides).max(3);
let poly_path = Path::new(|b| {
for i in 0..n {
let t = std::f32::consts::PI * 2.0 * i as f32 / n as f32
- std::f32::consts::PI / 2.0;
let px = cx + r * t.cos();
let py = cy + r * t.sin();
let (rpx, rpy) = rotate(px, py);
let spx = origin_x + rpx * zoom;
let spy = origin_y + rpy * zoom;
if i == 0 {
b.move_to(Point::new(spx, spy));
} else {
b.line_to(Point::new(spx, spy));
}
}
b.close();
});
frame.stroke(&poly_path, stroke_style);
}
hcie_engine_api::VectorShape::Arrow { x1, y1, x2, y2, .. } => {
let dx = x2 - x1;
let dy = y2 - y1;
let head_angle = dy.atan2(dx);
let arrow_angle = std::f32::consts::PI / 6.0;
let arrow_thickness = 1.0;
let head_len = arrow_thickness * 3.0;
let p0 = sx(*x1, *y1);
let p1 = sx(*x2, *y2);
let rx2 = origin_x + x2 * zoom;
let ry2 = origin_y + y2 * zoom;
let rx3 = rx2 - head_len * (head_angle + arrow_angle).cos();
let ry3 = ry2 - head_len * (head_angle + arrow_angle).sin();
let rx4 = rx2 - head_len * (head_angle - arrow_angle).cos();
let ry4 = ry2 - head_len * (head_angle - arrow_angle).sin();
frame.stroke(&Path::line(p0, p1), stroke_style);
frame.stroke(&Path::line(p1, Point::new(rx3, ry3)), stroke_style);
frame.stroke(&Path::line(p1, Point::new(rx4, ry4)), stroke_style);
}
hcie_engine_api::VectorShape::Rhombus { .. } => {
let top = sx(cx, py1);
let right = sx(px2, cy);
let bottom = sx(cx, py2);
let left = sx(px1, cy);
let rhombus_path = Path::new(|b| {
b.move_to(top);
b.line_to(right);
b.line_to(bottom);
b.line_to(left);
b.close();
});
frame.stroke(&rhombus_path, stroke_style);
}
hcie_engine_api::VectorShape::Cylinder { .. } => {
let segments = 36;
let left_scr = origin_x + px1 * zoom;
let right_scr = origin_x + px2 * zoom;
let top_scr = origin_y + py1 * zoom;
let bottom_scr = origin_y + py2 * zoom;
let hw = (right_scr - left_scr) / 2.0;
let ellipse_ry = bh * 0.1 * zoom;
let top_cy = top_scr + ellipse_ry;
let bot_cy = bottom_scr - ellipse_ry;
let draw_ellipse = |frame: &mut Frame, cy: f32| {
let ellipse_path = Path::new(|b| {
for i in 0..=segments {
let t = i as f32 / segments as f32 * std::f32::consts::TAU;
let px = left_scr + hw + hw * t.cos();
let py = cy + ellipse_ry * t.sin();
if i == 0 {
b.move_to(Point::new(px, py));
} else {
b.line_to(Point::new(px, py));
}
}
b.close();
});
frame.stroke(&ellipse_path, stroke_style.clone());
};
draw_ellipse(frame, top_cy);
draw_ellipse(frame, bot_cy);
frame.stroke(
&Path::line(
Point::new(left_scr, top_cy),
Point::new(left_scr, bot_cy),
),
stroke_style.clone(),
);
frame.stroke(
&Path::line(
Point::new(right_scr, top_cy),
Point::new(right_scr, bot_cy),
),
stroke_style.clone(),
);
}
hcie_engine_api::VectorShape::Heart { .. } => {
let hw = (bw / 2.0).max(1.0);
let hh = (bh / 2.0).max(1.0);
let steps = 48;
let heart_path = Path::new(|b| {
for i in 0..=steps {
let t = std::f32::consts::TAU * i as f32 / steps as f32;
let hx = cx + hw * (16.0 * t.sin().powi(3)) / 16.0;
let hy = cy
- hh * (13.0 * t.cos()
- 5.0 * (2.0 * t).cos()
- 2.0 * (3.0 * t).cos()
- (4.0 * t).cos())
/ 16.0;
let (rpx, rpy) = rotate(hx, hy);
let spx = origin_x + rpx * zoom;
let spy = origin_y + rpy * zoom;
if i == 0 {
b.move_to(Point::new(spx, spy));
} else {
b.line_to(Point::new(spx, spy));
}
}
b.close();
});
frame.stroke(&heart_path, stroke_style);
}
hcie_engine_api::VectorShape::Bubble { .. } => {
let rx = (bw / 2.0).max(1.0);
let ry = (bh / 2.0).max(1.0);
let segments = 48;
let bubble_path = Path::new(|b| {
for i in 0..=segments {
let t = i as f32 / segments as f32 * std::f32::consts::TAU;
let hx = cx + rx * t.cos();
let hy = cy + ry * t.sin();
let (rpx, rpy) = rotate(hx, hy);
let spx = origin_x + rpx * zoom;
let spy = origin_y + rpy * zoom;
if i == 0 {
b.move_to(Point::new(spx, spy));
} else {
b.line_to(Point::new(spx, spy));
}
}
b.close();
});
frame.stroke(&bubble_path, stroke_style.clone());
let tail_inner = sx(cx + rx * 0.2, cy + ry * 0.8);
let tail_tip = sx(cx - rx * 0.6, cy + ry * 1.4);
let tail_outer = sx(cx - rx * 0.6, cy + ry * 0.8);
let tail_path = Path::new(|b| {
b.move_to(tail_inner);
b.line_to(tail_tip);
b.line_to(tail_outer);
b.close();
});
frame.stroke(&tail_path, stroke_style);
}
hcie_engine_api::VectorShape::Gear { .. } => {
let r_base = (bw.min(bh) / 2.0).max(1.0);
let outer_r = r_base * 0.9;
let inner_r = r_base * 0.6;
let hole_r = r_base * 0.25;
let teeth = 8;
let gear_path = Path::new(|b| {
for i in 0..(teeth * 2) {
let a = std::f32::consts::PI * 2.0 * i as f32 / (teeth * 2) as f32
- std::f32::consts::PI / 2.0;
let r = if i % 2 == 0 { outer_r } else { inner_r };
let gx = cx + r * a.cos();
let gy = cy + r * a.sin();
let (rpx, rpy) = rotate(gx, gy);
let spx = origin_x + rpx * zoom;
let spy = origin_y + rpy * zoom;
if i == 0 {
b.move_to(Point::new(spx, spy));
} else {
b.line_to(Point::new(spx, spy));
}
}
b.close();
});
frame.stroke(&gear_path, stroke_style.clone());
let hole_path = Path::new(|b| {
let segments = 36;
for i in 0..=segments {
let t = i as f32 / segments as f32 * std::f32::consts::TAU;
let hx = cx + hole_r * t.cos();
let hy = cy + hole_r * t.sin();
let (rpx, rpy) = rotate(hx, hy);
let spx = origin_x + rpx * zoom;
let spy = origin_y + rpy * zoom;
if i == 0 {
b.move_to(Point::new(spx, spy));
} else {
b.line_to(Point::new(spx, spy));
}
}
b.close();
});
frame.stroke(&hole_path, stroke_style);
}
hcie_engine_api::VectorShape::Cross { .. } => {
let arm = (bw.min(bh) * 0.25).max(1.0);
let cross_path = Path::new(|b| {
let points = [
(cx - arm, py1),
(cx + arm, py1),
(cx + arm, cy - arm),
(px2, cy - arm),
(px2, cy + arm),
(cx + arm, cy + arm),
(cx + arm, py2),
(cx - arm, py2),
(cx - arm, cy + arm),
(px1, cy + arm),
(px1, cy - arm),
(cx - arm, cy - arm),
];
for (i, (px, py)) in points.iter().enumerate() {
let (rpx, rpy) = rotate(*px, *py);
let spx = origin_x + rpx * zoom;
let spy = origin_y + rpy * zoom;
if i == 0 {
b.move_to(Point::new(spx, spy));
} else {
b.line_to(Point::new(spx, spy));
}
}
b.close();
});
frame.stroke(&cross_path, stroke_style);
}
hcie_engine_api::VectorShape::Crescent { .. } => {
let rx = (bw / 2.0).max(1.0);
let ry = (bh / 2.0).max(1.0);
let segments = 48;
let start_angle = -std::f32::consts::PI * 0.2;
let end_angle = std::f32::consts::PI * 1.2;
let crescent_path = Path::new(|b| {
for i in 0..=segments {
let a = start_angle
+ (end_angle - start_angle) * i as f32 / segments as f32;
let hx = cx + rx * a.cos();
let hy = cy + ry * a.sin();
let (rpx, rpy) = rotate(hx, hy);
let spx = origin_x + rpx * zoom;
let spy = origin_y + rpy * zoom;
if i == 0 {
b.move_to(Point::new(spx, spy));
} else {
b.line_to(Point::new(spx, spy));
}
}
let inner_cx = cx + rx * 0.4;
let inner_rx = rx * 0.7;
let inner_ry = ry * 0.7;
let p_start_x = cx + rx * start_angle.cos();
let p_start_y = cy + ry * start_angle.sin();
let inner_p_start_x = inner_cx + inner_rx * start_angle.cos();
let inner_p_start_y = cy + inner_ry * start_angle.sin();
let diff_start_x = p_start_x - inner_p_start_x;
let diff_start_y = p_start_y - inner_p_start_y;
let p_end_x = cx + rx * end_angle.cos();
let p_end_y = cy + ry * end_angle.sin();
let inner_p_end_x = inner_cx + inner_rx * end_angle.cos();
let inner_p_end_y = cy + inner_ry * end_angle.sin();
let diff_end_x = p_end_x - inner_p_end_x;
let diff_end_y = p_end_y - inner_p_end_y;
for i in (0..=segments).rev() {
let t = i as f32 / segments as f32;
let a = start_angle + (end_angle - start_angle) * t;
let raw_x = inner_cx + inner_rx * a.cos();
let raw_y = cy + inner_ry * a.sin();
let offset_x = (1.0 - t) * diff_start_x + t * diff_end_x;
let offset_y = (1.0 - t) * diff_start_y + t * diff_end_y;
let (rpx, rpy) = rotate(raw_x + offset_x, raw_y + offset_y);
let spx = origin_x + rpx * zoom;
let spy = origin_y + rpy * zoom;
b.line_to(Point::new(spx, spy));
}
b.close();
});
frame.stroke(&crescent_path, stroke_style);
}
hcie_engine_api::VectorShape::Bolt { .. } => {
let nx = (bw * 0.18).max(1.0);
let bolt_path = Path::new(|b| {
let points = [
(cx + nx, py1),
(px2, py1 + bh * 0.28),
(cx - nx, cy - bh * 0.05),
(cx + nx, cy + bh * 0.05),
(px1, py2 - bh * 0.28),
(cx - nx, py2),
];
for (i, (px, py)) in points.iter().enumerate() {
let (rpx, rpy) = rotate(*px, *py);
let spx = origin_x + rpx * zoom;
let spy = origin_y + rpy * zoom;
if i == 0 {
b.move_to(Point::new(spx, spy));
} else {
b.line_to(Point::new(spx, spy));
}
}
b.close();
});
frame.stroke(&bolt_path, stroke_style);
}
hcie_engine_api::VectorShape::Arrow4 { .. } => {
let arm_w = (bw * 0.125).max(1.0);
let arm_h = (bh * 0.125).max(1.0);
let head_extend_x = bw * 0.5 * 0.3;
let head_extend_y = bh * 0.5 * 0.3;
let arrow4_path = Path::new(|b| {
let points = [
(cx - arm_w, cy - arm_h),
(cx - arm_w, py1 + head_extend_y),
(cx, py1),
(cx + arm_w, py1 + head_extend_y),
(cx + arm_w, cy - arm_h),
(px2 - head_extend_x, cy - arm_h),
(px2, cy),
(px2 - head_extend_x, cy + arm_h),
(cx + arm_w, cy + arm_h),
(cx + arm_w, py2 - head_extend_y),
(cx, py2),
(cx - arm_w, py2 - head_extend_y),
(cx - arm_w, cy + arm_h),
(px1 + head_extend_x, cy + arm_h),
(px1, cy),
(px1 + head_extend_x, cy - arm_h),
];
for (i, (px, py)) in points.iter().enumerate() {
let (rpx, rpy) = rotate(*px, *py);
let spx = origin_x + rpx * zoom;
let spy = origin_y + rpy * zoom;
if i == 0 {
b.move_to(Point::new(spx, spy));
} else {
b.line_to(Point::new(spx, spy));
}
}
b.close();
});
frame.stroke(&arrow4_path, stroke_style);
}
hcie_engine_api::VectorShape::Rect { .. } => {
let corners = [(px1, py1), (px2, py1), (px2, py2), (px1, py2)];
let v_path = Path::new(|b| {
for (i, &(px, py)) in corners.iter().enumerate() {
let (rpx, rpy) = rotate(px, py);
let spx = origin_x + rpx * zoom;
let spy = origin_y + rpy * zoom;
if i == 0 {
b.move_to(Point::new(spx, spy));
} else {
b.line_to(Point::new(spx, spy));
}
}
b.close();
});
frame.stroke(&v_path, stroke_style);
}
hcie_engine_api::VectorShape::FreePath { pts, .. } => {
if pts.is_empty() {
return;
}
let path = Path::new(|b| {
for (i, pt) in pts.iter().enumerate() {
let (rpx, rpy) = rotate(pt[0], pt[1]);
let spx = origin_x + rpx * zoom;
let spy = origin_y + rpy * zoom;
if i == 0 {
b.move_to(Point::new(spx, spy));
} else {
b.line_to(Point::new(spx, spy));
}
}
});
frame.stroke(&path, stroke_style);
}
hcie_engine_api::VectorShape::SvgShape { .. } => {
let corners = [(px1, py1), (px2, py1), (px2, py2), (px1, py2)];
let svg_path = Path::new(|b| {
for (i, &(px, py)) in corners.iter().enumerate() {
let (rpx, rpy) = rotate(px, py);
let spx = origin_x + rpx * zoom;
let spy = origin_y + rpy * zoom;
if i == 0 {
b.move_to(Point::new(spx, spy));
} else {
b.line_to(Point::new(spx, spy));
}
}
b.close();
});
frame.stroke(&svg_path, stroke_style);
}
}
}
/// Draws a small cursor glyph next to the hovered vector handle.
///
/// Purpose: Gives immediate visual feedback for the operation that will
@@ -1295,42 +1786,20 @@ impl canvas::Program<Message> for OverlayProgram {
// A solid bright outline is used instead of dashed so the preview is
// clearly visible during fast pointer movements.
if let Some(preview) = self.vector_drag_preview.as_ref() {
let (px1, py1, px2, py2) = preview.normalized_bounds();
let pang = preview.angle();
let cx = (px1 + px2) * 0.5;
let cy = (py1 + py2) * 0.5;
let cos_a = pang.cos();
let sin_a = pang.sin();
let rotate = |x: f32, y: f32| -> (f32, f32) {
let dx = x - cx;
let dy = y - cy;
(cx + dx * cos_a - dy * sin_a, cy + dx * sin_a + dy * cos_a)
let stroke_style = Stroke {
style: canvas::stroke::Style::Solid(
iced::Color::from_rgba(0.2, 0.9, 0.4, 0.95),
),
width: (2.5 / self.zoom.max(1.0)).max(1.0),
..Default::default()
};
let corners = [(px1, py1), (px2, py1), (px2, py2), (px1, py2)];
let rotated_corners: Vec<(f32, f32)> =
corners.iter().map(|&(x, y)| rotate(x, y)).collect();
let preview_path = Path::new(|b| {
for (i, &(rx, ry)) in rotated_corners.iter().enumerate() {
let sx = origin_x + rx * self.zoom;
let sy = origin_y + ry * self.zoom;
if i == 0 {
b.move_to(Point::new(sx, sy));
} else {
b.line_to(Point::new(sx, sy));
}
}
b.close();
});
// Solid bright outline for fast visible feedback
frame.stroke(
&preview_path,
Stroke {
style: canvas::stroke::Style::Solid(
iced::Color::from_rgba(0.2, 0.9, 0.4, 0.95),
),
width: (2.5 / self.zoom.max(1.0)).max(1.0),
..Default::default()
},
self.draw_vector_shape_preview(
&mut frame,
preview,
origin_x,
origin_y,
self.zoom,
stroke_style,
);
}
@@ -182,6 +182,8 @@ pub fn panel_body<'a>(
app.tool_state.brush_size,
app.tool_state.brush_opacity,
app.tool_state.brush_hardness,
app.tool_state.brush_color_variant,
app.tool_state.brush_variant_amount,
app.brush_category,
&app.tool_state.imported_brushes,
app.tool_state.active_brush_preset.as_ref(),
@@ -13,7 +13,7 @@ use crate::panels::styles;
use crate::theme::ThemeColors;
use crate::widgets::plain_slider::plain_slider;
use hcie_engine_api::BrushStyle;
use iced::widget::{button, column, container, horizontal_rule, row, scrollable, svg, text};
use iced::widget::{button, checkbox, column, container, horizontal_rule, row, scrollable, svg, text};
use iced::{Element, Length};
use std::sync::OnceLock;
@@ -412,6 +412,8 @@ pub fn view(
current_size: f32,
current_opacity: f32,
current_hardness: f32,
brush_color_variant: bool,
brush_variant_amount: f32,
active_category: BrushCategory,
imported_presets: &[hcie_engine_api::BrushPreset],
active_brush_preset: Option<&hcie_engine_api::BrushPreset>,
@@ -422,7 +424,7 @@ pub fn view(
(BrushCategory::All, "All brushes", "icons/panel-brush.svg"),
(BrushCategory::Drawing, "Drawing", "icons/pen.svg"),
(BrushCategory::Painting, "Painting", "icons/brush.svg"),
(BrushCategory::Watercolor, "Watercolor", "icons/brush.svg"),
(BrushCategory::Watercolor, "Watercolor", "icons/watercolor.svg"),
(BrushCategory::Effects, "Effects", "icons/glow.svg"),
(BrushCategory::Custom, "Custom", "icons/star.svg"),
(BrushCategory::Imported, "Imported", "icons/import_abr.svg"),
@@ -720,6 +722,21 @@ pub fn view(
colors,
Message::BrushHardnessChanged,
);
let variant_slider = plain_slider(
"Variant",
brush_variant_amount,
0.0..=1.0,
0.01,
"%",
0,
colors,
Message::BrushVariantAmountChanged,
);
let color_variant_check = checkbox("Color variant", brush_color_variant)
.on_toggle(Message::BrushColorVariantToggled)
.size(16)
.spacing(6);
// Live preview — show current brush tip
let preview_handle = get_brush_preview(current_style);
@@ -763,6 +780,8 @@ pub fn view(
size_slider,
opacity_slider,
hardness_slider,
color_variant_check,
variant_slider,
]
.spacing(2)
.padding(4);
@@ -21,9 +21,10 @@ use crate::theme::ThemeColors;
use crate::widgets::plain_slider::plain_slider;
use hcie_engine_api::{BlendMode, LayerInfo, LayerType};
use iced::widget::{
button, column, container, horizontal_rule, pick_list, row, scrollable, text, Space,
button, column, container, horizontal_rule, pick_list, row, scrollable, svg, text, Space,
};
use iced::{Element, Length};
use std::path::Path;
/// Wrapper for BlendMode to provide Display for pick_list.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -134,6 +135,38 @@ fn flat_btn_style(colors: ThemeColors) -> iced::widget::button::Style {
}
}
/// Helper to create a small SVG icon button.
/// Falls back to a text button if the SVG file is missing.
fn svg_icon_btn<'a>(
icon_path: &str,
_tip: &'a str,
msg: Message,
colors: ThemeColors,
size: f32,
) -> Element<'a, Message> {
let full_path = Path::new("hcie-iced-app/assets").join(icon_path);
if full_path.exists() {
let icon = svg(svg::Handle::from_path(&full_path))
.width(size)
.height(size)
.style(move |_theme: &iced::Theme, _status: svg::Status| svg::Style {
color: Some(colors.text_primary),
});
button(icon)
.on_press(msg)
.padding([2, 3])
.style(move |_theme, _status: iced::widget::button::Status| flat_btn_style(colors))
.into()
} else {
// Fallback to text
button(text(_tip).size((size * 0.6) as u16))
.on_press(msg)
.padding([2, 3])
.style(move |_theme, _status: iced::widget::button::Status| flat_btn_style(colors))
.into()
}
}
/// Build the thumbnail element for a layer row from the cache or as a fallback icon.
fn layer_thumbnail<'a>(
entry: &LayerEntry<'a>,
@@ -292,32 +325,27 @@ pub fn view<'a>(
};
// Visibility toggle (eye icon)
let vis_char = if info.visible {
"\u{1F441}"
let vis_icon_path = if info.visible {
"icons/visible.svg"
} else {
"\u{1F441}"
"icons/eye_closed.svg"
};
let vis_color = if info.visible {
colors.text_primary
} else {
iced::Color {
a: colors.text_secondary.a * 0.4,
..colors.text_secondary
}
};
let vis_btn = button(text(vis_char).size(11).style(move |_theme: &iced::Theme| {
iced::widget::text::Style {
color: Some(vis_color),
}
}))
.on_press(Message::LayerToggleVisibility(info.id))
.padding([0, 2])
.style(move |_theme, _status| flat_btn_style(colors));
let vis_tip = if info.visible { "Hide" } else { "Show" };
let vis_btn = svg_icon_btn(
vis_icon_path,
vis_tip,
Message::LayerToggleVisibility(info.id),
colors,
14.0,
);
let row_lock = button(text(if info.locked { "L" } else { "-" }).size(9))
.on_press(Message::LayerToggleLock(info.id))
.padding([0, 2])
.style(move |_theme, _status| flat_btn_style(colors));
let row_lock = svg_icon_btn(
"icons/lock.svg",
if info.locked { "Unlock" } else { "Lock" },
Message::LayerToggleLock(info.id),
colors,
12.0,
);
// Thumbnail
let thumb_elem = layer_thumbnail(entry, thumb_cache, thumb_gen, colors);
@@ -454,22 +482,31 @@ pub fn view<'a>(
}
// ── Bottom toolbar ─────────────────────────────────────
let add_btn = button(text("+Layer").size(BODY))
.on_press(Message::LayerAdd)
.padding([5, 8])
.style(move |_theme, _status| flat_btn_style(colors));
let group_btn = button(text("+Group").size(BODY))
.on_press(Message::LayerAddGroup)
.padding([5, 8])
.style(move |_theme, _status| flat_btn_style(colors));
let add_btn = svg_icon_btn(
"icons/new_file.svg",
"New Layer",
Message::LayerAdd,
colors,
16.0,
);
let group_btn = svg_icon_btn(
"icons/panel-layers.svg",
"New Group",
Message::LayerAddGroup,
colors,
16.0,
);
let flatten_btn = button(text("Flatten").size(BODY))
.on_press(Message::LayerFlatten)
.padding([5, 8])
.style(move |_theme, _status| flat_btn_style(colors));
let del_btn = button(text("\u{2716}").size(BODY))
.on_press(Message::LayerDelete(active_layer_id))
.padding([3, 6])
.style(move |_theme, _status| flat_btn_style(colors));
let del_btn = svg_icon_btn(
"icons/close.svg",
"Delete Layer",
Message::LayerDelete(active_layer_id),
colors,
14.0,
);
let move_up_btn = button(text("\u{25B2}").size(BODY))
.on_press(Message::LayerMoveUp(active_layer_id))
.padding([3, 4])
@@ -46,6 +46,7 @@ pub enum MenuCommand {
ClearPixels,
Paste,
PasteSpecial,
PasteAsNewLayer,
Fill,
Stroke,
FreeTransform,
@@ -343,9 +344,14 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
MenuItem::separator(),
MenuItem::command_with_shortcut(
"Paste Special",
"Shift+Ctrl+V",
"",
MenuCommand::PasteSpecial,
),
MenuItem::command_with_shortcut(
"Paste as New Layer",
"Ctrl+Shift+V",
MenuCommand::PasteAsNewLayer,
),
MenuItem::separator(),
MenuItem::command_with_shortcut("Fill...", "Shift+F5", MenuCommand::Fill),
MenuItem::command("Stroke...", MenuCommand::Stroke),
@@ -1083,6 +1089,11 @@ pub fn canvas_context_menu<'a>(
"Ctrl+V",
MenuCommand::Paste,
));
items.push(MenuItem::command_with_shortcut(
"Paste as New Layer",
"Ctrl+Shift+V",
MenuCommand::PasteAsNewLayer,
));
items.push(MenuItem::separator());
items.push(MenuItem::command("New Layer", MenuCommand::AddLayer));
@@ -17,7 +17,7 @@ use crate::settings::AppSettings;
use crate::theme::ThemeColors;
use crate::widgets::plain_slider::plain_slider;
use hcie_engine_api::Tool;
use iced::widget::{button, column, container, horizontal_rule, row, scrollable, text, text_input};
use iced::widget::{button, checkbox, column, container, horizontal_rule, row, scrollable, text, text_input};
use iced::{Element, Length};
/// Build the tool settings panel for the active tool.
@@ -123,6 +123,17 @@ fn brush_settings(
|v| Message::BrushSpacingChanged(v / 100.0),
c
),
checkbox("Color Variant", ts.brush_color_variant)
.on_toggle(Message::BrushColorVariantToggled)
.size(11),
prop_slider(
"Variant Amount",
ts.brush_variant_amount * 100.0,
0.0,
100.0,
|v| Message::BrushVariantAmountChanged(v / 100.0),
c
),
]
.spacing(4)
}
@@ -168,3 +168,33 @@ pub fn centered_transform(
rotation: 0.0,
})
}
/// Creates a full canvas-sized RGBA buffer with clipboard image centered.
///
/// Returns a `Vec<u8>` of size `canvas_w * canvas_h * 4` with the clipboard image
/// placed at the center, surrounded by transparent pixels. Used by the
/// "Paste as New Layer" feature to write directly into a new layer.
pub fn centered_layer_pixels(
pixels: &[u8],
img_w: u32,
img_h: u32,
canvas_w: u32,
canvas_h: u32,
) -> Vec<u8> {
let mut buf = vec![0u8; (canvas_w * canvas_h * 4) as usize];
let offset_x = ((canvas_w as i32 - img_w as i32) / 2).max(0) as u32;
let offset_y = ((canvas_h as i32 - img_h as i32) / 2).max(0) as u32;
let copy_w = img_w.min(canvas_w - offset_x);
let copy_h = img_h.min(canvas_h - offset_y);
for y in 0..copy_h {
let src_start = ((y * img_w + 0) * 4) as usize;
let src_end = ((y * img_w + copy_w) * 4) as usize;
let dst_start = (((y + offset_y) * canvas_w + offset_x) * 4) as usize;
if src_end <= pixels.len() && dst_start + (copy_w * 4) as usize <= buf.len() {
buf[dst_start..dst_start + (copy_w * 4) as usize]
.copy_from_slice(&pixels[src_start..src_end]);
}
}
buf
}
@@ -53,6 +53,12 @@ pub struct ToolSettings {
pub brush_hardness: f32,
pub brush_flow: f32,
pub brush_spacing: f32,
/// True when each brush dab should vary its color slightly.
#[serde(default)]
pub brush_color_variant: bool,
/// Magnitude of the per-dab color variation (0.0..1.0).
#[serde(default)]
pub brush_variant_amount: f32,
pub eraser_size: f32,
pub eraser_opacity: f32,
pub pen_size: f32,
@@ -184,6 +190,8 @@ impl Default for ToolSettings {
brush_hardness: 0.8,
brush_flow: 1.0,
brush_spacing: 1.0,
brush_color_variant: false,
brush_variant_amount: 0.0,
eraser_size: 20.0,
eraser_opacity: 1.0,
pen_size: 2.0,
@@ -334,6 +342,8 @@ impl AppSettings {
self.tool_settings.brush_size = tool_state.brush_size;
self.tool_settings.brush_opacity = tool_state.brush_opacity;
self.tool_settings.brush_hardness = tool_state.brush_hardness;
self.tool_settings.brush_color_variant = tool_state.brush_color_variant;
self.tool_settings.brush_variant_amount = tool_state.brush_variant_amount;
self.tool_settings.last_active_tool = format!("{:?}", tool_state.active_tool);
}
@@ -342,6 +352,8 @@ impl AppSettings {
tool_state.brush_size = self.tool_settings.brush_size;
tool_state.brush_opacity = self.tool_settings.brush_opacity;
tool_state.brush_hardness = self.tool_settings.brush_hardness;
tool_state.brush_color_variant = self.tool_settings.brush_color_variant;
tool_state.brush_variant_amount = self.tool_settings.brush_variant_amount;
}
}
@@ -5,6 +5,11 @@
//! **Logic & Workflow:** An Iced Canvas retains transient drag/edit state, maps pointer positions
//! to stepped values, captures numeric keyboard input while hovered, and draws a recessed track,
//! gradient value fill, spinner arrows, centered text, and hover elevation.
//! **Interaction model:**
//! - Click and drag anywhere on the track to adjust the value.
//! - Type any numeric character while hovering to start direct text input.
//! - Press `Enter` to commit the typed value, `Escape` to cancel.
//! - Click the spinner arrows for single-step increments/decrements.
//! **Side Effects / Dependencies:** Emits caller-provided messages; all state remains local to the
//! widget tree and all colors derive from `ThemeColors`.
@@ -17,7 +22,6 @@ use std::sync::Arc;
const CONTROL_HEIGHT: f32 = 22.0;
const SPINNER_WIDTH: f32 = 24.0;
const VALUE_EDIT_WIDTH: f32 = 72.0;
/// Persistent interaction state for one slider instance.
#[derive(Debug, Default)]
@@ -259,6 +263,7 @@ impl<Message> canvas::Program<Message> for PlainSlider<Message> {
return (canvas::event::Status::Ignored, None);
};
// Spinner arrows remain step controls.
if position.x >= bounds.width - SPINNER_WIDTH {
let direction = if position.y < bounds.height / 2.0 {
1.0
@@ -272,18 +277,10 @@ impl<Message> canvas::Program<Message> for PlainSlider<Message> {
);
}
let edit_left = (bounds.width - SPINNER_WIDTH - VALUE_EDIT_WIDTH) / 2.0;
let edit_right = edit_left + VALUE_EDIT_WIDTH;
if position.x >= edit_left && position.x <= edit_right {
state.editing = true;
state.buffer = if self.suffix == "%" {
format!("{:.*}", self.decimals, self.value * 100.0)
} else {
format!("{:.*}", self.decimals, self.value)
};
return (canvas::event::Status::Captured, None);
}
// Anywhere else on the track starts a drag; explicit edit mode
// is no longer required before typing.
state.editing = false;
state.buffer.clear();
state.dragging = true;
let value = self.value_at(position.x, bounds.width);
(
@@ -291,6 +288,15 @@ impl<Message> canvas::Program<Message> for PlainSlider<Message> {
Some((self.on_change)(value)),
)
}
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => {
// Right click outside edit mode is ignored; while editing it cancels.
if state.editing {
state.editing = false;
state.buffer.clear();
return (canvas::event::Status::Captured, None);
}
(canvas::event::Status::Ignored, None)
}
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => {
if let Some(position) = local {
let value = self.value_at(position.x, bounds.width);
@@ -338,6 +344,7 @@ impl<Message> canvas::Program<Message> for PlainSlider<Message> {
return (canvas::event::Status::Captured, None);
}
} else if let Some(text) = text.filter(|text| is_numeric_fragment(text)) {
// Typing while hovering immediately starts editing; no click needed.
PlainSlider::<Message>::begin_typing(state, &text);
return (canvas::event::Status::Captured, None);
}
@@ -130,16 +130,19 @@ pub fn render_watercolor_splat(_style: BrushStyle, base_color: [u8; 3]) -> Vec<u
let cy = h as f32 / 2.0;
let base_radius = w as f32 * 0.38;
// Draw 4 overlapping layers for depth
// ── Core puddles: overlapping irregular blobs ─────────────────────────
for (li, &(sx, sy)) in LAYER_SEEDS.iter().enumerate() {
let layer_hue = bh + (li as f32 - 1.5) * 8.0;
let layer_sat = (bs + (li as f32 - 1.5) * 0.05).clamp(0.0, 1.0);
let layer_lum = (bl + (li as f32 - 1.5) * 0.03).clamp(0.0, 1.0);
// Randomised offsets so every splat feels different
let angle = fbm(sx * 10.0, sy * 10.0, 2) * std::f32::consts::TAU;
let offset_r = w as f32 * 0.06 * fbm(sx * 20.0, sy * 20.0, 2);
let offset_x = angle.cos() * offset_r;
let offset_y = angle.sin() * offset_r;
let layer_hue = bh + (li as f32 - 1.5) * 12.0 + fbm(sx * 5.0, sy * 5.0, 2) * 10.0;
let layer_sat = (bs + (li as f32 - 1.5) * 0.06).clamp(0.0, 1.0);
let layer_lum = (bl + (li as f32 - 1.5) * 0.04).clamp(0.0, 1.0);
let rgb = hsl_to_rgb(layer_hue.rem_euclid(360.0), layer_sat, layer_lum);
let layer_alpha = 0.35 - li as f32 * 0.04;
let offset_x = (li as f32 - 1.5) * 2.5;
let offset_y = (li as f32 - 1.5) * 1.8;
let radius = base_radius * (1.0 + li as f32 * 0.06);
let layer_alpha = 0.32 - li as f32 * 0.03;
let radius = base_radius * (1.0 + li as f32 * 0.08);
for py in 0..h {
for px in 0..w {
@@ -147,14 +150,14 @@ pub fn render_watercolor_splat(_style: BrushStyle, base_color: [u8; 3]) -> Vec<u
let dy = py as f32 - (cy + offset_y);
let dist = (dx * dx + dy * dy).sqrt();
// Angular displacement for organic edge
let angle = dy.atan2(dx);
// More aggressive noise displacement for ragged edges
let a = dy.atan2(dx);
let noise_val = fbm(
angle * 2.0 + sx * 10.0,
dist / radius + sy * 5.0,
3,
a * 3.0 + sx * 10.0,
dist / radius * 1.5 + sy * 7.0,
4,
);
let displaced = radius * (0.7 + 0.3 * noise_val);
let displaced = radius * (0.55 + 0.45 * noise_val);
if dist > displaced {
continue;
@@ -162,20 +165,20 @@ pub fn render_watercolor_splat(_style: BrushStyle, base_color: [u8; 3]) -> Vec<u
let t = dist / displaced;
// Radial alpha: solid center, soft edge
let mut alpha = (1.0 - t * t) * layer_alpha;
// Chaotic radial alpha: not a smooth gradient
let mut alpha = (1.0 - t * t).powf(0.7 + noise_val * 0.6) * layer_alpha;
// Wet edge: slightly more opaque/darker near boundary (60-90%)
if t > 0.6 && t < 0.95 {
let edge_t = (t - 0.6) / 0.35;
alpha += 0.08 * (1.0 - edge_t);
// Wet edge ring
if t > 0.55 && t < 0.95 {
let edge_t = (t - 0.55) / 0.40;
alpha += 0.10 * (1.0 - edge_t) * (0.5 + 0.5 * noise_val);
}
// Granulation in outer region
if t > 0.35 {
let gran = fbm(px as f32 * 0.4 + sx * 100.0, py as f32 * 0.4 + sy * 100.0, 2);
let gran_mask = ((t - 0.35) / 0.65).min(1.0);
alpha *= 0.7 + 0.3 * gran * gran_mask;
// Granulation / paper texture in outer region
if t > 0.25 {
let gran = fbm(px as f32 * 0.55 + sx * 100.0, py as f32 * 0.55 + sy * 100.0, 3);
let gran_mask = ((t - 0.25) / 0.75).min(1.0);
alpha *= 0.65 + 0.35 * gran * gran_mask;
}
alpha = alpha.clamp(0.0, 1.0);
@@ -203,6 +206,53 @@ pub fn render_watercolor_splat(_style: BrushStyle, base_color: [u8; 3]) -> Vec<u
}
}
// ── Spontaneous splatter droplets ───────────────────────────────────────
let splatter_count = 18;
for i in 0..splatter_count {
let seed_a = i as f32 * 1.7;
let seed_r = i as f32 * 2.3;
let a = fbm(seed_a * 10.0, seed_a * 20.0, 2) * std::f32::consts::TAU;
let dist = base_radius * (0.45 + fbm(seed_r * 15.0, seed_r * 25.0, 2) * 0.9);
let sx = cx + a.cos() * dist;
let sy = cy + a.sin() * dist;
let s_radius = base_radius * (0.04 + fbm(seed_a * 30.0, seed_r * 30.0, 2) * 0.12);
let s_alpha = 0.25 + fbm(seed_a * 50.0, seed_r * 50.0, 2) * 0.35;
let s_hue = bh + fbm(seed_a * 8.0, seed_r * 8.0, 2) * 15.0;
let s_rgb = hsl_to_rgb(s_hue.rem_euclid(360.0), bs, bl);
let x0 = (sx - s_radius).max(0.0) as u32;
let x1 = (sx + s_radius).min(w as f32 - 1.0) as u32;
let y0 = (sy - s_radius).max(0.0) as u32;
let y1 = (sy + s_radius).min(h as f32 - 1.0) as u32;
for py in y0..=y1 {
for px in x0..=x1 {
let dx = px as f32 - sx;
let dy = py as f32 - sy;
let d = (dx * dx + dy * dy).sqrt();
if d > s_radius {
continue;
}
let alpha = (1.0 - d / s_radius) * s_alpha;
let idx = ((py * w + px) * 4) as usize;
let src_a = alpha;
let dst_a = pixels[idx + 3] as f32 / 255.0;
let out_a = src_a + dst_a * (1.0 - src_a);
if out_a > 0.0 {
pixels[idx] = ((s_rgb[0] as f32 * src_a
+ pixels[idx] as f32 * dst_a * (1.0 - src_a))
/ out_a) as u8;
pixels[idx + 1] = ((s_rgb[1] as f32 * src_a
+ pixels[idx + 1] as f32 * dst_a * (1.0 - src_a))
/ out_a) as u8;
pixels[idx + 2] = ((s_rgb[2] as f32 * src_a
+ pixels[idx + 2] as f32 * dst_a * (1.0 - src_a))
/ out_a) as u8;
pixels[idx + 3] = (out_a * 255.0) as u8;
}
}
}
}
pixels
}