mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
M1: selection and transform behaviour suite and fixes (#133)
A selection and transform behaviour suite derived from public documentation, with fixes: Alt-resizing shrinks as well as grows; groups, Scale-constrained frames and multiple selections resize each layer along its own axes keeping rotation and flip; several layers flip together past an edge; a flipped layer reads back the typed rotation; resizes land on whole pixels with the pixel grid; presses inside tiny layers move them; locked layers get no handles; a marquee can start on a top-level frame's background; select inverse skips hidden layers. Adds equal-spacing snapping with labelled gaps and Cmd/Ctrl to ignore constraints while resizing a frame.
This commit is contained in:
+4
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~173–310 h away (one Opus 5.5 agent; ~35–70 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -56,7 +56,7 @@ Figma's own languages on the landed localization layer (15–25 h).
|
||||
|
||||
| Dimension | Ready | Hours | Doc |
|
||||
|---|---|---|---|
|
||||
| Features (depth) | 57% (core editor 67%) | 60–115 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Features (depth) | 57% (core editor 68%) | 60–115 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
|
||||
| File formats | 55% (`.fig` read best effort, no write) | 8–15 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 49–98 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
@@ -73,7 +73,7 @@ Figma's own languages on the landed localization layer (15–25 h).
|
||||
| Area | Breadth | Ready | Hours | Doc |
|
||||
|---|---|---|---|---|
|
||||
| Canvas & navigation | 95% | 72% | 3–6 | [ui-parity](docs/ui-parity.md) |
|
||||
| Selection & transform | 95% | 68% | 6–10 | [ui-parity](docs/ui-parity.md) |
|
||||
| Selection & transform | 97% | 72% | 3–5 | [ui-parity](docs/ui-parity.md) |
|
||||
| Layers panel | 95% | 78% | 2–4 | [ui-parity](docs/ui-parity.md) |
|
||||
| Shapes & vector | 97% | 68% | 1–3 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Paint | 85% | 66% | 5–9 | [target-app-parity](docs/target-app-parity.md) |
|
||||
@@ -195,6 +195,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h |
|
||||
| 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 in the desktop app and through MCP; eight fixes: recovery copies of unsaved documents reopen after a crash or a forced quit (`file.recover`), Ctrl moves and deletes by word in text on Windows and Linux, scrubbed fields keep their value, typing on a field Tab reached edits it, Escape closes dialogs, linear gradients keep square bands, panel hints wrap, the window-close question and "Open in new window" are translated. Percentages and hours unchanged |
|
||||
| 2026-10-11 | minor | Auto layout behaviour suite written from the public documentation (64 layout cases, 8 command tests) and its fixes: a frame is never smaller than its padding, only the frame's inside stroke counts when strokes are included (and new auto layout includes them), auto gaps stop at 0, grid `fr` tracks behave like the CSS `fr` unit (a track never gets less than its content), canvas stacking is drawn, exported and hit (it was stored only). Added: auto gaps evenly and around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Auto layout 95%/68% → 98%/74%, 6–12 h → 3–6 h; percentages unchanged at this rounding; full parity ~348–672 h → ~345–666 h |
|
||||
| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h |
|
||||
|
||||
@@ -677,12 +677,13 @@ fn select_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({"selection": ids}))
|
||||
}
|
||||
|
||||
/// What Select all would select, less the selection (hidden and locked layers neither).
|
||||
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.locked)).collect();
|
||||
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}))
|
||||
}
|
||||
|
||||
@@ -1309,6 +1309,8 @@ mod section_contents_tests;
|
||||
#[cfg(test)]
|
||||
mod section_tests;
|
||||
#[cfg(test)]
|
||||
mod selection_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod selection_depth_tests;
|
||||
#[cfg(test)]
|
||||
mod shapebuilder_strokes_tests;
|
||||
@@ -1331,6 +1333,8 @@ mod text_style_picker_tests;
|
||||
#[cfg(test)]
|
||||
mod tiff_tests;
|
||||
#[cfg(test)]
|
||||
mod transform_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod variable_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_editor_tests;
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Selection behaviour suite, written from the public documentation of design tools: each rule
|
||||
//! is a table row with the selection it implies and a one-line derivation. Where the
|
||||
//! documentation is silent the row says so and pins today's behaviour.
|
||||
|
||||
// The tables read best as tuples.
|
||||
#![allow(clippy::type_complexity)]
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use crate::tools::{PointerEvent, PointerKind, handle_at};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_geom::Point;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn id(v: &Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, parent: Option<NodeId>) -> NodeId {
|
||||
let mut p = json!({"x": x, "y": y, "width": 40, "height": 40});
|
||||
if let Some(parent) = parent {
|
||||
p["parent"] = json!(parent);
|
||||
}
|
||||
id(&ex(s, "shape.rectangle", p))
|
||||
}
|
||||
|
||||
/// The layers of the scene, by name.
|
||||
#[allow(dead_code)]
|
||||
struct Scene {
|
||||
/// Top-level frame (0,0 400×300) holding: group `g` of `a` (20,20) and `b` (80,20); `c`
|
||||
/// (20,120); nested frame `n` (200,20 150×150) with `d` (220,40); boolean `u` of two
|
||||
/// rectangles at (20,200) and (40,200).
|
||||
f: NodeId,
|
||||
g: NodeId,
|
||||
a: NodeId,
|
||||
b: NodeId,
|
||||
c: NodeId,
|
||||
n: NodeId,
|
||||
d: NodeId,
|
||||
u: NodeId,
|
||||
/// On the page: `r` (500,0); `r2` (500,100) under the locked `lk` (same place); the hidden
|
||||
/// `hid` (500,200); the empty frame `empty` (600,0 80×80).
|
||||
r: NodeId,
|
||||
r2: NodeId,
|
||||
lk: NodeId,
|
||||
hid: NodeId,
|
||||
empty: NodeId,
|
||||
}
|
||||
|
||||
fn scene(s: &mut Session) -> Scene {
|
||||
let f = id(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300})));
|
||||
let a = rect(s, 20.0, 20.0, Some(f));
|
||||
let b = rect(s, 80.0, 20.0, Some(f));
|
||||
let g = id(&ex(s, "object.group", json!({"ids": [a, b]})));
|
||||
let c = rect(s, 20.0, 120.0, Some(f));
|
||||
let n = id(&ex(s, "frame.create", json!({"x": 200, "y": 20, "width": 150, "height": 150, "parent": f})));
|
||||
// Positions are in the parent's space.
|
||||
let d = rect(s, 20.0, 20.0, Some(n));
|
||||
let e1 = rect(s, 20.0, 200.0, Some(f));
|
||||
let e2 = rect(s, 40.0, 200.0, Some(f));
|
||||
let u = id(&ex(s, "bool.union", json!({"ids": [e1, e2]})));
|
||||
let r = rect(s, 500.0, 0.0, None);
|
||||
let r2 = rect(s, 500.0, 100.0, None);
|
||||
let lk = rect(s, 500.0, 100.0, None);
|
||||
ex(s, "object.toggleLock", json!({"ids": [lk], "locked": true}));
|
||||
let hid = rect(s, 500.0, 200.0, None);
|
||||
ex(s, "object.toggleVisible", json!({"ids": [hid], "visible": false}));
|
||||
let empty = id(&ex(s, "frame.create", json!({"x": 600, "y": 0, "width": 80, "height": 80})));
|
||||
ex(s, "select.none", json!({}));
|
||||
Scene { f, g, a, b, c, n, d, u, r, r2, lk, hid, empty }
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Mods {
|
||||
shift: bool,
|
||||
cmd: bool,
|
||||
}
|
||||
|
||||
const NONE: Mods = Mods { shift: false, cmd: false };
|
||||
const SHIFT: Mods = Mods { shift: true, cmd: false };
|
||||
const CMD: Mods = Mods { shift: false, cmd: true };
|
||||
|
||||
fn ev(kind: PointerKind, x: f64, y: f64, m: Mods) -> PointerEvent {
|
||||
let mut e = PointerEvent::at(kind, x, y, 1.0);
|
||||
(e.shift, e.cmd) = (m.shift, m.cmd);
|
||||
e
|
||||
}
|
||||
|
||||
/// A press and release without moving.
|
||||
fn click(s: &mut Session, x: f64, y: f64, m: Mods) {
|
||||
s.pointer(&ev(PointerKind::Down, x, y, m)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, x, y, m)).unwrap();
|
||||
}
|
||||
|
||||
fn drag(s: &mut Session, a: (f64, f64), b: (f64, f64), m: Mods) {
|
||||
s.pointer(&ev(PointerKind::Down, a.0, a.1, m)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, b.0, b.1, m)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, b.0, b.1, m)).unwrap();
|
||||
}
|
||||
|
||||
fn sorted(mut v: Vec<NodeId>) -> Vec<NodeId> {
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_selects_by_the_documented_rules() {
|
||||
let mut s = Session::new();
|
||||
let k = scene(&mut s);
|
||||
// (point, modifiers, selection before, selection after, derivation)
|
||||
let rows: Vec<((f64, f64), Mods, Vec<NodeId>, Vec<NodeId>, &str)> = vec![
|
||||
((520.0, 20.0), NONE, vec![], vec![k.r], "a click selects the layer under the pointer"),
|
||||
((30.0, 30.0), NONE, vec![], vec![k.g], "a group is selected as a whole"),
|
||||
((30.0, 130.0), NONE, vec![], vec![k.c], "a layer in a top-level frame is selected directly (frames hold their layers like the canvas)"),
|
||||
((230.0, 50.0), NONE, vec![], vec![k.n], "silent: a nested frame selects as a whole, like a group (pinned)"),
|
||||
((30.0, 210.0), NONE, vec![], vec![k.u], "a boolean group is selected as a whole"),
|
||||
((30.0, 30.0), CMD, vec![], vec![k.a], "Cmd/Ctrl-click selects the deepest layer"),
|
||||
((230.0, 50.0), CMD, vec![], vec![k.d], "Cmd/Ctrl-click reaches into nested frames"),
|
||||
((30.0, 210.0), CMD, vec![], vec![sel_operand(&s, k.u, 30.0)], "Cmd/Ctrl-click reaches a boolean's operand"),
|
||||
((90.0, 30.0), NONE, vec![k.a], vec![k.b], "with a layer selected, its siblings select at its level"),
|
||||
((520.0, 20.0), SHIFT, vec![k.c], vec![k.c, k.r], "Shift-click adds to the selection"),
|
||||
((30.0, 130.0), SHIFT, vec![k.c, k.r], vec![k.r], "Shift-click on a selected layer removes it"),
|
||||
((520.0, 120.0), NONE, vec![], vec![k.r2], "a locked layer can't be selected on the canvas: the click goes to what is under it"),
|
||||
((520.0, 220.0), NONE, vec![k.r], vec![], "a hidden layer can't be selected on the canvas (empty canvas deselects)"),
|
||||
((390.0, 290.0), NONE, vec![k.r], vec![], "the background of a top-level frame with layers is like the canvas: a click deselects"),
|
||||
((390.0, 290.0), NONE, vec![k.f], vec![k.f], "a selected top-level frame stays selected when its background is clicked"),
|
||||
((390.0, 290.0), CMD, vec![], vec![k.f], "silent: Cmd/Ctrl-click on a frame's background selects the frame (the deepest layer there)"),
|
||||
((640.0, 40.0), NONE, vec![], vec![k.empty], "an empty top-level frame is selected by a click on it"),
|
||||
];
|
||||
for (p, m, before, after, why) in rows {
|
||||
ex(&mut s, "select.set", json!({"ids": before}));
|
||||
click(&mut s, p.0, p.1, m);
|
||||
assert_eq!(sorted(s.selection()), sorted(after), "{why}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The operand of boolean `u` under x (its children are the two rectangles).
|
||||
fn sel_operand(s: &Session, u: NodeId, x: f64) -> NodeId {
|
||||
let doc = s.doc().unwrap();
|
||||
let kids = &doc.node(u).unwrap().children;
|
||||
// The later child paints on top: (40,200) covers x ≥ 40, else the first.
|
||||
if x >= 40.0 { kids[1] } else { kids[0] }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_enters_one_level() {
|
||||
let mut s = Session::new();
|
||||
let k = scene(&mut s);
|
||||
let comp_child = rect(&mut s, 800.0, 0.0, None);
|
||||
let comp = id(&ex(&mut s, "component.create", json!({"ids": [comp_child]})));
|
||||
let inst = id(&ex(&mut s, "instance.create", json!({"component": comp, "x": 300, "y": 200, "parent": k.f})));
|
||||
let inst_child = s.doc().unwrap().node(inst).unwrap().children[0];
|
||||
// (point, selection before, selection after, derivation)
|
||||
let rows: Vec<((f64, f64), Vec<NodeId>, Vec<NodeId>, &str)> = vec![
|
||||
((30.0, 30.0), vec![k.g], vec![k.a], "double-click on a group selects the layer inside it"),
|
||||
((230.0, 50.0), vec![k.n], vec![k.d], "double-click on a nested frame selects the layer inside it"),
|
||||
((30.0, 210.0), vec![k.u], vec![sel_operand(&s, k.u, 30.0)], "double-click on a boolean group selects its operand"),
|
||||
((320.0, 220.0), vec![inst], vec![inst_child], "double-click on an instance selects the layer inside it"),
|
||||
((30.0, 30.0), vec![], vec![k.a], "a double-click's first click selects the group, the second enters it"),
|
||||
];
|
||||
for (p, before, after, why) in rows {
|
||||
ex(&mut s, "select.set", json!({"ids": before}));
|
||||
click(&mut s, p.0, p.1, NONE);
|
||||
s.pointer(&ev(PointerKind::DoubleClick, p.0, p.1, NONE)).unwrap();
|
||||
assert_eq!(s.selection(), after, "{why}");
|
||||
}
|
||||
// A selected shape: vector edit mode.
|
||||
ex(&mut s, "select.set", json!({"ids": [k.r]}));
|
||||
s.pointer(&ev(PointerKind::DoubleClick, 520.0, 20.0, NONE)).unwrap();
|
||||
assert_eq!(s.ds().unwrap().vector_edit.as_ref().map(|v| v.id), Some(k.r), "double-click on a shape edits its points");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee_selects_what_it_touches() {
|
||||
let mut s = Session::new();
|
||||
let k = scene(&mut s);
|
||||
// (from, to, modifiers, selection before, selection after, derivation)
|
||||
let rows: Vec<((f64, f64), (f64, f64), Mods, Vec<NodeId>, Vec<NodeId>, &str)> = vec![
|
||||
((480.0, -20.0), (505.0, 10.0), NONE, vec![], vec![k.r], "a marquee selects layers it touches, not only those it contains"),
|
||||
((480.0, -20.0), (560.0, 260.0), NONE, vec![], vec![k.r, k.r2], "locked and hidden layers aren't selected by a marquee"),
|
||||
(
|
||||
(-10.0, -10.0),
|
||||
(450.0, 40.0),
|
||||
NONE,
|
||||
vec![],
|
||||
vec![],
|
||||
"from the canvas, a top-level frame is selected only when the marquee covers all of it",
|
||||
),
|
||||
((-10.0, -10.0), (450.0, 310.0), NONE, vec![], vec![k.f], "a marquee covering a top-level frame selects the frame"),
|
||||
(
|
||||
(390.0, 290.0),
|
||||
(25.0, 125.0),
|
||||
NONE,
|
||||
vec![],
|
||||
vec![k.c, k.n, k.u],
|
||||
"starting on a top-level frame's background selects the layers in it the marquee touches",
|
||||
),
|
||||
(
|
||||
(450.0, 290.0),
|
||||
(25.0, 25.0),
|
||||
CMD,
|
||||
vec![],
|
||||
vec![k.a, k.b, k.c, k.d, k.u],
|
||||
"silent: Cmd/Ctrl-marquee selects the innermost layers it touches (pinned)",
|
||||
),
|
||||
((480.0, -20.0), (505.0, 10.0), SHIFT, vec![k.c], vec![k.c, k.r], "Shift-marquee adds to the selection"),
|
||||
((480.0, -20.0), (505.0, 10.0), SHIFT, vec![k.c, k.r], vec![k.c], "Shift-marquee takes out what was selected and is touched again"),
|
||||
];
|
||||
for (a, b, m, before, after, why) in rows {
|
||||
ex(&mut s, "select.set", json!({"ids": before}));
|
||||
drag(&mut s, a, b, m);
|
||||
assert_eq!(sorted(s.selection()), sorted(after), "{why}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyboard_selection() {
|
||||
let mut s = Session::new();
|
||||
let k = scene(&mut s);
|
||||
let page_layers = |s: &Session| s.doc().unwrap().node(s.page().unwrap()).unwrap().children.clone();
|
||||
let visible_unlocked: Vec<NodeId> = page_layers(&s).into_iter().filter(|i| ![k.lk, k.hid].contains(i)).collect();
|
||||
// (command, selection before, selection after, derivation)
|
||||
let rows: Vec<(&str, Vec<NodeId>, Vec<NodeId>, &str)> = vec![
|
||||
("select.children", vec![k.g], vec![k.a, k.b], "Enter selects the children"),
|
||||
("select.parent", vec![k.a], vec![k.g], "Shift+Enter selects the parent"),
|
||||
("select.parent", vec![k.f], vec![k.f], "silent: Shift+Enter on a top-level layer keeps it (pinned)"),
|
||||
("select.next", vec![k.b], vec![k.a], "Tab selects the next layer down the layers list"),
|
||||
("select.previous", vec![k.a], vec![k.b], "Shift+Tab selects the previous one"),
|
||||
("select.all", vec![k.a], vec![k.a, k.b], "Select all selects the selection's siblings"),
|
||||
("select.all", vec![], visible_unlocked.clone(), "with nothing selected, all layers on the page (hidden and locked ones can't be selected)"),
|
||||
(
|
||||
"select.inverse",
|
||||
vec![k.r],
|
||||
visible_unlocked.iter().copied().filter(|i| *i != k.r).collect(),
|
||||
"Select inverse: the other layers Select all would select",
|
||||
),
|
||||
];
|
||||
for (cmd, before, after, why) in rows {
|
||||
ex(&mut s, "select.set", json!({"ids": before}));
|
||||
ex(&mut s, cmd, json!({}));
|
||||
assert_eq!(sorted(s.selection()), sorted(after), "{why}");
|
||||
}
|
||||
// Esc steps out to the parent, then deselects.
|
||||
ex(&mut s, "select.set", json!({"ids": [k.a]}));
|
||||
s.escape().unwrap();
|
||||
assert_eq!(s.selection(), vec![k.g], "Esc selects the parent");
|
||||
s.escape().unwrap();
|
||||
assert_eq!(s.selection(), vec![k.f]);
|
||||
s.escape().unwrap();
|
||||
assert_eq!(s.selection(), Vec::<NodeId>::new(), "Esc on a top-level layer deselects");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_layers_selected_in_the_layers_panel_stay_put() {
|
||||
let mut s = Session::new();
|
||||
let k = scene(&mut s);
|
||||
// Selectable from the layers panel (select.set), but the canvas offers no handles and a drag
|
||||
// that starts on another selected layer leaves it where it is.
|
||||
ex(&mut s, "select.set", json!({"ids": [k.lk]}));
|
||||
let doc = s.doc().unwrap().clone();
|
||||
let corner = Point::new(540.0, 140.0);
|
||||
assert_eq!(handle_at(&doc, &[k.lk], corner, 1.0), None, "a locked layer has no resize handles");
|
||||
assert!(handle_at(&doc, &[k.r2], corner, 1.0).is_some(), "the same box unlocked has");
|
||||
ex(&mut s, "select.set", json!({"ids": [k.lk, k.r]}));
|
||||
drag(&mut s, (520.0, 20.0), (530.0, 30.0), NONE);
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.world_bounds(k.r).unwrap().origin(), Point::new(510.0, 10.0), "the unlocked layer moves");
|
||||
assert_eq!(doc.world_bounds(k.lk).unwrap().origin(), Point::new(500.0, 100.0), "the locked one doesn't");
|
||||
// Layers inside a locked layer are locked too.
|
||||
ex(&mut s, "object.toggleLock", json!({"ids": [k.g], "locked": true}));
|
||||
assert_eq!(handle_at(s.doc().unwrap(), &[k.a], Point::new(60.0, 60.0), 1.0), None);
|
||||
}
|
||||
@@ -52,6 +52,9 @@ pub struct Targets {
|
||||
pub moving: Option<Rect>,
|
||||
}
|
||||
|
||||
/// A gap along a row: from, to, and where across it is drawn.
|
||||
type Gap = (f64, f64, f64);
|
||||
|
||||
impl Targets {
|
||||
/// Gather snap lines from the siblings of `moving` (and the parent frame), page guides and
|
||||
/// layout grids, excluding the moving nodes and their descendants. Inside a rotated frame they
|
||||
@@ -156,11 +159,84 @@ impl Targets {
|
||||
}
|
||||
|
||||
/// Snap the moving layers moved by page vector `d`: the correction to add to it (a page
|
||||
/// vector) and the guides to draw.
|
||||
/// vector) and the guides to draw. Besides lines, the box snaps to equal spacing with the
|
||||
/// layers beside it (whichever is nearer on each axis), and equal gaps show labelled.
|
||||
pub fn snap_move(&self, d: Vec2, threshold: f64) -> (Vec2, Vec<Guide>) {
|
||||
let Some(b) = self.moving else { return (Vec2::ZERO, Vec::new()) };
|
||||
let (dx, dy, guides) = self.snap_rect(b + self.to_space_vec(d), threshold);
|
||||
(Vec2::new(dx, dy), guides)
|
||||
let r = b + self.to_space_vec(d);
|
||||
let (xs, ys) = rect_spans(r);
|
||||
let (mut dx, mut dy, _) = self.snap_spans(&xs, &ys, threshold);
|
||||
for (horizontal, slot) in [(true, &mut dx), (false, &mut dy)] {
|
||||
if let Some(e) = self.equal_spacing(r, threshold, horizontal)
|
||||
&& slot.is_none_or(|v| e.abs() < v.abs())
|
||||
{
|
||||
*slot = Some(e);
|
||||
}
|
||||
}
|
||||
let c = Vec2::new(dx.unwrap_or(0.0), dy.unwrap_or(0.0));
|
||||
let at = r + c;
|
||||
let mut guides = self.guides_for(at);
|
||||
guides.extend(self.spacing_guides(at, true));
|
||||
guides.extend(self.spacing_guides(at, false));
|
||||
(self.to_page_vec(c), self.to_page(guides))
|
||||
}
|
||||
|
||||
/// The ways `r` can be spaced equally in its row (`horizontal`) or column, worked out as a row
|
||||
/// (a column's boxes are transposed): centred between its nearest neighbours, as far after the
|
||||
/// one before it as that one is from the next, or as far before the one after it. Each: how far
|
||||
/// `r` would move along the row, and the two gaps (from, to, across) that are equal then.
|
||||
fn spacings(&self, r: Rect, horizontal: bool) -> Vec<(f64, [Gap; 2])> {
|
||||
let t = |b: Rect| if horizontal { b } else { Rect::new(b.y0, b.x0, b.y1, b.x1) };
|
||||
let r = t(r);
|
||||
let row = self.boxes.iter().map(|b| t(*b)).filter(|b| b.y0 < r.y1 && b.y1 > r.y0);
|
||||
let (mut before, mut after): (Vec<Rect>, Vec<Rect>) = row.partition(|b| b.center().x < r.center().x);
|
||||
before.sort_by(|a, b| b.x1.total_cmp(&a.x1));
|
||||
after.sort_by(|a, b| a.x0.total_cmp(&b.x0));
|
||||
// Across the middle of what two boxes share.
|
||||
let mid = |a: &Rect, b: &Rect| (a.y0.max(b.y0) + a.y1.min(b.y1)) / 2.0;
|
||||
let w = r.width();
|
||||
let mut out = Vec::new();
|
||||
if let (Some(l), Some(n)) = (before.first(), after.first())
|
||||
&& n.x0 - l.x1 > w
|
||||
{
|
||||
let x0 = (l.x1 + n.x0 - w) / 2.0;
|
||||
out.push((x0, [(l.x1, x0, mid(l, &r)), (x0 + w, n.x0, mid(&r, n))]));
|
||||
}
|
||||
if let (Some(l), Some(l2)) = (before.first(), before.get(1))
|
||||
&& l.x0 > l2.x1
|
||||
{
|
||||
let x0 = l.x1 + (l.x0 - l2.x1);
|
||||
out.push((x0, [(l2.x1, l.x0, mid(l2, l)), (l.x1, x0, mid(l, &r))]));
|
||||
}
|
||||
if let (Some(n), Some(n2)) = (after.first(), after.get(1))
|
||||
&& n2.x0 > n.x1
|
||||
{
|
||||
let x0 = n.x0 - (n2.x0 - n.x1) - w;
|
||||
out.push((x0, [(x0 + w, n.x0, mid(&r, n)), (n.x1, n2.x0, mid(n, n2))]));
|
||||
}
|
||||
out.into_iter().map(|(x0, gaps)| (x0 - r.x0, gaps)).filter(|(d, _)| d.is_finite()).collect()
|
||||
}
|
||||
|
||||
/// The correction (snap space, along the row or column) that spaces `r` equally, if one is in
|
||||
/// reach.
|
||||
fn equal_spacing(&self, r: Rect, threshold: f64, horizontal: bool) -> Option<f64> {
|
||||
self.spacings(r, horizontal).into_iter().map(|(d, _)| d).filter(|d| d.abs() <= threshold).min_by(|a, b| a.abs().total_cmp(&b.abs()))
|
||||
}
|
||||
|
||||
/// Labelled lines across the gaps that are equal around `r` in its row or column.
|
||||
fn spacing_guides(&self, r: Rect, horizontal: bool) -> Vec<Guide> {
|
||||
let mut out: Vec<Guide> = Vec::new();
|
||||
for (_, gaps) in self.spacings(r, horizontal).into_iter().filter(|(d, _)| d.abs() < 1e-6) {
|
||||
for (from, to, across) in gaps {
|
||||
let (p, q) =
|
||||
if horizontal { (Point::new(from, across), Point::new(to, across)) } else { (Point::new(across, from), Point::new(across, to)) };
|
||||
let line = Guide { label: Some(crate::distance::label(to - from)), ..Guide::new(p, q) };
|
||||
if !out.contains(&line) {
|
||||
out.push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Page move `d` adjusted so that the moving layers' box keeps its origin on whole pixels of
|
||||
|
||||
+122
-124
@@ -495,9 +495,15 @@ fn shared_rotation(doc: &Document, ids: &[NodeId]) -> Option<f64> {
|
||||
(a.is_finite() && !axis_aligned && angles.all(same)).then_some(a)
|
||||
}
|
||||
|
||||
/// Which handle (if any) is under `p`.
|
||||
/// Whether layer `id` is locked or inside a locked layer: it can be selected in the layers panel
|
||||
/// but not moved or resized on the canvas.
|
||||
pub fn locked_in(doc: &Document, id: NodeId) -> bool {
|
||||
std::iter::once(id).chain(doc.ancestors(id)).any(|a| doc.node(a).is_some_and(|n| n.locked))
|
||||
}
|
||||
|
||||
/// Which handle (if any) is under `p`. A selection with a locked layer has none.
|
||||
pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<Handle> {
|
||||
if sel.is_empty() {
|
||||
if sel.is_empty() || sel.iter().any(|i| locked_in(doc, *i)) {
|
||||
return None;
|
||||
}
|
||||
let corners = selection_box(doc, sel)?;
|
||||
@@ -506,27 +512,48 @@ pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<
|
||||
handle_at_corners(&corners, p, zoom, is_line)
|
||||
}
|
||||
|
||||
/// A box whose sides are both shorter than this on screen (pixels) would be covered by its
|
||||
/// handles: the canvas draws just its outline (the handles still answer just outside it).
|
||||
pub const SMALL_BOX_PX: f64 = 16.0;
|
||||
|
||||
/// The shorter and longer sides, in screen pixels, of the box with `corners` (tl, tr, br, bl).
|
||||
pub fn box_sides_px(corners: &[Point; 4], zoom: f64) -> (f64, f64) {
|
||||
let [a, b, _, d] = corners;
|
||||
let (w, h) = ((*b - *a).hypot() * zoom, (*d - *a).hypot() * zoom);
|
||||
(w.min(h), w.max(h))
|
||||
}
|
||||
|
||||
/// Which handle of the box with `corners` (tl, tr, br, bl in page coordinates) is under `p`;
|
||||
/// `is_line` keeps only the two end handles.
|
||||
pub fn handle_at_corners(corners: &[Point; 4], p: Point, zoom: f64, is_line: bool) -> Option<Handle> {
|
||||
let tol = HANDLE_PX / zoom.max(1e-6);
|
||||
for (i, c) in corners.iter().enumerate() {
|
||||
if is_line && (i == 3 || i == 2) {
|
||||
continue;
|
||||
}
|
||||
if (*c - p).hypot() <= tol * 1.6 {
|
||||
return Some(Handle::Corner(i as u8));
|
||||
}
|
||||
// Inside the box, handles reach in at most a quarter of its shorter side, so that a press in
|
||||
// the middle of a small layer moves it; outside it they reach as far as ever.
|
||||
let inner = if !is_line && in_quad(corners, p) { box_sides_px(corners, zoom).0 / 4.0 / zoom.max(1e-6) } else { f64::INFINITY };
|
||||
let (corner_reach, edge_reach) = ((tol * 1.6).min(inner), tol.min(inner));
|
||||
// The nearest corner, then the nearest edge, in reach (a small box's reaches overlap).
|
||||
let nearest = |hits: &mut dyn Iterator<Item = (f64, Handle)>| hits.min_by(|a, b| a.0.total_cmp(&b.0)).map(|(_, h)| h);
|
||||
let corner = nearest(
|
||||
&mut corners
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| !(is_line && *i >= 2))
|
||||
.map(|(i, c)| ((*c - p).hypot(), Handle::Corner(i as u8)))
|
||||
.filter(|(d, _)| *d <= corner_reach),
|
||||
);
|
||||
if corner.is_some() {
|
||||
return corner;
|
||||
}
|
||||
if !is_line {
|
||||
for i in 0..4 {
|
||||
let (Some(a), Some(b)) = (corners.get(i), corners.get((i + 1) % 4)) else { continue };
|
||||
let seg = figcraft_geom::kurbo::Line::new(*a, *b);
|
||||
use figcraft_geom::kurbo::ParamCurveNearest;
|
||||
let near = seg.nearest(p, 0.001);
|
||||
if near.distance_sq.sqrt() <= tol && near.t > 0.05 && near.t < 0.95 {
|
||||
return Some(Handle::Edge(i as u8));
|
||||
}
|
||||
use figcraft_geom::kurbo::ParamCurveNearest;
|
||||
let edge = nearest(&mut (0..4).filter_map(|i| {
|
||||
let (a, b) = (corners.get(i)?, corners.get((i + 1) % 4)?);
|
||||
let near = figcraft_geom::kurbo::Line::new(*a, *b).nearest(p, 0.001);
|
||||
let d = near.distance_sq.sqrt();
|
||||
(d <= edge_reach && near.t > 0.05 && near.t < 0.95).then_some((d, Handle::Edge(i as u8)))
|
||||
}));
|
||||
if edge.is_some() {
|
||||
return edge;
|
||||
}
|
||||
}
|
||||
// Rotation zones: just outside each corner.
|
||||
@@ -565,107 +592,46 @@ pub(crate) fn handle_cursor(c: &[Point; 4], h: Handle) -> Cursor {
|
||||
}
|
||||
|
||||
/// The box (x0, y0, x1, y1) a `w0`×`h0` handle box becomes when `handle` is dragged to `q`, all in
|
||||
/// the box's own axes: `keep` constrains proportions, `from_center` resizes about the centre.
|
||||
/// Edges that cross over come back reversed (x1 < x0 or y1 < y0: a flip).
|
||||
/// the box's own axes: `keep` constrains proportions, `from_center` resizes about the centre (the
|
||||
/// opposite side mirrors the dragged one). Edges that cross over come back reversed (x1 < x0 or
|
||||
/// y1 < y0: a flip).
|
||||
pub(crate) fn resized_box(handle: Handle, q: Point, w0: f64, h0: f64, keep: bool, from_center: bool) -> (f64, f64, f64, f64) {
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (0.0, 0.0, w0, h0);
|
||||
match handle {
|
||||
Handle::Corner(0) => {
|
||||
x0 = q.x;
|
||||
y0 = q.y;
|
||||
}
|
||||
Handle::Corner(1) => {
|
||||
x1 = q.x;
|
||||
y0 = q.y;
|
||||
}
|
||||
Handle::Corner(2) => {
|
||||
x1 = q.x;
|
||||
y1 = q.y;
|
||||
}
|
||||
Handle::Corner(_) => {
|
||||
x0 = q.x;
|
||||
y1 = q.y;
|
||||
}
|
||||
Handle::Edge(0) => y0 = q.y,
|
||||
Handle::Edge(1) => x1 = q.x,
|
||||
Handle::Edge(2) => y1 = q.y,
|
||||
Handle::Edge(_) => x0 = q.x,
|
||||
Handle::Rotate(_) => {}
|
||||
// The dragged sides: x0 or x1 (`Some(false)` / `Some(true)`), y0 or y1.
|
||||
let (sx, sy) = match handle {
|
||||
Handle::Corner(0) => (Some(false), Some(false)),
|
||||
Handle::Corner(1) => (Some(true), Some(false)),
|
||||
Handle::Corner(2) => (Some(true), Some(true)),
|
||||
Handle::Corner(_) => (Some(false), Some(true)),
|
||||
Handle::Edge(0) => (None, Some(false)),
|
||||
Handle::Edge(1) => (Some(true), None),
|
||||
Handle::Edge(2) => (None, Some(true)),
|
||||
Handle::Edge(_) => (Some(false), None),
|
||||
Handle::Rotate(_) => (None, None),
|
||||
};
|
||||
match sx {
|
||||
Some(false) => (x0, x1) = (q.x, if from_center { w0 - q.x } else { w0 }),
|
||||
Some(true) => (x0, x1) = (if from_center { w0 - q.x } else { 0.0 }, q.x),
|
||||
None => {}
|
||||
}
|
||||
if keep && w0 > 1e-9 && h0 > 1e-9 {
|
||||
let (sx, sy) = ((x1 - x0) / w0, (y1 - y0) / h0);
|
||||
let k = match handle {
|
||||
Handle::Edge(0) | Handle::Edge(2) => sy,
|
||||
Handle::Edge(_) => sx,
|
||||
_ => {
|
||||
if sx.abs() > sy.abs() {
|
||||
sx
|
||||
} else {
|
||||
sy
|
||||
}
|
||||
}
|
||||
match sy {
|
||||
Some(false) => (y0, y1) = (q.y, if from_center { h0 - q.y } else { h0 }),
|
||||
Some(true) => (y0, y1) = (if from_center { h0 - q.y } else { 0.0 }, q.y),
|
||||
None => {}
|
||||
}
|
||||
if keep && w0 > 1e-9 && h0 > 1e-9 && !matches!(handle, Handle::Rotate(_)) {
|
||||
// One factor for both axes: the dragged axis's (a corner: the one that moved the most),
|
||||
// about the point that stays put (the opposite handle, or the centre).
|
||||
let (kx, ky) = ((x1 - x0) / w0, (y1 - y0) / h0);
|
||||
let k = match (sx, sy) {
|
||||
(None, _) => ky,
|
||||
(_, None) => kx,
|
||||
_ if kx.abs() > ky.abs() => kx,
|
||||
_ => ky,
|
||||
};
|
||||
let (nw, nh) = (w0 * k, h0 * k);
|
||||
match handle {
|
||||
Handle::Corner(0) => {
|
||||
x0 = w0 - nw;
|
||||
y0 = h0 - nh;
|
||||
}
|
||||
Handle::Corner(1) => {
|
||||
x1 = nw;
|
||||
y0 = h0 - nh;
|
||||
}
|
||||
Handle::Corner(2) => {
|
||||
x1 = nw;
|
||||
y1 = nh;
|
||||
}
|
||||
Handle::Corner(_) => {
|
||||
x0 = w0 - nw;
|
||||
y1 = nh;
|
||||
}
|
||||
Handle::Edge(0) | Handle::Edge(2) => {
|
||||
let c = w0 / 2.0;
|
||||
x0 = c - nw / 2.0;
|
||||
x1 = c + nw / 2.0;
|
||||
if matches!(handle, Handle::Edge(0)) {
|
||||
y0 = h0 - nh;
|
||||
} else {
|
||||
y1 = nh;
|
||||
}
|
||||
}
|
||||
Handle::Rotate(_) => {}
|
||||
Handle::Edge(_) => {
|
||||
let c = h0 / 2.0;
|
||||
y0 = c - nh / 2.0;
|
||||
y1 = c + nh / 2.0;
|
||||
if matches!(handle, Handle::Edge(3)) {
|
||||
x0 = w0 - nw;
|
||||
} else {
|
||||
x1 = nw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if from_center {
|
||||
let (cx, cy) = (w0 / 2.0, h0 / 2.0);
|
||||
let hw = ((x1 - x0) / 2.0).abs().max((cx - x0).abs().max((x1 - cx).abs()));
|
||||
let hh = ((y1 - y0) / 2.0).abs().max((cy - y0).abs().max((y1 - cy).abs()));
|
||||
match handle {
|
||||
Handle::Edge(0) | Handle::Edge(2) => {
|
||||
y0 = cy - hh;
|
||||
y1 = cy + hh;
|
||||
}
|
||||
Handle::Edge(_) => {
|
||||
x0 = cx - hw;
|
||||
x1 = cx + hw;
|
||||
}
|
||||
_ => {
|
||||
x0 = cx - hw;
|
||||
x1 = cx + hw;
|
||||
y0 = cy - hh;
|
||||
y1 = cy + hh;
|
||||
}
|
||||
}
|
||||
let pivot = if from_center { Point::new(w0 / 2.0, h0 / 2.0) } else { handle_point(opposite(handle), w0, h0) };
|
||||
(x0, x1) = (pivot.x * (1.0 - k), pivot.x + (w0 - pivot.x) * k);
|
||||
(y0, y1) = (pivot.y * (1.0 - k), pivot.y + (h0 - pivot.y) * k);
|
||||
}
|
||||
(x0, y0, x1, y1)
|
||||
}
|
||||
@@ -928,9 +894,17 @@ impl Session {
|
||||
};
|
||||
return Ok(());
|
||||
}
|
||||
// A frame's name label picks the frame itself.
|
||||
let target = label_at(&doc, page, &self.tool.labels, ev.p())
|
||||
.or_else(|| self.hit(&doc, page, ev.p(), ev.tol() * 2.0).map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)));
|
||||
// A frame's name label picks the frame itself. The background of a top-level frame with
|
||||
// layers in it is like the canvas (a press starts a marquee inside it) unless the frame is
|
||||
// selected; an empty frame is picked and dragged.
|
||||
let board_background = |leaf: NodeId| {
|
||||
!sel.contains(&leaf) && doc.node(leaf).is_some_and(|n| n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty())
|
||||
};
|
||||
let target = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| {
|
||||
self.hit(&doc, page, ev.p(), ev.tol() * 2.0)
|
||||
.filter(|leaf| ev.cmd || !board_background(*leaf))
|
||||
.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd))
|
||||
});
|
||||
match target {
|
||||
Some(target) => {
|
||||
// Clicking inside the current selection's box keeps it (drag them all).
|
||||
@@ -954,7 +928,11 @@ impl Session {
|
||||
if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) {
|
||||
return Ok(());
|
||||
}
|
||||
let ids = roots(&doc, &new_sel);
|
||||
// Locked layers selected in the layers panel stay put.
|
||||
let ids: Vec<NodeId> = roots(&doc, &new_sel).into_iter().filter(|i| !locked_in(&doc, *i)).collect();
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let targets = Targets::gather(&doc, page, &ids);
|
||||
self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" })?;
|
||||
self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false };
|
||||
@@ -1225,6 +1203,11 @@ impl Session {
|
||||
let _ = start;
|
||||
// In the handle box's own axes (one layer: its local space).
|
||||
let (frame, w0, h0) = r.frame;
|
||||
// The pixel grid: an upright box's dragged sides land on whole pixels (the Scale tool
|
||||
// scales by the factor as dragged).
|
||||
if !scale_tool && self.ds().map(|d| d.view.snap_pixel).unwrap_or(true) && crate::guides::axis_aligned(frame) {
|
||||
p = Point::new(p.x.round(), p.y.round());
|
||||
}
|
||||
let q = frame.inverse() * p;
|
||||
let is_line = r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| matches!(n.kind, NodeKind::Line))).unwrap_or(false);
|
||||
if scale_tool {
|
||||
@@ -1261,9 +1244,15 @@ impl Session {
|
||||
let nodes = r.nodes.clone();
|
||||
let single = r.single;
|
||||
let handle = r.handle;
|
||||
// Cmd/Ctrl on a frame: its children ignore their constraints and stay where they are.
|
||||
let ignore_constraints = ev.cmd;
|
||||
self.preview(|doc, _| {
|
||||
if single {
|
||||
let Some((id, world, _, _)) = nodes.first() else { return Ok(()) };
|
||||
let pinned: Vec<(NodeId, Affine)> = match doc.node(*id) {
|
||||
Some(n) if ignore_constraints && n.kind.is_frame_like() => n.children.iter().map(|c| (*c, doc.world_transform(*c))).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let mut t = *world * Affine::translate((nx0, ny0));
|
||||
if flip_x {
|
||||
t *= Affine::new([-1.0, 0.0, 0.0, 1.0, nw, 0.0]);
|
||||
@@ -1273,6 +1262,9 @@ impl Session {
|
||||
}
|
||||
doc.set_world_transform(*id, t)?;
|
||||
crate::cmd::props::resize_node(doc, *id, nw.max(0.01), nh.max(0.01))?;
|
||||
for (c, w) in &pinned {
|
||||
doc.set_world_transform(*c, *w)?;
|
||||
}
|
||||
let n = doc.get_mut(*id)?;
|
||||
if matches!(handle, Handle::Corner(_) | Handle::Edge(1) | Handle::Edge(3)) {
|
||||
n.child_layout.sizing_h = Sizing::Fixed;
|
||||
@@ -1288,15 +1280,21 @@ impl Session {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// Several layers: map the old box onto the new one, in the box's axes.
|
||||
let (sx, sy) = (if w0 > 1e-9 { nw / w0 } else { 1.0 }, if h0 > 1e-9 { nh / h0 } else { 1.0 });
|
||||
// Several layers: map the old box onto the new one, in the box's axes. Each layer's
|
||||
// centre follows the box (mirrored when it flips); each keeps its rotation and scales
|
||||
// along its own axes (see `axis_factors`), turning over with the box.
|
||||
let (sx, sy) = (if w0 > 1e-9 { (x1 - x0) / w0 } else { 1.0 }, if h0 > 1e-9 { (y1 - y0) / h0 } else { 1.0 });
|
||||
let mirror = Affine::new([sx.signum(), 0.0, 0.0, sy.signum(), 0.0, 0.0]);
|
||||
let inv = frame.inverse();
|
||||
for (id, world, w, h) in &nodes {
|
||||
let local = inv * *world;
|
||||
let o = local * Point::ZERO;
|
||||
let c = local.as_coeffs();
|
||||
doc.set_world_transform(*id, frame * Affine::new([c[0], c[1], c[2], c[3], nx0 + o.x * sx, ny0 + o.y * sy]))?;
|
||||
crate::cmd::props::resize_node(doc, *id, w * sx, h * sy)?;
|
||||
let center = local * Point::new(w / 2.0, h / 2.0);
|
||||
let (kw, kh) = figcraft_layout::axis_factors(local, sx, sy);
|
||||
let (w2, h2) = (w * kw, h * kh);
|
||||
let lin = (mirror * local).with_translation(Vec2::ZERO);
|
||||
let origin = Point::new(x0 + center.x * sx, y0 + center.y * sy) - lin * Point::new(w2 / 2.0, h2 / 2.0);
|
||||
doc.set_world_transform(*id, frame * lin.with_translation(origin))?;
|
||||
crate::cmd::props::resize_node(doc, *id, w2, h2)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
//! Transform behaviour suite, written from the public documentation of design tools: resizing
|
||||
//! (Shift, Alt, both, flipping past an edge), rotating (Shift in 15° steps), groups and several
|
||||
//! layers with different rotations or flips, frames and constraints, lines, text, nudging, the
|
||||
//! pixel grid, smart guides and the transform box itself. Each rule is a table row or an assert
|
||||
//! with a one-line derivation; where the documentation is silent the row says so and pins
|
||||
//! today's behaviour.
|
||||
|
||||
// The tables read best as tuples.
|
||||
#![allow(clippy::type_complexity)]
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use crate::tools::{Cursor, Handle, PointerEvent, PointerKind, handle_at, handle_cursor, resized_box, selection_box};
|
||||
use figcraft_doc::{NodeId, TextAutoResize};
|
||||
use figcraft_geom::{Point, Rect, is_flipped, rotation_degrees};
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn id(v: &Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, parent: Option<NodeId>) -> NodeId {
|
||||
let mut p = json!({"x": x, "y": y, "width": w, "height": h});
|
||||
if let Some(parent) = parent {
|
||||
p["parent"] = json!(parent);
|
||||
}
|
||||
id(&ex(s, "shape.rectangle", p))
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, ids: &[NodeId], props: Value) {
|
||||
ex(s, "node.set", json!({"ids": ids, "props": props}));
|
||||
}
|
||||
|
||||
/// A session with snapping to layers off (the rows that test snapping turn it back on).
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "view.snapObjects", json!({"on": false}));
|
||||
s
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Mods {
|
||||
shift: bool,
|
||||
alt: bool,
|
||||
cmd: bool,
|
||||
}
|
||||
|
||||
fn ev(kind: PointerKind, p: Point, m: Mods) -> PointerEvent {
|
||||
let mut e = PointerEvent::at(kind, p.x, p.y, 1.0);
|
||||
(e.shift, e.alt, e.cmd) = (m.shift, m.alt, m.cmd);
|
||||
e
|
||||
}
|
||||
|
||||
/// A pointer drag from `a` to `b` (one move), as one gesture.
|
||||
fn drag(s: &mut Session, a: Point, b: Point, m: Mods) {
|
||||
s.pointer(&ev(PointerKind::Down, a, m)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, b, m)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, b, m)).unwrap();
|
||||
}
|
||||
|
||||
fn bounds(s: &Session, id: NodeId) -> Rect {
|
||||
s.doc().unwrap().world_bounds(id).unwrap()
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
fn near_rect(a: Rect, b: Rect) -> bool {
|
||||
near(a.x0, b.x0) && near(a.y0, b.y0) && near(a.x1, b.x1) && near(a.y1, b.y1)
|
||||
}
|
||||
|
||||
fn size(s: &Session, id: NodeId) -> (f64, f64) {
|
||||
let n = s.doc().unwrap().node(id).unwrap();
|
||||
(n.width, n.height)
|
||||
}
|
||||
|
||||
fn rotation(s: &Session, id: NodeId) -> f64 {
|
||||
rotation_degrees(s.doc().unwrap().node(id).unwrap().transform)
|
||||
}
|
||||
|
||||
fn flipped(s: &Session, id: NodeId) -> bool {
|
||||
is_flipped(s.doc().unwrap().world_transform(id))
|
||||
}
|
||||
|
||||
fn undo_steps(s: &Session) -> usize {
|
||||
s.ds().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
const NONE: Mods = Mods { shift: false, alt: false, cmd: false };
|
||||
const SHIFT: Mods = Mods { shift: true, alt: false, cmd: false };
|
||||
const ALT: Mods = Mods { shift: false, alt: true, cmd: false };
|
||||
const BOTH: Mods = Mods { shift: true, alt: true, cmd: false };
|
||||
const CMD: Mods = Mods { shift: false, alt: false, cmd: true };
|
||||
|
||||
#[test]
|
||||
fn handle_drags_resize_by_the_documented_rules() {
|
||||
// A 100×50 box; (handle, pointer in the box's axes, modifiers, new box x0 y0 x1 y1, derivation).
|
||||
let rows: [(Handle, (f64, f64), Mods, (f64, f64, f64, f64), &str); 11] = [
|
||||
(Handle::Corner(2), (150.0, 80.0), NONE, (0.0, 0.0, 150.0, 80.0), "a corner moves the two edges it joins"),
|
||||
(Handle::Edge(1), (150.0, 999.0), NONE, (0.0, 0.0, 150.0, 50.0), "an edge moves only its side"),
|
||||
(Handle::Corner(2), (150.0, 60.0), SHIFT, (0.0, 0.0, 150.0, 75.0), "Shift keeps the proportions (following the axis dragged the most)"),
|
||||
(Handle::Edge(1), (150.0, 0.0), SHIFT, (0.0, -12.5, 150.0, 62.5), "Shift on an edge scales the other axis about its middle"),
|
||||
(Handle::Corner(2), (150.0, 80.0), ALT, (-50.0, -30.0, 150.0, 80.0), "Alt resizes from the centre"),
|
||||
(Handle::Corner(2), (75.0, 40.0), ALT, (25.0, 10.0, 75.0, 40.0), "Alt shrinks about the centre too"),
|
||||
(Handle::Edge(3), (10.0, 0.0), ALT, (10.0, 0.0, 90.0, 50.0), "Alt on an edge moves the opposite edge the other way"),
|
||||
(Handle::Corner(2), (150.0, 60.0), BOTH, (-50.0, -25.0, 150.0, 75.0), "Shift+Alt: proportional, from the centre"),
|
||||
(Handle::Edge(1), (-40.0, 0.0), NONE, (0.0, 0.0, -40.0, 50.0), "dragging an edge past the opposite one flips (reversed box)"),
|
||||
(Handle::Corner(0), (110.0, 60.0), NONE, (110.0, 60.0, 100.0, 50.0), "a corner dragged past the opposite one flips both ways"),
|
||||
(Handle::Corner(2), (-50.0, -25.0), SHIFT, (0.0, 0.0, -50.0, -25.0), "a proportional flip keeps the proportions"),
|
||||
];
|
||||
for (h, q, m, want, why) in rows {
|
||||
let got = resized_box(h, Point::new(q.0, q.1), 100.0, 50.0, m.shift, m.alt);
|
||||
assert!(near(got.0, want.0) && near(got.1, want.1) && near(got.2, want.2) && near(got.3, want.3), "{why}: {got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_and_flipping_one_layer() {
|
||||
let mut s = session();
|
||||
// A rotated layer resizes along its own axes, keeping its rotation and the opposite corner.
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None);
|
||||
set(&mut s, &[r], json!({"rotation": 30}));
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
let world = s.doc().unwrap().world_transform(r);
|
||||
let steps = undo_steps(&s);
|
||||
drag(&mut s, world * Point::new(100.0, 50.0), world * Point::new(150.0, 80.0), NONE);
|
||||
assert!(near(size(&s, r).0, 150.0) && near(size(&s, r).1, 80.0) && near(rotation(&s, r), 30.0), "rotated layers resize in their own axes");
|
||||
assert!((s.doc().unwrap().world_transform(r) * Point::ZERO - world * Point::ZERO).hypot() < 1e-6, "the opposite corner stays put");
|
||||
assert_eq!(undo_steps(&s), steps + 1, "one undo step per gesture");
|
||||
// Dragging the right edge past the left one flips the layer.
|
||||
let f = rect(&mut s, 300.0, 0.0, 100.0, 50.0, None);
|
||||
ex(&mut s, "select.set", json!({"ids": [f]}));
|
||||
drag(&mut s, Point::new(400.0, 25.0), Point::new(260.0, 25.0), NONE);
|
||||
assert!(flipped(&s, f) && near_rect(bounds(&s, f), Rect::new(260.0, 0.0, 300.0, 50.0)), "flipped, 40 wide, left of where it was");
|
||||
// Rotation handles: just outside a corner; Shift turns in 15° steps.
|
||||
let t = rect(&mut s, 0.0, 200.0, 100.0, 100.0, None);
|
||||
ex(&mut s, "select.set", json!({"ids": [t]}));
|
||||
let (c, from) = (Point::new(50.0, 250.0), Point::new(110.0, 190.0));
|
||||
let a = (from - c).atan2() + 37f64.to_radians();
|
||||
let to = c + figcraft_geom::Vec2::from_angle(a) * 80.0;
|
||||
drag(&mut s, from, to, NONE);
|
||||
assert!(near(rotation(&s, t).abs(), 37.0), "a free turn: {}", rotation(&s, t));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
drag(&mut s, from, to, SHIFT);
|
||||
assert!(near(rotation(&s, t).abs(), 30.0), "Shift: the nearest 15° step: {}", rotation(&s, t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_scale_children_keeping_their_rotation_and_flip() {
|
||||
let mut s = session();
|
||||
// A group of a 100×20 bar turned 90° (40..60 × -40..60) and a 50×50 square at (100, 0), its
|
||||
// box 40..150 × -40..60. Stretching it to twice its width (right edge to 260):
|
||||
let bar = rect(&mut s, 0.0, 0.0, 100.0, 20.0, None);
|
||||
set(&mut s, &[bar], json!({"rotation": 90}));
|
||||
let sq = rect(&mut s, 100.0, 0.0, 50.0, 50.0, None);
|
||||
ex(&mut s, "object.flipH", json!({"ids": [sq]}));
|
||||
let g = id(&ex(&mut s, "object.group", json!({"ids": [bar, sq]})));
|
||||
assert!(near_rect(bounds(&s, g), Rect::new(40.0, -40.0, 150.0, 60.0)));
|
||||
ex(&mut s, "select.set", json!({"ids": [g]}));
|
||||
drag(&mut s, Point::new(150.0, 10.0), Point::new(260.0, 10.0), NONE);
|
||||
assert!(near_rect(bounds(&s, g), Rect::new(40.0, -40.0, 260.0, 60.0)), "the group is twice as wide, as tall as before: {:?}", bounds(&s, g));
|
||||
// Each child scales with the group across the page: the bar's own height (now horizontal)
|
||||
// doubles and its length stays; both keep their rotation and flip.
|
||||
assert!(near(size(&s, bar).0, 100.0) && near(size(&s, bar).1, 40.0) && near(rotation(&s, bar), 90.0), "{:?}", size(&s, bar));
|
||||
assert!(near_rect(bounds(&s, bar), Rect::new(40.0, -40.0, 80.0, 60.0)), "{:?}", bounds(&s, bar));
|
||||
assert!(flipped(&s, sq) && near_rect(bounds(&s, sq), Rect::new(160.0, 0.0, 260.0, 50.0)), "{:?}", bounds(&s, sq));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_turned_groups_and_constraints_scale_along_each_layers_axes() {
|
||||
let mut s = session();
|
||||
// A group turned 90° holding a 100×20 bar, inside an outer group with a square: stretching the
|
||||
// outer group twice as wide makes the bar's page width (its own height) twice as large.
|
||||
let bar = rect(&mut s, 0.0, 0.0, 100.0, 20.0, None);
|
||||
let inner = id(&ex(&mut s, "object.group", json!({"ids": [bar]})));
|
||||
set(&mut s, &[inner], json!({"rotation": 90}));
|
||||
let sq = rect(&mut s, 100.0, 0.0, 50.0, 50.0, None);
|
||||
let outer = id(&ex(&mut s, "object.group", json!({"ids": [inner, sq]})));
|
||||
let (b0, o0) = (bounds(&s, bar), bounds(&s, outer));
|
||||
set(&mut s, &[outer], json!({"width": o0.width() * 2.0}));
|
||||
let b1 = bounds(&s, bar);
|
||||
assert!(near(b1.width(), b0.width() * 2.0) && near(b1.height(), b0.height()), "{b0:?} → {b1:?}");
|
||||
assert!(near(size(&s, bar).0, 100.0) && near(size(&s, bar).1, 40.0));
|
||||
// A frame's child turned 90° with Scale constraints: the frame twice as wide makes the child's
|
||||
// page width (its own height) twice as large, not its length.
|
||||
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 200, "height": 200})));
|
||||
let c = rect(&mut s, 50.0, 50.0, 100.0, 20.0, Some(f));
|
||||
set(&mut s, &[c], json!({"rotation": 90, "constraints": {"horizontal": "scale", "vertical": "scale"}}));
|
||||
let before = bounds(&s, c);
|
||||
set(&mut s, &[f], json!({"width": 400}));
|
||||
let after = bounds(&s, c);
|
||||
assert!(near(after.width(), before.width() * 2.0) && near(after.height(), before.height()), "{before:?} → {after:?}");
|
||||
assert!(near(after.x0, before.x0 * 2.0) && near(rotation(&s, c), 90.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_layers_with_different_rotations_resize_together() {
|
||||
let mut s = session();
|
||||
// P upright (0..100 × 0..50); Q 100×50 turned 90° about its centre (225..275 × -25..75): a
|
||||
// page-aligned box 0..275 × -25..75.
|
||||
let p = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None);
|
||||
let q = rect(&mut s, 200.0, 0.0, 100.0, 50.0, None);
|
||||
set(&mut s, &[q], json!({"rotation": 90}));
|
||||
ex(&mut s, "select.set", json!({"ids": [p, q]}));
|
||||
let b = selection_box(s.doc().unwrap(), &[p, q]).unwrap();
|
||||
assert_eq!(b[0], Point::new(0.0, -25.0));
|
||||
assert_eq!(b[2], Point::new(275.0, 75.0));
|
||||
// Twice as wide from the right edge: everything scales across the page, each layer along its
|
||||
// own axes, keeping its rotation.
|
||||
let steps = undo_steps(&s);
|
||||
drag(&mut s, Point::new(275.0, 25.0), Point::new(550.0, 25.0), NONE);
|
||||
assert!(near_rect(bounds(&s, p), Rect::new(0.0, 0.0, 200.0, 50.0)), "{:?}", bounds(&s, p));
|
||||
assert!(near_rect(bounds(&s, q), Rect::new(450.0, -25.0, 550.0, 75.0)), "{:?}", bounds(&s, q));
|
||||
assert!(near(size(&s, q).0, 100.0) && near(size(&s, q).1, 100.0) && near(rotation(&s, q), 90.0), "{:?}", size(&s, q));
|
||||
assert_eq!(undo_steps(&s), steps + 1);
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
// Dragged past the opposite edge, the arrangement mirrors and each layer flips.
|
||||
drag(&mut s, Point::new(275.0, 25.0), Point::new(-275.0, 25.0), NONE);
|
||||
assert!(near_rect(bounds(&s, p), Rect::new(-100.0, 0.0, 0.0, 50.0)), "{:?}", bounds(&s, p));
|
||||
assert!(near_rect(bounds(&s, q), Rect::new(-275.0, -25.0, -225.0, 75.0)), "{:?}", bounds(&s, q));
|
||||
assert!(flipped(&s, p) && flipped(&s, q));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn design_panel_edits_reach_every_selected_layer() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None);
|
||||
let b = rect(&mut s, 200.0, 0.0, 60.0, 30.0, None);
|
||||
set(&mut s, &[b], json!({"rotation": 20}));
|
||||
// Typing W, H, X or rotation into a mixed field sets it on each layer (one undo step).
|
||||
let steps = undo_steps(&s);
|
||||
set(&mut s, &[a, b], json!({"width": 80, "rotation": 45, "x": 10}));
|
||||
assert_eq!(undo_steps(&s), steps + 1);
|
||||
for i in [a, b] {
|
||||
assert!(near(size(&s, i).0, 80.0) && near(rotation(&s, i), 45.0), "{i:?}");
|
||||
assert!(near(s.doc().unwrap().node(i).unwrap().transform.as_coeffs()[4], 10.0));
|
||||
}
|
||||
// A flipped layer reads back the rotation typed and stays flipped (a horizontal flip reads
|
||||
// as 180°, the form the rotation field shows).
|
||||
ex(&mut s, "object.flipH", json!({"ids": [a]}));
|
||||
set(&mut s, &[a], json!({"rotation": 30}));
|
||||
assert!(near(rotation(&s, a), 30.0) && flipped(&s, a), "{}", rotation(&s, a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_resize_with_constraints_and_cmd_ignores_them() {
|
||||
let mut s = session();
|
||||
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100})));
|
||||
let c = rect(&mut s, 150.0, 10.0, 40.0, 40.0, Some(f));
|
||||
set(&mut s, &[c], json!({"constraints": {"horizontal": "right"}}));
|
||||
ex(&mut s, "select.set", json!({"ids": [f]}));
|
||||
drag(&mut s, Point::new(200.0, 50.0), Point::new(300.0, 50.0), NONE);
|
||||
assert_eq!(bounds(&s, c).x0, 250.0, "a Right constraint keeps the distance to the right edge");
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
drag(&mut s, Point::new(200.0, 50.0), Point::new(300.0, 50.0), CMD);
|
||||
assert_eq!(bounds(&s, c).x0, 150.0, "Cmd/Ctrl while resizing a frame ignores constraints");
|
||||
assert_eq!(bounds(&s, f).width(), 300.0);
|
||||
// From the left edge too: the children stay where they are on the page.
|
||||
drag(&mut s, Point::new(0.0, 50.0), Point::new(-50.0, 50.0), CMD);
|
||||
assert_eq!((bounds(&s, f).x0, bounds(&s, c).x0), (-50.0, 150.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_and_text() {
|
||||
let mut s = session();
|
||||
// A line's ends set its length and angle; Shift keeps the angle to 45° steps.
|
||||
let l = id(&ex(&mut s, "shape.line", json!({"x": 0, "y": 0, "width": 100})));
|
||||
ex(&mut s, "select.set", json!({"ids": [l]}));
|
||||
drag(&mut s, Point::new(100.0, 0.0), Point::new(60.0, 80.0), NONE);
|
||||
assert!(near(size(&s, l).0, 100.0) && near(rotation(&s, l).abs(), 53.13010235415598), "{} {}", size(&s, l).0, rotation(&s, l));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
drag(&mut s, Point::new(100.0, 0.0), Point::new(60.0, 80.0), SHIFT);
|
||||
assert!(near(rotation(&s, l).abs(), 45.0), "{}", rotation(&s, l));
|
||||
// Text: a side handle makes auto-width text fixed width (auto height); another handle a fixed
|
||||
// box.
|
||||
let t = id(&ex(&mut s, "text.create", json!({"text": "Hello world", "x": 0, "y": 200})));
|
||||
let auto = |s: &Session| s.doc().unwrap().node(t).unwrap().kind.text().map(|d| d.auto_resize);
|
||||
// Large type, so the box is big enough for side handles whatever font the machine falls back to.
|
||||
set(&mut s, &[t], json!({"fontSize": 48}));
|
||||
assert_eq!(auto(&s), Some(TextAutoResize::WidthAndHeight));
|
||||
ex(&mut s, "select.set", json!({"ids": [t]}));
|
||||
let b = bounds(&s, t);
|
||||
// Text sizes need a font (CI may have none).
|
||||
if b.height() > 4.0 {
|
||||
drag(&mut s, Point::new(b.x1 + 2.0, b.center().y), Point::new(b.x1 + 30.0, b.center().y), NONE);
|
||||
assert_eq!(auto(&s), Some(TextAutoResize::Height), "a side handle: auto height");
|
||||
let b = bounds(&s, t);
|
||||
drag(&mut s, Point::new(b.x1 + 2.0, b.y1 + 2.0), Point::new(b.x1 + 10.0, b.y1 + 20.0), NONE);
|
||||
assert_eq!(auto(&s), Some(TextAutoResize::None), "a corner: fixed size");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nudging_and_the_pixel_grid() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None);
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
// Arrows move by 1, Shift+arrows by the big nudge (10, a preference).
|
||||
ex(&mut s, "transform.nudge", json!({"dx": 1, "dy": 0}));
|
||||
ex(&mut s, "transform.nudge", json!({"dx": 0, "dy": 1, "big": true}));
|
||||
assert_eq!(bounds(&s, r).origin(), Point::new(1.0, 10.0));
|
||||
ex(&mut s, "pref.nudge", json!({"big": 8}));
|
||||
ex(&mut s, "transform.nudge", json!({"dx": -1, "dy": 0, "big": true}));
|
||||
assert_eq!(bounds(&s, r).origin(), Point::new(-7.0, 10.0));
|
||||
// With the pixel grid on, moves, resizes and new shapes land on whole pixels.
|
||||
drag(&mut s, Point::new(20.0, 20.0), Point::new(30.3, 20.6), NONE);
|
||||
assert_eq!(bounds(&s, r).origin(), Point::new(3.0, 11.0), "moved by a whole pixel");
|
||||
drag(&mut s, Point::new(103.0, 61.0), Point::new(150.4, 80.6), NONE);
|
||||
assert_eq!(size(&s, r), (147.0, 70.0), "resized to whole pixels");
|
||||
// Off, they follow the pointer exactly.
|
||||
ex(&mut s, "view.snapPixel", json!({"on": false}));
|
||||
drag(&mut s, Point::new(150.0, 81.0), Point::new(160.5, 81.25), NONE);
|
||||
assert_eq!(size(&s, r), (157.5, 70.25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_guides_snap_to_edges_centres_and_equal_spacing() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0, None);
|
||||
let c = rect(&mut s, 400.0, 0.0, 100.0, 100.0, None);
|
||||
let b = rect(&mut s, 200.0, 300.0, 100.0, 100.0, None);
|
||||
let _ = (a, c);
|
||||
ex(&mut s, "select.set", json!({"ids": [b]}));
|
||||
// (drag to, B's box after, derivation): B is grabbed at its centre (250, 350).
|
||||
let rows: [((f64, f64), (f64, f64), &str); 4] = [
|
||||
((153.0, 350.0), (100.0, 300.0), "silent: an edge within 5 screen px of another layer's edge snaps to it (pinned)"),
|
||||
((302.0, 52.0), (252.0, 0.0), "edges and centres snap (its top to the others' tops)"),
|
||||
((248.0, 52.0), (200.0, 0.0), "between two layers, it snaps to equal gaps on both sides"),
|
||||
((852.0, 52.0), (800.0, 0.0), "beyond the last of a row, to the gap the row already has"),
|
||||
];
|
||||
for (to, want, why) in rows {
|
||||
drag(&mut s, Point::new(250.0, 350.0), Point::new(to.0, to.1), NONE);
|
||||
assert_eq!(bounds(&s, b).origin(), Point::new(want.0, want.1), "{why}");
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
}
|
||||
// While dragging, the equal gaps show as labelled lines; Cmd/Ctrl turns snapping off.
|
||||
s.pointer(&ev(PointerKind::Down, Point::new(250.0, 350.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, Point::new(248.0, 52.0), NONE)).unwrap();
|
||||
let labels: Vec<String> = s.tool.overlay.guides.iter().filter_map(|g| g.label.clone()).collect();
|
||||
assert_eq!(labels, vec!["100", "100"], "{:?}", s.tool.overlay.guides);
|
||||
s.pointer(&ev(PointerKind::Move, Point::new(248.0, 52.0), CMD)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, Point::new(248.0, 52.0), CMD)).unwrap();
|
||||
assert_eq!(bounds(&s, b).origin(), Point::new(198.0, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_transform_box_and_its_handles() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0, None);
|
||||
let doc = s.doc().unwrap().clone();
|
||||
let z = 1.0;
|
||||
// Cursors follow the handle and the box's rotation; outside a corner, the rotation cursor.
|
||||
let q = selection_box(&doc, &[r]).unwrap();
|
||||
for (h, angle) in [(Handle::Corner(0), 45.0), (Handle::Corner(1), 135.0), (Handle::Edge(0), 90.0), (Handle::Edge(1), 180.0)] {
|
||||
assert!(matches!(handle_cursor(&q, h), Cursor::Resize(a) if near(a, angle)), "{h:?}");
|
||||
}
|
||||
assert_eq!(handle_cursor(&q, Handle::Rotate(1)), Cursor::Rotate);
|
||||
assert_eq!(handle_at(&doc, &[r], Point::new(110.0, -10.0), z), Some(Handle::Rotate(1)));
|
||||
set(&mut s, &[r], json!({"rotation": 30}));
|
||||
let doc = s.doc().unwrap().clone();
|
||||
let q = selection_box(&doc, &[r]).unwrap();
|
||||
let Cursor::Resize(a) = handle_cursor(&q, Handle::Edge(1)) else { panic!() };
|
||||
assert!(near((a - 150.0).rem_euclid(180.0), 0.0), "the edge cursor turns with the layer: {a}");
|
||||
// Hovering sets the cursor.
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
let corner = doc.world_transform(r) * Point::new(100.0, 100.0);
|
||||
s.pointer(&ev(PointerKind::Hover, corner, NONE)).unwrap();
|
||||
assert!(matches!(s.tool.overlay.cursor, Cursor::Resize(_)));
|
||||
// A layer smaller than its handles on screen: a press inside it moves it, the handles still
|
||||
// answer just outside it.
|
||||
let tiny = rect(&mut s, 300.0, 0.0, 4.0, 4.0, None);
|
||||
ex(&mut s, "select.set", json!({"ids": [tiny]}));
|
||||
let doc = s.doc().unwrap().clone();
|
||||
assert_eq!(handle_at(&doc, &[tiny], Point::new(302.0, 2.0), z), None, "inside: no handle");
|
||||
assert_eq!(handle_at(&doc, &[tiny], Point::new(297.0, 297.0 - 300.0), z), Some(Handle::Corner(0)), "outside the corner: its handle");
|
||||
drag(&mut s, Point::new(302.0, 2.0), Point::new(322.0, 12.0), NONE);
|
||||
assert_eq!(bounds(&s, tiny), Rect::new(320.0, 10.0, 324.0, 14.0), "a press inside moves it");
|
||||
drag(&mut s, Point::new(326.0, 16.0), Point::new(340.0, 30.0), NONE);
|
||||
assert_eq!(bounds(&s, tiny), Rect::new(320.0, 10.0, 340.0, 30.0), "the corner handle just outside resizes it");
|
||||
// At a larger zoom the same layer is big enough on screen for its handles inside it.
|
||||
assert_eq!(handle_at(s.doc().unwrap(), &[tiny], Point::new(320.2, 10.2), 20.0), Some(Handle::Corner(0)));
|
||||
}
|
||||
@@ -99,4 +99,12 @@ fn set_rotation_keeps_center() {
|
||||
let c1 = r * Point::new(50.0, 25.0);
|
||||
assert!((c0 - c1).hypot() < 1e-9);
|
||||
assert!((rotation_degrees(r) - 90.0).abs() < 1e-9);
|
||||
// A flipped box stays flipped and reads back the rotation set, whichever way it was flipped.
|
||||
for flip in [Affine::FLIP_X, Affine::FLIP_Y] {
|
||||
for deg in [0.0, 30.0, -120.0] {
|
||||
let r = set_rotation_about_center(xf * flip, 100.0, 50.0, deg);
|
||||
assert!(is_flipped(r) && (rotation_degrees(r) - deg).abs() < 1e-9, "{deg}");
|
||||
assert!((xf * flip * Point::new(50.0, 25.0) - r * Point::new(50.0, 25.0)).hypot() < 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,11 +34,12 @@ pub fn from_position_rotation(x: f64, y: f64, degrees: f64) -> Affine {
|
||||
}
|
||||
|
||||
/// Change the rotation of `xf` to `degrees` about the centre of a `w`×`h` box in its local space,
|
||||
/// keeping that centre in place and keeping any flip.
|
||||
/// keeping that centre in place and keeping any flip. A flip is kept as a vertical one, the form
|
||||
/// [`rotation_degrees`] reads (a horizontal flip reads as 180°), so the rotation set reads back.
|
||||
pub fn set_rotation_about_center(xf: Affine, w: f64, h: f64, degrees: f64) -> Affine {
|
||||
let center_local = Point::new(w / 2.0, h / 2.0);
|
||||
let center = xf * center_local;
|
||||
let flip = if is_flipped(xf) { Affine::FLIP_X } else { Affine::IDENTITY };
|
||||
let flip = if is_flipped(xf) { Affine::FLIP_Y } else { Affine::IDENTITY };
|
||||
let rot = Affine::rotate(-degrees.to_radians());
|
||||
let lin = rot * flip;
|
||||
// Solve translation so that lin * center_local + t = center.
|
||||
|
||||
@@ -73,6 +73,19 @@ fn linear(a: Affine) -> Affine {
|
||||
Affine::new([c[0], c[1], c[2], c[3], 0.0, 0.0])
|
||||
}
|
||||
|
||||
/// How much a layer's own width and height scale (no skew: it keeps its rotation and flip) when
|
||||
/// its parent's space scales by `kx`, `ky`: how much each of its axes (`xf`'s linear part)
|
||||
/// stretches. A layer turned 90° swaps the two; one turned 45° gets a mix.
|
||||
pub fn axis_factors(xf: Affine, kx: f64, ky: f64) -> (f64, f64) {
|
||||
let [a, b, c, d, _, _] = xf.as_coeffs();
|
||||
let along = |x: f64, y: f64, fallback: f64| {
|
||||
let len = x.hypot(y);
|
||||
let k = (kx * x).hypot(ky * y) / len;
|
||||
if len > 1e-12 && k.is_finite() { k } else { fallback }
|
||||
};
|
||||
(along(a, b, kx), along(c, d, ky))
|
||||
}
|
||||
|
||||
/// Place `id` so its (rotated) bounding box's top-left is at `(x, y)` in the parent.
|
||||
fn place(doc: &mut Document, id: NodeId, x: f64, y: f64) {
|
||||
let Some(n) = doc.node(id) else { return };
|
||||
@@ -912,9 +925,9 @@ pub fn apply_constraints(doc: &mut Document, id: NodeId, ow: f64, oh: f64) {
|
||||
y1 *= sy;
|
||||
}
|
||||
}
|
||||
// New local size from the bbox change (axis-aligned children exactly; rotated approximately).
|
||||
let kw = if bw > 1e-9 { (x1 - x0) / bw } else { 1.0 };
|
||||
let kh = if bh > 1e-9 { (y1 - y0) / bh } else { 1.0 };
|
||||
// New local size from the bbox change, along the child's own axes (axis-aligned children,
|
||||
// turned or flipped, exactly; others approximately, never skewed).
|
||||
let (kw, kh) = axis_factors(lin, if bw > 1e-9 { (x1 - x0) / bw } else { 1.0 }, if bh > 1e-9 { (y1 - y0) / bh } else { 1.0 });
|
||||
let (new_w, new_h) = (cw * kw, ch * kh);
|
||||
let resized = (new_w - cw).abs() > 1e-9 || (new_h - ch).abs() > 1e-9;
|
||||
if resized {
|
||||
@@ -935,16 +948,20 @@ pub fn apply_constraints(doc: &mut Document, id: NodeId, ow: f64, oh: f64) {
|
||||
}
|
||||
|
||||
/// Scale a group's children when the group is resized (groups scale their contents).
|
||||
/// Each child keeps its rotation and flip and scales along its own axes ([`axis_factors`]), its
|
||||
/// centre following the group's scale.
|
||||
pub fn scale_group_children(doc: &mut Document, id: NodeId, kx: f64, ky: f64) {
|
||||
let Some(n) = doc.node(id) else { return };
|
||||
for c in n.children.clone() {
|
||||
let Some(cn) = doc.node(c) else { continue };
|
||||
let b = figcraft_geom::transform_rect_bbox(cn.transform, cn.local_rect());
|
||||
let center = figcraft_geom::transform_rect_bbox(cn.transform, cn.local_rect()).center();
|
||||
let (w, h) = (cn.width, cn.height);
|
||||
set_size(doc, c, w * kx, h * ky);
|
||||
place(doc, c, b.x0 * kx, b.y0 * ky);
|
||||
let (kw, kh) = axis_factors(cn.transform, kx, ky);
|
||||
set_size(doc, c, w * kw, h * kh);
|
||||
let (nw, nh) = outer_size(doc, c);
|
||||
place(doc, c, center.x * kx - nw / 2.0, center.y * ky - nh / 2.0);
|
||||
if doc.node(c).is_some_and(|n| n.kind.bounds_follow_children()) {
|
||||
scale_group_children(doc, c, kx, ky);
|
||||
scale_group_children(doc, c, kw, kh);
|
||||
} else if doc.node(c).is_some_and(|n| n.kind.is_frame_like()) {
|
||||
apply_constraints(doc, c, w, h);
|
||||
}
|
||||
|
||||
@@ -860,7 +860,20 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
|
||||
if let Some(hint) = scale {
|
||||
label = format!("{label} · {}x", fmt(hint.factor.abs()));
|
||||
}
|
||||
size_label = transform_box(app, painter, b.map(|p| app.to_screen(p.x, p.y)), col, sel.len() > 1, if is_line { 2 } else { 4 }, label);
|
||||
// A layer smaller than its handles on screen shows just its outline (they still answer
|
||||
// just outside it).
|
||||
let tiny = figcraft_engine::tools::box_sides_px(&b, zoom).1 < figcraft_engine::tools::SMALL_BOX_PX;
|
||||
// Locked layers (selected in the layers panel) can't be resized: no handles either.
|
||||
let handles = if sel.iter().any(|i| figcraft_engine::tools::locked_in(&doc, *i)) {
|
||||
0
|
||||
} else if is_line {
|
||||
2
|
||||
} else if tiny {
|
||||
0
|
||||
} else {
|
||||
4
|
||||
};
|
||||
size_label = transform_box(app, painter, b.map(|p| app.to_screen(p.x, p.y)), col, sel.len() > 1, handles, label);
|
||||
arc_overlay(app, painter, col);
|
||||
if app.session.tool.tool == ToolId::Scale {
|
||||
// The point that stays put: the dragged pivot, else the anchor.
|
||||
|
||||
+12
-1
@@ -982,7 +982,18 @@ guide, along the frame's axis nearer the ruler's.
|
||||
depth (the context menu's Select layer); `select.set`, `select.children` / `select.parent` (Enter
|
||||
/ Shift+Enter), `select.next` / `select.previous` (Tab / Shift+Tab). With `pointer`, `cmd: true`
|
||||
clicks select the nested layer, `cmd` drags from empty canvas select the nested layers they touch,
|
||||
and `shift` drags toggle what they touch.
|
||||
and `shift` drags toggle what they touch. A press on the background of a top-level frame with
|
||||
layers in it (the frame not selected) starts a marquee inside it, as on the canvas; an empty frame
|
||||
is picked and dragged. Locked layers (selectable with `select.set`) get no handles and stay put
|
||||
when the rest of the selection is dragged. `select.all` / `select.inverse` skip hidden and locked
|
||||
layers.
|
||||
- **Transform gestures**: with `pointer`, `shift` keeps proportions, `alt` resizes from the centre
|
||||
(both together too), dragging past the opposite edge flips (several layers mirror and flip);
|
||||
groups and several layers scale each layer along its own axes, keeping its rotation and flip;
|
||||
`cmd` while resizing a frame ignores its children's constraints (they stay where they are) and,
|
||||
as in every drag, turns snapping off. Moves snap to edges, centres and equal spacing (the gaps
|
||||
show in `tool.overlay.guides` with labels); with the pixel grid on, moves and resizes land on
|
||||
whole pixels.
|
||||
|
||||
Tool failures come back as results with `isError: true` and a message; protocol problems (unknown
|
||||
tool, malformed JSON, wrong parameter types) are JSON-RPC errors. Nothing crashes the server.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# FigCraft parity with Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
|
||||
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
|
||||
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
|
||||
@@ -95,7 +95,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
||||
| # | Area | Weight | Breadth | Ready | Remaining | Hours |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | Canvas & navigation | 6 | 97% | 72% | Display P3 on screen, QA at extreme zooms (outlines for the selection and mask outlines landed) | 2–5 |
|
||||
| 2 | Selection & transform | 8 | 95% | 68% | Handles, measuring and snapping checked tool by tool against Figma; rotated groups and mixed selections | 6–10 |
|
||||
| 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 |
|
||||
| 3 | Layers panel | 5 | 95% | 78% | Keyboard box selection, keyboard rename/context menu, long-list QA | 2–4 |
|
||||
| 4 | Shapes & vector | 8 | 97% | 68% | Vector QA with real files (live boolean editing, boolean hit testing on the result, arc fields, shape builder on strokes and open paths and one-sided widths landed) | 1–2 |
|
||||
| 5 | Paint | 7 | 87% | 66% | Video fills, wide-gamut canvas, embedded image profiles, HEIC images (animated GIF fills and TIFF images landed) | 4–7 |
|
||||
@@ -122,7 +122,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
||||
|
||||
| Dimension | Weight | Ready | Hours | Evidence | Doc |
|
||||
|---|---|---|---|---|---|
|
||||
| Features (depth) | 35 | 57% | 60–115 (areas 1–13, 15, 16, 20) | The table above; core editor ~67% | this file |
|
||||
| Features (depth) | 35 | 57% | 60–115 (areas 1–13, 15, 16, 20) | The table above; core editor ~68% | this file |
|
||||
| UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) |
|
||||
| File formats | 12 | 55% | 8–15 | `.fig` read, best effort (tested on generated fixtures, not real files), no `.fig` write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) |
|
||||
| Collaboration | 10 | 13% | 55–110 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) |
|
||||
@@ -580,6 +580,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h |
|
||||
| 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors (as outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Import/export breadth 65% → 80%, ready 35% → 50%, 30–60 h → 8–15 h; file formats 40% → 55%, 30–60 h → 8–15 h; feature breadth ~79% → ~80% (core ~90% → ~91%), areas ready ~57% → ~58% (core ~67% → ~68%); ready for real work ~47% → ~49%; essentials ~53% → ~57% (opening files people send ×0.80 → ×0.88); mainstream ~43% → ~46% (weekly areas 66.8% → 67.8%, file exchange ×0.80 → ×0.85); beta ~195–355 h → ~173–310 h; full parity ~345–666 h → ~323–621 h |
|
||||
| 2026-10-11 | minor | Auto layout behaviour suite written from the public documentation (64 layout cases, 8 command tests) and its fixes: a frame is never smaller than its padding, only the frame's inside stroke counts when strokes are included (and new auto layout includes them), auto gaps stop at 0, grid `fr` tracks behave like the CSS `fr` unit (a track never gets less than its content), canvas stacking is drawn, exported and hit (it was stored only). Added: auto gaps evenly and around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Auto layout 95%/68% → 98%/74%, 6–12 h → 3–6 h; percentages unchanged at this rounding; full parity ~348–672 h → ~345–666 h |
|
||||
| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h |
|
||||
|
||||
+8
-5
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
|
||||
|
||||
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
|
||||
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
|
||||
@@ -30,21 +30,23 @@ interaction feel not yet compared.
|
||||
|
||||
| Item | Status | Notes |
|
||||
|---|---|---|
|
||||
| Click, Shift-click, marquee, Shift-marquee toggle | done | |
|
||||
| Click, Shift-click, marquee, Shift-marquee toggle | done | a marquee selects what it touches; from a top-level frame's background, the layers inside it; locked and hidden layers are skipped (`selection_behaviour_tests`) |
|
||||
| Deep select (Cmd/Ctrl-click, Cmd/Ctrl-marquee) | done | |
|
||||
| Select layer menu (every layer under the click) | done | |
|
||||
| Enter / Shift+Enter (children / parent), Tab / Shift+Tab (siblings) | done | Tab renames the next layer while renaming |
|
||||
| Resize handles: Shift keeps proportions, Alt from centre, constraints-aware | done | |
|
||||
| Resize handles: Shift keeps proportions, Alt from centre, constraints-aware | done | Alt shrinks too; past an edge flips (several layers mirror); groups and mixed rotations scale each layer along its own axes, keeping rotation and flip (no skew: children turned off a right angle are approximate); Cmd/Ctrl ignores a frame's constraints (`transform_behaviour_tests`) |
|
||||
| Rotate just outside a corner; Shift in 15° steps | done | rotated handle box when layers share a rotation |
|
||||
| Flip, rotate 90° | done | |
|
||||
| Alt-drag duplicate | done | |
|
||||
| Scale tool (K): handles about an anchor, live factor, panel | done | |
|
||||
| Nudge 1 / Shift 10 (preference), Cmd+arrows resize | done | arrows reorder auto layout children |
|
||||
| Smart guides: edges, centres, spacing labels, parent, layout grid, ruler guides | done | red, `snap.rs`; thresholds not yet compared with Figma's |
|
||||
| Smart guides: edges, centres, equal spacing (between two layers or continuing a row's gap, labelled), spacing labels, parent, layout grid, ruler guides | done | red, `snap.rs`; thresholds not yet compared with Figma's |
|
||||
| Ctrl to suspend snapping while dragging (macOS) | missing | |
|
||||
| Measure (Alt hover, Cmd+Alt nested, Shift+Alt padding/gaps) | done | labels never overlap |
|
||||
| Pixel grid snapping, pixel preview, 2x preview | done | |
|
||||
| Pixel grid snapping, pixel preview, 2x preview | done | moves, resizes and new shapes land on whole pixels |
|
||||
| Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | partial | check scrub speed and Shift/Alt steps |
|
||||
| Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation |
|
||||
| Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection |
|
||||
| Keyboard box selection | missing | |
|
||||
|
||||
## Tools
|
||||
@@ -96,6 +98,7 @@ interaction feel not yet compared.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Selection and transform behaviour suite and fixes: Alt shrinks, groups and mixed rotations scale each layer along its own axes, several layers flip together, whole-pixel resizes, small layers move from inside, locked layers stay put, marquees from a top-level frame's background, equal spacing snapping, Cmd/Ctrl ignores constraints while resizing a frame |
|
||||
| 2026-10-11 | minor | Gamut warnings: the colour picker flags Display P3 colours outside sRGB; View ▸ Soft-proof sRGB stripes them on the canvas |
|
||||
| 2026-10-11 | minor | Several windows: File ▸ New window, Open in new window on a tab, a tab dragged off the strip; each window has its own tabs and panels and asks about unsaved changes when it closes |
|
||||
| 2026-10-11 | minor | Canvas fixes: boolean groups clicked where their result is, a layer dragged inside a group stays in it, View ▸ Frame outlines and View ▸ Slices draw |
|
||||
|
||||
Reference in New Issue
Block a user