From 381b788f563a35b81190b59bc3e807ef860998f4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 17:46:19 +0700 Subject: [PATCH] Slices: Lock Slices and Hide Slices deselect the slices and keep them deselected Squashed from #2841 onto main. --- crates/engine/src/cmd/docmenu.rs | 2 +- crates/engine/src/cmd/edit.rs | 14 +- crates/engine/src/cmd/fileio/batch.rs | 7 +- crates/engine/src/cmd/object.rs | 6 +- crates/engine/src/cmd/slices.rs | 37 +++- crates/engine/src/lib.rs | 54 +++--- crates/engine/src/tests_menucmds.rs | 17 ++ crates/engine/src/tests_slices.rs | 248 ++++++++++++++++++++++++++ docs/mcp.md | 20 ++- 9 files changed, 351 insertions(+), 54 deletions(-) diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index fc56b5c6e..88da38361 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -617,7 +617,7 @@ fn lock_guides(s: &mut Session, p: &Value) -> Result { if v { let active = s.active; for (i, st) in s.docs.iter_mut().enumerate() { - if Some(i) != active && crate::deselect_guides(&st.doc, &mut st.selection) { + if Some(i) != active && crate::deselect_hidden(&s.menu, &st.doc, &mut st.selection) { st.revision += 1; } } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 5ec67c4ea..3ef1f0ab7 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -453,7 +453,7 @@ pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> { fn undo(s: &mut Session, _: &Value) -> Result { settle_interaction(s)?; - let guides_locked = s.menu.guides_locked; + let menu = s.menu.clone(); let st = s.doc_mut()?; let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?; let label = e.label.clone(); @@ -468,10 +468,8 @@ fn undo(s: &mut Session, _: &Value) -> Result { st.history.trim(); st.revision += 1; st.selection.prune(&st.doc); - // Guides locked since the step was made stay deselected. - if guides_locked { - crate::deselect_guides(&st.doc, &mut st.selection); - } + // Guides locked and slices locked or hidden since the step was made stay deselected. + crate::deselect_hidden(&menu, &st.doc, &mut st.selection); st.sync_new_art(); super::maskedit::follow_history(st, editing); leave_isolation_if_gone(st); @@ -481,7 +479,7 @@ fn undo(s: &mut Session, _: &Value) -> Result { fn redo(s: &mut Session, _: &Value) -> Result { settle_interaction(s)?; - let guides_locked = s.menu.guides_locked; + let menu = s.menu.clone(); let st = s.doc_mut()?; let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?; let label = e.label.clone(); @@ -496,9 +494,7 @@ fn redo(s: &mut Session, _: &Value) -> Result { st.history.trim(); st.revision += 1; st.selection.prune(&st.doc); - if guides_locked { - crate::deselect_guides(&st.doc, &mut st.selection); - } + crate::deselect_hidden(&menu, &st.doc, &mut st.selection); st.sync_new_art(); super::maskedit::follow_history(st, editing); leave_isolation_if_gone(st); diff --git a/crates/engine/src/cmd/fileio/batch.rs b/crates/engine/src/cmd/fileio/batch.rs index 02d8c7246..4b017caa7 100644 --- a/crates/engine/src/cmd/fileio/batch.rs +++ b/crates/engine/src/cmd/fileio/batch.rs @@ -99,7 +99,8 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option, cmds: &mut [Va /// ones close, closed or replaced ones (`stash`) come back, and each has its art, selection, /// history, file, format and saved state again (#1697: undoing the batch's interaction alone left /// a Save As step's new file and an Undo step's history in place). Files the steps wrote stay -/// written. Lock Guides stays as the steps left it, and while it is on the guides are deselected. +/// written. Lock Guides, Lock Slices and Hide Slices stay as the steps left them, and the guides +/// and slices they keep from being selected are deselected. fn roll_back(s: &mut Session, start: Vec, stash: &[DocState]) { let open = std::mem::take(&mut s.docs); s.docs = start; @@ -107,8 +108,6 @@ fn roll_back(s: &mut Session, start: Vec, stash: &[DocState]) { // Past the newest revision the document reached, so the restored one redraws. let newest = open.iter().chain(stash).filter(|d| d.uid == st.uid).map(|d| d.revision).max().unwrap_or(st.revision); st.revision = newest.saturating_add(1); - if s.menu.guides_locked { - crate::deselect_guides(&st.doc, &mut st.selection); - } + crate::deselect_hidden(&s.menu, &st.doc, &mut st.selection); } } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index c559b4bff..22ef28d13 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1139,16 +1139,14 @@ fn isolate(s: &mut Session, p: &Value) -> Result { } fn exit_isolation(s: &mut Session, _: &Value) -> Result { - let guides_locked = s.menu.guides_locked; + let menu = s.menu.clone(); let st = s.doc_mut()?; if let Some(i) = st.isolation.take() { super::distortcmds::finish_edit_text(st, i); let target = st.doc.parent_of(i).filter(|p| st.doc.node(*p).is_some_and(|n| n.extras().shaper.is_some())).unwrap_or(i); st.selection.set([target]); // A guide path isolated while Lock Guides is on isn't selected (#1534). - if guides_locked { - crate::deselect_guides(&st.doc, &mut st.selection); - } + crate::deselect_hidden(&menu, &st.doc, &mut st.selection); // The art on a locked artboard can't be selected. st.protect_locked_artboards(&[], &Default::default(), None); } diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index b75680e1a..57e3b30bb 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -109,13 +109,21 @@ pub fn specs() -> Vec { has_doc, clip_to_artboard ), - cmd!(query "view.slices.hide", "Hide Slices", ["View"], None, "{hidden?: bool} toggle (or set) whether the canvas hides the slices (session view state) → {hidden}", has_doc, hide), + cmd!( + query "view.slices.hide", + "Hide Slices", + ["View"], + None, + "{hidden?: bool} toggle (or set) whether the canvas hides the slices (session view state); hiding deselects the slices in every open document, and showing them again leaves them deselected and does not restore the selection that was cleared; while they are hidden no command selects one (an object that is an object slice can still be selected as art) → {hidden}", + has_doc, + hide + ), cmd!( query "view.slices.lock", "Lock Slices", ["View"], None, - "{locked?: bool} toggle (or set) the slice lock: locked slices can't be selected or edited with the Slice Selection tool → {locked}", + "{locked?: bool} toggle (or set) the slice lock: locked slices can't be selected or edited with the Slice Selection tool; locking deselects the slices in every open document, and unlocking leaves them deselected and does not restore the selection that was cleared; while it is on no command selects one (an object that is an object slice can still be selected as art) → {locked}", has_doc, lock ), @@ -124,7 +132,7 @@ pub fn specs() -> Vec { "List Slices", [], None, - "{} the slices as laid out, numbered left to right and top to bottom → {slices: [{number, id (null for auto slices), source: \"user\"|\"object\"|\"auto\", name, x, y, width, height, options, selected}], clipToArtboard, hidden, locked}", + "{} the slices as laid out, numbered left to right and top to bottom → {slices: [{number, id (null for auto slices), source: \"user\"|\"object\"|\"auto\", name, x, y, width, height, options, selected (selected with the Slice Selection tool or a slice command, or an object slice whose object is selected)}], clipToArtboard, hidden, locked}", has_doc, list ), @@ -589,18 +597,35 @@ fn clip_to_artboard(s: &mut Session, p: &Value) -> Result { fn hide(s: &mut Session, p: &Value) -> Result { let v = p.get("hidden").and_then(Value::as_bool).unwrap_or(!s.menu.slices_hidden); s.menu.slices_hidden = v; + if v { + deselect_in_every_document(s); + } Ok(json!({ "hidden": v })) } fn lock(s: &mut Session, p: &Value) -> Result { let v = p.get("locked").and_then(Value::as_bool).unwrap_or(!s.menu.slices_locked); s.menu.slices_locked = v; + if v { + deselect_in_every_document(s); + } Ok(json!({ "locked": v })) } +/// Locked or hidden slices can't stay selected, in any open document ([`crate::deselect_hidden`]). +fn deselect_in_every_document(s: &mut Session) { + for st in &mut s.docs { + if crate::deselect_hidden(&s.menu, &st.doc, &mut st.selection) { + st.revision += 1; + } + } +} + fn list(s: &mut Session, _: &Value) -> Result { let st = s.doc()?; let d = &st.doc; + // What the Object → Slice commands act on: the selected objects that are object slices too. + let selected = selected_slices(d, &st.selection); let slices: Vec = d .slice_layout() .iter() @@ -609,7 +634,7 @@ fn list(s: &mut Session, _: &Value) -> Result { json!({ "number": a.number, "id": a.id.map(|i| i.0), "source": a.source.key(), "name": d.slice_name(a), "x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height(), - "options": options_json(&o), "selected": a.id.is_some_and(|id| st.selection.slices.contains(&id)), + "options": options_json(&o), "selected": a.id.is_some_and(|id| selected.contains(&id)), }) }) .collect(); @@ -618,9 +643,9 @@ fn list(s: &mut Session, _: &Value) -> Result { fn select_all(s: &mut Session, _: &Value) -> Result { let ids = s.doc()?.doc.slice_ids(); - let n = ids.len(); s.select(|_, sel| sel.set_slices(ids))?; - Ok(json!({ "count": n })) + // None while the slices are locked or hidden. + Ok(json!({ "count": s.doc()?.selection.slices.len() })) } // ---------- the Slice and Slice Selection tools ---------- diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 43403f26e..577d6dfad 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -869,6 +869,19 @@ pub(crate) fn deselect_guides(d: &Document, sel: &mut Selection) -> bool { any } +/// Deselect what the View menu keeps from being selected: the guides while Lock Guides is on +/// ([`deselect_guides`]), and the slices that the Slice Selection tool or a slice command selected +/// ([`Selection::slices`]) while Lock Slices or Hide Slices is on. A selected object that is an +/// object slice stays selected. → whether any was selected. +pub(crate) fn deselect_hidden(menu: &cmd::menucmds::MenuState, d: &Document, sel: &mut Selection) -> bool { + let guides = menu.guides_locked && deselect_guides(d, sel); + let slices = (menu.slices_locked || menu.slices_hidden) && !sel.slices.is_empty(); + if slices { + sel.slices.clear(); + } + guides || slices +} + /// The error of an edit that would nest objects deeper than [`vectorcraft_doc::MAX_DEPTH`]. pub const TOO_DEEP: &str = "objects can't be nested more than 32 deep"; const _: () = assert!(vectorcraft_doc::MAX_DEPTH == 32, "TOO_DEEP (and its catalog rows) names the cap"); @@ -1920,7 +1933,7 @@ impl Session { if !self.previewing && cmd::typing_in_progress(self) { cmd::settle_interaction(self)?; } - let guides_locked = self.menu.guides_locked; + let menu = self.menu.clone(); let st = self.doc_mut()?; let before = st.doc.clone(); let before_sel = st.selection.clone(); @@ -1977,10 +1990,10 @@ impl Session { match result { Ok(v) => { st.selection.prune(&st.doc); - // An edit that selects what it unlocks, shows or pastes leaves locked guides out. - if guides_locked { - deselect_guides(&st.doc, &mut st.selection); - } + // An edit that selects what it unlocks, shows or pastes leaves locked guides out, + // and one that selects the slices it makes leaves them out while they are locked + // or hidden. + deselect_hidden(&menu, &st.doc, &mut st.selection); // The art on a locked artboard can't stay selected, however it got there, except // the new art this edit made and new art still selected. st.protect_locked_artboards(&before_sel.objects, &before_sel.new_art, Some(&*before)); @@ -2033,16 +2046,15 @@ impl Session { /// Change only the selection (not an undo step). pub fn select(&mut self, f: impl FnOnce(&Document, &mut Selection)) -> Result<()> { self.active_appearance_item = None; - let guides_locked = self.menu.guides_locked; + let menu = self.menu.clone(); let st = self.doc_mut()?; let before = st.selection.objects.clone(); let new_art = st.selection.new_art.clone(); f(&st.doc, &mut st.selection); st.selection.prune(&st.doc); - // Lock Guides locks the paths made into guides too (#1534), whatever selects them. - if guides_locked { - deselect_guides(&st.doc, &mut st.selection); - } + // Lock Guides locks the paths made into guides too (#1534), and Lock Slices and Hide + // Slices keep the slices out, whatever selects them. + deselect_hidden(&menu, &st.doc, &mut st.selection); // The art on a locked artboard can't be selected, whatever selects it, except new art // that was selected and still is. st.protect_locked_artboards(&before, &new_art, None); @@ -2104,6 +2116,7 @@ impl Session { /// Re-apply `cmd` on top of the interaction snapshot (replacing the previous preview). pub fn preview(&mut self, cmd: &str, params: &Value) -> Result { { + let menu = self.menu.clone(); let st = self.doc_mut()?; let Some(it) = &st.interaction else { return Err(EngineError::Other("no interaction in progress".into())) }; // Undoing the previous preview counts as a change, so views redraw even when the command @@ -2116,6 +2129,9 @@ impl Session { if selection_changed { st.selection.clone_from(&it.selection); } + // Guides locked and slices locked or hidden during the interaction stay deselected, + // even when the preview fails. + deselect_hidden(&menu, &st.doc, &mut st.selection); cmd::globaledit::follow_history(st); } // The previous preview's paint is undone with it. @@ -2191,13 +2207,11 @@ impl Session { pub fn cancel_interaction(&mut self) -> Result<()> { self.clear_deferred_overlay_cache(); self.pending_paint = None; - let guides_locked = self.menu.guides_locked; + let menu = self.menu.clone(); let st = self.doc_mut()?; st.undo_interaction(); - // Guides locked during the interaction stay deselected. - if guides_locked { - deselect_guides(&st.doc, &mut st.selection); - } + // Guides locked and slices locked or hidden during the interaction stay deselected. + deselect_hidden(&menu, &st.doc, &mut st.selection); Ok(()) } @@ -2269,9 +2283,7 @@ impl Session { { st.doc = e.doc; st.selection = Arc::unwrap_or_clone(e.selection); - if self.menu.guides_locked { - deselect_guides(&st.doc, &mut st.selection); - } + deselect_hidden(&self.menu, &st.doc, &mut st.selection); st.sync_new_art(); cmd::globaledit::follow_history(st); st.revision += 1; @@ -2333,7 +2345,7 @@ impl Session { self.end_document_undo_group(scope.uid, cancel); return; }; - let guides_locked = self.menu.guides_locked; + let menu = self.menu.clone(); let Some(st) = self.document_mut(scope.uid) else { return }; let Some(g) = &mut st.undo_group else { return }; let newest = g.first.as_ref().is_some_and(|f| st.history.undo.last().is_some_and(|e| Arc::ptr_eq(&e.doc, f))); @@ -2352,9 +2364,7 @@ impl Session { st.doc = doc; st.selection = selection; cmd::globaledit::follow_history(st); - if guides_locked { - deselect_guides(&st.doc, &mut st.selection); - } + deselect_hidden(&menu, &st.doc, &mut st.selection); st.history.recost_last(&st.doc); st.revision += 1; self.journal.truncate(scope.journal); diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 8e57771b0..8b16e00da 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -2417,6 +2417,23 @@ fn a_canceled_interaction_leaves_locked_guides_deselected() { assert!(selected(&s).is_empty()); } +/// A drag preview that fails puts back the selection from before the drag, without the guide +/// paths that Lock Guides locked during it, so a move after the drag moves nothing. +#[test] +fn a_failed_preview_leaves_locked_guides_deselected() { + let mut s = session(); + let g = guide_path(&mut s, 0.0); + sel(&mut s, &[g]); + s.begin_interaction("Move").unwrap(); + s.preview("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); + s.execute("view.guides.lock", &json!({"locked": true})).unwrap(); + assert!(s.preview("object.move", &json!({"dx": "far", "dy": 0})).is_err()); + assert!(selected(&s).is_empty()); + s.commit_interaction().unwrap(); + let _ = s.execute("object.move", &json!({"dx": 10, "dy": 0})); + assert_eq!(bounds(&s, g).x0, 0.0); +} + fn guides(s: &Session) -> Vec<(bool, f64)> { s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect() } diff --git a/crates/engine/src/tests_slices.rs b/crates/engine/src/tests_slices.rs index 897d0569f..227eb011a 100644 --- a/crates/engine/src/tests_slices.rs +++ b/crates/engine/src/tests_slices.rs @@ -215,6 +215,7 @@ fn clip_to_artboard_hide_lock_select_and_delete_all() { assert_eq!((l["hidden"].clone(), l["locked"].clone(), l["clipToArtboard"].clone()), (json!(true), json!(true), json!(true))); run(&mut s, "view.slices.hide", json!({"hidden": false})); assert!(!s.slices_hidden()); + run(&mut s, "view.slices.lock", json!({"locked": false})); let a = rect(&mut s, 10.0, 10.0, 10.0, 10.0); run(&mut s, "object.slice.make", json!({})); @@ -232,6 +233,253 @@ fn clip_to_artboard_hide_lock_select_and_delete_all() { assert!(s.execute("object.slice.deleteAll", &json!({})).is_err(), "nothing to delete"); } +/// The slices selected with the Slice Selection tool or a slice command. +fn picked(s: &Session) -> Vec { + s.doc().unwrap().selection.slices.clone() +} + +/// View → Lock Slices deselects the slices in every open document, and while it is on nothing +/// selects one, so the commands that act on the selected slices leave them alone. Turning it off +/// leaves them deselected. +#[test] +fn lock_slices_deselects_the_slices_and_keeps_them_unselected() { + let mut s = session(); + let id = user_slice(&mut s, [0.0, 0.0, 50.0, 50.0]); + run(&mut s, "select.object.slices", json!({})); + run(&mut s, "view.slices.lock", json!({"locked": true})); + assert!(picked(&s).is_empty(), "after Lock Slices"); + let _ = s.execute("object.slice.move", &json!({"dx": 10, "dy": 0})); + let _ = s.execute("object.slice.options", &json!({"name": "hero"})); + assert_eq!(rect_of(&mut s, id), [0.0, 0.0, 50.0, 50.0]); + assert_eq!(s.doc().unwrap().doc.slices[0].options.name, ""); + assert_eq!(run(&mut s, "select.object.slices", json!({}))["count"], 0); + assert_eq!(run(&mut s, "object.slice.select", json!({"slices": [id]}))["selected"], json!([])); + run(&mut s, "object.slice.create", json!({"x": 100, "y": 100, "width": 20, "height": 20})); + assert!(picked(&s).is_empty(), "after Create Slice"); + // Locking from another document deselects this one's slices too. + run(&mut s, "view.slices.lock", json!({"locked": false})); + assert!(picked(&s).is_empty(), "after turning Lock Slices off"); + assert_eq!(run(&mut s, "select.object.slices", json!({}))["count"], 2); + run(&mut s, "file.new", json!({"width": 300, "height": 300})); + run(&mut s, "view.slices.lock", json!({"locked": true})); + run(&mut s, "document.activate", json!({"index": 0})); + assert!(picked(&s).is_empty(), "in the other document"); +} + +/// View → Hide Slices deselects the slices in every open document, and while they are hidden +/// nothing selects one, so the commands that act on the selected slices leave them alone. Turning +/// it off leaves them deselected. +#[test] +fn hide_slices_deselects_the_slices_and_keeps_them_unselected() { + let mut s = session(); + let id = user_slice(&mut s, [0.0, 0.0, 50.0, 50.0]); + run(&mut s, "select.object.slices", json!({})); + run(&mut s, "view.slices.hide", json!({"hidden": true})); + assert!(picked(&s).is_empty(), "after Hide Slices"); + let _ = s.execute("object.slice.move", &json!({"dx": 10, "dy": 0})); + let _ = s.execute("object.slice.options", &json!({"name": "hero"})); + assert_eq!(rect_of(&mut s, id), [0.0, 0.0, 50.0, 50.0]); + assert_eq!(s.doc().unwrap().doc.slices[0].options.name, ""); + assert_eq!(run(&mut s, "select.object.slices", json!({}))["count"], 0); + assert_eq!(run(&mut s, "object.slice.select", json!({"slices": [id]}))["selected"], json!([])); + run(&mut s, "object.slice.create", json!({"x": 100, "y": 100, "width": 20, "height": 20})); + assert!(picked(&s).is_empty(), "after Create Slice"); + // Hiding from another document deselects this one's slices too. + run(&mut s, "view.slices.hide", json!({"hidden": false})); + assert!(picked(&s).is_empty(), "after turning Hide Slices off"); + assert_eq!(run(&mut s, "select.object.slices", json!({}))["count"], 2); + run(&mut s, "file.new", json!({"width": 300, "height": 300})); + run(&mut s, "view.slices.hide", json!({"hidden": true})); + run(&mut s, "document.activate", json!({"index": 0})); + assert!(picked(&s).is_empty(), "in the other document"); +} + +/// Undo and Redo bring back a selection without the slices that Lock Slices has locked since, so +/// a move after them leaves the slice where it is. +#[test] +fn undo_and_redo_leave_locked_slices_deselected() { + let mut s = session(); + let id = run(&mut s, "object.slice.create", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + run(&mut s, "object.slice.move", json!({"dx": 10, "dy": 0})); + run(&mut s, "view.slices.lock", json!({"locked": true})); + run(&mut s, "edit.undo", json!({})); + assert!(picked(&s).is_empty(), "after Undo"); + let _ = s.execute("object.slice.move", &json!({"dx": 10, "dy": 0})); + assert_eq!(rect_of(&mut s, id)[0], 0.0); + // Undone with the slice selected, redone once it is locked. + run(&mut s, "view.slices.lock", json!({"locked": false})); + run(&mut s, "object.slice.select", json!({"slices": [id]})); + run(&mut s, "object.slice.move", json!({"dx": 10, "dy": 0})); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "view.slices.lock", json!({"locked": true})); + run(&mut s, "edit.redo", json!({})); + assert!(picked(&s).is_empty(), "after Redo"); + let _ = s.execute("object.slice.move", &json!({"dx": 10, "dy": 0})); + assert_eq!(rect_of(&mut s, id)[0], 10.0); +} + +/// Undo and Redo bring back a selection without the slices that Hide Slices has hidden since, so +/// a move after them leaves the slice where it is. +#[test] +fn undo_and_redo_leave_hidden_slices_deselected() { + let mut s = session(); + let id = run(&mut s, "object.slice.create", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + run(&mut s, "object.slice.move", json!({"dx": 10, "dy": 0})); + run(&mut s, "view.slices.hide", json!({"hidden": true})); + run(&mut s, "edit.undo", json!({})); + assert!(picked(&s).is_empty(), "after Undo"); + let _ = s.execute("object.slice.move", &json!({"dx": 10, "dy": 0})); + assert_eq!(rect_of(&mut s, id)[0], 0.0); + // Undone with the slice selected, redone once it is hidden. + run(&mut s, "view.slices.hide", json!({"hidden": false})); + run(&mut s, "object.slice.select", json!({"slices": [id]})); + run(&mut s, "object.slice.move", json!({"dx": 10, "dy": 0})); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "view.slices.hide", json!({"hidden": true})); + run(&mut s, "edit.redo", json!({})); + assert!(picked(&s).is_empty(), "after Redo"); + let _ = s.execute("object.slice.move", &json!({"dx": 10, "dy": 0})); + assert_eq!(rect_of(&mut s, id)[0], 10.0); +} + +/// A failed batch, a canceled undo group, a failed drag preview and a canceled drag bring back the +/// selection from before them, without the slices that Lock Slices locked inside them. +#[test] +fn a_failed_batch_a_canceled_undo_group_and_a_canceled_drag_leave_locked_slices_deselected() { + let mut s = session(); + let id = run(&mut s, "object.slice.create", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + let steps = json!([{"command": "view.slices.lock", "params": {"locked": true}}, {"command": "object.isolate", "params": {}}]); + assert!(s.execute("command.batch", &json!({"commands": steps})).is_err()); + assert!(s.slices_locked()); + assert!(picked(&s).is_empty(), "after the failed batch"); + run(&mut s, "view.slices.lock", json!({"locked": false})); + run(&mut s, "object.slice.select", json!({"slices": [id]})); + run(&mut s, "edit.undoGroup.begin", json!({})); + run(&mut s, "object.slice.move", json!({"dx": 10, "dy": 0})); + run(&mut s, "view.slices.lock", json!({"locked": true})); + run(&mut s, "edit.undoGroup.end", json!({"cancel": true})); + assert_eq!(rect_of(&mut s, id)[0], 0.0); + assert!(picked(&s).is_empty(), "after the canceled undo group"); + run(&mut s, "view.slices.lock", json!({"locked": false})); + run(&mut s, "object.slice.select", json!({"slices": [id]})); + s.begin_interaction("Move Slice").unwrap(); + s.preview("object.slice.move", &json!({"dx": 10, "dy": 0})).unwrap(); + run(&mut s, "view.slices.lock", json!({"locked": true})); + // A preview that fails puts back the snapshot, without the locked slice. + assert!(s.preview("object.slice.move", &json!({"dx": "far", "dy": 0})).is_err()); + assert!(picked(&s).is_empty(), "after a failed preview"); + s.cancel_interaction().unwrap(); + assert_eq!(rect_of(&mut s, id)[0], 0.0); + assert!(picked(&s).is_empty(), "after the canceled drag"); +} + +/// A failed batch, a canceled undo group, a failed drag preview and a canceled drag bring back the +/// selection from before them, without the slices that Hide Slices hid inside them. +#[test] +fn a_failed_batch_a_canceled_undo_group_and_a_canceled_drag_leave_hidden_slices_deselected() { + let mut s = session(); + let id = run(&mut s, "object.slice.create", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + let steps = json!([{"command": "view.slices.hide", "params": {"hidden": true}}, {"command": "object.isolate", "params": {}}]); + assert!(s.execute("command.batch", &json!({"commands": steps})).is_err()); + assert!(s.slices_hidden()); + assert!(picked(&s).is_empty(), "after the failed batch"); + run(&mut s, "view.slices.hide", json!({"hidden": false})); + run(&mut s, "object.slice.select", json!({"slices": [id]})); + run(&mut s, "edit.undoGroup.begin", json!({})); + run(&mut s, "object.slice.move", json!({"dx": 10, "dy": 0})); + run(&mut s, "view.slices.hide", json!({"hidden": true})); + run(&mut s, "edit.undoGroup.end", json!({"cancel": true})); + assert_eq!(rect_of(&mut s, id)[0], 0.0); + assert!(picked(&s).is_empty(), "after the canceled undo group"); + run(&mut s, "view.slices.hide", json!({"hidden": false})); + run(&mut s, "object.slice.select", json!({"slices": [id]})); + s.begin_interaction("Move Slice").unwrap(); + s.preview("object.slice.move", &json!({"dx": 10, "dy": 0})).unwrap(); + run(&mut s, "view.slices.hide", json!({"hidden": true})); + // A preview that fails puts back the snapshot, without the hidden slice. + assert!(s.preview("object.slice.move", &json!({"dx": "far", "dy": 0})).is_err()); + assert!(picked(&s).is_empty(), "after a failed preview"); + s.cancel_interaction().unwrap(); + assert_eq!(rect_of(&mut s, id)[0], 0.0); + assert!(picked(&s).is_empty(), "after the canceled drag"); +} + +/// A canceled undo scope that joined an open undo group brings back the selection from when the +/// scope began, without the slices that Lock Slices or Hide Slices took out inside it. +#[test] +fn a_canceled_undo_scope_leaves_locked_or_hidden_slices_deselected() { + for (command, key) in [("view.slices.lock", "locked"), ("view.slices.hide", "hidden")] { + let mut s = session(); + let id = run(&mut s, "object.slice.create", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + run(&mut s, "object.slice.select", json!({"slices": [id]})); + run(&mut s, "edit.undoGroup.begin", json!({})); + run(&mut s, "object.slice.move", json!({"dx": 10, "dy": 0})); + let scope = s.begin_undo_scope().unwrap(); + run(&mut s, command, json!({ key: true })); + s.end_undo_scope(scope, true); + assert!(picked(&s).is_empty(), "{command}: after the canceled scope"); + let _ = s.execute("object.slice.move", &json!({"dx": 10, "dy": 0})); + assert_eq!(rect_of(&mut s, id)[0], 10.0, "{command}"); + } +} + +/// Select slice `id` and the first ruler guide together, through [`Session::select`]. +fn pick_slice_and_guide(s: &mut Session, id: u64) { + s.select(|_, sel| { + sel.slices = vec![NodeId(id)]; + sel.guides = vec![0]; + }) + .unwrap(); +} + +/// With Lock Guides, Lock Slices and Hide Slices all on, Undo and a selection deselect both the +/// guide and the slice, which is locked and hidden at once, and the slices stay deselected until +/// both Lock Slices and Hide Slices are off. +#[test] +fn locked_guides_and_a_locked_hidden_slice_are_deselected_together() { + let mut s = session(); + let id = user_slice(&mut s, [0.0, 0.0, 50.0, 50.0]); + run(&mut s, "guide.add", json!({"vertical": true, "pos": 100})); + pick_slice_and_guide(&mut s, id); + // A step whose selection before it holds the guide and the slice, for Undo to bring back. + rect(&mut s, 200.0, 200.0, 10.0, 10.0); + run(&mut s, "view.guides.lock", json!({"locked": true})); + run(&mut s, "view.slices.lock", json!({"locked": true})); + run(&mut s, "view.slices.hide", json!({"hidden": true})); + let none = |s: &Session| { + let sel = &s.doc().unwrap().selection; + sel.slices.is_empty() && sel.guides.is_empty() + }; + run(&mut s, "edit.undo", json!({})); + assert!(none(&s), "after Undo"); + pick_slice_and_guide(&mut s, id); + assert!(none(&s), "after a selection"); + // Shown while still locked, or unlocked while still hidden, the slices stay out. + run(&mut s, "view.slices.hide", json!({"hidden": false})); + assert_eq!(run(&mut s, "select.object.slices", json!({}))["count"], 0); + run(&mut s, "view.slices.hide", json!({"hidden": true})); + run(&mut s, "view.slices.lock", json!({"locked": false})); + assert_eq!(run(&mut s, "select.object.slices", json!({}))["count"], 0); + run(&mut s, "view.slices.hide", json!({"hidden": false})); + assert_eq!(run(&mut s, "select.object.slices", json!({}))["count"], 1); +} + +/// `slice.list` reports an object slice as selected while its object is selected: the Object → +/// Slice commands act on it then, and the canvas draws it as selected. +#[test] +fn slice_list_reports_an_object_slice_selected_with_its_object() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + run(&mut s, "object.slice.make", json!({})); + rect(&mut s, 100.0, 100.0, 20.0, 20.0); + run(&mut s, "object.slice.make", json!({})); + let selected = |s: &mut Session| slices(s).into_iter().filter(|v| v["selected"] == true).map(|v| v["id"].clone()).collect::>(); + run(&mut s, "select.set", json!({"ids": [a]})); + assert_eq!(selected(&mut s), [json!(a)]); + run(&mut s, "select.none", json!({})); + assert!(selected(&mut s).is_empty()); +} + #[test] fn slices_survive_a_native_round_trip() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index 643ca72e2..c4e794e82 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3545,14 +3545,18 @@ numbered left to right and top to bottom: `{number, id (null for auto slices), s width, height, options, selected}`, plus `clipToArtboard`, `hidden` and `locked`. The Object → Slice commands act on the selected slices: the ones `select.object.slices` (Select → Object → Slices) -selects, and the selected objects that are object slices. `object.slice.release` (an object slice leaves its object, -a user slice becomes an unpainted rectangle), `object.slice.fromGuides` (the grid the ruler guides cut the artboards -into), `object.slice.fromSelection`, `object.slice.duplicate {dx?, dy?}`, `object.slice.combine`, -`object.slice.divide {rows? | rowHeight?, columns? | columnWidth?}`, `object.slice.deleteAll`, -`object.slice.options {kind?: image|noImage|htmlText, name?, url?, target?, message?, alt?, text?, background?: -""|matte|#rrggbb, hAlign?, vAlign?}` and `object.slice.clipToArtboard {on?}` are each one undo step. -`view.slices.hide {hidden?}` and `view.slices.lock {locked?}` are session view state; the canvas draws the slices in -the `sliceLineColor` preference, numbered while `showSliceNumbers` is on. +selects, and the selected objects that are object slices. `slice.list` reports both kinds as `selected`. +`object.slice.release` (an object slice leaves its object, a user slice becomes an unpainted rectangle), +`object.slice.fromGuides` (the grid the ruler guides cut the artboards into), `object.slice.fromSelection`, +`object.slice.duplicate {dx?, dy?}`, `object.slice.combine`, `object.slice.divide {rows? | rowHeight?, columns? | +columnWidth?}`, `object.slice.deleteAll`, `object.slice.options {kind?: image|noImage|htmlText, name?, url?, target?, +message?, alt?, text?, background?: ""|matte|#rrggbb, hAlign?, vAlign?}` and `object.slice.clipToArtboard {on?}` are +each one undo step. `view.slices.hide {hidden?}` and `view.slices.lock {locked?}` are session view state; the canvas +draws the slices in the `sliceLineColor` preference, numbered while `showSliceNumbers` is on. Locking or hiding +deselects the slices in every open document; unlocking or showing them again leaves them deselected and does not +restore the selection that was cleared. While the slices are locked or hidden, a command that selects slices, such as +`select.object.slices` (which replies `count: 0`), `object.slice.select`, `object.slice.create`, `edit.undo` or +`edit.redo`, leaves them out; a selected object that is an object slice stays selected. ```json {"name":"run_command","arguments":{"command":"object.slice.make","params":{}}}