Merge pull request #3846 from marcoarnulfo/feat/pencil-shift-straight

Pencil: Shift held at the press draws a straight line at 45° steps
This commit is contained in:
BFlat
2026-10-24 03:10:26 +07:00
committed by GitHub
4 changed files with 225 additions and 18 deletions
+25
View File
@@ -116,6 +116,31 @@ fn pencil_alt_closes_path() {
assert!(ps[0].1.is_closed());
}
/// #3799: with Shift held, a wavy Pencil drag draws one straight segment at 45° steps from the
/// Constrain Angle (here 30°: a drag at about 60° comes out at 75°).
#[test]
fn pencil_with_shift_draws_a_straight_line() {
let mut s = session();
s.execute("prefs.set", &json!({"key": "constrainAngle", "value": 30})).unwrap();
let v = view();
let shift = Mods { shift: true, ..Default::default() };
s.select_tool("pencil", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 400.0).with_mods(shift), v).unwrap();
for (x, y) in [(120.0, 380.0), (125.0, 330.0), (160.0, 320.0), (150.0, 313.4)] {
s.pointer(&PointerEvent::new(PointerKind::Drag, x, y).with_mods(shift), v).unwrap();
}
s.pointer(&PointerEvent::new(PointerKind::Up, 150.0, 313.4).with_mods(shift), v).unwrap();
let ps = paths(&s);
assert_eq!(ps.len(), 1);
let sp = &ps[0].1.subpaths[0];
assert_eq!(sp.anchors.len(), 2, "{sp:?}");
assert!(sp.anchors.iter().all(|a| a.h_in == a.p && a.h_out == a.p), "no handles: {sp:?}");
let (a, b) = (sp.anchors[0].p, sp.anchors[1].p);
assert!(a.distance(Point::new(100.0, 400.0)) < 1e-6, "{a:?}");
let angle = (-(b.y - a.y)).atan2(b.x - a.x).to_degrees();
assert!((angle - 75.0).abs() < 1e-6, "{angle}");
}
#[test]
fn pencil_continues_selected_open_path() {
let mut s = session();
+194 -15
View File
@@ -3,7 +3,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. The Pencil's new path starts and ends on
//! 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
//! 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
@@ -45,6 +46,9 @@ 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.
straight: 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>,
@@ -88,6 +92,7 @@ impl GestureTool {
// Off, as Pencil Tool Options ships it: Alt keeps drawing a closed path until it is on.
alt_smooth: false,
smoothing: false,
straight: false,
shift_raw: vec![],
shift_next: None,
merge_only_selection: false,
@@ -128,6 +133,11 @@ 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
&& let Some(&start) = self.points.first()
{
return start + vectorcraft_geom::constrain_angle_from(p - start, 45.0, cx.constrain_angle);
}
let Some(&start) = self.points.first().filter(|_| self.id == "blobBrush" && m.shift) else {
self.shift_raw.clear();
return p;
@@ -176,8 +186,9 @@ impl GestureTool {
}
}
/// The selected open path whose end is near `p` (Edit Selected Paths, off at 0 pixels).
fn find_extend(&self, cx: &ToolContext, p: Point) -> Option<(NodeId, bool)> {
/// The selected open path whose end is near `p` (Edit Selected Paths, off at 0 pixels), and
/// that end.
fn find_extend(&self, cx: &ToolContext, p: Point) -> Option<(NodeId, bool, Point)> {
if self.edit_within <= 0.0 {
return None;
}
@@ -191,10 +202,10 @@ impl GestureTool {
let Some(sp) = pd.subpaths.iter().rev().find(|s| !s.closed && !s.anchors.is_empty()) else { continue };
let (first, last) = (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p);
if p.distance(last) <= tol {
return Some((*id, false));
return Some((*id, false, last));
}
if p.distance(first) <= tol {
return Some((*id, true));
return Some((*id, true, first));
}
}
None
@@ -247,7 +258,8 @@ impl GestureTool {
}
// Close Paths When Ends Are Within is off at 0 pixels.
let near = self.close_within > 0.0 && pts.len() > 2 && pts[0].distance(pts[pts.len() - 1]) <= cx.tol(self.close_within);
let closed = (m.alt && !self.alt_smooth) || (self.extend.is_none() && near);
// 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));
vec![Action::Preview("path.freehand".into(), self.freehand_params(closed)), Action::Commit]
}
"blobBrush" => vec![Action::Exec(
@@ -289,10 +301,15 @@ impl Tool for GestureTool {
self.active = true;
self.began = false;
self.smoothing = self.id == "pencil" && self.alt_smooth && ev.mods.alt;
self.extend = if self.draws_path() && !self.smoothing { self.find_extend(cx, p) } else { None };
self.straight = self.id == "pencil" && !self.smoothing && 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 };
// A path continued starts at its end, one redrawn on it; a new one where it snaps.
let start = if self.snaps() && !self.smoothing && self.extend.is_none() && self.redraw.is_none() {
// A straight line is constrained from the end it continues, so it starts there.
let start = if let Some((_, _, end)) = extend.filter(|_| self.straight) {
end
} else if self.snaps() && !self.smoothing && self.extend.is_none() && self.redraw.is_none() {
self.snap.press(cx, p, &[], None)
} else {
p
@@ -306,14 +323,24 @@ impl Tool for GestureTool {
return vec![];
}
let p = self.constrained(cx, p, ev.mods);
if !self.began && self.id == "pencil" && self.alt_smooth && ev.mods.alt {
if !self.began && !self.straight && self.id == "pencil" && self.alt_smooth && ev.mods.alt {
self.smoothing = true;
}
if self.snaps() && !self.smoothing {
if self.snaps() && !self.smoothing && !self.straight {
// Where the stroke would end: shown, taken on release.
self.snap.drag(cx, p, None);
}
if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) {
if self.straight {
// 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.
self.points.truncate(1);
return if std::mem::take(&mut self.began) { vec![Action::Cancel] } else { vec![] };
}
self.points.truncate(1);
} else if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) {
return vec![];
}
self.push(p, ev.pressure);
@@ -333,9 +360,17 @@ impl Tool for GestureTool {
return vec![];
}
let p = self.constrained(cx, p, ev.mods);
let end = if self.snaps() && !self.smoothing { self.snap.drag(cx, p, None) } else { p };
let end = if self.snaps() && !self.smoothing && !self.straight { self.snap.drag(cx, p, None) } else { p };
self.snap.clear();
if self.points.last().is_some_and(|l| l.distance(p) >= cx.tol(1.0)) {
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)) {
self.push(end, ev.pressure);
} else {
cancel = self.began;
}
} else if self.points.last().is_some_and(|l| l.distance(p) >= cx.tol(1.0)) {
self.push(end, ev.pressure);
} else if self.snaps()
&& !self.smoothing
@@ -345,10 +380,11 @@ impl Tool for GestureTool {
// The last step was where the button went up: it moves where that snaps.
*last = end;
}
let out = self.finish(cx, ev.mods);
let out = if cancel { vec![Action::Cancel] } else { self.finish(cx, ev.mods) };
self.active = false;
self.began = false;
self.smoothing = false;
self.straight = false;
self.clear();
self.extend = None;
self.redraw = None;
@@ -369,6 +405,7 @@ impl Tool for GestureTool {
self.active = false;
self.began = false;
self.smoothing = false;
self.straight = false;
self.clear();
if began { vec![Action::Cancel] } else { vec![] }
}
@@ -388,6 +425,7 @@ impl Tool for GestureTool {
self.snap.clear();
self.active = false;
self.began = false;
self.straight = false;
self.clear();
if began { vec![Action::Commit] } else { vec![] }
}
@@ -411,7 +449,8 @@ impl Tool for GestureTool {
return Cursor::Smooth;
}
// Alt held at the release closes the path (`finish`).
if self.draws_path() && !self.alt_smooth && m.alt {
// A straight line stays open, so its release has nothing to close.
if self.draws_path() && !self.alt_smooth && m.alt && !self.straight {
return if self.id == "paintbrush" { Cursor::PaintbrushClose } else { Cursor::PencilClose };
}
match self.id {
@@ -694,6 +733,146 @@ mod tests {
assert!(pts.len() == 3 && pts[2] == (0.0, 0.0) && (a - 30.0).abs() < 1e-9, "{pts:?}");
}
/// #3799: Shift held at the press draws a straight Pencil line from the press point, at 45°
/// steps from the Constrain Angle, previewed as it is dragged; the Paintbrush keeps following
/// the hand.
#[test]
fn pencil_shift_draws_a_straight_line_at_45_degree_steps() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
let shift = Mods { shift: 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 angle = |q: (f64, f64), from: (f64, f64)| (-(q.1 - from.1)).atan2(q.0 - from.0).to_degrees();
let mut t = GestureTool::new("pencil");
// A wavy drag heading up and right at about 40°: a line at 45°, as long as the drag.
let acts = drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, -10.0), (50.0, -60.0), (100.0, -84.0)], shift);
let all = pts(&acts);
assert!(all.iter().all(|v| v.len() == 2 && v[0] == (0.0, 0.0)), "{all:?}");
let end = *all.last().unwrap().last().unwrap();
assert!((angle(end, (0.0, 0.0)) - 45.0).abs() < 1e-9, "{end:?}");
assert!((end.0.hypot(end.1) - 100f64.hypot(84.0)).abs() < 1e-9, "{end:?}");
let last = acts.last().unwrap();
assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == false) && last[1] == Action::Commit, "{last:?}");
// The steps turn with the Constrain Angle: at 30°, a drag at 40° gives 30°, one at 60° gives 75°.
cx.constrain_angle = 30.0;
for (to, want) in [((100.0, -84.0), 30.0), ((50.0, -86.6), 75.0)] {
let all = pts(&drag_held(&mut t, &cx, &[(10.0, 10.0), (to.0 + 10.0, to.1 + 10.0)], shift));
let end = *all.last().unwrap().last().unwrap();
assert!((angle(end, (10.0, 10.0)) - want).abs() < 1e-9, "{to:?}: {all:?}");
}
// Without Shift, and with the Paintbrush, the stroke follows the hand.
for (id, m) in [("pencil", Mods::default()), ("paintbrush", shift)] {
let mut t = GestureTool::new(id);
let all = pts(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, -10.0), (50.0, -60.0)], m));
assert_eq!(all.last().unwrap().len(), 3, "{id}: {all:?}");
}
}
/// #3799: a Shift line leaves no Smart Guides of its press on screen while it is dragged
/// (its end doesn't snap), and Alt at its release leaves it open.
#[test]
fn pencil_shift_line_drops_the_press_guides_and_stays_open_with_alt() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let shift = Mods { shift: true, ..Default::default() };
let mut t = GestureTool::new("pencil");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 102.0, 98.0).with_mods(shift));
assert!(!t.overlays(&cx).is_empty(), "the press snaps to the corner and shows it");
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 140.0).with_mods(shift));
assert!(t.overlays(&cx).is_empty(), "{:?}", t.overlays(&cx));
let shift_alt = Mods { shift: true, alt: true, ..Default::default() };
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 140.0).with_mods(shift_alt));
assert!(matches!(&acts[0], Action::Preview(_, v) if v["closed"] == false && v["points"].as_array().unwrap().len() == 2), "{acts:?}");
}
/// #3799: a Shift line started on an end of a selected open path continues the path from that
/// end, constrained from the end itself.
#[test]
fn pencil_shift_line_continues_a_selected_path_from_its_end() {
let (mut d, _) = doc_with_rect();
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 p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
let mut t = GestureTool::new("pencil");
// Pressed 5 pt off the end, released 100 pt right and 4 pt above it.
let acts = drag_held(&mut t, &cx, &[(405.0, 303.0), (450.0, 290.0), (500.0, 296.0)], Mods { shift: true, ..Default::default() });
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], [400.0 + 100f64.hypot(4.0), 300.0]]));
}
/// #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]
fn pencil_shift_line_ignores_alt_for_cursor_and_smoothing() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
let shift = Mods { shift: true, ..Default::default() };
let shift_alt = Mods { shift: true, alt: true, ..Default::default() };
let mut t = GestureTool::new("pencil");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift));
assert_eq!(t.cursor(&cx, Point::new(0.0, 0.0), shift_alt), Cursor::Pencil);
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 0.0, 0.0).with_mods(shift));
let mut t = GestureTool::new("pencil");
t.alt_smooth = true;
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift));
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 80.0, 0.0).with_mods(shift_alt));
assert!(matches!(acts.as_slice(), [Action::Begin(_), Action::Preview(..)]), "{acts:?}");
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 80.0, 0.0).with_mods(shift_alt));
assert!(matches!(&acts[0], Action::Preview(_, v) if v["points"].as_array().unwrap().len() == 2), "{acts:?}");
}
/// #3846: a Shift line dragged back to its press point has no line: the preview is cancelled
/// and the release commits nothing (not the previous end of the line).
#[test]
fn pencil_shift_line_back_at_the_press_point_draws_nothing() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
let shift = Mods { shift: true, ..Default::default() };
// Released back at the press point, and dragged back there first.
for back_in_drag in [false, true] {
let mut t = GestureTool::new("pencil");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 80.0, 0.0).with_mods(shift));
let mut acts = vec![];
if back_in_drag {
// The shown line goes as soon as the pointer is back, not only at the release.
let back = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 0.2, 0.0).with_mods(shift));
assert_eq!(back, vec![Action::Cancel]);
acts.extend(back);
}
acts.extend(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 0.2, 0.0).with_mods(shift)));
assert!(acts.contains(&Action::Cancel), "{back_in_drag}: {acts:?}");
assert!(!acts.contains(&Action::Commit) && !acts.iter().any(|a| matches!(a, Action::Preview(..))), "{back_in_drag}: {acts:?}");
}
}
#[test]
fn pencil_alt_closes_and_continues_selected_path() {
let (d, id) = doc_with_rect();
File diff suppressed because one or more lines are too long
+3 -2
View File
File diff suppressed because one or more lines are too long