Guides: Lock Guides keeps guides deselected after Undo, Exit Isolation Mode and Paste

With View > Guides > Lock Guides on, Session::select drops the paths
made into guides from the selection (#1534), but commands that set the
selection another way skipped that filter and left a locked guide
selected, so the next move moved it. object.exitIsolation selected an
isolated guide path. Undo, Redo, a canceled undo group, a canceled
drag or dialog preview and a failed command.batch brought back locked
guide paths and ruler guides in the selection. Unlock All, Show All
and Paste selected the guide paths they unlocked, showed or pasted.
Locking the guides deselected them in the active document only.

The filter is now deselect_guides, which also clears the selected
ruler guides. Session::select and Session::edit run it while Lock
Guides is on, and so do object.exitIsolation, Undo, Redo,
Session::cancel_interaction, a canceled undo group and a failed batch.
Locking the guides deselects them in every open document. With Lock
Guides off, the selection is set as before.

Docs: docs/mcp.md.
This commit is contained in:
dotCooCoo
2026-10-17 17:13:03 +07:00
committed by bflatastic
parent e09038041f
commit 30c5bd3589
7 changed files with 193 additions and 10 deletions
+11 -3
View File
@@ -153,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Lock Guides",
["View", "Guides"],
Some("Cmd+Alt+;"),
"{locked?: bool} toggle (or set) the session's guide lock → {locked}",
"{locked?: bool} toggle (or set) the session's guide lock; locking deselects the ruler guides and guide paths in every open document → {locked}",
has_doc,
lock_guides
),
@@ -601,12 +601,20 @@ fn release_guides(s: &mut Session, p: &Value) -> Result<Value> {
fn lock_guides(s: &mut Session, p: &Value) -> Result<Value> {
let v = p.get("locked").and_then(Value::as_bool).unwrap_or(!s.menu.guides_locked);
s.menu.guides_locked = v;
// Locked guides can't stay selected, ruler guides or guide paths ([`Session::select`] lets
// those go).
// Locked guides can't stay selected, ruler guides or guide paths, in any open document
// ([`Session::select`] lets those go in the active one).
let st = s.doc()?;
if v && (!st.selection.guides.is_empty() || st.selection.objects.iter().any(|id| crate::is_guide_path(&st.doc, *id))) {
s.select(|_, sel| sel.guides.clear())?;
}
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) {
st.revision += 1;
}
}
}
Ok(json!({ "locked": v }))
}
+9
View File
@@ -371,6 +371,7 @@ pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> {
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
settle_interaction(s)?;
let guides_locked = s.menu.guides_locked;
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();
@@ -383,12 +384,17 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
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);
}
super::maskedit::follow_history(st, editing);
Ok(json!({ "undone": label }))
}
fn redo(s: &mut Session, _: &Value) -> Result<Value> {
settle_interaction(s)?;
let guides_locked = s.menu.guides_locked;
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();
@@ -401,6 +407,9 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
st.history.trim();
st.revision += 1;
st.selection.prune(&st.doc);
if guides_locked {
crate::deselect_guides(&st.doc, &mut st.selection);
}
super::maskedit::follow_history(st, editing);
Ok(json!({ "redone": label }))
}
+4 -1
View File
@@ -99,7 +99,7 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, 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.
/// written. Lock Guides stays as the steps left it, and while it is on the guides are deselected.
fn roll_back(s: &mut Session, start: Vec<DocState>, stash: &[DocState]) {
let open = std::mem::take(&mut s.docs);
s.docs = start;
@@ -107,5 +107,8 @@ fn roll_back(s: &mut Session, start: Vec<DocState>, 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);
}
}
}
+5
View File
@@ -1061,11 +1061,16 @@ fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
}
fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
let guides_locked = s.menu.guides_locked;
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);
}
}
st.revision += 1;
ok()
+28 -5
View File
@@ -682,6 +682,18 @@ pub(crate) fn is_guide_path(d: &Document, id: NodeId) -> bool {
d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: true, .. }))
}
/// Deselect the ruler guides and the paths made into guides: while View → Guides → Lock Guides is
/// on, they can't be selected (#1534), whatever selects them. → whether any was selected.
pub(crate) fn deselect_guides(d: &Document, sel: &mut Selection) -> bool {
let paths: Vec<NodeId> = sel.objects.iter().copied().filter(|id| is_guide_path(d, *id)).collect();
let any = !paths.is_empty() || !sel.guides.is_empty();
for id in paths {
sel.remove(id);
}
sel.guides.clear();
any
}
/// 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");
@@ -1598,6 +1610,7 @@ impl Session {
if !self.previewing && cmd::typing_in_progress(self) {
cmd::settle_interaction(self)?;
}
let guides_locked = self.menu.guides_locked;
let st = self.doc_mut()?;
let before = st.doc.clone();
let before_sel = st.selection.clone();
@@ -1645,6 +1658,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);
}
st.revision += 1;
if st.interaction.is_none() {
let selection = match st.history.undo.last() {
@@ -1673,10 +1690,7 @@ impl Session {
st.selection.prune(&st.doc);
// Lock Guides locks the paths made into guides too (#1534), whatever selects them.
if guides_locked {
let locked: Vec<NodeId> = st.selection.objects.iter().copied().filter(|id| is_guide_path(&st.doc, *id)).collect();
for id in locked {
st.selection.remove(id);
}
deselect_guides(&st.doc, &mut st.selection);
}
// Selecting art makes its layer (or sublayer) the current one, as in the Layers panel of
// the reference app: new art then goes beside it.
@@ -1807,7 +1821,13 @@ impl Session {
pub fn cancel_interaction(&mut self) -> Result<()> {
self.pending_paint = None;
self.doc_mut()?.undo_interaction();
let guides_locked = self.menu.guides_locked;
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);
}
Ok(())
}
@@ -1849,6 +1869,9 @@ 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);
}
st.revision += 1;
journal = Some(g.journal);
}
+133
View File
@@ -1860,6 +1860,139 @@ fn locked_guides_lock_guide_paths() {
assert_eq!(selected(&s), vec![b]);
}
/// A rectangle at (`x`, 0), 10 pt wide, made into a guide; nothing is selected after.
fn guide_path(s: &mut Session, x: f64) -> NodeId {
let g = rect(s, x, 0.0, 10.0, 10.0);
sel(s, &[g]);
s.execute("view.guides.make", &json!({})).unwrap();
g
}
/// Exit Isolation Mode selects the isolated art, except a guide path while Lock Guides is on, so
/// a move after it leaves the guide where it is.
#[test]
fn exit_isolation_leaves_a_locked_guide_path_deselected() {
let mut s = session();
let g = guide_path(&mut s, 0.0);
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
s.execute("object.exitIsolation", &json!({})).unwrap();
assert!(selected(&s).is_empty());
let _ = s.execute("object.move", &json!({"dx": 10, "dy": 0}));
assert_eq!(bounds(&s, g).x0, 0.0);
// Lock Guides off: the guide is selected.
s.execute("view.guides.lock", &json!({"locked": false})).unwrap();
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
s.execute("object.exitIsolation", &json!({})).unwrap();
assert_eq!(selected(&s), vec![g]);
}
/// Undo and Redo bring back a selection without the guides, ruler guides or guide paths, that
/// Lock Guides has locked since, so a move after them leaves the guides where they are.
#[test]
fn undo_and_redo_leave_locked_guides_deselected() {
let mut s = session();
let g = guide_path(&mut s, 0.0);
s.execute("guide.add", &json!({"vertical": true, "pos": 100})).unwrap();
let select_both = |s: &mut Session| {
sel(s, &[g]);
s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap();
};
select_both(&mut s);
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
assert_eq!((bounds(&s, g).x0, guides(&s)), (10.0, vec![(true, 110.0)]));
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((selected(&s), selected_guides(&s)), (vec![], vec![]), "after Undo");
let _ = s.execute("object.move", &json!({"dx": 10, "dy": 0}));
assert_eq!((bounds(&s, g).x0, guides(&s)), (0.0, vec![(true, 100.0)]));
// Undone with both selected, redone once they are locked.
s.execute("view.guides.lock", &json!({"locked": false})).unwrap();
select_both(&mut s);
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!((selected(&s), selected_guides(&s)), (vec![], vec![]), "after Redo");
let _ = s.execute("object.move", &json!({"dx": 10, "dy": 0}));
assert_eq!((bounds(&s, g).x0, guides(&s)), (10.0, vec![(true, 110.0)]));
}
/// Unlock All, Show All and Paste select the art they unlock, show or paste, except guide paths
/// while Lock Guides is on.
#[test]
fn unlock_all_show_all_and_paste_leave_locked_guide_paths_deselected() {
let mut s = session();
let (g, h) = (guide_path(&mut s, 0.0), guide_path(&mut s, 20.0));
sel(&mut s, &[g]);
s.execute("edit.copy", &json!({})).unwrap();
s.execute("object.lock", &json!({})).unwrap();
sel(&mut s, &[h]);
s.execute("object.hide", &json!({})).unwrap();
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
assert_eq!(s.execute("object.unlockAll", &json!({})).unwrap()["count"], 1);
assert!(selected(&s).is_empty(), "after Unlock All");
assert_eq!(s.execute("object.showAll", &json!({})).unwrap()["count"], 1);
assert!(selected(&s).is_empty(), "after Show All");
s.execute("edit.paste", &json!({})).unwrap();
assert!(selected(&s).is_empty(), "after Paste");
// Lock Guides off: the pasted guide is selected.
s.execute("view.guides.lock", &json!({"locked": false})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let [copy] = selected(&s)[..] else { panic!("the pasted guide is selected") };
assert!(crate::is_guide_path(&s.doc().unwrap().doc, copy));
}
/// Lock Guides deselects the guides of every open document, not only the active one's.
#[test]
fn lock_guides_deselects_the_guides_of_every_open_document() {
let mut s = session();
let g = guide_path(&mut s, 0.0);
s.execute("guide.add", &json!({"vertical": true, "pos": 100})).unwrap();
sel(&mut s, &[g]);
s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
s.execute("document.activate", &json!({"index": 0})).unwrap();
assert_eq!((selected(&s), selected_guides(&s)), (vec![], vec![]));
}
/// A failed batch and a canceled undo group bring back the selection from before them, without
/// the guide paths that Lock Guides locked inside them.
#[test]
fn a_failed_batch_and_a_canceled_undo_group_leave_locked_guides_deselected() {
let mut s = session();
let g = guide_path(&mut s, 0.0);
sel(&mut s, &[g]);
let steps = json!([{"command": "view.guides.lock", "params": {"locked": true}}, {"command": "object.isolate", "params": {}}]);
assert!(s.execute("command.batch", &json!({"commands": steps})).is_err());
assert!(s.guides_locked());
assert!(selected(&s).is_empty(), "after the failed batch");
s.execute("view.guides.lock", &json!({"locked": false})).unwrap();
sel(&mut s, &[g]);
s.execute("edit.undoGroup.begin", &json!({})).unwrap();
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
s.execute("edit.undoGroup.end", &json!({"cancel": true})).unwrap();
assert_eq!(bounds(&s, g).x0, 0.0);
assert!(selected(&s).is_empty(), "after the canceled undo group");
}
/// Canceling a drag or a dialog's preview brings back the selection from before it, without the
/// guide paths that Lock Guides locked during it.
#[test]
fn a_canceled_interaction_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();
s.cancel_interaction().unwrap();
assert_eq!(bounds(&s, g).x0, 0.0);
assert!(selected(&s).is_empty());
}
fn guides(s: &Session) -> Vec<(bool, f64)> {
s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect()
}
+3 -1
View File
@@ -1078,7 +1078,9 @@ removes them) → `{selected}`. `guide.move {index, pos}` puts one guide somewhe
moves the selected ones (vertical guides by `dx`, horizontal ones by `dy`; `copy` leaves them and selects the
moved copies), and `guide.remove {index?}` deletes one guide or the selected ones → `{count}`; each is one undo
step. With guides selected, `edit.clear` (Delete) deletes them and `object.nudge` (the arrow keys) nudges them.
View › Guides › Lock Guides (`view.guides.lock`) deselects them and refuses these commands until unlocked.
View › Guides › Lock Guides (`view.guides.lock`) deselects them and the paths made into guides, in every open
document, and refuses these commands until unlocked. While it is on, a command that selects art, such as `edit.undo`,
`edit.redo`, `object.exitIsolation`, `object.unlockAll`, `object.showAll` or `edit.paste`, leaves the guides out.
`view.guides.release {index?, ids?}` (View › Guides › Release Guides) turns guides into paths → `{count}`. With
`index` it releases that ruler guide, and with `ids` it releases those guide paths (and the guide paths inside them).