mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-12 10:02:01 +00:00
M5: Locked layers follow Figma's rules: no nudge, a locked parent's children stay locked (#1560) (#2219)
* M5: Locked layers follow Figma's rules: no nudge, a locked parent's children stay locked (#1560) Pins Figma's locked-layer behaviour (measured in Figma's web app, 2026-11-10; help center "Lock and unlock layers") in a new lock_behaviour_tests suite. Fixes the divergences: transform.nudge and transform.nudgeSize skip locked layers, object.toggleLock without a value counts a child of a locked parent as locked (so it never locks the child on its own), and the Layers panel padlock on such a child is inert. 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 * Layers panel: a padlock sweep skips the children of a locked layer (#1560) A padlock drag sweep no longer collects rows with `locked_above`, so dragging across a locked frame's children leaves their own flags alone and draws no swept value on them. An eye sweep is unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium * M5: Lock/Unlock leaves a locked layer's child alone in a mixed selection (#1560) With a locked frame's child and an unlocked layer selected, Lock/Unlock set both own flags, so the child stayed locked once the frame was unlocked. With no explicit `locked` value, the command now skips a layer whose parent is locked, as its padlock does; an explicit value (scripts) still sets it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Model-Effort: Opus 5.5 drove and wrote --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: FredApps <FredApps@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
FredApps
parent
898bfc2e60
commit
24cf70329b
@@ -182,6 +182,9 @@ fn table_edits_add_and_remove_rows_and_columns_keeping_text() {
|
||||
|
||||
#[test]
|
||||
fn a_double_click_types_in_the_cell_and_its_row_grows_with_the_text() {
|
||||
// Measure with system fonts when the optional craft-fonts checkout is absent,
|
||||
// without depending on another test having scanned them first.
|
||||
figcraft_text::scan_system_fonts();
|
||||
let mut s = board();
|
||||
let r = s.execute("board.table", &json!({"x": 0, "y": 0})).unwrap();
|
||||
let t = id(&r, "id");
|
||||
|
||||
@@ -174,7 +174,8 @@ pub(super) fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// no undo step.
|
||||
let mut work = s.doc()?.clone();
|
||||
let doc = &mut work;
|
||||
let ids = roots(doc, &ids);
|
||||
// A locked layer (or one in a locked layer) ignores the arrow keys.
|
||||
let ids: Vec<NodeId> = roots(doc, &ids).into_iter().filter(|i| !crate::tools::locked_in(doc, *i)).collect();
|
||||
let mut changed = false;
|
||||
// Children in an auto layout flow move one place instead (see `al_keys`).
|
||||
for i in &ids {
|
||||
@@ -209,6 +210,9 @@ pub(super) fn nudge_size(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("transform.nudgeSize", s, p)?;
|
||||
let ids = roots(s.doc()?, &ids);
|
||||
for i in ids {
|
||||
if crate::tools::locked_in(s.doc()?, i) {
|
||||
continue;
|
||||
}
|
||||
let Some((w, h)) = s.doc()?.node(i).map(|n| (n.width, n.height)) else { continue };
|
||||
let mut q = serde_json::Map::new();
|
||||
q.insert("ids".into(), json!([i]));
|
||||
@@ -231,9 +235,16 @@ pub(super) fn toggle(s: &mut Session, p: &Value, what: &str) -> Result<Value> {
|
||||
// Figma: if any is visible, hide all (and vice versa).
|
||||
let value = explicit.unwrap_or_else(|| match what {
|
||||
"visible" => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.visible)),
|
||||
_ => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.locked)),
|
||||
// A child of a locked layer counts as locked: its own flag can't be toggled while the
|
||||
// parent is locked.
|
||||
_ => !ids.iter().all(|i| crate::tools::locked_in(doc, *i)),
|
||||
});
|
||||
for i in &ids {
|
||||
// Lock/Unlock leaves a child of a locked layer alone, as its padlock does: its own flag must
|
||||
// still hold once the parent is unlocked. An explicit `locked` (scripts) sets it anyway.
|
||||
let under_lock = |i: NodeId| doc.ancestors(i).into_iter().any(|a| doc.node(a).is_some_and(|n| n.locked));
|
||||
let skip: Vec<NodeId> =
|
||||
if what == "locked" && explicit.is_none() { ids.iter().copied().filter(|i| under_lock(*i)).collect() } else { Vec::new() };
|
||||
for i in ids.iter().filter(|i| !skip.contains(i)) {
|
||||
let n = doc.get_mut(*i)?;
|
||||
if what == "visible" {
|
||||
n.visible = value;
|
||||
|
||||
@@ -419,6 +419,8 @@ mod live_signal_tests;
|
||||
#[cfg(test)]
|
||||
mod live_tests;
|
||||
#[cfg(test)]
|
||||
mod lock_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod mask_tests;
|
||||
#[cfg(test)]
|
||||
mod masks_behaviour_tests;
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Locked layers, as measured in Figma's web app (2026-11-10) and described in the help-center
|
||||
//! article "Lock and unlock layers": the canvas never selects a locked layer or a child of a locked
|
||||
//! frame; a locked layer selected in the layers panel has no handles and ignores the arrow keys, but
|
||||
//! its properties still edit; a child of a locked frame can't be locked or unlocked on its own.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use crate::tools::{PointerEvent, PointerKind, handle_at};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_geom::Point;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, parent: Option<NodeId>) -> NodeId {
|
||||
let mut p = json!({"x": x, "y": y, "width": 40, "height": 40});
|
||||
if let Some(parent) = parent {
|
||||
p["parent"] = json!(parent);
|
||||
}
|
||||
id_of(&ex(s, "shape.rectangle", p)["id"]).unwrap()
|
||||
}
|
||||
|
||||
/// A frame `f` (0,0 300x200) holding rectangle `d` (20,20 40x40); a locked rectangle `lk`
|
||||
/// (400,0 40x40) and an unlocked one `r` (400,100 40x40) on the page.
|
||||
fn scene(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
|
||||
let f = id_of(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200}))["id"]).unwrap();
|
||||
let d = rect(s, 20.0, 20.0, Some(f));
|
||||
let lk = rect(s, 400.0, 0.0, None);
|
||||
let r = rect(s, 400.0, 100.0, None);
|
||||
ex(s, "object.toggleLock", json!({"ids": [lk], "locked": true}));
|
||||
(f, d, lk, r)
|
||||
}
|
||||
|
||||
fn ev(kind: PointerKind, x: f64, y: f64, cmd: bool) -> PointerEvent {
|
||||
let mut e = PointerEvent::at(kind, x, y, 1.0);
|
||||
e.cmd = cmd;
|
||||
e
|
||||
}
|
||||
|
||||
fn click(s: &mut Session, x: f64, y: f64, cmd: bool) {
|
||||
s.pointer(&ev(PointerKind::Down, x, y, cmd)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, x, y, cmd)).unwrap();
|
||||
}
|
||||
|
||||
fn origin(s: &Session, id: NodeId) -> (f64, f64) {
|
||||
let b = s.doc().unwrap().world_bounds(id).unwrap();
|
||||
(b.x0, b.y0)
|
||||
}
|
||||
|
||||
fn size(s: &Session, id: NodeId) -> (f64, f64) {
|
||||
let b = s.doc().unwrap().world_bounds(id).unwrap();
|
||||
(b.width(), b.height())
|
||||
}
|
||||
|
||||
fn locked(s: &Session, id: NodeId) -> bool {
|
||||
s.doc().unwrap().node(id).unwrap().locked
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_never_selects_a_locked_layer_or_a_locked_frames_child() {
|
||||
let mut s = Session::new();
|
||||
let (f, d, _lk, _r) = scene(&mut s);
|
||||
click(&mut s, 420.0, 20.0, false);
|
||||
assert!(s.selection().is_empty(), "a click on a locked layer selects nothing");
|
||||
click(&mut s, 420.0, 20.0, true);
|
||||
assert!(s.selection().is_empty(), "Cmd/Ctrl-click on a locked layer selects nothing");
|
||||
s.pointer(&ev(PointerKind::Down, 390.0, -10.0, false)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, 450.0, 50.0, false)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, 450.0, 50.0, false)).unwrap();
|
||||
assert!(s.selection().is_empty(), "a marquee over a locked layer selects nothing");
|
||||
ex(&mut s, "object.toggleLock", json!({"ids": [f], "locked": true}));
|
||||
click(&mut s, 30.0, 30.0, false);
|
||||
assert!(!s.selection().contains(&d), "a click on a child of a locked frame doesn't select it");
|
||||
click(&mut s, 30.0, 30.0, true);
|
||||
assert!(s.selection().is_empty(), "Cmd/Ctrl-click on a child of a locked frame selects nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panel_selected_locked_layer_has_no_handles_ignores_arrows_and_takes_property_edits() {
|
||||
let mut s = Session::new();
|
||||
let (_f, _d, lk, _r) = scene(&mut s);
|
||||
ex(&mut s, "select.set", json!({"ids": [lk]}));
|
||||
assert_eq!(handle_at(s.doc().unwrap(), &[lk], Point::new(400.0, 0.0), 1.0), None, "no handles on a locked layer");
|
||||
let steps = s.undo_depth();
|
||||
for _ in 0..3 {
|
||||
ex(&mut s, "transform.nudge", json!({"dx": 1}));
|
||||
}
|
||||
assert_eq!(origin(&s, lk), (400.0, 0.0), "the arrow keys don't move a locked layer");
|
||||
ex(&mut s, "transform.nudgeSize", json!({"dw": 1}));
|
||||
assert_eq!(size(&s, lk), (40.0, 40.0), "the resize arrows don't resize a locked layer");
|
||||
assert_eq!(s.undo_depth(), steps, "an arrow that changes nothing adds no undo step");
|
||||
ex(&mut s, "node.set", json!({"id": lk, "props": {"x": 300}}));
|
||||
assert_eq!(origin(&s, lk).0, 300.0, "the X field still moves a locked layer");
|
||||
let before = format!("{:?}", s.doc().unwrap().node(lk).unwrap().fills);
|
||||
ex(&mut s, "fill.set", json!({"ids": [lk], "color": "#ff0000"}));
|
||||
assert_ne!(format!("{:?}", s.doc().unwrap().node(lk).unwrap().fills), before, "a fill still edits");
|
||||
assert_eq!(s.selection(), vec![lk], "the selection stays the locked layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_child_of_a_locked_frame_cannot_be_locked_or_unlocked_on_its_own() {
|
||||
let mut s = Session::new();
|
||||
let (f, d, _lk, _r) = scene(&mut s);
|
||||
ex(&mut s, "object.toggleLock", json!({"ids": [f], "locked": true}));
|
||||
ex(&mut s, "object.toggleLock", json!({"ids": [d]}));
|
||||
assert!(!locked(&s, d), "toggling a child of a locked frame doesn't lock the child");
|
||||
ex(&mut s, "select.set", json!({"ids": [d]}));
|
||||
ex(&mut s, "object.toggleLock", json!({}));
|
||||
assert!(!locked(&s, d), "Lock/Unlock on a panel-selected child of a locked frame does nothing");
|
||||
ex(&mut s, "object.toggleLock", json!({"ids": [f], "locked": false}));
|
||||
click(&mut s, 30.0, 30.0, false);
|
||||
assert_eq!(s.selection(), vec![d], "after unlocking the frame a click selects the child");
|
||||
assert!(!locked(&s, d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_unlock_on_a_mixed_selection_leaves_a_locked_frames_child_alone() {
|
||||
let mut s = Session::new();
|
||||
let (f, d, _lk, r) = scene(&mut s);
|
||||
ex(&mut s, "object.toggleLock", json!({"ids": [f], "locked": true}));
|
||||
ex(&mut s, "select.set", json!({"ids": [d, r]}));
|
||||
ex(&mut s, "object.toggleLock", json!({}));
|
||||
assert!(locked(&s, r), "the unlocked layer in the selection is locked");
|
||||
assert!(!locked(&s, d), "the locked frame's child keeps its own flag");
|
||||
ex(&mut s, "object.toggleLock", json!({"ids": [f], "locked": false}));
|
||||
click(&mut s, 30.0, 30.0, false);
|
||||
assert_eq!(s.selection(), vec![d], "so unlocking the frame frees the child");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_shift_l_on_a_panel_selected_locked_layer_unlocks_it() {
|
||||
let mut s = Session::new();
|
||||
let (_f, _d, lk, _r) = scene(&mut s);
|
||||
ex(&mut s, "select.set", json!({"ids": [lk]}));
|
||||
ex(&mut s, "object.toggleLock", json!({}));
|
||||
assert!(!locked(&s, lk), "Lock/Unlock unlocks a locked layer");
|
||||
assert!(handle_at(s.doc().unwrap(), &[lk], Point::new(400.0, 0.0), 1.0).is_some(), "handles return");
|
||||
}
|
||||
@@ -252,6 +252,54 @@ fn sweeping_across_eyes_and_padlocks_sets_several_layers() {
|
||||
assert!(!u.app.session.doc().unwrap().node(a).unwrap().visible, "a click on the eye hides that layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_padlock_of_a_layer_in_a_locked_frame_is_inert() {
|
||||
let mut s = Session::new();
|
||||
let frame = id(s.execute("frame.create", &json!({"name": "Locked frame"})).unwrap());
|
||||
let child = rect(&mut s, "child", 0.0, Some(frame));
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("object.toggleLock", &json!({"ids": [frame], "locked": true})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
// Selecting the child reveals its row; then the selection goes away.
|
||||
u.app.run("select.set", json!({"ids": [child]}));
|
||||
u.frame(vec![]);
|
||||
u.app.run("select.none", json!({}));
|
||||
u.frame(vec![]);
|
||||
let steps = u.app.session.undo_depth();
|
||||
let r = u.row_at(child);
|
||||
// The padlock column (see the sweep test above).
|
||||
click(&mut u, Pos2::new(250.0, r.y), Modifiers::NONE);
|
||||
assert!(!u.app.session.doc().unwrap().node(child).unwrap().locked, "the child's padlock doesn't lock it");
|
||||
assert_eq!(u.app.session.undo_depth(), steps, "and adds no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_padlock_sweep_skips_the_children_of_a_locked_frame() {
|
||||
let mut s = Session::new();
|
||||
let frame = id(s.execute("frame.create", &json!({"name": "Locked frame"})).unwrap());
|
||||
let c1 = rect(&mut s, "child 1", 0.0, Some(frame));
|
||||
let c2 = rect(&mut s, "child 2", 20.0, Some(frame));
|
||||
let sibling = rect(&mut s, "sibling", 40.0, None);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("object.toggleLock", &json!({"ids": [frame], "locked": true})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
// Selecting a child reveals the frame's rows; then the selection goes away.
|
||||
u.app.run("select.set", json!({"ids": [c1]}));
|
||||
u.frame(vec![]);
|
||||
u.app.run("select.none", json!({}));
|
||||
u.frame(vec![]);
|
||||
let padlock = 250.0;
|
||||
let (rs, rc) = (u.row_at(sibling), u.row_at(c1));
|
||||
let from = Pos2::new(padlock, rs.y);
|
||||
u.frame(vec![Event::PointerMoved(from)]);
|
||||
u.drag_to(from, Pos2::new(padlock, rc.y));
|
||||
u.button(Pos2::new(padlock, rc.y), false);
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
assert!(doc.node(sibling).unwrap().locked, "the sibling is locked by the sweep");
|
||||
assert!(!doc.node(c1).unwrap().locked, "a child of a locked frame keeps its own flag");
|
||||
assert!(!doc.node(c2).unwrap().locked, "so does the other child");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_several_layers_opens_the_rename_dialog() {
|
||||
let (mut u, [a, b, c, _d]) = four();
|
||||
|
||||
@@ -972,6 +972,8 @@ fn eye_lock(
|
||||
// A sweep across the eyes or padlocks takes in the rows the pointer passes.
|
||||
if let Some(sw) = sweep.as_mut()
|
||||
&& !sw.ids.contains(&row.id)
|
||||
// A padlock sweep leaves the children of a locked layer alone (their padlock is inert).
|
||||
&& !(sw.lock && row.locked_above)
|
||||
&& ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.y_range().contains(p.y))
|
||||
{
|
||||
sw.ids.push(row.id);
|
||||
@@ -1003,13 +1005,20 @@ fn eye_lock(
|
||||
icons::paint(ui.painter(), lr.shrink(3.0), if locked || row.locked_above { Icon::Lock } else { Icon::Unlock }, col);
|
||||
}
|
||||
if show_lock && !dev {
|
||||
let lresp = ui.interact(lr, ui.scope_id().with(("lock", row.id)), Sense::click_and_drag());
|
||||
// A child of a locked layer can't be locked or unlocked on its own while the parent is
|
||||
// locked (Lock and unlock layers): its padlock is inert.
|
||||
let sense = if row.locked_above { Sense::hover() } else { Sense::click_and_drag() };
|
||||
let lresp = ui.interact(lr, ui.scope_id().with(("lock", row.id)), sense);
|
||||
a11y::toggle(&lresp, tr("Locked"), locked || row.locked_above);
|
||||
if lresp.clicked() {
|
||||
lock = Some(row.id);
|
||||
}
|
||||
if lresp.drag_started() {
|
||||
*sweep = Some(Sweep { lock: true, on: !n.locked, ids: vec![row.id] });
|
||||
if row.locked_above {
|
||||
a11y::disabled(&lresp);
|
||||
} else {
|
||||
if lresp.clicked() {
|
||||
lock = Some(row.id);
|
||||
}
|
||||
if lresp.drag_started() {
|
||||
*sweep = Some(Sweep { lock: true, on: !n.locked, ids: vec![row.id] });
|
||||
}
|
||||
}
|
||||
}
|
||||
(vis, lock)
|
||||
|
||||
+4
-3
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user