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
+292 -6
View File
@@ -1325,6 +1325,10 @@ impl Engine {
} else {
None
};
let desc = format!(
"Add Vector Shape ({})",
vector_shape_name_or_kind(&shape)
);
if let Some(layer) = self.document.active_layer_mut() {
if let LayerData::Vector { ref mut shapes } = layer.data {
shapes.push(shape);
@@ -1333,21 +1337,18 @@ impl Engine {
self.document.modified = true;
}
}
self.document.push_vector_snapshot(
layer_idx,
before_shapes,
"Add Vector Shape".to_string(),
);
self.document.push_vector_snapshot(layer_idx, before_shapes, desc);
return;
}
}
// Create the final vector layer before recording history. This keeps the first shape and
// its auto-created layer in one atomic Undo/Redo entry instead of capturing an empty
// intermediate raster layer.
let desc = format!("Add Vector Shape ({})", vector_shape_name_or_kind(&shape));
self.document.add_vector_layer_with_shape(
format!("Vector {}", self.document.layers.len()),
shape,
"Add Vector Shape",
&desc,
);
}
@@ -1395,6 +1396,14 @@ impl Engine {
}
pub fn set_vector_shape_color(&mut self, layer_id: u64, shape_idx: usize, color: [u8; 4]) {
let layer_idx = self.document.layer_index_by_id(layer_id);
let before_shapes = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => Some(shapes.clone()),
_ => None,
});
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data {
if let Some(shape) = shapes.get_mut(shape_idx) {
@@ -1405,9 +1414,33 @@ impl Engine {
}
}
}
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
if let Some(shape) = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => shapes.get(shape_idx),
_ => None,
})
{
self.document.push_vector_snapshot(
idx,
Some(before),
format!("Set Stroke Color ({})", vector_shape_name_or_kind(shape)),
);
}
}
}
pub fn set_vector_shape_fill(&mut self, layer_id: u64, shape_idx: usize, fill: bool) {
let layer_idx = self.document.layer_index_by_id(layer_id);
let before_shapes = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => Some(shapes.clone()),
_ => None,
});
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data {
if let Some(shape) = shapes.get_mut(shape_idx) {
@@ -1420,9 +1453,33 @@ impl Engine {
}
}
}
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
if let Some(shape) = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => shapes.get(shape_idx),
_ => None,
})
{
self.document.push_vector_snapshot(
idx,
Some(before),
format!("Toggle Fill ({})", vector_shape_name_or_kind(shape)),
);
}
}
}
pub fn set_vector_shape_fill_color(&mut self, layer_id: u64, shape_idx: usize, color: [u8; 4]) {
let layer_idx = self.document.layer_index_by_id(layer_id);
let before_shapes = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => Some(shapes.clone()),
_ => None,
});
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data {
if let Some(shape) = shapes.get_mut(shape_idx) {
@@ -1435,9 +1492,33 @@ impl Engine {
}
}
}
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
if let Some(shape) = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => shapes.get(shape_idx),
_ => None,
})
{
self.document.push_vector_snapshot(
idx,
Some(before),
format!("Set Fill Color ({})", vector_shape_name_or_kind(shape)),
);
}
}
}
pub fn set_vector_shape_stroke(&mut self, layer_id: u64, shape_idx: usize, stroke: f32) {
let layer_idx = self.document.layer_index_by_id(layer_id);
let before_shapes = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => Some(shapes.clone()),
_ => None,
});
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data {
if let Some(shape) = shapes.get_mut(shape_idx) {
@@ -1448,9 +1529,33 @@ impl Engine {
}
}
}
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
if let Some(shape) = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => shapes.get(shape_idx),
_ => None,
})
{
self.document.push_vector_snapshot(
idx,
Some(before),
format!("Set Stroke Width ({})", vector_shape_name_or_kind(shape)),
);
}
}
}
pub fn set_vector_shape_opacity(&mut self, layer_id: u64, shape_idx: usize, opacity: f32) {
let layer_idx = self.document.layer_index_by_id(layer_id);
let before_shapes = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => Some(shapes.clone()),
_ => None,
});
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data {
if let Some(shape) = shapes.get_mut(shape_idx) {
@@ -1461,6 +1566,22 @@ impl Engine {
}
}
}
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
if let Some(shape) = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => shapes.get(shape_idx),
_ => None,
})
{
self.document.push_vector_snapshot(
idx,
Some(before),
format!("Set Shape Opacity ({})", vector_shape_name_or_kind(shape)),
);
}
}
}
pub fn set_vector_shape_bounds(
@@ -1472,6 +1593,14 @@ impl Engine {
x2: f32,
y2: f32,
) {
let layer_idx = self.document.layer_index_by_id(layer_id);
let before_shapes = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => Some(shapes.clone()),
_ => None,
});
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data {
if let Some(shape) = shapes.get_mut(shape_idx) {
@@ -1482,9 +1611,36 @@ impl Engine {
}
}
}
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
if let Some(shape) = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => shapes.get(shape_idx),
_ => None,
})
{
self.document.push_vector_snapshot(
idx,
Some(before),
format!(
"Resize Vector Shape ({})",
vector_shape_name_or_kind(shape)
),
);
}
}
}
pub fn set_vector_shape_hardness(&mut self, layer_id: u64, shape_idx: usize, hardness: f32) {
let layer_idx = self.document.layer_index_by_id(layer_id);
let before_shapes = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => Some(shapes.clone()),
_ => None,
});
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data {
if let Some(shape) = shapes.get_mut(shape_idx) {
@@ -1495,6 +1651,25 @@ impl Engine {
}
}
}
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
if let Some(shape) = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => shapes.get(shape_idx),
_ => None,
})
{
self.document.push_vector_snapshot(
idx,
Some(before),
format!(
"Set Shape Hardness ({})",
vector_shape_name_or_kind(shape)
),
);
}
}
}
pub fn set_vector_shape_line_caps(
@@ -1575,6 +1750,14 @@ impl Engine {
}
pub fn set_vector_shape_angle(&mut self, layer_id: u64, shape_idx: usize, angle: f32) {
let layer_idx = self.document.layer_index_by_id(layer_id);
let before_shapes = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => Some(shapes.clone()),
_ => None,
});
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data {
if let Some(shape) = shapes.get_mut(shape_idx) {
@@ -1585,6 +1768,25 @@ impl Engine {
}
}
}
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
if let Some(shape) = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => shapes.get(shape_idx),
_ => None,
})
{
self.document.push_vector_snapshot(
idx,
Some(before),
format!(
"Rotate Vector Shape ({})",
vector_shape_name_or_kind(shape)
),
);
}
}
}
pub fn set_vector_shape_svg(&mut self, layer_id: u64, shape_idx: usize, new_svg: &str) {
@@ -1619,6 +1821,14 @@ impl Engine {
}
pub fn reorder_vector_shape(&mut self, layer_id: u64, from_idx: usize, to_idx: usize) {
let layer_idx = self.document.layer_index_by_id(layer_id);
let before_shapes = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => Some(shapes.clone()),
_ => None,
});
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data {
if from_idx < shapes.len() && to_idx < shapes.len() {
@@ -1630,9 +1840,27 @@ impl Engine {
}
}
}
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
self.document.push_vector_snapshot(
idx,
Some(before),
"Reorder Vector Shape".to_string(),
);
}
}
pub fn delete_vector_shape(&mut self, layer_id: u64, shape_idx: usize) {
let layer_idx = self.document.layer_index_by_id(layer_id);
let before_shapes = self
.document
.get_layer_by_id(layer_id)
.and_then(|l| match &l.data {
LayerData::Vector { shapes } => Some(shapes.clone()),
_ => None,
});
let removed_kind = before_shapes.as_ref().and_then(|shapes| {
shapes.get(shape_idx).map(vector_shape_name_or_kind)
});
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data {
if shape_idx < shapes.len() {
@@ -1643,6 +1871,13 @@ impl Engine {
}
}
}
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
let desc = removed_kind.map_or_else(
|| "Delete Vector Shape".to_string(),
|kind| format!("Delete Vector Shape ({})", kind),
);
self.document.push_vector_snapshot(idx, Some(before), desc);
}
}
pub fn boolean_vector_shapes(
@@ -3996,6 +4231,57 @@ impl Engine {
}
}
/// Returns a short label for the shape kind, used in history descriptions.
pub fn vector_shape_kind(shape: &VectorShape) -> String {
match shape {
VectorShape::Line { .. } => "Line".to_string(),
VectorShape::Rect { .. } => "Rect".to_string(),
VectorShape::Circle { .. } => "Circle".to_string(),
VectorShape::Arrow { .. } => "Arrow".to_string(),
VectorShape::Star { .. } => "Star".to_string(),
VectorShape::Polygon { .. } => "Polygon".to_string(),
VectorShape::Rhombus { .. } => "Rhombus".to_string(),
VectorShape::Cylinder { .. } => "Cylinder".to_string(),
VectorShape::Heart { .. } => "Heart".to_string(),
VectorShape::Bubble { .. } => "Bubble".to_string(),
VectorShape::Gear { .. } => "Gear".to_string(),
VectorShape::Cross { .. } => "Cross".to_string(),
VectorShape::Crescent { .. } => "Crescent".to_string(),
VectorShape::Bolt { .. } => "Bolt".to_string(),
VectorShape::Arrow4 { .. } => "Arrow4".to_string(),
VectorShape::SvgShape { kind, .. } => kind.clone(),
VectorShape::FreePath { .. } => "Path".to_string(),
}
}
/// Returns the shape's name if non-empty, otherwise its kind label.
pub fn vector_shape_name_or_kind(shape: &VectorShape) -> String {
let name = match shape {
VectorShape::Line { name, .. }
| VectorShape::Rect { name, .. }
| VectorShape::Circle { name, .. }
| VectorShape::Arrow { name, .. }
| VectorShape::Star { name, .. }
| VectorShape::Polygon { name, .. }
| VectorShape::Rhombus { name, .. }
| VectorShape::Cylinder { name, .. }
| VectorShape::Heart { name, .. }
| VectorShape::Bubble { name, .. }
| VectorShape::Gear { name, .. }
| VectorShape::Cross { name, .. }
| VectorShape::Crescent { name, .. }
| VectorShape::Bolt { name, .. }
| VectorShape::Arrow4 { name, .. }
| VectorShape::SvgShape { name, .. }
| VectorShape::FreePath { name, .. } => name.as_str(),
};
if name.is_empty() {
vector_shape_kind(shape).to_string()
} else {
name.to_string()
}
}
fn parse_hex_color(hex: &str) -> [u8; 4] {
let h = hex.trim_start_matches('#');
if h.len() >= 6 {
+48 -3
View File
@@ -318,8 +318,11 @@ impl Engine {
}
if let Some(before) = before_pixels {
self.document
.push_draw_snapshot(layer_idx, before, None, "Brush Stroke".to_string());
let desc = format!(
"Brush Stroke ({})",
crate::stroke_brush::brush_style_label(tip.style)
);
self.document.push_draw_snapshot(layer_idx, before, None, desc);
}
}
@@ -382,7 +385,7 @@ impl Engine {
}
if let Some(before) = before_pixels {
self.document
.push_draw_snapshot(layer_idx, before, None, "Fill Rect".to_string());
.push_draw_snapshot(layer_idx, before, None, "Fill Rectangle".to_string());
}
}
@@ -474,3 +477,45 @@ impl Engine {
)
}
}
/// Returns a short human-readable label for a built-in brush style.
///
/// Mirrors the names used in the GUI brush preset list so history entries
/// can include the active style, e.g. "Brush Stroke (Round)".
pub fn brush_style_label(style: BrushStyle) -> &'static str {
match style {
BrushStyle::Round | BrushStyle::Default => "Round",
BrushStyle::Square => "Square",
BrushStyle::HardRound => "Hard Round",
BrushStyle::SoftRound => "Soft Round",
BrushStyle::Star => "Star",
BrushStyle::Noise => "Noise",
BrushStyle::Texture => "Texture",
BrushStyle::Spray => "Spray",
BrushStyle::Pencil => "Pencil",
BrushStyle::Pen => "Pen",
BrushStyle::Calligraphy => "Calligraphy",
BrushStyle::Oil => "Oil",
BrushStyle::Charcoal => "Charcoal",
BrushStyle::Leaf => "Leaf",
BrushStyle::Rock => "Rock",
BrushStyle::Meadow => "Meadow",
BrushStyle::Wood => "Wood",
BrushStyle::Watercolor => "Watercolor",
BrushStyle::Marker => "Marker",
BrushStyle::Sketch => "Sketch",
BrushStyle::Hatch => "Hatch",
BrushStyle::Glow => "Glow",
BrushStyle::Airbrush => "Airbrush",
BrushStyle::Crayon => "Crayon",
BrushStyle::WetPaint => "Wet Paint",
BrushStyle::InkPen => "Ink Pen",
BrushStyle::Clouds => "Clouds",
BrushStyle::Dirt => "Dirt",
BrushStyle::Tree => "Tree",
BrushStyle::Bristle => "Bristle",
BrushStyle::Mixer => "Mixer",
BrushStyle::Blender => "Blender",
BrushStyle::Bitmap => "Bitmap",
}
}
+20 -3
View File
@@ -62,16 +62,28 @@ pub struct PendingHistoryItem {
}
impl Engine {
/// Returns true if at least one background-thread history snapshot is still
/// waiting to be committed to the document history.
///
/// This is used by the GUI to decide whether to schedule an additional
/// delayed `commit_pending_history` poll after a brush stroke ends.
pub fn has_pending_history(&self) -> bool {
!self.pending_history.lock().unwrap().is_empty()
}
/// Commit any pending history items computed in the background thread.
/// This should be polled on the UI thread.
///
/// Also recycles returned buffers back into the engine's pool to avoid
/// allocations on subsequent strokes.
pub fn commit_pending_history(&mut self) {
///
/// Returns `true` if at least one pending snapshot was committed on this
/// call, `false` if the queue was empty.
pub fn commit_pending_history(&mut self) -> bool {
let items = {
let mut pending = self.pending_history.lock().unwrap();
if pending.is_empty() {
return;
return false;
}
std::mem::take(&mut *pending)
};
@@ -106,6 +118,7 @@ impl Engine {
);
}
}
true
}
/// Begin a new brush stroke on the given layer.
@@ -259,6 +272,7 @@ impl Engine {
None
};
let style = self.current_tip.style;
let bounds = self.last_stroke_bounds;
let pending_history = self.pending_history.clone();
@@ -275,7 +289,10 @@ impl Engine {
bounds: None,
before_shapes,
after_shapes,
description: "Brush Stroke".to_string(),
description: format!(
"Brush Stroke ({})",
crate::stroke_brush::brush_style_label(style)
),
return_before: None,
return_after: None,
};