feat: Enhance brush settings and add new layer paste functionality

- Added brush color variant and variant amount settings to the brush panel.
- Introduced a checkbox for color variant and a slider for variant amount in the brush settings.
- Implemented "Paste as New Layer" functionality, allowing users to paste clipboard images directly onto a new layer.
- Updated menus to include the new paste option with a shortcut.
- Improved layer panel iconography by replacing text buttons with SVG icons for visibility and lock toggles.
- Created new SVG assets for closed eye and watercolor icons.
- Enhanced history panel to provide richer descriptions for brush strokes and vector shape modifications.
- Fixed the issue where the first brush stroke was missing from the history panel.
This commit is contained in:
2026-07-21 17:40:41 +03:00
parent e5d899d72d
commit 7b4073e55b
18 changed files with 1732 additions and 173 deletions
+200 -20
View File
@@ -1222,21 +1222,120 @@ pub fn draw_watercolor_brush(
is_eraser: bool,
mask: Option<&[u8]>,
) {
let mut rng = rand::thread_rng();
let effective_size = size * pressure;
let particle_opacity = opacity * 0.2 * pressure;
draw_dab(
pixels,
width,
height,
cx,
cy,
effective_size,
hardness,
color,
particle_opacity,
is_eraser,
mask,
);
if effective_size < 0.5 {
return;
}
let base_opacity = opacity * 0.2 * pressure;
let center_color = color;
// ── Central irregular core ──────────────────────────────────────────────
// Build the main mark from several overlapping soft dabs so the boundary
// is lumpy instead of a perfect circle.
let core_dabs = rng.gen_range(2..=4);
for _ in 0..core_dabs {
let angle = rng.gen_range(0.0..std::f32::consts::TAU);
let offset = effective_size * rng.gen_range(0.0..0.12);
let dx = angle.cos() * offset;
let dy = angle.sin() * offset;
let s = effective_size * rng.gen_range(0.85..1.15);
let a = base_opacity * rng.gen_range(0.7..1.3);
draw_dab(
pixels,
width,
height,
cx + dx,
cy + dy,
s,
hardness,
center_color,
a,
is_eraser,
mask,
);
}
// ── Satellite puddles ───────────────────────────────────────────────────
// Secondary dabs pulled away from the stroke path by water tension,
// giving the edge its characteristic ragged bloom.
let satellite_count = (effective_size / 6.0).clamp(2.0, 8.0) as i32;
for _ in 0..satellite_count {
let angle = rng.gen_range(0.0..std::f32::consts::TAU);
let dist = effective_size * rng.gen_range(0.25..0.65);
let dx = angle.cos() * dist;
let dy = angle.sin() * dist;
let s = effective_size * rng.gen_range(0.2..0.55);
let a = base_opacity * rng.gen_range(0.25..0.75);
draw_dab(
pixels,
width,
height,
cx + dx,
cy + dy,
s,
hardness * rng.gen_range(0.5..1.0),
center_color,
a,
is_eraser,
mask,
);
}
// ── Tiny splatter particles ───────────────────────────────────────────
// Small droplets flung outward, visible on high-resolution strokes.
let splatter_count = (effective_size * 1.2).clamp(3.0, 22.0) as i32;
for _ in 0..splatter_count {
let angle = rng.gen_range(0.0..std::f32::consts::TAU);
let dist = effective_size * rng.gen_range(0.45..1.15);
let dx = angle.cos() * dist;
let dy = angle.sin() * dist;
let s = effective_size * rng.gen_range(0.03..0.14);
let a = base_opacity * rng.gen_range(0.4..1.3);
draw_dab(
pixels,
width,
height,
cx + dx,
cy + dy,
s,
rng.gen_range(0.0..0.4),
center_color,
a,
is_eraser,
mask,
);
}
// ── Back-run / blossom bursts ───────────────────────────────────────────
// A few very large, faint "blooms" that extend beyond the main stroke,
// simulating water pushing pigment to the edges.
let bloom_count = if effective_size > 25.0 {
rng.gen_range(1..=3)
} else {
0
};
for _ in 0..bloom_count {
let angle = rng.gen_range(0.0..std::f32::consts::TAU);
let dist = effective_size * rng.gen_range(0.5..0.9);
let dx = angle.cos() * dist;
let dy = angle.sin() * dist;
let s = effective_size * rng.gen_range(0.6..1.0);
let a = base_opacity * rng.gen_range(0.15..0.35);
draw_dab(
pixels,
width,
height,
cx + dx,
cy + dy,
s,
0.0,
center_color,
a,
is_eraser,
mask,
);
}
}
/// Apply calligraphy brush - multiple parallel dabs
@@ -2432,20 +2531,95 @@ fn quadratic_bezier(p0: f32, p1: f32, p2: f32, t: f32) -> f32 {
/// `amount` is expected in [0, 1]. Hue is shifted by ±(amount*60°),
/// lightness by ±(amount*0.4). Saturation is preserved to keep the
/// color within the same family (green stays green, purple stays purple).
/// Per-thread remembered HSL walk state for one base color.
///
/// Keeps the last hue/lightness/saturation offsets so successive dabs move smoothly
/// instead of jumping to independent random values. The state is keyed by the
/// base color so unrelated colors do not interfere.
thread_local! {
static LAST_HSL_WALK: std::cell::RefCell<std::collections::HashMap<u64, (f32, f32, f32)>> =
std::cell::RefCell::new(std::collections::HashMap::new());
}
/// Hash helper for the base color used as thread-local state key.
fn color_hash(color: [u8; 4]) -> u64 {
((color[0] as u64) << 24)
| ((color[1] as u64) << 16)
| ((color[2] as u64) << 8)
| (color[3] as u64)
}
/// Vary the base color smoothly in HSL space and avoid harsh sequential contrasts.
///
/// **Logic & Workflow:**
/// 1. Loads the previous HSL offset for this base color from thread-local state.
/// 2. Takes a small random step so the transition from the previous dab is gradual.
/// 3. Rejects offsets that land in the complementary hue zone (150°..210° from base),
/// which is where high-contrast color combinations occur.
/// 4. Stores the new offset back into thread-local state.
///
/// **Arguments:**
/// * `color` — base RGBA color.
/// * `amount` — 0.0..1.0 overall variation strength.
/// * `rng` — random source.
pub fn vary_color_hsl<R: Rng>(color: [u8; 4], amount: f32, rng: &mut R) -> [u8; 4] {
let clamped = amount.clamp(0.0, 1.0);
if clamped <= 0.0 {
return color;
}
let (h, s, l) = rgb_to_hsl(color[0], color[1], color[2]);
let hue_shift = rng.gen_range(-1.0..=1.0) * clamped * 60.0;
let light_shift = rng.gen_range(-1.0..=1.0) * clamped * 0.4;
let new_h = (h + hue_shift).rem_euclid(360.0);
let new_l = (l + light_shift).clamp(0.0, 1.0);
let (r, g, b) = hsl_to_rgb(new_h, s, new_l);
let (base_h, base_s, base_l) = rgb_to_hsl(color[0], color[1], color[2]);
let key = color_hash(color);
let (mut hue_off, mut light_off, mut sat_off) = LAST_HSL_WALK
.with_borrow(|m| m.get(&key).copied().unwrap_or((0.0, 0.0, 0.0)));
// Maximum per-dab step: keeps transitions smooth.
let max_hue_step = clamped * 18.0; // ±18° per dab at amount=1
let max_light_step = clamped * 0.12;
let max_sat_step = clamped * 0.10;
// Total range stays within a harmonious neighbourhood of the base color.
let max_hue_off = clamped * 60.0;
let max_light_off = clamped * 0.35;
let max_sat_off = clamped * 0.25;
for _ in 0..8 {
let step_h = rng.gen_range(-1.0..=1.0) * max_hue_step;
hue_off = (hue_off + step_h).clamp(-max_hue_off, max_hue_off);
let step_l = rng.gen_range(-1.0..=1.0) * max_light_step;
light_off = (light_off + step_l).clamp(-max_light_off, max_light_off);
let step_s = rng.gen_range(-1.0..=1.0) * max_sat_step;
sat_off = (sat_off + step_s).clamp(-max_sat_off, max_sat_off);
// Reject complementary (high-contrast) hue offsets.
// The complementary zone is 150°..210° away from the base hue.
let abs_hue = hue_off.abs();
if abs_hue < 150.0 || abs_hue > 210.0 {
break;
}
// If we land in the complementary zone, nudge back toward base and retry.
hue_off *= 0.7;
}
LAST_HSL_WALK.with_borrow_mut(|m| {
m.insert(key, (hue_off, light_off, sat_off));
});
let new_h = (base_h + hue_off).rem_euclid(360.0);
let new_s = (base_s + sat_off).clamp(0.0, 1.0);
let new_l = (base_l + light_off).clamp(0.0, 1.0);
let (r, g, b) = hsl_to_rgb(new_h, new_s, new_l);
[r, g, b, color[3]]
}
/// Reset the smooth color-walk state. Useful when starting a fresh stroke or test.
pub fn reset_color_variant_walk() {
LAST_HSL_WALK.with_borrow_mut(|m| m.clear());
}
fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
let rf = r as f32 / 255.0;
let gf = g as f32 / 255.0;
@@ -2975,6 +3149,12 @@ pub fn draw_specialized_stroke(
return;
}
// Start each stroke from the base color so successive strokes do not inherit
// a strong leftover offset from the previous stroke.
if color_variant && variant_amount > 0.0 {
reset_color_variant_walk();
}
let spacing = brush_spacing_pixels(size, spacing_ratio);
for i in 0..points.len() {