Isolation mode: double-clicking a single object isolates it

With Double Click To Isolate on, the Selection tool isolated groups only. A
double-click on a single path, compound path, image, mesh or placed file now
isolates it too, as the public documentation describes. Type still switches to
the Type tool; blends, envelopes, repeats and symbol instances keep their own
editing modes.

While a single object is isolated only it is hit, marqueed and taken by Select
All, new art goes beside it in its group or layer, and a double-click on the
empty canvas leaves with it selected. object.isolate accepts any object except
type.
This commit is contained in:
Ayberk A.
2026-10-12 18:56:41 +07:00
committed by bflatastic
parent 412e83c825
commit 3073f0ab03
9 changed files with 104 additions and 15 deletions
+17 -3
View File
@@ -31,6 +31,10 @@ impl Hit {
/// The object the Selection tool selects: the child of the layer (the outermost group).
/// Inside isolation mode (`scope`), the child of the isolated container instead.
pub fn top_object(&self, scope: Option<NodeId>) -> NodeId {
// A single object isolated: it is the only object.
if scope == Some(self.leaf) {
return self.leaf;
}
// The innermost of the isolated container and an envelope whose contents are edited.
let at = |s: Option<NodeId>| s.and_then(|s| self.ancestry.iter().position(|x| *x == s));
if let Some(i) = at(scope).max(at(self.contents_of)) {
@@ -101,10 +105,16 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No
// locked, and clicks pass through it (#833).
if editable_only
&& let Some(scope) = opt.scope
&& let Some(container) = doc.node(scope).filter(|n| n.is_container())
&& let Some(isolated) = doc.node(scope)
&& let Some(mut chain) = doc.ancestry(scope)
{
return hit_children(container, p, opt, skip, &mut chain, editable_only).map(finish);
if isolated.is_container() {
return hit_children(isolated, p, opt, skip, &mut chain, editable_only).map(finish);
}
// A single object isolated (a path, an image…): only it hits, among its siblings.
chain.pop();
let parent = chain.last().and_then(|id| doc.node(*id))?;
return hit_children(parent, p, opt, &|id| id != scope || skip(id), &mut chain, editable_only).map(finish);
}
let mut chain = Vec::new();
for layer in doc.layers.iter().rev() {
@@ -283,7 +293,11 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> {
let mut out = Vec::new();
let tops: Vec<&std::sync::Arc<Node>> = match scope.and_then(|s| doc.node(s)) {
Some(s) => s.children().map(|c| c.iter().collect()).unwrap_or_default(),
Some(s) => match s.children() {
Some(c) => c.iter().collect(),
// A single object isolated: only it.
None => doc.parent_of(s.id).and_then(|p| doc.node(p)).and_then(Node::children).into_iter().flatten().filter(|c| c.id == s.id).collect(),
},
// The objects of the layers, looking through sublayers (hidden, locked and template ones
// are left out).
None => doc
+4 -2
View File
@@ -857,8 +857,10 @@ fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
.or_else(|| s.active().and_then(|d| d.selection.objects.first().copied()))
.ok_or_else(|| bad("object.isolate", "missing id"))?;
let st = s.doc_mut()?;
if st.doc.node(id).is_none_or(|n| !n.is_container()) {
return Err(bad("object.isolate", "only groups and layers can be isolated"));
// Groups, layers and single objects (a path, a compound path, an image…); type isn't
// isolated: a double-click on it edits its text.
if st.doc.node(id).is_none_or(|n| matches!(n.kind, NodeKind::Text(_))) {
return Err(bad("object.isolate", "type objects can't be isolated"));
}
let isolation = st.doc.node(id).filter(|n| n.extras().shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id);
st.isolation = Some(isolation);
+2 -1
View File
@@ -193,7 +193,8 @@ pub fn specs() -> Vec<CommandSpec> {
fn selectable(d: &Document, iso: Option<NodeId>) -> Vec<NodeId> {
match iso.and_then(|i| d.node(i)) {
Some(c) if d.ancestry(c.id).is_some_and(|a| a.iter().all(|i| d.node(*i).is_some_and(|n| n.visible && !n.locked && !n.is_template()))) => {
c.layer_art(true)
// A single object isolated is all there is to select.
if c.is_container() { c.layer_art(true) } else { vec![c.id] }
}
Some(_) => vec![],
None => d.selectable_art(),
+6
View File
@@ -483,6 +483,12 @@ impl DocState {
{
return Some(i);
}
// A single object isolated: new art goes beside it, in its group or layer.
if let Some(p) = self.isolation.filter(|i| self.doc.node(*i).is_some_and(|n| !n.is_container())).and_then(|i| self.doc.parent_of(i))
&& self.doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }))
{
return Some(p);
}
// A sublayer takes new art only while it and the layers around it are shown and unlocked.
self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer()) && self.doc.is_editable(*l)).or_else(|| self.doc.default_layer())
}
+54
View File
@@ -298,6 +298,60 @@ fn selection_preferences_drive_the_selection_tool() {
assert!(double(&mut s).is_some(), "on: the group is isolated");
}
/// Double Click To Isolate: a double-click with the Selection tool isolates a single object too,
/// not only a group. Inside, only it is hit, marqueed and selected by Select All; new art goes
/// beside it; a double-click on the empty canvas leaves, with it selected. Type isn't isolated:
/// a double-click on it switches to the Type tool.
#[test]
fn double_clicking_a_single_object_isolates_it() {
use vectorcraft_tools::{PointerEvent, PointerKind};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = |v: Value| NodeId(v["id"].as_u64().unwrap());
let rect = |s: &mut Session, x: f64| id(s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 50, "height": 50})).unwrap());
let (a, b) = (rect(&mut s, 100.0), rect(&mut s, 200.0));
s.select_tool("selection", ViewInfo::default()).unwrap();
let ev = |s: &mut Session, kinds: &[PointerKind], x: f64, y: f64| {
for k in kinds {
s.pointer(&PointerEvent::new(*k, x, y), ViewInfo::default()).unwrap();
}
};
let click = [PointerKind::Down, PointerKind::Up];
let double = [PointerKind::DoubleClick];
ev(&mut s, &double, 125.0, 125.0);
assert_eq!(s.doc().unwrap().isolation, Some(a));
// Only the isolated object is hit: a click on the other selects nothing.
ev(&mut s, &click, 225.0, 125.0);
assert!(s.doc().unwrap().selection.objects.is_empty());
ev(&mut s, &click, 125.0, 125.0);
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
// A marquee over both and Select All take it alone.
s.execute("select.none", &json!({})).unwrap();
ev(&mut s, &[PointerKind::Down], 90.0, 90.0);
ev(&mut s, &[PointerKind::Move, PointerKind::Up], 300.0, 300.0);
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
s.execute("select.none", &json!({})).unwrap();
s.execute("select.all", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
// A second double-click on it stays in isolation; new art lands on its layer.
ev(&mut s, &double, 125.0, 125.0);
assert_eq!(s.doc().unwrap().isolation, Some(a));
let c = rect(&mut s, 300.0);
let st = s.doc().unwrap();
assert_eq!(st.doc.parent_of(c), st.doc.parent_of(a));
// A double-click on the empty canvas leaves isolation, the object selected.
ev(&mut s, &double, 50.0, 350.0);
let st = s.doc().unwrap();
assert_eq!((st.isolation, st.selection.objects.clone()), (None, vec![a]));
assert!(st.doc.node(b).is_some());
// Type is not isolated.
let t = id(s.execute("text.create", &json!({"x": 100, "y": 300, "text": "Type"})).unwrap());
assert!(s.execute("object.isolate", &json!({"id": t.0})).is_err());
s.execute("prefs.set", &json!({"key": "doubleClickToIsolate", "value": false})).unwrap();
ev(&mut s, &double, 225.0, 125.0);
assert_eq!(s.doc().unwrap().isolation, None, "off: no isolation");
}
/// General › Use Precise Cursors (#394): the Pen's pointer becomes a crosshair; the Selection
/// tool's arrow stays.
#[test]
+4 -1
View File
@@ -632,7 +632,10 @@ mod tests {
let cx2 = cx(&d, &whole, &p);
let a = crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 190.0, 110.0));
assert_eq!(a, vec![Action::Dialog(DIALOG.into(), json!({"id": id.0, "corners": [0, 1, 2, 3]}))]);
assert!(crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 150.0, 150.0)).is_empty());
// Off the widgets, the Selection tool's double-click isolates the shape (Double Click To
// Isolate) instead.
let a = crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 150.0, 150.0));
assert_eq!(a, vec![Action::Exec("object.isolate".into(), json!({"id": id.0}))]);
}
/// Hide Corner Widget for angles greater than (#394): a rectangle's right angles hide below
+12 -5
View File
@@ -3,8 +3,8 @@
//! Guides, or with them off View › Snap to Point, snap it),
//! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°),
//! drag a live rectangle's or polygon's corner widget to round its corners (Alt-click cycles their kind,
//! double-click opens the Corners dialog), double-click to enter isolation mode (Double Click To
//! Isolate), Cmd/Ctrl-click to select the object behind (Command Click to Select Objects Behind),
//! double-click opens the Corners dialog), double-click a group or an object to enter isolation
//! mode (Double Click To Isolate), Cmd/Ctrl-click to select the object behind (Command Click to Select Objects Behind),
//! click or drag a ruler guide ([`crate::rulerguide`]), drag the brackets of type on a path
//! ([`crate::pathtype`]), double-click the type widget to convert point type to area type and back
//! ([`crate::typewidget`]).
@@ -260,12 +260,19 @@ impl Tool for SelectionTool {
}
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
let top = h.top_object(cx.isolation);
if cx.double_click_isolate && cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Group { .. })) {
return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))];
}
if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Text(_))) {
return vec![Action::SwitchTool("type".into())];
}
// A group, a compound path or a single path, image, mesh or placed file, not
// the one already isolated. Blends, envelopes, repeats and symbol instances
// aren't: they have editing modes of their own.
use vectorcraft_doc::NodeKind as K;
let isolates = cx.doc.node(top).is_some_and(|n| {
matches!(n.kind, K::Group { .. } | K::Compound { .. } | K::Path { .. } | K::Image(_) | K::Mesh(_) | K::PlacedDocument(_))
});
if cx.double_click_isolate && isolates && cx.isolation != Some(top) {
return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))];
}
} else if cx.isolation.is_some() {
return vec![Action::Exec("object.exitIsolation".into(), json!({}))];
}