M1: keyboard transforms: arrows reorder auto layout children, Cmd+arrows resize (#6)

- Arrow keys on children of an auto layout frame move them one place along the flow (Left/Up
  earlier, Right/Down later; grids step by a row vertically); selected neighbours move as a
  block and stop at the ends. Free layers still nudge.
- Cmd/Ctrl+arrows resize the selection by the nudge amount (Shift: big nudge), top-left fixed;
  only the changed axis becomes Fixed. New command `transform.nudgeSize {dw, dh, big?}`.
- Control channel `ui.key` arrows with `cmd` resize too.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 02:42:17 +07:00 committed by GitHub
parent 51fada8d8f
commit 14435acb63
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 153 additions and 13 deletions

View File

@ -119,7 +119,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Area | Weight | Score | Remaining work | Hours |
|---|---|---|---|---|
| Canvas & navigation | 6 | 60% | rulers/guides dragging, pixel preview, minimap-free perf at scale | 8–12 |
| Selection & transform | 8 | 55% | rotated multi-select handles, scale tool polish, measure hover polish, nudge-resize | 12–18 |
| Selection & transform | 8 | 60% | rotated multi-select handles, scale tool polish, measure hover polish | 10–15 |
| Layers panel | 5 | 70% | drag polish (auto-scroll while dragging, drop into collapsed groups), keyboard row navigation | 4–6 |
| Shapes & vector | 8 | 55% | eraser, variable width, shape builder, multi-point transform box, point snapping, live boolean editing, arc handles on canvas | 15–22 |
| Paint | 7 | 55% | gradient handles on canvas, image crop mode, document colours, colour models | 12–18 |
@ -135,7 +135,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Dev mode / inspect | 2 | 35% | measurements on canvas, code variants (Swift/Compose), assets | 6–10 |
| Comments, versions, misc | 3 | 25% | comments, version history UI, plugins (WASM) | 15–25 |
Weighted feature depth ≈ **48%**.
Weighted feature depth ≈ **49%**.
## Milestones

View File

@ -397,7 +397,7 @@ fn scale_cmd(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("transform.resize", s, p)?;
let anchor = string(p, "anchor").unwrap_or_else(|| "tl".into());
let (ax, ay) = match anchor.as_str() {

View File

@ -117,7 +117,20 @@ pub fn specs() -> Vec<CommandSpec> {
select_and_page(s, ids.clone())?;
Ok(json!({"selection": ids}))
}),
spec!("transform.nudge", "Nudge", "{dx, dy, big? (use the big nudge amount)}", true, nudge),
spec!(
"transform.nudge",
"Nudge",
"{dx, dy, big? (use the big nudge amount)} (auto layout children move one place along the flow instead)",
true,
nudge
),
spec!(
"transform.nudgeSize",
"Resize by nudge",
"{dw, dh, big?} (grow or shrink the selection by the nudge amount, top-left fixed)",
true,
nudge_size
),
spec!("object.rename", "Rename", "{id?, name}", true, |s, p| {
let name = req_string("object.rename", p, "name")?;
let ids = req_targets("object.rename", s, p)?;
@ -666,13 +679,91 @@ fn select_same(s: &mut Session, same: impl Fn(&Node, &Node) -> bool) -> Result<V
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 dx = num_or(p, "dx", 0.0) * k;
let dy = num_or(p, "dy", 0.0) * k;
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)?;
let doc = s.doc_mut()?;
for i in roots(doc, &ids) {
let n = doc.get_mut(i)?;
n.transform = Affine::translate((dx, dy)) * n.transform;
let ids = roots(doc, &ids);
// Children in an auto layout flow move one place instead (Left/Up earlier, Right/Down later).
let flowing = |doc: &Document, i: NodeId| {
let n = doc.node(i)?;
let parent = n.parent?;
let l = doc.node(parent)?.layout.clone()?;
(!n.child_layout.absolute).then_some((parent, l))
};
let mut parents: BTreeMap<NodeId, AutoLayout> = BTreeMap::new();
for i in &ids {
match flowing(doc, *i) {
Some((parent, l)) => {
parents.insert(parent, l);
}
None => {
let n = doc.get_mut(*i)?;
n.transform = Affine::translate((dx, dy)) * n.transform;
}
}
}
for (parent, l) in parents {
// (`f64::signum` is 1 for 0.0, so compare explicitly.)
let sign = |v: f64| -> isize {
if v > 0.0 {
1
} else if v < 0.0 {
-1
} else {
0
}
};
let step: isize = match l.mode {
LayoutMode::Vertical => sign(uy),
LayoutMode::Grid => sign(ux) + sign(uy) * l.grid_columns.clamp(1, 1000) as isize,
_ => sign(ux),
};
reorder_in_flow(doc, parent, &ids, step)?;
}
ok()
}
/// Move the selected children of `parent` `step` places along its child order (selected
/// neighbours move together; the block stops at either end).
fn reorder_in_flow(doc: &mut Document, parent: NodeId, selected: &[NodeId], step: isize) -> Result<()> {
if step == 0 {
return Ok(());
}
let mut kids = doc.get(parent)?.children.clone();
for _ in 0..step.unsigned_abs().min(kids.len()) {
let forward = step > 0;
let order: Vec<usize> = if forward { (0..kids.len()).rev().collect() } else { (0..kids.len()).collect() };
for i in order {
let j = if forward { i + 1 } else { i.wrapping_sub(1) };
let (Some(a), Some(b)) = (kids.get(i).copied(), kids.get(j).copied()) else { continue };
if selected.contains(&a) && !selected.contains(&b) {
kids.swap(i, j);
}
}
}
doc.get_mut(parent)?.children = kids;
Ok(())
}
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 {
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)));
}
super::arrange::resize_cmd(s, &Value::Object(q))?;
}
ok()
}

View File

@ -343,3 +343,42 @@ fn vector_edit_mode() {
s.execute("vector.edit.end", &json!({})).unwrap();
assert!(s.ds().unwrap().vector_edit.is_none());
}
#[test]
fn arrows_reorder_auto_layout_children_and_cmd_arrows_resize() {
let mut s = sess();
let row = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 40})).unwrap());
let kids: Vec<NodeId> =
(0..3).map(|i| id(&s.execute("shape.rectangle", &json!({"x": i * 60, "y": 0, "width": 40, "height": 20, "parent": row})).unwrap())).collect();
s.execute("layout.add", &json!({"ids": [row]})).unwrap();
let order = |s: &Session| s.doc().unwrap().node(row).unwrap().children.clone();
assert_eq!(order(&s), kids);
// Right moves the first child one place later; Down does nothing in a horizontal flow.
s.execute("transform.nudge", &json!({"ids": [kids[0]], "dx": 1, "dy": 0})).unwrap();
assert_eq!(order(&s), vec![kids[1], kids[0], kids[2]]);
s.execute("transform.nudge", &json!({"ids": [kids[0]], "dx": 0, "dy": 1})).unwrap();
assert_eq!(order(&s), vec![kids[1], kids[0], kids[2]]);
// A selected block moves together and stops at the end.
s.execute("transform.nudge", &json!({"ids": [kids[1], kids[0]], "dx": 1, "dy": 0, "big": true})).unwrap();
assert_eq!(order(&s), vec![kids[2], kids[1], kids[0]]);
s.execute("transform.nudge", &json!({"ids": [kids[0]], "dx": -1, "dy": 0})).unwrap();
assert_eq!(order(&s), vec![kids[2], kids[0], kids[1]]);
s.undo().unwrap();
assert_eq!(order(&s), vec![kids[2], kids[1], kids[0]]);
// Free layers still nudge.
let free = id(&s.execute("shape.rectangle", &json!({"x": 500, "y": 500, "width": 10, "height": 10})).unwrap());
s.execute("transform.nudge", &json!({"ids": [free], "dx": 1, "dy": 0, "big": true})).unwrap();
assert_eq!(s.doc().unwrap().world_bounds(free).unwrap().x0, 510.0);
// Cmd+arrows: resize by the nudge amount, top-left fixed, only the changed axis becomes fixed.
s.execute("transform.nudgeSize", &json!({"ids": [free], "dw": 1, "dh": 0})).unwrap();
let n = s.doc().unwrap().node(free).unwrap().clone();
assert_eq!((n.width, n.height), (11.0, 10.0));
assert_eq!(s.doc().unwrap().world_bounds(free).unwrap().x0, 510.0);
s.execute("transform.nudgeSize", &json!({"ids": [free], "dw": 0, "dh": -1, "big": true})).unwrap();
assert_eq!(s.doc().unwrap().node(free).unwrap().height, 0.0, "never negative");
// A hugging auto layout frame keeps hugging vertically when only its width is nudged.
s.execute("node.set", &json!({"id": row, "props": {"sizingH": "hug", "sizingV": "hug"}})).unwrap();
s.execute("transform.nudgeSize", &json!({"ids": [row], "dw": 1, "dh": 0, "big": true})).unwrap();
let r = s.doc().unwrap().node(row).unwrap().clone();
assert_eq!(r.child_layout.sizing_v, figcraft_doc::Sizing::Hug);
}

View File

@ -171,7 +171,12 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
"ArrowUp" => (0, -1),
_ => (0, 1),
};
app.session.execute("transform.nudge", &json!({"dx": dx, "dy": dy, "big": b(p, "shift")})).map_err(|e| e.to_string())
let (cmd, params) = if b(p, "cmd") {
("transform.nudgeSize", json!({"dw": dx, "dh": dy, "big": b(p, "shift")}))
} else {
("transform.nudge", json!({"dx": dx, "dy": dy, "big": b(p, "shift")}))
};
app.session.execute(cmd, &params).map_err(|e| e.to_string())
}
_ => Err(format!("no shortcut `{spec}`")),
},

View File

@ -144,9 +144,14 @@ fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool) -> bool {
Key::ArrowDown => (0.0, 1.0),
_ => (0.0, 0.0),
};
if (dx, dy) != (0.0, 0.0) && !m.command && !m.alt {
if (dx, dy) != (0.0, 0.0) && !m.alt {
if !app.session.selection().is_empty() {
app.run("transform.nudge", json!({"dx": dx, "dy": dy, "big": m.shift}));
if m.command {
// Cmd/Ctrl+arrows resize: right/down grow, left/up shrink.
app.run("transform.nudgeSize", json!({"dw": dx, "dh": dy, "big": m.shift}));
} else {
app.run("transform.nudge", json!({"dx": dx, "dy": dy, "big": m.shift}));
}
}
return true;
}

View File

@ -76,7 +76,7 @@ A mouse gesture with the current tool. `kind`: `down`, `move`, `up`, `click`, `d
### `ui.key {key, shift?, alt?, cmd?, ctrl?}`
A key as if typed on the canvas (in vector edit mode the edit keys come first: `V` `P` `Q` `X` `Shift+B` tools, arrows nudge points, `Delete`/`Shift+Delete`, `Cmd+A`, `Cmd+J`): a menu shortcut (`{"key":"G","cmd":true}` groups), a tool key,
`Escape`, `Delete`/`Backspace`, `Enter`, arrows (nudge; shift = big nudge). While text is being
`Escape`, `Delete`/`Backspace`, `Enter`, arrows (nudge; shift = big nudge; auto layout children move one place along the flow; with `cmd` they resize the selection). While text is being
edited, keys go to the text editor. Returns `{"command": id}` for shortcuts.
### `ui.type {text}`