From f9a6c1071946f1eb20b0b98eb599262188522b8e Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:58:39 +1000 Subject: [PATCH] PEN.1: fix endpoint picking and commit handle previews Co-authored-by: Codex # Conflicts: # contributors/people.toml --- contributors/people.toml | 4 + crates/engine/src/tests.rs | 357 +++++++++++++ crates/engine/tests/pen_endpoint_picking.rs | 419 +++++++++++++++ crates/tools/src/pen.rs | 544 ++++++++++++++++++-- 4 files changed, 1277 insertions(+), 47 deletions(-) create mode 100644 crates/engine/tests/pen_endpoint_picking.rs diff --git a/contributors/people.toml b/contributors/people.toml index 9bd8e3744..ae99c8a4d 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -81,3 +81,7 @@ sync_github_name = true real_name = "Atos Lins" display_name = "Atos Lins" sync_github_name = false + +[people.sanyamsingh001] +display_name = "sanyamsingh001" +sync_github_name = false diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b0afc0d89..8071a7edd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1038,6 +1038,363 @@ fn pen_click_on_a_selected_path_adds_an_anchor_unless_disabled() { assert_eq!(d.node(id).unwrap().path_data().unwrap().anchor_count(), 3); } +/// Pen interactions exercised through the real preview/commit and undo machinery. +mod pen_interactions { + use super::*; + use vectorcraft_geom::{AnchorKind, PathData, Point}; + use vectorcraft_tools::{Cursor, Mods}; + + fn gesture(s: &mut Session, pts: &[(f64, f64)], mods: Mods, v: ViewInfo) { + let (first, last) = (pts[0], pts[pts.len() - 1]); + s.pointer(&PointerEvent::new(PointerKind::Down, first.0, first.1).with_mods(mods), v).unwrap(); + for &(x, y) in &pts[1..] { + s.pointer(&PointerEvent::new(PointerKind::Drag, x, y).with_mods(mods), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Up, last.0, last.1).with_mods(mods), v).unwrap(); + } + + fn path(s: &Session, id: NodeId) -> PathData { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone() + } + + fn curve() -> (Session, NodeId, ViewInfo) { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("pen", v).unwrap(); + gesture(&mut s, &[(100.0, 300.0)], Mods::default(), v); + gesture(&mut s, &[(213.0, 313.0), (233.0, 333.0), (253.0, 353.0)], Mods::default(), v); + let id = s.doc().unwrap().selection.objects[0]; + assert_eq!(path(&s, id).anchor_count(), 2); + (s, id, v) + } + + fn nearby_endpoints(zoom: f64) -> (Session, NodeId, ViewInfo) { + let mut s = session(); + let v = ViewInfo { zoom, smart_guides: false, snap_to_point: false, ..ViewInfo::default() }; + let end_x = 100.0 + 6.0 / zoom; + let created = s + .execute( + "path.create", + &json!({"anchors": [ + {"x": 100.0, "y": 300.0}, + {"x": end_x, "y": 300.0, "in": [end_x, 280.0], "out": [end_x, 320.0]} + ]}), + ) + .unwrap(); + let id = NodeId(created["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("prefs.set", &json!({"key": "selectionTolerance", "value": 8})).unwrap(); + s.select_tool("pen", v).unwrap(); + (s, id, v) + } + + #[test] + fn overlapping_endpoints_convert_the_terminal_anchor_without_closing_and_undo() { + for zoom in [0.5, 1.0, 2.0] { + for grid in [false, true] { + for alt in [false, true] { + let (mut s, id, v) = nearby_endpoints(zoom); + let v = ViewInfo { snap_to_grid: grid, ..v }; + let before = path(&s, id); + let last = before.subpaths[0].anchors[1]; + gesture(&mut s, &[(last.p.x, last.p.y)], Mods::default(), v); + let steps = s.doc().unwrap().history.undo.len(); + let mods = Mods { alt, ..Mods::default() }; + assert_eq!(s.cursor(last.p, mods, v), Cursor::PenConvert); + gesture(&mut s, &[(last.p.x, last.p.y)], mods, v); + let converted = path(&s, id); + assert!(!converted.is_closed(), "zoom={zoom}, grid={grid}, alt={alt}"); + assert_eq!(converted.anchor_count(), 2); + assert_eq!(converted.subpaths[0].anchors[0], before.subpaths[0].anchors[0]); + let a = converted.subpaths[0].anchors[1]; + assert_eq!(a.h_out, a.p); + assert_eq!(a.h_in, if alt { a.p } else { last.h_in }); + assert!(!s.in_interaction() && !s.tool_busy()); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, id), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(path(&s, id), converted); + } + } + } + } + + #[test] + fn overlapping_endpoints_reverse_from_the_first_anchor_and_undo() { + for zoom in [0.5, 1.0, 2.0] { + for grid in [false, true] { + let (mut s, id, v) = nearby_endpoints(zoom); + let v = ViewInfo { snap_to_grid: grid, ..v }; + let before = path(&s, id); + let first = before.subpaths[0].anchors[0].p; + let steps = s.doc().unwrap().history.undo.len(); + assert_eq!(s.cursor(first, Mods::default(), v), Cursor::PenContinue); + gesture(&mut s, &[(first.x, first.y)], Mods::default(), v); + let reversed = path(&s, id); + assert_eq!(reversed.subpaths[0].anchors[0].p, before.subpaths[0].anchors[1].p); + assert_eq!(reversed.subpaths[0].anchors[1].p, first); + assert!(!reversed.is_closed()); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, id), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(path(&s, id), reversed); + gesture(&mut s, &[(400.0, 500.0)], Mods::default(), v); + let extended = path(&s, id); + assert_eq!(extended.anchor_count(), 3); + assert_eq!(extended.subpaths[0].anchors[..2], reversed.subpaths[0].anchors); + assert!(!s.in_interaction() && !s.tool_busy()); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1); + } + } + } + + /// #544: snapping and the configured pick radius must not steal a terminal-anchor press. + #[test] + fn terminal_conversion_keeps_the_curve_and_is_undoable() { + let (mut s, id, v) = curve(); + let before = path(&s, id); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("prefs.set", &json!({"key": "selectionTolerance", "value": 8})).unwrap(); + let v = ViewInfo { zoom: 2.0, snap_to_grid: true, ..v }; + let pos = Point::new(213.0, 316.0); // Six screen pixels from the anchor. + assert_eq!(s.cursor(pos, Mods::default(), v), Cursor::PenConvert); + gesture(&mut s, &[(pos.x, pos.y)], Mods::default(), v); + let converted = path(&s, id); + let last = converted.subpaths[0].anchors[1]; + assert_eq!(last.p, Point::new(213.0, 313.0)); + assert_eq!(last.h_in, before.subpaths[0].anchors[1].h_in); + assert_eq!(last.h_out, last.p); + assert_eq!(last.kind, AnchorKind::Corner); + assert_eq!(converted.to_bezpath(), before.to_bezpath(), "the completed curve is untouched"); + assert_eq!(converted.anchor_count(), 2); + assert!(!s.in_interaction()); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, id), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(path(&s, id), converted); + gesture(&mut s, &[(300.0, 400.0)], Mods::default(), v); + let extended = path(&s, id); + assert_eq!(extended.anchor_count(), 3); + assert_eq!(extended.subpaths[0].anchors[..2], converted.subpaths[0].anchors); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1); + } + + #[test] + fn terminal_handle_drag_keeps_the_incoming_handle_and_shift_constraint() { + let (mut s, id, v) = curve(); + let before = path(&s, id); + let steps = s.doc().unwrap().history.undo.len(); + let shift = Mods { shift: true, ..Mods::default() }; + gesture(&mut s, &[(213.0, 313.0), (223.0, 323.0), (233.0, 343.0), (243.0, 353.0)], shift, v); + let moved = path(&s, id); + let last = moved.subpaths[0].anchors[1]; + assert_eq!(last.h_in, before.subpaths[0].anchors[1].h_in); + assert_eq!(last.h_out, last.p + vectorcraft_geom::constrain_angle(Point::new(243.0, 353.0) - last.p, 45.0)); + assert_eq!(moved.to_bezpath(), before.to_bezpath()); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "all previews are one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, id), before); + } + + /// #516: the last anchor honors Alt's Anchor Point behavior, and drawing continues afterwards. + #[test] + fn alt_on_the_terminal_anchor_converts_both_handles_and_resumes() { + let (mut s, id, v) = curve(); + let alt = Mods { alt: true, ..Mods::default() }; + gesture(&mut s, &[(213.0, 313.0), (243.0, 343.0), (243.0, 353.0)], alt, v); + let last = path(&s, id).subpaths[0].anchors[1]; + assert_eq!(last.kind, AnchorKind::Smooth); + assert_eq!(last.h_in, Point::new(183.0, 273.0)); + assert_eq!(last.h_out, Point::new(243.0, 353.0)); + gesture(&mut s, &[(213.0, 313.0)], alt, v); + let last = path(&s, id).subpaths[0].anchors[1]; + assert_eq!((last.h_in, last.h_out), (last.p, last.p)); + gesture(&mut s, &[(300.0, 400.0)], Mods::default(), v); + assert_eq!(path(&s, id).anchor_count(), 3); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1); + } + + #[test] + fn modifiers_on_a_subsequent_anchor_keep_the_retracted_terminal_curve() { + let (mut s, id, v) = curve(); + let v = ViewInfo { smart_guides: false, snap_to_point: false, ..v }; + gesture(&mut s, &[(213.0, 313.0)], Mods::default(), v); + let before = path(&s, id); + let steps = s.doc().unwrap().history.undo.len(); + let alt = Mods { alt: true, ..Mods::default() }; + for (kind, x, y, mods) in [ + (PointerKind::Down, 310.0, 430.0, Mods::default()), + (PointerKind::Drag, 340.0, 450.0, Mods::default()), + (PointerKind::Drag, 370.0, 480.0, alt), + (PointerKind::Drag, 380.0, 500.0, Mods { space: true, ..alt }), + (PointerKind::Drag, 380.0, 490.0, Mods { shift: true, ..alt }), + (PointerKind::Up, 380.0, 490.0, Mods::default()), + ] { + s.pointer(&PointerEvent::new(kind, x, y).with_mods(mods), v).unwrap(); + } + let extended = path(&s, id); + assert_eq!(extended.anchor_count(), 3); + assert_eq!(extended.subpaths[0].anchors[..2], before.subpaths[0].anchors); + let a = extended.subpaths[0].anchors[2]; + assert_eq!(a.p, Point::new(320.0, 450.0)); + assert_eq!(a.h_in, Point::new(290.0, 430.0), "Alt freezes the incoming handle and Space translates it"); + assert_eq!(a.h_out, a.p + vectorcraft_geom::constrain_angle(Point::new(380.0, 490.0) - a.p, 45.0)); + assert_eq!(a.kind, AnchorKind::Corner); + assert!(!s.in_interaction()); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, id), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(path(&s, id), extended); + } + + #[test] + fn cancelling_a_pen_preview_restores_the_curve_without_a_history_step() { + for kind in ["place", "handle", "close", "alt"] { + let (mut s, id, v) = curve(); + let before = path(&s, id); + let steps = s.doc().unwrap().history.undo.len(); + let at = match kind { + "place" => (300.0, 400.0), + "close" => (100.0, 300.0), + _ => (213.0, 313.0), + }; + let mods = Mods { alt: kind == "alt", ..Mods::default() }; + s.pointer(&PointerEvent::new(PointerKind::Down, at.0, at.1).with_mods(mods), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, at.0 + 25.0, at.1 + 40.0).with_mods(mods), v).unwrap(); + assert_ne!(path(&s, id), before); + s.cancel_interaction().unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, at.0 + 25.0, at.1 + 40.0), v).unwrap(); + assert_eq!(path(&s, id), before, "{kind}"); + assert!(!s.in_interaction()); + assert!(!s.tool_busy()); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + // Closing ends drawing; resuming from the terminal end still edits the same path. + if kind == "close" { + gesture(&mut s, &[(213.0, 313.0)], Mods::default(), v); + } + gesture(&mut s, &[(400.0, 500.0)], Mods::default(), v); + assert_eq!(path(&s, id).anchor_count(), 3); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1); + } + } + + #[test] + fn ending_the_first_anchor_drag_keeps_one_undoable_path() { + for key in [Some(ToolKey::Escape), Some(ToolKey::Enter), None] { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("pen", v).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 300.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 140.0, 320.0), v).unwrap(); + let id = s.doc().unwrap().selection.objects[0]; + let preview = path(&s, id); + match key { + Some(k) => { + s.tool_key(k, Mods::default(), v).unwrap(); + } + None => { + s.select_tool("selection", v).unwrap(); + } + } + assert!(!s.in_interaction()); + assert!(!s.tool_busy()); + assert_eq!(path(&s, id), preview); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + if key.is_some() { + s.pointer(&PointerEvent::new(PointerKind::Up, 140.0, 320.0), v).unwrap(); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(id).is_none()); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(path(&s, id), preview); + s.select_tool("pen", v).unwrap(); + gesture(&mut s, &[(500.0, 500.0)], Mods::default(), v); + assert_eq!(path(&s, id), preview); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2); + } + } + + /// #516: ending a drag must keep the preview as one undo step, with no transaction left open. + #[test] + fn ending_a_pen_drag_finishes_its_transaction() { + for kind in ["place", "handle", "close", "alt"] { + for key in [Some(ToolKey::Escape), Some(ToolKey::Enter), None] { + let (mut s, id, v) = curve(); + let before = path(&s, id); + let steps = s.doc().unwrap().history.undo.len(); + let at = match kind { + "place" => (300.0, 400.0), + "close" => (100.0, 300.0), + _ => (213.0, 313.0), + }; + let mods = Mods { alt: kind == "alt", ..Mods::default() }; + s.pointer(&PointerEvent::new(PointerKind::Down, at.0, at.1).with_mods(mods), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, at.0 + 25.0, at.1 + 40.0).with_mods(mods), v).unwrap(); + let preview = path(&s, id); + assert!(s.in_interaction()); + match key { + Some(k) => { + s.tool_key(k, Mods::default(), v).unwrap(); + } + None => { + s.select_tool("selection", v).unwrap(); + } + } + assert!(!s.in_interaction(), "{kind}, {key:?}"); + assert!(!s.tool_busy()); + assert_eq!(path(&s, id), preview); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + if key.is_some() { + // A delayed button release after Enter/Escape must not re-open or re-commit. + s.pointer(&PointerEvent::new(PointerKind::Up, at.0 + 25.0, at.1 + 40.0), v).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, id), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(path(&s, id), preview); + s.select_tool("pen", v).unwrap(); + gesture(&mut s, &[(500.0, 500.0)], Mods::default(), v); + assert_eq!(path(&s, id), preview, "the ended path stays as it was"); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2, "a new path starts"); + } + } + } + + #[test] + fn shift_click_closes_an_oblique_path_before_constraining_a_new_point() { + let (mut s, id, v) = curve(); + let shift = Mods { shift: true, ..Mods::default() }; + assert_eq!(s.cursor(Point::new(100.0, 300.0), shift, v), Cursor::PenClose); + gesture(&mut s, &[(100.0, 300.0)], shift, v); + assert!(path(&s, id).is_closed()); + assert_eq!(path(&s, id).anchor_count(), 2); + } + + #[test] + fn an_ended_path_resumes_from_either_end_with_grid_snapping() { + for first in [false, true] { + let (mut s, id, v) = curve(); + let before = path(&s, id); + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + let v = ViewInfo { snap_to_grid: true, ..v }; + let end = before.subpaths[0].anchors[usize::from(!first)].p; + assert_eq!(s.cursor(end, Mods::default(), v), Cursor::PenContinue); + gesture(&mut s, &[(end.x, end.y)], Mods::default(), v); + gesture(&mut s, &[(400.0, 500.0)], Mods::default(), v); + let resumed = path(&s, id); + assert_eq!(resumed.anchor_count(), 3); + assert_eq!(resumed.subpaths[0].anchors[1].p, end); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1); + } + } +} + #[test] fn direct_selection_moves_one_anchor() { let mut s = session(); diff --git a/crates/engine/tests/pen_endpoint_picking.rs b/crates/engine/tests/pen_endpoint_picking.rs new file mode 100644 index 000000000..b6f293535 --- /dev/null +++ b/crates/engine/tests/pen_endpoint_picking.rs @@ -0,0 +1,419 @@ +//! Original tiny-geometry controls for Pen endpoint/Alt integration with continue-any-path. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::json; +use vectorcraft_doc::{Node, NodeId, NodeKind}; +use vectorcraft_engine::{Session, ViewInfo}; +use vectorcraft_geom::{AnchorKind, Point}; +use vectorcraft_tools::{Cursor, Mods, PointerEvent, PointerKind}; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s.prefs.selection_tolerance = 8.0; + s.prefs.use_precise_cursors = false; + s +} + +fn view() -> ViewInfo { + ViewInfo { zoom: 1.0, smart_guides: false, snap_to_grid: false, snap_to_pixel: false, snap_to_point: false, ..Default::default() } +} + +fn node(s: &Session, id: NodeId) -> &Node { + s.doc().unwrap().doc.node(id).unwrap() +} + +fn path_ids(s: &Session) -> Vec { + let mut ids = vec![]; + s.doc().unwrap().doc.walk(|n| { + if matches!(n.kind, NodeKind::Path { .. }) { + ids.push(n.id); + } + }); + ids.sort_unstable(); + ids +} + +fn points(s: &Session, id: NodeId) -> Vec<(f64, f64)> { + let path = node(s, id).path_data().unwrap(); + assert_eq!(path.subpaths.len(), 1); + assert!(!path.subpaths[0].closed); + path.subpaths[0].anchors.iter().map(|a| (a.p.x, a.p.y)).collect() +} + +fn line(s: &mut Session, from: (f64, f64), to: (f64, f64)) -> NodeId { + let result = s.execute("shape.line", &json!({"x1": from.0, "y1": from.1, "x2": to.0, "y2": to.1})).unwrap(); + NodeId(result["id"].as_u64().unwrap()) +} + +fn gesture(s: &mut Session, view: ViewInfo, samples: &[(f64, f64)], mods: Mods) { + let first = samples.first().unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, first.0, first.1).with_mods(mods), view).unwrap(); + for &(x, y) in samples.iter().skip(1) { + s.pointer(&PointerEvent::new(PointerKind::Drag, x, y).with_mods(mods), view).unwrap(); + } + let last = samples.last().unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, last.0, last.1).with_mods(mods), view).unwrap(); +} + +// B is above A. Its first endpoint coincides with A's interior corner, not either end of A. +fn overlapping_endpoint() -> (Session, NodeId, NodeId) { + let mut s = session(); + let result = s.execute("path.create", &json!({"anchors": [{"x": 100, "y": 100}, {"x": 200, "y": 100}, {"x": 300, "y": 100}]})).unwrap(); + let a = NodeId(result["id"].as_u64().unwrap()); + let b = line(&mut s, (200.0, 100.0), (200.0, 200.0)); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.select_tool("pen", view()).unwrap(); + assert_eq!(s.cursor(Point::new(300.0, 100.0), Mods::default(), view()), Cursor::PenContinue); + gesture(&mut s, view(), &[(300.0, 100.0)], Mods::default()); + assert_eq!(path_ids(&s), vec![a, b]); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + (s, a, b) +} + +#[test] +fn alt_at_another_endpoint_reports_conversion_and_preserves_objects_through_undo_redo() { + let (mut s, a, b) = overlapping_endpoint(); + let before_a = node(&s, a).clone(); + let before_b = node(&s, b).clone(); + let before_undo = s.doc().unwrap().history.undo.len(); + let alt = Mods { alt: true, ..Default::default() }; + let advertised = s.cursor(Point::new(200.0, 100.0), alt, view()); + gesture(&mut s, view(), &[(200.0, 100.0), (200.0, 120.0)], alt); + + let mut converted_a = before_a.clone(); + let NodeKind::Path { path, .. } = &mut converted_a.kind else { panic!("fixture must be a path") }; + let middle = &mut path.subpaths[0].anchors[1]; + middle.kind = AnchorKind::Smooth; + middle.h_in = Point::new(200.0, 80.0); + middle.h_out = Point::new(200.0, 120.0); + assert_eq!(path_ids(&s), vec![a, b], "Alt conversion must not join away either object"); + assert_eq!(node(&s, a), &converted_a, "only A's interior corner becomes smooth"); + assert_eq!(node(&s, b), &before_b, "B's endpoint is under the pointer but B must stay unchanged"); + assert_eq!(s.doc().unwrap().history.undo.len(), before_undo + 1); + assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Convert Anchor Point"); + assert_eq!(path_ids(&s), vec![a, b]); + assert_eq!(node(&s, a), &before_a); + assert_eq!(node(&s, b), &before_b); + assert_eq!(s.doc().unwrap().history.undo.len(), before_undo); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(path_ids(&s), vec![a, b]); + assert_eq!(node(&s, a), &converted_a); + assert_eq!(node(&s, b), &before_b); + assert_eq!(s.doc().unwrap().history.undo.len(), before_undo + 1); + // Check the captured cursor last: a mismatch must not hide the actual conversion/history controls. + assert_eq!(advertised, Cursor::PenConvert, "Alt cursor must describe the conversion actually performed, not Join"); +} + +#[test] +fn plain_click_at_same_endpoint_joins_paths_with_undo_redo() { + let (mut s, a, b) = overlapping_endpoint(); + let before_a = node(&s, a).clone(); + let before_b = node(&s, b).clone(); + let before_undo = s.doc().unwrap().history.undo.len(); + assert_eq!(s.cursor(Point::new(200.0, 100.0), Mods::default(), view()), Cursor::PenJoin); + gesture(&mut s, view(), &[(200.0, 100.0)], Mods::default()); + assert_eq!(path_ids(&s), vec![a]); + assert!(s.doc().unwrap().doc.node(b).is_none()); + assert_eq!(points(&s, a), [(100.0, 100.0), (200.0, 100.0), (300.0, 100.0), (200.0, 100.0), (200.0, 200.0)]); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + assert_eq!(s.doc().unwrap().history.undo.len(), before_undo + 1); + let joined = node(&s, a).clone(); + assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Join"); + assert_eq!(path_ids(&s), vec![a, b]); + assert_eq!(node(&s, a), &before_a); + assert_eq!(node(&s, b), &before_b); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(path_ids(&s), vec![a]); + assert_eq!(node(&s, a), &joined); + assert!(s.doc().unwrap().doc.node(b).is_none()); +} + +fn grid_continue(preselected: bool, snap_to_grid: bool) { + let mut s = session(); + let mut prefs = s.prefs.clone(); + prefs.gridline_every = 100.0; + prefs.grid_subdivisions = 1; + s.apply_prefs(prefs); + assert_eq!(s.doc().unwrap().doc.grid.spacing, 100.0); + assert_eq!(s.doc().unwrap().doc.grid.subdivisions, 1); + let id = line(&mut s, (13.0, 303.0), (63.0, 303.0)); + let original = node(&s, id).clone(); + s.execute("select.set", &json!({"ids": if preselected { vec![id.0] } else { vec![] }})).unwrap(); + let view = ViewInfo { snap_to_grid, ..view() }; + s.select_tool("pen", view).unwrap(); + assert_eq!(s.cursor(Point::new(63.0, 303.0), Mods::default(), view), Cursor::PenContinue); + let journal_start = s.journal.len(); + gesture(&mut s, view, &[(63.0, 303.0)], Mods::default()); + assert_eq!(path_ids(&s), vec![id], "clicking the advertised endpoint must continue it, not create a snapped path"); + assert_eq!(node(&s, id), &original); + assert_eq!(s.doc().unwrap().selection.objects, vec![id]); + assert!(!s.journal[journal_start..].iter().any(|(command, _)| command == "path.create")); + let before_append = s.doc().unwrap().history.undo.len(); + gesture(&mut s, view, &[(200.0, 300.0)], Mods::default()); + assert_eq!(path_ids(&s), vec![id]); + assert_eq!(points(&s, id), [(13.0, 303.0), (63.0, 303.0), (200.0, 300.0)]); + assert_eq!(s.doc().unwrap().history.undo.len(), before_append + 1); + let extended = node(&s, id).clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id), &original); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(node(&s, id), &extended); +} + +#[test] +fn unselected_grid_snapped_endpoint_continues_at_raw_pointer() { + grid_continue(false, true); +} + +#[test] +fn preselected_grid_snapped_endpoint_continues_at_raw_pointer() { + grid_continue(true, true); +} + +#[test] +fn unselected_endpoint_continues_with_grid_snap_disabled() { + grid_continue(false, false); +} + +fn nearest_first_continue(preselected: bool) { + let mut s = session(); + let id = line(&mut s, (100.0, 300.0), (106.0, 300.0)); + s.execute("select.set", &json!({"ids": if preselected { vec![id.0] } else { vec![] }})).unwrap(); + s.select_tool("pen", view()).unwrap(); + // Both ends are within the explicit 8 px tolerance, but the first is exactly under the pointer. + assert_eq!(s.cursor(Point::new(100.0, 300.0), Mods::default(), view()), Cursor::PenContinue); + gesture(&mut s, view(), &[(100.0, 300.0)], Mods::default()); + assert_eq!(path_ids(&s), vec![id]); + assert_eq!(s.doc().unwrap().selection.objects, vec![id]); + gesture(&mut s, view(), &[(80.0, 300.0)], Mods::default()); + assert_eq!(path_ids(&s), vec![id]); + assert_eq!(points(&s, id), [(106.0, 300.0), (100.0, 300.0), (80.0, 300.0)], "continue from the nearer first end"); +} + +#[test] +fn unselected_short_path_continues_nearest_first_endpoint() { + nearest_first_continue(false); +} + +#[test] +fn preselected_short_path_continues_nearest_first_endpoint() { + nearest_first_continue(true); +} + +#[test] +fn short_other_path_joins_nearest_endpoint_and_keeps_last_on_tie() { + // Both ends of B are within 8 px. First/last must win by distance; the midpoint keeps Last. + // Snapping is disabled here to isolate endpoint choice; active joins keep upstream's snapped input. + for (x, first, last) in [(100.0, 100.0, 106.0), (106.0, 106.0, 100.0), (103.0, 106.0, 100.0)] { + let mut s = session(); + let a = line(&mut s, (20.0, 300.0), (40.0, 300.0)); + let b = line(&mut s, (100.0, 300.0), (106.0, 300.0)); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.select_tool("pen", view()).unwrap(); + gesture(&mut s, view(), &[(40.0, 300.0)], Mods::default()); + let before_a = node(&s, a).clone(); + let before_b = node(&s, b).clone(); + let before_undo = s.doc().unwrap().history.undo.len(); + + assert_eq!(s.cursor(Point::new(x, 300.0), Mods::default(), view()), Cursor::PenJoin); + gesture(&mut s, view(), &[(x, 300.0)], Mods::default()); + assert_eq!(path_ids(&s), vec![a], "x={x}"); + assert!(s.doc().unwrap().doc.node(b).is_none()); + assert_eq!(points(&s, a), [(20.0, 300.0), (40.0, 300.0), (first, 300.0), (last, 300.0)], "x={x}"); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + assert_eq!(s.doc().unwrap().history.undo.len(), before_undo + 1); + let joined = node(&s, a).clone(); + + assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Join"); + assert_eq!(path_ids(&s), vec![a, b]); + assert_eq!(node(&s, a), &before_a); + assert_eq!(node(&s, b), &before_b); + assert_eq!(s.doc().unwrap().history.undo.len(), before_undo); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(path_ids(&s), vec![a]); + assert_eq!(node(&s, a), &joined); + assert!(s.doc().unwrap().doc.node(b).is_none()); + assert_eq!(s.doc().unwrap().history.undo.len(), before_undo + 1); + } +} + +fn grid_join(from: (f64, f64), to: (f64, f64), at: (f64, f64), snap_to_grid: bool, expected_cursor: Cursor) { + let mut s = session(); + let mut prefs = s.prefs.clone(); + prefs.gridline_every = 100.0; + prefs.grid_subdivisions = 1; + s.apply_prefs(prefs); + assert_eq!(s.doc().unwrap().doc.grid.spacing, 100.0); + assert_eq!(s.doc().unwrap().doc.grid.subdivisions, 1); + let v = ViewInfo { snap_to_grid, ..view() }; + let a = line(&mut s, (20.0, 100.0), (40.0, 100.0)); + let b = line(&mut s, from, to); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.select_tool("pen", v).unwrap(); + assert_eq!(s.cursor(Point::new(40.0, 100.0), Mods::default(), v), Cursor::PenContinue); + gesture(&mut s, v, &[(40.0, 100.0)], Mods::default()); + assert_eq!(path_ids(&s), vec![a, b]); + let before_a = node(&s, a).clone(); + let before_b = node(&s, b).clone(); + let before_undo = s.doc().unwrap().history.undo.len(); + + assert_eq!(s.cursor(Point::new(at.0, at.1), Mods::default(), v), expected_cursor); + gesture(&mut s, v, &[at], Mods::default()); + assert_eq!(path_ids(&s), vec![a], "the endpoint press joins B into A"); + assert!(s.doc().unwrap().doc.node(b).is_none()); + assert_eq!(points(&s, a), [(20.0, 100.0), (40.0, 100.0), from, to], "join B's first end, not its farther last end"); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + assert_eq!(s.doc().unwrap().history.undo.len(), before_undo + 1); + let joined = node(&s, a).clone(); + + assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Join"); + assert_eq!(path_ids(&s), vec![a, b]); + assert_eq!(node(&s, a), &before_a); + assert_eq!(node(&s, b), &before_b); + assert_eq!(s.doc().unwrap().history.undo.len(), before_undo); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(path_ids(&s), vec![a]); + assert_eq!(node(&s, a), &joined); + assert!(s.doc().unwrap().doc.node(b).is_none()); + assert_eq!(s.doc().unwrap().history.undo.len(), before_undo + 1); +} + +#[test] +fn off_grid_other_endpoint_joins_without_grid_snapping() { + grid_join((63.0, 303.0), (113.0, 303.0), (63.0, 303.0), false, Cursor::PenJoin); +} + +#[test] +fn off_grid_other_endpoint_joins_before_grid_snapping() { + // The raw pointer hits B's first end; (100, 300) from grid snapping hits neither end. + grid_join((63.0, 303.0), (113.0, 303.0), (63.0, 303.0), true, Cursor::PenJoin); +} + +#[test] +fn overlapping_other_endpoints_join_nearest_raw_end_before_grid_snapping() { + // Raw picks (94, 303), but snapping to (100, 300) would wrongly favor (100, 303). + grid_join((94.0, 303.0), (100.0, 303.0), (94.0, 303.0), true, Cursor::PenJoin); +} + +#[test] +fn another_path_join_keeps_snapped_target_when_raw_pointer_misses() { + // Upstream also joins through snapping when raw hits no endpoint. Keep that fallback. + grid_join((100.0, 300.0), (150.0, 300.0), (63.0, 303.0), true, Cursor::Pen); +} + +fn snapped_only_continue(preselected: bool) { + for first in [true, false] { + let mut s = session(); + let mut prefs = s.prefs.clone(); + prefs.gridline_every = 100.0; + prefs.grid_subdivisions = 1; + s.apply_prefs(prefs); + assert_eq!(s.doc().unwrap().doc.grid.spacing, 100.0); + assert_eq!(s.doc().unwrap().doc.grid.subdivisions, 1); + let id = line(&mut s, (100.0, 300.0), (200.0, 300.0)); + let original = node(&s, id).clone(); + s.execute("select.set", &json!({"ids": if preselected { vec![id.0] } else { vec![] }})).unwrap(); + let v = ViewInfo { snap_to_grid: true, ..view() }; + s.select_tool("pen", v).unwrap(); + let at = if first { (63.0, 303.0) } else { (237.0, 303.0) }; + let end = if first { Point::new(100.0, 300.0) } else { Point::new(200.0, 300.0) }; + assert_eq!(s.cursor(end, Mods::default(), v), Cursor::PenContinue, "the endpoint is eligible"); + assert_eq!(s.cursor(Point::new(at.0, at.1), Mods::default(), v), Cursor::Pen, "the raw pointer misses both ends"); + let journal_start = s.journal.len(); + let before_continue = s.doc().unwrap().history.undo.len(); + gesture(&mut s, v, &[at], Mods::default()); + + assert_eq!(path_ids(&s), vec![id], "snapped-only continuation must keep one original path: selected={preselected}, first={first}"); + assert_eq!(s.doc().unwrap().selection.objects, vec![id]); + let expected = if first { [(200.0, 300.0), (100.0, 300.0)] } else { [(100.0, 300.0), (200.0, 300.0)] }; + assert_eq!(points(&s, id), expected, "continue from the snapped end: selected={preselected}, first={first}"); + assert!(!s.journal[journal_start..].iter().any(|(command, _)| command == "path.create")); + assert!(!s.in_interaction() && !s.tool_busy()); + assert_eq!(s.doc().unwrap().history.undo.len(), before_continue + usize::from(first)); + let continued = node(&s, id).clone(); + if !first { + assert_eq!(continued, original, "continuing from the last end does not edit the path"); + } + + let before_append = s.doc().unwrap().history.undo.len(); + let next = if first { (0.0, 300.0) } else { (300.0, 300.0) }; + gesture(&mut s, v, &[next], Mods::default()); + assert_eq!(path_ids(&s), vec![id]); + assert_eq!(s.doc().unwrap().selection.objects, vec![id]); + assert_eq!(points(&s, id), [expected[0], expected[1], next]); + assert!(!s.journal[journal_start..].iter().any(|(command, _)| command == "path.create")); + assert!(!s.in_interaction() && !s.tool_busy()); + assert_eq!(s.doc().unwrap().history.undo.len(), before_append + 1); + let extended = node(&s, id).clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path_ids(&s), vec![id]); + assert_eq!(node(&s, id), &continued); + assert_eq!(s.doc().unwrap().history.undo.len(), before_append); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(path_ids(&s), vec![id]); + assert_eq!(node(&s, id), &extended); + assert_eq!(s.doc().unwrap().history.undo.len(), before_append + 1); + } +} + +#[test] +fn selected_snapped_only_endpoint_continues_when_raw_pointer_misses() { + snapped_only_continue(true); +} + +#[test] +fn unselected_snapped_only_endpoint_continues_when_raw_pointer_misses() { + snapped_only_continue(false); +} + +#[test] +fn raw_unselected_endpoint_precedes_snapped_selected_endpoint() { + let mut s = session(); + let mut prefs = s.prefs.clone(); + prefs.gridline_every = 100.0; + prefs.grid_subdivisions = 1; + s.apply_prefs(prefs); + assert_eq!(s.doc().unwrap().doc.grid.spacing, 100.0); + assert_eq!(s.doc().unwrap().doc.grid.subdivisions, 1); + let selected = line(&mut s, (100.0, 300.0), (150.0, 300.0)); + let raw = line(&mut s, (13.0, 303.0), (63.0, 303.0)); + let original_selected = node(&s, selected).clone(); + let original_raw = node(&s, raw).clone(); + s.execute("select.set", &json!({"ids": [selected.0]})).unwrap(); + let v = ViewInfo { snap_to_grid: true, ..view() }; + s.select_tool("pen", v).unwrap(); + assert_eq!(s.cursor(Point::new(100.0, 300.0), Mods::default(), v), Cursor::PenContinue); + assert_eq!(s.cursor(Point::new(63.0, 303.0), Mods::default(), v), Cursor::PenContinue); + let journal_start = s.journal.len(); + let before_append = s.doc().unwrap().history.undo.len(); + gesture(&mut s, v, &[(63.0, 303.0)], Mods::default()); + + assert_eq!(s.doc().unwrap().selection.objects, vec![raw], "the raw unselected end wins over the snapped selected end"); + assert_eq!(path_ids(&s), vec![selected, raw]); + assert_eq!(node(&s, selected), &original_selected); + assert_eq!(node(&s, raw), &original_raw); + assert!(!s.journal[journal_start..].iter().any(|(command, _)| command == "path.create")); + assert!(!s.in_interaction() && !s.tool_busy()); + assert_eq!(s.doc().unwrap().history.undo.len(), before_append); + + gesture(&mut s, v, &[(200.0, 400.0)], Mods::default()); + assert_eq!(path_ids(&s), vec![selected, raw]); + assert_eq!(s.doc().unwrap().selection.objects, vec![raw]); + assert_eq!(node(&s, selected), &original_selected); + assert_eq!(points(&s, raw), [(13.0, 303.0), (63.0, 303.0), (200.0, 400.0)]); + assert!(!s.journal[journal_start..].iter().any(|(command, _)| command == "path.create")); + assert!(!s.in_interaction() && !s.tool_busy()); + assert_eq!(s.doc().unwrap().history.undo.len(), before_append + 1); + let extended = node(&s, raw).clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path_ids(&s), vec![selected, raw]); + assert_eq!(node(&s, selected), &original_selected); + assert_eq!(node(&s, raw), &original_raw); + assert_eq!(s.doc().unwrap().history.undo.len(), before_append); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(path_ids(&s), vec![selected, raw]); + assert_eq!(node(&s, selected), &original_selected); + assert_eq!(node(&s, raw), &extended); + assert_eq!(s.doc().unwrap().history.undo.len(), before_append + 1); +} diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index a2429c3a8..9e0f9e340 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -95,13 +95,8 @@ fn open_end_at(cx: &ToolContext, p: Point, tol: f64, except: Option) -> return None; } let (first, last) = (sp.anchors.first()?.p, sp.anchors.last()?.p); - if p.distance(last) <= tol { - Some((id, false)) - } else if p.distance(first) <= tol { - Some((id, true)) - } else { - None - } + let end = pick_endpoint(Some(first), last, p, tol, Endpoint::Last)?; + Some((id, end == Endpoint::First)) } /// The last anchor of `id`'s last subpath: (subpath, anchor). @@ -112,6 +107,29 @@ fn last_anchor(cx: &ToolContext, id: NodeId) -> Option<(usize, usize)> { Some((si, ai)) } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Endpoint { + First, + Last, +} + +/// Pick the closest eligible endpoint at `p`. The caller supplies the historical +/// tie choice: closing prefers the first end, resuming prefers the last. A one-anchor path has +/// no eligible closing end. +fn pick_endpoint(first: Option, last: Point, p: Point, tol: f64, tie: Endpoint) -> Option { + let first_distance = first.map(|a| p.distance(a)).filter(|d| *d <= tol); + let last_distance = p.distance(last); + let last_distance = (last_distance <= tol).then_some(last_distance); + match (first_distance, last_distance) { + (Some(f), Some(l)) if f < l => Some(Endpoint::First), + (Some(f), Some(l)) if l < f => Some(Endpoint::Last), + (Some(_), Some(_)) => Some(tie), + (Some(_), None) => Some(Endpoint::First), + (None, Some(_)) => Some(Endpoint::Last), + (None, None) => None, + } +} + /// Alt held over a handle end, an anchor or a segment of a selected path: the Anchor Point tool's /// gesture, within its tolerance. fn alt_converts(cx: &ToolContext, p: Point, m: Mods) -> bool { @@ -164,26 +182,44 @@ impl Tool for PenTool { self.last = ev.pos; if let Some((id, first, last, _)) = active { let end = last_anchor(cx, id); + let closing_end = end.filter(|(_, ai)| *ai > 0).map(|_| first); + let picked = pick_endpoint(closing_end, last, ev.pos, tol, Endpoint::First); + // Pick at the raw pointer before snapping or constraining a new point. // A one-anchor path doesn't close on itself: its anchor is the last one too. - if p.distance(first) <= tol && end.is_some_and(|(_, ai)| ai > 0) { + if picked == Some(Endpoint::First) { self.drag = Some(Place { at: first, path: Some(id), closing: true }); return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))]; } - if p.distance(last) <= tol + // Alt uses Anchor Point behavior at the terminal anchor too. + if let Some(acts) = self.alt_convert(cx, ev) { + return acts; + } + if picked == Some(Endpoint::Last) && let Some((si, ai)) = end { self.handle = Some((id, si, ai, last)); return vec![Action::Begin("Convert Anchor Point".into()), set_out_handle(id, si, ai, last)]; } + let raw_other = open_end_at(cx, ev.pos, tol, Some(id)); + if raw_other.is_none() && p != ev.pos { + let snapped = pick_endpoint(closing_end, last, p, tol, Endpoint::First); + if snapped == Some(Endpoint::First) { + self.drag = Some(Place { at: first, path: Some(id), closing: true }); + return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))]; + } + if snapped == Some(Endpoint::Last) + && let Some((si, ai)) = end + { + self.handle = Some((id, si, ai, last)); + return vec![Action::Begin("Convert Anchor Point".into()), set_out_handle(id, si, ai, last)]; + } + } // An end of another open path: the two become one, and the path is finished. - if let Some((other, at_first)) = open_end_at(cx, p, tol, Some(id)) { + if let Some((other, at_first)) = raw_other.or_else(|| open_end_at(cx, p, tol, Some(id))) { self.stop(); let end = if at_first { "first" } else { "last" }; return vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", end]}))]; } - if let Some(acts) = self.alt_convert(cx, ev) { - return acts; - } self.drag = Some(Place { at: p, path: Some(id), closing: false }); self.in_h = p; return vec![Action::Begin("Pen".into()), Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": p.x, "y": p.y}))]; @@ -194,25 +230,28 @@ impl Tool for PenTool { if let Some(acts) = spine_click(cx, p, tol) { return acts; } - // Continue a selected open path when clicking on one of its ends. - if let Some((_, first, last, _)) = active_path(cx) - && (p.distance(last) <= tol || p.distance(first) <= tol) - { - self.drawing = true; - self.path = active_path(cx).map(|a| a.0); - if p.distance(first) <= tol && p.distance(last) > tol { - return vec![Action::Exec("path.reverse".into(), json!({}))]; + // A raw endpoint anywhere wins before either snapped continuation route. + for at in std::iter::once(ev.pos).chain((p != ev.pos).then_some(p)) { + // Continue a selected open path when clicking on one of its ends. + if let Some((id, first, last, _)) = active_path(cx) + && let Some(picked) = pick_endpoint(Some(first), last, at, tol, Endpoint::Last) + { + self.drawing = true; + self.path = Some(id); + if picked == Endpoint::First { + return vec![Action::Exec("path.reverse".into(), json!({}))]; + } + return vec![]; } - return vec![]; - } - // Continue any other open path from the end clicked, selecting it. - if let Some((id, at_first)) = open_end_at(cx, p, tol, None) { - (self.drawing, self.path) = (true, Some(id)); - let mut out = vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; - if at_first { - out.push(Action::Exec("path.reverse".into(), json!({"ids": [id.0]}))); + // Continue any other open path from the end clicked, selecting it. + if let Some((id, at_first)) = open_end_at(cx, at, tol, None) { + (self.drawing, self.path) = (true, Some(id)); + let mut out = vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; + if at_first { + out.push(Action::Exec("path.reverse".into(), json!({"ids": [id.0]}))); + } + return out; } - return out; } if let Some(act) = auto_add_delete(cx, ev.pos, ev.mods, tol) { return vec![act]; @@ -288,21 +327,21 @@ impl Tool for PenTool { } fn key(&mut self, cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec { match key { - ToolKey::Enter | ToolKey::Escape => { - self.stop(); - self.drag = None; - self.handle = None; - self.snap.clear(); - // An Alt gesture under way ends as it stands. - self.convert.take().map(|mut t| t.deactivate(cx)).unwrap_or_default() - } + ToolKey::Enter | ToolKey::Escape => self.deactivate(cx), _ => vec![], } } fn deactivate(&mut self, cx: &ToolContext) -> Vec { self.stop(); self.snap.clear(); - self.convert.take().map(|mut t| t.deactivate(cx)).unwrap_or_default() + let placing = self.drag.take().is_some(); + let handling = self.handle.take().is_some(); + // Finish the preview as it stands; clearing state alone leaves its transaction open. + let mut acts = self.convert.take().map(|mut t| t.deactivate(cx)).unwrap_or_default(); + if placing || handling { + acts.push(Action::Commit); + } + acts } fn overlays(&self, cx: &ToolContext) -> Vec { if let Some(t) = &self.convert { @@ -329,14 +368,13 @@ impl Tool for PenTool { fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { let tol = cx.tol(5.0).max(cx.point_tol()); if let Some((id, first, last, _)) = self.active(cx) { - let one = last_anchor(cx, id).is_some_and(|(_, ai)| ai == 0); - if p.distance(first) <= tol && !one { - return Cursor::PenClose; + let closing_end = last_anchor(cx, id).filter(|(_, ai)| *ai > 0).map(|_| first); + match pick_endpoint(closing_end, last, p, tol, Endpoint::First) { + Some(Endpoint::First) => return Cursor::PenClose, + Some(Endpoint::Last) => return Cursor::PenConvert, + None => {} } - if p.distance(last) <= tol { - return Cursor::PenConvert; - } - if open_end_at(cx, p, tol, Some(id)).is_some() { + if !alt_converts(cx, p, m) && open_end_at(cx, p, tol, Some(id)).is_some() { return Cursor::PenJoin; } } @@ -349,7 +387,7 @@ impl Tool for PenTool { Some([_]) => return Cursor::PenAdd, _ => {} } - if active_path(cx).is_some_and(|(_, first, last, _)| p.distance(last) <= tol || p.distance(first) <= tol) + if active_path(cx).is_some_and(|(_, first, last, _)| pick_endpoint(Some(first), last, p, tol, Endpoint::Last).is_some()) || open_end_at(cx, p, tol, None).is_some() { return Cursor::PenContinue; @@ -427,6 +465,172 @@ mod tests { use crate::testutil::*; use vectorcraft_doc::Selection; + #[test] + fn snapped_last_endpoint_converts_active_path_after_raw_miss() { + let first = Point::new(100.0, 300.0); + let last = Point::new(200.0, 400.0); + let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[first, Point::new(200.0, 300.0), last], false)); + d.grid.spacing = 100.0; + d.grid.subdivisions = 1; + let paint = paint(); + let cx = ToolContext { + zoom: 1.0, + selection_tolerance: 8.0, + smart_guides: false, + snap_to_point: false, + snap_to_pixel: false, + snap_to_grid: true, + ..cx(&d, &s, &paint) + }; + let sp = &d.node(id).unwrap().path_data().unwrap().subpaths[0]; + assert!(!sp.closed); + assert_eq!(sp.anchors.iter().map(|a| a.p).collect::>(), vec![first, Point::new(200.0, 300.0), last]); + let mut pen = PenTool::default(); + assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); + assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + assert!(!pen.busy()); + + let raw = Point::new(237.0, 403.0); + let tol = cx.tol(5.0).max(cx.point_tol()); + assert!(raw.distance(first) > tol && raw.distance(last) > tol); + let from = Leave::segment(&cx, last, false); + assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), last); + assert!(open_end_at(&cx, raw, tol, Some(id)).is_none()); + assert!(open_end_at(&cx, last, tol, Some(id)).is_none()); + + assert_eq!( + pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), + vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 2, last.x, last.y)] + ); + assert_eq!(pen.handle, Some((id, 0, 2, last))); + assert!(pen.drag.is_none() && pen.busy()); + assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)), vec![Action::Commit]); + assert!(pen.handle.is_none() && !pen.busy()); + assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + } + + #[test] + fn raw_other_endpoint_wins_before_snapped_active_last_endpoint() { + let first = Point::new(100.0, 300.0); + let last = Point::new(200.0, 400.0); + let raw = Point::new(237.0, 403.0); + let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[first, Point::new(200.0, 300.0), last], false)); + let other = d.alloc_id(); + let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&[raw, Point::new(337.0, 403.0)], false)); + d.insert(Some(d.layers[0].id), 2, vectorcraft_doc::Node::path(other, path, vectorcraft_doc::Appearance::default_art())).unwrap(); + d.grid.spacing = 100.0; + d.grid.subdivisions = 1; + let paint = paint(); + let cx = ToolContext { + zoom: 1.0, + selection_tolerance: 8.0, + smart_guides: false, + snap_to_point: false, + snap_to_pixel: false, + snap_to_grid: true, + ..cx(&d, &s, &paint) + }; + let mut pen = PenTool::default(); + assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); + assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + assert!(!pen.busy()); + let tol = cx.tol(5.0).max(cx.point_tol()); + assert!(raw.distance(first) > tol && raw.distance(last) > tol); + assert_eq!(open_end_at(&cx, raw, tol, Some(id)), Some((other, true))); + let from = Leave::segment(&cx, last, false); + assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), last); + + assert_eq!( + pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), + vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", "first"]}))] + ); + assert!(!pen.busy() && pen.active(&cx).is_none()); + assert!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)).is_empty()); + } + + #[test] + fn snapped_first_endpoint_closes_active_path_after_raw_miss() { + let first = Point::new(100.0, 300.0); + let last = Point::new(200.0, 400.0); + let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[first, Point::new(200.0, 300.0), last], false)); + d.grid.spacing = 100.0; + d.grid.subdivisions = 1; + let paint = paint(); + let cx = ToolContext { + zoom: 1.0, + selection_tolerance: 8.0, + smart_guides: false, + snap_to_point: false, + snap_to_pixel: false, + snap_to_grid: true, + ..cx(&d, &s, &paint) + }; + let sp = &d.node(id).unwrap().path_data().unwrap().subpaths[0]; + assert!(!sp.closed); + assert_eq!(sp.anchors.iter().map(|a| a.p).collect::>(), vec![first, Point::new(200.0, 300.0), last]); + let mut pen = PenTool::default(); + assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); + assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + assert!(!pen.busy()); + + let raw = Point::new(63.0, 303.0); + let tol = cx.tol(5.0).max(cx.point_tol()); + assert!(raw.distance(first) > tol && raw.distance(last) > tol); + let from = Leave::segment(&cx, last, false); + assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), first); + assert!(open_end_at(&cx, raw, tol, Some(id)).is_none()); + assert!(open_end_at(&cx, first, tol, Some(id)).is_none()); + + assert_eq!( + pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + ); + let place = pen.drag.unwrap(); + assert!(place.closing); + assert_eq!((place.path, place.at), (Some(id), first)); + assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)), vec![Action::Commit]); + assert!(!pen.busy() && pen.active(&cx).is_none()); + } + + #[test] + fn raw_other_endpoint_wins_before_snapped_active_first_endpoint() { + let first = Point::new(100.0, 300.0); + let last = Point::new(200.0, 400.0); + let raw = Point::new(63.0, 303.0); + let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[first, Point::new(200.0, 300.0), last], false)); + let other = d.alloc_id(); + let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&[raw, Point::new(63.0, 403.0)], false)); + d.insert(Some(d.layers[0].id), 2, vectorcraft_doc::Node::path(other, path, vectorcraft_doc::Appearance::default_art())).unwrap(); + d.grid.spacing = 100.0; + d.grid.subdivisions = 1; + let paint = paint(); + let cx = ToolContext { + zoom: 1.0, + selection_tolerance: 8.0, + smart_guides: false, + snap_to_point: false, + snap_to_pixel: false, + snap_to_grid: true, + ..cx(&d, &s, &paint) + }; + let mut pen = PenTool::default(); + assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty()); + assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last))); + assert!(!pen.busy()); + let tol = cx.tol(5.0).max(cx.point_tol()); + assert!(raw.distance(first) > tol && raw.distance(last) > tol); + assert_eq!(open_end_at(&cx, raw, tol, Some(id)), Some((other, true))); + let from = Leave::segment(&cx, last, false); + assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), first); + + assert_eq!( + pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)), + vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", "first"]}))] + ); + assert!(!pen.busy() && pen.active(&cx).is_none()); + assert!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)).is_empty()); + } + #[test] fn first_click_creates_path() { let (d, _) = doc_with_rect(); @@ -557,6 +761,252 @@ mod tests { assert_eq!(t.cursor(&cx, Point::new(10.0, 300.0), Mods::default()), Cursor::PenClose); } + /// #544: picking the terminal anchor uses the pointer, even when new points snap elsewhere. + #[test] + fn terminal_anchor_conversion_precedes_grid_snapping() { + let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(13.0, 303.0), Point::new(63.0, 303.0)], false)); + d.grid.spacing = 100.0; + d.grid.subdivisions = 1; + let p = paint(); + let cx = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; + let mut t = PenTool { drawing: true, ..PenTool::default() }; + assert_eq!(t.cursor(&cx, Point::new(63.0, 303.0), Mods::default()), Cursor::PenConvert); + assert_eq!( + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 63.0, 303.0)), + vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 63.0, 303.0)] + ); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 63.0, 303.0)), vec![Action::Commit]); + // The same raw picking applies when resuming the path and when closing it. + let mut t = PenTool::default(); + assert_eq!(t.cursor(&cx, Point::new(63.0, 303.0), Mods::default()), Cursor::PenContinue); + assert!(click(&mut t, &cx, 63.0, 303.0, Mods::default()).is_empty()); + assert_eq!(t.active(&cx).map(|a| a.0), Some(id)); + assert_eq!( + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 13.0, 303.0)), + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + ); + } + + #[test] + fn smart_guides_cannot_steal_a_terminal_anchor_press() { + let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false)); + let other = d.alloc_id(); + let rect = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(66.0, 300.0, 76.0, 310.0)); + d.insert(Some(d.layers[0].id), 2, vectorcraft_doc::Node::path(other, rect, vectorcraft_doc::Appearance::default_art())).unwrap(); + let p = paint(); + let cx = cx(&d, &s, &p); + let pos = Point::new(63.0, 300.0); + assert_eq!(crate::guides::snap_draw(&cx, pos, &[id]).0, Point::new(66.0, 300.0)); + let mut t = PenTool { drawing: true, ..PenTool::default() }; + assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::PenConvert); + assert_eq!( + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pos.x, pos.y)), + vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)] + ); + } + + /// Nearby endpoints share a pick radius: use the closest one for cursor and press. + #[test] + fn overlapping_endpoint_tolerances_pick_the_nearest_end() { + let paint = paint(); + let mut failures = vec![]; + for zoom in [0.5, 1.0, 2.0] { + let first = Point::new(100.0, 300.0); + let last = Point::new(100.0 + 6.0 / zoom, 300.0); + let (d, id, selection) = drawing(vectorcraft_geom::SubPath::polyline(&[first, last], false)); + for grid in [false, true] { + let cx = ToolContext { + zoom, + selection_tolerance: 8.0, + smart_guides: false, + snap_to_point: false, + snap_to_grid: grid, + ..cx(&d, &selection, &paint) + }; + for shift in [false, true] { + let mods = Mods { shift, ..Mods::default() }; + // Include off-anchor clicks in both halves of the overlap and the midpoint. + for offset in [6.0, 0.0, 1.0, 3.0, 5.0] { + let at = Point::new(first.x + offset / zoom, first.y); + let first_end = offset <= 3.0; + let mut pen = PenTool { drawing: true, ..PenTool::default() }; + let cursor = if first_end { Cursor::PenClose } else { Cursor::PenConvert }; + let actual_cursor = pen.cursor(&cx, at, mods); + let expected = if first_end { + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + } else { + vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, last.x, last.y)] + }; + let actual = pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(mods)); + if actual_cursor != cursor || actual != expected { + failures.push((zoom, grid, shift, offset, actual_cursor, actual)); + } + assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y)), vec![Action::Commit]); + } + } + } + } + assert!(failures.is_empty(), "cursor and press must pick the closest eligible endpoint: {failures:?}"); + } + + #[test] + fn overlapping_endpoint_tolerances_continue_from_the_nearest_end() { + let paint = paint(); + let mut failures = vec![]; + for zoom in [0.5, 1.0, 2.0] { + let first = Point::new(100.0, 300.0); + let last = Point::new(100.0 + 6.0 / zoom, 300.0); + let (d, id, selection) = drawing(vectorcraft_geom::SubPath::polyline(&[first, last], false)); + for grid in [false, true] { + let cx = ToolContext { + zoom, + selection_tolerance: 8.0, + smart_guides: false, + snap_to_point: false, + snap_to_grid: grid, + ..cx(&d, &selection, &paint) + }; + for offset in [6.0, 0.0, 1.0, 3.0, 5.0] { + let at = Point::new(first.x + offset / zoom, first.y); + let mut pen = PenTool::default(); + assert_eq!(pen.cursor(&cx, at, Mods::default()), Cursor::PenContinue); + let expected = if offset < 3.0 { vec![Action::Exec("path.reverse".into(), json!({}))] } else { vec![] }; + let actual = click(&mut pen, &cx, at.x, at.y, Mods::default()); + if actual != expected { + failures.push((zoom, grid, offset, actual)); + } + assert_eq!(pen.active(&cx).map(|a| a.0), Some(id)); + } + } + } + assert!(failures.is_empty(), "resuming must reverse only for the closest first endpoint: {failures:?}"); + } + + #[test] + fn coincident_endpoints_keep_the_prior_tie_choice() { + let at = Point::new(100.0, 300.0); + let (d, id, selection) = drawing(vectorcraft_geom::SubPath::polyline(&[at, at], false)); + let paint = paint(); + for zoom in [0.5, 1.0, 2.0] { + let cx = ToolContext { zoom, smart_guides: false, snap_to_point: false, ..cx(&d, &selection, &paint) }; + let mut drawing = PenTool { drawing: true, ..PenTool::default() }; + assert_eq!(drawing.cursor(&cx, at, Mods::default()), Cursor::PenClose); + assert_eq!( + click(&mut drawing, &cx, at.x, at.y, Mods::default()), + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + ); + let mut resuming = PenTool::default(); + assert_eq!(resuming.cursor(&cx, at, Mods::default()), Cursor::PenContinue); + assert!(click(&mut resuming, &cx, at.x, at.y, Mods::default()).is_empty()); + } + } + + #[test] + fn terminal_anchor_uses_the_selection_tolerance_at_each_zoom() { + let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false)); + let p = paint(); + for zoom in [0.5, 2.0] { + let cx = ToolContext { zoom, selection_tolerance: 8.0, smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; + let pos = Point::new(60.0, 300.0 + 6.0 / zoom); + let mut t = PenTool { drawing: true, ..PenTool::default() }; + assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::PenConvert); + assert_eq!( + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pos.x, pos.y)), + vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)] + ); + let narrow = ToolContext { selection_tolerance: 1.0, ..cx }; + // The upstream point picker retains a minimum screen radius at low tolerance. + let near = Point::new(60.0, 300.0 + 4.0 / zoom); + let mut narrow_pen = PenTool { drawing: true, ..PenTool::default() }; + assert_eq!(narrow_pen.cursor(&narrow, near, Mods::default()), Cursor::PenConvert); + assert_eq!( + click(&mut narrow_pen, &narrow, near.x, near.y, Mods::default()), + vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)] + ); + assert_eq!(narrow_pen.cursor(&narrow, Point::new(60.0, 300.0 + 6.0 / zoom), Mods::default()), Cursor::Pen); + } + } + + /// Upstream's point radius grows with the drawn anchor; cursor and press keep agreeing. + #[test] + fn enlarged_anchor_radius_is_used_for_endpoints_and_alt_conversion() { + let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false)); + let p = paint(); + for zoom in [0.5, 1.0, 2.0] { + let cx = ToolContext { zoom, selection_tolerance: 1.0, anchor_size: 7, smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; + let at = Point::new(60.0 + 6.0 / zoom, 300.0); + let mut terminal = PenTool { drawing: true, ..PenTool::default() }; + assert_eq!(terminal.cursor(&cx, at, Mods::default()), Cursor::PenConvert); + assert_eq!( + click(&mut terminal, &cx, at.x, at.y, Mods::default()), + vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)] + ); + let mut continuing = PenTool::default(); + assert_eq!(continuing.cursor(&cx, at, Mods::default()), Cursor::PenContinue); + assert!(click(&mut continuing, &cx, at.x, at.y, Mods::default()).is_empty()); + assert_eq!(continuing.active(&cx).map(|a| a.0), Some(id)); + let mut closing = PenTool { drawing: true, ..PenTool::default() }; + let first = Point::new(10.0 - 6.0 / zoom, 300.0); + assert_eq!(closing.cursor(&cx, first, Mods::default()), Cursor::PenClose); + assert_eq!( + click(&mut closing, &cx, first.x, first.y, Mods::default()), + vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))] + ); + let mut alt_pen = PenTool { drawing: true, ..PenTool::default() }; + let alt = Mods { alt: true, ..Mods::default() }; + assert!(alt_pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(alt)).is_empty()); + assert!(alt_pen.busy(), "Alt must reach the enlarged terminal-anchor target"); + assert!(alt_pen.convert.is_some()); + let away_from_segment = Point::new(35.0, 300.0 + 4.0 / zoom); + assert!(!alt_converts(&cx, away_from_segment, alt), "segment picking retains the smaller selection radius"); + } + } + + /// #516: Alt on the terminal anchor borrows Anchor Point, just like the other anchors. + #[test] + fn alt_on_the_terminal_anchor_pulls_symmetric_handles() { + let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false)); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Mods::default() }; + let mut t = PenTool { drawing: true, ..PenTool::default() }; + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 300.0).with_mods(alt)).is_empty()); + assert_eq!( + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 230.0, 330.0).with_mods(alt)), + vec![ + Action::Begin("Convert Anchor Point".into()), + Action::Preview("path.convertAnchor".into(), json!({"id": id.0, "subpath": 0, "anchor": 1, "to": "smooth", "x": 230.0, "y": 330.0})) + ] + ); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 230.0, 330.0)), vec![Action::Commit]); + assert!( + matches!(click(&mut t, &cx, 300.0, 400.0, Mods::default()).as_slice(), [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor") + ); + } + + #[test] + fn ending_during_a_pen_gesture_commits_and_clears_it() { + let (d, _, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false)); + let p = paint(); + let cx = cx(&d, &s, &p); + for terminal in [false, true] { + for key in [Some(ToolKey::Escape), Some(ToolKey::Enter), None] { + let mut t = PenTool { drawing: true, ..PenTool::default() }; + let x = if terminal { 200.0 } else { 300.0 }; + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, 300.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 20.0, 330.0)); + let acts = match key { + Some(k) => t.key(&cx, k, Mods::default()), + None => t.deactivate(&cx), + }; + assert_eq!(acts, vec![Action::Commit]); + assert!(!t.busy()); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x + 20.0, 330.0)).is_empty()); + assert_eq!(t.cursor(&cx, Point::new(400.0, 400.0), Mods::default()), Cursor::Pen); + } + } + } + /// #501: a press on the first anchor closes the path, however far the drag then goes: the /// closing preview leaves the path closed (so not one the pen extends), and each move still /// previews the close with symmetric handles (Alt breaks them), released as a close.