feat: implement compact layout and real-time preview for Brushes & Tips panel
- Introduced a more compact layout for the Brushes & Tips panel in the Iced GUI. - Added a real-time brush-tip preview that updates based on current brush parameters and foreground color. - Adjusted thumbnail sizes and grid layout to improve usability and visual clarity. - Implemented caching for live previews to optimize performance. - Updated existing code to integrate new layout and preview functionalities. - Ensured no changes were made to engine crates, maintaining stability.
This commit is contained in:
@@ -0,0 +1,263 @@
|
|||||||
|
# Iced Brushes Panel — Compact Layout + Real-Time Preview
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make the `hcie-iced-app` Brushes & Tips panel more compact and add a real-time brush-tip preview that reacts to current brush parameters and foreground color.
|
||||||
|
|
||||||
|
**Scope is limited to the Iced GUI layer.** Engine crates stay locked and untouched; all rendering goes through the public `hcie_engine_api` surface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Active target workspace (per user override): `hcie-iced-app/`.
|
||||||
|
- Primary file: `hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs`.
|
||||||
|
- Panel wiring: `hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs` calls `crate::panels::brushes::view(...)`.
|
||||||
|
- Current layout:
|
||||||
|
- Category tabs with 16 px SVG icons.
|
||||||
|
- Thumbnail image 48 px inside a 56 px cell.
|
||||||
|
- Two-column grid with 4 px spacing.
|
||||||
|
- Label text size 9 px.
|
||||||
|
- A bottom "Preview" area using a static style-keyed 64 px image.
|
||||||
|
- Available engine APIs (already public in `hcie_engine_api`):
|
||||||
|
- `Engine::render_brush_stamp_preview(width, height, &BrushTip, color)` — fast single-dab preview.
|
||||||
|
- `Engine::render_brush_preview(width, height, &BrushTip, color, &points)` — full stroke preview.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|----------|--------|
|
||||||
|
| Preview API | `Engine::render_brush_stamp_preview` (fast, parameter-aware). |
|
||||||
|
| Thumbnail image size | 36 px. |
|
||||||
|
| Thumbnail cell size | 40 px. |
|
||||||
|
| Grid columns | 3. |
|
||||||
|
| Label text | 8 px, kept. |
|
||||||
|
| Grid/item spacing | 3 px. |
|
||||||
|
| Tab icon size | Keep 16 px (already compact). |
|
||||||
|
| Live preview color | Current foreground color `fg_color`. |
|
||||||
|
| Cache key | `(style, size_byte, hardness_byte, opacity_byte, fg_color)` so the preview updates when parameters change. |
|
||||||
|
| Watercolor preset thumbnails | Keep current splat renderer but shrink to 36 px image / 40 px cell and align with the new grid. |
|
||||||
|
| Imported preset thumbnails | Keep list row style but shrink preview to 36 px image in 40 px cell. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
### 1. Add foreground color to panel signature
|
||||||
|
|
||||||
|
In `hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs`, pass `app.fg_color` into `brushes::view(...)`.
|
||||||
|
|
||||||
|
In `hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs`, extend the function signature:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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>,
|
||||||
|
fg_color: [u8; 4], // NEW
|
||||||
|
colors: ThemeColors,
|
||||||
|
) -> Element<'static, Message>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Introduce compact layout constants
|
||||||
|
|
||||||
|
At the top of `brushes.rs`, replace/adjust constants:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
const THUMB_SIZE: u32 = 40;
|
||||||
|
const PREVIEW_IMG_SIZE: u32 = 36;
|
||||||
|
const GRID_COLUMNS: usize = 3;
|
||||||
|
const GRID_SPACING: u16 = 3;
|
||||||
|
const ITEM_SPACING: u16 = 3;
|
||||||
|
const LABEL_SIZE: u16 = 8;
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add a real-time preview cache keyed by parameters:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
static LIVE_PREVIEW_CACHE: OnceLock<Mutex<HashMap<PreviewKey, iced::widget::image::Handle>>> = OnceLock::new();
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
struct PreviewKey {
|
||||||
|
style_idx: usize,
|
||||||
|
size: u8, // quantized to whole px
|
||||||
|
hardness: u8, // 0..100
|
||||||
|
opacity: u8, // 0..100
|
||||||
|
r: u8,
|
||||||
|
g: u8,
|
||||||
|
b: u8,
|
||||||
|
a: u8,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Build a parameter-aware preview generator
|
||||||
|
|
||||||
|
Add a helper that constructs a `BrushTip` from the current tool state and calls `Engine::render_brush_stamp_preview`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn build_tip_from_state(style: BrushStyle, size: f32, opacity: f32, hardness: f32) -> hcie_engine_api::BrushTip {
|
||||||
|
hcie_engine_api::BrushTip {
|
||||||
|
style,
|
||||||
|
size,
|
||||||
|
opacity,
|
||||||
|
hardness,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn get_live_stamp_preview(
|
||||||
|
style: BrushStyle,
|
||||||
|
size: f32,
|
||||||
|
opacity: f32,
|
||||||
|
hardness: f32,
|
||||||
|
fg_color: [u8; 4],
|
||||||
|
) -> iced::widget::image::Handle {
|
||||||
|
let key = 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,
|
||||||
|
r: fg_color[0],
|
||||||
|
g: fg_color[1],
|
||||||
|
b: fg_color[2],
|
||||||
|
a: fg_color[3],
|
||||||
|
};
|
||||||
|
let cache = LIVE_PREVIEW_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
|
let mut cache = cache.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
if let Some(handle) = cache.get(&key) {
|
||||||
|
return handle.clone();
|
||||||
|
}
|
||||||
|
let tip = build_tip_from_state(style, size, opacity, hardness);
|
||||||
|
let pixels = hcie_engine_api::Engine::render_brush_stamp_preview(
|
||||||
|
PREVIEW_IMG_SIZE,
|
||||||
|
PREVIEW_IMG_SIZE,
|
||||||
|
&tip,
|
||||||
|
fg_color,
|
||||||
|
);
|
||||||
|
let handle = iced::widget::image::Handle::from_rgba(
|
||||||
|
PREVIEW_IMG_SIZE,
|
||||||
|
PREVIEW_IMG_SIZE,
|
||||||
|
pixels,
|
||||||
|
);
|
||||||
|
cache.insert(key, handle.clone());
|
||||||
|
handle
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Update existing thumbnail cache to new size
|
||||||
|
|
||||||
|
- Keep `BRUSH_PREVIEW_CACHE` for style-only previews.
|
||||||
|
- Regenerate it with the new `THUMB_SIZE` (40 px). The existing sine-wave `generate_brush_preview` can stay, just adjust the drawing bounds to match.
|
||||||
|
- Ensure the function returns `width=THUMB_SIZE, height=THUMB_SIZE` pixels.
|
||||||
|
|
||||||
|
### 5. Re-layout the grid
|
||||||
|
|
||||||
|
Change the grid construction from 2 columns to 3 columns using `GRID_COLUMNS`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut grid_rows = column![].spacing(GRID_SPACING);
|
||||||
|
let mut current_row = row![].spacing(GRID_SPACING);
|
||||||
|
|
||||||
|
for (idx, preset) in filtered_styles.iter().enumerate() {
|
||||||
|
// ... build 40 px cell with 36 px image + 8 px label ...
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply the same 3-column compact layout to:
|
||||||
|
- Built-in styles grid.
|
||||||
|
- Watercolor presets grid.
|
||||||
|
- Media presets grid.
|
||||||
|
|
||||||
|
For imported presets, keep the list-row layout but set the preview image to 36 px and container to 40 px.
|
||||||
|
|
||||||
|
### 6. Replace the bottom static preview with the live preview
|
||||||
|
|
||||||
|
Locate the existing code:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let preview_handle = get_brush_preview(current_style);
|
||||||
|
let live_preview = container(iced::widget::image(preview_handle).width(64).height(64))
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let preview_handle = get_live_stamp_preview(
|
||||||
|
current_style,
|
||||||
|
current_size,
|
||||||
|
current_opacity,
|
||||||
|
current_hardness,
|
||||||
|
fg_color,
|
||||||
|
);
|
||||||
|
let live_preview = container(iced::widget::image(preview_handle).width(PREVIEW_IMG_SIZE as f32).height(PREVIEW_IMG_SIZE as f32))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(56)
|
||||||
|
.center_x(Length::Fill)
|
||||||
|
.center_y(Length::Fill)
|
||||||
|
.style(move |_theme| styles::recessed_control(colors));
|
||||||
|
```
|
||||||
|
|
||||||
|
Make the preview area slightly shorter (56 px instead of 80 px) to reclaim vertical space.
|
||||||
|
|
||||||
|
### 7. Tighten slider/controls spacing
|
||||||
|
|
||||||
|
- Reduce the column padding from `4` to `3`.
|
||||||
|
- Keep sliders, but make the panel column spacing `2` or `3`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
1. `hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs` — main panel layout and preview logic.
|
||||||
|
2. `hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs` — pass `app.fg_color` to `brushes::view`.
|
||||||
|
|
||||||
|
No engine crates are modified. No `Cargo.toml` changes are needed because `hcie_engine_api` is already a dependency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
1. `cargo check -p hcie-iced-gui` must pass.
|
||||||
|
2. `cargo test -p hcie-iced-gui brushes` (or `cargo test -p hcie-iced-gui`) must pass; the existing tests in `brushes.rs` (`catalog_contains_every_engine_brush_style`, `category_filter_keeps_expected_groups`, `media_crates_expose_complete_unique_catalog`) must remain green.
|
||||||
|
3. Launch the Iced GUI and open the Brushes & Tips panel. Visually verify:
|
||||||
|
- 3-column grid of 40 px cells.
|
||||||
|
- 8 px labels still readable.
|
||||||
|
- Bottom preview area updates when size, opacity, hardness, or foreground color changes.
|
||||||
|
- Selection border still highlights the active brush.
|
||||||
|
4. If the Iced CLI screenshot mechanism works, capture the Brushes panel for before/after comparison.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
1. **Cache growth**: the parameter-keyed cache can grow quickly if size/opacity/hardness are continuous. Mitigation: quantize size to `u8`, hardness/opacity to 0..100, and fg color to full bytes; this bounds the space to ~256 * 101 * 101 * 256^4 possible keys but only populated entries consume memory.
|
||||||
|
2. **Performance of `render_brush_stamp_preview`**: small single-dab previews (36x36 or 40x40) should be fast; if not, consider throttling updates to panel redraws only.
|
||||||
|
3. **Iced layout overflow**: switching to 3 columns with 3 px spacing in a narrow pane may still wrap. If so, the pane minimum width in `dock/sizing.rs` may need a small bump (currently 160 px preferred 300 px). Test first; only bump if required.
|
||||||
|
4. **Tests assume style-only cache**: the existing `BRUSH_PREVIEW_CACHE` tests do not inspect cache contents, so resizing should not break them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- EGUI (`hcie-egui-app`) changes — explicitly excluded by the user; it remains a reference only.
|
||||||
|
- Engine-side brush rendering changes — engine crates are locked.
|
||||||
|
- New brush engine presets or categories.
|
||||||
|
- Bitmap/imported brush stamp preview fidelity beyond what `render_brush_stamp_preview` already provides.
|
||||||
@@ -187,6 +187,7 @@ pub fn panel_body<'a>(
|
|||||||
app.brush_category,
|
app.brush_category,
|
||||||
&app.tool_state.imported_brushes,
|
&app.tool_state.imported_brushes,
|
||||||
app.tool_state.active_brush_preset.as_ref(),
|
app.tool_state.active_brush_preset.as_ref(),
|
||||||
|
app.fg_color,
|
||||||
colors,
|
colors,
|
||||||
),
|
),
|
||||||
PaneType::Filters => crate::panels::filters::view(
|
PaneType::Filters => crate::panels::filters::view(
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
//! Brushes panel — brush preset browser with visual thumbnails and live preview.
|
//! Brushes panel — compact brush preset browser with realistic live preview.
|
||||||
//!
|
//!
|
||||||
//! Matches the egui version's layout:
|
//! **Purpose:** Displays the brush library as a compact 3-column grid of
|
||||||
//! - Category tabs (All, Drawing, Painting, Watercolor, Effects)
|
//! realistic brush-stroke thumbnails and renders a live, parameter-aware brush
|
||||||
//! - Grid of brush tip thumbnails with visual stroke preview
|
//! stroke preview at the bottom of the panel. Every preview is produced by the
|
||||||
//! - Selected brush highlighted with accent border
|
//! public `hcie_engine_api::Engine::render_brush_preview` path, so the thumbnail
|
||||||
//! - Size/Opacity/Hardness sliders at bottom
|
//! and preview pixels match what the brush engine actually draws on the canvas.
|
||||||
//!
|
//!
|
||||||
//! ⚠️ PERFORMANCE: Brush preview images are cached via OnceLock.
|
//! **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::app::Message;
|
||||||
use crate::panels::styles;
|
use crate::panels::styles;
|
||||||
@@ -212,12 +224,77 @@ const BRUSH_STYLES: &[BrushStyleEntry] = &[
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Cached brush preview handles keyed by engine style index.
|
/// Default thumbnail color: neutral light gray so the stroke shape is visible
|
||||||
static BRUSH_PREVIEW_CACHE: OnceLock<Mutex<HashMap<usize, iced::widget::image::Handle>>> =
|
/// 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();
|
OnceLock::new();
|
||||||
|
|
||||||
/// Thumbnail size in pixels.
|
/// Cached preset stroke preview handles keyed by preset id.
|
||||||
const THUMB_SIZE: u32 = 64;
|
///
|
||||||
|
/// 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,
|
||||||
|
r: u8,
|
||||||
|
g: u8,
|
||||||
|
b: u8,
|
||||||
|
a: u8,
|
||||||
|
}
|
||||||
|
|
||||||
/// Determines whether a built-in style belongs in the selected category.
|
/// Determines whether a built-in style belongs in the selected category.
|
||||||
///
|
///
|
||||||
@@ -290,132 +367,268 @@ fn media_preset_category(category: &str) -> BrushCategory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a brush stroke preview as RGBA pixels.
|
/// Build a representative protocol `BrushTip` for a built-in style thumbnail.
|
||||||
///
|
///
|
||||||
/// Draws a sine-wave stroke with varying thickness to show the brush characteristics.
|
/// **Purpose:** Each built-in style needs a tip that lets the engine reveal its
|
||||||
fn generate_brush_preview(style: BrushStyle) -> Vec<u8> {
|
/// actual stroke character in a small preview. Size is clamped later to fit
|
||||||
let w = THUMB_SIZE;
|
/// the thumbnail so only relative opacity/hardness matter here.
|
||||||
let h = THUMB_SIZE;
|
fn thumbnail_tip_for_style(style: BrushStyle) -> hcie_engine_api::BrushTip {
|
||||||
let mut pixels = vec![0u8; (w * h * 4) as usize];
|
let (size, opacity, hardness) = match style {
|
||||||
|
BrushStyle::Pencil | BrushStyle::Pen | BrushStyle::InkPen | BrushStyle::Calligraphy => {
|
||||||
let n_points = 40;
|
(4.0, 0.95, 1.0)
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
BrushStyle::Marker | BrushStyle::Sketch | BrushStyle::Hatch => (6.0, 0.9, 0.85),
|
||||||
// Color based on style
|
BrushStyle::Oil | BrushStyle::Charcoal | BrushStyle::Crayon => (12.0, 0.9, 0.5),
|
||||||
let color: [u8; 3] = match style {
|
BrushStyle::Watercolor
|
||||||
BrushStyle::Pencil | BrushStyle::Sketch => [180, 180, 180],
|
| BrushStyle::WetPaint
|
||||||
BrushStyle::Pen | BrushStyle::InkPen => [40, 40, 40],
|
| BrushStyle::Airbrush
|
||||||
BrushStyle::Calligraphy => [60, 40, 30],
|
| BrushStyle::Spray => (14.0, 0.75, 0.0),
|
||||||
BrushStyle::Oil => [180, 120, 60],
|
BrushStyle::Leaf
|
||||||
BrushStyle::Charcoal => [80, 80, 80],
|
| BrushStyle::Rock
|
||||||
BrushStyle::Watercolor => [100, 150, 200],
|
| BrushStyle::Meadow
|
||||||
BrushStyle::Crayon => [200, 100, 100],
|
| BrushStyle::Tree
|
||||||
BrushStyle::Airbrush | BrushStyle::Spray => [120, 120, 120],
|
| BrushStyle::Clouds
|
||||||
BrushStyle::Marker => [60, 120, 60],
|
| BrushStyle::Dirt
|
||||||
BrushStyle::Glow => [200, 200, 100],
|
| BrushStyle::Glow
|
||||||
BrushStyle::Leaf => [80, 160, 60],
|
| BrushStyle::Noise
|
||||||
_ => [200, 200, 200],
|
| 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),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Draw stroke with varying thickness
|
hcie_engine_api::BrushTip {
|
||||||
for i in 0..n_points {
|
style,
|
||||||
let (x1, y1, t1) = points[i];
|
size,
|
||||||
let (x2, y2, _) = points[i + 1];
|
opacity,
|
||||||
|
hardness,
|
||||||
let thickness = match style {
|
..hcie_engine_api::BrushTip::default()
|
||||||
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
|
/// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get or generate brush preview for a style.
|
/// Render a realistic brush stroke preview via the engine.
|
||||||
fn get_brush_preview(style: BrushStyle) -> iced::widget::image::Handle {
|
///
|
||||||
|
/// **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,
|
||||||
|
) -> hcie_engine_api::BrushTip {
|
||||||
|
hcie_engine_api::BrushTip {
|
||||||
|
style,
|
||||||
|
size,
|
||||||
|
opacity,
|
||||||
|
hardness,
|
||||||
|
..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,
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
fg_color: [u8; 4],
|
||||||
|
) -> 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, 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);
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 style_idx = style as usize;
|
||||||
let cache = BRUSH_PREVIEW_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
let cache = STYLE_PREVIEW_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
let mut cache = cache
|
let mut cache = cache
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
if let Some(handle) = cache.get(&style_idx) {
|
if let Some(handle) = cache.get(&style_idx) {
|
||||||
return handle.clone();
|
return handle.clone();
|
||||||
}
|
}
|
||||||
let handle = iced::widget::image::Handle::from_rgba(
|
let tip = clamped_preview_tip(thumbnail_tip_for_style(style), THUMB_BRUSH_SIZE);
|
||||||
THUMB_SIZE,
|
let pixels = render_stroke_preview(THUMB_SIZE, THUMB_SIZE, &tip, THUMB_COLOR);
|
||||||
THUMB_SIZE,
|
let handle = iced::widget::image::Handle::from_rgba(THUMB_SIZE, THUMB_SIZE, pixels);
|
||||||
generate_brush_preview(style),
|
|
||||||
);
|
|
||||||
cache.insert(style_idx, handle.clone());
|
cache.insert(style_idx, handle.clone());
|
||||||
handle
|
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.
|
/// Build the brushes panel with visual thumbnails.
|
||||||
pub fn view(
|
pub fn view(
|
||||||
current_style: BrushStyle,
|
current_style: BrushStyle,
|
||||||
@@ -427,6 +640,7 @@ pub fn view(
|
|||||||
active_category: BrushCategory,
|
active_category: BrushCategory,
|
||||||
imported_presets: &[hcie_engine_api::BrushPreset],
|
imported_presets: &[hcie_engine_api::BrushPreset],
|
||||||
active_brush_preset: Option<&hcie_engine_api::BrushPreset>,
|
active_brush_preset: Option<&hcie_engine_api::BrushPreset>,
|
||||||
|
fg_color: [u8; 4],
|
||||||
colors: ThemeColors,
|
colors: ThemeColors,
|
||||||
) -> Element<'static, Message> {
|
) -> Element<'static, Message> {
|
||||||
// Category tabs
|
// Category tabs
|
||||||
@@ -500,9 +714,9 @@ pub fn view(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Brush preset grid — two columns remain usable in narrow dock panes.
|
// Brush preset grid — three compact columns fit comfortably in narrow dock panes.
|
||||||
let mut grid_rows = column![].spacing(4);
|
let mut grid_rows = column![].spacing(GRID_SPACING);
|
||||||
let mut current_row = row![].spacing(4);
|
let mut current_row = row![].spacing(GRID_SPACING);
|
||||||
|
|
||||||
let filtered_styles: Vec<&BrushStyleEntry> = BRUSH_STYLES
|
let filtered_styles: Vec<&BrushStyleEntry> = BRUSH_STYLES
|
||||||
.iter()
|
.iter()
|
||||||
@@ -511,23 +725,27 @@ pub fn view(
|
|||||||
|
|
||||||
for (idx, preset) in filtered_styles.iter().enumerate() {
|
for (idx, preset) in filtered_styles.iter().enumerate() {
|
||||||
let is_selected = preset.style == current_style;
|
let is_selected = preset.style == current_style;
|
||||||
let preview = get_brush_preview(preset.style);
|
let preview = get_style_preview(preset.style);
|
||||||
let c = colors;
|
let c = colors;
|
||||||
let style = preset.style;
|
let style = preset.style;
|
||||||
|
|
||||||
let thumb = container(iced::widget::image(preview).width(48).height(48))
|
let thumb = container(
|
||||||
.width(56)
|
iced::widget::image(preview)
|
||||||
.height(56)
|
.width(THUMB_IMG_SIZE as f32)
|
||||||
.center_x(48)
|
.height(THUMB_IMG_SIZE as f32),
|
||||||
.center_y(48)
|
)
|
||||||
|
.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));
|
.style(move |_theme| styles::raised_card(c, is_selected));
|
||||||
|
|
||||||
let label = container(text(preset.name).size(9))
|
let label = container(text(preset.name).size(LABEL_SIZE))
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.center_x(Length::Fill);
|
.align_x(iced::alignment::Horizontal::Center);
|
||||||
|
|
||||||
let cell = column![thumb, label]
|
let cell = column![thumb, label]
|
||||||
.spacing(2)
|
.spacing(ITEM_SPACING)
|
||||||
.align_x(iced::Alignment::Center);
|
.align_x(iced::Alignment::Center);
|
||||||
|
|
||||||
let clickable = button(cell)
|
let clickable = button(cell)
|
||||||
@@ -554,54 +772,44 @@ pub fn view(
|
|||||||
|
|
||||||
current_row = current_row.push(clickable);
|
current_row = current_row.push(clickable);
|
||||||
|
|
||||||
if (idx + 1) % 2 == 0 || idx == filtered_styles.len() - 1 {
|
if (idx + 1) % GRID_COLUMNS == 0 || idx == filtered_styles.len() - 1 {
|
||||||
grid_rows = grid_rows.push(current_row);
|
grid_rows = grid_rows.push(current_row);
|
||||||
current_row = row![].spacing(4);
|
current_row = row![].spacing(GRID_SPACING);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Watercolor presets (from hcie-watercolor-brushes crate) ───────────
|
// ── Watercolor presets (from hcie-watercolor-brushes crate) ───────────
|
||||||
let mut wc_rows = column![].spacing(4);
|
let mut wc_rows = column![].spacing(GRID_SPACING);
|
||||||
if matches!(
|
if matches!(
|
||||||
active_category,
|
active_category,
|
||||||
BrushCategory::All | BrushCategory::Watercolor
|
BrushCategory::All | BrushCategory::Watercolor
|
||||||
) {
|
) {
|
||||||
let wc_presets = hcie_watercolor_brushes::watercolor_presets();
|
let wc_presets = hcie_watercolor_brushes::watercolor_presets();
|
||||||
let mut wc_row = row![].spacing(4);
|
let mut wc_row = row![].spacing(GRID_SPACING);
|
||||||
for (idx, preset) in wc_presets.iter().enumerate() {
|
for (idx, preset) in wc_presets.iter().enumerate() {
|
||||||
let is_selected = preset_style(preset.style) == current_style
|
let is_selected = preset_style(preset.style) == current_style
|
||||||
&& active_brush_preset
|
&& active_brush_preset
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(false, |p| p.id == preset.id);
|
.map_or(false, |p| p.id == preset.id);
|
||||||
let c = colors;
|
let c = colors;
|
||||||
let wc_color: [u8; 3] = match preset.id.as_str() {
|
let preview = get_preset_preview(preset);
|
||||||
"wc_wash" => [100, 150, 200],
|
let thumb = container(
|
||||||
"wc_wet" => [80, 130, 190],
|
iced::widget::image(preview)
|
||||||
"wc_dry" => [140, 110, 80],
|
.width(THUMB_IMG_SIZE as f32)
|
||||||
"wc_glaze" => [120, 160, 210],
|
.height(THUMB_IMG_SIZE as f32),
|
||||||
"wc_splat" => [90, 140, 180],
|
)
|
||||||
"wc_bleed" => [110, 80, 150],
|
.width(THUMB_SIZE as f32)
|
||||||
"wc_grain" => [130, 120, 100],
|
.height(THUMB_SIZE as f32)
|
||||||
"wc_bloom" => [90, 170, 200],
|
.align_x(iced::alignment::Horizontal::Center)
|
||||||
_ => [100, 150, 200],
|
.align_y(iced::alignment::Vertical::Center)
|
||||||
};
|
|
||||||
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));
|
.style(move |_theme| styles::raised_card(c, is_selected));
|
||||||
|
|
||||||
let label = container(text(preset.name.clone()).size(9))
|
let label = container(text(preset.name.clone()).size(LABEL_SIZE))
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.center_x(Length::Fill);
|
.align_x(iced::alignment::Horizontal::Center);
|
||||||
|
|
||||||
let cell = column![thumb, label]
|
let cell = column![thumb, label]
|
||||||
.spacing(2)
|
.spacing(ITEM_SPACING)
|
||||||
.align_x(iced::Alignment::Center);
|
.align_x(iced::Alignment::Center);
|
||||||
|
|
||||||
let preset_msg = preset.clone();
|
let preset_msg = preset.clone();
|
||||||
@@ -629,9 +837,9 @@ pub fn view(
|
|||||||
|
|
||||||
wc_row = wc_row.push(clickable);
|
wc_row = wc_row.push(clickable);
|
||||||
|
|
||||||
if (idx + 1) % 2 == 0 || idx == wc_presets.len() - 1 {
|
if (idx + 1) % GRID_COLUMNS == 0 || idx == wc_presets.len() - 1 {
|
||||||
wc_rows = wc_rows.push(wc_row);
|
wc_rows = wc_rows.push(wc_row);
|
||||||
wc_row = row![].spacing(4);
|
wc_row = row![].spacing(GRID_SPACING);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -649,27 +857,31 @@ pub fn view(
|
|||||||
|| active_category == media_preset_category(&preset.category)
|
|| active_category == media_preset_category(&preset.category)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let mut media_rows = column![].spacing(4);
|
let mut media_rows = column![].spacing(GRID_SPACING);
|
||||||
let mut media_row = row![].spacing(4);
|
let mut media_row = row![].spacing(GRID_SPACING);
|
||||||
for (idx, preset) in filtered_media.iter().enumerate() {
|
for (idx, preset) in filtered_media.iter().enumerate() {
|
||||||
let style = preset_style(preset.style);
|
let style = preset_style(preset.style);
|
||||||
let selected = style == current_style
|
let selected = style == current_style
|
||||||
&& active_brush_preset.is_some_and(|active| active.id == preset.id);
|
&& active_brush_preset.is_some_and(|active| active.id == preset.id);
|
||||||
let preview = get_brush_preview(style);
|
let preview = get_preset_preview(preset);
|
||||||
let preset_message = preset.clone();
|
let preset_message = preset.clone();
|
||||||
let c = colors;
|
let c = colors;
|
||||||
let cell = column![
|
let cell = column![
|
||||||
container(iced::widget::image(preview).width(48).height(48))
|
container(
|
||||||
.width(56)
|
iced::widget::image(preview)
|
||||||
.height(56)
|
.width(THUMB_IMG_SIZE as f32)
|
||||||
.center_x(48)
|
.height(THUMB_IMG_SIZE as f32)
|
||||||
.center_y(48)
|
)
|
||||||
|
.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)),
|
.style(move |_theme| styles::raised_card(c, selected)),
|
||||||
container(text(preset.name.clone()).size(9))
|
container(text(preset.name.clone()).size(LABEL_SIZE))
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.center_x(Length::Fill),
|
.align_x(iced::alignment::Horizontal::Center),
|
||||||
]
|
]
|
||||||
.spacing(2)
|
.spacing(ITEM_SPACING)
|
||||||
.align_x(iced::Alignment::Center);
|
.align_x(iced::Alignment::Center);
|
||||||
media_row = media_row.push(
|
media_row = media_row.push(
|
||||||
button(cell)
|
button(cell)
|
||||||
@@ -688,13 +900,13 @@ pub fn view(
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (idx + 1) % 2 == 0 || idx == filtered_media.len() - 1 {
|
if (idx + 1) % GRID_COLUMNS == 0 || idx == filtered_media.len() - 1 {
|
||||||
media_rows = media_rows.push(media_row);
|
media_rows = media_rows.push(media_row);
|
||||||
media_row = row![].spacing(4);
|
media_row = row![].spacing(GRID_SPACING);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut imported_rows = column![].spacing(3);
|
let mut imported_rows = column![].spacing(GRID_SPACING);
|
||||||
if matches!(
|
if matches!(
|
||||||
active_category,
|
active_category,
|
||||||
BrushCategory::All | BrushCategory::Imported
|
BrushCategory::All | BrushCategory::Imported
|
||||||
@@ -703,19 +915,25 @@ pub fn view(
|
|||||||
let style = preset_style(preset.style);
|
let style = preset_style(preset.style);
|
||||||
let selected = style == current_style;
|
let selected = style == current_style;
|
||||||
let preset_message = preset.clone();
|
let preset_message = preset.clone();
|
||||||
let preview = get_brush_preview(style);
|
let preview = get_preset_preview(preset);
|
||||||
let name = preset.name.clone();
|
let name = preset.name.clone();
|
||||||
imported_rows = imported_rows.push(
|
imported_rows = imported_rows.push(
|
||||||
button(
|
button(
|
||||||
row![
|
row![
|
||||||
container(iced::widget::image(preview).width(42).height(24))
|
container(
|
||||||
.width(48)
|
iced::widget::image(preview)
|
||||||
.height(28)
|
.width(THUMB_IMG_SIZE as f32)
|
||||||
.center_x(Length::Fill)
|
.height(THUMB_IMG_SIZE as f32)
|
||||||
.center_y(Length::Fill),
|
)
|
||||||
|
.width(THUMB_SIZE as f32)
|
||||||
|
.height(THUMB_SIZE as f32)
|
||||||
|
.align_x(iced::alignment::Horizontal::Center)
|
||||||
|
.align_y(iced::alignment::Vertical::Center),
|
||||||
column![
|
column![
|
||||||
text(name).size(10),
|
text(name).size(LABEL_SIZE + 1),
|
||||||
text("Imported ABR").size(8).color(colors.text_secondary),
|
text("Imported ABR")
|
||||||
|
.size(LABEL_SIZE)
|
||||||
|
.color(colors.text_secondary),
|
||||||
]
|
]
|
||||||
.spacing(1),
|
.spacing(1),
|
||||||
]
|
]
|
||||||
@@ -724,7 +942,7 @@ pub fn view(
|
|||||||
)
|
)
|
||||||
.on_press(Message::BrushPresetSelected(preset_message))
|
.on_press(Message::BrushPresetSelected(preset_message))
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.padding(3)
|
.padding(2)
|
||||||
.style(move |_theme, status| iced::widget::button::Style {
|
.style(move |_theme, status| iced::widget::button::Style {
|
||||||
background: Some(iced::Background::Color(if selected {
|
background: Some(iced::Background::Color(if selected {
|
||||||
colors.bg_active
|
colors.bg_active
|
||||||
@@ -804,30 +1022,49 @@ pub fn view(
|
|||||||
Message::BrushVariantAmountChanged,
|
Message::BrushVariantAmountChanged,
|
||||||
);
|
);
|
||||||
|
|
||||||
let color_variant_check = checkbox("Color variant", brush_color_variant)
|
// 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)
|
.on_toggle(Message::BrushColorVariantToggled)
|
||||||
.size(16)
|
.size(12),
|
||||||
.spacing(6);
|
text("Color variant").size(LABEL_SIZE),
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.align_y(iced::Alignment::Center);
|
||||||
|
|
||||||
// Live preview — show current brush tip
|
// Live preview — show a realistic engine-rendered stroke using the active
|
||||||
let preview_handle = get_brush_preview(current_style);
|
// size, opacity, hardness, and foreground color. The preview is
|
||||||
let live_preview = container(iced::widget::image(preview_handle).width(64).height(64))
|
// 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,
|
||||||
|
fg_color,
|
||||||
|
);
|
||||||
|
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)
|
.width(Length::Fill)
|
||||||
.height(80)
|
.height(56)
|
||||||
.center_x(Length::Fill)
|
.align_x(iced::alignment::Horizontal::Center)
|
||||||
.center_y(Length::Fill)
|
.align_y(iced::alignment::Vertical::Center)
|
||||||
.style(move |_theme| styles::recessed_control(colors));
|
.style(move |_theme| styles::recessed_control(colors));
|
||||||
|
|
||||||
// Panel title is in the dock tab — no duplicate inside the panel.
|
// Panel title is in the dock tab — no duplicate inside the panel.
|
||||||
let panel = column![
|
let panel = column![
|
||||||
tabs,
|
tabs,
|
||||||
horizontal_rule(1),
|
horizontal_rule(1),
|
||||||
scrollable(column![grid_rows, wc_rows, media_rows, imported_rows].spacing(6))
|
scrollable(column![grid_rows, wc_rows, media_rows, imported_rows].spacing(GRID_SPACING))
|
||||||
.height(Length::Fill),
|
.height(Length::Fill),
|
||||||
horizontal_rule(1),
|
horizontal_rule(1),
|
||||||
row![button(text("Import ABR").size(10))
|
row![button(text("Import ABR").size(LABEL_SIZE))
|
||||||
.on_press(Message::BrushImportAbr)
|
.on_press(Message::BrushImportAbr)
|
||||||
.padding([4, 8])
|
.padding([3, 7])
|
||||||
.style(
|
.style(
|
||||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||||
match status {
|
match status {
|
||||||
@@ -846,8 +1083,8 @@ pub fn view(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)]
|
)]
|
||||||
.spacing(4),
|
.spacing(GRID_SPACING),
|
||||||
text("Preview").size(10),
|
text("Preview").size(LABEL_SIZE + 1),
|
||||||
live_preview,
|
live_preview,
|
||||||
size_slider,
|
size_slider,
|
||||||
opacity_slider,
|
opacity_slider,
|
||||||
@@ -855,8 +1092,8 @@ pub fn view(
|
|||||||
color_variant_check,
|
color_variant_check,
|
||||||
variant_slider,
|
variant_slider,
|
||||||
]
|
]
|
||||||
.spacing(2)
|
.spacing(ITEM_SPACING)
|
||||||
.padding(4);
|
.padding(3);
|
||||||
|
|
||||||
container(panel)
|
container(panel)
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
|
|||||||
Reference in New Issue
Block a user