//! 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>> = 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 { 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::>() .len(), presets.len() ); } }