Fix #3799: Alt held at the press draws a straight Pencil line at any angle

With Alt key toggles to Smooth Tool off, holding Alt when a Pencil drag
starts now draws one straight segment from the press point to the
pointer, at the angle the pointer takes, as Shift does at 45° steps.
Pressed on an end of a selected open path, the line continues the path
from that end. Both ends snap to Smart Guides as a stroke's ends do, and
the line stays open when Alt is still held at the release. Shift and Alt
together keep the 45° steps.

Before, a drag started and released with Alt held drew a closed freehand
path. Alt pressed after the drag has started still closes the path at
the release, and with Alt key toggles to Smooth Tool on, Alt still switches
to the Smooth tool. Before a Pencil press, Alt no longer shows the closing
cursor.

Docs: docs/target-app-parity.md, docs/ui-parity.md.
This commit is contained in:
dotCooCoo
2026-10-24 17:29:32 +07:00
committed by bflatastic
parent 23c0e8c501
commit 4c5c54ccbc
7 changed files with 176 additions and 26 deletions
+2 -2
View File
@@ -530,13 +530,13 @@ look = "Crosshair with a minus badge."
[[cursor]]
id = "Pencil"
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."]
shown_when = ["The Pencil tool: draws a freeform path; starting near the end of a selected open path continues it; Shift or Alt held at the press draws a straight line; 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."]
shown_when = ["The Pencil tool with Alt held 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."
+140 -16
View File
@@ -4,7 +4,8 @@
//! Pencil and Paintbrush preview the fitted path live (`path.freehand` re-applied on the snapshot)
//! and commit on release; Alt on release (or ending near the start) closes the path, and starting
//! near an end of a selected open path continues it. Shift held at the press draws a straight
//! Pencil line at 45° steps from the Constrain Angle instead. The Pencil's new path starts and ends on
//! Pencil line at 45° steps from the Constrain Angle instead, and Alt held at the press (with Alt
//! key toggles to Smooth Tool off) draws a straight line at any angle. The Pencil's new path starts and ends on
//! Smart Guides ([`DrawSnap`]: an anchor, a path, in line with the art), hovering too; the points
//! between follow the hand. The Paintbrush records the pen pressure of each point (a stroke a pen
//! pressed less than fully on sends `[x, y, pressure]` points) for pressure-sensitive brushes. The
@@ -48,9 +49,12 @@ pub struct GestureTool {
pub alt_smooth: bool,
/// Active drag is smoothing with the Pencil tool.
smoothing: bool,
/// Active Pencil drag draws a straight line (Shift held at the press): the stroke is the
/// press point and the pointer constrained to 45° steps from it.
/// Active Pencil drag draws a straight line (Shift or Alt held at the press): the stroke is
/// the press point and the pointer.
straight: bool,
/// The straight line is Alt's: the pointer is not constrained to 45° steps, and the line's end
/// snaps to Smart Guides as a stroke's end does.
unconstrained: bool,
/// Where the pointer was for each Blob Brush point dragged since Shift went down: the last
/// `shift_raw.len()` of `points`.
shift_raw: Vec<Point>,
@@ -99,6 +103,7 @@ impl GestureTool {
alt_smooth: false,
smoothing: false,
straight: false,
unconstrained: false,
shift_raw: vec![],
shift_next: None,
shift_line: None,
@@ -140,6 +145,9 @@ impl GestureTool {
/// line. Without Shift the stroke takes `p`.
fn constrained(&mut self, cx: &ToolContext, p: Point, m: Mods) -> Point {
self.shift_next = None;
if self.straight && self.unconstrained {
return p;
}
if self.straight
&& let Some(&start) = self.points.first()
{
@@ -314,7 +322,10 @@ impl Tool for GestureTool {
self.active = true;
self.began = false;
self.smoothing = self.id == "pencil" && self.alt_smooth && ev.mods.alt;
self.straight = self.id == "pencil" && !self.smoothing && ev.mods.shift;
// Alt draws a straight line only while it doesn't lend the Smooth tool; with Shift
// too, the line keeps Shift's 45° steps.
self.straight = self.id == "pencil" && !self.smoothing && (ev.mods.shift || 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.redraw = if self.draws_path() && !self.smoothing && self.extend.is_none() { self.find_redraw(cx, p) } else { None };
@@ -343,9 +354,17 @@ impl Tool for GestureTool {
// Where the stroke would end: shown, taken on release.
self.snap.drag(cx, p, None);
}
let p = if self.straight && self.unconstrained && self.snaps() {
// Alt's line ends where a stroke would: shown, taken on release.
self.snap.drag(cx, p, None)
} else {
p
};
if self.straight {
// The press's Smart Guides would stay on screen: the line's end doesn't snap.
self.snap.clear();
if !self.unconstrained {
// The press's Smart Guides would stay on screen: the line's end doesn't snap.
self.snap.clear();
}
// The line runs from the press point to the pointer, wherever the drag went.
if self.points.first().is_some_and(|s| s.distance(p) < cx.tol(1.0)) {
// Back at the press point there is no line: the one shown goes.
@@ -376,12 +395,12 @@ impl Tool for GestureTool {
return vec![];
}
let p = self.constrained(cx, p, ev.mods);
let end = if self.snaps() && !self.smoothing && !self.straight { self.snap.drag(cx, p, None) } else { p };
let end = if self.snaps() && !self.smoothing && (!self.straight || self.unconstrained) { self.snap.drag(cx, p, None) } else { p };
self.snap.clear();
let mut cancel = false;
if self.straight {
self.points.truncate(1);
if self.points.first().is_some_and(|s| s.distance(p) >= cx.tol(1.0)) {
if self.points.first().is_some_and(|s| s.distance(end) >= cx.tol(1.0)) {
self.push(end, ev.pressure);
} else {
cancel = self.began;
@@ -401,6 +420,7 @@ impl Tool for GestureTool {
self.began = false;
self.smoothing = false;
self.straight = false;
self.unconstrained = false;
self.clear();
self.extend = None;
self.redraw = None;
@@ -422,6 +442,7 @@ impl Tool for GestureTool {
self.began = false;
self.smoothing = false;
self.straight = false;
self.unconstrained = false;
self.clear();
if began { vec![Action::Cancel] } else { vec![] }
}
@@ -445,6 +466,7 @@ impl Tool for GestureTool {
self.active = false;
self.began = false;
self.straight = false;
self.unconstrained = false;
self.clear();
if began { stale.into_iter().chain([Action::Commit]).collect() } else { vec![] }
}
@@ -468,8 +490,9 @@ impl Tool for GestureTool {
return Cursor::Smooth;
}
// Alt held at the release closes the path (`finish`).
// A straight line stays open, so its release has nothing to close.
if self.draws_path() && !self.alt_smooth && m.alt && !self.straight {
// 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") {
return if self.id == "paintbrush" { Cursor::PaintbrushClose } else { Cursor::PencilClose };
}
match self.id {
@@ -630,9 +653,9 @@ 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.
/// Holding Alt shows the Paintbrush's closing cursor before the press, and the Pencil's and the
/// Paintbrush's while drawing (before a press, Alt gives the Pencil a straight line); 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();
@@ -646,7 +669,7 @@ mod tests {
{
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}");
assert_eq!(t.cursor(&cx, at, alt), if id == "pencil" { own } else { 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));
@@ -892,6 +915,106 @@ mod tests {
}
}
/// #3799: Alt held at the press draws a straight Pencil line at any angle from the press point
/// to the pointer, previewed as it is dragged, and left open by a release with Alt still held;
/// with Shift too it keeps Shift's 45° steps. Alt pressed after the press keeps the stroke
/// freehand and closes it at the release, and the Paintbrush keeps following the hand.
#[test]
fn pencil_alt_at_the_press_draws_a_straight_line_at_any_angle() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
let alt = Mods { alt: true, ..Default::default() };
let pts = |acts: &[Vec<Action>]| -> Vec<Vec<(f64, f64)>> {
acts.iter()
.filter_map(|a| a.iter().find_map(|a| if let Action::Preview(_, v) = a { Some(v["points"].clone()) } else { None }))
.map(|v| v.as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect())
.collect()
};
let wavy = [(0.0, 0.0), (30.0, -10.0), (50.0, -60.0), (100.0, -84.0)];
let mut t = GestureTool::new("pencil");
assert_eq!(t.cursor(&cx, Point::new(0.0, 0.0), alt), Cursor::Pencil, "before the press");
let acts = drag_held(&mut t, &cx, &wavy, alt);
let all = pts(&acts);
assert_eq!(all.len(), 4, "{all:?}");
assert!(all.iter().all(|v| v.len() == 2 && v[0] == (0.0, 0.0)), "{all:?}");
assert_eq!(all[1][1], (50.0, -60.0), "the preview follows the pointer at its own angle");
assert_eq!(all[3][1], (100.0, -84.0), "{all:?}");
let last = acts.last().unwrap();
assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == false) && last[1] == Action::Commit, "{last:?}");
// While the line is drawn, Alt shows no closing ring.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(alt));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 40.0, 10.0).with_mods(alt));
assert_eq!(t.cursor(&cx, Point::new(40.0, 10.0), alt), Cursor::Pencil);
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 40.0, 10.0).with_mods(alt));
// Shift with Alt: 45° steps.
let all = pts(&drag_held(&mut t, &cx, &wavy, Mods { shift: true, alt: true, ..Default::default() }));
let end = *all.last().unwrap().last().unwrap();
assert!(((-end.1).atan2(end.0).to_degrees() - 45.0).abs() < 1e-9, "{end:?}");
// Alt pressed after the press: freehand, closed at the release.
let acts = drag(&mut t, &cx, &wavy, alt);
assert_eq!(pts(&acts).last().unwrap().len(), 4, "{acts:?}");
assert!(matches!(&acts.last().unwrap()[0], Action::Preview(_, v) if v["closed"] == true), "{acts:?}");
// The Paintbrush with Alt held follows the hand and closes.
let mut b = GestureTool::new("paintbrush");
let acts = drag_held(&mut b, &cx, &wavy, alt);
assert_eq!(pts(&acts).last().unwrap().len(), 4, "{acts:?}");
assert!(matches!(&acts.last().unwrap()[0], Action::Preview(_, v) if v["closed"] == true), "{acts:?}");
}
/// #3799: with Alt key toggles to Smooth Tool on, Alt held at the press smooths as before and
/// draws no line.
#[test]
fn pencil_alt_with_alt_to_smooth_on_smooths_instead_of_drawing_a_line() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.set([id]);
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
let alt = Mods { alt: true, ..Default::default() };
let mut t = GestureTool::new("pencil");
t.set_option("altSmooth", &json!(true));
let acts = drag_held(&mut t, &cx, &[(50.0, 150.0), (150.0, 150.0), (250.0, 160.0)], alt);
assert!(acts[..acts.len() - 1].iter().all(|a| a.is_empty()), "{acts:?}");
assert!(matches!(&acts.last().unwrap()[0], Action::Exec(c, v) if c == "path.smoothRegion" && v["points"].as_array().unwrap().len() == 3));
}
/// #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]
fn pencil_alt_line_snaps_its_ends_and_continues_a_selected_path() {
let (mut d, _) = doc_with_rect();
let alt = Mods { alt: true, ..Default::default() };
let p = paint();
{
let s = Selection::default();
let cx = cx(&d, &s, &p);
let mut t = GestureTool::new("pencil");
let acts = drag_held(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0), (198.0, 203.0)], alt);
let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") };
assert_eq!(v["points"], json!([[100.0, 100.0], [200.0, 200.0]]));
}
let l = 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(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap();
let mut s = Selection::default();
s.set([id]);
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
let mut t = GestureTool::new("pencil");
let acts = drag_held(&mut t, &cx, &[(405.0, 303.0), (450.0, 340.0), (470.0, 330.0)], alt);
let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") };
assert_eq!(v["extend"], json!({"id": id.0, "end": "end"}));
assert_eq!(v["points"], json!([[400.0, 300.0], [470.0, 330.0]]));
}
#[test]
fn pencil_alt_closes_and_continues_selected_path() {
let (d, id) = doc_with_rect();
@@ -1024,9 +1147,10 @@ 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::PencilClose);
// Before a press, Alt gives a straight line rather than a closed path.
assert_eq!(t.cursor(&cx, Point::new(50.0, 50.0), alt_mod), Cursor::Pencil);
// Alt-drag draws a closed pencil path.
// A drag released with Alt held draws a closed pencil path.
let empty_sel = Selection::default();
let cx_empty = crate::testutil::cx(&d, &empty_sel, &p);
let acts = drag(&mut t, &cx_empty, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], alt_mod);
+2 -2
View File
@@ -574,8 +574,8 @@ 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.
/// The Pencil tool with Alt held while drawing and Alt key toggles to Smooth Tool off: releasing
/// the mouse button with Alt still held closes the path. A crosshair with a ring.
PencilClose,
/// The Paintbrush tool.
Paintbrush,