mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 12:47:13 -06:00
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:
+40
-11
@@ -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> {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user