From 14435acb635eea5bdd2d4c3b7f52fef6d163e436 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 02:42:17 +0700 Subject: [PATCH] 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 --- ROADMAP.md | 4 +- crates/engine/src/cmd/arrange.rs | 2 +- crates/engine/src/cmd/edit.rs | 103 +++++++++++++++++++++++++++++-- crates/engine/src/tests.rs | 39 ++++++++++++ crates/ui-egui/src/control.rs | 7 ++- crates/ui-egui/src/shortcuts.rs | 9 ++- docs/control-protocol.md | 2 +- 7 files changed, 153 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 49255ae..0388391 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index c5db6f0..d87702e 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -397,7 +397,7 @@ fn scale_cmd(s: &mut Session, p: &Value) -> Result { ok() } -fn resize_cmd(s: &mut Session, p: &Value) -> Result { +pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result { 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() { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 6f27056..678f30e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -117,7 +117,20 @@ pub fn specs() -> Vec { 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 Result { 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 = 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 = 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 { + 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() } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ef97362..2cbd260 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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 = + (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); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2293762..4316b3d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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, ¶ms).map_err(|e| e.to_string()) } _ => Err(format!("no shortcut `{spec}`")), }, diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 49585f8..715e260 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -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; } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 44c22a3..bf8e4b5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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}`