e509def0b2
- 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.
917 lines
32 KiB
Rust
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()
|
|
);
|
|
}
|
|
}
|