2026-07-12 02:05:31 +03:00
|
|
|
//! Brushes panel — brush preset browser with visual thumbnails and live preview.
|
|
|
|
|
//!
|
|
|
|
|
//! Matches the egui version's layout:
|
|
|
|
|
//! - Category tabs (All, Drawing, Painting, 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.
|
2026-07-11 18:13:03 +03:00
|
|
|
|
|
|
|
|
use crate::app::Message;
|
|
|
|
|
use crate::panels::styles;
|
|
|
|
|
use crate::theme::ThemeColors;
|
|
|
|
|
use hcie_engine_api::BrushStyle;
|
2026-07-12 02:05:31 +03:00
|
|
|
use iced::widget::{button, column, container, horizontal_rule, row, scrollable, slider, text};
|
2026-07-11 18:13:03 +03:00
|
|
|
use iced::{Element, Length};
|
2026-07-12 02:05:31 +03:00
|
|
|
use std::sync::OnceLock;
|
2026-07-11 18:13:03 +03:00
|
|
|
|
2026-07-12 02:05:31 +03:00
|
|
|
/// A brush preset entry with category.
|
|
|
|
|
#[allow(dead_code)]
|
2026-07-11 18:13:03 +03:00
|
|
|
struct BrushPreset {
|
|
|
|
|
name: &'static str,
|
|
|
|
|
style: BrushStyle,
|
2026-07-12 02:05:31 +03:00
|
|
|
category: BrushCategory,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
enum BrushCategory {
|
|
|
|
|
All,
|
|
|
|
|
Drawing,
|
|
|
|
|
Painting,
|
|
|
|
|
Effects,
|
2026-07-11 18:13:03 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const BRUSH_PRESETS: &[BrushPreset] = &[
|
2026-07-12 02:05:31 +03:00
|
|
|
// Drawing
|
|
|
|
|
BrushPreset { name: "Default", style: BrushStyle::Default, category: BrushCategory::Drawing },
|
|
|
|
|
BrushPreset { name: "Pencil", style: BrushStyle::Pencil, category: BrushCategory::Drawing },
|
|
|
|
|
BrushPreset { name: "Pen", style: BrushStyle::Pen, category: BrushCategory::Drawing },
|
|
|
|
|
BrushPreset { name: "Ink Pen", style: BrushStyle::InkPen, category: BrushCategory::Drawing },
|
|
|
|
|
BrushPreset { name: "Calligraphy", style: BrushStyle::Calligraphy, category: BrushCategory::Drawing },
|
|
|
|
|
BrushPreset { name: "Sketch", style: BrushStyle::Sketch, category: BrushCategory::Drawing },
|
|
|
|
|
BrushPreset { name: "Marker", style: BrushStyle::Marker, category: BrushCategory::Drawing },
|
|
|
|
|
BrushPreset { name: "Hatch", style: BrushStyle::Hatch, category: BrushCategory::Drawing },
|
|
|
|
|
// Painting
|
|
|
|
|
BrushPreset { name: "Oil", style: BrushStyle::Oil, category: BrushCategory::Painting },
|
|
|
|
|
BrushPreset { name: "Charcoal", style: BrushStyle::Charcoal, category: BrushCategory::Painting },
|
|
|
|
|
BrushPreset { name: "Watercolor", style: BrushStyle::Watercolor, category: BrushCategory::Painting },
|
|
|
|
|
BrushPreset { name: "Crayon", style: BrushStyle::Crayon, category: BrushCategory::Painting },
|
|
|
|
|
BrushPreset { name: "Airbrush", style: BrushStyle::Airbrush, category: BrushCategory::Painting },
|
|
|
|
|
BrushPreset { name: "Spray", style: BrushStyle::Spray, category: BrushCategory::Painting },
|
|
|
|
|
BrushPreset { name: "Soft Round", style: BrushStyle::SoftRound, category: BrushCategory::Painting },
|
|
|
|
|
BrushPreset { name: "Hard Round", style: BrushStyle::HardRound, category: BrushCategory::Painting },
|
|
|
|
|
// Effects
|
|
|
|
|
BrushPreset { name: "Star", style: BrushStyle::Star, category: BrushCategory::Effects },
|
|
|
|
|
BrushPreset { name: "Noise", style: BrushStyle::Noise, category: BrushCategory::Effects },
|
|
|
|
|
BrushPreset { name: "Texture", style: BrushStyle::Texture, category: BrushCategory::Effects },
|
|
|
|
|
BrushPreset { name: "Glow", style: BrushStyle::Glow, category: BrushCategory::Effects },
|
|
|
|
|
BrushPreset { name: "Leaf", style: BrushStyle::Leaf, category: BrushCategory::Effects },
|
|
|
|
|
BrushPreset { name: "Clouds", style: BrushStyle::Clouds, category: BrushCategory::Effects },
|
2026-07-11 18:13:03 +03:00
|
|
|
];
|
|
|
|
|
|
2026-07-12 02:05:31 +03:00
|
|
|
/// Cached brush preview images: (style_index, pixels).
|
|
|
|
|
static BRUSH_PREVIEW_CACHE: OnceLock<Vec<(usize, Vec<u8>)>> = OnceLock::new();
|
|
|
|
|
|
|
|
|
|
/// Thumbnail size in pixels.
|
|
|
|
|
const THUMB_SIZE: u32 = 64;
|
|
|
|
|
|
|
|
|
|
/// 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;
|
|
|
|
|
|
|
|
|
|
// Check cache
|
|
|
|
|
if let Some(cache) = BRUSH_PREVIEW_CACHE.get() {
|
|
|
|
|
if let Some((_, pixels)) = cache.iter().find(|(idx, _)| *idx == style_idx) {
|
|
|
|
|
return iced::widget::image::Handle::from_rgba(THUMB_SIZE, THUMB_SIZE, pixels.clone());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate preview
|
|
|
|
|
let pixels = generate_brush_preview(style);
|
|
|
|
|
|
|
|
|
|
// Store in cache (ignore error if already set)
|
|
|
|
|
if let Some(cache) = BRUSH_PREVIEW_CACHE.get() {
|
|
|
|
|
let mut new_cache = cache.clone();
|
|
|
|
|
new_cache.push((style_idx, pixels.clone()));
|
|
|
|
|
// Can't replace OnceLock, so we just use the local copy
|
|
|
|
|
return iced::widget::image::Handle::from_rgba(THUMB_SIZE, THUMB_SIZE, pixels);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
iced::widget::image::Handle::from_rgba(THUMB_SIZE, THUMB_SIZE, pixels)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Build the brushes panel with visual thumbnails.
|
2026-07-11 18:13:03 +03:00
|
|
|
pub fn view(
|
2026-07-12 02:05:31 +03:00
|
|
|
current_style: BrushStyle,
|
2026-07-11 18:13:03 +03:00
|
|
|
current_size: f32,
|
|
|
|
|
current_opacity: f32,
|
|
|
|
|
current_hardness: f32,
|
|
|
|
|
colors: ThemeColors,
|
|
|
|
|
) -> Element<'static, Message> {
|
2026-07-12 02:05:31 +03:00
|
|
|
// Category tabs
|
|
|
|
|
let categories = [
|
|
|
|
|
(BrushCategory::All, "All"),
|
|
|
|
|
(BrushCategory::Drawing, "Drawing"),
|
|
|
|
|
(BrushCategory::Painting, "Painting"),
|
|
|
|
|
(BrushCategory::Effects, "Effects"),
|
|
|
|
|
];
|
2026-07-11 18:13:03 +03:00
|
|
|
|
2026-07-12 02:05:31 +03:00
|
|
|
let mut tabs = row![].spacing(2);
|
|
|
|
|
for (_cat, label) in categories {
|
|
|
|
|
let btn = button(text(label).size(10))
|
|
|
|
|
.on_press(Message::NoOp) // TODO: category filter
|
|
|
|
|
.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.bg_hover)),
|
|
|
|
|
text_color: colors.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: colors.text_secondary,
|
|
|
|
|
border: iced::Border::default(),
|
|
|
|
|
..Default::default()
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
tabs = tabs.push(btn);
|
2026-07-11 18:13:03 +03:00
|
|
|
}
|
|
|
|
|
|
2026-07-12 02:05:31 +03:00
|
|
|
// Brush preset grid — 3 columns
|
|
|
|
|
let mut grid_rows = column![].spacing(4);
|
|
|
|
|
let mut current_row = row![].spacing(4);
|
|
|
|
|
|
|
|
|
|
for (idx, preset) in BRUSH_PRESETS.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| {
|
|
|
|
|
if is_selected {
|
|
|
|
|
iced::widget::container::Style {
|
|
|
|
|
background: Some(iced::Background::Color(c.bg_active)),
|
|
|
|
|
border: iced::Border::default().color(c.accent).width(2),
|
|
|
|
|
..Default::default()
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
iced::widget::container::Style {
|
|
|
|
|
background: Some(iced::Background::Color(c.bg_panel)),
|
|
|
|
|
border: iced::Border::default().color(c.border_low).width(1),
|
|
|
|
|
..Default::default()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
// Wrap row every 3 items
|
|
|
|
|
if (idx + 1) % 3 == 0 || idx == BRUSH_PRESETS.len() - 1 {
|
|
|
|
|
grid_rows = grid_rows.push(current_row);
|
|
|
|
|
current_row = row![].spacing(4);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sliders
|
2026-07-11 18:13:03 +03:00
|
|
|
let size_slider = slider(1.0..=200.0, current_size, |v| Message::BrushSizeChanged(v))
|
|
|
|
|
.step(1.0)
|
|
|
|
|
.width(Length::Fill);
|
|
|
|
|
|
|
|
|
|
let opacity_slider = slider(0.0..=1.0, current_opacity, |v| Message::BrushOpacityChanged(v))
|
|
|
|
|
.step(0.01)
|
|
|
|
|
.width(Length::Fill);
|
|
|
|
|
|
|
|
|
|
let hardness_slider = slider(0.0..=1.0, current_hardness, |v| Message::BrushHardnessChanged(v))
|
|
|
|
|
.step(0.01)
|
|
|
|
|
.width(Length::Fill);
|
|
|
|
|
|
2026-07-12 02:05:31 +03:00
|
|
|
// 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| iced::widget::container::Style {
|
|
|
|
|
background: Some(iced::Background::Color(colors.bg_active)),
|
|
|
|
|
border: iced::Border::default().color(colors.accent).width(1),
|
|
|
|
|
..Default::default()
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-14 14:14:10 +03:00
|
|
|
// Panel title is in the dock tab — no duplicate inside the panel.
|
2026-07-11 18:13:03 +03:00
|
|
|
let panel = column![
|
2026-07-12 02:05:31 +03:00
|
|
|
tabs,
|
2026-07-11 18:13:03 +03:00
|
|
|
horizontal_rule(1),
|
2026-07-12 02:05:31 +03:00
|
|
|
scrollable(grid_rows).height(Length::Fill),
|
2026-07-11 18:13:03 +03:00
|
|
|
horizontal_rule(1),
|
2026-07-12 02:05:31 +03:00
|
|
|
text("Preview").size(10),
|
|
|
|
|
live_preview,
|
2026-07-11 18:13:03 +03:00
|
|
|
text(format!("Size: {:.0}", current_size)).size(10),
|
|
|
|
|
size_slider,
|
|
|
|
|
text(format!("Opacity: {:.0}%", current_opacity * 100.0)).size(10),
|
|
|
|
|
opacity_slider,
|
|
|
|
|
text(format!("Hardness: {:.0}%", current_hardness * 100.0)).size(10),
|
|
|
|
|
hardness_slider,
|
|
|
|
|
]
|
2026-07-14 14:14:10 +03:00
|
|
|
.spacing(2)
|
|
|
|
|
.padding(4);
|
2026-07-11 18:13:03 +03:00
|
|
|
|
|
|
|
|
container(panel)
|
|
|
|
|
.width(Length::Fill)
|
|
|
|
|
.height(Length::Fill)
|
|
|
|
|
.style(move |_theme| styles::panel_background(colors))
|
|
|
|
|
.into()
|
|
|
|
|
}
|