Merge pull request #7753 from dotCooCoo/feat/info-panel-tool-readouts

Info: the panel shows the move, transform, Zoom and Paintbrush values of the tool in use
This commit is contained in:
BFlat
2026-11-15 13:50:30 +07:00
committed by GitHub
14 changed files with 630 additions and 46 deletions
+2
View File
@@ -43,6 +43,7 @@ pub(crate) mod graph;
mod halftone;
pub mod help;
pub mod importmatches;
mod info;
pub(crate) mod inline;
mod interactivity;
mod intertwine;
@@ -344,6 +345,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(docmenu::specs());
v.extend(variablecmds::specs());
v.extend(docinfo::specs());
v.extend(info::specs());
v.extend(panelcmds::specs());
v.extend(artboardalign::specs());
v.extend(buildcmds::specs());
+163
View File
@@ -0,0 +1,163 @@
//! Window › Info (`info.readout`): the selection's size and the active tool's readout, as the Info
//! panel shows them.
use serde_json::{Value, json};
use vectorcraft_render::effects;
use super::{CommandSpec, Result, Session, always, cmd};
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
query "info.readout",
"Info",
[],
None,
"{} → {tool: the active tool's id, width?, height?: the sides of the selection's bounding box in pt (the box around the anchors selected with Direct Selection, when there are some), measured as prefs usePreviewBounds says; left out with nothing selected, readout?: what the Info panel shows for the tool in use: {kind: \"drag\", dx, dy, distance, angle} while the Selection, Direct Selection or Group Selection tool moves the selection or the Gradient tool draws a gradient vector on the art; {kind: \"scale\", width, height} (percent) during a Scale tool drag; {kind: \"rotate\", center: [x, y], angle: deg counter-clockwise, as object.rotate takes it} and {kind: \"reflect\", center, angle: the axis in deg counter-clockwise from horizontal, as object.reflect takes it} during a Rotate or Reflect tool drag; {kind: \"shear\", center, axis: 0 for a horizontal shear and 90 for a vertical one, angle: deg, as object.shear takes it} during a Shear tool drag; {kind: \"brush\", brush: name|null} while the Paintbrush is the tool; left out for any other tool and between drags. Lengths are in pt, a drag's angle is in deg counter-clockwise from the x axis, and center is the center of the selection's bounding box as the drag has carried it, in document coordinates}",
always,
readout
)]
}
impl Session {
/// The sides of the selection's bounding box as the Info panel shows them: of the box around
/// the anchors selected with Direct Selection when there are some, else of the bounding box
/// rotated with rotated objects ([`Session::transform_box`]), measured as Use Preview Bounds
/// says. None with nothing selected.
pub fn info_size(&self) -> Option<(f64, f64)> {
let st = self.active()?;
let anchors = effects::preview::anchor_bounds_with(&st.selection, &st.doc, self.bounds_measure());
let r = anchors.or_else(|| self.transform_box(&st.selection.objects).map(|b| b.rect))?;
Some((r.width(), r.height()))
}
}
fn readout(s: &mut Session, _: &Value) -> Result<Value> {
let mut out = json!({ "tool": s.tool_id() });
if let Some((w, h)) = s.info_size() {
out["width"] = json!(w);
out["height"] = json!(h);
}
if let Some(r) = s.tool_readout() {
out["readout"] = r.to_json();
}
Ok(out)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use vectorcraft_tools::{PointerEvent, PointerKind};
use crate::{Session, ViewInfo};
fn view() -> ViewInfo {
ViewInfo { smart_guides: false, snap_to_point: false, ..Default::default() }
}
/// A 100 by 60 rectangle at (50, 50), selected, with a 10 pt stroke.
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 60})).unwrap()["id"].clone();
s.execute("select.set", &json!({ "ids": [id] })).unwrap();
s.execute("stroke.set", &json!({"weight": 10})).unwrap();
s
}
fn tool(s: &mut Session, id: &str) {
s.select_tool(id, view()).unwrap();
}
fn pointer(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
s.pointer(&PointerEvent::new(kind, x, y), view()).unwrap();
}
fn info(s: &mut Session) -> serde_json::Value {
s.execute("info.readout", &json!({})).unwrap()
}
fn near(v: &serde_json::Value, want: f64) -> bool {
v.as_f64().is_some_and(|v| (v - want).abs() < 1e-6)
}
#[test]
fn the_size_follows_use_preview_bounds() {
let mut s = session();
let i = info(&mut s);
assert!(near(&i["width"], 100.0) && near(&i["height"], 60.0), "{i}");
assert!(i.get("readout").is_none(), "{i}");
s.execute("prefs.set", &json!({"key": "usePreviewBounds", "value": true})).unwrap();
let i = info(&mut s);
assert!(near(&i["width"], 110.0) && near(&i["height"], 70.0), "{i}");
s.execute("select.none", &json!({})).unwrap();
let i = info(&mut s);
assert!(i.get("width").is_none() && i.get("height").is_none(), "{i}");
}
#[test]
fn a_move_reads_how_far_it_has_gone_until_the_release() {
let mut s = session();
tool(&mut s, "selection");
pointer(&mut s, PointerKind::Down, 100.0, 80.0);
assert!(info(&mut s).get("readout").is_none());
pointer(&mut s, PointerKind::Drag, 130.0, 40.0);
let i = info(&mut s);
let r = &i["readout"];
assert_eq!(r["kind"], "drag", "{i}");
assert!(near(&r["dx"], 30.0) && near(&r["dy"], -40.0) && near(&r["distance"], 50.0), "{i}");
assert!(near(&r["angle"], 53.13010235415598), "{i}");
pointer(&mut s, PointerKind::Up, 130.0, 40.0);
let i = info(&mut s);
assert!(i.get("readout").is_none(), "{i}");
assert!(near(&i["width"], 100.0), "{i}");
}
#[test]
fn each_transform_tool_reads_its_drag() {
for (id, check) in [
("scale", (|r| r["kind"] == "scale" && near(&r["width"], 200.0) && near(&r["height"], 200.0)) as fn(&serde_json::Value) -> bool),
("rotate", |r| r["kind"] == "rotate" && near(&r["center"][0], 100.0) && near(&r["center"][1], 80.0) && near(&r["angle"], -90.0)),
("reflect", |r| r["kind"] == "reflect" && near(&r["center"][0], 100.0) && near(&r["angle"], 0.0)),
("shear", |r| {
r["kind"] == "shear"
&& near(&r["center"][0], 100.0)
&& near(&r["center"][1], 80.0)
&& near(&r["axis"], 0.0)
&& near(&r["angle"], 45.0)
}),
] {
let mut s = session();
tool(&mut s, id);
// The reference point is the box's center (100, 80).
let from = if id == "shear" { (100.0, 60.0) } else { (120.0, 100.0) };
pointer(&mut s, PointerKind::Down, from.0, from.1);
let to = match id {
"scale" => (140.0, 120.0),
"rotate" => (80.0, 100.0),
"reflect" => (160.0, 80.0),
_ => (120.0, 60.0),
};
pointer(&mut s, PointerKind::Drag, to.0, to.1);
let i = info(&mut s);
assert_eq!(i["tool"], id);
assert!(check(&i["readout"]), "{id}: {i}");
pointer(&mut s, PointerKind::Up, to.0, to.1);
assert!(info(&mut s).get("readout").is_none(), "{id}");
}
}
#[test]
fn the_paintbrush_reads_its_brush() {
let mut s = session();
tool(&mut s, "paintbrush");
let i = info(&mut s);
assert_eq!(i["readout"], json!({"kind": "brush", "brush": null}), "{i}");
}
#[test]
fn a_parameter_is_refused() {
let mut s = session();
let e = s.execute("info.readout", &json!({"tool": "scale"})).unwrap_err().to_string();
assert!(e.contains("tool"), "{e}");
}
}
@@ -206,6 +206,7 @@ pub(super) const ALLOWED: &[&str] = &[
"imageTrace.makeAndExpand",
"imageTrace.release",
"imageTrace.setView",
"info.readout",
"layer.clippingMask.toggle",
"layer.collectInNew",
"layer.delete",
+6
View File
@@ -1103,6 +1103,12 @@ impl Session {
self.pointer_held
}
/// What the Info panel shows for the active tool as it is now
/// ([`vectorcraft_tools::Tool::readout`]), in the view of the last pointer event.
pub fn tool_readout(&mut self) -> Option<vectorcraft_tools::Readout> {
self.with_tool_cx(self.last_view, |t, cx| t.readout(cx))
}
/// Is the active tool moving, scaling or rotating the selection with a drag?
pub fn tool_transforming(&self) -> bool {
self.tool.transforming()
+10
View File
@@ -188,6 +188,8 @@ fn snapped(d: Vec2, unsnapped: Vec2, shift: bool, guides: &[Overlay]) -> bool {
pub struct DirectSelectionTool {
group: bool,
state: State,
/// How far the move's last preview carried the anchors or the objects ([`Tool::readout`]).
moved: Option<Vec2>,
/// The spine point last clicked (blend, anchor): its handles show and can be dragged.
spine: Option<(NodeId, usize)>,
/// Other points of that spine picked with it (Shift-click adds or takes one away); dragging
@@ -251,6 +253,7 @@ impl DirectSelectionTool {
Self {
group,
state: State::Idle,
moved: None,
spine: None,
spine_more: vec![],
mesh: MeshEdit::default(),
@@ -733,6 +736,10 @@ impl Tool for DirectSelectionTool {
fn busy(&self) -> bool {
!matches!(self.state, State::Idle) || self.guide.busy()
}
fn readout(&self, _cx: &ToolContext) -> Option<crate::Readout> {
let moving = matches!(self.state, State::MoveAnchors { began: true, .. } | State::MoveObject { began: true, .. });
self.moved.filter(|_| moving).map(crate::Readout::drag)
}
fn options(&self) -> Value {
self.mesh.options()
}
@@ -1486,6 +1493,7 @@ impl DirectSelectionTool {
(d, self.guides) = (q - grab, guides);
}
self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides);
self.moved = Some(d);
out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y})));
out
}
@@ -1538,6 +1546,7 @@ impl DirectSelectionTool {
(d, self.guides) = (q - held, guides);
}
self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides);
self.moved = Some(d);
out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y})));
return out;
}
@@ -1553,6 +1562,7 @@ impl DirectSelectionTool {
(d, self.guides) = if ev.mods.shift { snap.snap_along(cx, hold, d, ev.mods.alt) } else { snap.snap(cx, hold, d, ev.mods.alt) };
}
self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides);
self.moved = Some(d);
out.push(Action::Preview(
"object.transform".into(),
json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(d)), "copy": ev.mods.alt}),
+8
View File
@@ -57,10 +57,12 @@ pub use catalog::{TOOL_GROUPS, ToolInfo, tool_info};
mod context;
mod cursor;
mod input;
mod readout;
pub use context::{GlyphSnap, MarqueeMode, PaintDefaults, PixelOp, PixelSnap, ScreenFrame, ToolContext};
pub use cursor::Cursor;
pub use input::{Action, Mods, PenPose, PointerEvent, PointerKind, ToolKey};
pub use readout::Readout;
/// Visual feedback drawn by the UI in screen space (coordinates here are document space).
#[derive(Clone, Debug, PartialEq)]
@@ -265,6 +267,12 @@ pub trait Tool: Send {
fn wants_rehover(&self) -> bool {
false
}
/// What the Info panel shows for the tool as it is now: the drag in progress (a move of the
/// selection, a gradient vector, a Scale, Rotate, Reflect or Shear tool drag), or the
/// Paintbrush's brush. None for a tool with nothing of its own to show.
fn readout(&self, _cx: &ToolContext) -> Option<Readout> {
None
}
/// Called when the user switches away (finish pending work).
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
vec![]
+76
View File
@@ -0,0 +1,76 @@
//! What the tool in use gives the Info panel to show ([`crate::Tool::readout`]).
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Vec2};
/// The Info panel's values for the tool in use. Lengths and points are in document units, and
/// angles are in degrees as the matching command takes them: counter-clockwise for
/// `object.rotate`'s `angle` and `object.reflect`'s `axis`, and `object.shear`'s `angle`.
#[derive(Clone, Debug, PartialEq)]
pub enum Readout {
/// A drag that moves the selection or draws a gradient vector: how far it has gone in x and y.
Drag { dx: f64, dy: f64 },
/// A Scale tool drag: the horizontal and vertical scale (1 leaves the size as it is).
Scale { sx: f64, sy: f64 },
/// A Rotate tool drag: where the center of the selection's bounding box is, and the angle
/// turned.
Rotate { center: Point, angle: f64 },
/// A Reflect tool drag: where the center of the selection's bounding box is, and the angle of
/// the axis.
Reflect { center: Point, angle: f64 },
/// A Shear tool drag: where the center of the selection's bounding box is, the angle of the
/// shear axis (0 horizontal, 90 vertical) and the shear angle.
Shear { center: Point, axis: f64, angle: f64 },
/// The Paintbrush: the brush it paints with, if any.
Brush { name: Option<String> },
}
impl Readout {
/// The readout of a drag by `d`.
pub fn drag(d: Vec2) -> Self {
Readout::Drag { dx: d.x, dy: d.y }
}
/// The length of a [`Readout::Drag`] and its angle, as the Measure tool gives them.
pub fn distance_angle(&self) -> Option<(f64, f64)> {
match *self {
Readout::Drag { dx, dy } => {
let (dist, angle, ..) = crate::xform::measure(Point::ORIGIN, Point::new(dx, dy));
Some((dist, angle))
}
_ => None,
}
}
/// The readout as `info.readout` reports it.
pub fn to_json(&self) -> Value {
let at = |p: Point| json!([p.x, p.y]);
match self {
Readout::Drag { dx, dy } => {
let (distance, angle) = self.distance_angle().unwrap_or_default();
json!({ "kind": "drag", "dx": dx, "dy": dy, "distance": distance, "angle": angle })
}
Readout::Scale { sx, sy } => json!({ "kind": "scale", "width": sx * 100.0, "height": sy * 100.0 }),
Readout::Rotate { center, angle } => json!({ "kind": "rotate", "center": at(*center), "angle": angle }),
Readout::Reflect { center, angle } => json!({ "kind": "reflect", "center": at(*center), "angle": angle }),
Readout::Shear { center, axis, angle } => json!({ "kind": "shear", "center": at(*center), "axis": axis, "angle": angle }),
Readout::Brush { name } => json!({ "kind": "brush", "brush": name }),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_drag_gives_its_length_and_its_angle_up_the_page() {
let r = Readout::drag(Vec2::new(30.0, -40.0));
let (d, a) = r.distance_angle().unwrap();
assert!((d - 50.0).abs() < 1e-9 && (a - 53.13010235415598).abs() < 1e-9, "{d} {a}");
assert_eq!(r.to_json()["dx"], 30.0);
assert_eq!(Readout::Scale { sx: 1.5, sy: 0.5 }.to_json(), json!({"kind": "scale", "width": 150.0, "height": 50.0}));
assert_eq!(Readout::Scale { sx: 1.0, sy: 1.0 }.distance_angle(), None);
assert_eq!(Readout::Brush { name: None }.to_json(), json!({"kind": "brush", "brush": null}));
}
}
+7
View File
@@ -172,6 +172,8 @@ pub struct SelectionTool {
/// The params of the move's last preview: in Shaper's Construction mode the move ends with
/// them and `releaseOutside` ([`construction`]).
last_move: Option<Value>,
/// How far the move's last preview carried the selection ([`Tool::readout`]).
moved: Option<Vec2>,
/// Held state used for the most recent bounding-box preview.
last_backquote: bool,
/// Pointer position used for the most recent bounding-box preview.
@@ -898,6 +900,10 @@ impl Tool for SelectionTool {
matches!(self.state, State::Moving { began: true, .. })
}
fn readout(&self, _cx: &ToolContext) -> Option<crate::Readout> {
self.moved.filter(|_| self.moving()).map(crate::Readout::drag)
}
fn transforming(&self) -> bool {
match &self.state {
State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. } => true,
@@ -1753,6 +1759,7 @@ impl SelectionTool {
params["patterns"] = json!(true);
}
self.last_move = Some(params.clone());
self.moved = Some(d);
out.push(Action::Preview("object.transform".into(), params));
out
}
+3
View File
@@ -299,6 +299,9 @@ impl Tool for BrushPaintTool {
fn busy(&self) -> bool {
self.inner.busy()
}
fn readout(&self, cx: &ToolContext) -> Option<crate::Readout> {
Some(crate::Readout::Brush { name: self.brush_name(cx) })
}
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
let acts = self.inner.deactivate(cx);
self.map(cx, acts)
+6
View File
@@ -545,6 +545,12 @@ impl Tool for GradientTool {
self.gesture.as_ref().is_some_and(|g| g.began) || self.free.as_ref().is_some_and(freeform::Gesture::began)
}
/// The vector a drag on the art has drawn so far.
fn readout(&self, _cx: &ToolContext) -> Option<crate::Readout> {
let (start, end) = self.gesture.as_ref().filter(|g| g.began)?.vector?;
Some(crate::Readout::drag(end - start))
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
// A freeform gradient: its points instead of the bar.
match (ev.kind, &mut self.free) {
+48 -8
View File
@@ -20,7 +20,7 @@ use super::target_overlays;
use crate::bbox::rotate_for_drag;
use crate::guides::{PointSnap, Targets, snap_pick};
use crate::select::{matrix_json, selection_bounds, selection_box};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Readout, Tool, ToolContext, ToolKey};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TransformKind {
@@ -98,6 +98,8 @@ pub struct TransformTool {
origin: Option<(Point, Vec<NodeId>)>,
drag: Option<Drag>,
measure: Option<(Point, String)>,
/// What the Info panel shows for the drag in progress.
readout: Option<Readout>,
/// Snap feedback while hovering ("anchor" / "center" label), and for the dragged pointer.
hover: Vec<Overlay>,
/// What the dragged pointer snaps to, gathered when the drag begins.
@@ -126,7 +128,7 @@ pub fn reflect_matrix(o: Point, theta: f64) -> Affine {
impl TransformTool {
pub fn new(kind: TransformKind) -> Self {
Self { kind, origin: None, drag: None, measure: None, hover: vec![], snap: None }
Self { kind, origin: None, drag: None, measure: None, readout: None, hover: vec![], snap: None }
}
/// The effective reference point: the custom one if it belongs to the current selection,
@@ -154,12 +156,44 @@ impl TransformTool {
/// and Shift's 45° steps start from the box's axes. A Reflect axis drawn by the drag is the
/// line the pointer draws on the page.
pub fn matrix_in(&self, angle: f64, o: Point, start: Point, p: Point, shift: bool, bounds: Rect) -> (Affine, String) {
let (m, a, b) = self.solve(angle, o, start, p, shift, bounds);
(m, self.label(a, b))
}
/// The canvas label for the two values [`Self::solve`] gives.
fn label(&self, a: f64, b: f64) -> String {
match self.kind {
TransformKind::Rotate => format!("{:.1}°", -a),
TransformKind::Reflect => format!("{a:.1}°"),
TransformKind::Scale => format!("W: {:.1}%\nH: {:.1}%", a * 100.0, b * 100.0),
TransformKind::Shear => format!("{:.1}°", if a == 0.0 { b } else { -b }),
}
}
/// What the Info panel shows for the transform `m` of a selection with `bounds`, with the two
/// values [`Self::solve`] gives.
fn readout_of(&self, m: Affine, a: f64, b: f64, bounds: Rect) -> Readout {
let center = m * bounds.center();
match self.kind {
TransformKind::Rotate => Readout::Rotate { center, angle: a },
TransformKind::Reflect => Readout::Reflect { center, angle: a },
TransformKind::Scale => Readout::Scale { sx: a, sy: b },
TransformKind::Shear => Readout::Shear { center, axis: a, angle: b },
}
}
/// [`TransformTool::matrix_in`]'s transform, with the two values of its readout: the angle
/// turned, in degrees counter-clockwise as `object.rotate` takes it (Rotate), the angle of the
/// axis in degrees counter-clockwise (Reflect), the scale along the box's width and height
/// (Scale), or the angle of the shear axis (0 horizontal, 90 vertical) and the shear angle as
/// `object.shear` takes it (Shear).
fn solve(&self, angle: f64, o: Point, start: Point, p: Point, shift: bool, bounds: Rect) -> (Affine, f64, f64) {
let frame = OrientedBox { angle, rect: Rect::ZERO };
let local = |v: Vec2| frame.to_doc().inverse() * v.to_point();
match self.kind {
TransformKind::Rotate => {
let (a, deg) = rotate_for_drag(o, start, p, shift);
(a, format!("{:.1}°", -deg))
(a, deg, 0.0)
}
TransformKind::Reflect => {
let mut v = p - o;
@@ -169,7 +203,7 @@ impl TransformTool {
v = (frame.to_doc() * vectorcraft_geom::constrain_angle(local(v).to_vec2(), 45.0).to_point()).to_vec2();
}
let theta = v.y.atan2(v.x);
(reflect_matrix(o, theta), format!("{:.1}°", vectorcraft_geom::normalize_deg(-theta.to_degrees())))
(reflect_matrix(o, theta), vectorcraft_geom::normalize_deg(-theta.to_degrees()), 0.0)
}
TransformKind::Scale => {
let d0 = local(start - o).to_vec2();
@@ -184,7 +218,7 @@ impl TransformTool {
}
let clamp = |s: f64| if s.abs() < 1e-4 { 1e-4f64.copysign(s) } else { s };
let (sx, sy) = (clamp(sx), clamp(sy));
(about(o, frame.conjugate(Affine::scale_non_uniform(sx, sy))), format!("W: {:.1}%\nH: {:.1}%", sx * 100.0, sy * 100.0))
(about(o, frame.conjugate(Affine::scale_non_uniform(sx, sy))), sx, sy)
}
TransformKind::Shear => {
// The shear axis follows the dominant drag direction. Shift keeps the drag on that
@@ -223,7 +257,7 @@ impl TransformTool {
};
}
}
(m, format!("{:.1}°", (-k.atan()).to_degrees()))
if horizontal { (m, 0.0, (-k.atan()).to_degrees()) } else { (m, 90.0, k.atan().to_degrees()) }
}
}
}
@@ -231,6 +265,7 @@ impl TransformTool {
/// Ends the drag, its snapping and readouts with it.
fn end_drag(&mut self) -> Option<Drag> {
self.measure = None;
self.readout = None;
self.snap = None;
self.hover.clear();
self.drag.take()
@@ -250,6 +285,10 @@ impl Tool for TransformTool {
self.drag.is_some_and(|d| d.began)
}
fn readout(&self, _cx: &ToolContext) -> Option<Readout> {
self.readout.clone()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
match ev.kind {
@@ -294,10 +333,11 @@ impl Tool for TransformTool {
}
let (p, guides) = self.snap.as_ref().map_or((p, vec![]), |s| s.snap(cx, p));
self.hover = guides;
let (m, text) = self.matrix_in(d.angle_for(ev.mods.alt, ev.backquote), d.origin, d.pick, p, ev.mods.shift, d.bounds);
let (m, a, b) = self.solve(d.angle_for(ev.mods.alt, ev.backquote), d.origin, d.pick, p, ev.mods.shift, d.bounds);
(d.last, d.pointer, d.shift) = (m, p, ev.mods.shift);
self.drag = Some(d);
self.measure = Some((p, text));
self.measure = Some((p, self.label(a, b)));
self.readout = Some(self.readout_of(m, a, b, d.bounds));
out.push(preview(m, ev.mods.alt, ev.backquote));
out
}
+267 -38
View File
@@ -1,10 +1,15 @@
//! Info panel: cursor X/Y, selection W/H, the pointer's distance/angle from the selection, and the
//! selection's fill and stroke colour values.
//! Info panel: cursor X/Y, selection W/H, the pointer's distance/angle from the selection, the
//! readout of the tool in use (a move, a gradient vector, a Scale, Rotate, Reflect or Shear tool
//! drag, the Zoom tool's magnification, the Paintbrush's brush) and the selection's fill and
//! stroke colour values.
use egui::{Sense, Ui, vec2};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{NodeKind, Unit};
use vectorcraft_geom::Point;
use vectorcraft_tools::Readout;
use super::{current_paints, first_selected, pstate, set_pstate};
use super::{current_paints, mixed_paints, pstate, set_pstate};
use crate::VectorcraftApp;
use crate::theme::Tokens;
use crate::widgets::{self, menu_item};
@@ -26,10 +31,25 @@ pub fn color_values(c: &Color) -> Vec<(&'static str, String)> {
}
}
fn paint_block(ui: &mut Ui, p: &Paint) {
/// The name Show Options adds for paint `p`: the swatch and the percentage of a tint, or the
/// gradient swatch a gradient is linked to.
fn paint_name(p: &Paint) -> Option<String> {
match p {
Paint::Solid { swatch: Some(n), tint, .. } if *tint < 1.0 => Some(format!("{n} {:.0}%", vectorcraft_color::tint_percent(*tint))),
Paint::Gradient(g) => g.swatch.clone(),
_ => None,
}
}
/// One paint's row: its tile and values, and with `options` its name ([`paint_name`]). A paint the
/// selected objects do not share (`mixed`) leaves the row empty.
fn paint_block(ui: &mut Ui, p: &Paint, mixed: bool, options: bool) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
if mixed {
return;
}
widgets::swatch_tile(ui, r, p, false, false);
match p {
Paint::Solid { color, .. } => {
@@ -38,58 +58,143 @@ fn paint_block(ui: &mut Ui, p: &Paint) {
}
}
other => {
ui.label(egui::RichText::new(other.label()).color(t.text_dim));
let name = paint_name(other).filter(|_| options).unwrap_or_else(|| other.label());
ui.label(egui::RichText::new(name).color(t.text_dim));
}
}
});
if let (false, true, Paint::Solid { .. }, Some(name)) = (mixed, options, p, paint_name(p)) {
ui.label(egui::RichText::new(name).color(t.text_dim));
}
}
/// What the panel reads from the document and the tool for its rows of numbers ([`Rows::of`]).
struct Shown<'a> {
units: Unit,
/// The rulers' zero point: X and Y count from it.
origin: Point,
/// The document point under the pointer.
pointer: Point,
/// The sides and the center of the selection's bounding box.
size: Option<(f64, f64, Point)>,
readout: Option<&'a Readout>,
/// Show Options is on.
options: bool,
}
/// The panel's rows of numbers: a left and a right column, row by row.
#[derive(Debug, Default, PartialEq)]
struct Rows {
left: Vec<String>,
right: Vec<String>,
}
impl Rows {
/// X and Y are the pointer's position, and during a Rotate, Reflect or Shear tool drag the
/// center of the selection's bounding box. W and H are the selection's size, the change in x
/// and y while a drag moves the selection or draws a gradient vector (with the distance and the
/// angle under them), and the percentages of a Scale tool drag. The transform tools' angles
/// take their place during their drags.
fn of(s: &Shown) -> Self {
let len = |v: f64| s.units.format(v);
let at = match s.readout {
Some(Readout::Rotate { center, .. } | Readout::Reflect { center, .. } | Readout::Shear { center, .. }) => *center,
_ => s.pointer,
};
let mut rows = Rows { left: vec![format!("X: {}", len(at.x - s.origin.x)), format!("Y: {}", len(at.y - s.origin.y))], right: vec![] };
match s.readout {
Some(r @ Readout::Drag { dx, dy }) => {
rows.right = vec![format!("W: {}", len(*dx)), format!("H: {}", len(*dy))];
let (d, a) = r.distance_angle().unwrap_or_default();
rows.left.push(format!("D: {}", len(d)));
rows.right.push(format!("A: {a:.1}°"));
}
Some(Readout::Scale { sx, sy }) => rows.right = vec![format!("W: {:.1}%", sx * 100.0), format!("H: {:.1}%", sy * 100.0)],
Some(Readout::Rotate { angle, .. } | Readout::Reflect { angle, .. }) => rows.right = vec![format!("A: {angle:.1}°")],
Some(Readout::Shear { axis, angle, .. }) => rows.right = vec![format!("A: {axis:.1}°"), format!("{}: {angle:.1}°", tl!("Shear"))],
Some(Readout::Brush { .. }) | None => {
let (w, h) = s.size.map(|(w, h, _)| (len(w), len(h))).unwrap_or_default();
rows.right = vec![format!("W: {w}"), format!("H: {h}")];
if let (true, Some((.., c))) = (s.options, s.size) {
let (d, a) = Readout::drag(s.pointer - c).distance_angle().unwrap_or_default();
rows.left.push(format!("D: {}", len(d)));
rows.right.push(format!("A: {a:.1}°"));
}
}
}
rows
}
}
/// The font and size the selected objects share when every one of them is type: the first
/// object's first style, as the Character panel shows its size. None when an object is not type or
/// two of them differ.
fn shared_type(app: &VectorcraftApp) -> Option<(String, f64, f64)> {
let st = app.session.active()?;
let mut shared = None;
for id in &st.selection.objects {
let NodeKind::Text(tx) = &st.doc.node(*id)?.kind else { return None };
let raw = tx.first_style();
// The size in document points, as the Character panel shows it (#1034); a collapsed
// transform has none, so the stored size stands.
let cs = tx.effective_char_style(&raw).unwrap_or(raw);
let this = (format!("{} {}", cs.font_family, cs.font_style), cs.size, cs.tracking);
match &shared {
None => shared = Some(this),
Some(first) if *first == this => {}
Some(_) => return None,
}
}
shared
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let units = app.session.general_unit();
let p = app.hover_doc.unwrap_or_default();
// X and Y count from the rulers' zero point, as the rulers and the Transform panel do.
let origin = app.ruler_origin();
let bounds = app.session.active().and_then(|st| st.doc.bounds_of(&st.selection.objects, false));
let options = !pstate::<bool>(ui.ctx(), "info-hide-options");
// W and H are the sides of the bounding box the Transform panel shows, measured as Use Preview
// Bounds says.
let size = app.selection_box().map(|b| (b.rect.width(), b.rect.height(), b.center()));
let readout = app.session.tool_readout();
let rows = Rows::of(&Shown {
units: app.session.general_unit(),
// X and Y count from the rulers' zero point, as the rulers and the Transform panel do.
origin: app.ruler_origin(),
pointer: app.hover_doc.unwrap_or_default(),
size,
readout: readout.as_ref(),
options,
});
let mono = |ui: &mut Ui, s: String| {
ui.label(egui::RichText::new(s).monospace().color(t.text));
};
ui.columns(2, |cols| {
mono(&mut cols[0], format!("X: {}", units.format(p.x - origin.x)));
mono(&mut cols[0], format!("Y: {}", units.format(p.y - origin.y)));
let (w, h) = bounds.map(|b| (units.format(b.width()), units.format(b.height()))).unwrap_or_default();
mono(&mut cols[1], format!("W: {w}"));
mono(&mut cols[1], format!("H: {h}"));
for s in rows.left {
mono(&mut cols[0], s);
}
for s in rows.right {
mono(&mut cols[1], s);
}
});
if !pstate::<bool>(ui.ctx(), "info-hide-options")
&& let Some(b) = bounds
{
let c = b.center();
let d = ((p.x - c.x).powi(2) + (p.y - c.y).powi(2)).sqrt();
let a = (-(p.y - c.y)).atan2(p.x - c.x).to_degrees();
ui.columns(2, |cols| {
mono(&mut cols[0], format!("D: {}", units.format(d)));
mono(&mut cols[1], format!("A: {a:.1}°"));
});
if app.session.tool_id() == "zoom" {
let zoom = app.view().map_or(1.0, |v| v.zoom);
mono(ui, format!("{}: {}", tl!("Zoom"), crate::state::zoom_label(zoom, app.session.prefs.display_print_size)));
}
if let Some(Readout::Brush { name: Some(name) }) = readout {
mono(ui, name);
}
widgets::divider(ui);
let (f, s) = current_paints(app);
paint_block(ui, &f);
paint_block(ui, &s);
if let Some(n) = first_selected(app)
&& let vectorcraft_doc::NodeKind::Text(tx) = &n.kind
{
let (fill_mixed, stroke_mixed) = mixed_paints(app, ui.ctx());
paint_block(ui, &f, fill_mixed, options);
paint_block(ui, &s, stroke_mixed, options);
if let Some((font, size, tracking)) = shared_type(app) {
widgets::divider(ui);
let raw = tx.first_style();
// The size in document points, as the Character panel shows it (#1034); a collapsed
// transform has none, so the stored size stands.
let st = tx.effective_char_style(&raw).unwrap_or(raw);
mono(ui, format!("{} {}", st.font_family, st.font_style));
mono(ui, font);
mono(
ui,
crate::i18n::fmt(
tl!("Size: {size} Tracking: {tracking}"),
&[("size", &app.session.type_unit().format(st.size)), ("tracking", &format!("{:.0}", st.tracking))],
&[("size", &app.session.type_unit().format(size)), ("tracking", &format!("{tracking:.0}"))],
),
);
}
@@ -104,7 +209,32 @@ pub fn menu(_app: &mut VectorcraftApp, ui: &mut Ui) {
#[cfg(test)]
mod tests {
use super::super::tests_appearance::{app_with_rect, frame_events, run};
use super::*;
use serde_json::json;
use vectorcraft_engine::ViewInfo;
use vectorcraft_tools::{PointerEvent, PointerKind};
fn texts(app: &mut VectorcraftApp) -> Vec<String> {
frame_events(&egui::Context::default(), app, vec![], show).into_iter().map(|(t, _)| t).collect()
}
fn view() -> ViewInfo {
ViewInfo { smart_guides: false, snap_to_point: false, ..Default::default() }
}
fn pointer(app: &mut VectorcraftApp, kind: PointerKind, x: f64, y: f64) {
app.session.pointer(&PointerEvent::new(kind, x, y), view()).unwrap();
}
fn shown(readout: Option<&Readout>, options: bool) -> Rows {
let size = Some((100.0, 60.0, Point::new(100.0, 80.0)));
Rows::of(&Shown { units: Unit::Points, origin: Point::new(10.0, 20.0), pointer: Point::new(130.0, 40.0), size, readout, options })
}
fn strings(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn values_follow_model() {
@@ -117,8 +247,6 @@ mod tests {
/// #1882: scaled type shows its size in document points, as the Character panel does (#1034).
#[test]
fn scaled_type_shows_its_scaled_size() {
use super::super::tests_appearance::{app_with_rect, frame_events, run};
use serde_json::json;
let mut app = app_with_rect();
let id = run(&mut app, "text.create", json!({"x": 10, "y": 150, "text": "Hi", "size": 12}))["id"].clone();
run(&mut app, "select.set", json!({ "ids": [id] }));
@@ -127,4 +255,105 @@ mod tests {
let size = texts.iter().find(|(t, _)| t.starts_with("Size:")).map(|(t, _)| t.as_str());
assert_eq!(size, Some("Size: 24 pt Tracking: 0"), "{texts:?}");
}
#[test]
fn the_rows_follow_the_tool_in_use() {
// No tool readout: the pointer from the zero point, the selection's size, and with Show
// Options the pointer's distance and angle from the selection's center.
let idle = shown(None, true);
assert_eq!(idle.left, strings(&["X: 120 pt", "Y: 20 pt", "D: 50 pt"]));
assert_eq!(idle.right, strings(&["W: 100 pt", "H: 60 pt", "A: 53.1°"]));
assert_eq!(shown(None, false), Rows { left: strings(&["X: 120 pt", "Y: 20 pt"]), right: strings(&["W: 100 pt", "H: 60 pt"]) });
// The Paintbrush leaves the numbers as they are.
assert_eq!(shown(Some(&Readout::Brush { name: None }), true), idle);
// A drag: the change in x and y, the distance and the angle, with Show Options off too.
let drag = shown(Some(&Readout::Drag { dx: -30.0, dy: 40.0 }), false);
assert_eq!(drag.left, strings(&["X: 120 pt", "Y: 20 pt", "D: 50 pt"]));
assert_eq!(drag.right, strings(&["W: -30 pt", "H: 40 pt", "A: -126.9°"]));
let scale = shown(Some(&Readout::Scale { sx: 1.5, sy: -0.25 }), true);
assert_eq!(scale.left, strings(&["X: 120 pt", "Y: 20 pt"]));
assert_eq!(scale.right, strings(&["W: 150.0%", "H: -25.0%"]));
// Rotate, Reflect and Shear: the center of the selection's box and the angles.
let center = Point::new(60.0, 70.0);
let rotate = shown(Some(&Readout::Rotate { center, angle: 30.0 }), true);
assert_eq!(rotate, Rows { left: strings(&["X: 50 pt", "Y: 50 pt"]), right: strings(&["A: 30.0°"]) });
assert_eq!(shown(Some(&Readout::Reflect { center, angle: 45.0 }), true).right, strings(&["A: 45.0°"]));
let shear = shown(Some(&Readout::Shear { center, axis: 90.0, angle: -12.5 }), true);
assert_eq!(shear, Rows { left: strings(&["X: 50 pt", "Y: 50 pt"]), right: strings(&["A: 90.0°", "Shear: -12.5°"]) });
}
#[test]
fn w_and_h_follow_use_preview_bounds() {
let mut app = app_with_rect();
run(&mut app, "stroke.set", json!({"weight": 10}));
let has = |app: &mut VectorcraftApp, s: &str| texts(app).iter().any(|t| t == s);
assert!(has(&mut app, "W: 100 pt") && has(&mut app, "H: 100 pt"), "{:?}", texts(&mut app));
run(&mut app, "prefs.set", json!({"key": "usePreviewBounds", "value": true}));
assert!(has(&mut app, "W: 110 pt") && has(&mut app, "H: 110 pt"), "{:?}", texts(&mut app));
}
#[test]
fn a_move_shows_the_change_until_the_release() {
let mut app = app_with_rect();
app.session.select_tool("selection", view()).unwrap();
pointer(&mut app, PointerKind::Down, 60.0, 60.0);
pointer(&mut app, PointerKind::Drag, 90.0, 20.0);
let during = texts(&mut app);
for want in ["W: 30 pt", "H: -40 pt", "D: 50 pt", "A: 53.1°"] {
assert!(during.iter().any(|t| t == want), "{want}: {during:?}");
}
pointer(&mut app, PointerKind::Up, 90.0, 20.0);
let after = texts(&mut app);
assert!(after.iter().any(|t| t == "W: 100 pt") && !after.iter().any(|t| t == "W: 30 pt"), "{after:?}");
}
#[test]
fn the_zoom_tool_shows_the_magnification() {
let mut app = app_with_rect();
assert!(!texts(&mut app).iter().any(|t| t.starts_with("Zoom:")));
app.session.select_tool("zoom", view()).unwrap();
let zoom = app.view().map_or(1.0, |v| v.zoom);
let want = format!("Zoom: {}", crate::state::zoom_label(zoom, app.session.prefs.display_print_size));
assert!(texts(&mut app).contains(&want), "{want}: {:?}", texts(&mut app));
}
#[test]
fn the_paintbrush_shows_its_brush() {
let mut app = app_with_rect();
app.session.select_tool("paintbrush", view()).unwrap();
let brush = run(&mut app, "brush.list", json!({}))["brushes"][0]["name"].as_str().map(str::to_string);
let Some(brush) = brush else { panic!("the document has no brush") };
assert!(!texts(&mut app).contains(&brush));
app.session.set_tool_option("brush", &json!(brush));
assert!(texts(&mut app).contains(&brush), "{brush}: {:?}", texts(&mut app));
}
#[test]
fn several_objects_show_only_the_paint_they_share() {
let mut app = app_with_rect();
run(&mut app, "paint.setFill", json!({"color": "#ff0000"}));
let other = run(&mut app, "shape.rectangle", json!({"x": 120, "y": 10, "width": 40, "height": 40}))["id"].clone();
run(&mut app, "paint.setFill", json!({"color": "#ff0000"}));
run(&mut app, "select.all", json!({}));
assert!(texts(&mut app).iter().any(|t| t == "R: 255"), "{:?}", texts(&mut app));
run(&mut app, "select.set", json!({ "ids": [other] }));
run(&mut app, "paint.setFill", json!({"color": "#00ff00"}));
assert!(texts(&mut app).iter().any(|t| t == "G: 255"));
run(&mut app, "select.all", json!({}));
let all = texts(&mut app);
assert!(!all.iter().any(|t| t == "G: 255" || t == "R: 255"), "{all:?}");
}
#[test]
fn show_options_names_a_tint_and_a_gradient_swatch() {
let tint = Paint::Solid { color: Color::rgb8(255, 128, 128), swatch: Some("Brand".into()), tint: 0.4 };
assert_eq!(paint_name(&tint), Some("Brand 40%".into()));
let full = Paint::Solid { color: Color::rgb8(255, 0, 0), swatch: Some("Brand".into()), tint: 1.0 };
assert_eq!(paint_name(&full), None);
assert_eq!(paint_name(&Paint::None), None);
let mut g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default());
assert_eq!(paint_name(&Paint::Gradient(Box::new(g.clone()))), None);
g.swatch = Some("Sunset".into());
assert_eq!(paint_name(&Paint::Gradient(Box::new(g))), Some("Sunset".into()));
}
}
+32
View File
@@ -2979,6 +2979,38 @@ changes neither option. Behavior reference: [View artwork](https://helpx.adobe.c
{"name":"invoke_menu","arguments":{"command":"ui.navigatorPanelOptions","params":{"color":"Light Blue","solidDashes":true}}}
```
## Info panel
`info.readout` takes no params and returns what Window › Info shows for the selection and the tool in use:
`{tool, width?, height?, readout?}`. `tool` is the active tool's id. `width` and `height` are the sides of the
selection's bounding box in points (of the box around the anchors selected with Direct Selection when there are
some), measured as the `usePreviewBounds` preference says, and are left out with nothing selected. `readout` is left
out unless the tool has something of its own to show:
- `{kind: "drag", dx, dy, distance, angle}` while the Selection, Direct Selection or Group Selection tool moves the
selection, or the Gradient tool draws a gradient vector on the art.
- `{kind: "scale", width, height}` during a Scale tool drag, in percent.
- `{kind: "rotate", center, angle}` and `{kind: "reflect", center, angle}` during a Rotate or Reflect tool drag.
`angle` is the angle turned, as `object.rotate` takes it, or the angle of the reflection axis, as `object.reflect`
takes its `axis`. Both are in degrees counter-clockwise.
- `{kind: "shear", center, axis, angle}` during a Shear tool drag. `axis` is 0 for a horizontal shear and 90 for a
vertical one, and `angle` is the shear angle in degrees, as `object.shear` takes it.
- `{kind: "brush", brush}` while the Paintbrush is the tool. `brush` is the brush it paints with, or null.
Lengths are in points. A drag's `angle` is in degrees counter-clockwise from the x axis. `center` is the center of the
selection's bounding box as the drag has carried it, in document coordinates. A param is an error. In the app's panel, X and Y show the
pointer's position from the rulers' zero point, or `center` during a Rotate, Reflect or Shear tool drag. W and H show
the selection's size, `dx` and `dy` during a drag (with the distance and the angle under them), or the Scale tool's
percentages. The Zoom tool adds the view's magnification and the Paintbrush its brush's name. Several selected objects
show only the fill, stroke and type values they share, and Show Options adds a tint's swatch and percentage and the
name of a gradient's swatch. The number formats, the rows shown between drags and the Direct Selection and Group
Selection readouts are VectorCraft's own. Behavior reference: the Info panel overview in the
[archived user guide](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=76).
```json
{"name":"run_command","arguments":{"command":"info.readout","params":{}}}
```
## Tools panel placement
By default the docked Tools panel sits at the window's left edge, outside any panels docked on the left.
+1
View File
@@ -430,6 +430,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
| Control bar: Paragraph: link and Align left/center/right while type is selected (#5372) | done | After the Character controls, as the public documentation has the Control panel format paragraphs ([Paragraph panel overview](https://helpx.adobe.com/illustrator/using/formatting-paragraphs.html), [Align text](https://helpx.adobe.com/illustrator/using/align-text.html)); the link opens the Paragraph panel in a popover, as Character: does with the Character panel. The bar trims from its right end: X/Y/W/H, Distribute, Align, area type vertical alignment, then these buttons, then the link. The three-button set is our choice for room; all seven alignments are in the popover. `panels/paragraph/tests.rs` headless tests (`the_control_bar_shows_the_paragraph_alignment_of_the_selected_type`, `the_control_bar_trims_its_paragraph_section_after_the_controls_to_its_right`) |
| Pathfinder shape results keep their source names and SVG IDs | done | #1293; Minus Front keeps the back name, Minus Back the front name; Unite/Intersect/Exclude use the backmost named input; compound output carries it once; geometry and paint unchanged |
| Attributes fill-rule controls with a compound member selected | done | Edits the compound's rule; direction still edits only the member; rendered-hole and Undo/Redo regression tests (#1197) |
| Info panel follows the tool in use: W and H show the change in x and y, with the distance and the angle, while the selection moves or the Gradient tool draws a vector; the Scale tool's percentages; the center and angles of a Rotate, Reflect or Shear tool drag; the Zoom tool's magnification; the Paintbrush's brush. W and H follow Use Preview Bounds, several selected objects show only the paint and type they share, and Show Options names a tint or a gradient swatch | done | `info.readout`; `Tool::readout`; behavior from the Info panel overview in the [archived user guide](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=76); number formats, the rows shown between drags and the Direct Selection and Group Selection readouts are VectorCraft's own |
| Make Compound Path on a group (such as outlined type) keeps its backmost path's paint; compound paths joined into one keep their holes | done | #1537 |
| Symbol libraries: Window › Symbol Libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons, User Defined, Other Library…, Save Symbols…) and the Symbols panel's Symbol Libraries Menu button and its menu's Open Symbol Library / Save Symbol Library…; a click on a library symbol adds it to the document's Symbols (one undo step, it becomes the current symbol), Shift/Cmd-clicks select for Add to Symbols, and a symbol dragged onto the canvas is added and placed where it is dropped | done | [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html); `window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary` (dialog `saveSymbolLibrary`), `window.userSymbolLibrary1`…`10`; `panels::tests_symbol_libraries`; five built-in libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons; original, generated in code) |
| Brush libraries: Window › Brush Libraries (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media, Tech Circuit, User Defined, Other Library…, Save Brushes…) and the Brushes panel's Brush Libraries Menu button and its menu's Open Brush Library / Save Brush Library…; a click on a library brush adds it to the document's Brushes (one undo step, it becomes the current brush, and selected paths take it in the same step), Shift/Cmd-clicks select for Add to Brushes, a brush dragged onto a path is added and applied to it and one dragged onto the Brushes panel is added; thumbnails render with the library's patterns and symbols | done | [public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel overview](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html); `window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary` (dialog `saveBrushLibrary`), `window.userBrushLibrary1`…`10`; `panels::tests_brush_libraries`; ten built-in libraries (original, generated in code) |