mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 05:42:38 +00:00
* 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>
281 lines
11 KiB
Rust
281 lines
11 KiB
Rust
use super::{
|
|
Affine, BTreeMap, BTreeSet, Node, NodeId, Result, Session, Value, bad, boolean, json, num, num_or, ok, req_num, req_string, req_targets, roots,
|
|
};
|
|
|
|
pub(super) fn layers_at(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let (x, y) = (req_num("select.layersAt", p, "x")?, req_num("select.layersAt", p, "y")?);
|
|
let doc = s.doc()?;
|
|
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))
|
|
.into_iter()
|
|
.filter_map(|(id, depth)| doc.node(id).map(|n| json!({"id": id, "name": n.name, "type": n.kind.type_name(), "depth": depth})))
|
|
.collect();
|
|
Ok(json!({"layers": layers}))
|
|
}
|
|
|
|
pub(super) fn select_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
|
let doc = s.doc()?;
|
|
let sel = s.selection();
|
|
// Select all siblings of the current selection, else the page's top level.
|
|
let parent = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).unwrap_or(s.page()?);
|
|
let ids: Vec<NodeId> = doc.get(parent)?.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.locked)).collect();
|
|
s.set_selection(ids.clone());
|
|
Ok(json!({"selection": ids}))
|
|
}
|
|
|
|
/// What Select all would select, less the selection (hidden and locked layers neither).
|
|
pub(super) fn select_inverse(s: &mut Session, _p: &Value) -> Result<Value> {
|
|
let doc = s.doc()?;
|
|
let sel = s.selection();
|
|
let parent = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).unwrap_or(s.page()?);
|
|
let ids: Vec<NodeId> =
|
|
doc.get(parent)?.children.iter().copied().filter(|c| !sel.contains(c) && doc.node(*c).is_some_and(|n| n.visible && !n.locked)).collect();
|
|
s.set_selection(ids.clone());
|
|
Ok(json!({"selection": ids}))
|
|
}
|
|
|
|
/// `select.area`: what a marquee from (x, y) over the box selects; no box asks the app for one.
|
|
pub(super) fn select_area(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "select.area";
|
|
let keys = ["x", "y", "width", "height"];
|
|
if keys.iter().all(|k| p.get(*k).is_none()) {
|
|
s.request_ui("selectArea", json!({}));
|
|
return ok();
|
|
}
|
|
let [x, y, w, h] = keys.map(|k| num(p, k));
|
|
let (Some(x), Some(y), Some(w), Some(h)) = (x, y, w, h) else { return Err(bad(cmd, "pass x, y, width and height (numbers)")) };
|
|
if ![x, y, w, h].iter().all(|v| v.is_finite() && v.abs() < 1e9) {
|
|
return Err(bad(cmd, "the box is out of range"));
|
|
}
|
|
let start = figcraft_geom::Point::new(x, y);
|
|
let r = figcraft_geom::Rect::from_points(start, figcraft_geom::Point::new(x + w, y + h));
|
|
let add = boolean(p, "add") == Some(true);
|
|
let base = if add { s.selection() } else { Vec::new() };
|
|
let within = s.marquee_within(start)?;
|
|
let sel = s.marquee_selection(r, within, boolean(p, "deep") == Some(true), &base, add)?;
|
|
s.set_selection(sel.clone());
|
|
Ok(json!({"selection": sel}))
|
|
}
|
|
|
|
pub(super) fn select_children(s: &mut Session, _p: &Value) -> Result<Value> {
|
|
let doc = s.doc()?;
|
|
let mut ids = Vec::new();
|
|
for i in s.selection() {
|
|
if let Some(n) = doc.node(i) {
|
|
ids.extend(n.children.iter().copied());
|
|
}
|
|
}
|
|
if ids.is_empty() {
|
|
return Ok(json!({"selection": s.selection()}));
|
|
}
|
|
s.set_selection(ids.clone());
|
|
Ok(json!({"selection": ids}))
|
|
}
|
|
|
|
pub(super) fn select_parent(s: &mut Session, _p: &Value) -> Result<Value> {
|
|
let doc = s.doc()?;
|
|
let mut ids: Vec<NodeId> = Vec::new();
|
|
for i in s.selection() {
|
|
if let Some(p) = doc.node(i).and_then(|n| n.parent)
|
|
&& !doc.node(p).is_some_and(Node::is_page)
|
|
&& !ids.contains(&p)
|
|
{
|
|
ids.push(p);
|
|
}
|
|
}
|
|
if ids.is_empty() {
|
|
return Ok(json!({"selection": s.selection()}));
|
|
}
|
|
s.set_selection(ids.clone());
|
|
Ok(json!({"selection": ids}))
|
|
}
|
|
|
|
pub(super) fn select_sibling(s: &mut Session, dir: i64) -> Result<Value> {
|
|
let doc = s.doc()?;
|
|
let sel = s.selection();
|
|
let Some(cur) = sel.last().copied() else {
|
|
let first = doc.get(s.page()?)?.children.last().copied();
|
|
s.set_selection(first.into_iter().collect());
|
|
return Ok(json!({"selection": s.selection()}));
|
|
};
|
|
let parent = doc.get(cur)?.parent.ok_or_else(|| bad("select.next", "no parent"))?;
|
|
let kids = &doc.get(parent)?.children;
|
|
let idx = kids.iter().position(|c| *c == cur).unwrap_or(0) as i64;
|
|
let n = kids.len() as i64;
|
|
// Layers panel order is reversed (top of the list = last child): Tab goes down the list.
|
|
let next = (idx - dir).rem_euclid(n.max(1)) as usize;
|
|
let id = kids.get(next).copied().unwrap_or(cur);
|
|
s.set_selection(vec![id]);
|
|
Ok(json!({"selection": [id]}))
|
|
}
|
|
|
|
pub(super) fn select_matching(s: &mut Session, _p: &Value) -> Result<Value> {
|
|
let doc = s.doc()?;
|
|
let sel = s.selection();
|
|
let Some(first) = sel.first().and_then(|i| doc.node(*i)) else { return Ok(json!({"selection": []})) };
|
|
let (name, ty) = (first.name.clone(), first.kind.type_name());
|
|
let page = s.page()?;
|
|
let ids: Vec<NodeId> =
|
|
doc.subtree(page).into_iter().filter(|i| doc.node(*i).is_some_and(|n| n.name == name && n.kind.type_name() == ty)).collect();
|
|
s.set_selection(ids.clone());
|
|
Ok(json!({"selection": ids}))
|
|
}
|
|
|
|
pub(super) fn select_same(s: &mut Session, same: impl Fn(&Node, &Node) -> bool) -> Result<Value> {
|
|
let doc = s.doc()?;
|
|
let sel = s.selection();
|
|
let Some(first) = sel.first().and_then(|i| doc.node(*i)).cloned() else { return Ok(json!({"selection": []})) };
|
|
let page = s.page()?;
|
|
let ids: Vec<NodeId> = doc.subtree(page).into_iter().filter(|i| *i != page && doc.node(*i).is_some_and(|n| same(&first, n))).collect();
|
|
s.set_selection(ids.clone());
|
|
Ok(json!({"selection": ids}))
|
|
}
|
|
|
|
/// The opacity number keys give: one digit is tens of percent (0 is 100%), two digits typed in
|
|
/// quick succession the exact percentage.
|
|
pub fn opacity_from_digits(digits: &str) -> Option<f32> {
|
|
let mut n = 0u32;
|
|
let mut count = 0;
|
|
for c in digits.chars() {
|
|
// Input-derived: a number that outgrows `u32` stops the scan (None); it never wraps.
|
|
n = n.checked_mul(10)?.checked_add(c.to_digit(10)?)?;
|
|
count += 1;
|
|
}
|
|
match count {
|
|
1 if n == 0 => Some(1.0),
|
|
1 => Some(n as f32 / 10.0),
|
|
2 => Some(n as f32 / 100.0),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub(super) fn opacity_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let digits = req_string("object.opacityKeys", p, "digits")?;
|
|
let o = opacity_from_digits(&digits).ok_or_else(|| bad("object.opacityKeys", "`digits` must be one or two digits"))?;
|
|
let ids = req_targets("object.opacityKeys", s, p)?;
|
|
let doc = s.doc_mut()?;
|
|
let before = crate::cmd::styles::bound_before(doc, &ids);
|
|
for i in ids {
|
|
doc.get_mut(i)?.opacity = o;
|
|
}
|
|
crate::cmd::styles::detach_edited(doc, &before);
|
|
Ok(json!({"opacity": o}))
|
|
}
|
|
|
|
pub(super) fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let big = boolean(p, "big").unwrap_or(false);
|
|
let k = if big { s.prefs.nudge_big } else { s.prefs.nudge_small };
|
|
let (ux, uy) = (num_or(p, "dx", 0.0), num_or(p, "dy", 0.0));
|
|
let (dx, dy) = (ux * k, uy * k);
|
|
let ids = req_targets("transform.nudge", s, p)?;
|
|
// Edit a copy and keep it only if something moved, so an arrow that can't move anything adds
|
|
// no undo step.
|
|
let mut work = s.doc()?.clone();
|
|
let doc = &mut work;
|
|
// 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 {
|
|
if crate::al_keys::flowing_parent(doc, *i).is_none() && (dx, dy) != (0.0, 0.0) {
|
|
let n = doc.get_mut(*i)?;
|
|
n.transform = Affine::translate((dx, dy)) * n.transform;
|
|
changed = true;
|
|
}
|
|
}
|
|
for dir in crate::al_keys::Dir::from_units(ux, uy) {
|
|
// Grouped per arrow: a move into a neighbouring frame changes the parent.
|
|
let mut groups: BTreeMap<NodeId, Vec<NodeId>> = BTreeMap::new();
|
|
for i in &ids {
|
|
if let Some(parent) = crate::al_keys::flowing_parent(doc, *i) {
|
|
groups.entry(parent).or_default().push(*i);
|
|
}
|
|
}
|
|
for (parent, block) in groups {
|
|
changed |= crate::al_keys::arrow_move(doc, parent, &block, dir)?;
|
|
}
|
|
}
|
|
if changed {
|
|
s.ds_mut()?.doc = std::sync::Arc::new(work);
|
|
}
|
|
ok()
|
|
}
|
|
|
|
pub(super) fn nudge_size(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let big = boolean(p, "big").unwrap_or(false);
|
|
let k = if big { s.prefs.nudge_big } else { s.prefs.nudge_small };
|
|
let (dw, dh) = (num_or(p, "dw", 0.0) * k, num_or(p, "dh", 0.0) * k);
|
|
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]));
|
|
// Only the axis that changes becomes fixed.
|
|
if dw != 0.0 {
|
|
q.insert("width".into(), json!((w + dw).max(0.0)));
|
|
}
|
|
if dh != 0.0 {
|
|
q.insert("height".into(), json!((h + dh).max(0.0)));
|
|
}
|
|
crate::cmd::arrange::resize_cmd(s, &Value::Object(q))?;
|
|
}
|
|
ok()
|
|
}
|
|
|
|
pub(super) fn toggle(s: &mut Session, p: &Value, what: &str) -> Result<Value> {
|
|
let ids = req_targets("object.toggle", s, p)?;
|
|
let doc = s.doc_mut()?;
|
|
let explicit = boolean(p, what);
|
|
// 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)),
|
|
// 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)),
|
|
});
|
|
// 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;
|
|
} else {
|
|
n.locked = value;
|
|
}
|
|
}
|
|
if what == "locked" && value {
|
|
s.set_selection(vec![]);
|
|
}
|
|
Ok(json!({what: value}))
|
|
}
|
|
|
|
pub(super) fn hide_others(s: &mut Session, _p: &Value) -> Result<Value> {
|
|
let sel = s.selection();
|
|
let page = s.page()?;
|
|
let doc = s.doc_mut()?;
|
|
let keep: BTreeSet<NodeId> = sel
|
|
.iter()
|
|
.flat_map(|i| {
|
|
let mut v = doc.ancestors(*i);
|
|
v.push(*i);
|
|
v
|
|
})
|
|
.collect();
|
|
let tops: Vec<NodeId> = doc.get(page)?.children.clone();
|
|
for t in tops {
|
|
if !keep.contains(&t) {
|
|
doc.get_mut(t)?.visible = false;
|
|
}
|
|
}
|
|
ok()
|
|
}
|