Brushes: Shift-click and Ctrl/Cmd-click select several brushes, and Delete Brush deletes them

A Shift-click or Ctrl/Cmd-click on a brush in the Brushes panel used to
apply the brush, as a plain click does. It now selects brushes without
applying one. Shift-click adds the brushes from the last brush clicked
without Shift (else the highlighted brush) to the clicked one, in the
order the panel shows them. Ctrl/Cmd-click adds the clicked brush to the
highlighted brushes or removes it. The panel highlights the selected
brushes, and the bottom bar's Delete Brush and the panel menu's
Duplicate Brush and Delete Brush act on all of them. A plain click
works as before and ends the multi-selection, and so does selecting
other art, another document or another current brush.

brush.delete and brush.duplicate also take {names: [name...]}. Each
call is one undo step, and an unknown name fails before anything
changes. brush.duplicate answers {names} for a names list.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-17 15:14:41 +07:00
committed by bflatastic
parent 77f39084de
commit e7b78e7150
6 changed files with 375 additions and 49 deletions
+38 -20
View File
@@ -16,6 +16,7 @@ use vectorcraft_geom::{Affine, Point, Vec2};
use super::edit::{outermost, selected_roots};
use super::pathops::{remove_each, separate};
use super::swatch::names_param;
use super::*;
use crate::EngineError;
@@ -65,8 +66,16 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
brush_new
),
cmd!("brush.delete", "Delete Brush", [], None, "{name} delete a brush; strokes using it become plain strokes", has_doc, brush_delete),
cmd!("brush.duplicate", "Duplicate Brush", [], None, "{name, newName?} → {name}", has_doc, brush_duplicate),
cmd!(
"brush.delete",
"Delete Brush",
[],
None,
"{name} | {names: [name…]} delete brushes; strokes using them become plain strokes",
has_doc,
brush_delete
),
cmd!("brush.duplicate", "Duplicate Brush", [], None, "{name, newName?} | {names: [name…]} → {name} | {names}", has_doc, brush_duplicate),
cmd!(
"brush.options",
"Brush Options…",
@@ -339,10 +348,10 @@ fn merge(base: &mut Value, patch: &Value) {
}
}
fn save_library(s: &mut Session, label: &str, lib: Vec<Brush>, rename: Option<(String, Option<String>)>) -> Result<()> {
fn save_library(s: &mut Session, label: &str, lib: Vec<Brush>, renames: &[(String, Option<String>)]) -> Result<()> {
s.edit(label, |d, _| {
brush::store(d, &lib);
if let Some((from, to)) = rename {
for (from, to) in renames {
let ids: Vec<NodeId> = {
let mut v = vec![];
d.walk(|n| {
@@ -402,7 +411,7 @@ fn brush_new(s: &mut Session, p: &Value) -> Result<Value> {
v[k] = serde_json::to_value(&art).unwrap_or_default();
}
lib.push(parse_brush(C, v)?);
save_library(s, "New Brush", lib, None)?;
save_library(s, "New Brush", lib, &[])?;
Ok(json!({ "name": name }))
}
@@ -414,26 +423,35 @@ fn parse_brush(cmd: &str, v: Value) -> Result<Brush> {
}
fn brush_delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = name_param(p, "brush.delete")?.to_string();
const C: &str = "brush.delete";
let names = names_param(p, C)?;
let mut lib = brush::library(&s.doc()?.doc);
let n = lib.len();
lib.retain(|b| b.name != name);
if lib.len() == n {
return Err(bad("brush.delete", format!("no brush named `{name}`")));
if let Some(name) = names.iter().find(|n| !lib.iter().any(|b| &b.name == *n)) {
return Err(bad(C, format!("no brush named `{name}`")));
}
save_library(s, "Delete Brush", lib, Some((name, None)))?;
lib.retain(|b| !names.contains(&b.name));
let renames: Vec<(String, Option<String>)> = names.into_iter().map(|n| (n, None)).collect();
save_library(s, "Delete Brush", lib, &renames)?;
ok()
}
/// Adds a copy of each brush in `name` or `names` at the end of the library. `newName`, given
/// without `names`, names the copy.
fn brush_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let name = name_param(p, "brush.duplicate")?;
let mut lib = brush::library(&s.doc()?.doc);
let mut b = lib.iter().find(|b| b.name == name).cloned().ok_or_else(|| bad("brush.duplicate", format!("no brush named `{name}`")))?;
b.name = brush::unique_name(&lib, str_param(p, "newName").unwrap_or(&format!("{name} copy")));
let out = b.name.clone();
lib.push(b);
save_library(s, "Duplicate Brush", lib, None)?;
Ok(json!({ "name": out }))
const C: &str = "brush.duplicate";
let names = names_param(p, C)?;
let before = brush::library(&s.doc()?.doc);
let mut lib = before.clone();
let mut out = vec![];
for name in &names {
let mut b = before.iter().find(|b| &b.name == name).cloned().ok_or_else(|| bad(C, format!("no brush named `{name}`")))?;
let new_name = str_param(p, "newName").filter(|_| p.get("names").is_none()).map_or_else(|| format!("{name} copy"), str::to_string);
b.name = brush::unique_name(&lib, &new_name);
out.push(b.name.clone());
lib.push(b);
}
save_library(s, "Duplicate Brush", lib, &[])?;
Ok(if p.get("names").is_some() { json!({ "names": out }) } else { json!({ "name": out.first() }) })
}
fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
@@ -458,7 +476,7 @@ fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
lib[i] = parse_brush(C, v)?;
let out = lib[i].name.clone();
let rename = new_name.map(|nn| (name, Some(nn)));
save_library(s, "Brush Options", lib, rename)?;
save_library(s, "Brush Options", lib, rename.as_slice())?;
Ok(json!({ "name": out }))
}
+1 -1
View File
@@ -204,7 +204,7 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
}
/// The `names` list and `name` parameters, without repeats; at least one is required.
fn names_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
pub(super) fn names_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
let mut names: Vec<String> = vec![];
for n in str_list(p, "names").into_iter().chain(str_param(p, "name").map(str::to_string)) {
if !names.contains(&n) {
+36
View File
@@ -140,6 +140,42 @@ fn brush_options_rename_duplicate_and_delete_update_strokes() {
assert!(s.execute("brush.delete", &json!({"name": "5 pt. Round"})).is_err());
}
/// The Brushes panel's multi-selection: `names` deletes or duplicates several brushes in one undo
/// step, and an unknown name changes nothing.
#[test]
fn brushes_delete_and_duplicate_by_names() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0);
s.execute("brush.apply", &json!({"name": "3 pt. Round"})).unwrap();
let b = rect(&mut s, 100.0, 10.0, 50.0, 50.0);
s.execute("brush.apply", &json!({"name": "6 pt. Flat"})).unwrap();
let names = |s: &mut Session| -> Vec<String> {
let list = s.execute("brush.list", &json!({})).unwrap();
list["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap().to_string()).collect()
};
let before = names(&mut s);
let undo = s.doc().unwrap().history.undo.len();
// Copies of both, after the last brush.
let d = s.execute("brush.duplicate", &json!({"names": ["3 pt. Round", "6 pt. Flat"]})).unwrap();
assert_eq!(d["names"], json!(["3 pt. Round copy", "6 pt. Flat copy"]));
assert_eq!(names(&mut s)[before.len()..], ["3 pt. Round copy", "6 pt. Flat copy"]);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(names(&mut s), before);
// A name that isn't a brush: an error, and nothing is deleted.
assert!(s.execute("brush.delete", &json!({"names": ["3 pt. Round", "Nope"]})).is_err());
assert!(s.execute("brush.duplicate", &json!({"names": ["3 pt. Round", "Nope"]})).is_err());
assert_eq!((names(&mut s), brush_of(&s, a).as_deref()), (before.clone(), Some("3 pt. Round")));
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
// Both go in one step; the strokes that used them become plain strokes.
s.execute("brush.delete", &json!({"names": ["3 pt. Round", "6 pt. Flat"]})).unwrap();
assert_eq!((brush_of(&s, a), brush_of(&s, b)), (None, None));
assert!(!names(&mut s).iter().any(|n| n == "3 pt. Round" || n == "6 pt. Flat"));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((names(&mut s), brush_of(&s, b).as_deref()), (before, Some("6 pt. Flat")));
}
#[test]
fn expand_brush_replaces_path_with_art_group() {
let mut s = session();
+297 -27
View File
@@ -1,6 +1,7 @@
//! Brushes panel: the document's brush library with rendered stroke previews. Clicking a brush
//! applies it to the selected paths (and makes it the Paintbrush's current brush); double-clicking
//! one opens its Brush Options.
//! one opens its Brush Options. Shift-click selects a range of brushes and Cmd/Ctrl-click adds or
//! removes one, for Delete Brush and Duplicate Brush.
use std::collections::HashMap;
@@ -64,15 +65,71 @@ fn selected_brush(app: &VectorcraftApp) -> Option<String> {
first_selected(app).and_then(|n| n.appearance.stroke().and_then(|s| s.brush.clone()))
}
/// What the panel's last click was made with: the document, its selected objects and the current
/// brush.
type ClickKey = (u64, Vec<u64>, Option<String>);
fn click_key(app: &VectorcraftApp, current: Option<&str>) -> Option<ClickKey> {
app.session.active().map(|st| (st.uid, st.selection.objects.iter().map(|id| id.0).collect(), current.map(str::to_string)))
}
/// Whether the panel's last click was made with the document, objects and current brush active
/// now. Panel state `br-selected` holds the multi-selection and the key of that click.
fn same_click(app: &VectorcraftApp, ctx: &egui::Context, current: Option<&str>) -> bool {
let (_, key): (Option<Vec<String>>, Option<ClickKey>) = pstate(ctx, "br-selected");
key.is_some() && key == click_key(app, current)
}
/// The brushes that Shift-clicks and Cmd/Ctrl-clicks selected, of those in `list`. It is `None`
/// after a plain click, once none of its brushes is left, and once another document, other
/// objects or another current brush is active, which also clears it and the key of the last
/// click; `Some` of an empty list after Cmd/Ctrl-clicks deselected every brush.
fn multi(app: &VectorcraftApp, ctx: &egui::Context, list: &[(String, String)], current: Option<&str>) -> Option<Vec<String>> {
if !same_click(app, ctx, current) {
set_pstate(ctx, "br-selected", (None::<Vec<String>>, None::<ClickKey>));
return None;
}
let (names, _): (Option<Vec<String>>, Option<ClickKey>) = pstate(ctx, "br-selected");
let names = names?;
if names.is_empty() {
return Some(names);
}
let kept: Vec<String> = names.into_iter().filter(|n| list.iter().any(|b| &b.0 == n)).collect();
(!kept.is_empty()).then_some(kept)
}
/// The brushes Delete Brush and Duplicate Brush act on: the multi-selection, else the selected
/// path's brush, else the current one.
fn targets(app: &VectorcraftApp, ctx: &egui::Context, list: &[(String, String)], current: Option<&str>) -> Vec<String> {
multi(app, ctx, list, current).unwrap_or_else(|| selected_brush(app).or(current.map(str::to_string)).into_iter().collect())
}
/// Runs `brush.delete` or `brush.duplicate` on `names`.
fn run_on(app: &mut VectorcraftApp, id: &str, names: &[String]) {
let p = match names {
[name] => json!({ "name": name }),
_ => json!({ "names": names }),
};
app.run(id, p).ok();
}
/// The names in `list` in the order the panel shows them: by type in [`KINDS`] order, without the
/// hidden types.
fn shown<'a>(list: &'a [(String, String)], hidden: &[String]) -> Vec<&'a str> {
let kinds = KINDS.iter().filter(|(ty, _)| !hidden.iter().any(|h| h == ty));
kinds.flat_map(|(ty, _)| list.iter().filter(move |b| b.1 == *ty).map(|b| b.0.as_str())).collect()
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let (list, current) = brushes(app);
let sel_brush = selected_brush(app);
let mut multi = multi(app, ui.ctx(), &list, current.as_deref());
let list_view: bool = pstate(ui.ctx(), "br-list");
let hidden: Vec<String> = pstate(ui.ctx(), "br-hidden");
let defs: HashMap<String, Value> =
list.iter().filter_map(|(n, _)| app.run("brush.get", json!({"name": n})).ok().map(|v| (n.clone(), v))).collect();
let mut clicked: Option<String> = None;
let mut clicked: Option<(String, egui::Modifiers)> = None;
let mut options: Option<String> = None;
let list_rect = widgets::list_box(ui, |ui| {
ui.set_min_height(110.0);
@@ -93,7 +150,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let (r, resp) = ui.allocate_exact_size(size, Sense::click_and_drag());
// Dragged onto a path, the brush is applied to it.
widgets::drag_source(ui, &resp, || PanelDrag::Brush { name: name.to_string(), def: defs.get(name).cloned().unwrap_or_default() });
let on = sel_brush.as_deref() == Some(name) || (sel_brush.is_none() && current.as_deref() == Some(name));
let on = match &multi {
Some(multi) => multi.iter().any(|m| m == name),
None => sel_brush.as_deref() == Some(name) || (sel_brush.is_none() && current.as_deref() == Some(name)),
};
if on {
ui.painter().rect_filled(r, 0.0, t.row_selected);
} else if resp.hovered() {
@@ -118,12 +178,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
resp.on_hover_text(name)
};
// A click applies the brush; the second click of a double-click opens its options.
// A click applies or selects the brush; the second click of a double-click opens its
// options.
let mut pick = |resp: egui::Response, name: &String| {
if resp.double_clicked() {
options = Some(name.clone());
} else if resp.clicked() {
clicked = Some(name.clone());
clicked = Some((name.clone(), resp.ctx.input(|i| i.modifiers)));
}
};
if list_view {
@@ -148,13 +209,36 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
{
app.status(e);
}
if let Some(name) = clicked {
let has_sel = app.session.active().is_some_and(|d| !d.selection.is_empty());
if has_sel {
app.run("brush.apply", json!({"name": name})).ok();
} else {
app.run("brush.setCurrent", json!({"name": name})).ok();
let highlighted = sel_brush.clone().or(current.clone());
match clicked {
// Shift and Cmd/Ctrl only select, as in the Swatches panel: they start from the
// multi-selection, else from the highlighted brush.
Some((name, m)) if m.shift || m.command => {
let order = shown(&list, &hidden);
// A range starts from the last brush clicked without Shift while the same document,
// art and current brush are active, else from the highlighted brush.
let anchor: String = pstate(ui.ctx(), "br-anchor");
if !same_click(app, ui.ctx(), current.as_deref()) || !order.contains(&anchor.as_str()) {
set_pstate(ui.ctx(), "br-anchor", highlighted.clone().unwrap_or_default());
}
let from = multi.unwrap_or_else(|| highlighted.iter().cloned().collect());
let sel = super::swatches::click_selection(ui, "br-anchor", from, &order, &name, m);
set_pstate(ui.ctx(), "br-selected", (Some(sel.clone()), click_key(app, current.as_deref())));
multi = Some(sel);
}
Some((name, _)) => {
set_pstate(ui.ctx(), "br-anchor", name.clone());
multi = None;
let has_sel = app.session.active().is_some_and(|d| !d.selection.is_empty());
if has_sel {
app.run("brush.apply", json!({"name": name})).ok();
} else {
app.run("brush.setCurrent", json!({"name": name})).ok();
}
let now = brushes(app).1;
set_pstate(ui.ctx(), "br-selected", (None::<Vec<String>>, click_key(app, now.as_deref())));
}
None => {}
}
if let Some(name) = options
&& let Err(e) = app.run("ui.brushOptions", json!({ "name": name }))
@@ -162,7 +246,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
app.status(e);
}
let has_brush = sel_brush.is_some();
let target = sel_brush.clone().or(current.clone());
let targets = multi.unwrap_or_else(|| highlighted.into_iter().collect());
let has_sel = app.session.active().is_some_and(|d| !d.selection.is_empty());
widgets::bottom_bar(ui, |ui| {
widgets::icon_button_enabled(ui, "library", tl!("Brush Libraries (on the roadmap)"), false, false, 24.0);
@@ -176,18 +260,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if widgets::icon_button_enabled(ui, "dc-new-item", tl!("New Art Brush from Selection"), false, has_sel, 24.0).clicked() {
app.run("brush.new", json!({"type": "art"})).ok();
}
if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Brush"), false, target.is_some(), 24.0).clicked()
&& let Some(n) = &target
{
app.run("brush.delete", json!({"name": n})).ok();
if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Brush"), false, !targets.is_empty(), 24.0).clicked() {
run_on(app, "brush.delete", &targets);
}
});
}
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let sel = selected_brush(app);
let (_, current) = brushes(app);
let target = sel.clone().or(current);
let (list, current) = brushes(app);
let targets = targets(app, ui.ctx(), &list, current.as_deref());
let has_sel = app.session.active().is_some_and(|d| !d.selection.is_empty());
for (label, ty) in [(tl!("New Calligraphic Brush"), "calligraphic"), (tl!("New Bristle Brush"), "bristle")] {
if menu_item(ui, label, true, false) {
@@ -203,15 +285,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
app.run("brush.new", json!({"type": ty})).ok();
}
}
if menu_item(ui, tl!("Duplicate Brush"), target.is_some(), false)
&& let Some(n) = &target
{
app.run("brush.duplicate", json!({"name": n})).ok();
if menu_item(ui, tl!("Duplicate Brush"), !targets.is_empty(), false) {
run_on(app, "brush.duplicate", &targets);
}
if menu_item(ui, tl!("Delete Brush"), target.is_some(), false)
&& let Some(n) = &target
{
app.run("brush.delete", json!({"name": n})).ok();
if menu_item(ui, tl!("Delete Brush"), !targets.is_empty(), false) {
run_on(app, "brush.delete", &targets);
}
if menu_item(ui, tl!("Remove Brush Stroke"), sel.is_some(), false) {
app.run("brush.remove", json!({})).ok();
@@ -219,7 +297,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
if menu_item(ui, tl!("Expand Brush Strokes"), sel.is_some(), false) {
app.run("object.expandBrush", json!({})).ok();
}
// On the selected path's brush, else the current one, as the other items.
// On the selected path's brush, else the current one.
if menu_item(ui, tl!("Brush Options…"), brush_options::available(app), false)
&& let Err(e) = app.run("ui.brushOptions", json!({}))
{
@@ -267,6 +345,45 @@ mod tests {
egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
}
/// One frame of `draw` at `time` with `modifiers` held, its output kept.
fn frame_with(
ctx: &egui::Context,
app: &mut VectorcraftApp,
events: Vec<egui::Event>,
modifiers: egui::Modifiers,
time: f64,
draw: fn(&mut VectorcraftApp, &mut Ui),
) -> egui::FullOutput {
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 600.0)));
let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect();
let raw = egui::RawInput { screen_rect, time: Some(time), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| draw(app, ui));
out.textures_delta.clear();
out
}
/// A click at `at` with `modifiers` held, in a frame of `draw` at `time`.
fn click(ctx: &egui::Context, app: &mut VectorcraftApp, at: Pos2, modifiers: egui::Modifiers, time: f64, draw: fn(&mut VectorcraftApp, &mut Ui)) {
let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers };
frame_with(ctx, app, vec![egui::Event::PointerMoved(at), press(true), press(false)], modifiers, time, draw);
}
/// The accessibility nodes of `out` that take a click, with their rectangles, top to bottom
/// and then left to right.
fn clickable(out: &egui::FullOutput) -> Vec<(Option<String>, Rect)> {
let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes;
let mut v: Vec<(Option<String>, Rect)> = nodes
.iter()
.filter(|(_, n)| n.supports_action(egui::accesskit::Action::Click))
.filter_map(|(_, n)| {
let b = n.bounds()?;
Some((n.label().map(|l| l.trim().to_string()), Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32))))
})
.collect();
v.sort_by(|a, b| a.1.min.y.total_cmp(&b.1.min.y).then(a.1.min.x.total_cmp(&b.1.min.x)));
v
}
/// #852: double-clicking a brush opens its Brush Options; OK changes the brush (and strokes
/// painted with it). A single click only applies the brush.
#[test]
@@ -309,6 +426,159 @@ mod tests {
assert_eq!(brushes(&mut app).1.as_deref(), Some("Pressure Round"), "renamed, still current");
}
/// The rectangles `out` fills with the selected-row color.
fn lit(ctx: &egui::Context, out: &egui::FullOutput) -> Vec<Rect> {
let t = Tokens::get(ctx);
out.shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::Rect(r) if r.fill == t.row_selected => Some(r.rect),
_ => None,
})
.collect()
}
fn brush_of(app: &VectorcraftApp, id: NodeId) -> Option<String> {
app.session.active().unwrap().doc.node(id).unwrap().appearance.stroke().and_then(|s| s.brush.clone())
}
fn names(app: &mut VectorcraftApp) -> Vec<String> {
brushes(app).0.into_iter().map(|b| b.0).collect()
}
/// Click the panel menu's item `label` (frames at `time`, `time + 0.5`).
fn menu_click(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str, time: f64) {
let out = frame_with(ctx, app, vec![], egui::Modifiers::NONE, time, menu);
let at = clickable(&out).into_iter().find(|(l, _)| l.as_deref() == Some(label)).unwrap_or_else(|| panic!("no `{label}`")).1.center();
click(ctx, app, at, egui::Modifiers::NONE, time + 0.5, menu);
}
/// The first three brushes (the Calligraphic ones): a click on the first applies it to the
/// selected rectangle, and then a Shift-click on the third selects all three without applying
/// the third.
fn select_three(ctx: &egui::Context, app: &mut VectorcraftApp, rect: NodeId) -> (Vec<String>, Vec<Rect>) {
let out = frame_with(ctx, app, vec![], egui::Modifiers::NONE, 0.0, show);
let tiles: Vec<Rect> = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).take(3).collect();
let (list, _) = brushes(app);
assert!(list[..3].iter().all(|b| b.1 == "calligraphic"), "{list:?}");
let three: Vec<String> = list[..3].iter().map(|b| b.0.clone()).collect();
click(ctx, app, tiles[0].center(), egui::Modifiers::NONE, 1.0, show);
assert_eq!(brush_of(app, rect).as_deref(), Some(three[0].as_str()));
click(ctx, app, tiles[2].center(), egui::Modifiers::SHIFT, 2.0, show);
assert_eq!(brush_of(app, rect).as_deref(), Some(three[0].as_str()), "a Shift-click only selects");
(three, tiles)
}
/// Shift-click selects a range of brushes and Cmd/Ctrl-click adds or removes one; the panel
/// highlights them, and Duplicate Brush and Delete Brush act on all of them in one undo step.
#[test]
fn shift_and_command_clicks_select_brushes_for_delete_and_duplicate() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let rect = NodeId(app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap());
let ctx = egui::Context::default();
ctx.enable_accesskit();
let (three, tiles) = select_three(&ctx, &mut app, rect);
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 2.5, show);
let lit_tiles = |out: &egui::FullOutput| -> Vec<usize> { (0..3).filter(|i| lit(&ctx, out).contains(&tiles[*i])).collect() };
assert_eq!(lit_tiles(&out), [0, 1, 2]);
// Cmd/Ctrl-click takes the second out again.
click(&ctx, &mut app, tiles[1].center(), egui::Modifiers::COMMAND, 3.0, show);
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 3.5, show);
assert_eq!(lit_tiles(&out), [0, 2]);
let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
let before = (names(&mut app), undo(&app));
menu_click(&ctx, &mut app, "Duplicate Brush", 4.0);
let copies = [format!("{} copy", three[0]), format!("{} copy", three[2])];
assert_eq!(names(&mut app)[before.0.len()..], copies);
assert_eq!(undo(&app), before.1 + 1);
// The bottom bar's last button, Delete Brush.
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 5.0, show);
let trash = clickable(&out).last().unwrap().1.center();
click(&ctx, &mut app, trash, egui::Modifiers::NONE, 6.0, show);
let left = names(&mut app);
assert!(!left.contains(&three[0]) && !left.contains(&three[2]) && left.contains(&three[1]), "{left:?}");
assert!(copies.iter().all(|c| left.contains(c)), "{left:?}");
assert_eq!(brush_of(&app, rect), None);
assert_eq!(undo(&app), before.1 + 2);
}
/// Selecting other art ends the multi-selection: selecting the first art again does not bring
/// it back, and with a path stroked with another brush selected, the panel highlights that
/// brush and Delete Brush deletes it alone.
#[test]
fn selecting_other_art_ends_the_brush_multi_selection() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let rect = |app: &mut VectorcraftApp, x: f64| {
NodeId(app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap())
};
let a = rect(&mut app, 10.0);
let ctx = egui::Context::default();
ctx.enable_accesskit();
let (three, tiles) = select_three(&ctx, &mut app, a);
let lit_tiles = |out: &egui::FullOutput| -> Vec<usize> { (0..3).filter(|i| lit(&ctx, out).contains(&tiles[*i])).collect() };
// A path without a brush, then the first one again: the current brush is highlighted.
rect(&mut app, 60.0);
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 3.0, show);
assert_eq!(lit_tiles(&out), [0]);
app.run("select.set", json!({"ids": [a.0]})).unwrap();
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 3.5, show);
assert_eq!(lit_tiles(&out), [0]);
// A path stroked with the second brush.
let c = rect(&mut app, 110.0);
app.run("brush.apply", json!({"name": three[1]})).unwrap();
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 4.0, show);
assert_eq!(lit_tiles(&out), [1]);
menu_click(&ctx, &mut app, "Delete Brush", 5.0);
let left = names(&mut app);
assert!(left.contains(&three[0]) && !left.contains(&three[1]) && left.contains(&three[2]), "{left:?}");
assert_eq!((brush_of(&app, a).as_deref(), brush_of(&app, c)), (Some(three[0].as_str()), None));
}
/// After other art is selected, a Shift-click selects the range from that art's brush, not from
/// the brush clicked before.
#[test]
fn a_shift_click_after_selecting_other_art_starts_from_its_brush() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
let ctx = egui::Context::default();
ctx.enable_accesskit();
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 0.0, show);
let tiles: Vec<Rect> = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).take(3).collect();
let (list, _) = brushes(&mut app);
click(&ctx, &mut app, tiles[0].center(), egui::Modifiers::NONE, 1.0, show);
app.run("shape.rectangle", json!({"x": 100, "y": 10, "width": 50, "height": 40})).unwrap();
app.run("brush.apply", json!({"name": list[2].0})).unwrap();
click(&ctx, &mut app, tiles[1].center(), egui::Modifiers::SHIFT, 2.0, show);
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 2.5, show);
assert_eq!(lit(&ctx, &out).iter().filter(|r| tiles.contains(r)).collect::<Vec<_>>(), [&tiles[1], &tiles[2]]);
}
/// Cmd/Ctrl-clicks can deselect every brush: none is highlighted, and Delete Brush and
/// Duplicate Brush are off.
#[test]
fn command_clicks_deselect_every_brush() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let a = NodeId(app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap());
let ctx = egui::Context::default();
ctx.enable_accesskit();
let (_, tiles) = select_three(&ctx, &mut app, a);
for (i, tile) in tiles.iter().enumerate() {
click(&ctx, &mut app, tile.center(), egui::Modifiers::COMMAND, 3.0 + i as f64, show);
}
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 7.0, show);
assert!(!lit(&ctx, &out).iter().any(|r| tiles.contains(r)));
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 8.0, menu);
let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes;
for label in ["Duplicate Brush", "Delete Brush"] {
let item = nodes.iter().find(|(_, n)| n.label().map(str::trim) == Some(label)).unwrap_or_else(|| panic!("no `{label}`"));
assert!(item.1.is_disabled(), "{label}");
}
}
#[test]
fn art_dropped_on_the_panel_becomes_an_art_brush_and_a_brush_drags_out() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
+2 -1
View File
@@ -1098,7 +1098,8 @@ already walks Image Trace. Short agent entry points:
`symbol.place {name?, x?, y?}`, `symbol.breakLink {ids?}`, `symbol.replace`, `symbol.edit`, `symbol.duplicate`,
`symbol.selectInstances {name}`.
- Brushes: `brush.list`, `brush.get {name}`, `brush.apply {name, ids?}`, `brush.new {type, name?, params?, ids?}`,
`brush.setCurrent {name}`, `brush.options {name, params?}`, `brush.freehand`, `object.expandBrush {ids?}`.
`brush.setCurrent {name}`, `brush.options {name, params?}`, `brush.delete {name} | {names}`,
`brush.duplicate {name, newName?} | {names}`, `brush.freehand`, `object.expandBrush {ids?}`.
- Variables: `variable.list`, `variable.define {name, kind: text|visibility|image|graph}`, `variable.bind {variable, ids?}`,
`variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeImageDynamic`, `variable.makeGraphDynamic`,
`variable.unbind`, `variable.rename`, `variable.delete`, `variable.highlight` (image values are file paths, graph
+1
View File
@@ -599,6 +599,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke.
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
- **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS.
In the Brushes panel, Shift-click selects a range of brushes and Ctrl/Cmd-click adds or removes one. Delete Brush and Duplicate Brush then act on all of them (`brush.delete {names}`, `brush.duplicate {names}`).
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
Fill and stroke paint (Fill, Stroke, None, Apply Last Color and Apply Last Gradient, also on characters selected with the Type tool), the Stroke panel's options, gradient and freeform point edits, Swap Fill and Stroke, Default Fill and Stroke, the Eyedropper, and the Appearance panel's item edits, Clear Appearance, Reduce to Basic Appearance, Remove Item, item reordering and Move Appearance record no undo step when they change nothing.
A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own.