1279 lines
45 KiB
Rust
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()
|
|
);
|
|
}
|
|
}
|