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:
parent
51fada8d8f
commit
14435acb63
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`")),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
Loading…
Reference in New Issue