Files
hcie-rust-v3.05/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs
T
Your Name e509def0b2
mandatory-regression-gate / deterministic-tests (push) Has been cancelled
mandatory-regression-gate / protected-performance-path (push) Has been cancelled
feat: Add digital, dry, ink, and paint brush presets; implement regression testing hooks
- Introduced new crates for digital brushes, dry media brushes, ink brushes, and paint brushes, each containing various presets.
- Updated the menus to include "Paste Special" and "Paste as New Layer" options.
- Enhanced the feature scorecard tests to validate selection texture encoding.
- Added a regression gate workflow for automated testing on push and pull requests.
- Implemented Git hooks for pre-commit checks to ensure code formatting and run tests before commits.
- Created a settings file for local configurations.
2026-07-23 02:28:49 +03:00

917 lines
32 KiB
Rust

//! Brushes panel — brush preset browser with visual thumbnails and 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
//!
//! ⚠️ PERFORMANCE: Brush preview images are cached via OnceLock.
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",
style: BrushStyle::Star,
category: BrushCategory::Drawing,
},
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,
},
];
/// Cached brush preview handles keyed by engine style index.
static BRUSH_PREVIEW_CACHE: OnceLock<Mutex<HashMap<usize, iced::widget::image::Handle>>> =
OnceLock::new();
/// Thumbnail size in pixels.
const THUMB_SIZE: u32 = 64;
/// 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,
}
}
/// Generate a brush stroke preview as RGBA pixels.
///
/// 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],
};
// 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;
}
}
}
}
}
}
pixels
}
/// Get or generate brush preview for a style.
fn get_brush_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 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),
);
cache.insert(style_idx, 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,
active_category: BrushCategory,
imported_presets: &[hcie_engine_api::BrushPreset],
active_brush_preset: Option<&hcie_engine_api::BrushPreset>,
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 — two columns remain usable in narrow dock panes.
let mut grid_rows = column![].spacing(4);
let mut current_row = row![].spacing(4);
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_brush_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 label = container(text(preset.name).size(9))
.width(Length::Fill)
.center_x(Length::Fill);
let cell = column![thumb, label]
.spacing(2)
.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) % 2 == 0 || idx == filtered_styles.len() - 1 {
grid_rows = grid_rows.push(current_row);
current_row = row![].spacing(4);
}
}
// ── Watercolor presets (from hcie-watercolor-brushes crate) ───────────
let mut wc_rows = column![].spacing(4);
if matches!(
active_category,
BrushCategory::All | BrushCategory::Watercolor
) {
let wc_presets = hcie_watercolor_brushes::watercolor_presets();
let mut wc_row = row![].spacing(4);
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 label = container(text(preset.name.clone()).size(9))
.width(Length::Fill)
.center_x(Length::Fill);
let cell = column![thumb, label]
.spacing(2)
.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) % 2 == 0 || idx == wc_presets.len() - 1 {
wc_rows = wc_rows.push(wc_row);
wc_row = row![].spacing(4);
}
}
}
// 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(4);
let mut media_row = row![].spacing(4);
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 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))
.width(Length::Fill)
.center_x(Length::Fill),
]
.spacing(2)
.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) % 2 == 0 || idx == filtered_media.len() - 1 {
media_rows = media_rows.push(media_row);
media_row = row![].spacing(4);
}
}
let mut imported_rows = column![].spacing(3);
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_brush_preview(style);
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),
column![
text(name).size(10),
text("Imported ABR").size(8).color(colors.text_secondary),
]
.spacing(1),
]
.spacing(6)
.align_y(iced::Alignment::Center),
)
.on_press(Message::BrushPresetSelected(preset_message))
.width(Length::Fill)
.padding(3)
.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 color_variant_check = checkbox("Color variant", brush_color_variant)
.on_toggle(Message::BrushColorVariantToggled)
.size(16)
.spacing(6);
// Live preview — show current brush tip
let preview_handle = get_brush_preview(current_style);
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));
// 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))
.height(Length::Fill),
horizontal_rule(1),
row![button(text("Import ABR").size(10))
.on_press(Message::BrushImportAbr)
.padding([4, 8])
.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(4),
text("Preview").size(10),
live_preview,
size_slider,
opacity_slider,
hardness_slider,
color_variant_check,
variant_slider,
]
.spacing(2)
.padding(4);
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(), 47);
assert_eq!(
presets
.iter()
.map(|preset| preset.id.as_str())
.collect::<HashSet<_>>()
.len(),
presets.len()
);
}
}