Paintbrush: Alt held at the press smooths a selected path

With a path selected that the Smooth tool can change, Alt held at a
Paintbrush press now switches the drag to the Smooth tool: it smooths the
selected paths under the drag with the Smooth tool's radius and Smooth Tool
Options' Fidelity, and the cursor shows the Smooth tool before the press.
Alt pressed after a stroke begins still closes the path on release. With no
such path selected (nothing, only text or an image, or only a two-point
line), Alt at the press draws the closed stroke as before.

The Paintbrush no longer reads the Pencil's "Alt key toggles to Smooth Tool"
setting: its Alt close and Alt smoothing do not depend on it.
This commit is contained in:
dotCooCoo
2026-10-31 06:11:00 +07:00
committed by bflatastic
parent a072cf16be
commit 51b6bbdd17
5 changed files with 126 additions and 28 deletions
+2 -2
View File
@@ -112,10 +112,10 @@ fn alt_smooth_fidelity(s: &mut Session) -> Option<f64> {
})
}
/// Alt switches Pencil and Blob Brush to Smooth, using Smooth Tool Options' stored Fidelity.
/// Alt switches Pencil, Paintbrush and Blob Brush to Smooth, using Smooth Tool Options' stored Fidelity.
#[test]
fn alt_smoothing_takes_the_smooth_tools_fidelity() {
for id in ["pencil", "blobBrush"] {
for id in ["pencil", "paintbrush", "blobBrush"] {
let mut s = session();
s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
select(&mut s, "smooth");
+4 -4
View File
@@ -220,8 +220,8 @@ impl Session {
}
}
}
// Alt switches the Pencil or Blob Brush to Smooth, using Smooth Tool Options' Fidelity.
if matches!(t.id(), "pencil" | "blobBrush")
// Alt switches the Pencil, Paintbrush or Blob Brush to Smooth, using Smooth Tool Options' Fidelity.
if matches!(t.id(), "pencil" | "paintbrush" | "blobBrush")
&& let Some(v) = prefs.tool_settings.get("smooth").and_then(|s| s.get("fidelity"))
{
t.set_option("smoothFidelity", v);
@@ -633,8 +633,8 @@ impl Session {
/// Set options of tool `id` (default: the active tool). Another tool's persistent options are
/// stored for when it is chosen, and the active tool takes those it shares with it (the
/// Liquify tools' Global Brush Dimensions, the Smooth tool's Fidelity for the Pencil's and
/// Blob Brush's Alt smoothing). Returns the tool's options.
/// Liquify tools' Global Brush Dimensions, the Smooth tool's Fidelity for Pencil, Paintbrush
/// and Blob Brush Alt smoothing). Returns the tool's options.
pub fn set_tool_options(&mut self, id: Option<&str>, values: &Map<String, Value>) -> Value {
let active = id.is_none_or(|id| id == self.tool.id());
self.tool_set_since_reset |= active;
+40 -14
View File
@@ -14,6 +14,8 @@
//! collect the drag polyline (shown as an overlay) and run one command on release. With Shift held, the Blob Brush's points stay on the horizontal or
//! vertical line through the press point. Alt held at the press, or before the Blob Brush's drag
//! has moved, switches the Blob Brush to the Smooth tool: the drag smooths the selected paths.
//! Alt held at a Paintbrush press with a selected path also smooths; Alt pressed after its stroke
//! begins closes the path on release.
//! Alt held at the Knife's press makes it cut along the straight line from the press point to the
//! pointer, even if Alt is released during the drag. Shift on each drag or release constrains that
//! line to 45° steps from the canvas axes.
@@ -26,8 +28,8 @@ use super::{FEEDBACK, eraser_area, points_json, polyline};
use crate::guides::DrawSnap;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// How far from its drag the Smooth tool smooths, in screen pixels. The Pencil uses the same
/// radius while Alt switches it to the Smooth tool.
/// How far from its drag the Smooth tool smooths, in screen pixels. Alt smoothing with the
/// Pencil, Paintbrush or Blob Brush uses the same radius.
const SMOOTH_RADIUS: f64 = 12.0;
/// The finest Fidelity (screen pixels) a Pencil or Paintbrush stroke is fitted at while it is drawn:
@@ -79,7 +81,8 @@ 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, off by default).
/// instead of closing the path (Pencil only, off by default). Paintbrush Alt smoothing with a
/// selected path and Blob Brush Alt smoothing do not use this toggle.
pub alt_smooth: bool,
/// "Live preview" (Pencil only, on by default): the stroke shows with its fill and stroke as it
/// is drawn. Off, only a thin line follows the pen while drawing, and the path takes its fill
@@ -89,10 +92,10 @@ pub struct GestureTool {
/// preview shows the points joined by straight segments and the release fits them (Pencil
/// only, on by default).
pub live_fit: bool,
/// The Smooth tool's Fidelity, used when Alt switches the Pencil or Blob Brush to Smooth.
/// The Smooth tool's Fidelity, used when Alt switches the Pencil, Paintbrush or Blob Brush to Smooth.
/// The engine sets it from Smooth Tool Options (`smoothFidelity`).
smooth_fidelity: f64,
/// Active drag is smoothing with the Pencil or the Blob Brush (Alt held).
/// Active drag is smoothing with the Pencil, Paintbrush or Blob Brush (Alt held).
smoothing: bool,
/// The pointer has moved from the press point, by any distance (a move too short to add a
/// point counts).
@@ -126,6 +129,22 @@ fn redraw_targets(cx: &ToolContext, id: NodeId) -> Vec<NodeId> {
}
}
/// Whether the selection contains a path the Smooth command can act on.
fn has_selected_smooth_path(cx: &ToolContext) -> bool {
let editable = cx.doc.editable();
cx.selection.objects.iter().filter_map(|id| cx.doc.node(*id)).any(|n| {
let mut found = false;
n.walk(&mut |child| {
if matches!(&child.kind, NodeKind::Path { guide: false, path, .. } if path.control_bounds().is_some() && path.subpaths.iter().any(|sp| sp.anchors.len() >= 3))
&& editable(child.id)
{
found = true;
}
});
found
})
}
impl GestureTool {
pub fn new(id: &str) -> Self {
let id: &'static str = match id {
@@ -402,7 +421,7 @@ impl GestureTool {
// Close Paths When Ends Are Within is off at 0 pixels.
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.alt_smooth) || (self.extend.is_none() && near));
let closed = !self.straight && ((m.alt && (self.id == "paintbrush" || !self.alt_smooth)) || (self.extend.is_none() && near));
let mut params = self.freehand_params(closed);
// A Pencil stroke that continues a selected path and ends on an open end of another
// path selected at the press connects the two.
@@ -464,9 +483,12 @@ impl Tool for GestureTool {
self.active = true;
self.began = false;
self.near_start = false;
// Alt switches the Pencil to the Smooth tool while Alt key toggles to Smooth Tool is on, and always
// switches the Blob Brush.
self.smoothing = ev.mods.alt && (self.id == "blobBrush" || (self.id == "pencil" && self.alt_smooth));
// 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
&& (self.id == "blobBrush"
|| (self.id == "pencil" && self.alt_smooth)
|| (self.id == "paintbrush" && has_selected_smooth_path(cx)));
// Alt draws a straight line only while it doesn't lend the Smooth tool; with Shift
// too, the line keeps Shift's 45° steps. Alt makes the Knife cut along a straight line.
self.straight = (self.id == "pencil" && !self.smoothing && (ev.mods.shift || ev.mods.alt)) || (self.id == "knife" && ev.mods.alt);
@@ -694,9 +716,13 @@ impl Tool for GestureTool {
o
}
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
// A drag that smooths, or Alt before a Pencil or Blob Brush 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) || self.blob_alt_smooths(m) {
// An active smoothing drag, or Alt before a press that would switch the Pencil, Paintbrush
// or Blob Brush to Smooth. A Paintbrush stroke already drawing keeps drawing with Alt.
if self.smoothing
|| (self.id == "pencil" && self.alt_smooth && m.alt && !self.began)
|| (!self.active && self.id == "paintbrush" && m.alt && has_selected_smooth_path(cx))
|| self.blob_alt_smooths(m)
{
return Cursor::Smooth;
}
// Before a press that would draw a straight line: over an end of a selected open path,
@@ -707,7 +733,7 @@ impl Tool for GestureTool {
// 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.
if self.draws_path() && !self.alt_smooth && m.alt && !self.straight && (self.active || self.id != "pencil") {
if self.draws_path() && (self.id == "paintbrush" || !self.alt_smooth) && m.alt && !self.straight && (self.active || self.id != "pencil") {
return if self.id == "paintbrush" { Cursor::PaintbrushClose } else { Cursor::PencilClose };
}
// A release near a freehand stroke's start closes it too (`finish`): the ring shows while
@@ -789,7 +815,7 @@ impl Tool for GestureTool {
"altSmooth" => self.alt_smooth = v.as_bool().unwrap_or(self.alt_smooth),
"livePreview" if self.id == "pencil" => self.live_preview = v.as_bool().unwrap_or(self.live_preview),
"liveFit" if self.id == "pencil" => self.live_fit = v.as_bool().unwrap_or(self.live_fit),
"smoothFidelity" if matches!(self.id, "pencil" | "blobBrush") => {
"smoothFidelity" if matches!(self.id, "pencil" | "paintbrush" | "blobBrush") => {
self.smooth_fidelity = v.as_f64().unwrap_or(self.smooth_fidelity).clamp(super::min_fidelity("smooth"), 100.0)
}
"keepSelected" => self.keep_selected = v.as_bool().unwrap_or(self.keep_selected),
+79 -7
View File
@@ -872,7 +872,7 @@ fn pencil_alt_smooths_within_the_smooth_tools_radius() {
}
}
/// An Alt smoothing drag uses the Smooth tool's Fidelity for both Pencil and Blob Brush.
/// An Alt smoothing drag uses the Smooth tool's Fidelity for Pencil, Paintbrush and Blob Brush.
#[test]
fn alt_smoothing_uses_the_smooth_tools_fidelity() {
let (d, id) = doc_with_rect();
@@ -888,21 +888,93 @@ fn alt_smoothing_uses_the_smooth_tools_fidelity() {
};
let alt = Mods { alt: true, ..Default::default() };
let want = fidelity(&drag(&mut GestureTool::new("smooth"), &cx, &pts, Mods::default()));
for id in ["pencil", "blobBrush"] {
for (id, alt_smooth) in [("pencil", true), ("blobBrush", false), ("paintbrush", false), ("paintbrush", true)] {
let mut t = GestureTool::new(id);
if id == "pencil" {
t.set_option("altSmooth", &json!(true));
}
t.set_option("altSmooth", &json!(alt_smooth));
t.set_option("fidelity", &json!(10.0));
assert_eq!(fidelity(&drag_held(&mut t, &cx, &pts, alt)), want, "{id}");
assert_eq!(fidelity(&drag_held(&mut t, &cx, &pts, alt)), want, "{id} altSmooth={alt_smooth}");
t.set_option("smoothFidelity", &json!(7.5));
assert_eq!(fidelity(&drag_held(&mut t, &cx, &pts, alt)), 7.5, "{id}");
assert_eq!(fidelity(&drag_held(&mut t, &cx, &pts, alt)), 7.5, "{id} altSmooth={alt_smooth}");
// Drawing keeps the tool's own Fidelity; Smooth's setting stays out of its options.
assert_eq!(t.options()["fidelity"], json!(10.0), "{id}");
assert!(t.options().get("smoothFidelity").is_none(), "{id}");
}
}
/// Alt pressed after a Paintbrush stroke begins closes it, and an empty selection still draws.
#[test]
fn paintbrush_alt_after_press_closes_without_smoothing_and_empty_selection_draws() {
let (d, id) = doc_with_rect();
let mut selected = Selection::default();
selected.set([id]);
let empty = Selection::default();
let p = paint();
let alt = Mods { alt: true, ..Default::default() };
let pts = [(50.0, 150.0), (150.0, 150.0), (250.0, 160.0)];
let mut t = GestureTool::new("paintbrush");
let selected_cx = cx(&d, &selected, &p);
t.pointer(&selected_cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1));
t.pointer(&selected_cx, &PointerEvent::new(PointerKind::Drag, pts[1].0, pts[1].1).with_mods(alt));
let last = t.pointer(&selected_cx, &PointerEvent::new(PointerKind::Up, pts[2].0, pts[2].1).with_mods(alt));
assert!(matches!(last.first(), Some(Action::Preview(c, v)) if c == "path.freehand" && v["closed"] == true), "{last:?}");
let cx = cx(&d, &empty, &p);
let last = drag_held(&mut t, &cx, &pts, alt);
assert!(matches!(last.last().and_then(|a| a.first()), Some(Action::Preview(c, v)) if c == "path.freehand" && v["closed"] == true), "{last:?}");
}
/// The Paintbrush shows Smooth before an Alt press only with a selected path.
#[test]
fn paintbrush_alt_cursor_matches_selection_at_press() {
let (d, id) = doc_with_rect();
let mut selected = Selection::default();
selected.set([id]);
let empty = Selection::default();
let p = paint();
let alt = Mods { alt: true, ..Default::default() };
let t = GestureTool::new("paintbrush");
assert_eq!(t.cursor(&cx(&d, &selected, &p), Point::new(50.0, 150.0), alt), Cursor::Smooth);
assert_eq!(t.cursor(&cx(&d, &empty, &p), Point::new(50.0, 150.0), alt), Cursor::PaintbrushClose);
}
/// A selected text object leaves Alt Paintbrush drawing a closed stroke with its close cursor.
#[test]
fn paintbrush_alt_with_only_text_selected_draws_a_closed_stroke() {
let (d, text_id) = doc_with_area_type();
let mut selected = Selection::default();
selected.set([text_id]);
let p = paint();
let cx = cx(&d, &selected, &p);
let alt = Mods { alt: true, ..Default::default() };
let mut t = GestureTool::new("paintbrush");
assert_eq!(t.cursor(&cx, Point::new(50.0, 150.0), alt), Cursor::PaintbrushClose);
let acts = drag_held(&mut t, &cx, &[(50.0, 150.0), (150.0, 150.0), (250.0, 160.0)], alt);
assert!(acts.iter().flatten().any(|a| matches!(a, Action::Preview(c, v) if c == "path.freehand" && v["closed"] == true)), "{acts:?}");
assert!(matches!(acts.last().and_then(|a| a.get(1)), Some(Action::Commit)), "{acts:?}");
}
/// A selected two-anchor line leaves Alt Paintbrush drawing a closed stroke with its close cursor.
#[test]
fn paintbrush_alt_with_only_two_anchor_line_selected_draws_a_closed_stroke() {
let (mut d, _) = doc_with_rect();
let layer = d.layers[0].id;
let id = d.alloc_id();
let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![
vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)),
vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)),
]));
d.insert(Some(layer), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap();
let mut selected = Selection::default();
selected.set([id]);
let p = paint();
let cx = cx(&d, &selected, &p);
let alt = Mods { alt: true, ..Default::default() };
let mut t = GestureTool::new("paintbrush");
assert_eq!(t.cursor(&cx, Point::new(50.0, 150.0), alt), Cursor::PaintbrushClose);
let acts = drag_held(&mut t, &cx, &[(50.0, 150.0), (150.0, 150.0), (250.0, 160.0)], alt);
assert!(acts.iter().flatten().any(|a| matches!(a, Action::Preview(c, v) if c == "path.freehand" && v["closed"] == true)), "{acts:?}");
assert!(matches!(acts.last().and_then(|a| a.get(1)), Some(Action::Commit)), "{acts:?}");
}
/// #3799: Alt's line starts and ends on Smart Guides as a stroke does, and pressed on an end of
/// a selected open path it continues the path from that end to the pointer.
#[test]
+1 -1
View File
@@ -670,7 +670,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
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.
With the Blob Brush, Alt held before the press, or pressed before the drag has moved, switches to the Smooth tool, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists: the cursor shows the Smooth tool, the drag shows a thin line without the length label, Shift does not constrain it, and the release smooths the selected paths under the drag (`path.smoothRegion`) instead of painting; with nothing selected the release changes no path. A stroke that has moved keeps painting when Alt goes down.
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 (before a press and while drawing) shows a closing cursor (a crosshair with a ring), and so does the Pencil with Alt key toggles to Smooth Tool off when Alt is pressed after the drag starts; releasing the mouse button with Alt still held closes the path. A Pencil drag started with Shift or Alt held draws a straight line instead, which stays open.
While Alt is held during a Paintbrush stroke or before a press with no path selected that the Smooth tool can act on, the Paintbrush shows a closing cursor (a crosshair with a ring), and so does the Pencil with Alt key toggles to Smooth Tool off when Alt is pressed after the drag starts; releasing the mouse button with Alt still held closes the path. Before a Paintbrush press with such a path selected, Alt shows the Smooth cursor, and holding Alt at the press smooths that path with the Smooth tool's radius and Smooth Tool Options' Fidelity. A Pencil drag started with Shift or Alt held draws a straight line instead, which stays open.
With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). With Option/Alt held at the press and Alt key toggles to Smooth Tool off, the Pencil draws a straight line at any angle, whose ends snap to Smart Guides, and it stays open when Alt is still held at the release; Shift and Alt together keep the 45° steps (#3799). While Shift or Alt is held before the press, the Pencil shows a straight-line cursor (a crosshair with a short line), and over an end of a selected open path, where the line continues the path, a path-continuation cursor (the same line leaving a small square), as the page describes.
- **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).
- **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. The Flare tool's center drag turns the rays by 0.1 radian at each drag update in which the pointer's document y grows, and back by as much at each one in which it falls, Shift held keeps their turn, and Space held during either of its drags moves the flare. 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).