Files
hcie-rust-v3.05/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs
T
phantom 4dccf18743 Implement Photopea theme and tool settings panel
- Added Photopea theme implementation report and design specifications.
- Created tool options and settings panels to match Photopea's layout.
- Implemented settings persistence to save and load user preferences.
- Updated toolbar to include SVG icons and tool options in a single row.
- Enhanced tool settings with specific parameters for each tool type.
- Added functionality for resetting tool settings to defaults.
2026-07-14 14:14:10 +03:00

352 lines
14 KiB
Rust

//! 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, 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"),
(BrushCategory::Drawing, "Drawing"),
(BrushCategory::Painting, "Painting"),
(BrushCategory::Effects, "Effects"),
];
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);
}
// 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
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),
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()
}