Files
hcie-rust-v3.05/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs
T
Your Name a64f1184cd
mandatory-regression-gate / deterministic-tests (push) Waiting to run
mandatory-regression-gate / protected-performance-path (push) Waiting to run
feat: add star sparkle brush preset and enhance star brush parameters
2026-07-26 03:50:02 +03:00

1279 lines
45 KiB
Rust

//! Brushes panel — compact brush preset browser with realistic live preview.
//!
//! **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.
//!
//! **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;
use crate::theme::ThemeColors;
use crate::widgets::plain_slider::plain_slider;
use hcie_engine_api::BrushStyle;
use iced::widget::{
button, checkbox, column, container, horizontal_rule, row, scrollable, svg, text,
};
use iced::{Element, Length};
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
/// One built-in brush style exposed by the engine.
struct BrushStyleEntry {
name: &'static str,
style: BrushStyle,
category: BrushCategory,
}
/// User-selectable category used to filter brush styles and runtime presets.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BrushCategory {
All,
Drawing,
Painting,
Watercolor,
Effects,
Custom,
Imported,
}
const BRUSH_STYLES: &[BrushStyleEntry] = &[
BrushStyleEntry {
name: "Default",
style: BrushStyle::Default,
category: BrushCategory::Drawing,
},
BrushStyleEntry {
name: "Round",
style: BrushStyle::Round,
category: BrushCategory::Drawing,
},
BrushStyleEntry {
name: "Square",
style: BrushStyle::Square,
category: BrushCategory::Drawing,
},
BrushStyleEntry {
name: "Hard Round",
style: BrushStyle::HardRound,
category: BrushCategory::Drawing,
},
BrushStyleEntry {
name: "Soft Round",
style: BrushStyle::SoftRound,
category: BrushCategory::Drawing,
},
BrushStyleEntry {
name: "Pencil",
style: BrushStyle::Pencil,
category: BrushCategory::Drawing,
},
BrushStyleEntry {
name: "Pen",
style: BrushStyle::Pen,
category: BrushCategory::Drawing,
},
BrushStyleEntry {
name: "Ink Pen",
style: BrushStyle::InkPen,
category: BrushCategory::Drawing,
},
BrushStyleEntry {
name: "Calligraphy",
style: BrushStyle::Calligraphy,
category: BrushCategory::Drawing,
},
BrushStyleEntry {
name: "Marker",
style: BrushStyle::Marker,
category: BrushCategory::Drawing,
},
BrushStyleEntry {
name: "Sketch",
style: BrushStyle::Sketch,
category: BrushCategory::Drawing,
},
BrushStyleEntry {
name: "Hatch",
style: BrushStyle::Hatch,
category: BrushCategory::Drawing,
},
BrushStyleEntry {
name: "Star Sparkle",
style: BrushStyle::Star,
category: BrushCategory::Effects,
},
BrushStyleEntry {
name: "Oil",
style: BrushStyle::Oil,
category: BrushCategory::Painting,
},
BrushStyleEntry {
name: "Charcoal",
style: BrushStyle::Charcoal,
category: BrushCategory::Painting,
},
BrushStyleEntry {
name: "Wood",
style: BrushStyle::Wood,
category: BrushCategory::Painting,
},
BrushStyleEntry {
name: "Watercolor",
style: BrushStyle::Watercolor,
category: BrushCategory::Painting,
},
BrushStyleEntry {
name: "Airbrush",
style: BrushStyle::Airbrush,
category: BrushCategory::Painting,
},
BrushStyleEntry {
name: "Crayon",
style: BrushStyle::Crayon,
category: BrushCategory::Painting,
},
BrushStyleEntry {
name: "Wet Paint",
style: BrushStyle::WetPaint,
category: BrushCategory::Painting,
},
BrushStyleEntry {
name: "Bristle",
style: BrushStyle::Bristle,
category: BrushCategory::Painting,
},
BrushStyleEntry {
name: "Mixer",
style: BrushStyle::Mixer,
category: BrushCategory::Painting,
},
BrushStyleEntry {
name: "Blender",
style: BrushStyle::Blender,
category: BrushCategory::Painting,
},
BrushStyleEntry {
name: "Noise",
style: BrushStyle::Noise,
category: BrushCategory::Effects,
},
BrushStyleEntry {
name: "Texture",
style: BrushStyle::Texture,
category: BrushCategory::Effects,
},
BrushStyleEntry {
name: "Spray",
style: BrushStyle::Spray,
category: BrushCategory::Effects,
},
BrushStyleEntry {
name: "Leaf",
style: BrushStyle::Leaf,
category: BrushCategory::Effects,
},
BrushStyleEntry {
name: "Rock",
style: BrushStyle::Rock,
category: BrushCategory::Effects,
},
BrushStyleEntry {
name: "Meadow",
style: BrushStyle::Meadow,
category: BrushCategory::Effects,
},
BrushStyleEntry {
name: "Glow",
style: BrushStyle::Glow,
category: BrushCategory::Effects,
},
BrushStyleEntry {
name: "Clouds",
style: BrushStyle::Clouds,
category: BrushCategory::Effects,
},
BrushStyleEntry {
name: "Dirt",
style: BrushStyle::Dirt,
category: BrushCategory::Effects,
},
BrushStyleEntry {
name: "Tree",
style: BrushStyle::Tree,
category: BrushCategory::Effects,
},
BrushStyleEntry {
name: "Imported",
style: BrushStyle::Bitmap,
category: BrushCategory::Imported,
},
];
/// 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();
/// 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,
cyclic: bool,
r: u8,
g: u8,
b: u8,
a: u8,
}
/// Determines whether a built-in style belongs in the selected category.
///
/// **Arguments:** `active` is the selected filter and `style_category` is the catalog grouping.
/// **Returns:** `true` when the style should remain visible. **Side Effects:** None.
fn category_matches_style(active: BrushCategory, style_category: BrushCategory) -> bool {
match active {
BrushCategory::All => true,
BrushCategory::Drawing
| BrushCategory::Painting
| BrushCategory::Watercolor
| BrushCategory::Effects => active == style_category,
BrushCategory::Custom => false,
BrushCategory::Imported => style_category == BrushCategory::Imported,
}
}
/// Maps a runtime preset style into the protocol style rendered by this panel.
///
/// **Arguments:** `style` comes from `hcie_brush_engine` through the public preset API.
/// **Returns:** The equivalent protocol `BrushStyle`. **Side Effects:** None.
fn preset_style(style: hcie_engine_api::brush::BrushStyle) -> BrushStyle {
use hcie_engine_api::brush::BrushStyle as PresetStyle;
match style {
PresetStyle::Round => BrushStyle::Round,
PresetStyle::Square => BrushStyle::Square,
PresetStyle::HardRound => BrushStyle::HardRound,
PresetStyle::SoftRound => BrushStyle::SoftRound,
PresetStyle::Star => BrushStyle::Star,
PresetStyle::Noise => BrushStyle::Noise,
PresetStyle::Texture => BrushStyle::Texture,
PresetStyle::Spray => BrushStyle::Spray,
PresetStyle::Pencil => BrushStyle::Pencil,
PresetStyle::Pen => BrushStyle::Pen,
PresetStyle::Calligraphy => BrushStyle::Calligraphy,
PresetStyle::Oil => BrushStyle::Oil,
PresetStyle::Charcoal => BrushStyle::Charcoal,
PresetStyle::Leaf => BrushStyle::Leaf,
PresetStyle::Rock => BrushStyle::Rock,
PresetStyle::Meadow => BrushStyle::Meadow,
PresetStyle::Wood => BrushStyle::Wood,
PresetStyle::Watercolor => BrushStyle::Watercolor,
PresetStyle::Marker => BrushStyle::Marker,
PresetStyle::Sketch => BrushStyle::Sketch,
PresetStyle::Hatch => BrushStyle::Hatch,
PresetStyle::Glow => BrushStyle::Glow,
PresetStyle::Airbrush => BrushStyle::Airbrush,
PresetStyle::Crayon => BrushStyle::Crayon,
PresetStyle::WetPaint => BrushStyle::WetPaint,
PresetStyle::InkPen => BrushStyle::InkPen,
PresetStyle::Clouds => BrushStyle::Clouds,
PresetStyle::Dirt => BrushStyle::Dirt,
PresetStyle::Tree => BrushStyle::Tree,
PresetStyle::Bristle => BrushStyle::Bristle,
PresetStyle::Mixer => BrushStyle::Mixer,
PresetStyle::Blender => BrushStyle::Blender,
PresetStyle::Bitmap => BrushStyle::Bitmap,
}
}
/// Maps a named media preset category into the panel's compact tab model.
///
/// **Arguments:** `category` is supplied by a media preset crate. **Returns:** The matching panel
/// filter. **Side Effects / Dependencies:** None.
fn media_preset_category(category: &str) -> BrushCategory {
match category {
"Opaque Watermedia" | "Oils" | "Acrylics" | "Tempera" => BrushCategory::Painting,
"Digital Blend" | "Digital Texture" | "Digital FX" => BrushCategory::Effects,
_ => BrushCategory::Drawing,
}
}
/// Build a representative protocol `BrushTip` for a built-in style thumbnail.
///
/// **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),
};
hcie_engine_api::BrushTip {
style,
size,
opacity,
hardness,
..hcie_engine_api::BrushTip::default()
}
}
/// 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,
cyclic_color: bool,
angle: f32,
size_variance: f32,
angle_variance: f32,
) -> hcie_engine_api::BrushTip {
hcie_engine_api::BrushTip {
style,
size,
opacity,
hardness,
time_enabled: cyclic_color,
time_size_start: 1.0,
time_size_end: 1.0,
time_opacity_start: 1.0,
time_opacity_end: 1.0,
time_angle_start: 0.0,
time_angle_end: 0.0,
angle,
roundness: size_variance,
rotation_random: angle_variance,
..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,
cyclic: bool,
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,
cyclic,
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,
cyclic_color: bool,
fg_color: [u8; 4],
angle: f32,
size_variance: f32,
angle_variance: f32,
) -> 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, cyclic_color, 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,
cyclic_color,
angle,
size_variance,
angle_variance,
);
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
}
/// Clamp a cyclic speed value to the engine's valid range.
fn clamp_cyclic_speed(speed: f32) -> f32 {
speed.clamp(0.01, 5.0)
}
/// 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 = 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 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,
current_size: f32,
current_opacity: f32,
current_hardness: f32,
brush_color_variant: bool,
brush_variant_amount: f32,
brush_cyclic_color: bool,
brush_cyclic_speed: f32,
brush_angle: f32,
brush_size_variance: f32,
brush_angle_variance: f32,
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
let categories = [
(BrushCategory::All, "All brushes", "icons/panel-brush.svg"),
(BrushCategory::Drawing, "Drawing", "icons/pen.svg"),
(BrushCategory::Painting, "Painting", "icons/brush.svg"),
(
BrushCategory::Watercolor,
"Watercolor",
"icons/watercolor.svg",
),
(BrushCategory::Effects, "Effects", "icons/glow.svg"),
(BrushCategory::Custom, "Custom", "icons/star.svg"),
(BrushCategory::Imported, "Imported", "icons/import_abr.svg"),
];
let mut tabs = row![].spacing(2);
for (cat, label, icon_path) in categories {
let active = cat == active_category;
let icon = svg(svg::Handle::from_path(
std::path::Path::new("hcie-iced-app/assets").join(icon_path),
))
.width(16)
.height(16)
.style(move |_theme, _status| svg::Style {
color: Some(if active {
colors.accent
} else {
colors.text_secondary
}),
});
let btn = button(icon)
.on_press(Message::BrushCategorySelected(cat))
.padding([3, 6])
.style(
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
iced::widget::button::Status::Hovered => iced::widget::button::Style {
background: Some(iced::Background::Color(colors.bg_hover)),
text_color: colors.text_primary,
border: iced::Border::default(),
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(if active {
colors.bg_active
} else {
iced::Color::TRANSPARENT
})),
text_color: if active {
colors.accent
} else {
colors.text_secondary
},
border: iced::Border::default()
.color(if active {
colors.accent
} else {
colors.border_low
})
.width(if active { 1 } else { 0 }),
..Default::default()
},
},
);
tabs = tabs.push(styles::balloon_tooltip(
btn,
label,
iced::widget::tooltip::Position::Bottom,
colors,
));
}
// 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()
.filter(|entry| category_matches_style(active_category, entry.category))
.collect();
for (idx, preset) in filtered_styles.iter().enumerate() {
let is_selected = preset.style == current_style;
let preview = get_style_preview(preset.style);
let c = colors;
let style = preset.style;
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(LABEL_SIZE))
.width(Length::Fill)
.align_x(iced::alignment::Horizontal::Center);
let cell = column![thumb, label]
.spacing(ITEM_SPACING)
.align_x(iced::Alignment::Center);
let clickable = button(cell)
.on_press(Message::BrushStyleSelected(style))
.padding(2)
.style(
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
iced::widget::button::Status::Hovered => iced::widget::button::Style {
background: Some(iced::Background::Color(c.bg_hover)),
text_color: c.text_primary,
border: iced::Border::default(),
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.0,
))),
text_color: c.text_primary,
border: iced::Border::default(),
..Default::default()
},
},
);
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);
}
}
// ── Watercolor presets (from hcie-watercolor-brushes crate) ───────────
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(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 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(LABEL_SIZE))
.width(Length::Fill)
.align_x(iced::alignment::Horizontal::Center);
let cell = column![thumb, label]
.spacing(ITEM_SPACING)
.align_x(iced::Alignment::Center);
let preset_msg = preset.clone();
let clickable = button(cell)
.on_press(Message::BrushPresetSelected(preset_msg))
.padding(2)
.style(
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
iced::widget::button::Status::Hovered => iced::widget::button::Style {
background: Some(iced::Background::Color(c.bg_hover)),
text_color: c.text_primary,
border: iced::Border::default(),
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.0, 0.0, 0.0, 0.0,
))),
text_color: c.text_primary,
border: iced::Border::default(),
..Default::default()
},
},
);
wc_row = wc_row.push(clickable);
if (idx + 1) % GRID_COLUMNS == 0 || idx == wc_presets.len() - 1 {
wc_rows = wc_rows.push(wc_row);
wc_row = row![].spacing(GRID_SPACING);
}
}
}
// Traditional and digital media catalogs live in independent crates and expose only public
// engine-API preset data. This keeps catalog growth out of the GUI and engine internals.
let mut media_presets = hcie_dry_media_brushes::dry_media_presets();
media_presets.extend(hcie_ink_brushes::ink_presets());
media_presets.extend(hcie_paint_brushes::paint_presets());
media_presets.extend(hcie_digital_brushes::digital_presets());
let filtered_media: Vec<_> = media_presets
.into_iter()
.filter(|preset| {
active_category == BrushCategory::All
|| active_category == media_preset_category(&preset.category)
})
.collect();
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_preset_preview(preset);
let preset_message = preset.clone();
let c = colors;
let cell = column![
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)
.align_x(iced::alignment::Horizontal::Center),
]
.spacing(ITEM_SPACING)
.align_x(iced::Alignment::Center);
media_row = media_row.push(
button(cell)
.on_press(Message::BrushPresetSelected(preset_message))
.padding(2)
.style(move |_theme, status| iced::widget::button::Style {
background: Some(iced::Background::Color(
if status == iced::widget::button::Status::Hovered {
c.bg_hover
} else {
iced::Color::TRANSPARENT
},
)),
text_color: c.text_primary,
border: iced::Border::default(),
..Default::default()
}),
);
if (idx + 1) % GRID_COLUMNS == 0 || idx == filtered_media.len() - 1 {
media_rows = media_rows.push(media_row);
media_row = row![].spacing(GRID_SPACING);
}
}
let mut imported_rows = column![].spacing(GRID_SPACING);
if matches!(
active_category,
BrushCategory::All | BrushCategory::Imported
) {
for preset in imported_presets {
let style = preset_style(preset.style);
let selected = style == current_style;
let preset_message = preset.clone();
let preview = get_preset_preview(preset);
let name = preset.name.clone();
imported_rows = imported_rows.push(
button(
row![
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(LABEL_SIZE + 1),
text("Imported ABR")
.size(LABEL_SIZE)
.color(colors.text_secondary),
]
.spacing(1),
]
.spacing(6)
.align_y(iced::Alignment::Center),
)
.on_press(Message::BrushPresetSelected(preset_message))
.width(Length::Fill)
.padding(2)
.style(move |_theme, status| iced::widget::button::Style {
background: Some(iced::Background::Color(if selected {
colors.bg_active
} else if status == iced::widget::button::Status::Hovered {
colors.bg_hover
} else {
colors.bg_panel
})),
border: iced::Border::default()
.color(if selected {
colors.accent
} else {
colors.border_low
})
.width(1)
.rounded(4),
text_color: colors.text_primary,
..Default::default()
}),
);
}
}
if active_category == BrushCategory::Custom
|| (active_category == BrushCategory::Imported && imported_presets.is_empty())
{
imported_rows = imported_rows.push(
text(if active_category == BrushCategory::Custom {
"No custom presets"
} else {
"No imported presets"
})
.size(10)
.color(colors.text_secondary),
);
}
// Sliders
let size_slider = plain_slider(
"Size",
current_size,
1.0..=200.0,
1.0,
"px",
0,
colors,
Message::BrushSizeChanged,
);
let opacity_slider = plain_slider(
"Opacity",
current_opacity,
0.0..=1.0,
0.01,
"%",
0,
colors,
Message::BrushOpacityChanged,
);
let hardness_slider = plain_slider(
"Hardness",
current_hardness,
0.0..=1.0,
0.01,
"%",
0,
colors,
Message::BrushHardnessChanged,
);
let variant_slider = plain_slider(
"Variant",
brush_variant_amount,
0.0..=1.0,
0.01,
"%",
0,
colors,
Message::BrushVariantAmountChanged,
);
let cyclic_speed_slider = plain_slider(
"Cyclic Speed",
clamp_cyclic_speed(brush_cyclic_speed),
0.01..=5.0,
0.01,
"x",
2,
colors,
Message::BrushCyclicSpeedChanged,
);
let is_star = current_style == BrushStyle::Star;
let star_angle_slider = if is_star {
Some(plain_slider(
"Star Angle",
brush_angle,
0.0..=std::f32::consts::TAU,
0.01,
"rad",
2,
colors,
Message::BrushAngleChanged,
))
} else {
None
};
let star_size_var_slider = if is_star {
Some(plain_slider(
"Size Variance",
brush_size_variance,
0.0..=1.0,
0.01,
"%",
0,
colors,
Message::BrushSizeVarianceChanged,
))
} else {
None
};
let star_angle_var_slider = if is_star {
Some(plain_slider(
"Angle Variance",
brush_angle_variance,
0.0..=1.0,
0.01,
"%",
0,
colors,
Message::BrushAngleVarianceChanged,
))
} else {
None
};
// 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);
let cyclic_color_check = row![
checkbox("", brush_cyclic_color)
.on_toggle(Message::BrushCyclicColorToggled)
.size(12),
text("Cyclic color").size(LABEL_SIZE),
]
.spacing(4)
.align_y(iced::Alignment::Center);
// 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,
brush_cyclic_color,
fg_color,
brush_angle,
brush_size_variance,
brush_angle_variance,
);
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 mut panel_children: Vec<Element<'static, Message>> = vec![
tabs.into(),
horizontal_rule(1).into(),
scrollable(column![grid_rows, wc_rows, media_rows, imported_rows].spacing(GRID_SPACING))
.height(Length::Fill)
.into(),
horizontal_rule(1).into(),
];
panel_children.push(
row![button(text("Import ABR").size(LABEL_SIZE))
.on_press(Message::BrushImportAbr)
.padding([3, 7])
.style(
move |_theme: &iced::Theme, status: iced::widget::button::Status| {
match status {
iced::widget::button::Status::Hovered => iced::widget::button::Style {
background: Some(iced::Background::Color(colors.accent)),
text_color: colors.text_primary,
border: iced::Border::default(),
..Default::default()
},
_ => iced::widget::button::Style {
background: Some(iced::Background::Color(colors.bg_panel)),
text_color: colors.text_primary,
border: iced::Border::default().color(colors.border_low).width(1),
..Default::default()
},
}
}
)]
.spacing(GRID_SPACING)
.into(),
);
panel_children.push(text("Preview").size(LABEL_SIZE + 1).into());
panel_children.push(live_preview.into());
panel_children.push(size_slider.into());
panel_children.push(opacity_slider.into());
panel_children.push(hardness_slider.into());
panel_children.push(color_variant_check.into());
panel_children.push(variant_slider.into());
panel_children.push(cyclic_color_check.into());
panel_children.push(cyclic_speed_slider.into());
if let Some(s) = star_angle_slider {
panel_children.push(s.into());
}
if let Some(s) = star_size_var_slider {
panel_children.push(s.into());
}
if let Some(s) = star_angle_var_slider {
panel_children.push(s.into());
}
let panel = iced::widget::Column::with_children(panel_children)
.spacing(ITEM_SPACING)
.padding(3);
container(panel)
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| styles::panel_background(colors))
.into()
}
#[cfg(test)]
mod tests {
use super::{category_matches_style, BrushCategory, BRUSH_STYLES};
use std::collections::HashSet;
#[test]
fn catalog_contains_every_engine_brush_style() {
assert_eq!(BRUSH_STYLES.len(), 34);
}
#[test]
fn category_filter_keeps_expected_groups() {
assert!(category_matches_style(
BrushCategory::All,
BrushCategory::Effects
));
assert!(category_matches_style(
BrushCategory::Drawing,
BrushCategory::Drawing
));
assert!(!category_matches_style(
BrushCategory::Painting,
BrushCategory::Drawing
));
assert!(category_matches_style(
BrushCategory::Imported,
BrushCategory::Imported
));
}
/// Locks the reference-derived crate inventory and prevents preset ID collisions.
#[test]
fn media_crates_expose_complete_unique_catalog() {
let mut presets = hcie_dry_media_brushes::dry_media_presets();
presets.extend(hcie_ink_brushes::ink_presets());
presets.extend(hcie_paint_brushes::paint_presets());
presets.extend(hcie_digital_brushes::digital_presets());
presets.extend(hcie_watercolor_brushes::watercolor_presets());
assert_eq!(presets.len(), 48);
assert_eq!(
presets
.iter()
.map(|preset| preset.id.as_str())
.collect::<HashSet<_>>()
.len(),
presets.len()
);
}
}