From f0d3f81f3be11ae43e61b65e19019c7fd82900d0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 15 Oct 2026 22:14:22 -0700 Subject: [PATCH] Canvas: a right-click on locked objects offers Unlock with each object's name A right-click on the canvas now records the locked objects under the pointer with vectorcraft_doc::hit::locked_objects_at, topmost first and at most 20. A locked group is listed instead of the objects it holds. Hidden objects and objects on hidden, locked or template layers are not listed, and in isolation mode only the isolated group's contents are listed. While the menu is open, it has an Unlock submenu after Undo and Redo that lists each of them by the name the Layers panel shows. Choosing a name runs layer.setProps {ids: [id], locked: false}, as the Layers panel's lock column does, in one undo step. ui.contextMenu.list shows the same items. Docs: docs/control-protocol.md, docs/target-app-parity.md. --- crates/doc/src/hit.rs | 137 ++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 40 +++++-- crates/ui-egui/src/menus.rs | 34 +++++- crates/ui-egui/src/panels/layers.rs | 8 ++ crates/ui-egui/src/state.rs | 6 ++ crates/ui-egui/src/tests_contextmenu.rs | 37 +++++++ docs/control-protocol.md | 2 +- docs/target-app-parity.md | 1 + 8 files changed, 252 insertions(+), 13 deletions(-) diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index a562fa3ff..bf84b450a 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -169,6 +169,80 @@ fn objects_at_repeated(doc: &Document, p: Point, opt: HitOptions, scope: Option< tops } +/// The most objects [`locked_objects_at`] lists. +pub const MAX_LOCKED_AT: usize = 20; + +/// The locked objects under `p`, topmost first, at most [`MAX_LOCKED_AT`]: each object whose own +/// lock is on and that a click at `p` would hit if it were not locked. A locked group is listed, +/// not what it holds. Hidden objects and everything on hidden, locked and template layers are left +/// out. While a pattern is edited only its tile layer is searched, and in isolation mode +/// (`opt.scope`) only what is inside the isolated group or object. +pub fn locked_objects_at(doc: &Document, p: Point, opt: HitOptions) -> Vec { + let symbols = doc.symbol_boxes(); + let mut out = Vec::new(); + if let Some(scope) = opt.scope + && let Some(isolated) = doc.node(scope) + { + if isolated.is_container() { + locked_children(isolated, p, opt, &symbols, &mut out); + } else if isolated.visible && isolated.locked && locked_hit(isolated, p, opt, &symbols) { + out.push(isolated.id); + } + return out; + } + for layer in doc.layers.iter().rev() { + if !layer.visible || layer.locked || layer.is_template() || doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) { + continue; + } + let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt }; + locked_children(layer, p, opt, &symbols, &mut out); + } + out +} + +/// [`locked_objects_at`] through the children of `parent`, front first: a locked child is tested +/// whole, and an unlocked layer, group, blend or envelope in Edit Contents is searched inside. +fn locked_children(parent: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, out: &mut Vec) { + let Some(children) = parent.children() else { return }; + if parent.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) { + return; + } + for i in hit_order(parent, children, p) { + if out.len() >= MAX_LOCKED_AT { + return; + } + let Some(c) = children.get(i) else { continue }; + if !may_hit(parent, i, c, p, opt, symbols, false) || c.is_layer() && (c.locked || c.is_template()) { + continue; + } + if c.locked { + if locked_hit(c, p, opt, symbols) { + out.push(c.id); + } + continue; + } + match &c.kind { + NodeKind::Layer { preview: false, .. } => locked_children(c, p, HitOptions { outline: true, ..opt }, symbols, out), + NodeKind::Layer { .. } + | NodeKind::Group { .. } + | NodeKind::Intertwine { .. } + | NodeKind::Blend { .. } + | NodeKind::Envelope { editing: true, .. } => locked_children(c, p, opt, symbols, out), + _ => {} + } + } +} + +/// Would a click at `p` hit the locked object `n` if it were not locked? +fn locked_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> bool { + let inside = || hit_children(n, p, opt, symbols, &|_| false, &mut Vec::new(), false).is_some(); + match &n.kind { + NodeKind::Group { .. } | NodeKind::Intertwine { .. } | NodeKind::Envelope { editing: true, .. } => inside(), + NodeKind::Blend { .. } => inside() || hit_leaf(n, p, opt, symbols).is_some(), + _ => hit_leaf(n, p, opt, symbols).is_some(), + } +} + /// [`hit_test`] passing over the nodes `skip` names (and their contents). Symbol instances are /// measured by their art ([`Node::geometric_bounds_with`]). fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, editable_only: bool) -> Option { @@ -901,6 +975,69 @@ world", assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none()); } + /// Insert `n` locked at the top of `parent`. + fn insert_locked(d: &mut Document, parent: NodeId, mut n: Node) -> NodeId { + let id = n.id; + n.locked = true; + d.insert(Some(parent), usize::MAX, n).unwrap(); + id + } + + /// `locked_objects_at`: the locked objects under the point, topmost first. A locked group is + /// listed rather than its contents, and a locked object in an unlocked group is listed itself. + /// Hidden objects, locked layers and the art outside an isolated group are left out. + #[test] + fn locked_objects_at_lists_the_locked_stack() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let rect = + |d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art()); + let (a, b) = (rect(&mut d, 0.0), rect(&mut d, 10.0)); + let (a, b) = (insert_locked(&mut d, l, a), insert_locked(&mut d, l, b)); + let (mut inner, locked_group) = (rect(&mut d, 20.0), d.alloc_id()); + inner.locked = true; + let inner_id = inner.id; + let mut g = Node::group(locked_group, vec![Arc::new(inner)]); + g.locked = true; + d.insert(Some(l), usize::MAX, g).unwrap(); + let open_group = d.alloc_id(); + let mut child = rect(&mut d, 25.0); + child.locked = true; + let child_id = child.id; + let free = rect(&mut d, 25.0); + d.insert(Some(l), usize::MAX, Node::group(open_group, vec![Arc::new(child), Arc::new(free)])).unwrap(); + let at = |d: &Document, x: f64, scope| locked_objects_at(d, Point::new(x, 25.0), HitOptions { scope, ..HitOptions::default() }); + assert_eq!(at(&d, 30.0, None), [child_id, locked_group, b, a]); + assert!(!at(&d, 30.0, None).contains(&inner_id), "the locked group stands for what it holds"); + assert_eq!(at(&d, 5.0, None), [a]); + assert!(at(&d, 150.0, None).is_empty()); + // Isolated, only the group's own objects are looked at. + assert_eq!(at(&d, 30.0, Some(open_group)), [child_id]); + // A hidden object is left out, and nothing on a locked or hidden layer is listed. + d.node_mut(b).unwrap().visible = false; + assert_eq!(at(&d, 30.0, None), [child_id, locked_group, a]); + d.node_mut(l).unwrap().locked = true; + assert!(at(&d, 30.0, None).is_empty()); + d.node_mut(l).unwrap().locked = false; + d.node_mut(l).unwrap().visible = false; + assert!(at(&d, 30.0, None).is_empty()); + } + + /// `locked_objects_at` lists at most `MAX_LOCKED_AT` objects, the topmost ones. + #[test] + fn locked_objects_at_stops_at_its_limit() { + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + let ids: Vec = (0..MAX_LOCKED_AT + 5) + .map(|_| { + let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art()); + insert_locked(&mut d, l, n) + }) + .collect(); + let found = locked_objects_at(&d, Point::new(25.0, 25.0), HitOptions::default()); + assert_eq!(found, ids.iter().rev().take(MAX_LOCKED_AT).copied().collect::>()); + } + #[test] fn visible_sampling_reads_locked_art_and_layers_but_excludes_hidden_art() { let mut d = Document::new(200.0, 200.0); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 14d2e9a04..d7a4413b1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1473,9 +1473,20 @@ fn walk_drawn<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { } /// The topmost editable object under document point `p` at `zoom`, as the selection tools pick it -/// (Selection & Anchor Display › Tolerance and Object Selection by Path Only, Type › Type Object -/// Selection by Path Only). +/// ([`hit_with`]). fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option { + hit_with(app, p, zoom, vectorcraft_doc::hit::hit_test) +} + +/// `test` on the active document at document point `p`, with the options the selection tools hit +/// objects with at `zoom` (Selection & Anchor Display › Tolerance and Object Selection by Path Only, +/// Type › Type Object Selection by Path Only, isolation mode). +fn hit_with( + app: &VectorcraftApp, + p: Point, + zoom: f64, + test: impl FnOnce(&vectorcraft_doc::Document, Point, vectorcraft_doc::hit::HitOptions) -> Option, +) -> Option { let prefs = &app.session.prefs; let opt = vectorcraft_doc::hit::HitOptions { tol: prefs.selection_tolerance / zoom, @@ -1484,23 +1495,32 @@ fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option