Merge pull request #7380 from ncic/pencil-cursors

Pencil: shows where a stroke continues, connects and closes
This commit is contained in:
BFlat
2026-11-15 00:38:01 +07:00
committed by GitHub
13 changed files with 296 additions and 61 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Pencil",
[],
None,
"{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic and Scatter brushes with Pressure variation; a point without one presses fully. Pen tilt in degrees, each −90..90, x positive leaning right and y leaning down the page, and barrel rotation in degrees clockwise: the stroke keeps how far the pen leaned, which way and how far its barrel turned, for brushes with Tilt, Bearing and Rotation variation; null for what a point lacks), fidelity?: pt (1.5; from 0.000015625 (0.01 / 640) to 100 / 0.0313 (about 3194.89), the Pencil's and the Paintbrush's 0.01 to 100 px Fidelity at the highest and the lowest zoom; a value outside that range is an error. The tools send their Fidelity in pixels divided by the zoom, kept within the range), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: a new path leaves nothing selected, and a continued or redrawn one is deselected while the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it",
"{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic and Scatter brushes with Pressure variation; a point without one presses fully. Pen tilt in degrees, each −90..90, x positive leaning right and y leaning down the page, and barrel rotation in degrees clockwise: the stroke keeps how far the pen leaned, which way and how far its barrel turned, for brushes with Tilt, Bearing and Rotation variation; null for what a point lacks), fidelity?: pt (1.5; from 0.000015625 (0.01 / 640) to 100 / 0.0313 (about 3194.89), the Pencil's and the Paintbrush's 0.01 to 100 px Fidelity at the highest and the lowest zoom; a value outside that range is an error. The tools send their Fidelity in pixels divided by the zoom, kept within the range), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: a new path leaves nothing selected, and a continued or redrawn one is deselected while the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end, and closes it when `closed` is true (left out, when the stroke ends within four Fidelities of the path's other end, 6 to 400 pt); with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it",
has_doc,
freehand
),
+22 -5
View File
@@ -217,11 +217,18 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
}
None => None,
};
// An extending stroke that ends within four Fidelities of the path's other end closes it:
// at least 6 pt and at most 400 pt, whatever the Fidelity it is fitted at.
// `closed`, when given, says whether the stroke closes the path: the Pencil and the
// Paintbrush send it, by their Close Paths When Ends Are Within and Alt at the release.
// Left out, an extending stroke that ends within four Fidelities of the path's other end
// closes it: at least 6 pt and at most 400 pt, whatever the Fidelity it is fitted at.
let close_tol = (tol * 4.0).clamp(6.0, 400.0);
let closing = connect.is_none() && sp.anchors.len() >= 2 && pts.last().is_some_and(|p| p.distance(first) <= close_tol);
if closing && let Some(l) = pts.last_mut() {
let near_first = pts.last().is_some_and(|p| p.distance(first) <= close_tol);
let wants_close = if p.get("closed").is_some_and(|v| !v.is_null()) { closed } else { near_first };
let closing = connect.is_none() && sp.anchors.len() >= 2 && wants_close;
// A stroke ending near the other end ends on it; one ending farther away closes with a
// straight segment back to it.
let ends_on_first = closing && near_first;
if ends_on_first && let Some(l) = pts.last_mut() {
*l = first;
}
if let Some((_, _, y, _)) = &connect
@@ -238,13 +245,23 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
add.remove(0);
}
sp.anchors.extend(add);
if closing
if ends_on_first
&& sp.anchors.len() > 2
&& let Some(l) = sp.anchors.pop()
&& let Some(a0) = sp.anchors.first_mut()
{
a0.h_in = l.h_in;
sp.closed = true;
} else if closing && !ends_on_first && sp.anchors.len() > 2 {
// Closed from away from the other end: a straight segment back to it, whatever
// handles the open ends carried.
sp.closed = true;
if let Some(l) = sp.anchors.last_mut() {
l.h_out = l.p;
}
if let Some(f) = sp.anchors.first_mut() {
f.h_in = f.p;
}
}
for a in &mut sp.anchors {
*a = Anchor::with_handles(a.p, a.h_in, a.h_out);
+41
View File
@@ -5269,6 +5269,47 @@ fn pencil_extend_closes_within_400_pt_at_any_fidelity() {
}
}
/// `closed`, when given, decides whether a stroke continuing an open path closes it (the tools
/// send it by Close Paths When Ends Are Within): false leaves it open even ending on the other end;
/// true closes one ending 50 pt away with a straight segment back, the stroke's end kept, and one
/// ending 2 pt away on the other end itself.
#[test]
fn pencil_extend_closes_as_closed_says() {
let mut s = session();
for (end, closed, ends_on_start) in [([0i32, 0], false, false), ([0, 50], true, false), ([0, 2], true, true)] {
let l = line(&mut s, 0.0, 0.0, 200.0, 0.0);
let points = json!([[200, 0], [200, 100], [100, 150], end]);
let p = json!({"points": points, "fidelity": 1.5, "closed": closed, "extend": {"id": l.0}, "keepSelected": false});
s.execute("path.freehand", &p).unwrap();
let pd = path(&s, l);
let sp = &pd.subpaths[0];
assert_eq!(sp.closed, closed, "ending at {end:?}");
if closed {
let stroke_end = Point::new(f64::from(end[0]), f64::from(end[1]));
let kept = sp.anchors.iter().any(|a| a.p.distance(stroke_end) < 1e-6);
assert_eq!(kept, !ends_on_start, "ending at {end:?}: {:?}", sp.anchors);
let on_start = sp.anchors.iter().filter(|a| a.p.distance(Point::new(0.0, 0.0)) < 1e-6).count();
assert_eq!((sp.anchors[0].p, on_start), (Point::new(0.0, 0.0), 1), "ending at {end:?}");
}
}
}
/// A continued path closed from away from its other end closes with a straight segment, even when
/// that end carries a handle the open path didn't use.
#[test]
fn pencil_extend_closes_far_away_with_a_straight_segment() {
let mut s = session();
let anchors = json!([{"x": 0, "y": 0, "in": [-40, 30]}, {"x": 200, "y": 0}]);
let l = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
let points = json!([[200, 0], [200, 100], [100, 150], [0, 50]]);
let p = json!({"points": points, "fidelity": 1.5, "closed": true, "extend": {"id": l.0}, "keepSelected": false});
s.execute("path.freehand", &p).unwrap();
let sp = path(&s, l).subpaths[0].clone();
assert!(sp.closed);
let (first, last) = (sp.anchors[0], sp.anchors[sp.anchors.len() - 1]);
assert_eq!((first.h_in, last.h_out), (first.p, last.p), "the closing segment has no handles: {:?}", sp.anchors);
}
/// At Fidelity 0.01 short, one-point and repeated-point strokes still draw or are refused, never
/// crash, and what they draw has finite anchors.
#[test]
+7 -5
View File
@@ -110,14 +110,16 @@ pub enum Cursor {
/// The Pencil tool with Alt pressed after the drag starts and Alt key toggles to Smooth Tool off:
/// releasing the mouse button with Alt still held closes the path. A drag started with Shift or
/// Alt held draws an open straight line instead. Also while a freehand stroke is drawn back near
/// its start, where releasing the mouse button closes the path. A crosshair with a ring.
/// its start, or a stroke continuing a selected path near that path's other end, where
/// releasing the mouse button closes the path. A crosshair with a ring.
PencilClose,
/// The Pencil tool before a press that draws a straight line (Shift or Alt held). A crosshair with a
/// short straight line.
/// short level line.
PencilStraight,
/// The Pencil tool before a press that draws a straight line (Shift or Alt held), over an end of
/// a selected open path: the line continues the path from that end. A crosshair with a short
/// straight line leaving a square end point.
/// The Pencil tool before a press over an end of a selected open path: the stroke, freehand or a
/// straight line, continues the path from that end. Also while a continuing stroke ends near an
/// open end of another path selected at the press, where releasing the mouse button connects the
/// two. A crosshair with a short line rising to the right.
PencilContinue,
/// The Paintbrush tool.
Paintbrush,
+13 -5
View File
@@ -629,7 +629,9 @@ hotspot = "The lead tip."
[[cursor]]
id = "PencilNew"
group = "Drawing and editing tools"
shown_when = ["The Pencil tool before a press that draws a new freehand path: the pointer is not within Edit Selected Paths of a selected path that the press would continue from an end or redraw. Over such a path, and while a stroke is drawn, the Pencil cursor has no star."]
shown_when = [
"The Pencil tool before a press that draws a new freehand path: the pointer is not within Edit Selected Paths of a selected path that the press would continue from an end or redraw. Over such a path, and while a stroke is drawn, the Pencil cursor has no star.",
]
hotspot = "The lead tip."
look = "The Pencil cursor with a small star badge, where PencilClose has its ring."
@@ -639,6 +641,7 @@ group = "Drawing and editing tools"
shown_when = [
"The Pencil tool with Alt pressed after the drag starts, when Alt key toggles to Smooth Tool is off: releasing the mouse button with Alt still held closes the path. A drag started with Shift or Alt held draws an open straight line and does not show this cursor.",
"The Pencil tool while a freehand stroke is drawn back to within Close Paths When Ends Are Within of its start: releasing the mouse button there closes the path.",
"The Pencil tool while a stroke continuing a selected open path, freehand or a straight line, is drawn to within Close Paths When Ends Are Within of that path's other end: releasing the mouse button there closes the path.",
]
hotspot = "The lead tip."
look = "The Pencil cursor with a ring badge (closing the loop), like PenClose."
@@ -646,16 +649,21 @@ look = "The Pencil cursor with a ring badge (closing the loop), like PenClose."
[[cursor]]
id = "PencilStraight"
group = "Drawing and editing tools"
shown_when = ["The Pencil tool with Shift or Alt held before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press draws a straight line, at 45° steps from the Constrain Angle when Shift is held and at any angle with Alt alone."]
shown_when = [
"The Pencil tool with Shift or Alt held before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press draws a straight line, at 45° steps from the Constrain Angle when Shift is held and at any angle with Alt alone.",
]
hotspot = "The lead tip."
look = "The Pencil cursor with a short straight-line badge, where PencilClose has its ring."
look = "The Pencil cursor with a short level-line badge, where PencilClose has its ring."
[[cursor]]
id = "PencilContinue"
group = "Drawing and editing tools"
shown_when = ["The Pencil tool over an end of a selected open path, within Edit Selected Paths, with Shift or Alt held before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press draws a straight line that continues the path from that end."]
shown_when = [
"The Pencil tool over an end of a selected open path, within Edit Selected Paths, before a press, except with Alt held when Alt key toggles to Smooth Tool is on: the press continues the path from that end, freehand, or with a straight line when Shift or Alt is held.",
"The Pencil tool while a stroke continuing a selected path ends within Edit Selected Paths of an open end of another path selected at the press: releasing the mouse button connects the two.",
]
hotspot = "The lead tip."
look = "PencilStraight's badge with a small filled square at the line's lower end (the end point the line continues from)."
look = "The Pencil cursor with a short badge line rising to the right, where PencilClose has its ring (the stroke carries on from the path)."
[[cursor]]
id = "Paintbrush"
+72 -15
View File
@@ -98,8 +98,13 @@ pub struct GestureTool {
/// A release where the pointer last dragged to would close the stroke because it ends near
/// its start (Close Paths When Ends Are Within): the cursor shows the closing ring.
near_start: bool,
/// A release where the pointer last dragged to would connect the continued path to another
/// selected path's open end (`finish`): the cursor shows the continuation cursor.
connects: bool,
/// Pencil/Paintbrush continuing a selected open path: (path, continue from its start).
extend: Option<(NodeId, bool)>,
/// The other end of the path `extend` continues: a release near it closes the path.
close_to: Option<Point>,
/// A Pencil stroke continuing a selected open path: the other paths selected at the press,
/// whose open ends it connects to.
connect_to: Vec<NodeId>,
@@ -817,7 +822,9 @@ impl GestureTool {
active: false,
began: false,
near_start: false,
connects: false,
extend: None,
close_to: None,
connect_to: vec![],
redraw: None,
fidelity,
@@ -1100,6 +1107,21 @@ impl GestureTool {
self.find_end(cx, p, &cx.selection.objects)
}
/// The other end of the open end `find_end` found: the opposite end of `id`'s last open
/// subpath.
fn other_end(cx: &ToolContext, id: NodeId, at_start: bool) -> Option<Point> {
let pd = cx.doc.node(id)?.path_data()?;
let sp = pd.subpaths.iter().rev().find(|s| !s.closed && !s.anchors.is_empty())?;
let a = if at_start { sp.anchors.last() } else { sp.anchors.first() };
a.map(|a| a.p)
}
/// Continues the path at the open end `find_extend` found, if any, noting its other end.
fn set_extend(&mut self, cx: &ToolContext, found: Option<(NodeId, bool, Point)>) {
self.extend = found.map(|(id, at_start, _)| (id, at_start));
self.close_to = found.and_then(|(id, at_start, _)| Self::other_end(cx, id, at_start));
}
/// [`Self::find_extend`] among `ids`.
fn find_end(&self, cx: &ToolContext, p: Point, ids: &[NodeId]) -> Option<(NodeId, bool, Point)> {
if self.edit_within <= 0.0 {
@@ -1144,16 +1166,32 @@ impl GestureTool {
}
/// Does a stroke that ends at `end` end near its start (Close Paths When Ends Are Within, off
/// at 0 pixels)?
/// at 0 pixels)? A stroke continuing a selected path ends near that path's other end, a
/// straight line too; a new straight line never closes, as it would run back over itself.
fn ends_near_start(&self, cx: &ToolContext, end: Point) -> bool {
self.close_within > 0.0 && self.points.len() > 2 && self.points[0].distance(end) <= cx.tol(self.close_within)
if self.close_within <= 0.0 {
return false;
}
let tol = cx.tol(self.close_within);
match (self.extend, self.close_to) {
(Some(_), Some(to)) => self.points.len() >= 2 && to.distance(end) <= tol,
(Some(_), None) => false,
(None, _) => !self.straight && self.points.len() > 2 && self.points.first().is_some_and(|s| s.distance(end) <= tol),
}
}
/// Does a release now, with the stroke ending at `end`, close a freehand stroke because it ends
/// near its start (`finish`)? A stroke that redraws a selected path closes only when the engine
/// finds it does not end on that path, so it counts as not closing.
/// The open end of another path selected at the press that a continuing Pencil stroke ending
/// at `end` connects to (`finish`).
fn connects_at(&self, cx: &ToolContext, end: Point) -> Option<(NodeId, bool, Point)> {
self.extend.and_then(|_| self.find_end(cx, end, &self.connect_to))
}
/// Does a release now, with the stroke ending at `end`, close the stroke because it ends near
/// its start, or near the other end of the path it continues (`finish`)? A stroke that redraws
/// a selected path closes only when the engine finds it does not end on that path, so it counts
/// as not closing, and a stroke that connects to another path doesn't close.
fn release_closes_near_start(&self, cx: &ToolContext, end: Point) -> bool {
self.draws_path() && !self.smoothing && !self.straight && self.extend.is_none() && self.redraw.is_none() && self.ends_near_start(cx, end)
self.draws_path() && !self.smoothing && self.redraw.is_none() && self.ends_near_start(cx, end) && self.connects_at(cx, end).is_none()
}
/// Parameters of `path.freehand` for the current stroke, fitted at Fidelity on screen. Only the
@@ -1194,14 +1232,16 @@ impl GestureTool {
if !self.began {
return vec![];
}
// Close Paths When Ends Are Within is off at 0 pixels.
// Close Paths When Ends Are Within is off at 0 pixels. A stroke continuing a
// selected path closes it near its other end, a straight line too.
let near = pts.last().is_some_and(|&end| self.ends_near_start(cx, end));
// A straight line has two points: closing it would draw it back over itself.
let closed = !self.straight && ((m.alt && (self.id == "paintbrush" || !self.alt_smooth)) || (self.extend.is_none() && near));
let mut params = self.freehand_params(cx, closed);
// Alt at the release closes a freehand stroke; a new straight line has two points:
// closing it would draw it back over itself.
let closed = (!self.straight && m.alt && (self.id == "paintbrush" || !self.alt_smooth)) || near;
// A Pencil stroke that continues a selected path and ends on an open end of another
// path selected at the press connects the two.
let connect = pts.last().and_then(|&end| self.find_end(cx, end, &self.connect_to));
// path selected at the press connects the two, and doesn't close.
let connect = pts.last().and_then(|&end| self.connects_at(cx, end));
let mut params = self.freehand_params(cx, closed && connect.is_none());
if let Some((other, at_start, _)) = connect {
params["connect"] = json!({"id": other.0, "end": if at_start { "start" } else { "end" }});
}
@@ -1267,6 +1307,7 @@ impl Tool for GestureTool {
self.active = true;
self.began = false;
self.near_start = false;
self.connects = false;
// Alt switches the Pencil when its toggle is on, the Blob Brush always, and the
// Paintbrush when the press has a selected path.
self.smoothing = ev.mods.alt
@@ -1278,7 +1319,7 @@ impl Tool for GestureTool {
self.straight = (self.id == "pencil" && !self.smoothing && (ev.mods.shift || ev.mods.alt)) || (self.id == "knife" && ev.mods.alt);
self.unconstrained = self.straight && !ev.mods.shift;
let extend = if self.draws_path() && !self.smoothing { self.find_extend(cx, p) } else { None };
self.extend = extend.map(|(id, at_start, _)| (id, at_start));
self.set_extend(cx, extend);
// The stroke's previews select the path it continues, so the others are kept now.
self.connect_to = match self.extend {
Some((id, _)) if self.id == "pencil" => cx.selection.objects.iter().copied().filter(|o| *o != id).collect(),
@@ -1336,16 +1377,20 @@ impl Tool for GestureTool {
return if std::mem::take(&mut self.began) { vec![Action::Cancel] } else { vec![] };
}
self.points.truncate(1);
// The line ends at `p`, where its end snaps.
end = p;
} else if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) || self.points.len() >= crate::MAX_STROKE_SAMPLES {
// A release here keeps the last point unless it snaps (`Up`), so the ring
// follows the point `finish` will test.
let kept = self.points.last().copied().filter(|l| l.distance(p) < cx.tol(1.0) && !self.snaps());
let release_end = kept.unwrap_or(end);
self.near_start = self.release_closes_near_start(cx, release_end);
self.connects = self.connects_at(cx, release_end).is_some();
return vec![];
}
self.push(p, ev);
self.near_start = self.release_closes_near_start(cx, end);
self.connects = self.connects_at(cx, end).is_some();
if !self.draws_path() || self.smoothing {
return vec![];
}
@@ -1406,11 +1451,13 @@ impl Tool for GestureTool {
self.active = false;
self.began = false;
self.near_start = false;
self.connects = false;
self.smoothing = false;
self.straight = false;
self.unconstrained = false;
self.clear();
self.extend = None;
self.close_to = None;
self.connect_to.clear();
self.redraw = None;
out
@@ -1440,6 +1487,7 @@ impl Tool for GestureTool {
self.active = false;
self.began = false;
self.near_start = false;
self.connects = false;
self.smoothing = false;
self.straight = false;
self.unconstrained = false;
@@ -1470,6 +1518,7 @@ impl Tool for GestureTool {
self.snap.clear();
self.active = false;
self.near_start = false;
self.connects = false;
self.began = false;
self.smoothing = false;
self.straight = false;
@@ -1557,6 +1606,11 @@ impl Tool for GestureTool {
if !self.active && self.straight_at_press(m) {
return if self.find_extend(cx, p).is_some() { Cursor::PencilContinue } else { Cursor::PencilStraight };
}
// A continuing Pencil stroke that a release would connect to another selected path's open
// end (`finish`) shows that it continues on; `connects` tests the point the release keeps.
if self.id == "pencil" && self.active && self.connects {
return Cursor::PencilContinue;
}
// Alt held at the release closes the path (`finish`).
// A straight line stays open, so its release has nothing to close, and before a press Alt
// gives the Pencil a straight line.
@@ -1576,9 +1630,12 @@ impl Tool for GestureTool {
"smooth" => smooth_cursor(cx),
"pathEraser" => Cursor::PathEraser,
"join" => Cursor::Join,
// Before a press over an open end of a selected path the Pencil shows that the stroke
// continues it (`PointerKind::Down`).
_ if !self.active && self.find_extend(cx, p).is_some() => Cursor::PencilContinue,
// Before a press that draws a new path the Pencil shows its star; over a selected path
// that the press continues from an end or redraws (`PointerKind::Down`), it does not.
_ if !self.active && self.find_extend(cx, p).is_none() && self.find_redraw(cx, p).is_none() => Cursor::PencilNew,
// that the press redraws (`PointerKind::Down`), it does not.
_ if !self.active && self.find_redraw(cx, p).is_none() => Cursor::PencilNew,
_ => Cursor::Pencil,
}
}
+105 -9
View File
@@ -793,7 +793,7 @@ fn pencil_shows_the_star_before_a_press_that_draws_a_new_path() {
}
}
let mut t = GestureTool::new("pencil");
assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::Pencil, "over an end the stroke continues");
assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::PencilContinue, "over an end the stroke continues");
assert_eq!(t.cursor(&cx, middle, Mods::default()), Cursor::Pencil, "over the path the stroke redraws");
assert_eq!(t.cursor(&cx, away, Mods::default()), Cursor::PencilNew, "away from the path");
// While a stroke is drawn, the Pencil's own cursor.
@@ -838,7 +838,7 @@ fn pencil_shift_line_continues_a_selected_path_from_its_end() {
}
/// Before a press over an end of a selected open path, the Pencil shows its continuation cursor
/// exactly when the press would draw a straight line that continues the path; away from the
/// exactly when the press continues the path, freehand or with a straight line; away from the
/// ends, with nothing selected, with Edit Selected Paths off, once the press is down and with
/// the Paintbrush, it does not.
#[test]
@@ -859,12 +859,9 @@ fn pencil_shows_the_continuation_cursor_over_an_end_a_line_continues() {
let shift = Mods { shift: true, ..Default::default() };
let alt = Mods { alt: true, ..Default::default() };
let shift_alt = Mods { shift: true, alt: true, ..Default::default() };
// The release's stroke: a straight line that continues the path.
let continues = |acts: &[Vec<Action>]| {
acts.last()
.and_then(|a| a.first())
.is_some_and(|a| matches!(a, Action::Preview(_, v) if v.get("extend").is_some() && v["points"].as_array().is_some_and(|q| q.len() == 2)))
};
// The release's stroke continues the path.
let continues =
|acts: &[Vec<Action>]| acts.last().and_then(|a| a.first()).is_some_and(|a| matches!(a, Action::Preview(_, v) if v.get("extend").is_some()));
for alt_smooth in [false, true] {
for m in [Mods::default(), shift, alt, shift_alt] {
for (at, other_end) in [(Point::new(405.0, 303.0), (500.0, 296.0)), (Point::new(296.0, 302.0), (200.0, 310.0))] {
@@ -880,7 +877,7 @@ fn pencil_shows_the_continuation_cursor_over_an_end_a_line_continues() {
let end = Point::new(405.0, 303.0);
assert_eq!(t.cursor(&cx, end, shift), Cursor::PencilContinue);
assert_eq!(t.cursor(&cx, end, alt), Cursor::PencilContinue, "Alt alone continues at any angle");
assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::Pencil, "without Shift or Alt the stroke is freehand");
assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::PencilContinue, "a freehand stroke continues it too");
assert_eq!(t.cursor(&cx, Point::new(350.0, 360.0), shift), Cursor::PencilStraight, "away from the ends");
// While the line is drawn, the Pencil's own cursor.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, end.x, end.y).with_mods(shift));
@@ -897,6 +894,105 @@ fn pencil_shows_the_continuation_cursor_over_an_end_a_line_continues() {
assert_eq!(Cursor::PencilContinue.precise(), Cursor::Crosshair, "Use Precise Cursors");
}
/// A Pencil stroke continuing a selected path shows the continuation cursor while a release would
/// connect it to an open end of another path selected at the press, and the closing ring while a
/// release would close the path because the stroke ends within Close Paths When Ends Are Within
/// of the path's other end, a straight line too; the release does what the cursor showed.
#[test]
fn a_continued_stroke_shows_where_it_connects_or_closes() {
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let add = |d: &mut vectorcraft_doc::Document, pts: &[(f64, f64)]| {
let id = d.alloc_id();
let mut els = vec![vectorcraft_geom::PathEl::MoveTo(Point::new(pts[0].0, pts[0].1))];
els.extend(pts[1..].iter().map(|&(x, y)| vectorcraft_geom::PathEl::LineTo(Point::new(x, y))));
let pd = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(els));
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, pd, vectorcraft_doc::Appearance::default_art())).unwrap();
id
};
let a = add(&mut d, &[(300.0, 300.0), (400.0, 300.0), (400.0, 400.0)]);
let b = add(&mut d, &[(500.0, 500.0), (600.0, 500.0)]);
let p = paint();
let mut both = Selection::default();
both.set([a, b]);
let mut cx = cx(&d, &both, &p);
cx.smart_guides = false;
cx.snap_to_point = false;
let none = Mods::default();
let shift = Mods { shift: true, ..Default::default() };
// Runs a stroke from `pts[0]`, giving the cursor at its last point before the release and the
// release's `path.freehand` parameters.
let run = |t: &mut GestureTool, pts: &[(f64, f64)], m: Mods| {
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1).with_mods(m));
for &(x, y) in &pts[1..] {
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, y).with_mods(m));
}
let (x, y) = pts[pts.len() - 1];
let shown = t.cursor(&cx, Point::new(x, y), m);
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, y).with_mods(m));
let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") };
(shown, v.clone())
};
let mut t = GestureTool::new("pencil");
// To the other selected path's start: it connects, and doesn't close.
let (shown, v) = run(&mut t, &[(400.0, 400.0), (450.0, 450.0), (498.0, 502.0)], none);
assert_eq!(shown, Cursor::PencilContinue, "near the other path's end");
assert_eq!(
(v["extend"].clone(), v["connect"].clone(), v["closed"].clone()),
(json!({"id": a.0, "end": "end"}), json!({"id": b.0, "end": "start"}), json!(false))
);
// Back near the path's own start (10 px from it): the ring, and the release closes it.
let (shown, v) = run(&mut t, &[(400.0, 400.0), (350.0, 420.0), (290.0, 380.0), (292.0, 306.0)], none);
assert_eq!(shown, Cursor::PencilClose, "near the path's other end");
assert_eq!((v["extend"].clone(), v["closed"].clone()), (json!({"id": a.0, "end": "end"}), json!(true)));
assert!(v.get("connect").is_none(), "{v}");
// Farther than Close Paths When Ends Are Within (15 px): no ring, and it stays open.
let (shown, v) = run(&mut t, &[(400.0, 400.0), (350.0, 420.0), (290.0, 380.0), (290.0, 330.0)], none);
assert_eq!((shown, v["closed"].clone()), (Cursor::Pencil, json!(false)), "away from the other end");
// A straight line from the end to near the start: the ring, and it closes the path.
let (shown, v) = run(&mut t, &[(400.0, 400.0), (350.0, 350.0), (306.0, 306.0)], shift);
assert_eq!(shown, Cursor::PencilClose, "a straight line near the other end");
assert_eq!((v["points"].as_array().map(Vec::len), v["closed"].clone()), (Some(2), json!(true)));
// Close Paths When Ends Are Within off: neither ring nor close.
t.set_option("closeWithin", &json!(0.0));
let (shown, v) = run(&mut t, &[(400.0, 400.0), (350.0, 420.0), (290.0, 380.0), (292.0, 306.0)], none);
assert_eq!((shown, v["closed"].clone()), (Cursor::Pencil, json!(false)), "at 0 px");
}
/// The continuation cursor tests where the release ends, after snapping, not the pointer: here
/// Snap to Grid moves a stroke's end from 13 pt away from another selected path's end (outside Edit
/// Selected Paths' 12 px) to 9 pt away, so the release connects, and the cursor says so.
#[test]
fn the_continuation_cursor_follows_the_snapped_release() {
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let add = |d: &mut vectorcraft_doc::Document, a: (f64, f64), b: (f64, f64)| {
let id = d.alloc_id();
let els = vec![vectorcraft_geom::PathEl::MoveTo(Point::new(a.0, a.1)), vectorcraft_geom::PathEl::LineTo(Point::new(b.0, b.1))];
let pd = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(els));
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, pd, vectorcraft_doc::Appearance::default_art())).unwrap();
id
};
// On the 9 pt grid.
let a = add(&mut d, (306.0, 306.0), (405.0, 405.0));
let b = add(&mut d, (504.0, 504.0), (603.0, 504.0));
let p = paint();
let mut both = Selection::default();
both.set([a, b]);
let mut cx = cx(&d, &both, &p);
cx.smart_guides = false;
cx.snap_to_point = false;
cx.snap_to_grid = true;
let mut t = GestureTool::new("pencil");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 405.0, 405.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 460.0, 450.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 517.0, 504.0));
assert_eq!(t.cursor(&cx, Point::new(517.0, 504.0), Mods::default()), Cursor::PencilContinue);
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 517.0, 504.0));
let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") };
assert_eq!(v["connect"], json!({"id": b.0, "end": "start"}), "{v}");
}
/// #3846: Alt during a Shift line doesn't promise a close the release can't do, and with Alt
/// Toggles to Smooth, Alt pressed after the press doesn't turn the line into a smoothing drag.
#[test]
+4 -1
View File
@@ -673,7 +673,10 @@ mod cursor_tests {
// Away from a selected path the Pencil shows its new-path cursor, drawn with a star; its
// own cursor, for drawing and for a selected path under the pointer, borrows the crosshair.
assert_eq!(cursor_at("pencil", &d, away), Cursor::PencilNew);
assert_eq!(Cursor::PencilNew.precise(), Cursor::Crosshair);
// Its badged cursors too: Use Precise Cursors shows the plain crosshair.
for c in [Cursor::PencilNew, Cursor::PencilClose, Cursor::PencilStraight, Cursor::PencilContinue, Cursor::PaintbrushClose] {
assert_eq!(c.precise(), Cursor::Crosshair, "{c:?}");
}
assert_eq!(Cursor::PencilNew.stand_in(), None);
assert_eq!(Cursor::Pencil.precise(), Cursor::Crosshair);
assert_eq!(Cursor::Pencil.stand_in(), Some(Cursor::Crosshair));
+5 -7
View File
@@ -677,18 +677,16 @@ fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
ink.circle_stroke(p + vec2(8.0, 8.0), 3.0, Stroke::new(3.0, HALO));
ink.circle_stroke(p + vec2(8.0, 8.0), 3.0, Stroke::new(1.2, INK));
}
// The Pencil's crosshair with a short straight line where the closing ring goes.
// The Pencil's crosshair with a short level line where the closing ring goes.
Cursor::PencilStraight => {
crosshair(ink, p);
line(ink, p + vec2(5.0, 12.0), p + vec2(12.0, 5.0));
line(ink, p + vec2(3.55, 8.5), p + vec2(13.45, 8.5));
}
// The straight-line cursor's line leaving a filled square: the end point a line continues from.
// The Pencil's crosshair with a short line rising to the right where the closing ring goes:
// the stroke carries on from the path.
Cursor::PencilContinue => {
crosshair(ink, p);
// The square first, so its halo does not cover the line.
let end = p + vec2(5.0, 12.0);
poly(ink, [(-2.0, -2.0), (2.0, -2.0), (2.0, 2.0), (-2.0, 2.0)].map(|(x, y)| end + vec2(x, y)).to_vec(), INK, INK);
line(ink, end, p + vec2(12.0, 5.0));
line(ink, p + vec2(5.0, 12.0), p + vec2(12.0, 5.0));
}
Cursor::Eyedropper => eyedropper(ink, p),
Cursor::ResizeH => double_arrow(ink, p, vec2(1.0, 0.0)),
+11 -6
View File
@@ -244,7 +244,7 @@ fn the_pencil_new_path_cursor_is_the_crosshair_with_a_star() {
}
}
/// The Pencil's straight-line cursor is the crosshair with a short straight line below right,
/// The Pencil's straight-line cursor is the crosshair with a short level line below right,
/// through the point where the closing cursor's ring is hollow.
#[test]
fn the_pencil_straight_line_cursor_is_the_crosshair_with_a_line() {
@@ -264,15 +264,18 @@ fn the_pencil_straight_line_cursor_is_the_crosshair_with_a_line() {
assert_eq!(at(&cross, 8.5, 8.5)[3], 0, "the crosshair has no badge at {ppp}");
// Along the line, not across it: no ink off its ends.
assert!(!inked(at(&img, 5.0, 5.0)) && !inked(at(&img, 12.0, 12.0)), "a line, not a cross, at {ppp}");
// Level: inked at both ends of it, and not where the continuation cursor's line rises.
assert!(inked(at(&img, 4.5, 8.5)) && inked(at(&img, 12.5, 8.5)), "a level line at {ppp}");
assert!(!inked(at(&img, 6.0, 11.0)) && !inked(at(&img, 11.0, 6.0)), "not a rising one at {ppp}");
assert_ne!(img, ring, "at {ppp}");
assert_eq!(at(&img, -6.0, 0.0), at(&cross, -6.0, 0.0), "the arms are the crosshair's at {ppp}");
}
}
/// The Pencil's continuation cursor is the straight-line cursor with a filled square on the
/// line's lower end, beside the line where the straight-line cursor has no ink.
/// The Pencil's continuation cursor is the crosshair with a short line rising to the right below
/// right, where the straight-line cursor's line is level.
#[test]
fn the_pencil_continuation_cursor_is_the_straight_line_cursor_with_an_end_point() {
fn the_pencil_continuation_cursor_is_the_crosshair_with_a_rising_line() {
for ppp in [1.0, 2.0] {
let cross = rasterize(Cursor::Crosshair, ppp).unwrap();
let img = rasterize(Cursor::PencilContinue, ppp).unwrap();
@@ -285,8 +288,10 @@ fn the_pencil_continuation_cursor_is_the_straight_line_cursor_with_an_end_point(
};
let inked = |p: [u8; 4]| p[3] > 200 && p[0] < 64;
assert!(inked(at(&img, 8.5, 8.5)), "the line crosses the badge's middle at {ppp}");
assert!(inked(at(&img, 3.5, 13.5)), "the end point's square at {ppp}");
assert!(!inked(at(&straight, 3.5, 13.5)), "the straight-line cursor has no end point at {ppp}");
assert!(inked(at(&img, 5.0, 11.0)) && inked(at(&img, 10.0, 6.0)), "the line rises to the right at {ppp}");
assert!(!inked(at(&straight, 6.0, 11.0)), "the straight-line cursor's line is level at {ppp}");
assert!(!inked(at(&img, 4.5, 8.5)) && !inked(at(&img, 12.5, 8.5)), "not a level one at {ppp}");
assert!(!inked(at(&img, 3.5, 13.5)), "no end point square at {ppp}");
assert!(!inked(at(&img, 12.0, 12.0)), "a line, not a cross, at {ppp}");
assert_eq!(at(&img, -6.0, 0.0), at(&cross, -6.0, 0.0), "the arms are the crosshair's at {ppp}");
}
+8 -3
View File
@@ -1,6 +1,6 @@
# VectorCraft MCP server
> **Last updated:** 2026-11-13 · **Change:** Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; no response-shape change. Previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097); existing response shapes retained.
> **Last updated:** 2026-11-14 · **Change:** Pencil continuation, straight-line and closing cursors; scores and estimates unchanged; no response-shape change. Previously: Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; no response-shape change. Previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097); existing response shapes retained.
`vectorcraft-cli mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio
(newline-delimited JSON-RPC 2.0; see [Protocol](#protocol) for version negotiation). Agents use it to
@@ -3224,8 +3224,12 @@ already walks Image Trace. Short agent entry points:
is rejected with an error. The Pencil and the Paintbrush give Fidelity in screen pixels, 0.01 to 100 px, and send it
divided by the zoom (0.0313 to 640) and kept within that range, so a stroke is fitted at the same tolerance on screen
at every zoom. A stroke
that continues a path (`extend`) closes it when it ends within four times the Fidelity of the path's other end, at
least 6 pt and at most 400 pt. The Blob Brush's `path.blob` Fidelity stays in points, 0.01 to 100 pt.
that continues a path (`extend`) closes it when `closed` is true: the Pencil and the Paintbrush send it when the
stroke ends within Close Paths When Ends Are Within of the path's other end (the cursor shows the closing ring there),
a straight Pencil line too, or with Alt held at the release. The stroke's end then joins the other end when it is
within four times the Fidelity of it (at least 6 pt and at most 400 pt), and a straight segment closes the path from
farther away. Without `closed`, the stroke closes the path when it ends within that four-times-Fidelity distance. The
Blob Brush's `path.blob` Fidelity stays in points, 0.01 to 100 pt.
- Variables: `variable.list`, `variable.define {name, kind: text|visibility|image|graph}`, `variable.bind {variable, ids?}`,
`variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeImageDynamic`, `variable.makeGraphDynamic`
(`variable.bind` and `variable.makeTextDynamic` refuse type that already has another text variable and name it),
@@ -11842,6 +11846,7 @@ the error text. The interactive fallback applies only to ordinary File > Save, n
| Date | Change |
|---|---|
| 2026-11-13 | `path.freehand`: `closed`, when given, decides whether a stroke that continues a path (`extend`) closes it; the Pencil and the Paintbrush send it by Close Paths When Ends Are Within. |
| 2026-11-13 | Snap to Point with Smart Guides off draws no Smart Guides labels or lines (#7250). |
| 2026-11-13 | Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097). |
| 2026-11-11 | Document READ_ONLY_LOCATION for saves (#6146); no response-shape change. |
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Pencil continuation, straight-line and closing cursors; scores and estimates unchanged; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
>
> Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369).
>
@@ -52,6 +52,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| Date | Change | Summary |
|---|---|---|
| 2026-11-14 | minor | Layers panel: right-clicking a layer/group/object row shows a context menu with Options…, Duplicate, Delete, Lock/Hide Others, Locate Object, and Collect for Export — matching standard desktop layer panel conventions. Scores unchanged. |
| 2026-11-13 | minor | Pencil cursors: the continuation cursor (a short rising line) shows over an open end of a selected path before any press, freehand too, and while a continuing stroke nears another selected path's open end it connects to; the straight-line cursor is a short level line; a continued path, a straight line too, shows the closing cursor and closes within Close Paths When Ends Are Within of its other end, and with Alt at the release. Scores and estimates unchanged. |
| 2026-11-13 | minor | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/graphic_styles.rs`). Scores and estimates unchanged. |
| 2026-11-13 | minor | Library panels (swatch, Color Book, brush, symbol and graphic style libraries): in a panel made taller the list fills the space above the footer instead of scrolling inside 220 or 280 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/library_panel/tests.rs`). Scores and estimates unchanged. |
| 2026-11-13 | minor | Swatches: in a panel made taller the swatch list fills the space above the footer instead of scrolling inside 150 or 200 pt (#1638). Scores unchanged. |
@@ -178,7 +179,7 @@ tools, never checked side by side) isn't captured row by row.
| Blend spine Anchor Point conversion and handle editing (#2544) | implemented | symmetric handles, independent drag/retraction, visible-handle policy and selection/stacking covered by tool and engine regressions; Windows native canvas checks and screenshots reviewed in PR #2643 |
| Several blend spine points picked and dragged together (#5285) | done | with Direct Selection, Shift-click picks more points of a selected blend's spine or takes one away, as on a path; dragging any picked point moves them all, and the key objects on them, in one undo step (`object.blend.spine.moveAnchor {with}`); a marquee doesn't pick spine points yet |
| Add/Delete/Anchor Point tools; Anchor Point tool drags segments to reshape | done | #2267: a click on a handle end retracts that handle and leaves the anchor a corner with the other one; Shift held at the press on an anchor moves it, handles and all (Shift kept down: at 45° steps) instead of pulling out handles |
| Pencil: Fidelity, Keep selected, Alt to Smooth, edit selected paths, close by proximity | done | Fidelity is in screen pixels, the same at any zoom, and goes down to 0.01 px in the dialog and the tool, which sends `path.freehand` the value in points at the current zoom (#5729; the Smooth tool keeps 0.05 pt); its slider runs across the Tool Options row on a logarithmic rail from 0.01 to 20 px, the field shows hundredths, and the slider's and the field's arrow keys step hundredths up to 0.1 px, tenths up to 1 px and whole pixels above (Shift ×10) (#3328); Alt key toggles to Smooth Tool (#2001); Shift held at the press draws a straight line at 45° steps from the Constrain Angle, and Alt held at the press (with Alt key toggles to Smooth Tool off) a straight line at any angle (#3799), with a straight-line cursor while Shift or Alt is held before the press, and a path-continuation cursor over an end of a selected open path, where the line continues the path; Edit Selected Paths continues a selected path from an end and, from a stroke that starts and ends on a selected path, redraws the section between (the shorter way round a closed path, #2850); with Keep selected (Pencil and Paintbrush, on by default) on, a stroke selects the path it draws or continues and replaces the earlier selection, and off a new stroke leaves nothing selected, the earlier selection included, while a continued or redrawn path is deselected and the rest of the selection stays; a filled Pencil path keeps a stroke of None (#1804, part of #1679); while a freehand stroke is drawn back to within Close Paths When Ends Are Within of its start, the Pencil and the Paintbrush show the closing cursor, and a release there closes the path; a Pencil stroke from an end of one selected open path to an end of another connects the two into one path |
| Pencil: Fidelity, Keep selected, Alt to Smooth, edit selected paths, close by proximity | done | Fidelity is in screen pixels, the same at any zoom, and goes down to 0.01 px in the dialog and the tool, which sends `path.freehand` the value in points at the current zoom (#5729; the Smooth tool keeps 0.05 pt); its slider runs across the Tool Options row on a logarithmic rail from 0.01 to 20 px, the field shows hundredths, and the slider's and the field's arrow keys step hundredths up to 0.1 px, tenths up to 1 px and whole pixels above (Shift ×10) (#3328); Alt key toggles to Smooth Tool (#2001); Shift held at the press draws a straight line at 45° steps from the Constrain Angle, and Alt held at the press (with Alt key toggles to Smooth Tool off) a straight line at any angle (#3799), with a straight-line cursor (a short level line) while Shift or Alt is held before the press, and a path-continuation cursor (a short rising line) over an end of a selected open path, where the stroke continues the path, freehand or straight, and while a continuing stroke ends near an open end of another selected path it connects to; Edit Selected Paths continues a selected path from an end and, from a stroke that starts and ends on a selected path, redraws the section between (the shorter way round a closed path, #2850); with Keep selected (Pencil and Paintbrush, on by default) on, a stroke selects the path it draws or continues and replaces the earlier selection, and off a new stroke leaves nothing selected, the earlier selection included, while a continued or redrawn path is deselected and the rest of the selection stays; a filled Pencil path keeps a stroke of None (#1804, part of #1679); while a freehand stroke is drawn back to within Close Paths When Ends Are Within of its start, or a stroke continuing a selected path (a straight line too) to within it of that path's other end, the Pencil and the Paintbrush show the closing cursor, and a release there closes the path; a Pencil stroke from an end of one selected open path to an end of another connects the two into one path |
| Drop Shadow, Outer Glow and Inner Glow: Opacity, the offsets, Blur and Darkness have spinner arrows that step by one (a unit for distances), repeat when held and stop at the fields' ends (#5367, #5368) | done | dialogs::effect test drop_shadow_and_outer_glow_fields_have_spinner_arrows; other effect dialogs' number fields still have none |
| Effect dialogs' colour fields: a swatch beside the hex follows typed values, and a double-click opens the Color Picker over the dialog, whose OK fills it and Cancel comes back unchanged (#5365) | done | dialogs::effect tests outer_glow_colour_swatch_opens_the_color_picker and drop_shadow_colour_swatch_opens_the_color_picker (Drop Shadow's Color, #5364); the picker previews nothing itself, the effect dialog previews the returned colour |
| Join tool scrubbing, Average, Join | done | Join merges the direct-selected anchors lying on one another along a path, as after Average, into one (#1533); the Join tool trims two crossing open paths whose overhanging ends it scrubs over and joins them at the crossing, instead of drawing a segment between their ends (#5844) |