M5: Select layer lists locked layers with a padlock and picks them (#1560)

The context menu's Select layer now lists locked layers (and what a locked frame holds) with a padlock in the leading column, and picking one selects it, as the help center's "Lock and unlock layers" says and as measured in Figma's web app, 2026-11-10. The submenu shows only with more than one layer under the pointer (measured on a single locked layer only, the least certain rule). Clicks, hover and marquee still never reach a locked layer.

- doc: geometry::HitOpts { locked } and layers_at_opts; layers_at and the other public hit functions keep their signatures and pass the default.
- engine: select.layersAt lists locked layers and its rows gain locked (the layer or an ancestor is locked).
- ui-egui: indented_item takes locked and paints the Layers panel's padlock icon; a row both selected and locked shows the check.
- docs: ui-parity Select layer row, revision row and status line; mcp.md select.layersAt.

Tests: layers_at_a_point_list_locked_layers_with_a_flag_and_select_set_takes_them, select_layer_marks_locked_layers_with_a_padlock_and_picks_them, select_layer_needs_more_than_one_layer_under_the_pointer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Model-Effort: Opus 5.5 drove; Fable 5.1 briefed; Sonnet 5.5 wrote at medium
This commit is contained in:
Trevor Brown
2026-11-12 03:03:25 -05:00
co-authored by Claude Sonnet 5.5
parent 0d6ce1124c
commit 6cb015d4f8
9 changed files with 177 additions and 34 deletions
+41 -16
View File
@@ -152,7 +152,7 @@ fn near_path(path: &BezPath, p: Point, dist: f64) -> bool {
/// boolean groups are hit where their `result` is.
pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option<NodeId> {
let p = doc.node(page)?;
p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, None).filter(|_| !masked_out(doc, p, *c, pt)))
p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, None, HitOpts::default()).filter(|_| !masked_out(doc, p, *c, pt)))
}
/// Whether child `c` of `parent` is masked at `pt`: a visible mask below it (the nearest mask
@@ -175,7 +175,7 @@ fn covers(doc: &Document, id: NodeId, n: &Node, pt: Point, depth: usize) -> bool
/// Like [`hit_leaf`], but only within layer `root` (it included).
pub fn hit_in(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option<NodeId> {
hit_rec(doc, root, pt, tol, 0, result, None)
hit_rec(doc, root, pt, tol, 0, result, None, HitOpts::default())
}
/// [`hit_leaf`] (or [`hit_in`] for a `root` that isn't a page) in outline mode: shapes are hit
@@ -185,8 +185,8 @@ pub fn hit_in(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolRes
/// when it shows them, and every layer on the edge of its box when it shows object bounds.
pub fn hit_outlines(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult, also: OutlineHits) -> Option<NodeId> {
match doc.node(root) {
Some(p) if p.is_page() => p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, Some(also))),
_ => hit_rec(doc, root, pt, tol, 0, result, Some(also)),
Some(p) if p.is_page() => p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, Some(also), HitOpts::default())),
_ => hit_rec(doc, root, pt, tol, 0, result, Some(also), HitOpts::default()),
}
}
@@ -204,13 +204,31 @@ fn on_box_edge(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64) -> boo
r.inflate(tol, tol).contains(lp) && !(r.width() > 2.0 * tol && r.height() > 2.0 * tol && r.inflate(-tol, -tol).contains(lp))
}
fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult, also: Option<OutlineHits>) -> Option<NodeId> {
/// Options for the hit tests that the plain pick never sets.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct HitOpts {
/// Reach locked layers (and what a locked frame holds) too. A click, hover or marquee never
/// does; the Select layer list does.
pub locked: bool,
}
#[allow(clippy::too_many_arguments)]
fn hit_rec(
doc: &Document,
id: NodeId,
pt: Point,
tol: f64,
depth: usize,
result: BoolResult,
also: Option<OutlineHits>,
opts: HitOpts,
) -> Option<NodeId> {
if depth > 512 {
return None;
}
let n = doc.node(id)?;
let outline = also.is_some();
if (!n.visible && !also.is_some_and(|a| a.hidden)) || n.locked {
if (!n.visible && !also.is_some_and(|a| a.hidden)) || n.locked && !opts.locked {
return None;
}
if let NodeKind::Section { contents_hidden: true } = n.kind {
@@ -228,7 +246,7 @@ fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result
return None;
}
for c in n.paint_order().rev() {
if let Some(h) = hit_rec(doc, c, pt, tol, depth + 1, result, also).filter(|_| !masked_out(doc, n, c, pt)) {
if let Some(h) = hit_rec(doc, c, pt, tol, depth + 1, result, also, opts).filter(|_| !masked_out(doc, n, c, pt)) {
return Some(h);
}
}
@@ -239,14 +257,21 @@ fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result
None
}
/// Every visible, unlocked layer under `pt` (page coordinates) with its depth below the page, in
/// layer-tree order (each layer before what it contains, the front-most first): what
/// "Select layer" in the context menu lists. Clip content is honoured; boolean groups (hit where
/// their `result` is) and sections with hidden contents count as one layer.
/// Every visible, unlocked layer under `pt`: [`layers_at_opts`] with the default options.
pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolResult) -> Vec<(NodeId, usize)> {
fn rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult, out: &mut Vec<(NodeId, usize)>) {
layers_at_opts(doc, page, pt, tol, result, HitOpts::default())
}
/// Every visible layer under `pt` (page coordinates) with its depth below the page, in
/// layer-tree order (each layer before what it contains, the front-most first): what
/// "Select layer" in the context menu lists. Locked layers are left out unless `opts.locked`.
/// Clip content is honoured; boolean groups (hit where their `result` is) and sections with
/// hidden contents count as one layer.
pub fn layers_at_opts(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolResult, opts: HitOpts) -> Vec<(NodeId, usize)> {
#[allow(clippy::too_many_arguments)]
fn rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult, opts: HitOpts, out: &mut Vec<(NodeId, usize)>) {
let Some(n) = doc.node(id) else { return };
if !n.visible || n.locked || depth > 512 {
if !n.visible || n.locked && !opts.locked || depth > 512 {
return;
}
let clips = n.clip && n.kind.is_frame_like();
@@ -256,14 +281,14 @@ pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64, result: Bool
let at = out.len();
let whole = matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Section { contents_hidden: true });
if whole {
if hit_rec(doc, id, pt, tol, depth, result, None).is_some() {
if hit_rec(doc, id, pt, tol, depth, result, None, opts).is_some() {
out.push((id, depth));
}
return;
}
for c in n.paint_order().rev() {
let before = out.len();
rec(doc, c, pt, tol, depth + 1, result, out);
rec(doc, c, pt, tol, depth + 1, result, opts, out);
if out.len() > before && masked_out(doc, n, c, pt) {
out.truncate(before);
}
@@ -276,7 +301,7 @@ pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64, result: Bool
if let Some(p) = doc.node(page) {
for c in p.children.iter().rev() {
let before = out.len();
rec(doc, *c, pt, tol, 0, result, &mut out);
rec(doc, *c, pt, tol, 0, result, opts, &mut out);
if out.len() > before && masked_out(doc, p, *c, pt) {
out.truncate(before);
}
+1 -1
View File
@@ -207,7 +207,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"select.layersAt",
"Layers at a point",
"{x, y (page coordinates)} → {layers: [{id, name, type, depth}]} (every layer under the point, front-most first; what the context menu's Select layer lists)",
"{x, y (page coordinates)} → {layers: [{id, name, type, depth, locked}]} (every layer under the point, locked ones too, front-most first; locked is true when the layer or an ancestor is locked; what the context menu's Select layer lists)",
false,
layers_at
),
+6 -2
View File
@@ -8,9 +8,13 @@ pub(super) fn layers_at(s: &mut Session, p: &Value) -> Result<Value> {
let zoom = s.ds()?.view.zoom.max(1e-6);
let sel = s.selection();
let result = crate::booleans::result_outline(doc, &sel);
let layers: Vec<Value> = figcraft_doc::geometry::layers_at(doc, s.page()?, figcraft_geom::Point::new(x, y), 2.0 / zoom, Some(&result))
let opts = figcraft_doc::geometry::HitOpts { locked: true };
let layers: Vec<Value> = figcraft_doc::geometry::layers_at_opts(doc, s.page()?, figcraft_geom::Point::new(x, y), 2.0 / zoom, Some(&result), opts)
.into_iter()
.filter_map(|(id, depth)| doc.node(id).map(|n| json!({"id": id, "name": n.name, "type": n.kind.type_name(), "depth": depth})))
.filter_map(|(id, depth)| {
doc.node(id)
.map(|n| json!({"id": id, "name": n.name, "type": n.kind.type_name(), "depth": depth, "locked": crate::tools::locked_in(doc, id)}))
})
.collect();
Ok(json!({"layers": layers}))
}
@@ -118,8 +118,33 @@ fn layers_at_a_point_list_the_stack_front_most_first() {
let got: Vec<(NodeId, u64)> =
out["layers"].as_array().unwrap().iter().map(|l| (id_of(&l["id"]).unwrap(), l["depth"].as_u64().unwrap())).collect();
assert_eq!(got, vec![(f, 0), (over, 1), (g, 1), (a, 2)]);
assert!(out["layers"].as_array().unwrap().iter().all(|l| l["locked"] == json!(false)), "none is locked");
assert_eq!(out["layers"][0]["type"], json!("FRAME"));
// Nothing under empty canvas; junk is refused.
assert_eq!(ex(&mut s, "select.layersAt", json!({"x": 2000, "y": 2000}))["layers"], json!([]));
assert!(s.execute("select.layersAt", &json!({"x": "left"})).is_err());
}
/// Measured in Figma's web app, 2026-11-10 (Select layer lists a locked layer, and picking it selects it).
#[test]
fn layers_at_a_point_list_locked_layers_with_a_flag_and_select_set_takes_them() {
let mut s = Session::new();
let (f, g, [a, ..], _) = scene(&mut s);
let over = rect_in(&mut s, 10.0, 10.0, Some(f));
let lk = rect_in(&mut s, 10.0, 10.0, Some(f));
ex(&mut s, "object.toggleLock", json!({"ids": [lk], "locked": true}));
let out = ex(&mut s, "select.layersAt", json!({"x": 30, "y": 30}));
let got: Vec<(NodeId, u64, bool)> = out["layers"]
.as_array()
.unwrap()
.iter()
.map(|l| (id_of(&l["id"]).unwrap(), l["depth"].as_u64().unwrap(), l["locked"].as_bool().unwrap()))
.collect();
assert_eq!(got, vec![(f, 0, false), (lk, 1, true), (over, 1, false), (g, 1, false), (a, 2, false)]);
ex(&mut s, "select.set", json!({"ids": [lk]}));
assert_eq!(s.selection(), vec![lk]);
ex(&mut s, "select.none", json!({}));
s.pointer(&ev(PointerKind::Down, 30.0, 30.0, false, false)).unwrap();
s.pointer(&ev(PointerKind::Up, 30.0, 30.0, false, false)).unwrap();
assert_eq!(s.selection(), vec![over], "a click still skips the locked layer");
}
+77
View File
@@ -127,3 +127,80 @@ fn copying_a_link_from_a_frames_name_label_links_that_frame() {
crate::menus::invoke(&mut u.app, "edit.copyLink");
assert_eq!(copied.lock().unwrap().last().cloned(), Some(format!("figcraft://node/{}", a.0)));
}
/// Two overlapping rectangles, the upper one locked; the menu opens on the overlap with nothing
/// selected. Returns the app's UI and the ids (lower, locked).
fn overlap_ui() -> (Ui, figcraft_doc::NodeId, figcraft_doc::NodeId) {
let mut s = Session::new();
let lower = id(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 80, "height": 80, "name": "Lower"})).unwrap());
let upper = id(&s.execute("shape.rectangle", &json!({"x": 120, "y": 120, "width": 80, "height": 80, "name": "Upper"})).unwrap());
s.execute("object.toggleLock", &json!({"ids": [upper], "locked": true})).unwrap();
let mut u = Ui::new(s);
u.frame(vec![]);
(u, lower, upper)
}
/// Measured in Figma's web app, 2026-11-10 (Lock and unlock layers: locked layers are listed in Select layer).
#[test]
fn select_layer_marks_locked_layers_with_a_padlock_and_picks_them() {
let (mut u, lower, upper) = overlap_ui();
let at = u.app.to_screen(150.0, 150.0);
crate::canvas::open_context_menu(&mut u.app, at, false);
u.app.session.set_selection(vec![]);
crate::menus::open_select_layer(&u.ctx, true);
u.frame(vec![]);
let shapes = flat(&u.frame_output(vec![]).shapes);
// The Layers panel lists the same names; the menu's rows are the right-most copies.
let menu_row = |name: &str| {
let (_, pos, size) = crate::live_tests::texts(&shapes)
.into_iter()
.filter(|(t, _, _)| t == name)
.max_by(|a, b| a.1.x.total_cmp(&b.1.x))
.unwrap_or_else(|| panic!("no row {name:?}"));
(pos, size)
};
let (lower_at, upper_at) = (menu_row("Lower"), menu_row("Upper"));
assert!(lower_at.0.x > 300.0 && upper_at.0.x > 300.0, "both layers are listed in the menu");
let in_leading = |(pos, size): (Pos2, egui::Vec2)| {
let c = pos + size / 2.0;
shapes
.iter()
.filter(|s| !matches!(s, egui::Shape::Text(_)))
.filter(|s| {
let r = s.visual_bounding_rect();
r.is_positive()
&& (r.center().y - c.y).abs() < 8.0
&& r.max.x < c.x - size.x / 2.0 - 4.0
&& r.min.x > c.x - size.x / 2.0 - 40.0
&& r.width() < 20.0
})
.count()
};
assert!(in_leading(upper_at) > 0, "the locked row has a padlock in its leading column");
assert_eq!(in_leading(lower_at), 0, "the other row has nothing there");
let p = upper_at.0 + upper_at.1 / 2.0;
for pressed in [true, false] {
u.frame(vec![
egui::Event::PointerMoved(p),
egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE },
]);
}
assert_eq!(u.app.session.selection(), vec![upper], "picking the locked row selects it");
let _ = lower;
}
/// Measured on a single locked layer only (Figma's web app, 2026-11-10): the least certain rule.
#[test]
fn select_layer_needs_more_than_one_layer_under_the_pointer() {
let (mut u, _, _) = overlap_ui();
let one = u.app.to_screen(105.0, 105.0);
crate::canvas::open_context_menu(&mut u.app, one, false);
u.frame(vec![]);
let shapes = flat(&u.frame_output(vec![]).shapes);
assert!(!has_text(&shapes, "Select layer"), "one layer under the pointer: no Select layer");
let two = u.app.to_screen(150.0, 150.0);
crate::canvas::open_context_menu(&mut u.app, two, false);
u.frame(vec![]);
let shapes = flat(&u.frame_output(vec![]).shapes);
assert!(has_text(&shapes, "Select layer"), "two layers: it shows");
}
+15 -8
View File
@@ -15,7 +15,8 @@ pub fn open_select_layer(ctx: &egui::Context, open: bool) {
}
/// The context menu's "Select layer" submenu (right-click on the canvas): every layer under the
/// click, nested ones indented, the selected ones checked. Returns whether one was picked.
/// click (locked ones with a padlock), nested ones indented, the selected ones checked; shown
/// only with more than one layer under the pointer. Returns whether one was picked.
pub(super) fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens) -> bool {
if !app.canvas.rect.contains(pos) {
return false;
@@ -23,15 +24,21 @@ pub(super) fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2,
let p = app.to_page(pos);
let Ok(ds) = app.session.ds() else { return false };
let tol = 2.0 / ds.view.zoom.max(1e-6);
let layers: Vec<(figcraft_doc::NodeId, usize, String)> =
figcraft_doc::geometry::layers_at(&ds.doc, ds.page, p, tol, Some(&figcraft_engine::booleans::result_outline(&ds.doc, &ds.selection)))
let opts = figcraft_doc::geometry::HitOpts { locked: true };
let layers: Vec<(figcraft_doc::NodeId, usize, String, bool)> = {
let result = figcraft_engine::booleans::result_outline(&ds.doc, &ds.selection);
figcraft_doc::geometry::layers_at_opts(&ds.doc, ds.page, p, tol, Some(&result), opts)
.into_iter()
.take(40)
.filter_map(|(id, depth)| {
ds.doc.node(id).map(|n| (id, depth, if n.name.is_empty() { n.kind.type_name().to_lowercase() } else { n.name.clone() }))
ds.doc.node(id).map(|n| {
let name = if n.name.is_empty() { n.kind.type_name().to_lowercase() } else { n.name.clone() };
(id, depth, name, figcraft_engine::tools::locked_in(&ds.doc, id))
})
})
.collect();
if layers.is_empty() {
.collect()
};
if layers.len() < 2 {
return false;
}
let sel = ds.selection.clone();
@@ -40,8 +47,8 @@ pub(super) fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2,
SubmenuOptions { area: Id::unique("ctx_select_layer"), key: SELECT_LAYER, label: tr("Select layer"), gap: 4.0, constrain: true, tokens: t },
|ui| {
let mut picked = None;
for (id, depth, name) in &layers {
if indented_item(ui, name, "", true, sel.contains(id), 12.0 * (*depth).min(8) as f32, t) {
for (id, depth, name, locked) in &layers {
if indented_item(ui, name, "", true, sel.contains(id), *locked, 12.0 * (*depth).min(8) as f32, t) {
picked = Some(*id);
}
}
+7 -3
View File
@@ -395,11 +395,13 @@ fn need_width(ui: &Ui, w: f32) {
}
pub(crate) fn item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checked: bool, t: &Tokens) -> bool {
indented_item(ui, label, shortcut, enabled, checked, 0.0, t)
indented_item(ui, label, shortcut, enabled, checked, false, 0.0, t)
}
/// A menu row whose label starts `indent` pixels further in (nested layers).
pub(super) fn indented_item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checked: bool, indent: f32, t: &Tokens) -> bool {
/// A menu row whose label starts `indent` pixels further in (nested layers); `locked` paints a
/// padlock in the leading column (a checked row shows the check instead).
#[allow(clippy::too_many_arguments)]
pub(super) fn indented_item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checked: bool, locked: bool, indent: f32, t: &Tokens) -> bool {
let w = ui.available_width();
let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click());
a11y::menu_item(&resp, label, shortcut, checked.then_some(true));
@@ -415,6 +417,8 @@ pub(super) fn indented_item(ui: &mut Ui, label: &str, shortcut: &str, enabled: b
let col = if enabled { t.menu_text } else { t.menu_dim };
if checked {
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0)), Icon::Check, col);
} else if locked {
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0)), Icon::Lock, col);
}
let galley = ui.painter().layout_no_wrap(label.to_string(), regular(12.0), col);
let keys = (!shortcut.is_empty())
+2 -2
View File
@@ -2082,8 +2082,8 @@ turned off 0°.
`node.set {pointCount}` keeps stars to 3–60 points; `miterAngle` (degrees, the Design panel's
Miter angle) sets the miter limit (`limit = 1 / sin(angle / 2)`); `get_node` reports a join
other than miter (`strokeJoin`) or a miter angle other than the default ~28.96° (`miterAngle`).
- **Select**: `select.layersAt {x, y}` lists every layer under a page point, front-most first with its
depth (the context menu's Select layer); `select.set`, `select.children` / `select.parent` (Enter
- **Select**: `select.layersAt {x, y}` lists every layer under a page point, locked ones too, front-most first with its
depth and a `locked` flag (true when the layer or an ancestor is locked; the context menu's Select layer); `select.set`, `select.children` / `select.parent` (Enter
/ Shift+Enter), `select.next` / `select.previous` (Tab / Shift+Tab). With `pointer`, `cmd: true`
clicks select the nested layer, `cmd` drags from empty canvas select the nested layers they touch,
and `shift` drags toggle what they touch. A press on the background of a top-level frame with
+3 -2
View File
File diff suppressed because one or more lines are too long