Direct Selection: a further Alt-click adds the path's group, then the next group up

Squashed from #4381 onto main.
This commit is contained in:
dotCooCoo
2026-10-29 20:47:39 +07:00
committed by bflatastic
parent e446a9a69b
commit 85a0adc5b8
3 changed files with 159 additions and 11 deletions
+40 -11
View File
@@ -3,8 +3,9 @@
//! Direct Selection: click an anchor to select it (Shift toggles), click a segment to select the
//! segment independently of its endpoint anchors, drag to move selected anchors (the one pressed on snaps to
//! anchors, segments and Smart Guides, or with them off Snap to Point; Shift keeps the move at 45°
//! steps; Alt on an anchor or a segment picks the whole path and Alt-drag copies it), drag a
//! direction handle to
//! steps; Alt on an anchor or a segment picks the whole path and Alt-drag copies it; an Alt-click
//! on a path selected whole adds its group, and each further Alt-click adds the next group up, as
//! Group Selection's clicks do), drag a direction handle to
//! reshape (Shift keeps it at 45° steps round its anchor, Alt moves it alone; smart guides snap
//! it), marquee to select anchors (Shift-drag toggles them: the selected ones inside are
//! deselected, the others selected), drag a corner widget of any path (a star, a pen path) to round its corners
@@ -178,6 +179,9 @@ pub struct DirectSelectionTool {
click: Option<(NodeId, usize, usize)>,
/// A click on a whole path picks the segment on release; a drag still moves the whole path.
click_segment: Option<(NodeId, usize, usize)>,
/// An Alt-click on a path selected whole adds this enclosing group on release, as a Group
/// Selection click does; an Alt-drag still copies the selection.
click_group: Option<NodeId>,
}
impl DirectSelectionTool {
@@ -199,6 +203,7 @@ impl DirectSelectionTool {
guide: GuideEdit::default(),
click: None,
click_segment: None,
click_group: None,
}
}
@@ -444,6 +449,23 @@ struct SegmentHit {
curved: bool,
}
/// The group a Group Selection click on a selected object adds, given the object's `ancestry`
/// (its layer and sublayers first, the object last). It walks up from the object and returns the
/// first ancestor below the layer and its sublayers that is not yet selected. The walk stops at the
/// isolated object, since the next group up is outside isolation mode, and at any layer or
/// sublayer, including one inside a group.
fn next_group(cx: &ToolContext, ancestry: &[NodeId]) -> Option<NodeId> {
// Skip the layer and its sublayers: they are not objects.
let layers = ancestry.iter().take_while(|id| cx.doc.node(**id).is_some_and(Node::is_layer)).count().max(1);
ancestry
.iter()
.skip(layers)
.rev()
.copied()
.take_while(|id| in_scope(cx.doc, cx.isolation, *id) && !cx.doc.node(*id).is_some_and(Node::is_layer))
.find(|id| !cx.selection.contains(*id))
}
/// The segment of path `id` nearest to `p`, where the hit test found its stroke.
fn segment_at(cx: &ToolContext, id: NodeId, p: Point) -> Option<SegmentHit> {
let pd = cx.doc.node(id)?.path_data()?;
@@ -488,7 +510,7 @@ impl Tool for DirectSelectionTool {
self.state = State::Idle;
self.guides.clear();
(self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None);
(self.click, self.click_segment) = (None, None);
(self.click, self.click_segment, self.click_group) = (None, None, None);
vec![Action::Cancel]
}
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
@@ -512,6 +534,7 @@ impl Tool for DirectSelectionTool {
(PointerKind::Down, _) => {
self.click = None;
self.click_segment = None;
self.click_group = None;
if let Some(c) = CornerDrag::hit(cx, ev, true) {
self.state = State::Corner(c);
return vec![];
@@ -882,11 +905,7 @@ impl DirectSelectionTool {
h.ancestry.push(part);
h.leaf = part;
}
// Walk up from the leaf: select the first ancestor not yet selected (below the layer).
// Stop at the isolated object: the next group up is outside isolation mode.
let chain: Vec<NodeId> = h.ancestry.iter().skip(1).rev().copied().take_while(|id| in_scope(cx.doc, cx.isolation, *id)).collect();
let target =
if cx.selection.contains(h.leaf) { chain.iter().find(|id| !cx.selection.contains(**id)).copied().unwrap_or(h.leaf) } else { h.leaf };
let target = if cx.selection.contains(h.leaf) { next_group(cx, &h.ancestry).unwrap_or(h.leaf) } else { h.leaf };
let mut deselect = None;
let mut out = vec![];
if ev.mods.shift {
@@ -933,10 +952,14 @@ impl DirectSelectionTool {
if cx.doc.node(id).is_some_and(|n| is_area_type(n) || is_path_type(n)) {
return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift);
}
// Alt picks the whole path (and Alt-drag copies it), as on a segment.
// Alt picks the whole path (and Alt-drag copies it), as on a segment. On a path
// already selected whole, an Alt-click adds the next enclosing group on release.
if ev.mods.alt {
self.state = State::MoveObject { start: p, grab: Some(grab), hold: grab, began: false, deselect: None };
let whole = cx.selection.contains(id) && cx.selection.partial(id).is_none();
if whole && !ev.mods.shift {
self.click_group = cx.doc.ancestry(id).and_then(|a| next_group(cx, &a));
}
return if whole { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] };
}
// A path with all its anchors selected is stored whole (no partial entry).
@@ -993,8 +1016,13 @@ impl DirectSelectionTool {
return vec![Action::Exec("select.segments".into(), json!({"id": h.leaf.0, "segments": [[s.si, s.seg]]}))];
}
}
// Clicking the fill selects the whole leaf path (all anchors).
// Clicking the fill selects the whole leaf path (all anchors). With Alt, a click
// on a path already selected whole adds the next enclosing group on release,
// as Group Selection's click does; each further Alt-click climbs one level.
self.state = State::MoveObject { start: p, grab: None, hold: hold_point(cx, &h, p), began: false, deselect: None };
if ev.mods.alt && whole && !ev.mods.shift {
self.click_group = next_group(cx, &h.ancestry);
}
if ev.mods.shift {
return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))];
}
@@ -1099,6 +1127,7 @@ impl DirectSelectionTool {
self.state = State::Idle;
self.guides.clear();
(self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None);
let group = self.click_group.take();
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}))],
@@ -1108,7 +1137,7 @@ impl DirectSelectionTool {
(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![],
(false, None, None, None) => group.map(|g| Action::Exec("select.add".into(), json!({"ids": [g.0]}))).into_iter().collect(),
}
}
fn release_marquee(&mut self, cx: &ToolContext, p: Point, (start, toggle, enclosed): (Point, bool, bool)) -> Vec<Action> {
+118
View File
@@ -577,6 +577,124 @@ fn a_double_click_outside_isolation_leaves_it() {
assert!(DirectSelectionTool::new(false).pointer(&cx(&d, &none, &p), &PointerEvent::new(PointerKind::DoubleClick, 700.0, 700.0)).is_empty());
}
/// With Direct Selection, an Alt-click on a path selected whole adds its group on release, and
/// each further Alt-click adds the next group up, as Group Selection's clicks do, on an anchor,
/// a segment and the fill alike. The climb stops below the layer and at the isolated group.
#[test]
fn alt_clicks_on_a_path_selected_whole_add_its_groups() {
let (d, inside, _, inner, outer) = nested_group_doc();
let p = paint();
let alt = Mods { alt: true, ..Default::default() };
let add = |id: NodeId| vec![Action::Exec("select.add".into(), json!({"ids": [id.0]}))];
let click = |cx: &ToolContext, x: f64, y: f64| {
let mut t = DirectSelectionTool::new(false);
let down = t.pointer(cx, &PointerEvent::new(PointerKind::Down, x, y).with_mods(alt));
assert!(down.is_empty(), "({x}, {y}): {down:?}");
t.pointer(cx, &PointerEvent::new(PointerKind::Up, x, y).with_mods(alt))
};
for (x, y) in [(100.0, 100.0), (150.0, 100.0), (150.0, 150.0)] {
let mut s = Selection::default();
s.set([inside]);
assert_eq!(click(&cx(&d, &s, &p), x, y), add(inner), "({x}, {y}): the path's group");
s.set([inside, inner]);
assert_eq!(click(&cx(&d, &s, &p), x, y), add(outer), "({x}, {y}): the group around it");
s.set([inside, inner, outer]);
assert!(click(&cx(&d, &s, &p), x, y).is_empty(), "({x}, {y}): the layer is not added");
s.set([inside, inner]);
let isolated = ToolContext { isolation: Some(inner), ..cx(&d, &s, &p) };
assert!(click(&isolated, x, y).is_empty(), "({x}, {y}): not the group outside isolation");
}
}
/// In a sublayer, a Group Selection click and a Direct Selection Alt-click climb to the group
/// and stop there: the sublayer is not added.
#[test]
fn group_clicks_in_a_sublayer_stop_below_the_sublayer() {
use vectorcraft_doc::{Appearance, Document, LayerColor, Node};
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let (sub, g, a) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
d.insert(Some(l), 0, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap();
d.insert(Some(sub), 0, Node::group(g, vec![])).unwrap();
d.insert(Some(g), 0, Node::path(a, vectorcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), Appearance::default_art()))
.unwrap();
let p = paint();
let alt = Mods { alt: true, ..Default::default() };
let click = |s: &Selection, group: bool, mods: Mods| {
let c = cx(&d, s, &p);
let mut t = DirectSelectionTool::new(group);
let down = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(mods));
let up = t.pointer(&c, &PointerEvent::new(PointerKind::Up, 150.0, 150.0).with_mods(mods));
[down, up].concat()
};
let add = |id: NodeId| vec![Action::Exec("select.add".into(), json!({"ids": [id.0]}))];
let mut s = Selection::default();
s.set([a]);
assert_eq!(click(&s, true, Mods::default()), add(g), "Group Selection adds the group");
assert_eq!(click(&s, false, alt), add(g), "an Alt-click adds the group");
s.set([a, g]);
assert!(click(&s, true, Mods::default()).is_empty(), "Group Selection does not add the sublayer");
assert!(click(&s, false, alt).is_empty(), "an Alt-click does not add the sublayer");
}
/// When a group holds a sublayer, the climb stops at the sublayer: it adds neither the sublayer
/// nor the group above it.
#[test]
fn group_clicks_stop_at_a_sublayer_inside_a_group() {
use vectorcraft_doc::{Appearance, Document, LayerColor, Node};
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let (outer, sub, g, a) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id());
d.insert(Some(l), 0, Node::group(outer, vec![])).unwrap();
d.insert(Some(outer), 0, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap();
d.insert(Some(sub), 0, Node::group(g, vec![])).unwrap();
d.insert(Some(g), 0, Node::path(a, vectorcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), Appearance::default_art()))
.unwrap();
let p = paint();
let alt = Mods { alt: true, ..Default::default() };
let click = |s: &Selection, group: bool, mods: Mods| {
let c = cx(&d, s, &p);
let mut t = DirectSelectionTool::new(group);
let down = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(mods));
let up = t.pointer(&c, &PointerEvent::new(PointerKind::Up, 150.0, 150.0).with_mods(mods));
[down, up].concat()
};
let add = |id: NodeId| vec![Action::Exec("select.add".into(), json!({"ids": [id.0]}))];
let mut s = Selection::default();
s.set([a]);
assert_eq!(click(&s, true, Mods::default()), add(g), "Group Selection adds the group");
assert_eq!(click(&s, false, alt), add(g), "an Alt-click adds the group");
s.set([a, g]);
assert!(click(&s, true, Mods::default()).is_empty(), "Group Selection does not add the sublayer");
assert!(click(&s, false, alt).is_empty(), "an Alt-click does not add the sublayer");
}
/// An Alt-drag from a path selected whole inside a group copies the path and adds no group, and
/// Alt+Shift-click on a segment or the fill still toggles the path, adding no group either.
#[test]
fn alt_drags_and_alt_shift_clicks_on_a_path_in_a_group_add_no_group() {
let (d, inside, ..) = nested_group_doc();
let p = paint();
let mut s = Selection::default();
s.set([inside]);
let cx = cx(&d, &s, &p);
let alt = Mods { alt: true, ..Default::default() };
for (x, y) in [(100.0, 100.0), (150.0, 100.0), (150.0, 150.0)] {
let mut t = DirectSelectionTool::new(false);
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y).with_mods(alt)).is_empty());
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 20.0, y).with_mods(alt));
assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "{a:?}");
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x + 20.0, y).with_mods(alt)), vec![Action::Commit]);
}
let alt_shift = Mods { alt: true, shift: true, ..Default::default() };
for (x, y) in [(150.0, 100.0), (150.0, 150.0)] {
let mut t = DirectSelectionTool::new(false);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y).with_mods(alt_shift));
assert_eq!(a, vec![Action::Exec("select.toggle".into(), json!({"id": inside.0}))], "({x}, {y})");
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, y).with_mods(alt_shift)).is_empty(), "({x}, {y})");
}
}
/// #927: a marquee across type selects the type object whole, beside the anchors it holds.
#[test]
fn marquee_selects_type_it_touches() {