Files
hcie-rust-v3.05/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs
T

505 lines
17 KiB
Rust
Raw Normal View History

//! 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.
use crate::app::Message;
use crate::panels::styles;
use crate::theme::ThemeColors;
use hcie_engine_api::BrushStyle;
use iced::widget::{
button, column, container, horizontal_rule, row, scrollable, slider, svg, text,
};
use iced::{Element, Length};
use std::sync::OnceLock;
/// A brush preset entry with category.
#[allow(dead_code)]
struct BrushPreset {
name: &'static str,
style: BrushStyle,
category: BrushCategory,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
enum BrushCategory {
All,
Drawing,
Painting,
Effects,
}
const BRUSH_PRESETS: &[BrushPreset] = &[
// 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,
},
];
/// 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.
pub fn view(
current_style: BrushStyle,
current_size: f32,
current_opacity: f32,
current_hardness: f32,
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::Effects, "Effects", "icons/glow.svg"),
];
let mut tabs = row![].spacing(2);
for (cat, label, icon_path) in categories {
let active = cat == BrushCategory::All;
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).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(iced::widget::tooltip(
btn,
text(format!("{label} (category filtering is not available yet)")).size(10),
iced::widget::tooltip::Position::Bottom,
));
}
// 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);
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);
if (idx + 1) % 2 == 0 || idx == BRUSH_PRESETS.len() - 1 {
grid_rows = grid_rows.push(current_row);
current_row = row![].spacing(4);
}
}
// Sliders
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);
// 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()
});
// Panel title is in the dock tab — no duplicate inside the panel.
let panel = column![
tabs,
horizontal_rule(1),
scrollable(grid_rows).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| {
2026-07-15 17:42:05 +03:00
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,
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,
]
.spacing(2)
.padding(4);
container(panel)
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| styles::panel_background(colors))
.into()
}