Merge pull request #3334 from dotCooCoo/feat/brush-select-unused

Brushes: Select All Unused selects the brushes the document does not use
This commit is contained in:
BFlat
2026-10-21 16:16:11 +07:00
committed by GitHub
6 changed files with 172 additions and 7 deletions
+1 -1
View File
@@ -297,7 +297,7 @@ fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> {
/// Visit `n` and its subtree, the art of opacity masks included (mask art after the masked
/// object, before its children).
fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) {
pub fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) {
f(n);
if let Some(mask) = n.extras().mask.as_deref() {
walk_with_masks(&mask.art, f);
+48
View File
@@ -6,6 +6,7 @@
//! `Node::geometric_bounds` assumes for instances, and each instance's `xf` scales it back. The
//! natural size is kept in `Document::unknown["symbolSizes"]` so new instances come out right.
use std::collections::BTreeSet;
use std::sync::Arc;
use serde_json::{Value, json};
@@ -84,6 +85,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
brush_duplicate
),
cmd!(
query "brush.unused",
"Select All Unused",
[],
None,
"{} → {names: [the brushes no stroke paints with, in library order]} (the Brushes panel selects them): strokes in the art (opacity masks included), symbols, graph designs, pattern tiles and graphic styles count, and so do strokes in the art of a brush that is used",
has_doc,
brush_unused
),
cmd!(
"brush.options",
"Brush Options…",
@@ -464,6 +474,44 @@ fn brush_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
Ok(if p.get("names").is_some() { json!({ "names": out }) } else { json!({ "name": out.first() }) })
}
/// Adds the names of the brushes the strokes of `items` paint with to `out`.
fn stroke_brushes(items: &[AppearanceItem], out: &mut Vec<String>) {
for it in items {
if let AppearanceItem::Stroke(st) = it
&& let Some(b) = &st.brush
{
out.push(b.clone());
}
}
}
/// Adds the names of the brushes the strokes in `n` (its opacity masks included) paint with to
/// `out`.
fn art_brushes(n: &Node, out: &mut Vec<String>) {
vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| stroke_brushes(&m.appearance.items, out));
}
/// The brushes no stroke paints with. Strokes in the art (opacity masks included), symbols, graph
/// designs, pattern tiles and graphic styles count, and so do the strokes in the art of every brush
/// that a counted stroke paints with.
fn brush_unused(s: &mut Session, _: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let lib = brush::library(d);
let mut todo = vec![];
let trees = d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.graph_designs.iter().map(|g| &g.art));
trees.chain(d.patterns.iter().flat_map(|p| &p.art)).for_each(|n| art_brushes(n, &mut todo));
d.graphic_styles.iter().for_each(|g| stroke_brushes(&g.appearance.items, &mut todo));
let mut used = BTreeSet::new();
while let Some(name) = todo.pop() {
if !used.contains(&name) {
lib.iter().filter(|b| b.name == name).flat_map(Brush::art).for_each(|n| art_brushes(n, &mut todo));
used.insert(name);
}
}
let names: Vec<&str> = lib.iter().map(|b| b.name.as_str()).filter(|n| !used.contains(*n)).collect();
Ok(json!({ "names": names }))
}
fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "brush.options";
let name = name_param(p, C)?.to_string();
+80
View File
@@ -202,6 +202,86 @@ fn brushes_delete_and_duplicate_by_names() {
assert_eq!((names(&mut s), brush_of(&s, b).as_deref()), (before, Some("6 pt. Flat")));
}
fn unused(s: &mut Session) -> Vec<String> {
serde_json::from_value(s.execute("brush.unused", &json!({})).unwrap()["names"].clone()).unwrap()
}
/// Select All Unused: `brush.unused` lists the brushes no stroke paints with, in library order,
/// without an undo step.
#[test]
fn brush_unused_lists_the_brushes_no_stroke_paints_with() {
let mut s = session();
let mut lib = vectorcraft_brush::library(&s.doc().unwrap().doc);
lib.retain(|b| ["3 pt. Round", "6 pt. Flat", "Charcoal"].contains(&b.name.as_str()));
vectorcraft_brush::store(std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc), &lib);
let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0);
s.execute("brush.apply", &json!({"name": "6 pt. Flat"})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
assert_eq!(unused(&mut s), ["3 pt. Round", "Charcoal"]);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
s.execute("brush.remove", &json!({"ids": [a.0]})).unwrap();
assert_eq!(unused(&mut s), ["3 pt. Round", "6 pt. Flat", "Charcoal"]);
}
/// A brush counts as used when a stroke paints with it in an opacity mask, a symbol, a graph
/// design, a pattern tile, a graphic style, or the art of a brush that is used. The art of a brush
/// that is not used, its own strokes included, does not count.
#[test]
fn brush_unused_counts_strokes_in_masks_symbols_designs_patterns_styles_and_used_brush_art() {
let mut s = session();
// Makes a rectangle painted with `brush` and leaves it selected; the test clears it once it
// has been used.
let brushed = |s: &mut Session, brush: &str| {
let id = rect(s, 10.0, 10.0, 40.0, 20.0);
s.execute("brush.apply", &json!({"name": brush, "ids": [id.0]})).unwrap();
id
};
let clear = |s: &mut Session, id: NodeId| {
s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap();
};
brushed(&mut s, "Charcoal");
let sym = s.execute("symbol.new", &json!({"name": "Mark"})).unwrap();
clear(&mut s, NodeId(sym["id"].as_u64().unwrap()));
let id2 = brushed(&mut s, "Dots");
s.execute("graph.design", &json!({"new": "Bar"})).unwrap();
clear(&mut s, id2);
let id3 = brushed(&mut s, "Arrow");
s.execute("object.pattern.make", &json!({"edit": false})).unwrap();
if s.doc().unwrap().doc.node(id3).is_some() {
clear(&mut s, id3);
}
let id4 = brushed(&mut s, "Chain");
s.execute("graphicStyle.new", &json!({"name": "Linked", "id": id4.0})).unwrap();
clear(&mut s, id4);
let masked = rect(&mut s, 100.0, 100.0, 60.0, 60.0);
let mask = brushed(&mut s, "6 pt. Flat");
select(&mut s, &[masked, mask]);
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
// "Holder" is used, and its art paints with "10 pt. Oval"; "Idle" is not, and its art paints
// with "Bristle Round". "Loop" paints its own art with itself and is not used anywhere else.
let id5 = brushed(&mut s, "10 pt. Oval");
s.execute("brush.new", &json!({"type": "art", "name": "Holder"})).unwrap();
clear(&mut s, id5);
let l = line(&mut s);
s.execute("brush.apply", &json!({"name": "Holder", "ids": [l.0]})).unwrap();
let id6 = brushed(&mut s, "Bristle Round");
s.execute("brush.new", &json!({"type": "art", "name": "Idle"})).unwrap();
clear(&mut s, id6);
let id7 = rect(&mut s, 10.0, 10.0, 40.0, 20.0);
s.execute("brush.new", &json!({"type": "art", "name": "Loop"})).unwrap();
s.execute("brush.apply", &json!({"name": "Loop", "ids": [id7.0]})).unwrap();
let art = serde_json::to_value(node(&s, id7)).unwrap();
s.execute("brush.options", &json!({"name": "Loop", "params": {"art": art}})).unwrap();
clear(&mut s, id7);
let left = unused(&mut s);
for used in ["Charcoal", "Dots", "Arrow", "Chain", "6 pt. Flat", "Holder", "10 pt. Oval"] {
assert!(!left.iter().any(|n| n == used), "{used} in {left:?}");
}
for name in ["Idle", "Bristle Round", "Loop", "3 pt. Round"] {
assert!(left.iter().any(|n| n == name), "{name} not in {left:?}");
}
}
#[test]
fn expand_brush_replaces_path_with_art_group() {
let mut s = session();
+38 -6
View File
@@ -1,7 +1,8 @@
//! 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. Shift-click selects a range of brushes and Cmd/Ctrl-click adds or
//! removes one, for Delete Brush and Duplicate Brush.
//! one opens its Brush Options. Shift-click selects a range of brushes, Cmd/Ctrl-click adds or
//! removes one, and the panel menu's Select All Unused selects the brushes no stroke paints with,
//! for Delete Brush and Duplicate Brush.
use std::collections::HashMap;
@@ -80,10 +81,11 @@ fn same_click(app: &VectorcraftApp, ctx: &egui::Context, current: Option<&str>)
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.
/// The brushes that Shift-clicks, Cmd/Ctrl-clicks and Select All Unused 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 or
/// Select All Unused found none.
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>));
@@ -304,6 +306,14 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
app.status(e);
}
ui.separator();
// Selects the brushes no stroke paints with, for Delete Brush and Duplicate Brush.
if menu_item(ui, tl!("Select All Unused"), app.session.active().is_some(), false)
&& let Ok(r) = app.run("brush.unused", json!({}))
{
let names: Vec<String> = serde_json::from_value(r["names"].clone()).unwrap_or_default();
set_pstate(ui.ctx(), "br-selected", (Some(names), click_key(app, current.as_deref())));
}
ui.separator();
let mut hidden: Vec<String> = pstate(ui.ctx(), "br-hidden");
for (ty, label) in KINDS {
let shown = !hidden.iter().any(|h| h == ty);
@@ -579,6 +589,28 @@ mod tests {
}
}
/// Select All Unused in the panel menu highlights every brush but the one the rectangle is
/// painted with, and Delete Brush then deletes all of them.
#[test]
fn select_all_unused_selects_the_brushes_no_stroke_paints_with() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let (list, _) = brushes(&mut app);
let a = NodeId(app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap());
app.run("brush.apply", json!({"name": list[1].0})).unwrap();
let ctx = egui::Context::default();
ctx.enable_accesskit();
menu_click(&ctx, &mut app, "Select All Unused", 1.0);
let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 2.0, show);
let tiles: Vec<Rect> = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).collect();
assert_eq!(tiles.len(), list.len());
let on: Vec<usize> = (0..tiles.len()).filter(|i| lit(&ctx, &out).contains(&tiles[*i])).collect();
assert_eq!(on, (0..tiles.len()).filter(|i| *i != 1).collect::<Vec<_>>());
menu_click(&ctx, &mut app, "Delete Brush", 3.0);
assert_eq!(names(&mut app), [list[1].0.clone()]);
assert_eq!(brush_of(&app, a), Some(list[1].0.clone()));
}
#[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());
+4
View File
@@ -1223,6 +1223,10 @@ 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}`. `symbol.new` and `symbol.duplicate {name, newName?}` trim the white space around the
name they are given, as `symbol.options` trims `newName`; a blank one gives "New Symbol" or "<name> copy".
- The Brushes panel menu's Select All Unused: `brush.unused` (a query) lists the brushes no stroke paints with, in
library order, and the panel selects them. Strokes in the art (opacity masks included), symbols, graph designs,
pattern tiles and graphic styles count, and so do the strokes in the art of a brush that is used. The art of a brush
that is not used does not count, so a brush whose own art is the only art that paints with it is listed.
- Brushes: `brush.list`, `brush.get {name}`, `brush.apply {name, ids?}`, `brush.new {type, name?, params?, ids?}`,
`brush.setCurrent {name}`, `brush.options {name, params?}`, `brush.delete {name} | {names}`,
`brush.duplicate {name, newName?} | {names}`, `brush.freehand`, `object.expandBrush {ids?}`. `brush.new`,
+1
View File
@@ -689,6 +689,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **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}`).
The Brushes panel menu's Select All Unused selects the brushes that are not used in the document, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. A brush counts as used when a stroke paints with it in the art (opacity masks included), in symbols, graph designs, pattern tiles or graphic styles, or in the art of a used brush (`brush.unused`).
- **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.
An image's own fills and strokes draw the shape that a Convert to Shape or Outline Object effect on them gives them (the image's box or its frame, reshaped by the effects after it), under or over the image as its Contents row says, on the canvas, in exports and through Expand Appearance; new fills and strokes on an image go under it. Without one of those effects they draw nothing, and Use Preview Bounds measures an image by its frame.