From eb17bf4205aeb953767edb87dd23f3b0e2284597 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 25 Jul 2026 16:28:31 +0300 Subject: [PATCH] feat: implement compact layout and real-time preview for Brushes & Tips panel - 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. --- ...66971-iced-brushes-compact-preview-plan.md | 263 +++++++ .../crates/hcie-iced-gui/src/dock/view.rs | 1 + .../hcie-iced-gui/src/panels/brushes.rs | 649 ++++++++++++------ 3 files changed, 707 insertions(+), 206 deletions(-) create mode 100644 .kilo/plans/1784597466971-iced-brushes-compact-preview-plan.md diff --git a/.kilo/plans/1784597466971-iced-brushes-compact-preview-plan.md b/.kilo/plans/1784597466971-iced-brushes-compact-preview-plan.md new file mode 100644 index 0000000..63fd47e --- /dev/null +++ b/.kilo/plans/1784597466971-iced-brushes-compact-preview-plan.md @@ -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>> = 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. diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs index 6204449..bea421e 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs @@ -187,6 +187,7 @@ pub fn panel_body<'a>( app.brush_category, &app.tool_state.imported_brushes, app.tool_state.active_brush_preset.as_ref(), + app.fg_color, colors, ), PaneType::Filters => crate::panels::filters::view( diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs index 4dbb668..3211a69 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs @@ -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: -//! - Category tabs (All, Drawing, Painting, Watercolor, Effects) -//! - Grid of brush tip thumbnails with visual stroke preview -//! - Selected brush highlighted with accent border -//! - Size/Opacity/Hardness sliders at bottom +//! **Purpose:** Displays the brush library as a compact 3-column grid of +//! realistic brush-stroke thumbnails and renders a live, parameter-aware brush +//! stroke preview at the bottom of the panel. Every preview is produced by the +//! public `hcie_engine_api::Engine::render_brush_preview` path, so the thumbnail +//! 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::panels::styles; @@ -212,12 +224,77 @@ const BRUSH_STYLES: &[BrushStyleEntry] = &[ }, ]; -/// Cached brush preview handles keyed by engine style index. -static BRUSH_PREVIEW_CACHE: OnceLock>> = +/// Default thumbnail color: neutral light gray so the stroke shape is visible +/// 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>> = OnceLock::new(); -/// Thumbnail size in pixels. -const THUMB_SIZE: u32 = 64; +/// Cached preset stroke preview handles keyed by preset id. +/// +/// 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>> = + 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>> = + 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. /// @@ -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. -fn generate_brush_preview(style: BrushStyle) -> Vec { - let w = THUMB_SIZE; - let h = THUMB_SIZE; - let mut pixels = vec![0u8; (w * h * 4) as usize]; - - let n_points = 40; - let mut points = Vec::with_capacity(n_points + 1); - for i in 0..=n_points { - let t = i as f32 / n_points as f32; - let x = 4.0 + t * (w as f32 - 8.0); - let angle = t * std::f32::consts::TAU * 1.25; - let sine = angle.sin(); - let taper = (t * (1.0 - t) * 4.0).powf(1.1); - let y = h as f32 / 2.0 + sine * (h as f32 * 0.22) * taper; - points.push((x, y, t)); - } - - // Color based on style - let color: [u8; 3] = match style { - BrushStyle::Pencil | BrushStyle::Sketch => [180, 180, 180], - BrushStyle::Pen | BrushStyle::InkPen => [40, 40, 40], - BrushStyle::Calligraphy => [60, 40, 30], - BrushStyle::Oil => [180, 120, 60], - BrushStyle::Charcoal => [80, 80, 80], - BrushStyle::Watercolor => [100, 150, 200], - BrushStyle::Crayon => [200, 100, 100], - BrushStyle::Airbrush | BrushStyle::Spray => [120, 120, 120], - BrushStyle::Marker => [60, 120, 60], - BrushStyle::Glow => [200, 200, 100], - BrushStyle::Leaf => [80, 160, 60], - _ => [200, 200, 200], +/// **Purpose:** Each built-in style needs a tip that lets the engine reveal its +/// actual stroke character in a small preview. Size is clamped later to fit +/// the thumbnail so only relative opacity/hardness matter here. +fn thumbnail_tip_for_style(style: BrushStyle) -> hcie_engine_api::BrushTip { + let (size, opacity, hardness) = match style { + BrushStyle::Pencil | BrushStyle::Pen | BrushStyle::InkPen | BrushStyle::Calligraphy => { + (4.0, 0.95, 1.0) + } + BrushStyle::Marker | BrushStyle::Sketch | BrushStyle::Hatch => (6.0, 0.9, 0.85), + BrushStyle::Oil | BrushStyle::Charcoal | BrushStyle::Crayon => (12.0, 0.9, 0.5), + BrushStyle::Watercolor + | BrushStyle::WetPaint + | BrushStyle::Airbrush + | BrushStyle::Spray => (14.0, 0.75, 0.0), + BrushStyle::Leaf + | BrushStyle::Rock + | BrushStyle::Meadow + | BrushStyle::Tree + | BrushStyle::Clouds + | BrushStyle::Dirt + | BrushStyle::Glow + | BrushStyle::Noise + | BrushStyle::Texture => (18.0, 0.9, 0.3), + BrushStyle::Star | BrushStyle::Square | BrushStyle::HardRound => (14.0, 0.95, 1.0), + BrushStyle::SoftRound | BrushStyle::Round | BrushStyle::Default => (14.0, 0.9, 0.5), + BrushStyle::Wood | BrushStyle::Bristle | BrushStyle::Mixer | BrushStyle::Blender => { + (14.0, 0.85, 0.45) + } + BrushStyle::Bitmap => (20.0, 0.9, 0.5), }; - // Draw stroke with varying thickness - for i in 0..n_points { - let (x1, y1, t1) = points[i]; - let (x2, y2, _) = points[i + 1]; - - let thickness = match style { - 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; - } - } - } - } - } + hcie_engine_api::BrushTip { + style, + size, + opacity, + hardness, + ..hcie_engine_api::BrushTip::default() } - - pixels } -/// Get or generate brush preview for a style. -fn get_brush_preview(style: BrushStyle) -> iced::widget::image::Handle { +/// Sample stroke path that fits inside a square preview buffer. +/// +/// 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 { + 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 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 .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); if let Some(handle) = cache.get(&style_idx) { return handle.clone(); } - let handle = iced::widget::image::Handle::from_rgba( - THUMB_SIZE, - THUMB_SIZE, - generate_brush_preview(style), - ); + let tip = clamped_preview_tip(thumbnail_tip_for_style(style), 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(style_idx, handle.clone()); 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. pub fn view( current_style: BrushStyle, @@ -427,6 +640,7 @@ pub fn view( active_category: BrushCategory, imported_presets: &[hcie_engine_api::BrushPreset], active_brush_preset: Option<&hcie_engine_api::BrushPreset>, + fg_color: [u8; 4], colors: ThemeColors, ) -> Element<'static, Message> { // Category tabs @@ -500,9 +714,9 @@ pub fn view( )); } - // Brush preset grid — two columns remain usable in narrow dock panes. - let mut grid_rows = column![].spacing(4); - let mut current_row = row![].spacing(4); + // Brush preset grid — three compact columns fit comfortably in narrow dock panes. + let mut grid_rows = column![].spacing(GRID_SPACING); + let mut current_row = row![].spacing(GRID_SPACING); let filtered_styles: Vec<&BrushStyleEntry> = BRUSH_STYLES .iter() @@ -511,23 +725,27 @@ pub fn view( for (idx, preset) in filtered_styles.iter().enumerate() { 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 style = preset.style; - 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 thumb = container( + iced::widget::image(preview) + .width(THUMB_IMG_SIZE as f32) + .height(THUMB_IMG_SIZE as f32), + ) + .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) - .center_x(Length::Fill); + .align_x(iced::alignment::Horizontal::Center); let cell = column![thumb, label] - .spacing(2) + .spacing(ITEM_SPACING) .align_x(iced::Alignment::Center); let clickable = button(cell) @@ -554,54 +772,44 @@ pub fn view( 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); - current_row = row![].spacing(4); + current_row = row![].spacing(GRID_SPACING); } } // ── Watercolor presets (from hcie-watercolor-brushes crate) ─────────── - let mut wc_rows = column![].spacing(4); + let mut wc_rows = column![].spacing(GRID_SPACING); if matches!( active_category, BrushCategory::All | BrushCategory::Watercolor ) { 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() { let is_selected = preset_style(preset.style) == current_style && active_brush_preset .as_ref() .map_or(false, |p| p.id == preset.id); let c = colors; - let wc_color: [u8; 3] = match preset.id.as_str() { - "wc_wash" => [100, 150, 200], - "wc_wet" => [80, 130, 190], - "wc_dry" => [140, 110, 80], - "wc_glaze" => [120, 160, 210], - "wc_splat" => [90, 140, 180], - "wc_bleed" => [110, 80, 150], - "wc_grain" => [130, 120, 100], - "wc_bloom" => [90, 170, 200], - _ => [100, 150, 200], - }; - 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 preview = get_preset_preview(preset); + let thumb = container( + iced::widget::image(preview) + .width(THUMB_IMG_SIZE as f32) + .height(THUMB_IMG_SIZE as f32), + ) + .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.clone()).size(9)) + let label = container(text(preset.name.clone()).size(LABEL_SIZE)) .width(Length::Fill) - .center_x(Length::Fill); + .align_x(iced::alignment::Horizontal::Center); let cell = column![thumb, label] - .spacing(2) + .spacing(ITEM_SPACING) .align_x(iced::Alignment::Center); let preset_msg = preset.clone(); @@ -629,9 +837,9 @@ pub fn view( 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_row = row![].spacing(4); + wc_row = row![].spacing(GRID_SPACING); } } } @@ -649,27 +857,31 @@ pub fn view( || active_category == media_preset_category(&preset.category) }) .collect(); - let mut media_rows = column![].spacing(4); - let mut media_row = row![].spacing(4); + let mut media_rows = column![].spacing(GRID_SPACING); + let mut media_row = row![].spacing(GRID_SPACING); for (idx, preset) in filtered_media.iter().enumerate() { let style = preset_style(preset.style); let selected = style == current_style && 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 c = colors; let cell = column![ - 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, selected)), - container(text(preset.name.clone()).size(9)) + container( + iced::widget::image(preview) + .width(THUMB_IMG_SIZE as f32) + .height(THUMB_IMG_SIZE as f32) + ) + .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, selected)), + container(text(preset.name.clone()).size(LABEL_SIZE)) .width(Length::Fill) - .center_x(Length::Fill), + .align_x(iced::alignment::Horizontal::Center), ] - .spacing(2) + .spacing(ITEM_SPACING) .align_x(iced::Alignment::Center); media_row = media_row.push( button(cell) @@ -688,13 +900,13 @@ pub fn view( ..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_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!( active_category, BrushCategory::All | BrushCategory::Imported @@ -703,19 +915,25 @@ pub fn view( let style = preset_style(preset.style); let selected = style == current_style; let preset_message = preset.clone(); - let preview = get_brush_preview(style); + let preview = get_preset_preview(preset); let name = preset.name.clone(); imported_rows = imported_rows.push( button( row![ - container(iced::widget::image(preview).width(42).height(24)) - .width(48) - .height(28) - .center_x(Length::Fill) - .center_y(Length::Fill), + container( + iced::widget::image(preview) + .width(THUMB_IMG_SIZE as f32) + .height(THUMB_IMG_SIZE as f32) + ) + .width(THUMB_SIZE as f32) + .height(THUMB_SIZE as f32) + .align_x(iced::alignment::Horizontal::Center) + .align_y(iced::alignment::Vertical::Center), column![ - text(name).size(10), - text("Imported ABR").size(8).color(colors.text_secondary), + text(name).size(LABEL_SIZE + 1), + text("Imported ABR") + .size(LABEL_SIZE) + .color(colors.text_secondary), ] .spacing(1), ] @@ -724,7 +942,7 @@ pub fn view( ) .on_press(Message::BrushPresetSelected(preset_message)) .width(Length::Fill) - .padding(3) + .padding(2) .style(move |_theme, status| iced::widget::button::Style { background: Some(iced::Background::Color(if selected { colors.bg_active @@ -804,30 +1022,49 @@ pub fn view( Message::BrushVariantAmountChanged, ); - let color_variant_check = checkbox("Color variant", brush_color_variant) - .on_toggle(Message::BrushColorVariantToggled) - .size(16) - .spacing(6); + // Compact color-variant toggle: an empty 12 px checkbox plus an 8 px label so + // the control matches the rest of the compact panel typography. + let color_variant_check = row![ + 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 - let preview_handle = get_brush_preview(current_style); - let live_preview = container(iced::widget::image(preview_handle).width(64).height(64)) - .width(Length::Fill) - .height(80) - .center_x(Length::Fill) - .center_y(Length::Fill) - .style(move |_theme| styles::recessed_control(colors)); + // Live preview — show a realistic engine-rendered stroke using the active + // size, opacity, hardness, and foreground color. The preview is + // parameter-keyed and cached so it updates in real time without + // re-rendering identical configurations. + let preview_handle = get_live_stroke_preview( + current_style, + current_size, + 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. let panel = column![ tabs, 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), horizontal_rule(1), - row![button(text("Import ABR").size(10)) + row![button(text("Import ABR").size(LABEL_SIZE)) .on_press(Message::BrushImportAbr) - .padding([4, 8]) + .padding([3, 7]) .style( move |_theme: &iced::Theme, status: iced::widget::button::Status| { match status { @@ -846,8 +1083,8 @@ pub fn view( } } )] - .spacing(4), - text("Preview").size(10), + .spacing(GRID_SPACING), + text("Preview").size(LABEL_SIZE + 1), live_preview, size_slider, opacity_slider, @@ -855,8 +1092,8 @@ pub fn view( color_variant_check, variant_slider, ] - .spacing(2) - .padding(4); + .spacing(ITEM_SPACING) + .padding(3); container(panel) .width(Length::Fill)