From 859b3732c1698faba34c8d6fd96c1f2962b31a1e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 1 Nov 2026 06:35:39 +0700 Subject: [PATCH] Fix #4977: Blob Brush and Eraser show their tip's outline at the pointer --- crates/engine/src/tooling.rs | 8 ++- crates/tools/src/context.rs | 3 + crates/tools/src/cursor.rs | 8 +-- crates/tools/src/cursors.toml | 6 +- crates/tools/src/draw2/gesture.rs | 34 +++++++++++- crates/tools/src/draw2/gesture/tests.rs | 73 ++++++++++++++++++++++++- crates/tools/src/lib.rs | 9 ++- crates/ui-egui/src/canvas/cursor.rs | 4 +- crates/ui-egui/src/cursors.rs | 5 ++ docs/ui-parity.md | 4 +- 10 files changed, 139 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 642d9a034..c54bda129 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -327,6 +327,7 @@ impl Session { }, screen: view.screen, plane_widget: self.prefs.perspective_widget.show.then_some(self.prefs.perspective_widget.position), + precise_cursors: self.precise_cursors(), }; let tool = &mut self.tool; match crate::guard::catch_panic(|| f(tool.as_mut(), &cx)) { @@ -619,7 +620,12 @@ impl Session { } None => self.with_tool_cx(view, |t, cx| t.cursor(cx, p, mods)), }; - if self.prefs.use_precise_cursors || self.caps_lock { c.precise() } else { c } + if self.precise_cursors() { c.precise() } else { c } + } + + /// General › Use Precise Cursors is on, or Caps Lock is. + fn precise_cursors(&self) -> bool { + self.prefs.use_precise_cursors || self.caps_lock } pub fn tool_options(&self) -> Value { diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs index 7773762f2..d6860bb7b 100644 --- a/crates/tools/src/context.rs +++ b/crates/tools/src/context.rs @@ -253,6 +253,9 @@ pub struct ToolContext<'a> { pub screen: Option, /// Where the Plane Switching Widget sits (Perspective Grid Options); None while it's hidden. pub plane_widget: Option, + /// General › Use Precise Cursors, or Caps Lock while it is on: the drawing tools' pointers are + /// crosshairs ([`crate::Cursor::precise`]), and the Blob Brush and the Eraser show no tip outline. + pub precise_cursors: bool, } impl ToolContext<'_> { diff --git a/crates/tools/src/cursor.rs b/crates/tools/src/cursor.rs index b34c32309..f7eb200ea 100644 --- a/crates/tools/src/cursor.rs +++ b/crates/tools/src/cursor.rs @@ -125,9 +125,11 @@ pub enum Cursor { /// the path. Also while a stroke is drawn back near its start, where releasing the mouse button /// closes the path. A crosshair with a ring. PaintbrushClose, - /// The Blob Brush tool. + /// The Blob Brush tool: a dot at the brush's centre, the tool drawing the tip's outline round + /// it on the canvas. BlobBrush, - /// The Eraser tool. + /// The Eraser tool: a dot at the tip's centre, the tool drawing the tip's outline round it on + /// the canvas. Eraser, /// The Smooth tool. Smooth, @@ -305,8 +307,6 @@ impl Cursor { | Cursor::MagicWand | Cursor::Pencil | Cursor::Paintbrush - | Cursor::BlobBrush - | Cursor::Eraser | Cursor::Smooth | Cursor::PathEraser | Cursor::Join diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index 7eaefffc9..80e51276b 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -583,14 +583,16 @@ look = "The Paintbrush cursor with a ring badge (closing the loop), like PenClos [[cursor]] id = "BlobBrush" group = "Drawing and editing tools" -shown_when = ["The Blob Brush tool: paints filled shapes that merge with touching ones of the same colour. No brush-size outline shows while hovering."] +shown_when = ["The Blob Brush tool: paints filled shapes that merge with touching ones of the same colour. The tool draws the tip's outline (its size, angle and roundness, at the zoom) round the pointer on the canvas, before and while painting; with precise cursors (Caps Lock) the crosshair shows instead."] hotspot = "The brush centre." +look = "Only a small dot at the centre: the tip outline drawn on the canvas is the rest of the pointer." [[cursor]] id = "Eraser" group = "Drawing and editing tools" -shown_when = ["The Eraser tool: a drag erases the area it covers. No size outline shows while hovering."] +shown_when = ["The Eraser tool: a drag erases the area it covers. The tool draws the tip's outline (its size, angle and roundness, at the zoom) round the pointer on the canvas, before and while erasing; with precise cursors (Caps Lock) the crosshair shows instead."] hotspot = "The eraser's centre." +look = "Only a small dot at the centre, as the Blob Brush's: the tip outline drawn on the canvas is the rest of the pointer." [[cursor]] id = "Smooth" diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index 045a3304e..a3c3cd93e 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -22,9 +22,9 @@ use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind}; -use vectorcraft_geom::Point; +use vectorcraft_geom::{Affine, Point}; -use super::{FEEDBACK, blob_area, eraser_area, points_json, polyline}; +use super::{FEEDBACK, blob_area, blob_tip, eraser_area, eraser_tip, points_json, polyline}; use crate::guides::DrawSnap; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; @@ -116,6 +116,9 @@ pub struct GestureTool { pub close_within: f64, /// Smart Guides for the Pencil's ends. snap: DrawSnap, + /// Where the Blob Brush's or the Eraser's tip outline shows: the pointer over the canvas, + /// unless Cmd/Ctrl lends a selection tool or Alt the Smooth tool. + tip_at: Option, } /// The paths a stroke can redraw for selected object `id`: the path itself, or a compound path's @@ -191,6 +194,7 @@ impl GestureTool { edit_within: 12.0, close_within: 15.0, snap: DrawSnap::default(), + tip_at: None, } } @@ -274,6 +278,22 @@ impl GestureTool { start + vectorcraft_geom::constrain_angle_from(p - start, 180.0, self.shift_line.unwrap_or(cx.constrain_angle)) } + /// The Blob Brush's or the Eraser's tip at the pointer, before and while painting: its outline + /// `size` across at its angle and roundness, at the zoom, black on a white halo so it reads on + /// any art. Precise cursors show only the crosshair, and smoothing shows no tip. + fn tip_outline(&self, cx: &ToolContext) -> Vec { + let Some(at) = self.tip_at.filter(|_| !self.smoothing && !cx.precise_cursors) else { return vec![] }; + let tip = if self.id == "blobBrush" { blob_tip(self.angle, self.roundness) } else { eraser_tip(self.angle, self.roundness) }; + let r = self.size / 2.0; + // A tolerance relative to the radius: four arcs at any size or zoom. + let circle = kurbo::Shape::to_path(&kurbo::Circle::new(Point::ORIGIN, r), 0.1 * r.max(1e-6)); + let path = Affine::translate(at.to_vec2()) * tip * circle; + vec![ + Overlay::Path { path: path.clone(), color: [0xff, 0xff, 0xff], width: 3.0, dashed: false }, + Overlay::Path { path, color: [0, 0, 0], width: 1.0, dashed: false }, + ] + } + /// Forget the stroke. fn clear(&mut self) { self.moved = false; @@ -476,6 +496,9 @@ impl Tool for GestureTool { } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; + if self.varies_size() { + self.tip_at = (!ev.mods.cmd && !self.blob_alt_smooths(ev.mods)).then_some(p); + } match ev.kind { PointerKind::Move if self.snaps() => { self.snap.hover(cx, p, &[], None); @@ -665,8 +688,12 @@ impl Tool for GestureTool { _ => vec![], } } + fn hover_ended(&mut self, _cx: &ToolContext) { + self.tip_at = None; + } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { let began = self.began; + self.tip_at = None; // Drags past the last preview are in the stroke kept, too, and with Live curve fitting // off the stroke kept is fitted. let stale = (began && (self.previewed != self.points.len() || !self.live_fit) && self.draws_path() && !self.smoothing) @@ -685,6 +712,7 @@ impl Tool for GestureTool { let mut o = self.snap.guides().to_vec(); // A path being drawn shows itself, except with the Pencil's Live preview off. if !self.active || self.points.len() < 2 || (self.began && self.previews()) { + o.extend(self.tip_outline(cx)); return o; } // A Blob Brush drag that smooths shows the Smooth tool's thin line. @@ -727,6 +755,8 @@ impl Tool for GestureTool { let length = self.points.windows(2).map(|w| w[0].distance(w[1])).sum(); o.push(Overlay::Measure { p: last + vectorcraft_geom::Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: format!("L: {}", cx.len(length)) }); } + // Over the stroke's footprint, which reaches the tip's edge at the pointer. + o.extend(self.tip_outline(cx)); o } fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index 1d6cc8ace..29fe7614f 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -344,7 +344,8 @@ fn blob_brush_shift_keeps_the_stroke_horizontal_or_vertical() { let (d, _) = doc_with_rect(); let s = Selection::default(); let p = paint(); - let mut cx = cx(&d, &s, &p); + // Precise cursors: no tip outline, only the stroke's own overlay. + let mut cx = ToolContext { precise_cursors: true, ..cx(&d, &s, &p) }; let shift = Mods { shift: true, ..Default::default() }; let mut t = GestureTool::new("blobBrush"); let ev = |k, x, y, m| PointerEvent::new(k, x, y).with_mods(m); @@ -1358,7 +1359,8 @@ fn the_eraser_sends_its_tip_and_shows_the_area_it_covers() { let (d, _) = doc_with_rect(); let s = Selection::default(); let p = paint(); - let cx = cx(&d, &s, &p); + // Precise cursors: no tip outline, only the stroke's own overlay. + let cx = ToolContext { precise_cursors: true, ..cx(&d, &s, &p) }; let mut t = GestureTool::new("eraser"); t.set_option("size", &json!(20)); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); @@ -1438,6 +1440,73 @@ fn the_blob_brush_sends_its_tip_and_shows_the_area_it_covers() { assert!(vectorcraft_geom::Shape::bounding_box(path).height() < 0.1, "{o:?}"); } +/// #4977: the Blob Brush and the Eraser show their tip at the pointer before a press and while +/// painting: its outline at its size, angle and roundness, a black line on a white halo, following +/// `[` / `]` and Tool Options at once. It goes when the hover ends, with precise cursors (Caps +/// Lock), while Cmd/Ctrl lends a selection tool and while Alt lends the Blob Brush the Smooth +/// tool. The other gesture tools show none. +#[test] +fn the_blob_brush_and_the_eraser_show_their_tip_at_the_pointer() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let bounds = |t: &GestureTool, cx: &ToolContext| match &t.overlays(cx)[..] { + [Overlay::Path { path: halo, color: [0xff, 0xff, 0xff], width: 3.0, .. }, Overlay::Path { path, color: [0, 0, 0], width: 1.0, .. }] => { + assert_eq!(halo, path); + Some(vectorcraft_geom::Shape::bounding_box(path)) + } + [] => None, + o => panic!("{o:?}"), + }; + let near = |b: Option, want: (f64, f64, f64, f64)| { + let b = b.unwrap_or_else(|| panic!("no tip")); + assert!([b.x0 - want.0, b.y0 - want.1, b.x1 - want.2, b.y1 - want.3].iter().all(|e| e.abs() < 0.01), "{b:?} vs {want:?}"); + }; + let hover = |t: &mut GestureTool, cx: &ToolContext, m: Mods| t.pointer(cx, &PointerEvent::new(PointerKind::Move, 100.0, 50.0).with_mods(m)); + for id in ["blobBrush", "eraser"] { + let mut t = GestureTool::new(id); + assert_eq!(bounds(&t, &cx), None, "{id}: nothing before the pointer comes"); + hover(&mut t, &cx, Mods::default()); + near(bounds(&t, &cx), (95.0, 45.0, 105.0, 55.0)); + // `]` grows it at once, without a move; Tool Options too. + t.key(&cx, ToolKey::BracketRight, Mods::default()); + near(bounds(&t, &cx), (94.5, 44.5, 105.5, 55.5)); + t.set_option("size", &json!(20)); + near(bounds(&t, &cx), (90.0, 40.0, 110.0, 60.0)); + // Upright and a quarter as wide: 5 pt across, 20 pt tall. + t.set_option("angle", &json!(90)); + t.set_option("roundness", &json!(25)); + near(bounds(&t, &cx), (97.5, 40.0, 102.5, 60.0)); + // It follows the drag, over the stroke's footprint. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 50.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 50.0)); + let o = t.overlays(&cx); + assert!(matches!(&o[o.len() - 2..], [Overlay::Path { width: 3.0, .. }, Overlay::Path { color: [0, 0, 0], width: 1.0, .. }]), "{id}: {o:?}"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0, 50.0)); + let precise = ToolContext { precise_cursors: true, ..crate::testutil::cx(&d, &s, &p) }; + assert_eq!(bounds(&t, &precise), None, "{id}: precise cursors show the crosshair alone"); + t.hover_ended(&cx); + assert_eq!(bounds(&t, &cx), None, "{id}: gone with the hover"); + hover(&mut t, &cx, Mods::default()); + assert!(t.deactivate(&cx).is_empty()); + assert_eq!(bounds(&t, &cx), None, "{id}: gone with the tool"); + } + // Alt before a press lends the Blob Brush the Smooth tool, and Cmd/Ctrl either tool a + // selection tool, whose cursors show instead. + let mut t = GestureTool::new("blobBrush"); + hover(&mut t, &cx, Mods { alt: true, ..Default::default() }); + assert_eq!(bounds(&t, &cx), None); + let mut t = GestureTool::new("eraser"); + hover(&mut t, &cx, Mods { cmd: true, ..Default::default() }); + assert_eq!(bounds(&t, &cx), None); + for id in ["pencil", "paintbrush", "smooth", "knife", "pathEraser", "join"] { + let mut t = GestureTool::new(id); + hover(&mut t, &cx, Mods::default()); + assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { width, .. } if *width == 3.0)), "{id}"); + } +} + #[test] fn smooth_needs_selection_and_brackets_resize() { let (d, _) = doc_with_rect(); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 29bac1173..67d2f5909 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -507,6 +507,7 @@ pub(crate) mod testutil { construction_angles: guides::construction_angles(guides::DEFAULT_CONSTRUCTION_ANGLES), screen: None, plane_widget: Some(Default::default()), + precise_cursors: false, } } } @@ -553,7 +554,13 @@ mod cursor_tests { ] { assert_eq!(cursor_at(id, &d, away), c, "{id}"); assert_eq!(c.precise(), Cursor::Crosshair, "{id}"); - assert_eq!(c.stand_in(), Some(if id == "mesh" { Cursor::PenAdd } else { Cursor::Crosshair }), "{id}"); + // The Blob Brush's and the Eraser's dot is drawn: their tip outline goes round it. + let stand_in = match id { + "mesh" => Some(Cursor::PenAdd), + "blobBrush" | "eraser" => None, + _ => Some(Cursor::Crosshair), + }; + assert_eq!(c.stand_in(), stand_in, "{id}"); } // 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. diff --git a/crates/ui-egui/src/canvas/cursor.rs b/crates/ui-egui/src/canvas/cursor.rs index 85ec1622a..1056e79e7 100644 --- a/crates/ui-egui/src/canvas/cursor.rs +++ b/crates/ui-egui/src/canvas/cursor.rs @@ -120,13 +120,13 @@ pub(super) fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor | Cursor::ShapeBuilder | Cursor::ShapeBuilderErase => C::Crosshair, Cursor::PencilClose | Cursor::PencilStraight | Cursor::PencilContinue | Cursor::PaintbrushClose => C::Crosshair, Cursor::TypeVertical => C::VerticalText, + // The system has no tip outline to go round: its crosshair aims as well. + Cursor::BlobBrush | Cursor::Eraser => C::Crosshair, // Not drawn yet: the cursor each borrows. Cursor::Lasso | Cursor::MagicWand | Cursor::Pencil | Cursor::Paintbrush - | Cursor::BlobBrush - | Cursor::Eraser | Cursor::Smooth | Cursor::PathEraser | Cursor::Join diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index 55312c352..c9a0bf9a9 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -554,6 +554,11 @@ fn glyph(c: Cursor, p: Pos2) -> Option> { double_arrow(ink, p + vec2(15.0, 18.0), vec2(1.0, 0.0)); } Cursor::Crosshair => crosshair(ink, p), + // Only the centre: the tool draws the tip's outline round it, at the zoom. + Cursor::BlobBrush | Cursor::Eraser => { + ink.circle_filled(p, 2.5, HALO); + ink.circle_filled(p, 1.25, INK); + } // The Pencil's crosshair with the Pen's star where the closing ring goes. Cursor::PencilNew => { crosshair(ink, p); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b1a507056..c1feab7e4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Layers panel: a blend's spine row, #4820) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Blob Brush and Eraser show their tip's outline at the pointer, #4977) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -87,6 +87,7 @@ tools, never checked side by side) isn't captured row by row. | Anchor Point and Curvature tools' own cursors | done | the caret on the anchor it converts; the pen nib with a curve badge | | Free Transform's Cmd/Ctrl side-shear cursor | done | two arrows passing along the side (ShearH, ShearV), instead of the resize arrow across it | | Cursors of their own for Lasso, Magic Wand, the freehand tools, Shaper, Live Paint Bucket, Measure, Print Tiling, the symbolism tools, vertical type, Scissors (over a segment and away), Mesh and Puppet Warp | partial | each tool returns its own cursor and Use Precise Cursors turns them into the crosshair; until they're drawn they borrow the crosshair, the I-beam, the Pen's add cursor or not-allowed | +| Blob Brush and Eraser: the tip's outline at the pointer | done | before a press and while painting, the outline of the tip (its size, angle and roundness, at the zoom) follows the pointer, black on a white halo, round a small centre dot; `[` / `]` and Tool Options change it at once; precise cursors (Caps Lock) show only the crosshair; it goes when the pointer leaves the canvas, while Cmd/Ctrl lends a selection tool and while Alt lends the Blob Brush the Smooth tool (#4977); `crates/tools/src/draw2/gesture.rs` and its tests | | Busy cursor during long operations | done | Background open, save and export show the system progress cursor (gap G1) | | Each anchor snaps to Smart Guides and construction angles | done | `DrawSnap` | | Curvature tool: click/drag points, double-click or Alt-click for a corner, temporary Direct Selection, point conversion | partial | circular point markers without direction handles or duplicate markers, Anchor Size and Hide Edges regression-tested (#1236, in part); curve geometry: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), open ends curving in, the same rule for the rubber band, dragged, inserted and converted points (#1236, in part); neighbours of a point dragged on an existing path keep their tangents, and modifiers requested in #975 remain | @@ -388,6 +389,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | Blob Brush and Eraser: the tip's outline (size, angle, roundness, at the zoom) follows the pointer before and while painting, around a small centre dot, updating at once with `[` / `]` and Tool Options; precise cursors (Caps Lock) show only the crosshair (#4977); scores and estimates unchanged (estimated) | | 2026-11-01 | minor | Layers panel: an open blend lists its spine as a `` row below its key objects, and a click selects the blend so Direct Selection edits the spine (#4820); scores and estimates unchanged | | 2026-11-01 | minor | Pen with Cmd/Ctrl held: the lent Selection tool no longer scales the path being drawn by bounding-box handles the canvas hides while its end anchor is selected (a flat path did nothing under a resize pointer); a Cmd drag moves the path instead, and with Direct Selection moves anchors and segments, one undo step each (#4980); scores and estimates unchanged | | 2026-11-01 | minor | Dynamic symbols (#4938): Symbol Options › Symbol Type; per-instance appearance for objects of a dynamic symbol's art; Reset in the Properties panel and the Symbols panel menu; on-canvas Direct Selection of the parts remains |