Isolation mode: object.isolate refuses hidden or locked objects and locked guide paths

A click with the Selection tool does not select a hidden or locked
object, an object in a hidden or locked group or layer, or a guide path
while Lock Guides is on, but object.isolate isolated each of them. Once
a locked or hidden group, or a group on a locked or hidden layer, was
isolated, a Selection tool drag moved the member under the pointer. The
Layers panel's Enter Isolation Mode sent the same command for a locked
or hidden layer or group.

object.isolate now refuses a hidden or locked object, or one in a
hidden or locked group or layer, with "the object is hidden or locked"
(the text of layer.target), and a guide path while Lock Guides is on
with "guides are locked". Isolation mode stays off, and the selection
stays as it is.

Docs: docs/mcp.md.
This commit is contained in:
dotCooCoo
2026-10-21 16:46:57 +07:00
committed by bflatastic
parent 9aad69b8f6
commit bdf54007a0
3 changed files with 103 additions and 2 deletions
+18 -1
View File
@@ -227,7 +227,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
|s, _| clip_edit(s, true)
),
cmd!("object.isolate", "Enter Isolation Mode", [], None, "{id?} (default: the selection's first object)", has_doc, isolate),
cmd!(
"object.isolate",
"Enter Isolation Mode",
[],
None,
"{id?} isolate a group, a layer or a single object (default: the first selected object); type, a hidden or locked object, an object in a hidden or locked group or layer, and a guide path while Lock Guides is on are refused",
has_doc,
isolate
),
cmd!("object.exitIsolation", "Exit Isolation Mode", [], None, "{}", has_doc, exit_isolation),
cmd!(
"object.setProps",
@@ -1053,12 +1061,21 @@ fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id")
.or_else(|| s.active().and_then(|d| d.selection.objects.first().copied()))
.ok_or_else(|| bad("object.isolate", "missing id"))?;
let guides_locked = s.menu.guides_locked;
let st = s.doc_mut()?;
// 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 matches!(st.doc.node(id).ok_or(EngineError::NoNode(id))?.kind, NodeKind::Text(_)) {
return Err(bad("object.isolate", "type objects can't be isolated"));
}
// A hidden or locked object, one in a hidden or locked group or layer, and a guide path while
// Lock Guides is on aren't isolated either: a click with the Selection tool doesn't select them.
if !st.doc.is_editable(id) {
return Err(bad("object.isolate", "the object is hidden or locked"));
}
if guides_locked && crate::is_guide_path(&st.doc, id) {
return Err(EngineError::Other("guides are locked".into()));
}
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);
st.selection.clear();
+82
View File
@@ -2035,6 +2035,88 @@ fn isolating_an_object_that_doesnt_exist_says_so() {
assert!(!e.to_string().contains("type"), "{e}");
}
/// `object.isolate` refuses a locked or hidden group, a group in a locked or hidden layer, a locked
/// or hidden member of a group, and a guide path while Lock Guides is on. Isolation mode stays off,
/// and a Selection tool drag over the art does what it does without the call. A group that is neither is isolated, and the drag moves its
/// member.
#[test]
fn isolate_refuses_locked_or_hidden_art_and_locked_guides() {
const HIDDEN_OR_LOCKED: &str = "invalid parameters for `object.isolate`: the object is hidden or locked";
let x0 = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
// A drag 30 pt to the right from the left edge of `a`.
let drag = |s: &mut Session| {
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 101.0, 115.0), v).unwrap();
for x in [111.0, 121.0, 131.0] {
s.pointer(&PointerEvent::new(PointerKind::Drag, x, 115.0), v).unwrap();
}
s.pointer(&PointerEvent::new(PointerKind::Up, 131.0, 115.0), v).unwrap();
};
// A group of `a` and another rectangle, on the only layer.
let group = |s: &mut Session| {
let a = rect(s, 100.0, 100.0, 30.0, 30.0);
let b = rect(s, 200.0, 100.0, 30.0, 30.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
(NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()), a)
};
// Each case locks, hides or guides something, given the layer, the group and `a`, and returns
// the id to isolate.
type Case = (&'static str, fn(&mut Session, NodeId, NodeId, NodeId) -> NodeId, &'static str);
let cases: [Case; 7] = [
("locked group", |s, _, g, _| set_then(s, "object.setProps", g, "locked", true, g), HIDDEN_OR_LOCKED),
("hidden group", |s, _, g, _| set_then(s, "object.setProps", g, "visible", false, g), HIDDEN_OR_LOCKED),
("locked layer", |s, l, g, _| set_then(s, "layer.setProps", l, "locked", true, g), HIDDEN_OR_LOCKED),
("hidden layer", |s, l, g, _| set_then(s, "layer.setProps", l, "visible", false, g), HIDDEN_OR_LOCKED),
("locked member", |s, _, _, a| set_then(s, "object.setProps", a, "locked", true, a), HIDDEN_OR_LOCKED),
("hidden member", |s, _, _, a| set_then(s, "object.setProps", a, "visible", false, a), HIDDEN_OR_LOCKED),
(
"guide path",
|s, _, _, a| {
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("view.guides.make", &json!({})).unwrap();
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
a
},
"guides are locked",
),
];
// Each case runs with and without `object.isolate` before the drag. Every case runs, and the
// wrong ones are listed: (case, what isolating gave, the isolated id, where the drag left `a`,
// where it leaves `a` with no isolating).
let mut wrong = vec![];
for (name, make, error) in cases {
let run = |isolate: bool| {
let mut s = session();
let (g, a) = group(&mut s);
let layer = s.doc().unwrap().doc.layers[0].id;
let target = make(&mut s, layer, g, a);
let r = isolate.then(|| s.execute("object.isolate", &json!({"id": target.0})).map_err(|e| e.to_string()));
let isolation = s.doc().unwrap().isolation;
drag(&mut s);
(r, isolation, x0(&s, a))
};
let ((r, isolation, x), (_, _, without)) = (run(true), run(false));
if (&r, isolation, x) != (&Some(Err(error.to_string())), None, without) {
wrong.push((name, r, isolation, x, without));
}
}
assert!(wrong.is_empty(), "{wrong:#?}");
// Neither locked nor hidden: isolated, and the drag moves the member.
let mut s = session();
let (g, a) = group(&mut s);
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
assert_eq!(s.doc().unwrap().isolation, Some(g));
drag(&mut s);
assert_eq!(x0(&s, a), 130.0, "the member moved");
}
/// Sets `key` of `id` to `on` with `cmd`, and gives back `then`.
fn set_then(s: &mut Session, cmd: &str, id: NodeId, key: &str, on: bool, then: NodeId) -> NodeId {
s.execute(cmd, &json!({"ids": [id.0], key: on})).unwrap();
then
}
/// A ring (a compound path with a hole) and its isolated paths.
fn isolated_ring(s: &mut Session) -> (NodeId, NodeId, NodeId) {
let outer = rect(s, 0.0, 0.0, 40.0, 40.0);
+3 -1
View File
@@ -1757,7 +1757,9 @@ and `layerRows`, the rows highlighted in the panel.
`layer.outlineOthers` / `layer.previewAll` (Ctrl/Cmd+Alt-clicking an eye runs Outline Others for the row's layer, or
Preview All Layers when the others are in Outline already), `layer.lockOthers` / `layer.unlockAll` (Alt-clicking an eye or a lock
runs Hide or Lock Others for the row's layer, or Show or Unlock All when the others already are), `layer.pasteRemembersLayers`,
`object.isolate {id}` (a group, a layer or a single object; not type) / `object.exitIsolation`.
`object.isolate {id?}` (a group, a layer or a single object, by default the first selected object; not type, not a
hidden or locked object or one in a hidden or locked group or layer, and not a guide path while Lock Guides is on) /
`object.exitIsolation`.
- **Dialogs and panel state** (control channel and the app's MCP): `ui.layerOptions {ids?}` and `ui.newLayer
{sublayer?}` open Layer Options (dialog `layerOptions`: name, color, template, locked, visible, printable, preview,
dimImages, dimPercent; OK is one undo step; Cmd/Ctrl+Alt+L runs `ui.newLayer` for a new layer), `ui.layersPanelOptions` opens Panel Options (`layersPanelOptions`: