diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index dc047d9d6..2d9fea923 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -182,20 +182,22 @@ pub fn has_multi(s: &Session) -> std::result::Result<(), String> { let st = s.active().ok_or("no document open")?; if st.selection.len() < 2 { Err("select at least two objects".into()) } else { Ok(()) } } -/// A document is open and it is not in isolation mode: Object › Lock's and Object › Hide's items, -/// Unlock All and Show All are off in isolation mode. Pattern editing mode and opacity mask -/// editing isolate their art too, and do not count as isolation mode here while their own layer -/// is the isolated one. An object isolated inside them counts. -pub fn outside_isolation(s: &Session) -> std::result::Result<(), String> { - let st = s.active().ok_or("no document open")?; - let editing_layer = |l| st.doc.pattern_edit.as_ref().is_some_and(|e| e.layer == l) || st.doc.mask_edit.as_ref().is_some_and(|e| e.layer == l); - if st.isolation.is_some_and(|l| !editing_layer(l)) { Err("exit isolation mode first".into()) } else { Ok(()) } +/// The isolated group, layer or object in isolation mode. Pattern editing mode and opacity mask +/// editing isolate their own layer, which does not count here; an object isolated inside them +/// counts. +pub(crate) fn ordinary_isolation(st: &crate::DocState) -> Option { + st.isolation.filter(|l| !st.doc.pattern_edit.as_ref().is_some_and(|e| e.layer == *l) && !st.doc.mask_edit.as_ref().is_some_and(|e| e.layer == *l)) } -/// Objects are selected ([`has_selection`]) and the document is not in isolation mode -/// ([`outside_isolation`]). -pub fn has_selection_outside_isolation(s: &Session) -> std::result::Result<(), String> { - outside_isolation(s)?; - has_selection(s) +/// Objects are selected ([`has_selection`]), and in isolation mode ([`ordinary_isolation`]) at +/// least one of them is inside the isolated object: Object › Lock's and Object › Hide's items. +pub fn has_selected_member(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + let Some(root) = ordinary_isolation(st) else { return has_selection(s) }; + if st.selection.objects.iter().any(|id| inside_isolation(&st.doc, root, *id)) { Ok(()) } else { Err("nothing selected".into()) } +} +/// Whether `id` lies inside the isolated object `root` (the root itself does not). +pub(crate) fn inside_isolation(d: &vectorcraft_doc::Document, root: vectorcraft_doc::NodeId, id: vectorcraft_doc::NodeId) -> bool { + id != root && d.ancestry(id).is_some_and(|a| a.contains(&root)) } pub fn can_undo(s: &Session) -> std::result::Result<(), String> { s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into()) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a2cab84a8..3534d40ac 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -566,17 +566,29 @@ pub(crate) fn selected_roots(s: &Session) -> Result> { /// rest still go to the compound path, which holds the paint. pub(crate) fn selected_roots_to_transform(s: &Session) -> Result> { let st = s.doc()?; - Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), true)) + Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |_| true)) +} + +/// [`selected_roots`] for the selected objects inside the isolated object `root` +/// ([`super::inside_isolation`]): `root` itself and objects outside it are left out before the +/// objects held by another selected object are dropped. A selected path of a compound path stands +/// for its compound unless the compound is `root` or `members` is set (as in +/// [`selected_roots_to_transform`]). +pub(crate) fn selected_roots_inside(s: &Session, root: NodeId, members: bool) -> Result> { + let st = s.doc()?; + let ids = st.selection.in_paint_order(&st.doc).into_iter().filter(|id| super::inside_isolation(&st.doc, root, *id)).collect(); + Ok(roots_within(&st.doc, ids, |compound| members || compound == root)) } /// The top-level objects among `ids` (in paint order): compound-path members stand for their /// compound, and ids whose ancestor is also listed, and layers, are dropped. pub(crate) fn roots_of(doc: &Document, ids: Vec) -> Vec { - roots_within(doc, ids, false) + roots_within(doc, ids, |_| false) } -/// [`roots_of`]; with `members`, a compound path's member listed stays itself. -fn roots_within(doc: &Document, ids: Vec, members: bool) -> Vec { +/// [`roots_of`]; a compound path's member listed stays itself when `members` holds for the +/// compound's id. +fn roots_within(doc: &Document, ids: Vec, members: impl Fn(NodeId) -> bool) -> Vec { // Every id's place in the tree, found in one walk (a lookup per id is a walk per id). let paths = doc.index_paths(ids.iter().copied()); // A compound-path member acts as its compound (compounds are one object). @@ -586,7 +598,7 @@ fn roots_within(doc: &Document, ids: Vec, members: bool) -> Vec let path = paths.get(&id); let parent = path.and_then(|p| p.split_last()).map(|(_, up)| up).filter(|up| !up.is_empty()); match parent.and_then(|up| doc.node_at(up).map(|n| (up, n))) { - Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && !members => (n.id, Some(up.to_vec())), + Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && !members(n.id) => (n.id, Some(up.to_vec())), _ => (id, path.cloned()), } }) diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 7a93ac617..73cf96c9b 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -15,8 +15,8 @@ use vectorcraft_render::encode::{RasterExportOptions, RasterFormat}; use super::edit::{duplicate_in, selected_roots, selected_roots_to_transform}; use super::{ - CommandSpec, EngineError, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, fileio, find_command, has_doc, has_selection, - has_selection_outside_isolation, switch_or, + CommandSpec, EngineError, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, fileio, find_command, has_doc, has_selected_member, + has_selection, switch_or, }; /// Session-level (not saved) state owned by the menu commands. @@ -43,8 +43,8 @@ pub fn specs() -> Vec { "All Artwork Above", ["Object", "Lock"], None, - "{} lock objects in the same layer that are above and overlap the selection, deselecting every selected object left locked or hidden (the key object too) → {count}", - has_selection_outside_isolation, + "{} lock objects in the same layer that are above and overlap the selection, deselecting every selected object left locked or hidden (the key object too); in isolation mode, lock the selected objects and the objects above them inside the isolated object → {count}", + has_selected_member, |s, _| above(s, true) ), cmd!( @@ -52,8 +52,8 @@ pub fn specs() -> Vec { "Other Layers", ["Object", "Lock"], None, - "{} lock every layer that holds no selected object → {count}", - has_selection_outside_isolation, + "{} lock every layer that holds no selected object; in isolation mode, change nothing and return a count of 0 → {count}", + has_selected_member, |s, _| other_layers(s, true) ), cmd!( @@ -61,8 +61,8 @@ pub fn specs() -> Vec { "Unselected Artwork", ["Object", "Lock"], Some("Cmd+Alt+Shift+2"), - "{} lock unselected artwork in one undo step, including unselected members of partly selected groups, clipping groups and compound paths, and guides while Lock Guides is on; hidden art and art on locked layers stay unlocked; a selected layer's art counts as selected; in isolation, only art inside the isolated object is affected; object.unlockAll unlocks locked art document-wide → {count}", - has_selection, + "{} lock unselected artwork in one undo step, including unselected members of partly selected groups, clipping groups and compound paths, and guides while Lock Guides is on; hidden art and art on locked layers stay unlocked; a selected layer's art counts as selected; in isolation, only art inside the isolated object is affected; object.unlockAll unlocks locked art document-wide, or inside the isolated object in isolation mode → {count}", + has_selected_member, |s, _| unselected_art(s, true) ), cmd!( @@ -70,8 +70,8 @@ pub fn specs() -> Vec { "All Artwork Above", ["Object", "Hide"], None, - "{} hide objects in the same layer that are above and overlap the selection, deselecting every selected object left hidden or locked (the key object too) → {count}", - has_selection_outside_isolation, + "{} hide objects in the same layer that are above and overlap the selection, deselecting every selected object left hidden or locked (the key object too); in isolation mode, hide the selected objects and the objects above them inside the isolated object → {count}", + has_selected_member, |s, _| above(s, false) ), cmd!( @@ -79,8 +79,8 @@ pub fn specs() -> Vec { "Other Layers", ["Object", "Hide"], None, - "{} hide every layer that holds no selected object → {count}", - has_selection_outside_isolation, + "{} hide every layer that holds no selected object; in isolation mode, change nothing and return a count of 0 → {count}", + has_selected_member, |s, _| other_layers(s, false) ), cmd!( @@ -88,8 +88,8 @@ pub fn specs() -> Vec { "Unselected Artwork", ["Object", "Hide"], Some("Cmd+Alt+Shift+3"), - "{} hide, in one undo step, every object select.all takes that is not selected and holds no selected object; in a group that holds one it hides the other members one by one, and a clip group, compound path or other object that holds one stays as it is; the objects of a selected layer count as selected; while Lock Guides is on, guide paths count as selected and stay as they are; object.showAll shows them again; disabled in isolation mode → {count}", - has_selection_outside_isolation, + "{} hide, in one undo step, every object select.all takes that is not selected and holds no selected object; in a group that holds one it hides the other members one by one, and a clip group, compound path or other object that holds one stays as it is; the objects of a selected layer count as selected; while Lock Guides is on, guide paths count as selected and stay as they are; in isolation mode, only art inside the isolated object is affected; object.showAll shows them again → {count}", + has_selected_member, |s, _| unselected_art(s, false) ), cmd!( @@ -243,14 +243,21 @@ fn reset_bounding_box(s: &mut Session, _: &Value) -> Result { // ---------- Lock / Hide ---------- fn above(s: &mut Session, lock: bool) -> Result { - let roots = selected_roots(s)?; - let d = &s.doc()?.doc; + let st = s.doc()?; + let d = &st.doc; + let isolation = super::ordinary_isolation(st); + // In isolation mode a path of the isolated compound path is an object of its own. + let roots = match isolation { + Some(root) => super::edit::selected_roots_inside(s, root, false)?, + None => selected_roots(s)?, + }; let symbols = d.symbol_boxes(); - // `selected` holds each selected object's visual bounds, by layer, with the index there of its - // ancestor directly inside the layer. Symbol instances are measured by their art. + // `selected` holds each selected object's visual bounds, by layer (the isolated object in + // isolation mode), with the index there of its ancestor directly inside it. Symbol instances + // are measured by their art. let mut selected: BTreeMap> = BTreeMap::new(); for id in &roots { - let Some(layer) = d.layer_of(*id) else { continue }; + let Some(layer) = isolation.or_else(|| d.layer_of(*id)) else { continue }; // The selected object's ancestor directly inside the layer. let anc = d.ancestry(*id).unwrap_or_default(); let Some(pos) = anc.iter().position(|a| *a == layer) else { continue }; @@ -259,10 +266,14 @@ fn above(s: &mut Session, lock: bool) -> Result { let Some((_, idx, _)) = d.position(top) else { continue }; selected.entry(layer).or_default().push((idx, b)); } - // The loop walks up each layer once. Each object that is not selected and overlaps a selected - // object below it is locked (Lock) or hidden (Hide). + // The loop walks up each layer once, or the isolated object in isolation mode. Each object that + // is not selected and overlaps a selected object below it is locked (Lock) or hidden (Hide). In + // isolation mode the selected objects are locked or hidden too. let roots: HashSet = roots.into_iter().collect(); let mut hits: BTreeSet = BTreeSet::new(); + if isolation.is_some() { + hits.extend(roots.iter().copied()); + } for (layer, mut below) in selected { below.sort_by_key(|(idx, _)| *idx); let mut below = below.into_iter().peekable(); @@ -307,6 +318,11 @@ fn above(s: &mut Session, lock: bool) -> Result { fn other_layers(s: &mut Session, lock: bool) -> Result { let st = s.doc()?; + // In isolation mode the other layers lie outside the isolated group, layer or sublayer, so the + // document and its undo history stay as they are. + if super::ordinary_isolation(st).is_some() { + return Ok(json!({ "count": 0 })); + } let keep: Vec = st.selection.objects.iter().filter_map(|id| st.doc.ancestry(*id)).flatten().collect(); let others: Vec = st.doc.layers.iter().map(|l| l.id).filter(|l| !keep.contains(l)).collect(); let n = others.len(); @@ -370,10 +386,19 @@ fn unselected( } fn unselected_art(s: &mut Session, lock: bool) -> Result { - let roots = selected_roots_to_transform(s)?; + // In isolation mode the isolated object itself does not count as a selected object. + let iso = super::ordinary_isolation(s.doc()?); + let roots = match iso { + Some(root) => super::edit::selected_roots_inside(s, root, true)?, + None => selected_roots_to_transform(s)?, + }; let guides_locked = s.menu.guides_locked; let st = s.doc()?; - let ids = unselected(&st.doc, st.isolation, &st.selection.objects, &roots, guides_locked, lock); + let selection: Vec = match iso { + Some(root) => st.selection.objects.iter().copied().filter(|id| super::inside_isolation(&st.doc, root, *id)).collect(), + None => st.selection.objects.to_vec(), + }; + let ids = unselected(&st.doc, st.isolation, &selection, &roots, guides_locked, lock); // With nothing to lock (Lock) or hide (Hide), the document and its undo history stay as they are. if !ids.is_empty() { s.edit(if lock { "Lock" } else { "Hide" }, |d, _| { diff --git a/crates/engine/src/cmd/object/arrange.rs b/crates/engine/src/cmd/object/arrange.rs index 325024bcb..741475d87 100644 --- a/crates/engine/src/cmd/object/arrange.rs +++ b/crates/engine/src/cmd/object/arrange.rs @@ -143,7 +143,7 @@ pub(super) fn ungroup(s: &mut Session, _: &Value) -> Result { } pub(super) fn lock(s: &mut Session, _: &Value) -> Result { - let ids = selected_roots(s)?; + let ids = selected_lock_hide_roots(s)?; // With every selected object already locked, the document and its undo history stay as they // are, and the objects are deselected. let d = &s.doc()?.doc; @@ -163,6 +163,16 @@ pub(super) fn lock(s: &mut Session, _: &Value) -> Result { ok() } +/// The selected objects ([`selected_roots`]) that Lock › Selection and Hide › Selection change: in +/// isolation mode, only those inside the isolated object, where a path of the isolated compound +/// path is an object of its own. +fn selected_lock_hide_roots(s: &Session) -> Result> { + match crate::cmd::ordinary_isolation(s.doc()?) { + Some(root) => crate::cmd::edit::selected_roots_inside(s, root, false), + None => selected_roots(s), + } +} + fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec { let mut v = vec![]; d.walk(|n| { @@ -174,14 +184,28 @@ fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec { } /// The objects that `f` picks for `object.unlockAll` and `object.showAll` (`cmd`): in the whole -/// document, or with `scope: "group"` inside the groups that hold the selected objects. A selected -/// group is the group that holds its own members, and the objects of a group inside another such -/// group are taken once. With `scope: "group"` and no selected object in a group, it returns an -/// error. +/// document, inside the isolated object in isolation mode, or with `scope: "group"` inside the +/// groups that hold the selected objects (in isolation mode, only the objects of those groups +/// that are inside the isolated object). A selected group is the group that holds its own +/// members, and the objects of a group inside another such group are taken once. With +/// `scope: "group"` and no selected object in a group, it returns an error. fn collect_flag_in_scope(s: &Session, cmd: &str, p: &Value, f: impl Fn(&Node) -> bool) -> Result> { let st = s.doc()?; let d = &st.doc; if choice(p, cmd, "scope", &["document", "group"])? != Some("group") { + if let Some(root) = crate::cmd::ordinary_isolation(st) { + let mut ids = vec![]; + if let Some(node) = d.node(root) { + for child in node.children().into_iter().flatten() { + child.walk(&mut |n| { + if !n.is_layer() && f(n) { + ids.push(n.id); + } + }); + } + } + return Ok(ids); + } return Ok(collect_flag(d, f)); } let is_group = |id: &NodeId| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. })); @@ -204,6 +228,10 @@ fn collect_flag_in_scope(s: &Session, cmd: &str, p: &Value, f: impl Fn(&Node) -> }); } } + // In isolation mode only the objects inside the isolated object are taken. + if let Some(root) = crate::cmd::ordinary_isolation(st) { + v.retain(|id| crate::cmd::inside_isolation(d, root, *id)); + } Ok(v) } @@ -226,7 +254,7 @@ pub(super) fn unlock_all(s: &mut Session, p: &Value) -> Result { } pub(super) fn hide(s: &mut Session, _: &Value) -> Result { - let ids = selected_roots(s)?; + let ids = selected_lock_hide_roots(s)?; // With every selected object already hidden, the document and its undo history stay as they // are, and the objects are deselected. let d = &s.doc()?.doc; diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index d5885ad3c..69f464edb 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -6,7 +6,7 @@ use super::props::set_props; use super::shape::{expand_shape, set_live_shape}; use super::transform::{move_cmd, nudge, reflect, rotate, scale, shear, transform, transform_again}; use super::{CommandSpec, cmd, has_doc, has_multi, has_selection, has_selection_or_guides}; -use crate::cmd::{has_selection_outside_isolation, outside_isolation}; +use crate::cmd::has_selected_member; pub fn specs() -> Vec { vec![ @@ -150,8 +150,8 @@ pub fn specs() -> Vec { "Selection", ["Object", "Lock"], Some("Cmd+2"), - "{ids?: [id…] (selected first; default: the selection)}", - has_selection_outside_isolation, + "{ids?: [id…] (selected first; default: the selection; in isolation mode, only those inside the isolated object)}", + has_selected_member, lock ), cmd!( @@ -159,8 +159,8 @@ pub fn specs() -> Vec { "Unlock All", ["Object"], Some("Cmd+Alt+2"), - "{scope?: \"document\"|\"group\" (default document; group: only the objects inside the groups that hold the selected objects, a selected group holding its own; the in-window menu item chosen with Shift+Alt)} unlock the locked objects and select them → {count}", - outside_isolation, + "{scope?: \"document\"|\"group\" (default document, which in isolation mode is the isolated object; group: only the objects inside the groups that hold the selected objects, a selected group holding its own; the in-window menu item chosen with Shift+Alt)} unlock the locked objects and select them → {count}", + has_doc, unlock_all ), cmd!( @@ -168,8 +168,8 @@ pub fn specs() -> Vec { "Selection", ["Object", "Hide"], Some("Cmd+3"), - "{ids?: [id…] (selected first; default: the selection)}", - has_selection_outside_isolation, + "{ids?: [id…] (selected first; default: the selection; in isolation mode, only those inside the isolated object)}", + has_selected_member, hide ), cmd!( @@ -177,8 +177,8 @@ pub fn specs() -> Vec { "Show All", ["Object"], Some("Cmd+Alt+3"), - "{scope?: \"document\"|\"group\" (default document; group: only the objects inside the groups that hold the selected objects, a selected group holding its own; the in-window menu item chosen with Shift+Alt)} show the hidden objects and select them → {count}", - outside_isolation, + "{scope?: \"document\"|\"group\" (default document, which in isolation mode is the isolated object; group: only the objects inside the groups that hold the selected objects, a selected group holding its own; the in-window menu item chosen with Shift+Alt)} show the hidden objects and select them → {count}", + has_doc, show_all ), cmd!( diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index ff11009b1..91194b273 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -316,137 +316,347 @@ fn shown_and_locked(s: &Session, id: NodeId) -> (bool, bool) { (n.visible, n.locked) } -/// Each of `commands` is off in isolation mode: the command list reports it disabled, its error -/// names isolation mode, and so does the error of a call that gives the selection as `ids` (for a -/// command that takes them). -fn off_in_isolation(s: &mut Session, commands: &[&str]) { - let ids: Vec = selected(s).iter().map(|i| i.0).collect(); - for c in commands { - let spec = find_command(c).unwrap(); - assert_eq!(spec.info(s).disabled_reason.as_deref(), Some("exit isolation mode first"), "{c}"); - let given = cmd::params::takes_key(spec.params, "ids").then(|| json!({"ids": ids})); - for p in std::iter::once(json!({})).chain(given) { - let e = s.execute(c, &p).unwrap_err().to_string(); - assert!(e.contains("exit isolation mode first"), "{c} {p}: {e}"); - } - } -} - -/// In isolation mode, Lock › Selection and Hide › Selection are off and change nothing, in an -/// isolated group and in an isolated compound path with one of its paths selected. After Exit -/// Isolation Mode they lock and hide again. -#[test] -fn lock_and_hide_selection_are_off_in_isolation_mode() { - let mut s = session(); - let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); - let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); - sel(&mut s, &[a, b]); - let g = id_of(&s.execute("object.group", &json!({})).unwrap()); - let outer = rect(&mut s, 100.0, 100.0, 100.0, 100.0); - let hole = rect(&mut s, 120.0, 120.0, 20.0, 20.0); - sel(&mut s, &[outer, hole]); - let compound = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()); - let ids = [a, b, g, outer, hole, compound]; - let commands = ["object.lock", "object.hide"]; - for (isolated, member) in [(g, a), (compound, hole)] { - s.execute("object.isolate", &json!({"id": isolated.0})).unwrap(); - sel(&mut s, &[member]); - off_in_isolation(&mut s, &commands); - for id in ids { - assert_eq!(shown_and_locked(&s, id), (true, false), "{id:?} in isolation of {isolated:?}"); - } - assert_eq!(selected(&s), [member]); - s.execute("object.exitIsolation", &json!({})).unwrap(); - } - sel(&mut s, &[a]); - s.execute("object.lock", &json!({})).unwrap(); - sel(&mut s, &[outer]); - s.execute("object.hide", &json!({})).unwrap(); - assert_eq!(shown_and_locked(&s, a), (true, true)); - assert_eq!(shown_and_locked(&s, compound), (false, false)); -} - -/// In isolation mode, Unlock All and Show All are off: the locked and the hidden objects outside -/// and inside the isolated group stay as they are. Pattern editing mode and opacity mask editing -/// are not isolation mode for them. -#[test] -fn unlock_all_and_show_all_are_off_in_isolation_mode() { - let mut s = session(); - let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); - let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); - let c = rect(&mut s, 40.0, 0.0, 10.0, 10.0); - sel(&mut s, &[a, b, c]); - let g = id_of(&s.execute("object.group", &json!({})).unwrap()); - let locked = rect(&mut s, 100.0, 0.0, 10.0, 10.0); - let hidden = rect(&mut s, 120.0, 0.0, 10.0, 10.0); - s.execute("layer.setProps", &json!({"id": locked.0, "locked": true})).unwrap(); - s.execute("layer.setProps", &json!({"id": hidden.0, "visible": false})).unwrap(); - s.execute("layer.setProps", &json!({"id": b.0, "locked": true})).unwrap(); - s.execute("layer.setProps", &json!({"id": c.0, "visible": false})).unwrap(); - s.execute("object.isolate", &json!({"id": g.0})).unwrap(); - sel(&mut s, &[a]); - off_in_isolation(&mut s, &["object.unlockAll", "object.showAll"]); - for (id, want) in [(locked, (true, true)), (hidden, (false, false)), (b, (true, true)), (c, (false, false))] { - assert_eq!(shown_and_locked(&s, id), want, "{id:?}"); - } - s.execute("object.exitIsolation", &json!({})).unwrap(); - s.execute("object.unlockAll", &json!({})).unwrap(); - s.execute("object.showAll", &json!({})).unwrap(); - for id in [locked, hidden, b, c] { - assert_eq!(shown_and_locked(&s, id), (true, false), "{id:?}"); - } - - // Pattern editing mode: the tile's art hides and shows again. - let r = rect(&mut s, 300.0, 300.0, 10.0, 10.0); - sel(&mut s, &[r]); - s.execute("object.pattern.make", &json!({"name": "P"})).unwrap(); - let tile = selected(&s); - s.execute("object.hide", &json!({})).unwrap(); - assert!(!node(&s, tile[0]).visible, "hidden in pattern editing mode"); - s.execute("object.showAll", &json!({})).unwrap(); - assert!(node(&s, tile[0]).visible, "shown in pattern editing mode"); - // An object isolated inside pattern editing mode is isolation mode again. - s.execute("object.isolate", &json!({"id": tile[0].0})).unwrap(); - sel(&mut s, &[tile[0]]); - off_in_isolation(&mut s, &["object.unlockAll", "object.showAll"]); - s.execute("object.pattern.cancel", &json!({})).unwrap(); - - // Opacity mask editing: the mask's art locks and unlocks again. - let obj = rect(&mut s, 0.0, 300.0, 200.0, 200.0); - let m = rect(&mut s, 0.0, 300.0, 100.0, 200.0); - sel(&mut s, &[obj, m]); - s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); - s.execute("transparency.editOpacityMask", &json!({})).unwrap(); - let mask = selected(&s); - s.execute("object.lock", &json!({})).unwrap(); - assert!(node(&s, mask[0]).locked, "locked in opacity mask editing"); - s.execute("object.unlockAll", &json!({})).unwrap(); - assert!(!node(&s, mask[0]).locked, "unlocked in opacity mask editing"); -} - -/// In isolation mode, Lock › All Artwork Above, Lock › Other Layers, Hide › All Artwork Above and -/// Hide › Other Layers are off: the art above and the other layer stay as they are. -#[test] -fn lock_and_hide_above_and_other_layers_are_off_in_isolation_mode() { +/// Check command enablement, targets, selection and history inside an isolated group. +fn check_group_isolation_command(command: &str, lock: bool, a_changed: bool, b_changed: bool) { let mut s = session(); let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); - let b = rect(&mut s, 200.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 20.0, 20.0, 100.0, 100.0); sel(&mut s, &[a, b]); let g = id_of(&s.execute("object.group", &json!({})).unwrap()); - let over = rect(&mut s, 50.0, 50.0, 100.0, 100.0); + let out = rect(&mut s, 30.0, 30.0, 100.0, 100.0); let l2 = id_of(&s.execute("layer.new", &json!({})).unwrap()); + let p = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + sel(&mut s, &[out]); + assert!(!find_command(command).unwrap().info(&s).enabled, "{command}: outside selection"); + sel(&mut s, &[a]); + assert!(find_command(command).unwrap().info(&s).enabled, "{command}"); + let before = history(&s).0; + s.execute(command, &json!({})).unwrap(); + let changed = |s: &Session, id| if lock { node(s, id).locked } else { !node(s, id).visible }; + assert_eq!(changed(&s, a), a_changed, "{command}: A"); + assert_eq!(changed(&s, b), b_changed, "{command}: B"); + assert!(!changed(&s, out) && !changed(&s, p) && !changed(&s, l2), "{command}: outside"); + assert_eq!(selected(&s), if a_changed { vec![] } else { vec![a] }, "{command}: selection"); + let steps = usize::from(a_changed || b_changed); + assert_eq!(history(&s).0, before + steps, "{command}: history"); + if steps != 0 { + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!changed(&s, a) && !changed(&s, b), "{command}: undo"); + assert_eq!(selected(&s), [a], "{command}: undo selection"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!((changed(&s, a), changed(&s, b)), (a_changed, b_changed), "{command}: redo"); + assert_eq!(selected(&s), if a_changed { vec![] } else { vec![a] }, "{command}: redo selection"); + } + if !a_changed && b_changed { + let restore = if lock { "object.unlockAll" } else { "object.showAll" }; + let params = if lock { json!({"id": out.0, "locked": true}) } else { json!({"id": out.0, "visible": false}) }; + s.execute("layer.setProps", ¶ms).unwrap(); + assert_eq!(s.execute(restore, &json!({})).unwrap(), json!({"count": 1})); + assert!(!changed(&s, b) && changed(&s, out), "{command}: scoped restore"); + } +} + +/// Generate the focused group isolation checks for each Lock and Hide command. +macro_rules! group_isolation_command_test { + ($name:ident, $command:literal, $lock:literal, $a:literal, $b:literal) => { + #[doc = concat!("Checks ", $command, " targets and history in group isolation.")] + #[test] + fn $name() { + check_group_isolation_command($command, $lock, $a, $b); + } + }; +} + +group_isolation_command_test!(lock_selection_in_group_isolation, "object.lock", true, true, false); +group_isolation_command_test!(lock_above_in_group_isolation, "object.lock.above", true, true, true); +group_isolation_command_test!(lock_other_layers_in_group_isolation, "object.lock.otherLayers", true, false, false); +group_isolation_command_test!(lock_unselected_in_group_isolation, "object.lock.unselected", true, false, true); +group_isolation_command_test!(hide_selection_in_group_isolation, "object.hide", false, true, false); +group_isolation_command_test!(hide_above_in_group_isolation, "object.hide.above", false, true, true); +group_isolation_command_test!(hide_other_layers_in_group_isolation, "object.hide.otherLayers", false, false, false); +group_isolation_command_test!(hide_unselected_in_group_isolation, "object.hide.unselected", false, false, true); + +/// Lock › Selection, Hide › Selection and All Artwork Above change only the selected path inside +/// an isolated compound path, with the other path below it. +#[test] +fn lock_and_hide_selection_in_isolated_compound_path() { + for command in ["object.lock", "object.hide", "object.lock.above", "object.hide.above"] { + let mut s = session(); + let outer = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let hole = rect(&mut s, 20.0, 20.0, 20.0, 20.0); + sel(&mut s, &[outer, hole]); + let compound = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()); + let outside = rect(&mut s, 150.0, 0.0, 20.0, 20.0); + s.execute("object.isolate", &json!({"id": compound.0})).unwrap(); + sel(&mut s, &[hole]); + assert!(find_command(command).unwrap().info(&s).enabled, "{command}"); + let before = history(&s).0; + let want = if command.ends_with(".above") { json!({"count": 1}) } else { serde_json::Value::Null }; + assert_eq!(s.execute(command, &json!({})).unwrap(), want, "{command}"); + assert_eq!(history(&s).0, before + 1, "{command}"); + assert_eq!(selected(&s), [], "{command}"); + let expected = if command.starts_with("object.lock") { (true, true) } else { (false, false) }; + assert_eq!(shown_and_locked(&s, hole), expected, "{command}"); + for id in [outer, compound, outside] { + assert_eq!(shown_and_locked(&s, id), (true, false), "{command}: {id:?}"); + } + } +} + +/// Lock › Selection, Hide › Selection and All Artwork Above change the whole compound path when +/// one of its paths is selected inside an isolated group that holds it. +#[test] +fn lock_and_hide_a_compound_path_member_in_an_isolated_group() { + for command in ["object.lock", "object.hide", "object.lock.above", "object.hide.above"] { + let mut s = session(); + let outer = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let hole = rect(&mut s, 20.0, 20.0, 20.0, 20.0); + sel(&mut s, &[outer, hole]); + let compound = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()); + let b = rect(&mut s, 150.0, 0.0, 20.0, 20.0); + sel(&mut s, &[compound, b]); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + sel(&mut s, &[hole]); + assert!(find_command(command).unwrap().info(&s).enabled, "{command}"); + let before = history(&s).0; + s.execute(command, &json!({})).unwrap(); + assert_eq!(history(&s).0, before + 1, "{command}"); + assert_eq!(selected(&s), [], "{command}"); + let expected = if command.starts_with("object.lock") { (true, true) } else { (false, false) }; + assert_eq!(shown_and_locked(&s, compound), expected, "{command}"); + for id in [outer, hole, b, g] { + assert_eq!(shown_and_locked(&s, id), (true, false), "{command}: {id:?}"); + } + } +} + +/// Check that restore commands affect only isolated art and preserve undo and selection. +fn check_restore_in_group_isolation(change: &str, restore: &str, lock: bool) { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 20.0, 20.0, 100.0, 100.0); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + let out = rect(&mut s, 30.0, 30.0, 100.0, 100.0); + let l2 = id_of(&s.execute("layer.new", &json!({})).unwrap()); + let p = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + for id in [out, p] { + let params = if lock { json!({"id": id.0, "locked": true}) } else { json!({"id": id.0, "visible": false}) }; + s.execute("layer.setProps", ¶ms).unwrap(); + } s.execute("object.isolate", &json!({"id": g.0})).unwrap(); sel(&mut s, &[a]); - off_in_isolation(&mut s, &["object.lock.above", "object.lock.otherLayers", "object.hide.above", "object.hide.otherLayers"]); - for id in [a, b, g, over, l2] { - assert_eq!(shown_and_locked(&s, id), (true, false), "{id:?}"); + s.execute(change, &json!({})).unwrap(); + assert!(selected(&s).is_empty()); + assert!(find_command(restore).unwrap().info(&s).enabled); + let before = history(&s).0; + assert_eq!(s.execute(restore, &json!({})).unwrap(), json!({"count": 1})); + assert_eq!(history(&s).0, before + 1); + assert_eq!(selected(&s), [a]); + let changed = |s: &Session, id| if lock { node(s, id).locked } else { !node(s, id).visible }; + assert!(!changed(&s, a) && !changed(&s, b)); + assert!(changed(&s, out) && changed(&s, p)); + let no_op = history(&s).0; + assert_eq!(s.execute(restore, &json!({})).unwrap(), json!({"count": 0})); + assert_eq!(history(&s).0, no_op); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(changed(&s, a)); + assert!(selected(&s).is_empty()); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(!changed(&s, a) && changed(&s, out) && changed(&s, p) && !changed(&s, l2)); + assert_eq!(selected(&s), [a]); +} + +/// Unlock All restores locked art inside the group after Lock Selection clears selection. +#[test] +fn unlock_all_in_group_isolation_without_selection() { + check_restore_in_group_isolation("object.lock", "object.unlockAll", true); +} + +/// Show All restores hidden art inside the group after Hide Selection clears selection. +#[test] +fn show_all_in_group_isolation_without_selection() { + check_restore_in_group_isolation("object.hide", "object.showAll", false); +} + +/// With `scope: "group"`, Unlock All and Show All in an isolated compound path inside a group +/// change only the compound path's paths, not the group's other objects or a group outside it +/// that holds a selected object. +#[test] +fn group_scope_restore_stays_inside_isolated_compound_path() { + for (restore, lock) in [("object.unlockAll", true), ("object.showAll", false)] { + let mut s = session(); + let outer = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let hole = rect(&mut s, 20.0, 20.0, 20.0, 20.0); + sel(&mut s, &[outer, hole]); + let compound = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()); + let sibling = rect(&mut s, 150.0, 0.0, 20.0, 20.0); + sel(&mut s, &[compound, sibling]); + s.execute("object.group", &json!({})).unwrap(); + let away = rect(&mut s, 300.0, 0.0, 20.0, 20.0); + let away_mate = rect(&mut s, 350.0, 0.0, 20.0, 20.0); + sel(&mut s, &[away, away_mate]); + s.execute("object.group", &json!({})).unwrap(); + for id in [hole, sibling, away_mate] { + let params = if lock { json!({"id": id.0, "locked": true}) } else { json!({"id": id.0, "visible": false}) }; + s.execute("layer.setProps", ¶ms).unwrap(); + } + s.execute("object.isolate", &json!({"id": compound.0})).unwrap(); + let changed = |s: &Session, id| if lock { node(s, id).locked } else { !node(s, id).visible }; + sel(&mut s, &[away]); + assert_eq!(s.execute(restore, &json!({"scope": "group"})).unwrap(), json!({"count": 0}), "{restore}: outside selection"); + assert!(changed(&s, away_mate) && changed(&s, hole) && changed(&s, sibling), "{restore}: outside selection"); + sel(&mut s, &[outer]); + assert_eq!(s.execute(restore, &json!({"scope": "group"})).unwrap(), json!({"count": 1}), "{restore}"); + assert!(!changed(&s, hole), "{restore}: hole"); + assert!(changed(&s, sibling) && changed(&s, away_mate), "{restore}: outside"); + assert_eq!(selected(&s), [hole], "{restore}: selection"); + } +} + +/// Lock › Selection, Hide › Selection and All Artwork Above, with the isolated group and one of +/// its members both selected, change the member. +#[test] +fn lock_and_hide_with_the_isolated_group_selected_change_its_member() { + for command in ["object.lock", "object.hide", "object.lock.above", "object.hide.above"] { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 200.0, 0.0, 100.0, 100.0); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + sel(&mut s, &[g, a]); + assert!(find_command(command).unwrap().info(&s).enabled, "{command}"); + let before = history(&s).0; + let want = if command.ends_with(".above") { json!({"count": 1}) } else { serde_json::Value::Null }; + assert_eq!(s.execute(command, &json!({})).unwrap(), want, "{command}"); + assert_eq!(history(&s).0, before + 1, "{command}"); + let expected = if command.starts_with("object.lock") { (true, true) } else { (false, false) }; + assert_eq!(shown_and_locked(&s, a), expected, "{command}: A"); + for id in [b, g] { + assert_eq!(shown_and_locked(&s, id), (true, false), "{command}: {id:?}"); + } + } +} + +/// Lock and Hide Unselected with the isolated group and one of its members selected change only its other members. +#[test] +fn lock_and_hide_unselected_with_the_isolated_group_selected_keep_its_member() { + for command in ["object.lock.unselected", "object.hide.unselected"] { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 200.0, 0.0, 100.0, 100.0); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + sel(&mut s, &[g, a]); + assert!(find_command(command).unwrap().info(&s).enabled, "{command}"); + assert_eq!(s.execute(command, &json!({})).unwrap(), json!({"count": 1}), "{command}"); + let changed = if command == "object.lock.unselected" { (true, true) } else { (false, false) }; + assert_eq!(shown_and_locked(&s, b), changed, "{command}: B"); + for id in [a, g] { + assert_eq!(shown_and_locked(&s, id), (true, false), "{command}: {id:?}"); + } + } +} + +/// Lock and Hide Unselected with the isolated layer and one of its objects selected change the layer's other objects, +/// and a selected sublayer inside the isolated layer keeps its art. +#[test] +fn lock_and_hide_unselected_with_the_isolated_layer_selected_keep_its_object() { + for command in ["object.lock.unselected", "object.hide.unselected"] { + let mut s = session(); + let layer = s.doc().unwrap().doc.layers[0].id; + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 200.0, 0.0, 100.0, 100.0); + let sub = id_of(&s.execute("layer.newSublayer", &json!({"parent": layer.0})).unwrap()); + s.execute("layer.setCurrent", &json!({"id": sub.0})).unwrap(); + let c = rect(&mut s, 400.0, 0.0, 100.0, 100.0); + s.execute("object.isolate", &json!({"id": layer.0})).unwrap(); + let changed = if command == "object.lock.unselected" { (true, true) } else { (false, false) }; + sel(&mut s, &[layer, a]); + assert!(find_command(command).unwrap().info(&s).enabled, "{command}"); + assert_eq!(s.execute(command, &json!({})).unwrap(), json!({"count": 2}), "{command}"); + for id in [b, c] { + assert_eq!(shown_and_locked(&s, id), changed, "{command}: {id:?}"); + } + for id in [a, sub, layer] { + assert_eq!(shown_and_locked(&s, id), (true, false), "{command}: {id:?}"); + } + s.execute("edit.undo", &json!({})).unwrap(); + sel(&mut s, &[layer, sub]); + assert_eq!(s.execute(command, &json!({})).unwrap(), json!({"count": 2}), "{command}: sublayer"); + for id in [a, b] { + assert_eq!(shown_and_locked(&s, id), changed, "{command}: sublayer, {id:?}"); + } + for id in [c, sub, layer] { + assert_eq!(shown_and_locked(&s, id), (true, false), "{command}: sublayer, {id:?}"); + } + } +} + +/// Pattern editing keeps Lock Selection and Unlock All available for its tile art. +#[test] +fn pattern_editing_keeps_lock_and_unlock_available() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[a]); + s.execute("object.pattern.make", &json!({"name": "P"})).unwrap(); + let tile = selected(&s)[0]; + assert!(find_command("object.lock").unwrap().info(&s).enabled); + s.execute("object.lock", &json!({})).unwrap(); + assert!(node(&s, tile).locked); + assert!(find_command("object.unlockAll").unwrap().info(&s).enabled); + s.execute("object.unlockAll", &json!({})).unwrap(); + assert!(!node(&s, tile).locked); + assert_eq!(selected(&s), [tile]); +} + +/// Opacity mask editing lets Lock and Hide Selection change mask art and restores it with Unlock All and Show All. +#[test] +fn opacity_mask_editing_keeps_lock_hide_and_restore_available() { + let mut s = session(); + let artwork = rect(&mut s, 0.0, 0.0, 200.0, 200.0); + let mask_source = rect(&mut s, 0.0, 0.0, 100.0, 200.0); + sel(&mut s, &[artwork, mask_source]); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + s.execute("transparency.editOpacityMask", &json!({})).unwrap(); + let mask = selected(&s)[0]; + for (change, restore) in [("object.lock", "object.unlockAll"), ("object.hide", "object.showAll")] { + assert!(find_command(change).unwrap().info(&s).enabled, "{change}"); + s.execute(change, &json!({})).unwrap(); + assert!(selected(&s).is_empty(), "{change}"); + assert_eq!(shown_and_locked(&s, mask), if change == "object.lock" { (true, true) } else { (false, false) }); + assert!(find_command(restore).unwrap().info(&s).enabled, "{restore}"); + assert_eq!(s.execute(restore, &json!({})).unwrap(), json!({"count": 1}), "{restore}"); + assert_eq!(shown_and_locked(&s, mask), (true, false), "{restore}"); + assert_eq!(selected(&s), [mask], "{restore}"); + } +} + +/// Other Layers changes nothing in an isolated layer or sublayer. +#[test] +fn other_layers_has_no_target_in_isolated_layers_or_sublayers() { + for sublayer in [false, true] { + let mut s = session(); + let layer = s.doc().unwrap().doc.layers[0].id; + let root = if sublayer { id_of(&s.execute("layer.newSublayer", &json!({"parent": layer.0})).unwrap()) } else { layer }; + s.execute("layer.setCurrent", &json!({"id": root.0})).unwrap(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let other = id_of(&s.execute("layer.new", &json!({"top": true})).unwrap()); + s.execute("object.isolate", &json!({"id": root.0})).unwrap(); + sel(&mut s, &[a]); + for command in ["object.lock.otherLayers", "object.hide.otherLayers"] { + assert!(find_command(command).unwrap().info(&s).enabled); + let before = history(&s).0; + assert_eq!(s.execute(command, &json!({})).unwrap(), json!({"count": 0})); + assert_eq!(history(&s).0, before); + assert_eq!(selected(&s), [a]); + assert_eq!(shown_and_locked(&s, other), (true, false)); + } } - s.execute("object.exitIsolation", &json!({})).unwrap(); - sel(&mut s, &[g]); - assert_eq!(s.execute("object.lock.above", &json!({})).unwrap(), json!({"count": 1})); - s.execute("object.hide.otherLayers", &json!({})).unwrap(); - assert_eq!(shown_and_locked(&s, over), (true, true)); - assert_eq!(shown_and_locked(&s, l2), (false, false)); } /// How many steps there are to undo and to redo, and whether the document has unsaved changes. @@ -580,12 +790,10 @@ fn hide_unselected_artwork_leaves_the_art_on_a_locked_artboard() { assert_eq!([a, b, on_locked].map(|id| node(&s, id).visible), [true, false, true]); } -/// In isolation mode Hide › Unselected Artwork is disabled: the menu item is off, and the command returns an error that -/// names isolation mode and changes nothing. While Lock Guides is on it leaves the guides as they are. With nothing -/// left to hide it records no undo step and returns a count of 0. A compound path that holds a selected path stays as -/// it is. +/// Hide Unselected affects only isolated art, keeps locked guides, records no step for a no-op, and +/// preserves a compound path that holds the selected path. #[test] -fn hide_unselected_artwork_keeps_locked_guides_and_is_disabled_in_isolation_mode() { +fn hide_unselected_artwork_keeps_locked_guides_and_scopes_isolation() { let mut s = session(); let guide = rect(&mut s, 0.0, 0.0, 10.0, 10.0); sel(&mut s, &[guide]); @@ -600,17 +808,15 @@ fn hide_unselected_artwork_keeps_locked_guides_and_is_disabled_in_isolation_mode let shown = |s: &Session, ids: &[NodeId]| ids.iter().map(|id| node(s, *id).visible).collect::>(); s.execute("object.isolate", &json!({"id": g.0})).unwrap(); select_with_a_step_to_redo(&mut s, &[m]); - let before = history(&s); - let info = find_command("object.hide.unselected").unwrap().info(&s); - assert!(!info.enabled && info.disabled_reason.as_deref() == Some("exit isolation mode first"), "{info:?}"); - let e = s.execute("object.hide.unselected", &json!({})).unwrap_err(); - assert!(matches!(e, EngineError::Disabled(..)) && e.to_string().contains("isolation mode"), "{e}"); - assert_eq!(shown(&s, &[m, n, out, guide]), [true; 4]); - assert_eq!((history(&s), selected(&s)), (before, vec![m])); + let before = history(&s).0; + assert!(find_command("object.hide.unselected").unwrap().info(&s).enabled); + assert_eq!(hide(&mut s), json!({"count": 1})); + assert_eq!(history(&s).0, before + 1); + assert_eq!(shown(&s, &[m, n, out, guide]), [true, false, true, true]); + assert_eq!(selected(&s), [m]); s.execute("object.exitIsolation", &json!({})).unwrap(); sel(&mut s, &[m]); - assert!(find_command("object.hide.unselected").unwrap().info(&s).enabled); - assert_eq!(hide(&mut s), json!({"count": 2})); + assert_eq!(hide(&mut s), json!({"count": 1})); assert_eq!(shown(&s, &[n, out, guide]), [false, false, true]); select_with_a_step_to_redo(&mut s, &[m]); let before = history(&s); @@ -621,7 +827,7 @@ fn hide_unselected_artwork_keeps_locked_guides_and_is_disabled_in_isolation_mode sel(&mut s, &[m]); assert_eq!(hide(&mut s), json!({"count": 1})); assert_eq!(shown(&s, &[guide]), [false]); - // A compound path that holds the selected path stays as it is with its paths. + // The compound path and its paths stay visible when one path is selected. let outer = rect(&mut s, 0.0, 200.0, 100.0, 100.0); let hole = rect(&mut s, 40.0, 240.0, 20.0, 20.0); sel(&mut s, &[outer, hole]); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 17b05d72e..f7361da0c 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -6971,10 +6971,10 @@ fn the_task_bar_recolors_coloured_art() { assert!(app.ui.dialog.is_none(), "no Recolor button without colours"); } -/// The Contextual Task Bar's Lock button is off in isolation mode, as Object › Lock › Selection -/// is: a click locks nothing and shows no error. Outside isolation mode it locks the selection. +/// The Contextual Task Bar's Lock button locks the selected member in isolation mode and the +/// selected group outside isolation mode. #[test] -fn the_task_bar_lock_button_is_off_in_isolation_mode() { +fn the_task_bar_lock_button_locks_selected_art_in_isolation_mode() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); // Plain paths: a basic shape shows its properties in Lock's place. @@ -6982,7 +6982,7 @@ fn the_task_bar_lock_button_is_off_in_isolation_mode() { |x: f64| json!({"anchors": [{"x": x, "y": 130}, {"x": x + 40.0, "y": 130}, {"x": x + 40.0, "y": 170}, {"x": x, "y": 170}], "closed": true}); let a = app.session.execute("path.create", &square(150.0)).unwrap()["id"].clone(); let b = app.session.execute("path.create", &square(210.0)).unwrap()["id"].clone(); - app.session.execute("select.set", &json!({"ids": [a.clone(), b]})).unwrap(); + app.session.execute("select.set", &json!({"ids": [a.clone(), b.clone()]})).unwrap(); let g = app.session.execute("object.group", &json!({})).unwrap()["id"].clone(); app.select_tool("selection"); let ctx = egui::Context::default(); @@ -7003,7 +7003,8 @@ fn the_task_bar_lock_button_is_off_in_isolation_mode() { app.session.execute("object.isolate", &json!({"id": g.clone()})).unwrap(); app.session.execute("select.set", &json!({"ids": [a.clone()]})).unwrap(); click_lock(&mut app); - assert!(!locked(&app, &a), "nothing is locked in isolation mode"); + assert!(locked(&app, &a), "the selected member is locked in isolation mode"); + assert!(!locked(&app, &b), "the other member stays unlocked"); assert_eq!(app.ui.status, "", "no error"); app.session.execute("object.exitIsolation", &json!({})).unwrap(); app.session.execute("select.set", &json!({"ids": [g.clone()]})).unwrap(); diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index b0ee269ad..17a8864c1 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -669,10 +669,9 @@ fn expand_appearance_is_an_object_menu_item_only() { assert_eq!(paths, [vec!["Object".to_string()]]); } -/// In isolation mode, every item of Object › Lock and Object › Hide, Unlock All and Show All are -/// greyed out, while Group stays on. After Exit Isolation Mode they are on again. +/// Isolation keeps the Lock and Hide actions available for selected members. #[test] -fn lock_and_hide_menus_are_greyed_out_in_isolation_mode() { +fn lock_and_hide_menus_are_enabled_in_isolation_mode() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({})).unwrap(); let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].clone(); @@ -715,8 +714,11 @@ fn lock_and_hide_menus_are_greyed_out_in_isolation_mode() { }; app.run("object.isolate", json!({"id": g.clone()})).unwrap(); app.run("select.set", json!({"ids": [b]})).unwrap(); - assert_eq!(shown(&app), [false, false, false, false, false, false, false, false, false, true], "in isolation mode"); + assert_eq!(shown(&app), [true; 10], "in isolation mode"); assert!(lock_unselected(&app), "shortcut command remains enabled in isolation"); + assert!(crate::menus::enabled(&app, "object.hide.unselected"), "Hide Unselected remains enabled in isolation"); + let chord = crate::shortcut_editor::default_of("object.hide.unselected").expect("default Hide Unselected shortcut"); + assert_eq!(crate::shortcut_editor::normalize(chord), crate::shortcut_editor::normalize("Cmd+Alt+Shift+3")); app.run("object.exitIsolation", json!({})).unwrap(); app.run("select.set", json!({"ids": [g]})).unwrap(); assert_eq!(shown(&app), [true; 10], "after Exit Isolation Mode"); diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 0bd879a31..49acb3e8a 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -1286,25 +1286,28 @@ fn blend_options_is_a_quick_action_for_one_blend() { assert!(!shows(&tall(&mut app, &ctx, vec![]), "Blend Options"), "a rectangle"); } -/// Quick Actions have no Lock in isolation mode, where Object › Lock › Selection is off. Outside -/// isolation mode Lock is there and locks the selection. +/// Quick Actions offer Lock for a selected member in isolation and for a selected group outside it. #[test] -fn lock_is_a_quick_action_outside_isolation_mode_only() { +fn lock_is_a_quick_action_in_and_outside_isolation_mode() { let mut app = new_app(); let (a, b) = (rect(&mut app, 20.0, 20.0, 40.0, 40.0), rect(&mut app, 200.0, 20.0, 40.0, 40.0)); app.run("select.set", json!({"ids": [a.clone(), b]})).unwrap(); let g = app.run("object.group", json!({})).unwrap()["id"].clone(); app.run("object.isolate", json!({"id": g.clone()})).unwrap(); - app.run("select.set", json!({"ids": [a]})).unwrap(); + app.run("select.set", json!({"ids": [a.clone()]})).unwrap(); let ctx = egui::Context::default(); let texts = tall(&mut app, &ctx, vec![]); - assert!(shows(&texts, "Offset Path") && !shows(&texts, "Lock"), "no Lock in isolation mode: {texts:?}"); + assert!(shows(&texts, "Offset Path") && shows(&texts, "Lock"), "Lock in isolation mode: {texts:?}"); + press(&mut app, &ctx, text_at(&texts, "Lock").center()); + let locked = |app: &VectorcraftApp, id: &serde_json::Value| { + app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().locked + }; + assert!(locked(&app, &a)); app.run("object.exitIsolation", json!({})).unwrap(); app.run("select.set", json!({"ids": [g.clone()]})).unwrap(); let texts = tall(&mut app, &ctx, vec![]); press(&mut app, &ctx, text_at(&texts, "Lock").center()); - let locked = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(g.as_u64().unwrap())).unwrap().locked; - assert!(locked, "Lock locks the group"); + assert!(locked(&app, &g), "Lock locks the group"); } /// A Live Paint group selected with a path has Merge Live Paint in Quick Actions, which adds diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 38365f5ed..ad71fb397 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -264,7 +264,7 @@ fn cmd_shift_o_creates_outlines() { /// Cmd+Alt+Shift+3 is Object › Hide › Unselected Artwork: it hides the object that is not /// selected. Cmd+Alt+3 still shows all, and Cmd+3 still hides the selection. In isolation mode -/// the item is disabled. +/// the shortcut hides unselected members of the isolated group. #[test] fn cmd_alt_shift_3_hides_unselected_artwork() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); @@ -285,15 +285,15 @@ fn cmd_alt_shift_3_hides_unselected_artwork() { app.session.execute("select.set", &json!({"ids": [a.0]})).unwrap(); frame(&mut app, vec![three(Modifiers::COMMAND)]); assert_eq!(shown(&app), [false, true], "Cmd+3 hides the selection"); - // In isolation mode the menu item is off, and the shortcut hides nothing. + // In isolation mode the shortcut hides only the group's unselected member. app.session.execute("object.showAll", &json!({})).unwrap(); app.session.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); let g = app.session.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); app.session.execute("object.isolate", &json!({"id": g})).unwrap(); app.session.execute("select.set", &json!({"ids": [a.0]})).unwrap(); - assert!(!crate::menus::enabled(&app, "object.hide.unselected")); + assert!(crate::menus::enabled(&app, "object.hide.unselected")); frame(&mut app, vec![three(Modifiers::COMMAND | Modifiers::ALT | Modifiers::SHIFT)]); - assert_eq!(shown(&app), [true, true], "nothing is hidden in isolation mode"); + assert_eq!(shown(&app), [true, false], "the unselected member is hidden in isolation mode"); } /// Cmd+Alt+Shift+J opens Average & Join's dialog, Points: Corner the first time. Cancel changes diff --git a/docs/mcp.md b/docs/mcp.md index d4898b517..6f342c98c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1380,7 +1380,8 @@ them again with the key object. When the call changes nothing, it records no und Other `object.setProps` calls leave the selection unchanged. `object.unlockAll` and `object.showAll` unlock or show every locked or hidden object in the document and select -them → `{count}`. With `scope: "group"` they take only the objects inside the groups that hold the selected objects: +them → `{count}`. In isolation mode they take only the objects inside the isolated object. With `scope: "group"` they +take only the objects inside the groups that hold the selected objects: for each selected object, the group nearest above it, or the object itself when it is a group. The objects of a group inside another such group are counted once. With no selected object in a group, the call returns the error "nothing selected is in a group" and changes nothing. `scope: "document"` is the default, and any other value is @@ -1397,16 +1398,15 @@ object that `select.all` takes and that is neither selected nor holds a selected group that holds a selected object it hides the group's other members one by one; a clip group, a compound path or another object that holds a selected object stays as it is with its members. The objects of a selected layer count as selected. While Lock Guides is on, the guide paths count as selected and stay as they are. The selection stays as it is, and `object.showAll` shows the objects again. With nothing to -hide it records no undo step. In isolation mode the menu item is disabled, and the command returns the error "exit -isolation mode first" and changes nothing. +hide it records no undo step. In isolation mode it hides only unselected objects inside the isolated object. `object.lock.unselected` (Cmd+Alt+Shift+2; shortcut and command, with no Object › Lock menu item) locks unselected artwork in one undo step and returns `{count}`. In a partly selected group, clipping group, or compound path, it locks the unselected members and leaves the container and selected members unlocked. It includes unselected guides even while Lock Guides is on. Hidden art and art on locked layers stay individually unlocked; a selected layer's artwork counts as selected. In ordinary isolation mode it locks only unselected art inside the isolated object. The selection -stays as it is. `object.unlockAll` unlocks and selects all locked artwork in the document, including art locked before -this command. With nothing to lock it records no undo step. +stays as it is. `object.unlockAll` unlocks and selects all locked artwork in the document, or inside the isolated +object in isolation mode, including art locked before this command. With nothing to lock it records no undo step. These Select menu commands select only the objects that `select.all` selects and the objects inside them: every `select.same.*` and `select.object.*` command except `select.object.slices`, `select.nextAbove`, `select.nextBelow` @@ -2334,11 +2334,14 @@ and `layerRows`, the rows highlighted in the panel. opens no rows for it and scrolls as with the option off. A change made with the panel's own rows, buttons or menu is not located, and a change made during a drag is located when the drag ends. A document opens with only its top-level layers open; the open rows are saved in the native file and reopen that way. -- **Lock and Hide in isolation mode**: in isolation mode, Object › Lock's and Object › Hide's items (`object.lock`, - `object.lock.above`, `object.lock.otherLayers`, `object.hide`, `object.hide.above`, `object.hide.otherLayers`), - `object.unlockAll` and `object.showAll` are off, and a call returns the error "command `object.hide` is not - available right now: exit isolation mode first", with the command's own id. Pattern editing mode and opacity mask - editing do not count as isolation mode for these commands. +- **Lock and Hide in isolation mode**: Object › Lock's and Object › Hide's items (`object.lock`, `object.lock.above`, + `object.lock.otherLayers`, `object.lock.unselected`, `object.hide`, `object.hide.above`, `object.hide.otherLayers`, + `object.hide.unselected`) are on while an object inside the isolated object is selected, and they change only objects + inside it. All Artwork Above locks or hides the selected objects as well as the objects above them that overlap them. + Other Layers changes nothing in an isolated group, layer or sublayer, returns a count of 0 and records no undo step. + `object.unlockAll` and `object.showAll` are on with nothing selected, and they unlock or show and select only the + objects inside the isolated object. Pattern editing mode and opacity mask editing do not count as isolation mode for + these commands. - **Search and filter** (control channel and the app's MCP): the Search All field keeps the rows whose names hold the text typed, and `ui.layersFilter {objects?, layers?}` sets the choices of the Filter button beside it. `objects` (By Object) takes `paths` (paths and compound paths), `shapes` (live rectangles, ellipses, polygons and lines), `text`, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e950d73e7..05985f9f1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -986,9 +986,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. The Libraries panel holds gradients and patterns: its + button adds the selection's fill or stroke gradient or pattern, a gradient or pattern swatch dragged from the Swatches panel is added, and a click paints the selection through the active Fill/Stroke proxy, a pattern bringing its definition and swatch into the document. Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included. - Object › Hide › Unselected Artwork (Cmd/Ctrl+Alt+Shift+3, `object.hide.unselected`) hides, in one undo step, every object Select All takes that is not selected and holds no selected object; the members of a group that holds a selected object are hidden one by one, and Object › Show All shows them again. It is disabled in isolation mode. Public references: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), "Hide unselected artwork"; [How to lock, hide, and delete objects in Illustrator](https://web.archive.org/web/20220303070637/https://helpx.adobe.com/illustrator/using/locking-hiding-deleting-objects.html), which says the Show All and Hide menus are unavailable in isolation mode. - Cmd/Ctrl+Alt+Shift+2 (`object.lock.unselected`) locks unselected artwork in one undo step and has no Object › Lock menu item. It locks unselected members of partly selected groups, clipping groups and compound paths, and unselected guides while Lock Guides is on. In ordinary isolation mode it locks only unselected art inside the isolated object. Object › Unlock All unlocks all locked art in the document. The [public shortcut page](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) calls the action "Lock all deselected artwork". - In isolation mode, the visible items of Object › Lock and Object › Hide, Unlock All and Show All are greyed out ([lock, hide, and delete objects](https://web.archive.org/web/20220303070637/https://helpx.adobe.com/illustrator/using/locking-hiding-deleting-objects.html)); the shortcut-only `object.lock.unselected` command remains enabled with a selection. The Contextual Task Bar's Lock button is greyed out too, the Properties panel's Quick Actions have no Lock, and the other Lock and Hide commands return an error. Pattern editing mode and opacity mask editing do not count as isolation mode for them. + Object › Hide › Unselected Artwork (Cmd/Ctrl+Alt+Shift+3, `object.hide.unselected`) hides, in one undo step, every object Select All takes that is not selected and holds no selected object; the members of a group that holds a selected object are hidden one by one, and Object › Show All shows them again. In isolation mode it hides only unselected objects inside the isolated object. Public reference: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), "Hide unselected artwork". + Cmd/Ctrl+Alt+Shift+2 (`object.lock.unselected`) locks unselected artwork in one undo step and has no Object › Lock menu item. It locks unselected members of partly selected groups, clipping groups and compound paths, and unselected guides while Lock Guides is on. In ordinary isolation mode it locks only unselected art inside the isolated object. Object › Unlock All unlocks all locked art in the document, or inside the isolated object in isolation mode. The [public shortcut page](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) calls the action "Lock all deselected artwork". + In isolation mode, the items of Object › Lock and Object › Hide are on while an object inside the isolated object is selected, and they change only objects inside it: All Artwork Above locks or hides the selected objects and the objects above them that overlap them, and Other Layers changes nothing in an isolated group, layer or sublayer. Unlock All and Show All are on with nothing selected and unlock or show and select only the objects inside the isolated object. The Contextual Task Bar's Lock button and the Properties panel's Quick Actions Lock lock the selected object. Pattern editing mode and opacity mask editing do not count as isolation mode for them. Liquify (its brush Width, Height and Angle, Intensity, Detail, Simplify, Twirl Rate, Complexity, Horizontal and Vertical), Puppet Warp (its Expand and each pin's angle), Transform in Perspective (its depth), Nudge in Perspective (its direction) and Move Plane (its location or distance) refuse a value other than a number or null, such as "40", Draw in Perspective refuses a click point that is not two numbers, and Width Profile refuses a point that is not three numbers; each leaves the document and its undo history as they were. Object › Lock › All Artwork Above and Object › Hide › All Artwork Above go through each top-level layer once, checking each object that is not selected against the selected objects below it: with 100 tiles selected under 25,000 rectangles, Lock took 6.4 s and now takes under 0.1 s, and with all of 5,001 objects selected it took 8.8 s and now takes under 0.1 s. Object › Unlock All and Object › Show All clicked in the in-window menu bar with Shift+Alt (Shift+Option) held unlock or show only the objects inside the groups that hold the selected objects ([lock, hide, and delete objects](https://web.archive.org/web/20230623025912/https://helpx.adobe.com/illustrator/using/locking-hiding-deleting-objects.html)); `object.unlockAll` and `object.showAll` take `scope: "group"` for it.