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.
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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<Mutex<HashMap<usize, iced::widget::image::Handle>>> =
|
||||
/// 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<Mutex<HashMap<usize, iced::widget::image::Handle>>> =
|
||||
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<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.
|
||||
///
|
||||
@@ -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<u8> {
|
||||
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<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 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)
|
||||
|
||||
Reference in New Issue
Block a user