feat: implement compact layout and real-time preview for Brushes & Tips panel
mandatory-regression-gate / deterministic-tests (push) Waiting to run
mandatory-regression-gate / protected-performance-path (push) Waiting to run

- Introduced a more compact layout for the Brushes & Tips panel in the Iced GUI.
- Added a real-time brush-tip preview that updates based on current brush parameters and foreground color.
- Adjusted thumbnail sizes and grid layout to improve usability and visual clarity.
- Implemented caching for live previews to optimize performance.
- Updated existing code to integrate new layout and preview functionalities.
- Ensured no changes were made to engine crates, maintaining stability.
This commit is contained in:
Your Name
2026-07-25 16:28:31 +03:00
parent 1dabd9c31e
commit eb17bf4205
3 changed files with 707 additions and 206 deletions
@@ -0,0 +1,263 @@
# Iced Brushes Panel — Compact Layout + Real-Time Preview
## Goal
Make the `hcie-iced-app` Brushes & Tips panel more compact and add a real-time brush-tip preview that reacts to current brush parameters and foreground color.
**Scope is limited to the Iced GUI layer.** Engine crates stay locked and untouched; all rendering goes through the public `hcie_engine_api` surface.
---
## Context
- Active target workspace (per user override): `hcie-iced-app/`.
- Primary file: `hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs`.
- Panel wiring: `hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs` calls `crate::panels::brushes::view(...)`.
- Current layout:
- Category tabs with 16 px SVG icons.
- Thumbnail image 48 px inside a 56 px cell.
- Two-column grid with 4 px spacing.
- Label text size 9 px.
- A bottom "Preview" area using a static style-keyed 64 px image.
- Available engine APIs (already public in `hcie_engine_api`):
- `Engine::render_brush_stamp_preview(width, height, &BrushTip, color)` — fast single-dab preview.
- `Engine::render_brush_preview(width, height, &BrushTip, color, &points)` — full stroke preview.
---
## Decisions
| Decision | Choice |
|----------|--------|
| Preview API | `Engine::render_brush_stamp_preview` (fast, parameter-aware). |
| Thumbnail image size | 36 px. |
| Thumbnail cell size | 40 px. |
| Grid columns | 3. |
| Label text | 8 px, kept. |
| Grid/item spacing | 3 px. |
| Tab icon size | Keep 16 px (already compact). |
| Live preview color | Current foreground color `fg_color`. |
| Cache key | `(style, size_byte, hardness_byte, opacity_byte, fg_color)` so the preview updates when parameters change. |
| Watercolor preset thumbnails | Keep current splat renderer but shrink to 36 px image / 40 px cell and align with the new grid. |
| Imported preset thumbnails | Keep list row style but shrink preview to 36 px image in 40 px cell. |
---
## Implementation Steps
### 1. Add foreground color to panel signature
In `hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs`, pass `app.fg_color` into `brushes::view(...)`.
In `hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs`, extend the function signature:
```rust
pub fn view(
current_style: BrushStyle,
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>,
fg_color: [u8; 4], // NEW
colors: ThemeColors,
) -> Element<'static, Message>
```
### 2. Introduce compact layout constants
At the top of `brushes.rs`, replace/adjust constants:
```rust
const THUMB_SIZE: u32 = 40;
const PREVIEW_IMG_SIZE: u32 = 36;
const GRID_COLUMNS: usize = 3;
const GRID_SPACING: u16 = 3;
const ITEM_SPACING: u16 = 3;
const LABEL_SIZE: u16 = 8;
```
Also add a real-time preview cache keyed by parameters:
```rust
static LIVE_PREVIEW_CACHE: OnceLock<Mutex<HashMap<PreviewKey, iced::widget::image::Handle>>> = OnceLock::new();
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct PreviewKey {
style_idx: usize,
size: u8, // quantized to whole px
hardness: u8, // 0..100
opacity: u8, // 0..100
r: u8,
g: u8,
b: u8,
a: u8,
}
```
### 3. Build a parameter-aware preview generator
Add a helper that constructs a `BrushTip` from the current tool state and calls `Engine::render_brush_stamp_preview`:
```rust
fn build_tip_from_state(style: BrushStyle, size: f32, opacity: f32, hardness: f32) -> hcie_engine_api::BrushTip {
hcie_engine_api::BrushTip {
style,
size,
opacity,
hardness,
..Default::default()
}
}
```
Add:
```rust
fn get_live_stamp_preview(
style: BrushStyle,
size: f32,
opacity: f32,
hardness: f32,
fg_color: [u8; 4],
) -> iced::widget::image::Handle {
let key = PreviewKey {
style_idx: style as usize,
size: size.clamp(1.0, 255.0) as u8,
hardness: (hardness * 100.0).clamp(0.0, 100.0) as u8,
opacity: (opacity * 100.0).clamp(0.0, 100.0) as u8,
r: fg_color[0],
g: fg_color[1],
b: fg_color[2],
a: fg_color[3],
};
let cache = LIVE_PREVIEW_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let mut cache = cache.lock().unwrap_or_else(|p| p.into_inner());
if let Some(handle) = cache.get(&key) {
return handle.clone();
}
let tip = build_tip_from_state(style, size, opacity, hardness);
let pixels = hcie_engine_api::Engine::render_brush_stamp_preview(
PREVIEW_IMG_SIZE,
PREVIEW_IMG_SIZE,
&tip,
fg_color,
);
let handle = iced::widget::image::Handle::from_rgba(
PREVIEW_IMG_SIZE,
PREVIEW_IMG_SIZE,
pixels,
);
cache.insert(key, handle.clone());
handle
}
```
### 4. Update existing thumbnail cache to new size
- Keep `BRUSH_PREVIEW_CACHE` for style-only previews.
- Regenerate it with the new `THUMB_SIZE` (40 px). The existing sine-wave `generate_brush_preview` can stay, just adjust the drawing bounds to match.
- Ensure the function returns `width=THUMB_SIZE, height=THUMB_SIZE` pixels.
### 5. Re-layout the grid
Change the grid construction from 2 columns to 3 columns using `GRID_COLUMNS`:
```rust
let mut grid_rows = column![].spacing(GRID_SPACING);
let mut current_row = row![].spacing(GRID_SPACING);
for (idx, preset) in filtered_styles.iter().enumerate() {
// ... build 40 px cell with 36 px image + 8 px label ...
current_row = current_row.push(clickable);
if (idx + 1) % GRID_COLUMNS == 0 || idx == filtered_styles.len() - 1 {
grid_rows = grid_rows.push(current_row);
current_row = row![].spacing(GRID_SPACING);
}
}
```
Apply the same 3-column compact layout to:
- Built-in styles grid.
- Watercolor presets grid.
- Media presets grid.
For imported presets, keep the list-row layout but set the preview image to 36 px and container to 40 px.
### 6. Replace the bottom static preview with the live preview
Locate the existing code:
```rust
let preview_handle = get_brush_preview(current_style);
let live_preview = container(iced::widget::image(preview_handle).width(64).height(64))
...
```
Replace with:
```rust
let preview_handle = get_live_stamp_preview(
current_style,
current_size,
current_opacity,
current_hardness,
fg_color,
);
let live_preview = container(iced::widget::image(preview_handle).width(PREVIEW_IMG_SIZE as f32).height(PREVIEW_IMG_SIZE as f32))
.width(Length::Fill)
.height(56)
.center_x(Length::Fill)
.center_y(Length::Fill)
.style(move |_theme| styles::recessed_control(colors));
```
Make the preview area slightly shorter (56 px instead of 80 px) to reclaim vertical space.
### 7. Tighten slider/controls spacing
- Reduce the column padding from `4` to `3`.
- Keep sliders, but make the panel column spacing `2` or `3`.
---
## Affected Files
1. `hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs` — main panel layout and preview logic.
2. `hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs` — pass `app.fg_color` to `brushes::view`.
No engine crates are modified. No `Cargo.toml` changes are needed because `hcie_engine_api` is already a dependency.
---
## Validation
1. `cargo check -p hcie-iced-gui` must pass.
2. `cargo test -p hcie-iced-gui brushes` (or `cargo test -p hcie-iced-gui`) must pass; the existing tests in `brushes.rs` (`catalog_contains_every_engine_brush_style`, `category_filter_keeps_expected_groups`, `media_crates_expose_complete_unique_catalog`) must remain green.
3. Launch the Iced GUI and open the Brushes & Tips panel. Visually verify:
- 3-column grid of 40 px cells.
- 8 px labels still readable.
- Bottom preview area updates when size, opacity, hardness, or foreground color changes.
- Selection border still highlights the active brush.
4. If the Iced CLI screenshot mechanism works, capture the Brushes panel for before/after comparison.
---
## Risks
1. **Cache growth**: the parameter-keyed cache can grow quickly if size/opacity/hardness are continuous. Mitigation: quantize size to `u8`, hardness/opacity to 0..100, and fg color to full bytes; this bounds the space to ~256 * 101 * 101 * 256^4 possible keys but only populated entries consume memory.
2. **Performance of `render_brush_stamp_preview`**: small single-dab previews (36x36 or 40x40) should be fast; if not, consider throttling updates to panel redraws only.
3. **Iced layout overflow**: switching to 3 columns with 3 px spacing in a narrow pane may still wrap. If so, the pane minimum width in `dock/sizing.rs` may need a small bump (currently 160 px preferred 300 px). Test first; only bump if required.
4. **Tests assume style-only cache**: the existing `BRUSH_PREVIEW_CACHE` tests do not inspect cache contents, so resizing should not break them.
---
## Out of Scope
- EGUI (`hcie-egui-app`) changes — explicitly excluded by the user; it remains a reference only.
- Engine-side brush rendering changes — engine crates are locked.
- New brush engine presets or categories.
- Bitmap/imported brush stamp preview fidelity beyond what `render_brush_stamp_preview` already provides.
@@ -187,6 +187,7 @@ pub fn panel_body<'a>(
app.brush_category, app.brush_category,
&app.tool_state.imported_brushes, &app.tool_state.imported_brushes,
app.tool_state.active_brush_preset.as_ref(), app.tool_state.active_brush_preset.as_ref(),
app.fg_color,
colors, colors,
), ),
PaneType::Filters => crate::panels::filters::view( PaneType::Filters => crate::panels::filters::view(
@@ -1,12 +1,24 @@
//! Brushes panel — brush preset browser with visual thumbnails and live preview. //! Brushes panel — compact brush preset browser with realistic live preview.
//! //!
//! Matches the egui version's layout: //! **Purpose:** Displays the brush library as a compact 3-column grid of
//! - Category tabs (All, Drawing, Painting, Watercolor, Effects) //! realistic brush-stroke thumbnails and renders a live, parameter-aware brush
//! - Grid of brush tip thumbnails with visual stroke preview //! stroke preview at the bottom of the panel. Every preview is produced by the
//! - Selected brush highlighted with accent border //! public `hcie_engine_api::Engine::render_brush_preview` path, so the thumbnail
//! - Size/Opacity/Hardness sliders at bottom //! and preview pixels match what the brush engine actually draws on the canvas.
//! //!
//! ⚠️ PERFORMANCE: Brush preview images are cached via OnceLock. //! **Layout:**
//! - Category tabs (All, Drawing, Painting, Watercolor, Effects, Custom, Imported).
//! - 3-column grid of 36 px stroke thumbnails inside 40 px cells with 8 px labels.
//! - Selected brush highlighted with an accent border.
//! - Live preview area showing the current style, size, opacity, hardness, and
//! foreground color as a short engine-rendered stroke.
//! - Size/Opacity/Hardness sliders, color variant toggle, and variant amount.
//!
//! **Performance:**
//! - Built-in style thumbnails are cached by `BrushStyle` via `OnceLock`.
//! - Media / watercolor / imported preset thumbnails are cached by preset id.
//! - The live stroke preview is cached by a quantized parameter key so it only
//! regenerates when size, opacity, hardness, or foreground color actually change.
use crate::app::Message; use crate::app::Message;
use crate::panels::styles; use crate::panels::styles;
@@ -212,12 +224,77 @@ const BRUSH_STYLES: &[BrushStyleEntry] = &[
}, },
]; ];
/// Cached brush preview handles keyed by engine style index. /// Default thumbnail color: neutral light gray so the stroke shape is visible
static BRUSH_PREVIEW_CACHE: OnceLock<Mutex<HashMap<usize, iced::widget::image::Handle>>> = /// on dark panel backgrounds. Thumbnails intentionally do not use the active
/// foreground color so each brush's visual identity remains stable.
const THUMB_COLOR: [u8; 4] = [200, 200, 200, 255];
/// Cached built-in style stroke preview handles keyed by style index.
///
/// Realistic stroke thumbnails are generated once per style via
/// `Engine::render_brush_preview` and reused for the session.
static STYLE_PREVIEW_CACHE: OnceLock<Mutex<HashMap<usize, iced::widget::image::Handle>>> =
OnceLock::new(); OnceLock::new();
/// Thumbnail size in pixels. /// Cached preset stroke preview handles keyed by preset id.
const THUMB_SIZE: u32 = 64; ///
/// Media, watercolor, and imported presets each supply their own `BrushTip`; this
/// cache stores the rendered stroke preview keyed by the unique preset id.
static PRESET_PREVIEW_CACHE: OnceLock<Mutex<HashMap<String, iced::widget::image::Handle>>> =
OnceLock::new();
/// Cached live stroke preview handles keyed by the full brush parameter set.
///
/// This cache lets the bottom "Preview" area update in real time as the user
/// changes size, opacity, hardness, or foreground color without re-rendering
/// the same configuration on every frame.
static LIVE_PREVIEW_CACHE: OnceLock<Mutex<HashMap<PreviewKey, iced::widget::image::Handle>>> =
OnceLock::new();
/// Compact thumbnail cell size in pixels.
const THUMB_SIZE: u32 = 40;
/// Image size drawn inside each thumbnail cell.
const THUMB_IMG_SIZE: u32 = 36;
/// Maximum brush size used for thumbnail previews so the full stroke fits.
const THUMB_BRUSH_SIZE: f32 = 16.0;
/// Number of brush thumbnail columns in the grid.
const GRID_COLUMNS: usize = 3;
/// Spacing between grid rows and columns, in logical pixels.
const GRID_SPACING: u16 = 3;
/// Vertical spacing inside a single thumbnail cell (image + label).
const ITEM_SPACING: u16 = 2;
/// Label text size for thumbnail names.
const LABEL_SIZE: u16 = 8;
/// Size of the live stroke preview image at the bottom of the panel.
const LIVE_PREVIEW_IMG_SIZE: u32 = 40;
/// Maximum brush size rendered in the live preview so the stroke character
/// remains visible inside the small preview well even when the user sets a
/// much larger brush for actual painting.
const LIVE_PREVIEW_BRUSH_SIZE: f32 = 22.0;
/// Cache key identifying one unique live brush stroke preview.
///
/// Size, hardness, and opacity are quantized to keep the cache bounded while
/// still capturing the visual changes the user makes in the panel.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct PreviewKey {
style_idx: usize,
size: u8,
hardness: u8,
opacity: u8,
r: u8,
g: u8,
b: u8,
a: u8,
}
/// Determines whether a built-in style belongs in the selected category. /// Determines whether a built-in style belongs in the selected category.
/// ///
@@ -290,132 +367,268 @@ fn media_preset_category(category: &str) -> BrushCategory {
} }
} }
/// Generate a brush stroke preview as RGBA pixels. /// Build a representative protocol `BrushTip` for a built-in style thumbnail.
/// ///
/// Draws a sine-wave stroke with varying thickness to show the brush characteristics. /// **Purpose:** Each built-in style needs a tip that lets the engine reveal its
fn generate_brush_preview(style: BrushStyle) -> Vec<u8> { /// actual stroke character in a small preview. Size is clamped later to fit
let w = THUMB_SIZE; /// the thumbnail so only relative opacity/hardness matter here.
let h = THUMB_SIZE; fn thumbnail_tip_for_style(style: BrushStyle) -> hcie_engine_api::BrushTip {
let mut pixels = vec![0u8; (w * h * 4) as usize]; let (size, opacity, hardness) = match style {
BrushStyle::Pencil | BrushStyle::Pen | BrushStyle::InkPen | BrushStyle::Calligraphy => {
let n_points = 40; (4.0, 0.95, 1.0)
let mut points = Vec::with_capacity(n_points + 1); }
for i in 0..=n_points { BrushStyle::Marker | BrushStyle::Sketch | BrushStyle::Hatch => (6.0, 0.9, 0.85),
let t = i as f32 / n_points as f32; BrushStyle::Oil | BrushStyle::Charcoal | BrushStyle::Crayon => (12.0, 0.9, 0.5),
let x = 4.0 + t * (w as f32 - 8.0); BrushStyle::Watercolor
let angle = t * std::f32::consts::TAU * 1.25; | BrushStyle::WetPaint
let sine = angle.sin(); | BrushStyle::Airbrush
let taper = (t * (1.0 - t) * 4.0).powf(1.1); | BrushStyle::Spray => (14.0, 0.75, 0.0),
let y = h as f32 / 2.0 + sine * (h as f32 * 0.22) * taper; BrushStyle::Leaf
points.push((x, y, t)); | BrushStyle::Rock
} | BrushStyle::Meadow
| BrushStyle::Tree
// Color based on style | BrushStyle::Clouds
let color: [u8; 3] = match style { | BrushStyle::Dirt
BrushStyle::Pencil | BrushStyle::Sketch => [180, 180, 180], | BrushStyle::Glow
BrushStyle::Pen | BrushStyle::InkPen => [40, 40, 40], | BrushStyle::Noise
BrushStyle::Calligraphy => [60, 40, 30], | BrushStyle::Texture => (18.0, 0.9, 0.3),
BrushStyle::Oil => [180, 120, 60], BrushStyle::Star | BrushStyle::Square | BrushStyle::HardRound => (14.0, 0.95, 1.0),
BrushStyle::Charcoal => [80, 80, 80], BrushStyle::SoftRound | BrushStyle::Round | BrushStyle::Default => (14.0, 0.9, 0.5),
BrushStyle::Watercolor => [100, 150, 200], BrushStyle::Wood | BrushStyle::Bristle | BrushStyle::Mixer | BrushStyle::Blender => {
BrushStyle::Crayon => [200, 100, 100], (14.0, 0.85, 0.45)
BrushStyle::Airbrush | BrushStyle::Spray => [120, 120, 120], }
BrushStyle::Marker => [60, 120, 60], BrushStyle::Bitmap => (20.0, 0.9, 0.5),
BrushStyle::Glow => [200, 200, 100],
BrushStyle::Leaf => [80, 160, 60],
_ => [200, 200, 200],
}; };
// Draw stroke with varying thickness hcie_engine_api::BrushTip {
for i in 0..n_points { style,
let (x1, y1, t1) = points[i]; size,
let (x2, y2, _) = points[i + 1]; opacity,
hardness,
let thickness = match style { ..hcie_engine_api::BrushTip::default()
BrushStyle::Pencil | BrushStyle::Sketch => {
1.0 + 2.0 * (t1 * std::f32::consts::PI).sin().abs()
}
BrushStyle::Pen | BrushStyle::InkPen => {
2.0 + 3.0 * (t1 * std::f32::consts::PI).sin().abs()
}
BrushStyle::Calligraphy => 1.0 + 5.0 * ((t1 * 3.0).sin().abs()),
BrushStyle::Oil | BrushStyle::Charcoal => {
3.0 + 6.0 * (t1 * std::f32::consts::PI).sin().abs()
}
BrushStyle::Watercolor => 2.0 + 4.0 * (t1 * std::f32::consts::PI).sin().abs(),
BrushStyle::Airbrush | BrushStyle::Spray => {
4.0 + 8.0 * (t1 * std::f32::consts::PI).sin().abs()
}
BrushStyle::Glow => 3.0 + 7.0 * (t1 * std::f32::consts::PI).sin().abs(),
_ => 2.0 + 5.0 * (t1 * std::f32::consts::PI).sin().abs(),
};
// Draw line segment with anti-aliasing
let steps = ((thickness * 2.0) as u32).max(1);
for s in 0..steps {
let frac = s as f32 / steps as f32;
let px = x1 + (x2 - x1) * frac;
let py = y1 + (y2 - y1) * frac;
let r = thickness / 2.0;
// Fill circle at (px, py) with radius r
let ix0 = (px - r).max(0.0) as u32;
let iy0 = (py - r).max(0.0) as u32;
let ix1 = (px + r).min(w as f32 - 1.0) as u32;
let iy1 = (py + r).min(h as f32 - 1.0) as u32;
for iy in iy0..=iy1 {
for ix in ix0..=ix1 {
let dx = ix as f32 - px;
let dy = iy as f32 - py;
let dist = (dx * dx + dy * dy).sqrt();
if dist <= r {
let alpha = ((1.0 - dist / r) * 255.0) as u8;
let idx = ((iy * w + ix) * 4) as usize;
// Alpha blend
let src_a = alpha as f32 / 255.0;
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] = ((color[0] as f32 * src_a
+ pixels[idx] as f32 * dst_a * (1.0 - src_a))
/ out_a) as u8;
pixels[idx + 1] = ((color[1] as f32 * src_a
+ pixels[idx + 1] as f32 * dst_a * (1.0 - src_a))
/ out_a) as u8;
pixels[idx + 2] = ((color[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
} }
/// Get or generate brush preview for a style. /// Sample stroke path that fits inside a square preview buffer.
fn get_brush_preview(style: BrushStyle) -> iced::widget::image::Handle { ///
/// The path is a short sine wave with pressure rising toward the middle,
/// which is enough to show brush spacing, texture, and tapering behavior.
fn preview_stroke_points(width: u32, height: u32) -> Vec<(f32, f32, f32)> {
let pad = 3.0;
let n = 24;
let mut points = Vec::with_capacity(n);
for i in 0..n {
let t = i as f32 / (n - 1) as f32;
let x = pad + t * (width as f32 - pad * 2.0);
let y =
height as f32 / 2.0 + (t * std::f32::consts::PI * 2.0).sin() * (height as f32 * 0.22);
let pressure = 0.6 + 0.4 * (1.0 - (t - 0.5).abs() * 2.0);
points.push((x, y, pressure));
}
points
}
/// Render a realistic brush stroke preview via the engine.
///
/// **Arguments:**
/// * `width`, `height` — Output buffer dimensions.
/// * `tip` — Protocol `BrushTip` describing the brush to preview.
/// * `color` — RGBA tint for the stroke.
///
/// **Returns:** RGBA pixel buffer produced by `Engine::render_brush_preview`.
/// **Side Effects:** Allocates a temporary engine; none on application state.
fn render_stroke_preview(
width: u32,
height: u32,
tip: &hcie_engine_api::BrushTip,
color: [u8; 4],
) -> Vec<u8> {
let points = preview_stroke_points(width, height);
hcie_engine_api::Engine::render_brush_preview(width, height, tip, color, &points)
}
/// Convert a brush-engine preset tip to the protocol tip used by engine previews.
///
/// `BrushPreset` stores the engine's internal `BrushTip`; the public preview API
/// expects the protocol `BrushTip`. This copies all shared rendering fields.
fn protocol_tip_from_preset(
source: &hcie_engine_api::brush::BrushTip,
) -> hcie_engine_api::BrushTip {
let mut tip = hcie_engine_api::BrushTip::default();
tip.style = preset_style(source.style);
tip.size = source.size;
tip.opacity = source.opacity;
tip.hardness = source.hardness;
tip.spacing = source.spacing;
tip.flow = source.flow;
tip.jitter_amount = source.jitter_amount;
tip.scatter_amount = source.scatter_amount;
tip.angle = source.angle;
tip.roundness = source.roundness;
tip.spray_particle_size = source.spray_particle_size;
tip.spray_density = source.spray_density;
tip.bitmap_pixels = source.bitmap_pixels.clone();
tip.bitmap_w = source.bitmap_w;
tip.bitmap_h = source.bitmap_h;
tip.color_variant = source.color_variant;
tip.variant_amount = source.variant_amount;
tip.density = source.density;
tip.drawing_angle = source.drawing_angle;
tip.rotation_random = source.rotation_random;
tip
}
/// Clamp a tip's size so the preview stroke fits a small thumbnail.
///
/// Hardness is nudged up slightly when shrinking so detail is not lost; opacity
/// and other dynamics are preserved.
fn clamped_preview_tip(
mut tip: hcie_engine_api::BrushTip,
max_size: f32,
) -> hcie_engine_api::BrushTip {
if tip.size > max_size {
let scale = max_size / tip.size;
// Slightly increase hardness as the brush shrinks to keep edges readable.
tip.hardness = (tip.hardness + (1.0 - tip.hardness) * (1.0 - scale)).clamp(0.0, 1.0);
tip.size = max_size;
}
if tip.size < 1.0 {
tip.size = 1.0;
}
tip
}
/// Build a protocol `BrushTip` from the live panel parameters.
///
/// **Purpose:** Feeds the live stroke preview renderer without needing the
/// full `ToolState`. The returned tip captures style, size, opacity, and
/// hardness so the preview matches the next brush stroke.
fn build_tip_from_state(
style: BrushStyle,
size: f32,
opacity: f32,
hardness: f32,
) -> hcie_engine_api::BrushTip {
hcie_engine_api::BrushTip {
style,
size,
opacity,
hardness,
..hcie_engine_api::BrushTip::default()
}
}
/// Quantize preview parameters into a cacheable key.
///
/// **Purpose:** Bound the live preview cache while still updating on every
/// meaningful parameter change made in the panel.
fn preview_key(
style: BrushStyle,
size: f32,
opacity: f32,
hardness: f32,
fg_color: [u8; 4],
) -> PreviewKey {
PreviewKey {
style_idx: style as usize,
size: size.clamp(1.0, 255.0) as u8,
hardness: (hardness * 100.0).clamp(0.0, 100.0) as u8,
opacity: (opacity * 100.0).clamp(0.0, 100.0) as u8,
r: fg_color[0],
g: fg_color[1],
b: fg_color[2],
a: fg_color[3],
}
}
/// Get or generate a live brush stroke preview for the current tool state.
///
/// **Purpose:** Renders a short engine-drawn stroke that reflects the active
/// style, size, opacity, hardness, and foreground color.
///
/// **Logic & Workflow:**
/// 1. Build a cache key from quantized parameters.
/// 2. Return a cached `image::Handle` if the same configuration was rendered
/// earlier in this session.
/// 3. Otherwise, build a `BrushTip`, ask the engine to render a stroke preview,
/// store the handle, and return it.
///
/// **Arguments:** See `build_tip_from_state` plus `fg_color`, the RGBA tint.
///
/// **Returns:** An Iced image handle sized `LIVE_PREVIEW_IMG_SIZE` square.
/// **Side Effects:** Populates `LIVE_PREVIEW_CACHE`.
fn get_live_stroke_preview(
style: BrushStyle,
size: f32,
opacity: f32,
hardness: f32,
fg_color: [u8; 4],
) -> iced::widget::image::Handle {
// Clamp the rendered brush size so a 110 px soft brush does not fill the
// entire 40 px preview well with a solid blob. The preview shows texture and
// stroke character; the slider still reports the real painting size.
let preview_size = size.clamp(4.0, LIVE_PREVIEW_BRUSH_SIZE);
let key = preview_key(style, preview_size, opacity, hardness, fg_color);
let cache = LIVE_PREVIEW_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let mut cache = cache
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(handle) = cache.get(&key) {
return handle.clone();
}
let tip = build_tip_from_state(style, preview_size, opacity, hardness);
let pixels =
render_stroke_preview(LIVE_PREVIEW_IMG_SIZE, LIVE_PREVIEW_IMG_SIZE, &tip, fg_color);
let handle = iced::widget::image::Handle::from_rgba(
LIVE_PREVIEW_IMG_SIZE,
LIVE_PREVIEW_IMG_SIZE,
pixels,
);
cache.insert(key, handle.clone());
handle
}
/// Get or generate a realistic stroke preview for a built-in brush style.
///
/// **Purpose:** Provides the thumbnails shown in the built-in style grid. The
/// preview is rendered through the engine so the stroke matches what the user
/// actually paints with that style.
fn get_style_preview(style: BrushStyle) -> iced::widget::image::Handle {
let style_idx = style as usize; let style_idx = style as usize;
let cache = BRUSH_PREVIEW_CACHE.get_or_init(|| Mutex::new(HashMap::new())); let cache = STYLE_PREVIEW_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let mut cache = cache let mut cache = cache
.lock() .lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()); .unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(handle) = cache.get(&style_idx) { if let Some(handle) = cache.get(&style_idx) {
return handle.clone(); return handle.clone();
} }
let handle = iced::widget::image::Handle::from_rgba( let tip = clamped_preview_tip(thumbnail_tip_for_style(style), THUMB_BRUSH_SIZE);
THUMB_SIZE, let pixels = render_stroke_preview(THUMB_SIZE, THUMB_SIZE, &tip, THUMB_COLOR);
THUMB_SIZE, let handle = iced::widget::image::Handle::from_rgba(THUMB_SIZE, THUMB_SIZE, pixels);
generate_brush_preview(style),
);
cache.insert(style_idx, handle.clone()); cache.insert(style_idx, handle.clone());
handle handle
} }
/// Get or generate a realistic stroke preview for a runtime brush preset.
///
/// **Purpose:** Provides the thumbnails shown for media, watercolor, and
/// imported presets. The preset's own `BrushTip` is used so the preview reflects
/// the preset's size, hardness, opacity, density, and other dynamics.
fn get_preset_preview(preset: &hcie_engine_api::BrushPreset) -> iced::widget::image::Handle {
let cache = PRESET_PREVIEW_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let mut cache = cache
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(handle) = cache.get(&preset.id) {
return handle.clone();
}
let tip = clamped_preview_tip(protocol_tip_from_preset(&preset.tip), THUMB_BRUSH_SIZE);
let pixels = render_stroke_preview(THUMB_SIZE, THUMB_SIZE, &tip, THUMB_COLOR);
let handle = iced::widget::image::Handle::from_rgba(THUMB_SIZE, THUMB_SIZE, pixels);
cache.insert(preset.id.clone(), handle.clone());
handle
}
/// Build the brushes panel with visual thumbnails. /// Build the brushes panel with visual thumbnails.
pub fn view( pub fn view(
current_style: BrushStyle, current_style: BrushStyle,
@@ -427,6 +640,7 @@ pub fn view(
active_category: BrushCategory, active_category: BrushCategory,
imported_presets: &[hcie_engine_api::BrushPreset], imported_presets: &[hcie_engine_api::BrushPreset],
active_brush_preset: Option<&hcie_engine_api::BrushPreset>, active_brush_preset: Option<&hcie_engine_api::BrushPreset>,
fg_color: [u8; 4],
colors: ThemeColors, colors: ThemeColors,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
// Category tabs // Category tabs
@@ -500,9 +714,9 @@ pub fn view(
)); ));
} }
// Brush preset grid — two columns remain usable in narrow dock panes. // Brush preset grid — three compact columns fit comfortably in narrow dock panes.
let mut grid_rows = column![].spacing(4); let mut grid_rows = column![].spacing(GRID_SPACING);
let mut current_row = row![].spacing(4); let mut current_row = row![].spacing(GRID_SPACING);
let filtered_styles: Vec<&BrushStyleEntry> = BRUSH_STYLES let filtered_styles: Vec<&BrushStyleEntry> = BRUSH_STYLES
.iter() .iter()
@@ -511,23 +725,27 @@ pub fn view(
for (idx, preset) in filtered_styles.iter().enumerate() { for (idx, preset) in filtered_styles.iter().enumerate() {
let is_selected = preset.style == current_style; let is_selected = preset.style == current_style;
let preview = get_brush_preview(preset.style); let preview = get_style_preview(preset.style);
let c = colors; let c = colors;
let style = preset.style; let style = preset.style;
let thumb = container(iced::widget::image(preview).width(48).height(48)) let thumb = container(
.width(56) iced::widget::image(preview)
.height(56) .width(THUMB_IMG_SIZE as f32)
.center_x(48) .height(THUMB_IMG_SIZE as f32),
.center_y(48) )
.style(move |_theme| styles::raised_card(c, is_selected)); .width(THUMB_SIZE as f32)
.height(THUMB_SIZE as f32)
.align_x(iced::alignment::Horizontal::Center)
.align_y(iced::alignment::Vertical::Center)
.style(move |_theme| styles::raised_card(c, is_selected));
let label = container(text(preset.name).size(9)) let label = container(text(preset.name).size(LABEL_SIZE))
.width(Length::Fill) .width(Length::Fill)
.center_x(Length::Fill); .align_x(iced::alignment::Horizontal::Center);
let cell = column![thumb, label] let cell = column![thumb, label]
.spacing(2) .spacing(ITEM_SPACING)
.align_x(iced::Alignment::Center); .align_x(iced::Alignment::Center);
let clickable = button(cell) let clickable = button(cell)
@@ -554,54 +772,44 @@ pub fn view(
current_row = current_row.push(clickable); current_row = current_row.push(clickable);
if (idx + 1) % 2 == 0 || idx == filtered_styles.len() - 1 { if (idx + 1) % GRID_COLUMNS == 0 || idx == filtered_styles.len() - 1 {
grid_rows = grid_rows.push(current_row); grid_rows = grid_rows.push(current_row);
current_row = row![].spacing(4); current_row = row![].spacing(GRID_SPACING);
} }
} }
// ── Watercolor presets (from hcie-watercolor-brushes crate) ─────────── // ── Watercolor presets (from hcie-watercolor-brushes crate) ───────────
let mut wc_rows = column![].spacing(4); let mut wc_rows = column![].spacing(GRID_SPACING);
if matches!( if matches!(
active_category, active_category,
BrushCategory::All | BrushCategory::Watercolor BrushCategory::All | BrushCategory::Watercolor
) { ) {
let wc_presets = hcie_watercolor_brushes::watercolor_presets(); let wc_presets = hcie_watercolor_brushes::watercolor_presets();
let mut wc_row = row![].spacing(4); let mut wc_row = row![].spacing(GRID_SPACING);
for (idx, preset) in wc_presets.iter().enumerate() { for (idx, preset) in wc_presets.iter().enumerate() {
let is_selected = preset_style(preset.style) == current_style let is_selected = preset_style(preset.style) == current_style
&& active_brush_preset && active_brush_preset
.as_ref() .as_ref()
.map_or(false, |p| p.id == preset.id); .map_or(false, |p| p.id == preset.id);
let c = colors; let c = colors;
let wc_color: [u8; 3] = match preset.id.as_str() { let preview = get_preset_preview(preset);
"wc_wash" => [100, 150, 200], let thumb = container(
"wc_wet" => [80, 130, 190], iced::widget::image(preview)
"wc_dry" => [140, 110, 80], .width(THUMB_IMG_SIZE as f32)
"wc_glaze" => [120, 160, 210], .height(THUMB_IMG_SIZE as f32),
"wc_splat" => [90, 140, 180], )
"wc_bleed" => [110, 80, 150], .width(THUMB_SIZE as f32)
"wc_grain" => [130, 120, 100], .height(THUMB_SIZE as f32)
"wc_bloom" => [90, 170, 200], .align_x(iced::alignment::Horizontal::Center)
_ => [100, 150, 200], .align_y(iced::alignment::Vertical::Center)
}; .style(move |_theme| styles::raised_card(c, is_selected));
let splat_pixels =
hcie_watercolor_brushes::render_watercolor_splat(preset.style, wc_color);
let preview =
iced::widget::image::Handle::from_rgba(THUMB_SIZE, THUMB_SIZE, splat_pixels);
let thumb = container(iced::widget::image(preview).width(48).height(48))
.width(56)
.height(56)
.center_x(48)
.center_y(48)
.style(move |_theme| styles::raised_card(c, is_selected));
let label = container(text(preset.name.clone()).size(9)) let label = container(text(preset.name.clone()).size(LABEL_SIZE))
.width(Length::Fill) .width(Length::Fill)
.center_x(Length::Fill); .align_x(iced::alignment::Horizontal::Center);
let cell = column![thumb, label] let cell = column![thumb, label]
.spacing(2) .spacing(ITEM_SPACING)
.align_x(iced::Alignment::Center); .align_x(iced::Alignment::Center);
let preset_msg = preset.clone(); let preset_msg = preset.clone();
@@ -629,9 +837,9 @@ pub fn view(
wc_row = wc_row.push(clickable); wc_row = wc_row.push(clickable);
if (idx + 1) % 2 == 0 || idx == wc_presets.len() - 1 { if (idx + 1) % GRID_COLUMNS == 0 || idx == wc_presets.len() - 1 {
wc_rows = wc_rows.push(wc_row); wc_rows = wc_rows.push(wc_row);
wc_row = row![].spacing(4); wc_row = row![].spacing(GRID_SPACING);
} }
} }
} }
@@ -649,27 +857,31 @@ pub fn view(
|| active_category == media_preset_category(&preset.category) || active_category == media_preset_category(&preset.category)
}) })
.collect(); .collect();
let mut media_rows = column![].spacing(4); let mut media_rows = column![].spacing(GRID_SPACING);
let mut media_row = row![].spacing(4); let mut media_row = row![].spacing(GRID_SPACING);
for (idx, preset) in filtered_media.iter().enumerate() { for (idx, preset) in filtered_media.iter().enumerate() {
let style = preset_style(preset.style); let style = preset_style(preset.style);
let selected = style == current_style let selected = style == current_style
&& active_brush_preset.is_some_and(|active| active.id == preset.id); && active_brush_preset.is_some_and(|active| active.id == preset.id);
let preview = get_brush_preview(style); let preview = get_preset_preview(preset);
let preset_message = preset.clone(); let preset_message = preset.clone();
let c = colors; let c = colors;
let cell = column![ let cell = column![
container(iced::widget::image(preview).width(48).height(48)) container(
.width(56) iced::widget::image(preview)
.height(56) .width(THUMB_IMG_SIZE as f32)
.center_x(48) .height(THUMB_IMG_SIZE as f32)
.center_y(48) )
.style(move |_theme| styles::raised_card(c, selected)), .width(THUMB_SIZE as f32)
container(text(preset.name.clone()).size(9)) .height(THUMB_SIZE as f32)
.align_x(iced::alignment::Horizontal::Center)
.align_y(iced::alignment::Vertical::Center)
.style(move |_theme| styles::raised_card(c, selected)),
container(text(preset.name.clone()).size(LABEL_SIZE))
.width(Length::Fill) .width(Length::Fill)
.center_x(Length::Fill), .align_x(iced::alignment::Horizontal::Center),
] ]
.spacing(2) .spacing(ITEM_SPACING)
.align_x(iced::Alignment::Center); .align_x(iced::Alignment::Center);
media_row = media_row.push( media_row = media_row.push(
button(cell) button(cell)
@@ -688,13 +900,13 @@ pub fn view(
..Default::default() ..Default::default()
}), }),
); );
if (idx + 1) % 2 == 0 || idx == filtered_media.len() - 1 { if (idx + 1) % GRID_COLUMNS == 0 || idx == filtered_media.len() - 1 {
media_rows = media_rows.push(media_row); media_rows = media_rows.push(media_row);
media_row = row![].spacing(4); media_row = row![].spacing(GRID_SPACING);
} }
} }
let mut imported_rows = column![].spacing(3); let mut imported_rows = column![].spacing(GRID_SPACING);
if matches!( if matches!(
active_category, active_category,
BrushCategory::All | BrushCategory::Imported BrushCategory::All | BrushCategory::Imported
@@ -703,19 +915,25 @@ pub fn view(
let style = preset_style(preset.style); let style = preset_style(preset.style);
let selected = style == current_style; let selected = style == current_style;
let preset_message = preset.clone(); let preset_message = preset.clone();
let preview = get_brush_preview(style); let preview = get_preset_preview(preset);
let name = preset.name.clone(); let name = preset.name.clone();
imported_rows = imported_rows.push( imported_rows = imported_rows.push(
button( button(
row![ row![
container(iced::widget::image(preview).width(42).height(24)) container(
.width(48) iced::widget::image(preview)
.height(28) .width(THUMB_IMG_SIZE as f32)
.center_x(Length::Fill) .height(THUMB_IMG_SIZE as f32)
.center_y(Length::Fill), )
.width(THUMB_SIZE as f32)
.height(THUMB_SIZE as f32)
.align_x(iced::alignment::Horizontal::Center)
.align_y(iced::alignment::Vertical::Center),
column![ column![
text(name).size(10), text(name).size(LABEL_SIZE + 1),
text("Imported ABR").size(8).color(colors.text_secondary), text("Imported ABR")
.size(LABEL_SIZE)
.color(colors.text_secondary),
] ]
.spacing(1), .spacing(1),
] ]
@@ -724,7 +942,7 @@ pub fn view(
) )
.on_press(Message::BrushPresetSelected(preset_message)) .on_press(Message::BrushPresetSelected(preset_message))
.width(Length::Fill) .width(Length::Fill)
.padding(3) .padding(2)
.style(move |_theme, status| iced::widget::button::Style { .style(move |_theme, status| iced::widget::button::Style {
background: Some(iced::Background::Color(if selected { background: Some(iced::Background::Color(if selected {
colors.bg_active colors.bg_active
@@ -804,30 +1022,49 @@ pub fn view(
Message::BrushVariantAmountChanged, Message::BrushVariantAmountChanged,
); );
let color_variant_check = checkbox("Color variant", brush_color_variant) // Compact color-variant toggle: an empty 12 px checkbox plus an 8 px label so
.on_toggle(Message::BrushColorVariantToggled) // the control matches the rest of the compact panel typography.
.size(16) let color_variant_check = row![
.spacing(6); checkbox("", brush_color_variant)
.on_toggle(Message::BrushColorVariantToggled)
.size(12),
text("Color variant").size(LABEL_SIZE),
]
.spacing(4)
.align_y(iced::Alignment::Center);
// Live preview — show current brush tip // Live preview — show a realistic engine-rendered stroke using the active
let preview_handle = get_brush_preview(current_style); // size, opacity, hardness, and foreground color. The preview is
let live_preview = container(iced::widget::image(preview_handle).width(64).height(64)) // parameter-keyed and cached so it updates in real time without
.width(Length::Fill) // re-rendering identical configurations.
.height(80) let preview_handle = get_live_stroke_preview(
.center_x(Length::Fill) current_style,
.center_y(Length::Fill) current_size,
.style(move |_theme| styles::recessed_control(colors)); current_opacity,
current_hardness,
fg_color,
);
let live_preview = container(
iced::widget::image(preview_handle)
.width(LIVE_PREVIEW_IMG_SIZE as f32)
.height(LIVE_PREVIEW_IMG_SIZE as f32),
)
.width(Length::Fill)
.height(56)
.align_x(iced::alignment::Horizontal::Center)
.align_y(iced::alignment::Vertical::Center)
.style(move |_theme| styles::recessed_control(colors));
// Panel title is in the dock tab — no duplicate inside the panel. // Panel title is in the dock tab — no duplicate inside the panel.
let panel = column![ let panel = column![
tabs, tabs,
horizontal_rule(1), horizontal_rule(1),
scrollable(column![grid_rows, wc_rows, media_rows, imported_rows].spacing(6)) scrollable(column![grid_rows, wc_rows, media_rows, imported_rows].spacing(GRID_SPACING))
.height(Length::Fill), .height(Length::Fill),
horizontal_rule(1), horizontal_rule(1),
row![button(text("Import ABR").size(10)) row![button(text("Import ABR").size(LABEL_SIZE))
.on_press(Message::BrushImportAbr) .on_press(Message::BrushImportAbr)
.padding([4, 8]) .padding([3, 7])
.style( .style(
move |_theme: &iced::Theme, status: iced::widget::button::Status| { move |_theme: &iced::Theme, status: iced::widget::button::Status| {
match status { match status {
@@ -846,8 +1083,8 @@ pub fn view(
} }
} }
)] )]
.spacing(4), .spacing(GRID_SPACING),
text("Preview").size(10), text("Preview").size(LABEL_SIZE + 1),
live_preview, live_preview,
size_slider, size_slider,
opacity_slider, opacity_slider,
@@ -855,8 +1092,8 @@ pub fn view(
color_variant_check, color_variant_check,
variant_slider, variant_slider,
] ]
.spacing(2) .spacing(ITEM_SPACING)
.padding(4); .padding(3);
container(panel) container(panel)
.width(Length::Fill) .width(Length::Fill)