G1: Group Selection Shift-click deselects compound path members

Match the Selection tool: Shift on an already selected hit removes it on
release without a drag, including one path of a compound path.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
nrq6993-a11y
2026-10-20 07:00:49 +07:00
committed by bflatastic
co-authored by Cursor
parent 891793b3a4
commit 61f35cd037
2 changed files with 71 additions and 14 deletions
+70 -13
View File
@@ -62,6 +62,8 @@ enum State {
start: Point,
grab: Option<Point>,
began: bool,
/// Group Selection: Shift-click on a selected object toggles it off on release.
deselect: Option<NodeId>,
},
/// Dragging a curved segment: it follows the pointer (`path.reshapeSegment`).
Segment {
@@ -452,14 +454,25 @@ impl Tool for DirectSelectionTool {
} else {
h.leaf
};
self.state = State::MoveObject { start: p, grab: None, began: false };
if cx.selection.contains(target) {
vec![]
} else if ev.mods.shift || cx.selection.contains(h.leaf) {
vec![Action::Exec("select.add".into(), json!({"ids": [target.0]}))]
} else {
vec![Action::Exec("select.set".into(), json!({"ids": [target.0]}))]
let mut deselect = None;
let mut out = vec![];
if ev.mods.shift {
// Shift on a selected member deselects what was hit (a compound path's part),
// not the next ancestor a plain click would add (#2441).
if cx.selection.contains(h.leaf) {
deselect = Some(h.leaf);
} else {
out.push(Action::Exec("select.toggle".into(), json!({"id": target.0})));
}
} else if !cx.selection.contains(target) {
if cx.selection.contains(h.leaf) {
out.push(Action::Exec("select.add".into(), json!({"ids": [target.0]})));
} else {
out.push(Action::Exec("select.set".into(), json!({"ids": [target.0]})));
}
}
self.state = State::MoveObject { start: p, grab: None, began: false, deselect };
out
}
(PointerKind::DoubleClick, _) if self.group => release_guide_at(cx, p, ev.mods).into_iter().collect(),
(PointerKind::DoubleClick, _) if !self.group => {
@@ -522,7 +535,7 @@ impl Tool for DirectSelectionTool {
}
// Alt picks the whole path (and Alt-drag copies it), as on a segment.
if ev.mods.alt {
self.state = State::MoveObject { start: p, grab: Some(grab), began: false };
self.state = State::MoveObject { start: p, grab: Some(grab), began: false, deselect: None };
let whole = cx.selection.contains(id) && cx.selection.partial(id).is_none();
return if whole { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] };
}
@@ -533,7 +546,7 @@ impl Tool for DirectSelectionTool {
// Paths all selected whole move as objects, held by this anchor, so Alt can turn
// the copy on and off during the drag as with the Selection tool.
self.state = if already && cx.selection.anchors.is_empty() {
State::MoveObject { start: p, grab: Some(grab), began: false }
State::MoveObject { start: p, grab: Some(grab), began: false, deselect: None }
} else {
State::MoveAnchors { start: p, grab, began: false }
};
@@ -586,7 +599,7 @@ impl Tool for DirectSelectionTool {
}
}
// Clicking the fill selects the whole leaf path (all anchors).
self.state = State::MoveObject { start: p, grab: None, began: false };
self.state = State::MoveObject { start: p, grab: None, began: false, deselect: None };
if ev.mods.shift {
return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))];
}
@@ -638,14 +651,14 @@ impl Tool for DirectSelectionTool {
));
out
}
(PointerKind::Drag, State::MoveObject { start, grab, began }) => {
(PointerKind::Drag, State::MoveObject { start, grab, began, .. }) => {
let mut out = vec![];
if !began {
if p.distance(start) < cx.tol(3.0) {
return out;
}
out.push(Action::Begin(if ev.mods.alt { "Copy".into() } else { "Move".into() }));
self.state = State::MoveObject { start, grab, began: true };
self.state = State::MoveObject { start, grab, began: true, deselect: None };
if grab.is_some() {
// Alt can go down or up at any time: gather both while nothing has moved.
self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
@@ -754,10 +767,25 @@ impl Tool for DirectSelectionTool {
self.state = State::Idle;
b.finish()
}
(PointerKind::Up, State::MoveObject { began, deselect, .. }) => {
self.state = State::Idle;
self.guides.clear();
(self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None);
match (began, deselect, self.click.take(), self.click_segment.take()) {
(true, ..) => vec![Action::Commit],
(false, Some(id), _, _) => vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))],
(false, None, Some((id, si, ai)), _) => {
vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))]
}
(false, None, None, Some((id, si, seg))) => {
vec![Action::Exec("select.segments".into(), json!({"id": id.0, "segments": [[si, seg]]}))]
}
(false, None, None, None) => vec![],
}
}
(
PointerKind::Up,
State::MoveAnchors { began, .. }
| State::MoveObject { began, .. }
| State::Segment { began, .. }
| State::SpinePoint { began, .. }
| State::SpineSegment { began, .. }
@@ -1377,6 +1405,35 @@ mod tests {
assert_eq!(click(&s, 25.0, 100.0), vec![Action::Exec("select.add".into(), json!({"ids": [compound.0]}))]);
}
/// Group Selection Shift-click on a selected compound-path member deselects it on release.
#[test]
fn group_selection_shift_click_deselects_a_compound_path_member() {
let mut d = vectorcraft_doc::Document::new(500.0, 500.0);
let l = d.layers[0].id;
let (outer, hole, compound) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
let rect = |id, a: f64, b: f64| {
std::sync::Arc::new(vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(a, a, b, b)), Default::default()))
};
let mut c = vectorcraft_doc::Node::new(
compound,
vectorcraft_doc::NodeKind::Compound {
children: vec![rect(outer, 0.0, 200.0), rect(hole, 50.0, 150.0)],
rule: vectorcraft_geom::FillRule::EvenOdd,
},
);
c.appearance = vectorcraft_doc::Appearance::default_art().into();
d.insert(Some(l), 0, c).unwrap();
let p = paint();
let mut s = Selection::default();
s.set([outer]);
let shift = Mods { shift: true, ..Mods::default() };
let cx = cx(&d, &s, &p);
let mut t = DirectSelectionTool::new(true);
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 25.0, 100.0).with_mods(shift));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 25.0, 100.0).with_mods(shift));
assert_eq!(a, vec![Action::Exec("select.toggle".into(), json!({"id": outer.0}))]);
}
/// A path inside an inner group, a sibling path outside it, and the groups' ids.
/// The inside path is the rectangle at (100, 100); the outside one at (300, 300).
fn nested_group_doc() -> (vectorcraft_doc::Document, NodeId, NodeId, NodeId, NodeId) {
+1 -1
View File
@@ -86,7 +86,7 @@ tools, never checked side by side) isn't captured row by row.
| Convert selected anchors to corner/smooth from the Control bar and Properties | done | |
| Cut path at selected anchors, Remove Anchor Points, Delete removes segments | done | Open-path anchor Delete removes adjoining segments without bridging the gap; targeted core and full-frame Delete/Backspace regressions pass for survivor selection and Undo/Redo. Explicit Remove Anchor Points keeps its reconnect/refit behavior; #1278: segment Delete preserves neighboring edges and endpoint anchors; a second Delete removes the remainder; explicit anchor Delete retains its behavior; #1674: Delete removes every selected anchor of a closed path and objects selected whole along with them, and keeps the remainder selected; #2241: Remove Anchor Points then selects the next anchor along the path (the one before at an open end), so it can be run again without picking each anchor |
| Direct Selection and Group Selection pick only inside the isolated object in isolation mode | done | #1666 |
| Group Selection picks one path of a compound path (the outer shape or a hole) and moves it on its own; a second click adds the compound path | done | #2441, in part (Shift-click to deselect is not covered) |
| Group Selection picks one path of a compound path (the outer shape or a hole) and moves it on its own; a second click adds the compound path | done | #2441; Shift-click on a selected member deselects it on release |
| Escape cancels a Direct Selection drag | done | #1803 (#1743) |
| Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | a Direct Selection click on a widget selects its corner (#1239); a press-and-drag on a widget of a path selected whole still rounds every corner (#908), and adding widgets to the selection by Shift-click (#1232) is missing; the bounding box hides while a widget is dragged with the Selection tool (#1229); a round corner next to a curved side curves smoothly instead of sticking out of the shape (#1512); a corner rounds until its cut reaches its neighbours' cuts, so one corner of a square alone reaches the whole side (#2332) |
| Corners between curved sides (#1144), Relative rounding | partial | Relative rounding missing |