From 9f74540d58c1280c25fe5cf473bdcfd13038ac8b Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Mon, 26 Oct 2026 18:49:33 +0700 Subject: [PATCH] G11: Anchor Point tool Space moves anchor mid-drag Squashed from #4254 onto main. --- crates/tools/src/draw2/anchor.rs | 115 ++++++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 3 deletions(-) diff --git a/crates/tools/src/draw2/anchor.rs b/crates/tools/src/draw2/anchor.rs index e3813d38e..f1950d8f3 100644 --- a/crates/tools/src/draw2/anchor.rs +++ b/crates/tools/src/draw2/anchor.rs @@ -7,8 +7,9 @@ //! 45° steps round its anchor); drag a segment → reshape the curve, a selected blend's spine's too, //! straight or curved (#1111; Shift: its handles at right angles to the line between its anchors, //! at one length). Shift held at the press on an anchor moves it instead, handles and all (Shift -//! still held: at 45° steps from where it was). Blend spine points and handles follow the same -//! conversions (#2544). +//! still held: at 45° steps from where it was). Space held during a convert- or handle-drag moves +//! the anchor, handles and all, as while drawing with the Pen. Blend spine points and handles +//! follow the same conversions (#2544). //! Scissors: click a path to split it there. use serde_json::json; @@ -83,6 +84,15 @@ enum State { }, } +/// Gesture to resume after Space is released mid-drag (#3694). +#[derive(Clone, Copy, Debug)] +enum SpaceResume { + Convert { id: NodeId, si: usize, ai: usize }, + Handle { id: NodeId, si: usize, ai: usize, out: bool }, + SpineConvert { id: NodeId, ai: usize }, + SpineHandle { id: NodeId, ai: usize, out: bool }, +} + pub struct AnchorTool { id: &'static str, state: State, @@ -91,6 +101,8 @@ pub struct AnchorTool { snap: HandleSnap, /// Move offset while Shift-dragging an anchor or spine point (pointer, delta). measure: Option<(Point, f64, f64)>, + /// While Space is held during a convert/handle drag, the shape gesture to return to. + space_resume: Option, } impl AnchorTool { @@ -101,7 +113,11 @@ impl AnchorTool { "scissors" => "scissors", _ => "addAnchor", }; - Self { id, state: State::Idle, guides: vec![], snap: HandleSnap::default(), measure: None } + Self { id, state: State::Idle, guides: vec![], snap: HandleSnap::default(), measure: None, space_resume: None } + } + + fn anchor_pos(cx: &ToolContext, id: NodeId, si: usize, ai: usize) -> Option { + cx.doc.node(id).and_then(|n| n.path_data()).and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai).map(|a| a.p)) } /// Nearest point of this eligible spine, in document coordinates. @@ -216,6 +232,49 @@ impl AnchorTool { } } + fn space_move_from(cx: &ToolContext, st: State, p: Point, began: bool) -> (State, Option) { + match st { + State::Convert { id, si, ai, .. } => match Self::anchor_pos(cx, id, si, ai) { + Some(from) => (State::Move { id, si, ai, from, start: p, began }, Some(SpaceResume::Convert { id, si, ai })), + None => (st, None), + }, + State::Handle { id, si, ai, out, .. } => match Self::anchor_pos(cx, id, si, ai) { + Some(from) => (State::Move { id, si, ai, from, start: p, began }, Some(SpaceResume::Handle { id, si, ai, out })), + None => (st, None), + }, + State::SpineConvert { id, ai, .. } => { + match crate::direct::spines(cx) + .into_iter() + .find(|(blend, _)| *blend == id) + .and_then(|(_, path)| path.subpaths.first()?.anchors.get(ai).map(|a| a.p)) + { + Some(from) => (State::SpineMove { id, ai, from, start: p, began }, Some(SpaceResume::SpineConvert { id, ai })), + None => (st, None), + } + } + State::SpineHandle { id, ai, out, .. } => { + match crate::direct::spines(cx) + .into_iter() + .find(|(blend, _)| *blend == id) + .and_then(|(_, path)| path.subpaths.first()?.anchors.get(ai).map(|a| a.p)) + { + Some(from) => (State::SpineMove { id, ai, from, start: p, began }, Some(SpaceResume::SpineHandle { id, ai, out })), + None => (st, None), + } + } + other => (other, None), + } + } + + fn resume_shape(p: Point, resume: SpaceResume, began: bool) -> State { + match resume { + SpaceResume::Convert { id, si, ai } => State::Convert { id, si, ai, start: p, began }, + SpaceResume::Handle { id, si, ai, out } => State::Handle { id, si, ai, out, start: p, began }, + SpaceResume::SpineConvert { id, ai } => State::SpineConvert { id, ai, start: p, began }, + SpaceResume::SpineHandle { id, ai, out } => State::SpineHandle { id, ai, out, start: p, began }, + } + } + /// How far a Shift-dragged anchor has gone: Shift still held keeps it at 45° steps. fn move_delta(cx: &ToolContext, start: Point, p: Point, shift: bool) -> vectorcraft_geom::Vec2 { if shift { constrain_angle_from(p - start, 45.0, cx.constrain_angle) } else { p - start } @@ -266,6 +325,7 @@ impl Tool for AnchorTool { } } ("anchorPoint", PointerKind::Down) => { + self.space_resume = None; self.state = Self::press_target(cx, p); if ev.mods.shift { self.state = Self::moving(cx, self.state); @@ -310,6 +370,27 @@ impl Tool for AnchorTool { *began = true; } } + if ev.mods.space { + if self.space_resume.is_none() + && matches!(self.state, State::Convert { .. } | State::Handle { .. } | State::SpineConvert { .. } | State::SpineHandle { .. }) + { + let was_began = match self.state { + State::Convert { began, .. } + | State::Handle { began, .. } + | State::SpineConvert { began, .. } + | State::SpineHandle { began, .. } => began, + _ => false, + }; + let (st, resume) = Self::space_move_from(cx, self.state, p, was_began); + if let Some(r) = resume { + self.state = st; + self.space_resume = Some(r); + } + } + } else if let Some(resume) = self.space_resume.take() { + let was_began = matches!(self.state, State::Move { began: true, .. } | State::SpineMove { began: true, .. }); + self.state = Self::resume_shape(p, resume, was_began); + } let preview = match self.state { State::Idle => return out, State::Convert { id, si, ai, .. } => Action::Preview( @@ -376,6 +457,7 @@ impl Tool for AnchorTool { ("anchorPoint", PointerKind::Up) => { self.guides.clear(); self.measure = None; + self.space_resume = None; let st = std::mem::replace(&mut self.state, State::Idle); match st { State::Convert { began: true, .. } @@ -984,6 +1066,33 @@ mod tests { assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0, 300.0).with_mods(shift)).is_empty()); } + /// #3694: Space held mid-drag on a convert moves the anchor, handles and all. + #[test] + fn anchor_point_space_mid_drag_moves_the_anchor() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(300.0, 300.0)], false); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, vectorcraft_geom::PathData::single(sp), vectorcraft_doc::Appearance::default_art())) + .unwrap(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let space = Mods { space: true, ..Mods::default() }; + let moved = |x: f64, y: f64| Action::Preview("path.moveAnchor".into(), json!({"id": id.0, "subpath": 0, "anchor": 1, "x": x, "y": y})); + let mut t = AnchorTool::new("anchorPoint"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 300.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 230.0, 340.0)); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.convertAnchor"), "{a:?}"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 240.0, 350.0).with_mods(space)); + assert_eq!(a, vec![moved(200.0, 300.0)]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 360.0).with_mods(space)); + assert_eq!(a, vec![moved(210.0, 310.0)]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 260.0, 370.0)); + assert!(matches!(&a[..], [Action::Preview(c, _)] if c == "path.convertAnchor"), "{a:?}"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 260.0, 370.0)), vec![Action::Commit]); + } + #[test] fn anchor_point_shift_drag_honors_measurement_labels() { let (mut d, _) = doc_with_rect();