Merge pull request #4254 from nrq6993-a11y/stream/vc-issue-3694-anchor-space

G11: Anchor Point tool Space moves anchor mid-drag
This commit is contained in:
BFlat
2026-10-26 18:53:43 +07:00
committed by GitHub
+112 -3
View File
@@ -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<SpaceResume>,
}
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<Point> {
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<SpaceResume>) {
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();