mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 11:56:50 -06:00
The last two documented shortcuts the parity table listed as missing, and a stale toast.
- **Alt+R: rotation origin** (both platforms). `transform.rotationOrigin {on?}` shows (or hides) a
handle at the selection's rotation origin: a ring with a cross through it, drawn in code. Drag it
to move the origin (it snaps to the selection box's corners, edge middles and centre within the
snap distance); double-click it to put it back at the centre; Escape hides it before it deselects,
and Alt+R again hides it too. `transform.setRotationOrigin {x, y} | {reset: true}` does the same
from the CLI, MCP and the control channel. The corner rotation handles, the rotation field
(`node.set {rotation}` on the selection: each layer's point under the origin stays put, so flipped
layers work too), Rotate 90/180 and `transform.rotate` without `cx`/`cy` turn about it. The
origin is stored on the document state per selection, in the first layer's own coordinates, so it
follows the layers as they move and turn; it is dropped when the selection changes and is not
saved in the file (the public documentation doesn't say it persists; pinned in the parity row).
- **Enter places an object with the chosen tool.** With a shape or frame tool chosen, Enter on the
canvas, or Enter again on the toolbar's chosen tool (after picking it from the toolbar with the
keyboard), places what a click with the tool makes (100x100; lines 100 long; sections 400x300, the
existing click sizes, now one `ToolId::click_size`) centred in the view, selected, as one undo
step; the canvas then takes the keyboard. New `tool.place {tool?, x?, y?}`; MCP's headless `key
Enter` does the same. The documented crosshair that the arrows move before placing is not built.
- **Keyboard box selection toast** (and its screen-reader text) now names both ways to size the box:
"Shift+arrows or Cmd+arrows" (Ctrl on Windows and Linux). The changed key is renamed in every
catalog with its translation updated.
- **Shortcuts panel labels:** a label too long for its column was measured in a smaller font than
it is drawn in, so long ones (several Spanish rows) ran into their keycaps; it is now measured in
its own font, ends with "..." before the keycaps and shows in full on hover. The new Tools row
reads "Place a shape or frame (its tool chosen)" with an Enter keycap.
- **Parity table:** the two rows move from Missing to bound (Alt+R -> `transform.rotationOrigin`;
Enter -> a Tools-tab gesture row, the keymap's Enter staying `select.children`); counts 129
documented, 128 bound, 1 moved, 0 missing, 2 kept by a platform, 48 pinned.
- New command labels in Spanish (`tool.place`, `transform.rotationOrigin`,
`transform.setRotationOrigin`), new UI strings in English and Spanish (es-419 overlays "Enter").
- Docs: ui-parity.md (selection and transform rows, shortcut counts, header, revision row),
mcp.md (the new commands).
137 lines
7.0 KiB
Rust
137 lines
7.0 KiB
Rust
//! The rotation origin (Alt+R) and placing an object with Enter (`tool.place`), the two shortcuts
|
|
//! the shortcut parity table listed as missing.
|
|
|
|
use serde_json::json;
|
|
|
|
use crate::Session;
|
|
use crate::params::id_of;
|
|
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
|
use figcraft_doc::NodeId;
|
|
use figcraft_geom::Point;
|
|
|
|
fn rect(s: &mut Session) -> NodeId {
|
|
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"]).unwrap();
|
|
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
|
r
|
|
}
|
|
|
|
fn ptr(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
|
|
s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap();
|
|
}
|
|
|
|
fn near(a: Point, b: Point) -> bool {
|
|
(a - b).hypot() < 1e-6
|
|
}
|
|
|
|
fn bounds(s: &Session, id: NodeId) -> [f64; 4] {
|
|
let b = s.doc().unwrap().world_bounds(id).unwrap();
|
|
[b.x0, b.y0, b.x1, b.y1]
|
|
}
|
|
|
|
fn close(a: [f64; 4], b: [f64; 4]) -> bool {
|
|
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
|
|
}
|
|
|
|
#[test]
|
|
fn origin_handle_drags_snaps_and_turns_the_handles_rotation() {
|
|
let mut s = Session::new();
|
|
let r = rect(&mut s);
|
|
assert_eq!(s.execute("transform.rotationOrigin", &json!({})).unwrap()["editing"], true, "Alt+R shows it");
|
|
assert!(near(s.ds().unwrap().origin_handle().unwrap(), Point::new(50.0, 50.0)), "at the centre");
|
|
// Dragged from the centre to near the top-left corner: it snaps there, and the layer stays.
|
|
ptr(&mut s, PointerKind::Down, 50.0, 50.0);
|
|
ptr(&mut s, PointerKind::Move, 3.0, 2.0);
|
|
ptr(&mut s, PointerKind::Up, 3.0, 2.0);
|
|
assert!(near(s.ds().unwrap().moved_origin().unwrap(), Point::ZERO));
|
|
assert!(close(bounds(&s, r), [0.0, 0.0, 100.0, 100.0]), "the press was the handle's, not a move");
|
|
// The corner rotation zone turns about it: a quarter turn clockwise leaves the corner in place.
|
|
ptr(&mut s, PointerKind::Down, -10.0, -10.0);
|
|
ptr(&mut s, PointerKind::Move, 10.0, -10.0);
|
|
ptr(&mut s, PointerKind::Up, 10.0, -10.0);
|
|
assert!(close(bounds(&s, r), [-100.0, 0.0, 0.0, 100.0]), "{:?}", bounds(&s, r));
|
|
assert!(near(s.ds().unwrap().moved_origin().unwrap(), Point::ZERO), "it turned with the layer");
|
|
// Moving the layer takes the origin along.
|
|
s.execute("transform.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
|
assert!(near(s.ds().unwrap().moved_origin().unwrap(), Point::new(10.0, 0.0)));
|
|
// A double-click on it puts it back at the centre; Escape hides it, then deselects as before.
|
|
ptr(&mut s, PointerKind::DoubleClick, 10.0, 0.0);
|
|
assert!(s.ds().unwrap().moved_origin().is_none());
|
|
assert!(near(s.ds().unwrap().origin_handle().unwrap(), Point::new(-40.0, 50.0)));
|
|
s.escape().unwrap();
|
|
assert!(s.ds().unwrap().origin_handle().is_none());
|
|
assert_eq!(s.selection(), vec![r], "the first Escape only hid the handle");
|
|
}
|
|
|
|
#[test]
|
|
fn rotation_field_and_rotate_commands_turn_about_a_moved_origin() {
|
|
let mut s = Session::new();
|
|
let r = rect(&mut s);
|
|
s.execute("transform.setRotationOrigin", &json!({"x": 100, "y": 100})).unwrap();
|
|
// The rotation field (node.set) keeps the bottom-right corner where it is.
|
|
s.execute("node.set", &json!({"ids": [r], "props": {"rotation": 30}})).unwrap();
|
|
let doc = s.doc().unwrap();
|
|
assert!(near(doc.world_transform(r) * Point::new(100.0, 100.0), Point::new(100.0, 100.0)));
|
|
assert!((figcraft_geom::rotation_degrees(doc.node(r).unwrap().transform) - 30.0).abs() < 1e-6);
|
|
// So do Rotate 90° and transform.rotate without a pivot.
|
|
s.execute("object.rotateLeft", &json!({})).unwrap();
|
|
s.execute("transform.rotate", &json!({"degrees": 15})).unwrap();
|
|
let doc = s.doc().unwrap();
|
|
assert!(near(doc.world_transform(r) * Point::new(100.0, 100.0), Point::new(100.0, 100.0)));
|
|
assert!((figcraft_geom::rotation_degrees(doc.node(r).unwrap().transform) - 135.0).abs() < 1e-6);
|
|
// Another layer's field turns about its own centre, as before.
|
|
let other = id_of(&s.execute("shape.rectangle", &json!({"x": 300, "y": 0, "width": 100, "height": 100})).unwrap()["id"]).unwrap();
|
|
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
|
s.execute("node.set", &json!({"ids": [other], "props": {"rotation": 45}})).unwrap();
|
|
let c = s.doc().unwrap().world_bounds(other).unwrap().center();
|
|
assert!(near(c, Point::new(350.0, 50.0)));
|
|
// Reset puts it back at the centre.
|
|
let v = s.execute("transform.setRotationOrigin", &json!({"reset": true})).unwrap();
|
|
assert_eq!(v["moved"], false);
|
|
assert!(s.execute("transform.setRotationOrigin", &json!({"x": 1})).is_err(), "x without y");
|
|
}
|
|
|
|
#[test]
|
|
fn origin_lasts_as_long_as_its_selection() {
|
|
let mut s = Session::new();
|
|
let r = rect(&mut s);
|
|
s.execute("transform.rotationOrigin", &json!({"on": true})).unwrap();
|
|
s.execute("transform.setRotationOrigin", &json!({"x": 0, "y": 0})).unwrap();
|
|
// Undo of an edit that keeps the selection keeps it.
|
|
s.execute("transform.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert!(near(s.ds().unwrap().moved_origin().unwrap(), Point::ZERO));
|
|
// Another selection drops it, and selecting the layer again doesn't bring it back.
|
|
s.execute("select.set", &json!({"ids": []})).unwrap();
|
|
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
|
assert!(s.ds().unwrap().moved_origin().is_none() && s.ds().unwrap().origin_handle().is_none());
|
|
// Nothing selected: refused.
|
|
s.execute("select.set", &json!({"ids": []})).unwrap();
|
|
assert!(s.execute("transform.rotationOrigin", &json!({})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn enter_places_a_click_sized_object_in_the_middle_of_the_view() {
|
|
let mut s = Session::new();
|
|
s.execute("view.set", &json!({"cx": 500, "cy": 300, "zoom": 1})).unwrap();
|
|
s.execute("tool.set", &json!({"tool": "rectangle"})).unwrap();
|
|
let undo = s.ds().unwrap().history.undo.len();
|
|
let v = s.execute("tool.place", &json!({})).unwrap();
|
|
let id = id_of(&v["ids"][0]).unwrap();
|
|
assert_eq!(s.selection(), vec![id], "selected");
|
|
assert!(close(bounds(&s, id), [450.0, 250.0, 550.0, 350.0]), "{:?}", bounds(&s, id));
|
|
assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1, "one undo step");
|
|
assert_eq!(s.tool.tool, ToolId::Move, "back to Move, as after a click");
|
|
// A frame at a given point; a line a click's length.
|
|
let f = id_of(&s.execute("tool.place", &json!({"tool": "frame", "x": 0, "y": 0})).unwrap()["ids"][0]).unwrap();
|
|
assert!(close(bounds(&s, f), [-50.0, -50.0, 50.0, 50.0]));
|
|
assert!(s.doc().unwrap().node(f).unwrap().kind.is_frame_like());
|
|
let l = id_of(&s.execute("tool.place", &json!({"tool": "line"})).unwrap()["ids"][0]).unwrap();
|
|
let b = bounds(&s, l);
|
|
assert!((b[0] - 450.0).abs() < 1e-6 && (b[2] - 550.0).abs() < 1e-6 && (b[1] - 300.0).abs() < 1e-6, "{b:?}");
|
|
// Not a shape tool: refused, nothing made.
|
|
let n = s.doc().unwrap().node_count();
|
|
assert!(s.execute("tool.place", &json!({})).is_err());
|
|
assert!(s.execute("tool.place", &json!({"tool": "text"})).is_err());
|
|
assert_eq!(s.doc().unwrap().node_count(), n);
|
|
}
|