Pencil and Paintbrush: holding Alt shows the closing loop cursor

Holding Alt while drawing with the Paintbrush, or with the Pencil while
Alt key toggles to Smooth Tool is off, closes the path at the release,
but the cursor did not change. The reference app's Paintbrush shows a
small loop while Alt is held, and its Pencil shows a small circle.

Cursor::PaintbrushClose and Cursor::PencilClose show while Alt is held
with the Paintbrush, and with the Pencil while that option is off,
before the press and while drawing. Both tools still borrow the
crosshair, so the closing cursors are drawn as that crosshair with the
PenClose ring. Use Precise Cursors turns them into the plain crosshair,
as it does PenClose.

With Alt key toggles to Smooth Tool on, the Pencil shows the Smooth
cursor while Alt would smooth: with Alt held before a stroke begins to
draw, and through a smoothing drag even after Alt is let go. Before, it
showed the Smooth cursor whenever Alt was held, also during a stroke
that kept drawing, and not during a smoothing drag once Alt was let go.

Docs: docs/target-app-parity.md, crates/tools/src/cursors.toml.
This commit is contained in:
dotCooCoo
2026-10-21 16:55:17 +07:00
committed by bflatastic
parent bcf8732d67
commit f761ae2585
7 changed files with 143 additions and 5 deletions
+18
View File
@@ -461,6 +461,24 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
}
}
/// With Alt held, the Pencil and the Paintbrush show their closing cursors, and Use Precise Cursors turns those into
/// a crosshair too.
#[test]
fn alt_shows_the_pencil_and_paintbrush_closing_cursors() {
use vectorcraft_tools::{Cursor, Mods};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let at = vectorcraft_geom::Point::new(300.0, 300.0);
let alt = Mods { alt: true, ..Default::default() };
for precise in [false, true] {
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": precise})).unwrap();
for (tool, c) in [("pencil", Cursor::PencilClose), ("paintbrush", Cursor::PaintbrushClose)] {
s.select_tool(tool, ViewInfo::default()).unwrap();
assert_eq!(s.cursor(at, alt, ViewInfo::default()), if precise { Cursor::Crosshair } else { c }, "{tool}, precise {precise}");
}
}
}
/// Every state of the Pen's pointer, and the other drawing pointers, turn into a crosshair; the rest stay.
#[test]
fn precise_cursors_replace_every_pen_state() {
+18 -1
View File
@@ -533,12 +533,26 @@ group = "Drawing and editing tools"
shown_when = ["The Pencil tool: draws a freeform path; starting near the end of a selected open path continues it; Alt while drawing closes the path."]
hotspot = "The lead tip."
[[cursor]]
id = "PencilClose"
group = "Drawing and editing tools"
shown_when = ["The Pencil tool with Alt held, before a press and while drawing, when Alt key toggles to Smooth Tool is off: releasing the mouse button with Alt still held closes the path."]
hotspot = "The lead tip."
look = "The Pencil cursor with a ring badge (closing the loop), like PenClose."
[[cursor]]
id = "Paintbrush"
group = "Drawing and editing tools"
shown_when = ["The Paintbrush tool: draws a brushed freeform path (with pen pressure), continuing a selected path from its end; Alt closes it."]
hotspot = "The bristle tip."
[[cursor]]
id = "PaintbrushClose"
group = "Drawing and editing tools"
shown_when = ["The Paintbrush tool with Alt held, before a press and while drawing: releasing the mouse button with Alt still held closes the path."]
hotspot = "The bristle tip."
look = "The Paintbrush cursor with a ring badge (closing the loop), like PenClose."
[[cursor]]
id = "BlobBrush"
group = "Drawing and editing tools"
@@ -554,7 +568,10 @@ hotspot = "The eraser's centre."
[[cursor]]
id = "Smooth"
group = "Drawing and editing tools"
shown_when = ["The Smooth tool: a drag along a selected path smooths it."]
shown_when = [
"The Smooth tool: a drag along a selected path smooths it.",
"The Pencil tool with Alt key toggles to Smooth Tool on: with Alt held before a stroke begins to draw, and through a drag that smooths.",
]
hotspot = "The tip."
[[cursor]]
+55 -3
View File
@@ -41,7 +41,7 @@ pub struct GestureTool {
/// or merges into (on for the Pencil and Paintbrush, off for the Blob Brush).
pub keep_selected: bool,
/// "Alt key toggles to Smooth Tool": holding Alt with the Pencil tool smooths selected paths
/// instead of closing the path (Pencil only, on by default).
/// instead of closing the path (Pencil only, off by default).
pub alt_smooth: bool,
/// Active drag is smoothing with the Pencil tool.
smoothing: bool,
@@ -404,9 +404,15 @@ impl Tool for GestureTool {
o
}
fn cursor(&self, _cx: &ToolContext, _p: Point, m: Mods) -> Cursor {
if self.id == "pencil" && self.alt_smooth && m.alt {
// A drag that smooths, or Alt before a Pencil stroke has begun to draw (a stroke that
// has begun keeps drawing with Alt).
if self.smoothing || (self.id == "pencil" && self.alt_smooth && m.alt && !self.began) {
return Cursor::Smooth;
}
// Alt held at the release closes the path (`finish`).
if self.draws_path() && !self.alt_smooth && m.alt {
return if self.id == "paintbrush" { Cursor::PaintbrushClose } else { Cursor::PencilClose };
}
match self.id {
"paintbrush" => Cursor::Paintbrush,
"blobBrush" => Cursor::BlobBrush,
@@ -565,6 +571,52 @@ mod tests {
}
}
/// Holding Alt shows the Pencil's and the Paintbrush's closing cursor, before the press and while
/// drawing; a release with Alt still held closes the path, and Alt let go before the release
/// leaves it open.
#[test]
fn alt_shows_the_closing_cursor_and_a_release_with_alt_closes() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let alt = Mods { alt: true, ..Default::default() };
let at = Point::new(10.0, 5.0);
for (id, own, closing, style) in
[("pencil", Cursor::Pencil, Cursor::PencilClose, "pencil"), ("paintbrush", Cursor::Paintbrush, Cursor::PaintbrushClose, "brush")]
{
let mut t = GestureTool::new(id);
assert_eq!(t.cursor(&cx, at, Mods::default()), own, "{id}");
assert_eq!(t.cursor(&cx, at, alt), closing, "{id}");
for alt_at_release in [true, false] {
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0).with_mods(alt));
assert_eq!(t.cursor(&cx, at, alt), closing, "{id} while drawing");
assert_eq!(t.cursor(&cx, at, Mods::default()), own, "{id} while drawing");
let up = Mods { alt: alt_at_release, ..Default::default() };
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 20.0, 0.0).with_mods(up));
assert!(
matches!(&acts[0], Action::Preview(_, v) if v["closed"] == alt_at_release && v["style"] == style),
"{id}, Alt at the release {alt_at_release}: {acts:?}"
);
}
}
// With Alt key toggles to Smooth Tool on, Alt lends the Pencil the Smooth tool instead, until
// a stroke has begun to draw: Alt then neither smooths nor closes, and the cursor stays the Pencil's.
let mut t = GestureTool::new("pencil");
t.set_option("altSmooth", &json!(true));
assert_eq!(t.cursor(&cx, at, alt), Cursor::Smooth);
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0));
assert_eq!(t.cursor(&cx, at, alt), Cursor::Pencil, "a stroke that has begun");
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 20.0, 0.0).with_mods(alt));
assert!(matches!(&acts[0], Action::Preview(_, v) if v["closed"] == false), "{acts:?}");
// A smoothing drag keeps the Smooth cursor when Alt is let go.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(alt));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0).with_mods(alt));
assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::Smooth, "a smoothing drag");
}
/// The Blob Brush has Keep selected and Merge only with selection, both off by default, and its
/// stroke sends both.
#[test]
@@ -773,7 +825,7 @@ mod tests {
// When altSmooth is disabled:
t.set_option("altSmooth", &json!(false));
assert_eq!(t.options()["altSmooth"], json!(false));
assert_eq!(t.cursor(&cx, Point::new(50.0, 50.0), alt_mod), Cursor::Pencil);
assert_eq!(t.cursor(&cx, Point::new(50.0, 50.0), alt_mod), Cursor::PencilClose);
// Alt-drag draws a closed pencil path.
let empty_sel = Selection::default();
+8
View File
@@ -559,8 +559,14 @@ pub enum Cursor {
MagicWand,
/// The Pencil tool.
Pencil,
/// The Pencil tool with Alt held while Alt key toggles to Smooth Tool is off: releasing the
/// mouse button with Alt still held closes the path. A crosshair with a ring.
PencilClose,
/// The Paintbrush tool.
Paintbrush,
/// The Paintbrush tool with Alt held: releasing the mouse button with Alt still held closes
/// the path. A crosshair with a ring.
PaintbrushClose,
/// The Blob Brush tool.
BlobBrush,
/// The Eraser tool.
@@ -671,7 +677,9 @@ impl Cursor {
| Cursor::Lasso
| Cursor::MagicWand
| Cursor::Pencil
| Cursor::PencilClose
| Cursor::Paintbrush
| Cursor::PaintbrushClose
| Cursor::BlobBrush
| Cursor::Eraser
| Cursor::Smooth
+1
View File
@@ -714,6 +714,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
Cursor::Width | Cursor::WidthAdd => C::Crosshair,
Cursor::WidthPoint => C::Move,
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor | Cursor::ShapeBuilder | Cursor::ShapeBuilderErase => C::Crosshair,
Cursor::PencilClose | Cursor::PaintbrushClose => C::Crosshair,
Cursor::TypeVertical => C::VerticalText,
// Not drawn yet: the cursor each borrows.
Cursor::Lasso
+42 -1
View File
@@ -409,7 +409,7 @@ fn magnifier(p: &mut Ink, o: Pos2, out: bool) {
/// Every cursor with a glyph: its own, drawn here, or the one it borrows.
#[cfg(test)]
const GLYPHS: [Cursor; 76] = [
const GLYPHS: [Cursor; 78] = [
Cursor::Hand,
Cursor::HandGrab,
Cursor::ZoomIn,
@@ -463,7 +463,9 @@ const GLYPHS: [Cursor; 76] = [
Cursor::Lasso,
Cursor::MagicWand,
Cursor::Pencil,
Cursor::PencilClose,
Cursor::Paintbrush,
Cursor::PaintbrushClose,
Cursor::BlobBrush,
Cursor::Eraser,
Cursor::Smooth,
@@ -510,6 +512,12 @@ fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
double_arrow(ink, p + vec2(15.0, 18.0), vec2(1.0, 0.0));
}
Cursor::Crosshair => crosshair(ink, p),
// The crosshair the Pencil and the Paintbrush borrow, with the PenClose ring below right.
Cursor::PencilClose | Cursor::PaintbrushClose => {
crosshair(ink, p);
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));
}
Cursor::Eyedropper => eyedropper(ink, p),
Cursor::ResizeH => double_arrow(ink, p, vec2(1.0, 0.0)),
Cursor::ResizeV => double_arrow(ink, p, vec2(0.0, 1.0)),
@@ -848,6 +856,39 @@ mod tests {
}
}
/// The Pencil's and the Paintbrush's closing cursors are the crosshair those tools show, with a
/// ring below right.
#[test]
fn the_closing_cursors_are_the_crosshair_with_a_ring() {
for ppp in [1.0, 2.0] {
let cross = rasterize(Cursor::Crosshair, ppp).unwrap();
for c in [Cursor::PencilClose, Cursor::PaintbrushClose] {
let img = rasterize(c, ppp).unwrap();
assert_eq!(img.hotspot, cross.hotspot, "{c:?} at {ppp}");
let [hx, hy] = img.hotspot.map(usize::from);
let at = |img: &CustomCursorImage, dx: f32, dy: f32| {
let (x, y) = ((hx as f32 + dx * ppp) as usize, (hy as f32 + dy * ppp) as usize);
if x < img.size[0] as usize && y < img.size[1] as usize { pixel(img, x, y) } else { [0; 4] }
};
// Black pixels in the square 4 to 12 points right of and below the hotspot, clear of the arms.
let ring_ink = |img: &CustomCursorImage| {
let steps = (8.0 * ppp) as usize;
(0..steps * steps)
.filter(|i| {
let p = at(img, 4.0 + (i % steps) as f32 / ppp, 4.0 + (i / steps) as f32 / ppp);
p[3] > 200 && p[0] < 64
})
.count()
};
assert!(ring_ink(&img) > 0, "{c:?} has its ring at {ppp}");
assert_eq!(ring_ink(&cross), 0, "the crosshair has no ring at {ppp}");
assert_eq!(at(&img, 8.0, 8.0)[3], 0, "{c:?}'s ring is hollow at {ppp}");
// The arms are the crosshair's.
assert_eq!(at(&img, -6.0, 0.0), at(&cross, -6.0, 0.0), "{c:?} at {ppp}");
}
}
}
#[test]
fn each_bitmap_is_made_once_so_the_os_cursor_is_kept() {
let mut images = Images::default();
+1
View File
@@ -554,6 +554,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; Alt-click or double-click toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners. A filled Pencil path keeps a stroke of None (#1804, part of #1679). The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661).
With the Blob Brush, Shift held during a drag keeps the stroke on the horizontal or vertical line through the press point, whichever is nearer to the pointer (90° steps from the Constrain Angle preference), and the points dragged since Shift went down move to the other line when the pointer crosses to it; the points dragged while Shift is up follow the pointer.
Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open. The curve through the points (`curvature_spline` in `crates/tools/src/draw2.rs`) makes each stretch of three points follow the circle through them: a smooth point's handles lie along that circle's tangent, each `chord / (3 cos²(α/2))` long (α between handle and chord, capped at 90°), and the ends of an open path mirror their neighbour's tangent in their chord, so a J or a U curves evenly, a turn tighter than 30° comes to a point, and uneven spacing doesn't loop; the rubber band, a dragged or inserted point and `path.convertAnchor` to smooth use the same rule, while the Pencil's fitting keeps its own; `tests_draw2.rs` checks that the rubber band is the path the click makes (#1236, in part). The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512).
While Alt is held, the Paintbrush, and the Pencil with Alt key toggles to Smooth Tool off, show a closing cursor (a crosshair with a ring); releasing the mouse button with Alt still held closes the path.
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812).
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).