mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-13 23:17:43 +00:00
M1: frames, groups, sections and masks behaviour suite and fixes
**A frames, groups, sections and masks behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (frames vs. groups when resized, constraints, Cmd/Ctrl-resize, clip content, Frame selection, grouping and ungrouping, group bounds and opacity, X/Y in groups, click targets, drawing a frame or section around layers, dropping into and out of frames, where sections may go, resizing sections, top-level frames as screens and exports, Use as mask, what a mask covers, alpha / vector / luminance masks, groups as masks, clicks on masked-out areas, a mask group's box and exports) is a table-driven case with the result the rule implies and a one-line derivation; where the documentation is silent the case says so and pins today's behaviour. **Fixes (the suite failed on 7 tests before them)** - **Masked-out areas don't take clicks**: hit testing and Select layer skip what a visible mask hides (the nearest mask below a layer; for a group or frame used as a mask, what its layers draw), so a click there reaches what's behind. Checked only once something is hit, and skipped in files without a mask (`Document::has_masks`, worked out once after each change), so hit testing costs what it did. - **A mask group's box and exports are the mask's**: a group's bounds and export area count a mask for the layers it masks (layers under the mask still count), so the selection box, W/H, snapping and PNG/SVG/PDF exports crop to the mask instead of the whole masked layer (`Document::box_children`). - **Sections stay on the page or in sections**: moving a section into a frame or group (`node.reparent`, refused at the root in `Document::reparent`), dragging one over a frame (it lands in the section around the frame, or on the page), grouping or framing a section, creating a section in a frame and pasting one into a selected frame (it goes on the page) no longer put sections where they can't be. - **Drawing a frame or section around layers puts them in it**: the layers fully inside the drawn box (its siblings; visible and unlocked; a frame never takes a section; not in auto layout) move into it, in their order, without moving on the page; one undo step. - **Resizing a section leaves its contents** where they are on the page (no constraints in sections), from a field, a handle (also the left/top edges) or `transform.resize` with any anchor. - **Cmd/Ctrl while resizing a frame leaves its layers as they are**: #133 (merged in) keeps their places; this branch also keeps their sizes (the frame is resized without running constraints, so a left & right or scale child no longer stretches and is only moved back). - **X/Y are measured in the frame, not the group**: groups and boolean groups have no coordinate space of their own, so a layer in a group shows (Design panel, `node.get`, `inspect_document`) and takes (`node.set`, `node.create` with a group parent) X/Y in the frame the group is in, or on the page (`Document::frame_relative`). Rotated groups too. Already there and now covered by the suite: constraints when a frame resizes, groups scaling their layers, clip content (drawing, hits, export size), Frame selection's size and place, grouping/ungrouping keeping world transforms (rotated groups too), nested groups, empty groups going away, group opacity on the composite, click / Cmd-click / double-click targets (frames in sections count as top-level frames), dropping by the pointer and pulling out, group layers staying in their group when dragged, top-level frames as prototype screens and export boards, Use as mask (bottom layer, "Mask group", at the topmost layer's place, single-layer toggle), masks covering every layer above them, hidden masks, alpha / vector / luminance masks, a group as a mask, masks in SVG and PDF exports. No new commands or UI strings (no i18n changes); each edit stays one undo step.
This commit is contained in:
1 parent
f3012ce6e4
commit
e917d3c2e6
22 files changed
+956
-76
No files matched your search
+7
-6
@@ -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 (styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; 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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; 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
|
||||
@@ -19,14 +19,14 @@ start with [`docs/gaps.md`](docs/gaps.md).
|
||||
| Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
|
||||
| Mainstream practitioner | **~48%** | estimated: weekly areas 69.1% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
|
||||
| Remaining to beta | **~173–310 h** one agent | estimated |
|
||||
| Remaining to full parity | **~315–607 h** one agent (~86–150 h wall-clock with 5 agents) | estimated |
|
||||
| Remaining to full parity | **~314–606 h** one agent (~86–150 h wall-clock with 5 agents) | estimated |
|
||||
|
||||
### Readiness by audience
|
||||
|
||||
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|
||||
|---|---|---|---|
|
||||
| Full target (ready for real work) | ~49% | ~300–576 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (8–15) |
|
||||
| Mainstream practitioner | ~48% | ~135–251 h | core editor depth incl. the `.fig` import's tail (53–104), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
|
||||
| Full target (ready for real work) | ~49% | ~299–575 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (8–15) |
|
||||
| Mainstream practitioner | ~48% | ~134–250 h | core editor depth incl. the `.fig` import's tail (52–103), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
|
||||
| Essentials user | ~57% | ~63–115 h | the `.fig` import's tail so the files people send open well (8–15), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
|
||||
|
||||
Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour
|
||||
@@ -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 69%) | 52–101 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Features (depth) | 57% (core editor 69%) | 53–103 | [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) |
|
||||
@@ -79,7 +79,7 @@ Figma's own languages on the landed localization layer (15–25 h).
|
||||
| Paint | 90% | 67% | 3–6 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Strokes | 95% | 75% | 1–2 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Effects | 100% | 72% | 3–6 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Frames, groups, sections, masks | 95% | 75% | 2–4 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Frames, groups, sections, masks | 97% | 80% | 1–3 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Auto layout & constraints | 98% | 74% | 3–6 | [auto-layout-parity](docs/auto-layout-parity.md) |
|
||||
| Text | 87% | 68% | 5–10 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Components & variants | 96% | 72% | 4–9 | [components-parity](docs/components-parity.md) |
|
||||
@@ -195,6 +195,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Frames, groups, sections and masks behaviour suite written from the public documentation (15 frame, group and section tests, 6 mask tests) and its fixes: a layer a mask hides can't be clicked there (clicks reach what's behind; Select layer agrees), a mask group's box and exports are the mask's (plus any layers under it), sections stay on the page or in sections (moves, drags, pastes, grouping and new sections in frames refused or kept on the page), drawing a frame or section around layers puts those fully inside it in it, resizing a section leaves its contents where they are (and a frame resized with Cmd/Ctrl held keeps its layers' sizes too), and X/Y of layers in groups are measured in the frame (groups have no coordinate space). Frames, groups, sections, masks 95%/75% → 97%/80%, 2–4 h → 1–3 h; percentages unchanged at this rounding; full parity ~323–621 h → ~322–620 h |
|
||||
| 2026-10-11 | minor | Styles and variables behaviour suite written from the public documentation (18 command-level tests) and its fixes: editing a styled fill, stroke, effect or layout grid list (directly, a paint row, an added fill or a bound variable) unlinks that style instead of being overwritten by the next style edit; applying a style replaces variables bound to what it sets; text styles go on text layers only; deleting a variable (or its collection) unbinds the layers using it, which keep their last value, and aliases to it take its values; bindings are type-checked (a colour variable can't drive a width) and number variables fill text content; aliases are checked when a variable is created. Added: duplicate, move (reorder, into a folder) and rename folder for styles, with a Local styles list grouped by type and folder (click to edit, right-click, drag to reorder); duplicate mode, set as default and move column for modes; per-platform code syntax (Web, iOS, Android) used by generated CSS and the variables JSON; font weight, paragraph spacing/indent and text content scopes for number variables. Found missing: variables inside styles. Styles & variables 92%/72% → 94%/76%, 1–2 h → 3–5 h; percentages unchanged at this rounding; full parity ~323–621 h → ~325–624 h |
|
||||
| 2026-10-11 | minor | Prototyping behaviour suite written from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag starts only for a drag its animation's way and the animation follows the pointer, key combinations (`Shift+K`) fire (and are recorded and imported), an overlay's after-delay timer starts when it opens, instances of interactive components play their variant's interactions and "Change to" changes the right instance, spring presets are physical springs that play until they settle, smart animate overshoots with spring and back easings, fixed layers draw above scrolling content; added scroll to offsets. Prototyping 88%/70% → 90%/74%, 5–9 h → 3–6 h; mainstream weekly areas 68.1% → 68.4%, mainstream ~46% → ~47%; other percentages unchanged at this rounding; full parity ~320–615 h → ~318–612 h |
|
||||
| 2026-10-11 | minor | Components behaviour suite written from the public documentation (10 tests, 45 table cases) and its fixes: instances now keep overrides of auto layout padding, gap and alignment, layout grids, export settings, prototype interactions and every text property (resizing, paragraph spacing, lists…; these were lost when the main changed); moving, rotating, resizing or re-constraining layers inside an instance, and its auto layout direction, are refused (the instance itself resizes); switching variants keeps an override only where both variants had the same value (text layers keep theirs); swapping from the Assets panel keeps only text overrides; the default variant is the top-left one (instances, Assets thumbnails, code); hidden exposed instances hide their properties; pushing changes from a nested layer goes to the outer main. Added: variant property reordering and duplicate combination warnings. Components 95%/68% → 96%/72%, 6–12 h → 4–9 h; with #133's selection and transform change, core editor ready ~68% → ~69% and mainstream ~46% → ~47% (weekly areas 68.1% → 68.8%); full parity ~320–615 h → ~315–607 h (the totals now also carry #133's area 2 hours) |
|
||||
|
||||
@@ -104,12 +104,25 @@ fn near_path(path: &BezPath, p: Point, dist: f64) -> bool {
|
||||
/// boolean groups are hit where their `result` is.
|
||||
pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option<NodeId> {
|
||||
let p = doc.node(page)?;
|
||||
for c in p.children.iter().rev() {
|
||||
if let Some(h) = hit_rec(doc, *c, pt, tol, 0, result) {
|
||||
return Some(h);
|
||||
}
|
||||
p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result).filter(|_| !masked_out(doc, p, *c, pt)))
|
||||
}
|
||||
|
||||
/// Whether child `c` of `parent` is masked at `pt`: a visible mask below it (the nearest mask
|
||||
/// below it, a hidden one masks nothing) doesn't cover the point, so nothing of `c` shows there.
|
||||
fn masked_out(doc: &Document, parent: &Node, c: NodeId, pt: Point) -> bool {
|
||||
if !doc.has_masks() {
|
||||
return false;
|
||||
}
|
||||
None
|
||||
let below = parent.children.iter().position(|k| *k == c).and_then(|i| parent.children.get(..i)).unwrap_or(&[]);
|
||||
let mask = below.iter().rev().filter_map(|k| doc.node(*k).map(|n| (*k, n))).find(|(_, n)| n.is_mask);
|
||||
mask.is_some_and(|(m, n)| n.visible && !covers(doc, m, n, pt, 0))
|
||||
}
|
||||
|
||||
/// Whether mask `id` draws at `pt` (its shape, or for a group or frame what its layers draw).
|
||||
fn covers(doc: &Document, id: NodeId, n: &Node, pt: Point, depth: usize) -> bool {
|
||||
depth < 512
|
||||
&& (hits_node_itself(doc, id, n, pt, 0.0)
|
||||
|| n.children.iter().any(|c| doc.node(*c).is_some_and(|k| k.visible && !k.is_mask && covers(doc, *c, k, pt, depth + 1))))
|
||||
}
|
||||
|
||||
/// Like [`hit_leaf`], but only within layer `root` (it included).
|
||||
@@ -140,7 +153,7 @@ fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result
|
||||
return None;
|
||||
}
|
||||
for c in n.paint_order().rev() {
|
||||
if let Some(h) = hit_rec(doc, c, pt, tol, depth + 1, result) {
|
||||
if let Some(h) = hit_rec(doc, c, pt, tol, depth + 1, result).filter(|_| !masked_out(doc, n, c, pt)) {
|
||||
return Some(h);
|
||||
}
|
||||
}
|
||||
@@ -173,7 +186,11 @@ pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64, result: Bool
|
||||
return;
|
||||
}
|
||||
for c in n.paint_order().rev() {
|
||||
let before = out.len();
|
||||
rec(doc, c, pt, tol, depth + 1, result, out);
|
||||
if out.len() > before && masked_out(doc, n, c, pt) {
|
||||
out.truncate(before);
|
||||
}
|
||||
}
|
||||
if out.len() > at || hits_node_itself(doc, id, n, pt, tol) {
|
||||
out.insert(at, (id, depth));
|
||||
@@ -182,7 +199,11 @@ pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64, result: Bool
|
||||
let mut out = Vec::new();
|
||||
if let Some(p) = doc.node(page) {
|
||||
for c in p.children.iter().rev() {
|
||||
let before = out.len();
|
||||
rec(doc, *c, pt, tol, 0, result, &mut out);
|
||||
if out.len() > before && masked_out(doc, p, *c, pt) {
|
||||
out.truncate(before);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
|
||||
+62
-14
@@ -76,9 +76,19 @@ pub enum DocError {
|
||||
|
||||
pub type DocResult<T> = Result<T, DocError>;
|
||||
|
||||
/// The set of touched node ids. Never part of equality or serialisation.
|
||||
/// The set of touched node ids, and whether any layer is a mask (worked out when first asked
|
||||
/// after a change: hit testing skips its mask checks without one). Never part of equality or
|
||||
/// serialisation.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Dirty(pub std::collections::BTreeSet<NodeId>);
|
||||
pub struct Dirty(pub std::collections::BTreeSet<NodeId>, std::sync::OnceLock<bool>);
|
||||
|
||||
impl Dirty {
|
||||
/// Record node `id` as touched (edited, added or removed).
|
||||
fn touch(&mut self, id: NodeId) {
|
||||
self.0.insert(id);
|
||||
self.1 = std::sync::OnceLock::new();
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Dirty {
|
||||
fn eq(&self, _: &Self) -> bool {
|
||||
@@ -238,6 +248,11 @@ impl Document {
|
||||
self.dirty.0.extend(ids);
|
||||
}
|
||||
|
||||
/// Whether any layer of the document is a mask.
|
||||
pub fn has_masks(&self) -> bool {
|
||||
*self.dirty.1.get_or_init(|| self.nodes.values().any(|n| n.is_mask))
|
||||
}
|
||||
|
||||
/// Node ids touched so far (without clearing).
|
||||
pub fn dirty(&self) -> &std::collections::BTreeSet<NodeId> {
|
||||
&self.dirty.0
|
||||
@@ -286,7 +301,7 @@ impl Document {
|
||||
pub fn node_mut(&mut self, id: NodeId) -> Option<&mut Node> {
|
||||
let n = self.nodes.get_mut(&id).map(Arc::make_mut);
|
||||
if n.is_some() {
|
||||
self.dirty.0.insert(id);
|
||||
self.dirty.touch(id);
|
||||
}
|
||||
n
|
||||
}
|
||||
@@ -359,7 +374,7 @@ impl Document {
|
||||
0.0,
|
||||
);
|
||||
self.nodes.insert(id, Arc::new(node));
|
||||
self.dirty.0.insert(id);
|
||||
self.dirty.touch(id);
|
||||
self.pages.push(id);
|
||||
id
|
||||
}
|
||||
@@ -395,7 +410,7 @@ impl Document {
|
||||
self.reserve_ids(id);
|
||||
node.parent = Some(parent);
|
||||
self.nodes.insert(id, Arc::new(node));
|
||||
self.dirty.0.insert(id);
|
||||
self.dirty.touch(id);
|
||||
let p = self.get_mut(parent)?;
|
||||
let at = index.unwrap_or(p.children.len()).min(p.children.len());
|
||||
p.children.insert(at, id);
|
||||
@@ -476,11 +491,42 @@ impl Document {
|
||||
}
|
||||
for d in self.subtree(id) {
|
||||
self.nodes.remove(&d);
|
||||
self.dirty.0.insert(d);
|
||||
self.dirty.touch(d);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether sections may go in `parent`: a page or another section (never a frame or a group).
|
||||
pub fn takes_sections(&self, parent: NodeId) -> bool {
|
||||
self.node(parent).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }))
|
||||
}
|
||||
|
||||
/// `id`'s transform in the space of the frame it is in (or of the page): groups and boolean
|
||||
/// groups have no coordinate space of their own, so theirs fold in. X and Y are measured there.
|
||||
pub fn frame_relative(&self, id: NodeId) -> Affine {
|
||||
let Some(n) = self.node(id) else { return Affine::IDENTITY };
|
||||
let mut t = n.transform;
|
||||
let mut parent = n.parent;
|
||||
while let Some(p) = parent.and_then(|p| self.node(p)).filter(|p| p.kind.bounds_follow_children()) {
|
||||
t = p.transform * t;
|
||||
parent = p.parent;
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// The children of `n` that make up its box (a group's bounds, an export's area): all of them,
|
||||
/// except layers a visible mask below them masks, whose part is the mask's.
|
||||
pub fn box_children<'a>(&'a self, n: &'a Node) -> impl Iterator<Item = (NodeId, &'a Node)> + 'a {
|
||||
let mut masked = false;
|
||||
n.children.iter().filter_map(|c| self.node(*c).map(|k| (*c, k))).filter(move |(_, k)| {
|
||||
if k.is_mask {
|
||||
masked = k.visible;
|
||||
return true;
|
||||
}
|
||||
!masked
|
||||
})
|
||||
}
|
||||
|
||||
/// Index of `id` among its siblings.
|
||||
pub fn index_in_parent(&self, id: NodeId) -> Option<usize> {
|
||||
let parent = self.node(id)?.parent?;
|
||||
@@ -500,6 +546,9 @@ impl Document {
|
||||
if node.is_page() {
|
||||
return Err(DocError::Invalid("pages can't be moved into nodes".into()));
|
||||
}
|
||||
if matches!(node.kind, NodeKind::Section { .. }) && !self.takes_sections(new_parent) {
|
||||
return Err(DocError::Invalid("sections go on the page or in other sections, not in frames or groups".into()));
|
||||
}
|
||||
let world = self.world_transform(id);
|
||||
let old_parent = node.parent;
|
||||
let old_index = self.index_in_parent(id);
|
||||
@@ -539,7 +588,7 @@ impl Document {
|
||||
node.parent = None;
|
||||
node.children.clear();
|
||||
self.nodes.insert(id, Arc::new(node));
|
||||
self.dirty.0.insert(id);
|
||||
self.dirty.touch(id);
|
||||
self.pages.push(id);
|
||||
Ok(id)
|
||||
}
|
||||
@@ -621,8 +670,8 @@ impl Document {
|
||||
let reach = n.paint_reach();
|
||||
let mut r = figcraft_geom::transform_rect_bbox(self.world_transform(id), n.local_rect().inflate(reach, reach));
|
||||
if !(n.clip && n.kind.is_frame_like()) || n.kind.bounds_follow_children() {
|
||||
for c in &n.children {
|
||||
if let Some(cr) = self.world_render_bounds(*c) {
|
||||
for (c, _) in self.box_children(n) {
|
||||
if let Some(cr) = self.world_render_bounds(c) {
|
||||
r = r.union(cr);
|
||||
}
|
||||
}
|
||||
@@ -649,11 +698,10 @@ impl Document {
|
||||
}
|
||||
let world = self.world_transform(id);
|
||||
let inv = world.inverse();
|
||||
// Children bounds in the group's current local space.
|
||||
// Children bounds in the group's current local space (a mask stands for what it masks).
|
||||
let mut local: Option<Rect> = None;
|
||||
for c in &n.children {
|
||||
let Some(cn) = self.node(*c) else { continue };
|
||||
let r = figcraft_geom::transform_rect_bbox(inv * self.world_transform(*c), cn.local_rect());
|
||||
for (c, cn) in self.box_children(n) {
|
||||
let r = figcraft_geom::transform_rect_bbox(inv * self.world_transform(c), cn.local_rect());
|
||||
local = Some(local.map_or(r, |l| l.union(r)));
|
||||
}
|
||||
let Some(local) = local else { return Ok(()) };
|
||||
@@ -740,7 +788,7 @@ impl Document {
|
||||
}
|
||||
self.reserve_ids(*new_id);
|
||||
self.nodes.insert(*new_id, Arc::new(c));
|
||||
self.dirty.0.insert(*new_id);
|
||||
self.dirty.touch(*new_id);
|
||||
}
|
||||
let p = self.get_mut(parent)?;
|
||||
let at = index.unwrap_or(p.children.len()).min(p.children.len());
|
||||
|
||||
@@ -266,7 +266,7 @@ impl Document {
|
||||
0.0,
|
||||
);
|
||||
self.nodes.insert(id, Arc::new(node));
|
||||
self.dirty.0.insert(id);
|
||||
self.dirty.touch(id);
|
||||
Arc::make_mut(&mut self.libraries).store = Some(id);
|
||||
id
|
||||
}
|
||||
|
||||
@@ -468,6 +468,8 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (w, h) = (num(p, "width").unwrap_or(ow).max(0.0), num(p, "height").unwrap_or(oh).max(0.0));
|
||||
let fixed_local = Point::new(ow * ax, oh * ay);
|
||||
let before = n.transform * fixed_local;
|
||||
// A section's contents stay where they are on the page, wherever its anchor puts it.
|
||||
let kept = if matches!(n.kind, NodeKind::Section { .. }) { super::props::children_worlds(doc, i) } else { Vec::new() };
|
||||
super::props::resize_node(doc, i, w, h)?;
|
||||
let n = doc.get_mut(i)?;
|
||||
let after = n.transform * Point::new(w * ax, h * ay);
|
||||
@@ -481,6 +483,7 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.auto_resize = if num(p, "height").is_some() { TextAutoResize::None } else { TextAutoResize::Height };
|
||||
}
|
||||
super::props::restore_worlds(doc, &kept)?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"node.create",
|
||||
"Create layer",
|
||||
"{type: rectangle|ellipse|polygon|star|line|arrow|vector|text|frame|section|component|slice, x?, y? (parent-relative), width?, height?, parent? (id; default: the page), index?, name?, text? (for text), path? (SVG path data for vector), props? {node.set keys}, select? (default true)}",
|
||||
"{type: rectangle|ellipse|polygon|star|line|arrow|vector|text|frame|section|component|slice, x?, y? (relative to the parent frame; groups don't count), width?, height?, parent? (id; default: the page), index?, name?, text? (for text), path? (SVG path data for vector), props? {node.set keys}, select? (default true)}",
|
||||
true,
|
||||
create
|
||||
),
|
||||
@@ -173,6 +173,11 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !pn.kind.is_container() {
|
||||
return Err(bad(cmd, format!("{parent} can't contain layers")));
|
||||
}
|
||||
if base == "Section" && !doc.takes_sections(parent) {
|
||||
return Err(bad(cmd, "sections go on the page or in other sections"));
|
||||
}
|
||||
// X and Y are in the frame's space: a group parent has none of its own.
|
||||
let space = if pn.kind.bounds_follow_children() { doc.frame_relative(parent) } else { figcraft_geom::Affine::IDENTITY };
|
||||
let x = num_or(p, "x", 0.0);
|
||||
let y = num_or(p, "y", 0.0);
|
||||
let (mut w, mut h) = match base {
|
||||
@@ -233,7 +238,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = ds.doc_mut();
|
||||
let nid = doc.alloc_id();
|
||||
let mut node = if base == "Section" { Node::new_section(nid, name, w, h) } else { Node::new(nid, kind, name, w, h) };
|
||||
node.transform = figcraft_geom::Affine::translate((x + offset.0, y + offset.1));
|
||||
node.transform = space.inverse() * figcraft_geom::Affine::translate((x + offset.0, y + offset.1));
|
||||
if base == "Arrow" {
|
||||
node.stroke.cap_end = StrokeCap::LineArrow;
|
||||
}
|
||||
|
||||
@@ -512,7 +512,8 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
|
||||
for (k, v) in &clip.images {
|
||||
doc.images.entry(k.clone()).or_insert_with(|| v.clone());
|
||||
}
|
||||
let parent_world = doc.world_transform(parent);
|
||||
// Sections go on the page (or in sections), never in the frame being pasted into.
|
||||
let page = doc.page_of(parent).unwrap_or(parent);
|
||||
let shift = match (at, clip.bounds) {
|
||||
(Some((x, y)), Some(b)) => Affine::translate((x - b.x0, y - b.y0)),
|
||||
_ => Affine::IDENTITY,
|
||||
@@ -525,10 +526,12 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
|
||||
map.insert(n.id, doc.alloc_id());
|
||||
}
|
||||
let mut nodes = nodes.clone();
|
||||
let section = nodes.first().is_some_and(|r| matches!(r.kind, NodeKind::Section { .. }));
|
||||
let into = if section && !doc.takes_sections(parent) { page } else { parent };
|
||||
if let Some(r) = nodes.first_mut() {
|
||||
r.transform = parent_world.inverse() * shift * r.transform;
|
||||
r.transform = doc.world_transform(into).inverse() * shift * r.transform;
|
||||
}
|
||||
out.push(doc.paste_nodes(&nodes, &map, *root, parent, None)?);
|
||||
out.push(doc.paste_nodes(&nodes, &map, *root, into, None)?);
|
||||
all.extend(map);
|
||||
}
|
||||
if let Some(src) = foreign {
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{Result, bad};
|
||||
pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "strokeBottomWeight", "strokeLeftWeight"];
|
||||
|
||||
/// Keys `node.set` understands (documented for agents).
|
||||
pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (parent-relative), width, height, rotation (degrees), \
|
||||
pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
|
||||
fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
|
||||
strokeCap / startCap / endCap, strokeJoin, dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
|
||||
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
|
||||
@@ -274,18 +274,19 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
match k {
|
||||
"x" | "y" => {
|
||||
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
|
||||
let n = doc.get_mut(id)?;
|
||||
let b = figcraft_geom::transform_rect_bbox(n.transform, n.local_rect());
|
||||
let c = n.transform.as_coeffs();
|
||||
// Figma's X/Y are the node's own origin (its rotated top-left) for rotated nodes.
|
||||
// X/Y are the node's own origin (its rotated top-left for rotated nodes), in the
|
||||
// frame it is in (groups have no space of their own).
|
||||
let n = doc.get(id)?;
|
||||
let group = n.parent.filter(|p| doc.node(*p).is_some_and(|pn| pn.kind.bounds_follow_children()));
|
||||
let space = group.map_or(Affine::IDENTITY, |g| doc.frame_relative(g));
|
||||
let c = (space * n.transform).as_coeffs();
|
||||
let (mut tx, mut ty) = (c[4], c[5]);
|
||||
let _ = b;
|
||||
if k == "x" {
|
||||
tx = val;
|
||||
} else {
|
||||
ty = val;
|
||||
}
|
||||
n.transform = Affine::new([c[0], c[1], c[2], c[3], tx, ty]);
|
||||
doc.get_mut(id)?.transform = space.inverse() * Affine::new([c[0], c[1], c[2], c[3], tx, ty]);
|
||||
}
|
||||
"width" | "height" | "w" | "h" => {
|
||||
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
|
||||
@@ -949,12 +950,29 @@ fn set_sizing(cmd: &str, doc: &mut Document, id: NodeId, s: Sizing, horizontal:
|
||||
}
|
||||
|
||||
/// Resize a node's box from its top-left, applying constraints / group scaling to children.
|
||||
/// The page transforms of `id`'s children, to put back with [`restore_worlds`] once a container
|
||||
/// whose contents stay where they are on the page (a section; a frame resized with Cmd/Ctrl held)
|
||||
/// has moved or changed size.
|
||||
pub fn children_worlds(doc: &Document, id: NodeId) -> Vec<(NodeId, Affine)> {
|
||||
doc.node(id).map(|n| n.children.iter().map(|c| (*c, doc.world_transform(*c))).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn restore_worlds(doc: &mut Document, worlds: &[(NodeId, Affine)]) -> Result<()> {
|
||||
for (c, w) in worlds {
|
||||
doc.set_world_transform(*c, *w)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn resize_node(doc: &mut Document, id: NodeId, w: f64, h: f64) -> Result<()> {
|
||||
let n = doc.get(id)?;
|
||||
let (ow, oh) = (n.width, n.height);
|
||||
let group = n.kind.bounds_follow_children();
|
||||
let section = matches!(n.kind, NodeKind::Section { .. });
|
||||
figcraft_layout::set_size(doc, id, w, h);
|
||||
if group {
|
||||
if section {
|
||||
// Sections have no constraints: what's in them stays as it is.
|
||||
} else if group {
|
||||
let kx = if ow > 1e-9 { w / ow } else { 1.0 };
|
||||
let ky = if oh > 1e-9 { h / oh } else { 1.0 };
|
||||
figcraft_layout::scale_group_children(doc, id, kx, ky);
|
||||
@@ -968,7 +986,7 @@ pub fn resize_node(doc: &mut Document, id: NodeId, w: f64, h: f64) -> Result<()>
|
||||
/// The design-panel view of a node (used by the UI and inspect).
|
||||
pub fn describe(doc: &Document, id: NodeId) -> Value {
|
||||
let Some(n) = doc.node(id) else { return Value::Null };
|
||||
let c = n.transform.as_coeffs();
|
||||
let c = doc.frame_relative(id).as_coeffs();
|
||||
let mut v = json!({
|
||||
"id": n.id,
|
||||
"name": n.name,
|
||||
|
||||
@@ -125,6 +125,9 @@ fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result<Value> {
|
||||
if matches!(w, Wrap::Section) && !ids.iter().all(|i| board(doc, *i)) {
|
||||
return Err(bad("object.wrapInSection", "only layers on the page or in a section can be wrapped in a section"));
|
||||
}
|
||||
if !matches!(w, Wrap::Section) && ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))) {
|
||||
return Err(bad("object.group", "sections can't go in groups or frames"));
|
||||
}
|
||||
let index = doc.index_in_parent(top).map(|i| i + 1);
|
||||
let mut bounds = doc.bounds_of(&ids).unwrap_or_default();
|
||||
if matches!(w, Wrap::Section) {
|
||||
|
||||
@@ -142,7 +142,8 @@ pub fn grid_cell_at(doc: &Document, parent: NodeId, p: Point) -> Option<(u32, u3
|
||||
/// being dragged) and their descendants. Inside auto layout the deepest auto layout frame under the
|
||||
/// pointer wins: plain frames there are items of the flow, not destinations (dropping onto a card
|
||||
/// in a list inserts next to it). Elsewhere the deepest frame or section under the pointer wins.
|
||||
/// Inside an instance only its slots (and what's in them) take drops; a slot always does.
|
||||
/// Inside an instance only its slots (and what's in them) take drops; a slot always does. Sections
|
||||
/// being dragged only go into sections.
|
||||
pub fn drop_parent(doc: &Document, page: NodeId, p: Point, exclude: &[NodeId]) -> Option<NodeId> {
|
||||
/// `locked`: inside an instance, outside its slots.
|
||||
fn rec(doc: &Document, id: NodeId, p: Point, exclude: &[NodeId], in_al: bool, locked: bool, depth: usize) -> Option<NodeId> {
|
||||
@@ -167,7 +168,13 @@ pub fn drop_parent(doc: &Document, page: NodeId, p: Point, exclude: &[NodeId]) -
|
||||
let deeper = n.children.iter().rev().find_map(|c| rec(doc, *c, p, exclude, in_al || auto_layout, kids_locked, depth + 1));
|
||||
deeper.or((!kids_locked && (auto_layout || !in_al || crate::components::is_slot(doc, id))).then_some(id))
|
||||
}
|
||||
doc.node(page)?.children.iter().rev().find_map(|c| rec(doc, *c, p, exclude, false, false, 0))
|
||||
let hit = doc.node(page)?.children.iter().rev().find_map(|c| rec(doc, *c, p, exclude, false, false, 0));
|
||||
// Sections go on the page or in sections: a frame under the pointer gives way to the section
|
||||
// it is in.
|
||||
if exclude.iter().any(|m| doc.node(*m).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))) {
|
||||
return hit.and_then(|h| std::iter::once(h).chain(doc.ancestors(h)).find(|a| doc.takes_sections(*a))).filter(|a| *a != page);
|
||||
}
|
||||
hit
|
||||
}
|
||||
|
||||
/// Whether layers dragged on the canvas inside `parent` stay in it (they only move): those of a
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
//! Frames, groups and sections behaviour suite: each case is a rule from the public documentation
|
||||
//! of frames, groups and sections (frames vs. groups when resized, constraints, clip content,
|
||||
//! frame selection, grouping and ungrouping, X/Y relative to the frame, click targets, drawing a
|
||||
//! frame or section around layers, dropping into and out of frames, where sections may go,
|
||||
//! resizing sections) with the result the rule implies. The comment on each case is the one-line
|
||||
//! derivation. Where the documentation is silent the case says so and pins today's behaviour.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
use figcraft_geom::Point;
|
||||
|
||||
fn id(v: Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn frame(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, "frame.create", p))
|
||||
}
|
||||
|
||||
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, "props": {"fills": ["#FF0000"]}});
|
||||
if let Some(parent) = parent {
|
||||
p["parent"] = json!(parent);
|
||||
}
|
||||
id(ex(s, "shape.rectangle", p))
|
||||
}
|
||||
|
||||
fn section(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
id(ex(s, "node.create", json!({"type": "section", "x": x, "y": y, "width": w, "height": h})))
|
||||
}
|
||||
|
||||
/// Page bounds as (x, y, w, h).
|
||||
fn wb(s: &Session, n: NodeId) -> (f64, f64, f64, f64) {
|
||||
let b = s.doc().unwrap().world_bounds(n).unwrap();
|
||||
(b.x0, b.y0, b.width(), b.height())
|
||||
}
|
||||
|
||||
fn parent(s: &Session, n: NodeId) -> Option<NodeId> {
|
||||
s.doc().unwrap().node(n).unwrap().parent
|
||||
}
|
||||
|
||||
fn ev(kind: PointerKind, x: f64, y: f64, cmd: bool) -> PointerEvent {
|
||||
let mut e = PointerEvent::at(kind, x, y, 1.0);
|
||||
e.cmd = cmd;
|
||||
e
|
||||
}
|
||||
|
||||
/// Press at `a`, drag to `b` in a few steps and release.
|
||||
fn drag(s: &mut Session, a: (f64, f64), b: (f64, f64), cmd: bool) {
|
||||
s.pointer(&ev(PointerKind::Down, a.0, a.1, cmd)).unwrap();
|
||||
for k in 1..=4 {
|
||||
let t = f64::from(k) / 4.0;
|
||||
s.pointer(&ev(PointerKind::Move, a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t, cmd)).unwrap();
|
||||
}
|
||||
s.pointer(&ev(PointerKind::Up, b.0, b.1, cmd)).unwrap();
|
||||
}
|
||||
|
||||
/// The RGBA of `node` rendered at 1x, at (`x`, `y`) of its export box.
|
||||
fn pixel(s: &Session, node: NodeId, x: u32, y: u32) -> [u8; 4] {
|
||||
let pix = figcraft_render::render_node(s.doc().unwrap(), node, 1.0).unwrap();
|
||||
let i = ((y * pix.width() + x) * 4) as usize;
|
||||
let d = pix.data();
|
||||
[d[i], d[i + 1], d[i + 2], d[i + 3]]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_a_frame_applies_constraints_and_a_group_scales() {
|
||||
// A 100-wide frame with a 20-wide child at x 10 is resized to 200 (one edit each).
|
||||
let cases = [
|
||||
// Left keeps the distance to the left edge.
|
||||
("left", (10.0, 20.0)),
|
||||
// Right keeps the distance to the right edge: 100 - 30 = 70, so 200 - 70 - 20 = 110.
|
||||
("right", (110.0, 20.0)),
|
||||
// Left & right keep both distances: 10 on the left, 70 on the right, 200 - 80 wide.
|
||||
("leftRight", (10.0, 120.0)),
|
||||
// Center keeps the offset from the centre: moves by (200 - 100) / 2.
|
||||
("center", (60.0, 20.0)),
|
||||
// Scale keeps the proportions: x and width × 2.
|
||||
("scale", (20.0, 40.0)),
|
||||
];
|
||||
for (h, want) in cases {
|
||||
let mut s = Session::new();
|
||||
let f = frame(&mut s, 0.0, 0.0, 100.0, 100.0, None);
|
||||
let c = rect(&mut s, 10.0, 10.0, 20.0, 20.0, Some(f));
|
||||
ex(&mut s, "node.set", json!({"id": c, "props": {"constraints": {"horizontal": h}}}));
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"width": 200}}));
|
||||
let (x, _, w, _) = wb(&s, c);
|
||||
assert_eq!((x, w), want, "{h}");
|
||||
s.undo().unwrap();
|
||||
assert_eq!(wb(&s, c).0, 10.0, "{h}: one undo step");
|
||||
}
|
||||
// Groups have no constraints for their layers: resizing one scales what's in it (0..20 and
|
||||
// 30..50 in a 50-wide group resized to 100: both × 2).
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0, None);
|
||||
let b = rect(&mut s, 30.0, 0.0, 20.0, 20.0, None);
|
||||
let g = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
|
||||
ex(&mut s, "node.set", json!({"id": g, "props": {"width": 100}}));
|
||||
assert_eq!((wb(&s, a), wb(&s, b)), ((0.0, 0.0, 40.0, 20.0), (60.0, 0.0, 40.0, 20.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_resize_of_a_frame_ignores_constraints() {
|
||||
// Documented: holding Cmd (Ctrl) while resizing a frame leaves its layers as they are.
|
||||
for (cmd, want_x, want_w) in [(false, 170.0, 120.0), (true, 70.0, 20.0)] {
|
||||
let mut s = Session::new();
|
||||
let f = frame(&mut s, 0.0, 0.0, 100.0, 100.0, None);
|
||||
let c = rect(&mut s, 70.0, 40.0, 20.0, 20.0, Some(f));
|
||||
let wide = rect(&mut s, 10.0, 70.0, 20.0, 20.0, Some(f));
|
||||
ex(&mut s, "node.set", json!({"id": c, "props": {"constraints": {"horizontal": "right"}}}));
|
||||
ex(&mut s, "node.set", json!({"id": wide, "props": {"constraints": {"horizontal": "leftRight"}}}));
|
||||
ex(&mut s, "select.set", json!({"ids": [f]}));
|
||||
// The right edge's handle, dragged 100 to the right.
|
||||
drag(&mut s, (100.0, 50.0), (200.0, 50.0), cmd);
|
||||
assert_eq!(wb(&s, f).2, 200.0, "cmd {cmd}");
|
||||
// Right: 70 + 100, left & right: 20 + 100 wide; with Cmd both stay as they were.
|
||||
assert_eq!((wb(&s, c).0, wb(&s, wide).2), (want_x, want_w), "cmd {cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_a_section_leaves_its_contents() {
|
||||
// Documented: resizing a section doesn't move or resize what's in it (no constraints there).
|
||||
let mut s = Session::new();
|
||||
let sec = section(&mut s, 0.0, 0.0, 400.0, 300.0);
|
||||
let f = frame(&mut s, 300.0, 200.0, 50.0, 50.0, Some(sec));
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"constraints": {"horizontal": "scale", "vertical": "bottom"}}}));
|
||||
let before = wb(&s, f);
|
||||
ex(&mut s, "node.set", json!({"id": sec, "props": {"width": 800, "height": 600}}));
|
||||
assert_eq!(wb(&s, f), before, "a field edit");
|
||||
// Dragging the left edge: the section's origin moves, the frame doesn't.
|
||||
ex(&mut s, "select.set", json!({"ids": [sec]}));
|
||||
drag(&mut s, (0.0, 300.0), (-100.0, 300.0), false);
|
||||
assert_eq!(wb(&s, sec), (-100.0, 0.0, 900.0, 600.0));
|
||||
assert_eq!(wb(&s, f), before, "a handle drag");
|
||||
// Anchored resizes too.
|
||||
ex(&mut s, "transform.resize", json!({"ids": [sec], "width": 500, "anchor": "r"}));
|
||||
assert_eq!(wb(&s, f), before, "transform.resize");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_content_hides_and_unhides_what_overflows() {
|
||||
// Documented: with Clip content on, what lies outside the frame isn't shown (or clickable);
|
||||
// off, it is. A 100×100 frame with a child from 80 to 140.
|
||||
for (clip, hit_outside, export_w) in [(true, false, 100), (false, true, 140)] {
|
||||
let mut s = Session::new();
|
||||
let f = frame(&mut s, 0.0, 0.0, 100.0, 100.0, None);
|
||||
let c = rect(&mut s, 80.0, 10.0, 60.0, 20.0, Some(f));
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"clip": clip}}));
|
||||
let page = s.page().unwrap();
|
||||
let hit = s.doc().unwrap().hit_test(page, Point::new(120.0, 20.0), 0.0, None);
|
||||
assert_eq!(hit == Some(c), hit_outside, "clip {clip}");
|
||||
assert_eq!(figcraft_render::render_node(s.doc().unwrap(), f, 1.0).unwrap().width(), export_w, "clip {clip}");
|
||||
}
|
||||
// New frames clip (silent on the default for Frame selection: pinned to the frame default).
|
||||
let mut s = Session::new();
|
||||
let f = frame(&mut s, 0.0, 0.0, 10.0, 10.0, None);
|
||||
assert!(s.doc().unwrap().node(f).unwrap().clip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_selection_wraps_the_layers_where_the_topmost_was() {
|
||||
// Documented: Frame selection puts the selected layers in a new frame the size of their
|
||||
// bounds, in the place of the topmost selected layer; the layers don't move on the page.
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0, None);
|
||||
let other = rect(&mut s, 200.0, 0.0, 10.0, 10.0, None);
|
||||
let b = rect(&mut s, 50.0, 40.0, 30.0, 30.0, None);
|
||||
let top = rect(&mut s, 300.0, 0.0, 10.0, 10.0, None);
|
||||
let f = id(ex(&mut s, "object.frameSelection", json!({"ids": [b, a]})));
|
||||
let doc = s.doc().unwrap();
|
||||
// Bounds: 10..80 by 10..70.
|
||||
assert_eq!(wb(&s, f), (10.0, 10.0, 70.0, 60.0));
|
||||
assert_eq!(doc.node(f).unwrap().children, vec![a, b], "the layers keep their order");
|
||||
let page = s.page().unwrap();
|
||||
assert_eq!(doc.node(page).unwrap().children, vec![other, f, top], "where b (the topmost selected) was");
|
||||
assert_eq!((wb(&s, a), wb(&s, b)), ((10.0, 10.0, 20.0, 20.0), (50.0, 40.0, 30.0, 30.0)));
|
||||
assert_eq!(s.selection(), vec![f]);
|
||||
// Inside a frame, the new frame nests there.
|
||||
let outer = frame(&mut s, 500.0, 0.0, 200.0, 200.0, None);
|
||||
let c = rect(&mut s, 20.0, 20.0, 10.0, 10.0, Some(outer));
|
||||
let inner = id(ex(&mut s, "object.frameSelection", json!({"ids": [c]})));
|
||||
assert_eq!(parent(&s, inner), Some(outer));
|
||||
// One undo step.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(parent(&s, c), Some(outer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouping_and_ungrouping_keep_layers_in_place() {
|
||||
// Documented: a group's bounds hug its layers; grouping and ungrouping don't move anything,
|
||||
// also once the group is rotated; ungrouping selects the layers.
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0, 40.0, 20.0, None);
|
||||
let b = rect(&mut s, 60.0, 30.0, 20.0, 20.0, None);
|
||||
ex(&mut s, "node.set", json!({"id": b, "props": {"rotation": 45}}));
|
||||
let before = (wb(&s, a), wb(&s, b));
|
||||
let g = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
|
||||
assert_eq!((wb(&s, a), wb(&s, b)), before);
|
||||
let hug = s.doc().unwrap().bounds_of(&[a, b]).unwrap();
|
||||
assert_eq!(wb(&s, g), (hug.x0, hug.y0, hug.width(), hug.height()), "the group hugs its layers");
|
||||
// Rotating the group turns its layers with it; ungrouping leaves them where they ended up.
|
||||
ex(&mut s, "transform.rotate", json!({"ids": [g], "degrees": 30}));
|
||||
let worlds = |s: &Session| [a, b].map(|i| s.doc().unwrap().world_transform(i));
|
||||
let turned = worlds(&s);
|
||||
ex(&mut s, "object.ungroup", json!({"ids": [g]}));
|
||||
for (x, y) in worlds(&s).iter().zip(turned.iter()) {
|
||||
let (p, q) = (x.as_coeffs(), y.as_coeffs());
|
||||
assert!(p.iter().zip(q.iter()).all(|(u, v)| (u - v).abs() < 1e-9), "{p:?} {q:?}");
|
||||
}
|
||||
assert!(s.doc().unwrap().node(g).is_none());
|
||||
assert_eq!(s.selection(), vec![a, b]);
|
||||
// Nested groups: ungrouping the outer one keeps the inner one.
|
||||
let c = rect(&mut s, 200.0, 0.0, 10.0, 10.0, None);
|
||||
let inner = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
|
||||
let outer = id(ex(&mut s, "object.group", json!({"ids": [inner, c]})));
|
||||
ex(&mut s, "object.ungroup", json!({"ids": [outer]}));
|
||||
assert_eq!(s.doc().unwrap().node(inner).unwrap().children, vec![a, b]);
|
||||
// Silent on what ungrouping does with the group's opacity: pinned (multiplied into the layers).
|
||||
let g = id(ex(&mut s, "object.group", json!({"ids": [c]})));
|
||||
ex(&mut s, "node.set", json!({"id": g, "props": {"opacity": 0.5}}));
|
||||
ex(&mut s, "object.ungroup", json!({"ids": [g]}));
|
||||
assert_eq!(s.doc().unwrap().node(c).unwrap().opacity, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_bounds_follow_their_layers_and_empty_groups_go() {
|
||||
// Documented: a group is as big as its layers; it changes when they do, and deleting its last
|
||||
// layer deletes it.
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0, None);
|
||||
let b = rect(&mut s, 30.0, 0.0, 20.0, 20.0, None);
|
||||
let g = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
|
||||
ex(&mut s, "transform.move", json!({"ids": [b], "dx": 50, "dy": 10}));
|
||||
assert_eq!(wb(&s, g), (0.0, 0.0, 100.0, 30.0));
|
||||
ex(&mut s, "edit.delete", json!({"ids": [a, b]}));
|
||||
assert!(s.doc().unwrap().node(g).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_opacity_applies_to_the_whole_group() {
|
||||
// Documented: a group's opacity applies to the group as one, so overlapping layers in a 50%
|
||||
// group don't show through each other; two 50% layers do (alpha 0.5 + 0.5 × 0.5 = 0.75).
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0, 30.0, 10.0, None);
|
||||
let b = rect(&mut s, 20.0, 0.0, 30.0, 10.0, None);
|
||||
let g = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
|
||||
ex(&mut s, "node.set", json!({"id": g, "props": {"opacity": 0.5}}));
|
||||
let (alone, overlap) = (pixel(&s, g, 5, 5)[3], pixel(&s, g, 25, 5)[3]);
|
||||
assert!(alone.abs_diff(128) <= 1 && overlap == alone, "{alone} {overlap}");
|
||||
ex(&mut s, "node.set", json!({"ids": [a, b], "props": {"opacity": 0.5}}));
|
||||
ex(&mut s, "node.set", json!({"id": g, "props": {"opacity": 1}}));
|
||||
assert!(pixel(&s, g, 25, 5)[3].abs_diff(191) <= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x_and_y_are_relative_to_the_frame_not_the_group() {
|
||||
// Documented: X and Y are measured from the top-left of the frame the layer is in (the canvas
|
||||
// if none); groups don't start a coordinate space of their own.
|
||||
let mut s = Session::new();
|
||||
let f = frame(&mut s, 100.0, 100.0, 300.0, 300.0, None);
|
||||
let a = rect(&mut s, 30.0, 40.0, 20.0, 20.0, Some(f));
|
||||
let b = rect(&mut s, 80.0, 60.0, 20.0, 20.0, Some(f));
|
||||
let g = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
|
||||
let xy = |s: &mut Session, n: NodeId| {
|
||||
let v = ex(s, "node.get", json!({"id": n}));
|
||||
(v["x"].as_f64().unwrap(), v["y"].as_f64().unwrap())
|
||||
};
|
||||
let cases = [
|
||||
// The group sits at the top-left of its layers, in the frame.
|
||||
(g, (30.0, 40.0)),
|
||||
// Its first layer is where the group starts: 30, 40 in the frame, not 0, 0 in the group.
|
||||
(a, (30.0, 40.0)),
|
||||
(b, (80.0, 60.0)),
|
||||
];
|
||||
for (n, want) in cases {
|
||||
assert_eq!(xy(&mut s, n), want, "{n}");
|
||||
}
|
||||
// Setting X places the layer in the frame; the group follows (b now from 50 to 70).
|
||||
ex(&mut s, "node.set", json!({"id": b, "props": {"x": 50}}));
|
||||
assert_eq!(xy(&mut s, b), (50.0, 60.0));
|
||||
assert_eq!(wb(&s, b).0, 150.0);
|
||||
assert_eq!(wb(&s, g), (130.0, 140.0, 40.0, 40.0));
|
||||
// A group on the canvas: page coordinates.
|
||||
let c = rect(&mut s, 500.0, 20.0, 10.0, 10.0, None);
|
||||
let d = rect(&mut s, 520.0, 50.0, 10.0, 10.0, None);
|
||||
ex(&mut s, "object.group", json!({"ids": [c, d]}));
|
||||
assert_eq!(xy(&mut s, d), (520.0, 50.0));
|
||||
// A rotated group: its layers' X/Y are still in the frame's axes.
|
||||
ex(&mut s, "transform.rotate", json!({"ids": [g], "degrees": 90}));
|
||||
let t = s.doc().unwrap().world_transform(a).as_coeffs();
|
||||
let (x, y) = xy(&mut s, a);
|
||||
assert!((x - (t[4] - 100.0)).abs() < 1e-9 && (y - (t[5] - 100.0)).abs() < 1e-9, "{x} {y}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_pick_children_of_top_level_frames_and_whole_groups() {
|
||||
// Documented: top-level frames (also those in sections) act as boards, so a click picks
|
||||
// their direct child; groups and nested frames are picked whole; a double click goes one
|
||||
// level in; Cmd/Ctrl-click picks the layer itself.
|
||||
let mut s = Session::new();
|
||||
let f = frame(&mut s, 0.0, 0.0, 300.0, 200.0, None);
|
||||
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0, Some(f));
|
||||
let b = rect(&mut s, 40.0, 10.0, 20.0, 20.0, Some(f));
|
||||
let g = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
|
||||
let nested = frame(&mut s, 100.0, 100.0, 80.0, 80.0, Some(f));
|
||||
let deep = rect(&mut s, 10.0, 10.0, 20.0, 20.0, Some(nested));
|
||||
let loose = rect(&mut s, 200.0, 20.0, 20.0, 20.0, Some(f));
|
||||
let sec = section(&mut s, 0.0, 400.0, 400.0, 300.0);
|
||||
let sf = frame(&mut s, 20.0, 20.0, 100.0, 100.0, Some(sec));
|
||||
let sr = rect(&mut s, 10.0, 10.0, 20.0, 20.0, Some(sf));
|
||||
let doc = s.doc().unwrap().clone();
|
||||
let pick = |leaf, ctx: &[NodeId], deep| figcraft_doc::geometry::select_target(&doc, leaf, ctx, deep);
|
||||
let enter = |leaf, sel: &[NodeId]| figcraft_doc::geometry::enter_target(&doc, leaf, sel);
|
||||
let cases: [(&str, NodeId, NodeId); 10] = [
|
||||
// A layer in a group in a top-level frame: the group.
|
||||
("group", pick(a, &[], false), g),
|
||||
// A plain child of a top-level frame: itself.
|
||||
("child", pick(loose, &[], false), loose),
|
||||
// A layer in a nested frame: the nested frame (a child of the board).
|
||||
("nested frame", pick(deep, &[], false), nested),
|
||||
// The frame's own background: the frame.
|
||||
("background", pick(f, &[], false), f),
|
||||
// A frame in a section is a top-level frame: its child.
|
||||
("frame in a section", pick(sr, &[], false), sr),
|
||||
// Cmd/Ctrl-click: the layer itself.
|
||||
("deep click", pick(a, &[], true), a),
|
||||
// With the group selected, a click on its layer picks the layer (siblings resolve there).
|
||||
("in the selected group", pick(b, &[a], false), b),
|
||||
// A double click on the selected group goes into it.
|
||||
("double click", enter(a, &[g]), a),
|
||||
// A double click on the selected nested frame goes into it.
|
||||
("double click a frame", enter(deep, &[nested]), deep),
|
||||
// A double click with nothing selected first picks what a click would.
|
||||
("double click, nothing selected", enter(a, &[]), g),
|
||||
];
|
||||
for (rule, got, want) in cases {
|
||||
assert_eq!(got, want, "{rule}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawing_a_frame_or_section_around_layers_puts_them_in_it() {
|
||||
// Documented: drawing a frame (or a section) around layers puts those fully inside it in the
|
||||
// new frame (section); layers only partly inside stay where they are.
|
||||
let mut s = Session::new();
|
||||
let inside = rect(&mut s, 20.0, 20.0, 30.0, 30.0, None);
|
||||
let partly = rect(&mut s, 180.0, 20.0, 40.0, 40.0, None);
|
||||
let out = rect(&mut s, 400.0, 400.0, 10.0, 10.0, None);
|
||||
let page = s.page().unwrap();
|
||||
ex(&mut s, "tool.frame", json!({}));
|
||||
drag(&mut s, (0.0, 0.0), (200.0, 200.0), false);
|
||||
let f = *s.selection().first().unwrap();
|
||||
assert!(matches!(s.doc().unwrap().node(f).unwrap().kind, NodeKind::Frame));
|
||||
assert_eq!((parent(&s, inside), parent(&s, partly), parent(&s, out)), (Some(f), Some(page), Some(page)));
|
||||
assert_eq!(wb(&s, inside), (20.0, 20.0, 30.0, 30.0), "it doesn't move on the page");
|
||||
// One undo step takes the frame away and puts the layer back.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(parent(&s, inside), Some(page));
|
||||
assert!(s.doc().unwrap().node(f).is_none());
|
||||
// Inside a frame: the frame's own layers that end up inside the new one.
|
||||
let board = frame(&mut s, 1000.0, 0.0, 400.0, 400.0, None);
|
||||
let k = rect(&mut s, 50.0, 50.0, 20.0, 20.0, Some(board));
|
||||
ex(&mut s, "tool.frame", json!({}));
|
||||
drag(&mut s, (1020.0, 20.0), (1120.0, 120.0), false);
|
||||
let nf = *s.selection().first().unwrap();
|
||||
assert_eq!((parent(&s, nf), parent(&s, k)), (Some(board), Some(nf)));
|
||||
// A section drawn around frames takes them (and leaves what's only partly inside).
|
||||
ex(&mut s, "tool.section", json!({}));
|
||||
drag(&mut s, (-50.0, -50.0), (1500.0, 500.0), false);
|
||||
let sec = *s.selection().first().unwrap();
|
||||
assert!(matches!(s.doc().unwrap().node(sec).unwrap().kind, NodeKind::Section { .. }));
|
||||
assert_eq!((parent(&s, board), parent(&s, inside), parent(&s, out)), (Some(sec), Some(sec), Some(sec)));
|
||||
assert_eq!(parent(&s, partly), Some(sec), "fully inside this larger one");
|
||||
// A frame drawn around a section leaves the section out (sections don't go in frames).
|
||||
let lone = section(&mut s, 3000.0, 0.0, 100.0, 100.0);
|
||||
ex(&mut s, "tool.frame", json!({}));
|
||||
drag(&mut s, (2900.0, -100.0), (3200.0, 200.0), false);
|
||||
assert_eq!(parent(&s, lone), Some(page));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_into_and_pulling_out_of_frames_follows_the_pointer() {
|
||||
// Documented: a layer dragged over a frame goes into it, decided by where the pointer is
|
||||
// (silent on layers only partly inside: pinned, the pointer decides); dragged out of the frame,
|
||||
// it leaves it.
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
let f = frame(&mut s, 0.0, 0.0, 200.0, 200.0, None);
|
||||
let r = rect(&mut s, 300.0, 0.0, 40.0, 40.0, None);
|
||||
// Pointer at 190 inside the frame; the rectangle spans 170..210, partly outside.
|
||||
drag(&mut s, (320.0, 20.0), (190.0, 20.0), false);
|
||||
assert_eq!(parent(&s, r), Some(f));
|
||||
assert_eq!(wb(&s, r).0, 170.0);
|
||||
drag(&mut s, (190.0, 20.0), (320.0, 20.0), false);
|
||||
assert_eq!(parent(&s, r), Some(page));
|
||||
// Layers of a group stay in it when dragged (the group follows).
|
||||
let a = rect(&mut s, 400.0, 0.0, 20.0, 20.0, None);
|
||||
let b = rect(&mut s, 430.0, 0.0, 20.0, 20.0, None);
|
||||
let g = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
drag(&mut s, (410.0, 10.0), (100.0, 100.0), false);
|
||||
assert_eq!(parent(&s, a), Some(g));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_go_on_the_page_or_in_sections() {
|
||||
// Documented: sections can be nested in sections but can't go in frames or groups.
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
let f = frame(&mut s, 0.0, 0.0, 300.0, 300.0, None);
|
||||
let sec = section(&mut s, 500.0, 0.0, 200.0, 200.0);
|
||||
let outer = section(&mut s, 1000.0, 0.0, 600.0, 600.0);
|
||||
let r = rect(&mut s, 900.0, 0.0, 10.0, 10.0, None);
|
||||
let refused = [
|
||||
("into a frame", json!({"ids": [sec], "parent": f})),
|
||||
("into a group", {
|
||||
let g = id(ex(&mut s, "object.group", json!({"ids": [r]})));
|
||||
json!({"ids": [sec], "parent": g})
|
||||
}),
|
||||
];
|
||||
for (rule, p) in refused {
|
||||
assert!(s.execute("node.reparent", &p).is_err(), "{rule}");
|
||||
assert_eq!(parent(&s, sec), Some(page), "{rule}");
|
||||
}
|
||||
assert!(s.execute("object.group", &json!({"ids": [sec]})).is_err(), "grouping a section");
|
||||
assert!(s.execute("object.frameSelection", &json!({"ids": [sec]})).is_err(), "framing a section");
|
||||
assert!(s.execute("node.create", &json!({"type": "section", "parent": f})).is_err(), "a new section in a frame");
|
||||
// Into a section: fine.
|
||||
ex(&mut s, "node.reparent", json!({"ids": [sec], "parent": outer}));
|
||||
assert_eq!(parent(&s, sec), Some(outer));
|
||||
ex(&mut s, "node.reparent", json!({"ids": [sec], "parent": page}));
|
||||
// Dragged over a frame, a section stays on the page.
|
||||
ex(&mut s, "select.set", json!({"ids": [sec]}));
|
||||
drag(&mut s, (600.0, 100.0), (150.0, 150.0), false);
|
||||
assert_eq!(parent(&s, sec), Some(page));
|
||||
assert_eq!(wb(&s, sec).0, 50.0, "but it moves");
|
||||
// Pasted with a frame selected: on the page, not in the frame.
|
||||
ex(&mut s, "edit.copy", json!({"ids": [sec]}));
|
||||
ex(&mut s, "select.set", json!({"ids": [f]}));
|
||||
let pasted = ex(&mut s, "edit.paste", json!({}));
|
||||
let p = id_of(&pasted["ids"][0]).unwrap();
|
||||
assert_eq!(parent(&s, p), Some(page));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_frames_are_screens_and_export_whole() {
|
||||
// Documented: top-level frames (on the page or in sections) are the screens of prototypes and
|
||||
// the boards that export; a nested frame is neither on its own.
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
let a = frame(&mut s, 0.0, 0.0, 100.0, 200.0, None);
|
||||
let nested = frame(&mut s, 10.0, 10.0, 30.0, 30.0, Some(a));
|
||||
let sec = section(&mut s, 300.0, 0.0, 400.0, 400.0);
|
||||
let b = frame(&mut s, 20.0, 20.0, 50.0, 60.0, Some(sec));
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.top_frames(page), vec![a, b]);
|
||||
assert_eq!(doc.top_level_frame(nested), Some(a));
|
||||
let px = figcraft_render::render_node(doc, a, 2.0).unwrap();
|
||||
assert_eq!((px.width(), px.height()), (200, 400), "the frame's own size at 2x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_and_groups_convert_through_frame_selection_and_remove_frame() {
|
||||
// Documented: Frame selection wraps a group in a frame, Ungroup / Remove frame take a container
|
||||
// away; layers stay where they are either way. (Silent on a one-step convert: not offered.)
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0, None);
|
||||
let b = rect(&mut s, 50.0, 10.0, 20.0, 20.0, None);
|
||||
let g = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
|
||||
let f = id(ex(&mut s, "object.frameSelection", json!({"ids": [g]})));
|
||||
ex(&mut s, "object.ungroup", json!({"ids": [g]}));
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.node(f).unwrap().children, vec![a, b]);
|
||||
assert_eq!((wb(&s, a), wb(&s, f)), ((10.0, 10.0, 20.0, 20.0), (10.0, 10.0, 60.0, 20.0)));
|
||||
let g2 = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
|
||||
ex(&mut s, "object.removeFrame", json!({"ids": [f]}));
|
||||
assert_eq!(parent(&s, g2), s.page().ok());
|
||||
assert_eq!(wb(&s, g2), (10.0, 10.0, 60.0, 20.0));
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use crate::Session;
|
||||
/// A node and (to `depth`) its children, with the properties a designer would check.
|
||||
pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
let Some(n) = doc.node(id) else { return Value::Null };
|
||||
let c = n.transform.as_coeffs();
|
||||
let c = doc.frame_relative(id).as_coeffs();
|
||||
let mut v = json!({
|
||||
"id": n.id,
|
||||
"name": n.name,
|
||||
|
||||
@@ -1251,6 +1251,8 @@ mod frame_label_tests;
|
||||
#[cfg(test)]
|
||||
mod frame_tests;
|
||||
#[cfg(test)]
|
||||
mod frames_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod gesture_preview_tests;
|
||||
#[cfg(test)]
|
||||
mod html_codegen_tests;
|
||||
@@ -1269,6 +1271,8 @@ mod live_tests;
|
||||
#[cfg(test)]
|
||||
mod mask_tests;
|
||||
#[cfg(test)]
|
||||
mod masks_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod measure_tests;
|
||||
#[cfg(test)]
|
||||
mod menu_gaps_tests;
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Masks behaviour suite: each case is a rule from the public documentation of masks (Use as
|
||||
//! mask, what a mask covers, alpha / vector / luminance masks, groups as masks, clicks on masked
|
||||
//! out areas, the mask group's box and exports) with the result the rule implies. The comment on
|
||||
//! each case is the one-line derivation. Where the documentation is silent the case says so and
|
||||
//! pins today's behaviour.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_geom::Point;
|
||||
|
||||
fn id(v: Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, fill: &str, parent: Option<NodeId>) -> NodeId {
|
||||
let mut p = json!({"x": x, "y": y, "width": w, "height": h, "props": {"fills": [fill]}});
|
||||
if let Some(parent) = parent {
|
||||
p["parent"] = json!(parent);
|
||||
}
|
||||
id(ex(s, "shape.rectangle", p))
|
||||
}
|
||||
|
||||
/// A white frame to draw a case in.
|
||||
fn board(s: &mut Session, w: f64, h: f64) -> NodeId {
|
||||
id(ex(s, "frame.create", json!({"x": 0, "y": 0, "width": w, "height": h})))
|
||||
}
|
||||
|
||||
/// The RGB of `node` rendered at 1x, at (`x`, `y`) of its export box.
|
||||
fn rgb(s: &Session, node: NodeId, x: u32, y: u32) -> [u8; 3] {
|
||||
let pix = figcraft_render::render_node(s.doc().unwrap(), node, 1.0).unwrap();
|
||||
let i = ((y * pix.width() + x) * 4) as usize;
|
||||
let d = pix.data();
|
||||
[d[i], d[i + 1], d[i + 2]]
|
||||
}
|
||||
|
||||
const WHITE: [u8; 3] = [255, 255, 255];
|
||||
const RED: [u8; 3] = [255, 0, 0];
|
||||
const GREEN: [u8; 3] = [0, 255, 0];
|
||||
const BLUE: [u8; 3] = [0, 0, 255];
|
||||
|
||||
fn wb(s: &Session, n: NodeId) -> (f64, f64, f64, f64) {
|
||||
let b = s.doc().unwrap().world_bounds(n).unwrap();
|
||||
(b.x0, b.y0, b.width(), b.height())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_as_mask_groups_the_layers_over_the_bottom_one() {
|
||||
// Documented: with several layers selected, Use as mask makes the bottom one the mask and puts
|
||||
// them in a "Mask group" (where the topmost was); with one layer it turns it into a mask (or
|
||||
// back); one undo step.
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0, 50.0, 50.0, "#000000", None);
|
||||
let b = rect(&mut s, 10.0, 10.0, 50.0, 50.0, "#FF0000", None);
|
||||
let c = rect(&mut s, 20.0, 20.0, 50.0, 50.0, "#00FF00", None);
|
||||
let top = rect(&mut s, 200.0, 0.0, 10.0, 10.0, "#0000FF", None);
|
||||
let g = id(ex(&mut s, "object.mask", json!({"ids": [c, a, b]})));
|
||||
let doc = s.doc().unwrap();
|
||||
let gn = doc.node(g).unwrap();
|
||||
assert_eq!((gn.name.as_str(), gn.children.clone()), ("Mask group", vec![a, b, c]));
|
||||
assert!(doc.node(a).unwrap().is_mask && !doc.node(b).unwrap().is_mask);
|
||||
let page = s.page().unwrap();
|
||||
assert_eq!(doc.node(page).unwrap().children, vec![g, top]);
|
||||
assert_eq!(s.selection(), vec![g]);
|
||||
s.undo().unwrap();
|
||||
assert!(s.doc().unwrap().node(g).is_none() && !s.doc().unwrap().node(a).unwrap().is_mask);
|
||||
// One layer: a toggle.
|
||||
ex(&mut s, "object.mask", json!({"ids": [a]}));
|
||||
assert!(s.doc().unwrap().node(a).unwrap().is_mask);
|
||||
ex(&mut s, "object.mask", json!({"ids": [a]}));
|
||||
assert!(!s.doc().unwrap().node(a).unwrap().is_mask);
|
||||
// Release mask: the layer is a plain layer again; silent on the group: pinned (it stays).
|
||||
let g = id(ex(&mut s, "object.mask", json!({"ids": [a, b]})));
|
||||
ex(&mut s, "object.releaseMask", json!({"ids": [a]}));
|
||||
assert!(!s.doc().unwrap().node(a).unwrap().is_mask);
|
||||
assert_eq!(s.doc().unwrap().node(a).unwrap().parent, Some(g));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mask_covers_every_layer_above_it_in_its_parent() {
|
||||
// Documented: a mask shows the layers above it (in its group or frame) only where it is, and
|
||||
// isn't drawn itself; layers below it aren't affected. A 200×100 white board: a blue band
|
||||
// under the mask, the mask at x 50..100, red and green bands above it.
|
||||
let mut s = Session::new();
|
||||
let f = board(&mut s, 200.0, 100.0);
|
||||
rect(&mut s, 0.0, 0.0, 200.0, 20.0, "#0000FF", Some(f));
|
||||
let m = rect(&mut s, 50.0, 0.0, 50.0, 100.0, "#888888", Some(f));
|
||||
let red = rect(&mut s, 0.0, 40.0, 200.0, 20.0, "#FF0000", Some(f));
|
||||
rect(&mut s, 0.0, 70.0, 200.0, 20.0, "#00FF00", Some(f));
|
||||
ex(&mut s, "object.mask", json!({"ids": [m]}));
|
||||
let cases = [
|
||||
// Below the mask: drawn everywhere, and the mask's own fill isn't drawn over it.
|
||||
((10, 10), BLUE),
|
||||
((75, 10), BLUE),
|
||||
// Above the mask: inside it, drawn; outside it, hidden.
|
||||
((75, 50), RED),
|
||||
((10, 50), WHITE),
|
||||
((75, 80), GREEN),
|
||||
((150, 80), WHITE),
|
||||
];
|
||||
for ((x, y), want) in cases {
|
||||
assert_eq!(rgb(&s, f, x, y), want, "({x}, {y})");
|
||||
}
|
||||
// A hidden mask masks nothing (silent on whether it's drawn: it isn't).
|
||||
ex(&mut s, "node.set", json!({"id": m, "props": {"visible": false}}));
|
||||
assert_eq!(rgb(&s, f, 10, 50), RED);
|
||||
ex(&mut s, "node.set", json!({"id": m, "props": {"visible": true}}));
|
||||
// Silent on a second mask higher up: pinned, it starts its own masked run (the green band is
|
||||
// masked by it alone).
|
||||
let m2 = rect(&mut s, 120.0, 0.0, 50.0, 100.0, "#888888", Some(f));
|
||||
ex(&mut s, "node.reorder", json!({"id": m2, "index": 3}));
|
||||
ex(&mut s, "object.mask", json!({"ids": [m2]}));
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().children.get(3), Some(&m2));
|
||||
assert_eq!((rgb(&s, f, 75, 50), rgb(&s, f, 75, 80), rgb(&s, f, 150, 80), rgb(&s, f, 150, 50)), (RED, WHITE, GREEN, WHITE));
|
||||
let _ = red;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_vector_and_luminance_masks() {
|
||||
// Documented: an alpha mask uses the mask's opacity, a vector mask its outline (fully), a
|
||||
// luminance mask its brightness. A red layer over a white board, masked by a full-size layer.
|
||||
let cases: [(&str, &str, f64, std::ops::RangeInclusive<u8>); 6] = [
|
||||
// Alpha, half opacity: red at 50% over white (G and B ≈ 128).
|
||||
("alpha", "#000000", 0.5, 126..=129),
|
||||
// Alpha, opaque (the colour doesn't matter): red.
|
||||
("alpha", "#000000", 1.0, 0..=0),
|
||||
// Vector: the outline counts fully, whatever the opacity.
|
||||
("vector", "#000000", 0.5, 0..=0),
|
||||
// Luminance: white shows the layer, black hides it, mid grey half shows it.
|
||||
("luminance", "#FFFFFF", 1.0, 0..=0),
|
||||
("luminance", "#000000", 1.0, 255..=255),
|
||||
("luminance", "#808080", 1.0, 100..=160),
|
||||
];
|
||||
for (kind, fill, opacity, gb) in cases {
|
||||
let mut s = Session::new();
|
||||
let f = board(&mut s, 100.0, 20.0);
|
||||
let m = rect(&mut s, 0.0, 0.0, 100.0, 20.0, fill, Some(f));
|
||||
rect(&mut s, 0.0, 0.0, 100.0, 20.0, "#FF0000", Some(f));
|
||||
ex(&mut s, "node.set", json!({"id": m, "props": {"opacity": opacity, "maskType": kind}}));
|
||||
ex(&mut s, "object.mask", json!({"ids": [m]}));
|
||||
let [r, g, b] = rgb(&s, f, 50, 10);
|
||||
assert!(r == 255 && gb.contains(&g) && gb.contains(&b), "{kind} {fill} {opacity}: {r} {g} {b}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_can_be_the_mask() {
|
||||
// Documented: any layer can be a mask, a group too: what it draws is the mask. Two squares at
|
||||
// 0..20 and 60..80 mask a red band.
|
||||
let mut s = Session::new();
|
||||
let f = board(&mut s, 100.0, 20.0);
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0, "#000000", Some(f));
|
||||
let b = rect(&mut s, 60.0, 0.0, 20.0, 20.0, "#000000", Some(f));
|
||||
let g = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
|
||||
let red = rect(&mut s, 0.0, 0.0, 100.0, 20.0, "#FF0000", Some(f));
|
||||
ex(&mut s, "object.mask", json!({"ids": [g, red]}));
|
||||
assert!(s.doc().unwrap().node(g).unwrap().is_mask);
|
||||
assert_eq!([10, 40, 70, 90].map(|x| rgb(&s, f, x, 10)), [RED, WHITE, RED, WHITE]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masked_out_areas_dont_take_clicks() {
|
||||
// Documented: what a mask hides can't be clicked; a click there reaches what's behind. A
|
||||
// 50×50 mask over a 100×100 layer, a blue square behind the group at 60..100.
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
let behind = rect(&mut s, 60.0, 60.0, 40.0, 40.0, "#0000FF", None);
|
||||
let m = rect(&mut s, 0.0, 0.0, 50.0, 50.0, "#000000", None);
|
||||
let c = rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#FF0000", None);
|
||||
let hit = |s: &Session, x: f64, y: f64| s.doc().unwrap().hit_test(page, Point::new(x, y), 0.0, None);
|
||||
// Before there's a mask, the layer is hit everywhere (and the next edit makes the mask count).
|
||||
assert_eq!(hit(&s, 75.0, 75.0), Some(c));
|
||||
let g = id(ex(&mut s, "object.mask", json!({"ids": [m, c]})));
|
||||
let cases = [
|
||||
// Inside the mask: the masked layer.
|
||||
((25.0, 25.0), Some(c)),
|
||||
// Outside the mask, over the blue square: the square.
|
||||
((75.0, 75.0), Some(behind)),
|
||||
// Outside the mask, over nothing else: nothing.
|
||||
((75.0, 10.0), None),
|
||||
];
|
||||
for ((x, y), want) in cases {
|
||||
assert_eq!(hit(&s, x, y), want, "({x}, {y})");
|
||||
}
|
||||
// Select layer lists only what's there.
|
||||
let layers = ex(&mut s, "select.layersAt", json!({"x": 75, "y": 75}));
|
||||
let got: Vec<NodeId> = layers["layers"].as_array().unwrap().iter().map(|l| id_of(&l["id"]).unwrap()).collect();
|
||||
assert_eq!(got, vec![behind]);
|
||||
// A click selects the mask group (a group is picked whole).
|
||||
s.pointer(&crate::tools::PointerEvent::at(crate::tools::PointerKind::Down, 25.0, 25.0, 1.0)).unwrap();
|
||||
s.pointer(&crate::tools::PointerEvent::at(crate::tools::PointerKind::Up, 25.0, 25.0, 1.0)).unwrap();
|
||||
assert_eq!(s.selection(), vec![g]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mask_groups_box_and_exports_are_the_masks() {
|
||||
// Silent in the help centre on the box; pinned to what exports show: a mask group is as big as
|
||||
// its mask (plus any layers under the mask), and exports crop to it.
|
||||
let mut s = Session::new();
|
||||
let m = rect(&mut s, 0.0, 0.0, 50.0, 40.0, "#000000", None);
|
||||
let c = rect(&mut s, -30.0, -30.0, 200.0, 200.0, "#FF0000", None);
|
||||
let g = id(ex(&mut s, "object.mask", json!({"ids": [m, c]})));
|
||||
assert_eq!(wb(&s, g), (0.0, 0.0, 50.0, 40.0));
|
||||
let px = figcraft_render::render_node(s.doc().unwrap(), g, 1.0).unwrap();
|
||||
assert_eq!((px.width(), px.height()), (50, 40));
|
||||
assert_eq!(rgb(&s, g, 25, 20), RED);
|
||||
let svg = figcraft_svg::export_svg(s.doc().unwrap(), &[g], &figcraft_svg::SvgOptions::default()).unwrap();
|
||||
assert!(svg.contains("viewBox=\"0 0 50 40\""), "{svg}");
|
||||
assert!(figcraft_pdf::export_nodes_pdf(s.doc().unwrap(), &[g]).is_ok());
|
||||
// Moving the masked layer doesn't change the box; moving the mask does.
|
||||
ex(&mut s, "transform.move", json!({"ids": [c], "dx": 10, "dy": 0}));
|
||||
assert_eq!(wb(&s, g), (0.0, 0.0, 50.0, 40.0));
|
||||
ex(&mut s, "transform.move", json!({"ids": [m], "dx": 10, "dy": 0}));
|
||||
assert_eq!(wb(&s, g), (10.0, 0.0, 50.0, 40.0));
|
||||
// A layer under the mask counts.
|
||||
let under = rect(&mut s, -40.0, 0.0, 10.0, 10.0, "#0000FF", None);
|
||||
ex(&mut s, "node.reparent", json!({"ids": [under], "parent": g, "index": 0}));
|
||||
assert_eq!(wb(&s, g), (-40.0, 0.0, 100.0, 40.0));
|
||||
let _ = (GREEN, BLUE);
|
||||
}
|
||||
@@ -400,6 +400,29 @@ pub(crate) fn pressure_profile(points: &[Point], pressure: &[Option<f32>]) -> Op
|
||||
Some(profile.collect())
|
||||
}
|
||||
|
||||
/// A frame or section drawn around layers takes those of its siblings that lie fully inside it
|
||||
/// (visible and unlocked ones, in their order; a frame never takes a section). Not in auto layout,
|
||||
/// where the new frame is one more item of the flow.
|
||||
fn take_enclosed(doc: &mut Document, drawn: NodeId) -> Result<()> {
|
||||
let Some(n) = doc.node(drawn) else { return Ok(()) };
|
||||
let section = matches!(n.kind, NodeKind::Section { .. });
|
||||
let (Some(parent), Some(area)) = (n.parent.and_then(|p| doc.node(p)), doc.world_bounds(drawn)) else { return Ok(()) };
|
||||
if parent.layout.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let inside: Vec<NodeId> = parent
|
||||
.children
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|c| *c != drawn && doc.node(*c).is_some_and(|k| k.visible && !k.locked && (section || !matches!(k.kind, NodeKind::Section { .. }))))
|
||||
.filter(|c| doc.world_bounds(*c).is_some_and(|b| area.contains(b.origin()) && area.contains(Point::new(b.x1, b.y1))))
|
||||
.collect();
|
||||
for c in inside {
|
||||
doc.reparent(c, drawn, None)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The frame whose name label (see [`ToolState::labels`]) is under `p` on `page`: the topmost
|
||||
/// one drawn, if it can be selected (still there, on the page, visible and unlocked).
|
||||
pub fn label_at(doc: &Document, page: NodeId, labels: &[(NodeId, [Point; 4])], p: Point) -> Option<NodeId> {
|
||||
@@ -1252,15 +1275,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;
|
||||
// What's in a section stays where it is on the page, and so does what's in a frame resized
|
||||
// with Cmd/Ctrl held (its constraints are ignored).
|
||||
let keeps = |doc: &Document, id: NodeId| {
|
||||
doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }) || (ev.cmd && single && n.kind.is_frame_like()))
|
||||
};
|
||||
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 kept = if keeps(doc, *id) { crate::cmd::props::children_worlds(doc, *id) } else { 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]);
|
||||
@@ -1269,9 +1292,11 @@ impl Session {
|
||||
t *= Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, nh]);
|
||||
}
|
||||
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)?;
|
||||
if kept.is_empty() {
|
||||
crate::cmd::props::resize_node(doc, *id, nw.max(0.01), nh.max(0.01))?;
|
||||
} else {
|
||||
figcraft_layout::set_size(doc, *id, nw.max(0.01), nh.max(0.01));
|
||||
crate::cmd::props::restore_worlds(doc, &kept)?;
|
||||
}
|
||||
let n = doc.get_mut(*id)?;
|
||||
if matches!(handle, Handle::Corner(_) | Handle::Edge(1) | Handle::Edge(3)) {
|
||||
@@ -1301,8 +1326,10 @@ impl Session {
|
||||
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);
|
||||
let kept = if keeps(doc, *id) { crate::cmd::props::children_worlds(doc, *id) } else { Vec::new() };
|
||||
doc.set_world_transform(*id, frame * lin.with_translation(origin))?;
|
||||
crate::cmd::props::resize_node(doc, *id, w2, h2)?;
|
||||
crate::cmd::props::restore_worlds(doc, &kept)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -1522,6 +1549,14 @@ impl Session {
|
||||
self.draw_drag(&fake, start, None, parent, &targets)?;
|
||||
self.tool.gesture = Gesture::None;
|
||||
}
|
||||
if matches!(self.tool.tool, ToolId::Frame | ToolId::Section)
|
||||
&& let Some(drawn) = self.selection().first().copied()
|
||||
{
|
||||
let ds = self.ds_mut()?;
|
||||
let before = ds.doc.clone();
|
||||
take_enclosed(ds.doc_mut(), drawn)?;
|
||||
crate::normalize(ds.doc_mut(), &before);
|
||||
}
|
||||
self.commit_interaction()?;
|
||||
if !self.prefs.keep_tool {
|
||||
self.set_tool(ToolId::Move);
|
||||
|
||||
@@ -29,7 +29,7 @@ pub const INSTRUCTIONS: &str = "FigCraft is an interface design tool (Figma-clas
|
||||
groups, shapes, text, components and instances, with auto layout, styles, variables and prototyping. \
|
||||
Coordinates are page units (1 unit = 1 px at 100% zoom), x right and y DOWN. create_node and set_properties \
|
||||
take x/y RELATIVE TO THE PARENT (the page when there is no parent, so page coordinates); inspect_document \
|
||||
reports both parent-relative x/y and absoluteBounds in page coordinates. Children of an auto-layout frame are \
|
||||
reports both x/y relative to the parent frame (groups don't count) and absoluteBounds in page coordinates. Children of an auto-layout frame are \
|
||||
positioned by the layout (set layout, gap, padding, mainAlign, crossAlign, sizingH/sizingV instead). \
|
||||
Every menu item, panel action and shortcut is a command: find ids and params with list_commands (use a filter) \
|
||||
and run them with run_command, or several as one undo step with batch. Commands act on `ids` when given, else \
|
||||
|
||||
@@ -140,12 +140,12 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"create_node",
|
||||
"Create layer",
|
||||
"Create a layer (engine `node.create`) and return its id and inspected properties. Coordinates are page units, y down; x/y are relative to the parent (the page when `parent` is omitted, so then they are page coordinates). Inside an auto-layout frame the position is decided by the layout. The new layer becomes the selection.",
|
||||
"Create a layer (engine `node.create`) and return its id and inspected properties. Coordinates are page units, y down; x/y are relative to the parent frame (the page when there is none, so then they are page coordinates; groups don't count). Inside an auto-layout frame the position is decided by the layout. The new layer becomes the selection.",
|
||||
obj(
|
||||
json!({
|
||||
"type": {"type": "string", "enum": ["frame", "rectangle", "ellipse", "polygon", "star", "line", "arrow", "text", "vector", "section", "component", "slice"], "description": "Layer type"},
|
||||
"x": num("Left, parent-relative (default 0)"),
|
||||
"y": num("Top, parent-relative (default 0)"),
|
||||
"x": num("Left, relative to the parent frame (default 0)"),
|
||||
"y": num("Top, relative to the parent frame (default 0)"),
|
||||
"width": num("Width (default 100; text hugs its content unless width is set, which makes it wrap)"),
|
||||
"height": num("Height (default 100; ignored for lines/arrows and auto-height text)"),
|
||||
"parent": id_schema("Parent frame/group/component id (default: the current page)"),
|
||||
@@ -162,7 +162,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"set_properties",
|
||||
"Set properties",
|
||||
"Set properties on one or more layers as one undo step (engine `node.set`). Keys: name, visible, locked, opacity (0-1), blend, x, y (parent-relative), width, height, rotation (degrees), fill (colour) / fills (array of paints: \"#hex\" or {\"type\":\"solid\",\"color\":\"#hex\"} or {\"type\":\"linearGradient\",\"stops\":[\"#hex\",\"#hex\"]} or stops [{\"position\":0..1,\"color\":\"#hex\"}], optional from/to handles in the unit box; radialGradient too), stroke / strokes, strokeWeight, strokeAlign (inside|center|outside), cornerRadius (n or [tl,tr,br,bl]), effects (array, e.g. {\"type\":\"dropShadow\",\"color\":\"#00000040\",\"offset\":[0,4],\"radius\":8}), clip, layout (null|horizontal|vertical|grid), gap, padding (n or [t,r,b,l]), mainAlign (min|center|max|spaceBetween|spaceAround|spaceEvenly), crossAlign, wrap, sizingH / sizingV (fixed|hug|fill), constraints {horizontal, vertical}, characters, fontFamily, fontStyle, fontSize, lineHeight, letterSpacing, textAlign, textAutoResize. Run list_commands with filter \"node.set\" for the full list. Unknown keys are an error. Returns the updated layers.",
|
||||
"Set properties on one or more layers as one undo step (engine `node.set`). Keys: name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), fill (colour) / fills (array of paints: \"#hex\" or {\"type\":\"solid\",\"color\":\"#hex\"} or {\"type\":\"linearGradient\",\"stops\":[\"#hex\",\"#hex\"]} or stops [{\"position\":0..1,\"color\":\"#hex\"}], optional from/to handles in the unit box; radialGradient too), stroke / strokes, strokeWeight, strokeAlign (inside|center|outside), cornerRadius (n or [tl,tr,br,bl]), effects (array, e.g. {\"type\":\"dropShadow\",\"color\":\"#00000040\",\"offset\":[0,4],\"radius\":8}), clip, layout (null|horizontal|vertical|grid), gap, padding (n or [t,r,b,l]), mainAlign (min|center|max|spaceBetween|spaceAround|spaceEvenly), crossAlign, wrap, sizingH / sizingV (fixed|hug|fill), constraints {horizontal, vertical}, characters, fontFamily, fontStyle, fontSize, lineHeight, letterSpacing, textAlign, textAutoResize. Run list_commands with filter \"node.set\" for the full list. Unknown keys are an error. Returns the updated layers.",
|
||||
obj(
|
||||
json!({"ids": ids_schema("Layer ids (default: the selection)"), "props": {"type": "object", "description": "Property values to set"}}),
|
||||
&["props"],
|
||||
@@ -172,7 +172,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"inspect_document",
|
||||
"Inspect document",
|
||||
"The active document as JSON: name, path, dirty flag, current page, selection, the current page's layer tree (id, name, type, x/y parent-relative, width, height, absoluteBounds in page coordinates, fills, strokes, effects, cornerRadius, layout, sizing, text properties, component links, overrides, bound variables), other pages summarised, styles, variable count and the undo history. Use it to verify every change.",
|
||||
"The active document as JSON: name, path, dirty flag, current page, selection, the current page's layer tree (id, name, type, x/y relative to the parent frame (groups don't count), width, height, absoluteBounds in page coordinates, fills, strokes, effects, cornerRadius, layout, sizing, text properties, component links, overrides, bound variables), other pages summarised, styles, variable count and the undo history. Use it to verify every change.",
|
||||
obj(
|
||||
json!({"depth": int("Tree depth for the current page (default 4)"), "allPages": boolean("Expand every page, not only the current one")}),
|
||||
&[],
|
||||
|
||||
@@ -566,8 +566,9 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]
|
||||
});
|
||||
}
|
||||
});
|
||||
let x = common(doc, sel, |n| (n.transform.as_coeffs()[4] * 100.0).round() / 100.0);
|
||||
let y = common(doc, sel, |n| (n.transform.as_coeffs()[5] * 100.0).round() / 100.0);
|
||||
// In the frame's space: groups have none of their own.
|
||||
let x = common(doc, sel, |n| (doc.frame_relative(n.id).as_coeffs()[4] * 100.0).round() / 100.0);
|
||||
let y = common(doc, sel, |n| (doc.frame_relative(n.id).as_coeffs()[5] * 100.0).round() / 100.0);
|
||||
let rot = common(doc, sel, |n| (figcraft_geom::rotation_degrees(n.transform) * 100.0).round() / 100.0);
|
||||
let parent_al = sel.iter().all(|i| doc.node(*i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()));
|
||||
let absolute = common(doc, sel, |n| n.child_layout.absolute);
|
||||
|
||||
@@ -21,7 +21,8 @@ real UI state. A request that the UI doesn't answer within 60 s gets a timeout e
|
||||
not JSON gets `{"id": null, "ok": false, "error": "bad JSON: …"}`. The implementation is in
|
||||
`crates/ui-egui/src/control.rs` (methods) and `apps/figcraft/src/control_server.rs` (socket).
|
||||
|
||||
Coordinates are page units, y down. `x`/`y` in `node.set`/`node.create` are parent-relative.
|
||||
Coordinates are page units, y down. `x`/`y` in `node.set`/`node.create` are relative to the parent
|
||||
frame (groups have no coordinate space of their own).
|
||||
|
||||
**Windows.** The desktop app can show files in several windows (`window.new`). Every method takes
|
||||
an optional `window` (an id from `window.list`; `0` is the main window) and then runs in that
|
||||
|
||||
+10
-3
@@ -54,9 +54,16 @@ Or in a project's `.mcp.json`:
|
||||
## Conventions agents must know
|
||||
|
||||
- Coordinates are **page units** (1 unit = 1 px at 100% zoom), x right, **y down**.
|
||||
- `create_node` and `set_properties` take `x`/`y` **relative to the parent** (the page when there is
|
||||
no parent, so they are page coordinates then). `inspect_document` reports parent-relative `x`/`y`
|
||||
plus `absoluteBounds` in page coordinates.
|
||||
- `create_node` and `set_properties` take `x`/`y` **relative to the parent frame** (the page when
|
||||
there is none, so they are page coordinates then). Groups and boolean groups have no coordinate
|
||||
space of their own: a layer in a group is placed in the frame (or on the page) the group is in.
|
||||
`inspect_document` reports `x`/`y` the same way, plus `absoluteBounds` in page coordinates.
|
||||
- Frames, groups, sections and masks: drawing a frame or section with the `pointer` tool around
|
||||
layers puts those fully inside it in it; sections go only on the page or in sections (moves,
|
||||
drags, pastes and new sections elsewhere are refused or land on the page); resizing a section
|
||||
leaves its contents where they are, and so does resizing a frame with `cmd` held (constraints
|
||||
ignored); a layer a mask hides can't be clicked there, and a mask group's box and exports are
|
||||
the mask's (plus any layers under the mask).
|
||||
- Children of an auto-layout frame are positioned by the layout: set `layout`, `gap`, `padding`,
|
||||
`mainAlign`, `crossAlign`, `sizingH`/`sizingV` instead of x/y. More auto layout props:
|
||||
- `sizingH` / `sizingV`: `fixed`, `hug` (auto layout frames and text) or `fill` (children in an
|
||||
|
||||
+11
-10
@@ -1,6 +1,6 @@
|
||||
# FigCraft parity with Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; 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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; 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
|
||||
@@ -21,7 +21,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c
|
||||
| Essentials user | **~57%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.88) |
|
||||
| Mainstream practitioner | **~48%** | estimated ([below](#mainstream-practitioner): weekly areas 69.1% × discounts 0.9 × 0.9 × 0.85) |
|
||||
| Remaining to beta | **~173–310 h** one Opus 5.5 agent (~35–70 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Remaining to full parity | **~315–607 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Remaining to full parity | **~314–606 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | |
|
||||
|
||||
The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were
|
||||
@@ -35,8 +35,8 @@ tool; presence was being graded as behaviour in places.
|
||||
|
||||
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|
||||
|---|---|---|---|
|
||||
| Full target (ready for real work) | ~49% | ~300–576 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (8–15) |
|
||||
| Mainstream practitioner | ~48% | ~135–251 h | core editor depth incl. the `.fig` import's tail (53–104), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
|
||||
| Full target (ready for real work) | ~49% | ~299–575 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (8–15) |
|
||||
| Mainstream practitioner | ~48% | ~134–250 h | core editor depth incl. the `.fig` import's tail (52–103), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
|
||||
| Essentials user | ~57% | ~63–115 h | the `.fig` import's tail so the files people send open well (8–15), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
|
||||
|
||||
Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour
|
||||
@@ -101,7 +101,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
||||
| 5 | Paint | 7 | 90% | 67% | Video fills, wide-gamut canvas, HEIC images (animated GIF fills, TIFF images and embedded image colour profiles landed) | 3–6 |
|
||||
| 6 | Strokes | 3 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 |
|
||||
| 7 | Effects | 4 | 100% | 72% | Glass and progressive blur tuned side by side | 3–6 |
|
||||
| 8 | Frames, groups, sections, masks | 5 | 95% | 75% | Visual QA with real files | 2–4 |
|
||||
| 8 | Frames, groups, sections, masks | 5 | 97% | 80% | Visual QA with real files; a one-step frame/group convert (behaviour suite from the documentation landed: masked-out areas don't take clicks, mask group bounds and exports follow the mask, sections kept out of frames and groups, frames and sections drawn around layers take them, section resizing leaves contents in place, X/Y measured in the frame) | 1–3 |
|
||||
| 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files; wrapped-line content alignment (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around and cell alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 |
|
||||
| 10 | Text | 8 | 87% | 68% | Spell check, real-file text metrics, list spacing, hanging punctuation, underline styles (behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed) | 5–10 |
|
||||
| 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries, component playground (behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 |
|
||||
@@ -115,14 +115,14 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
||||
| 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 |
|
||||
| 20 | Figma Draw extras | 2 | 80% | 55% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses). Missing: multi-edit, pressure from macOS/Linux tablets | 2–5 |
|
||||
| 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 |
|
||||
| | **Weighted (÷113)** | 113 | **~80%** | **~58%** | | **157–321** |
|
||||
| | Core editor only (1–16, ÷94) | 94 | ~91% | ~69% | | 52–102 |
|
||||
| | **Weighted (÷113)** | 113 | **~80%** | **~58%** | | **156–320** |
|
||||
| | Core editor only (1–16, ÷94) | 94 | ~91% | ~69% | | 51–101 |
|
||||
|
||||
## By dimension (ready for real work)
|
||||
|
||||
| Dimension | Weight | Ready | Hours | Evidence | Doc |
|
||||
|---|---|---|---|---|---|
|
||||
| Features (depth) | 35 | 57% | 54–104 (areas 1–13, 15, 16, 20) | The table above; core editor ~69% | this file |
|
||||
| Features (depth) | 35 | 57% | 53–103 (areas 1–13, 15, 16, 20) | The table above; core editor ~69% | 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) |
|
||||
@@ -133,7 +133,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
||||
| Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) |
|
||||
| Ecosystem/plugins | 3 | 28% | 15–40 | Own WASM plugins with a Rust SDK and two examples (#101), plugin panels and settings (ABI v2); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) |
|
||||
| AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) |
|
||||
| **Ready for real work** | 100 | **~49%** | **~315–607** | | |
|
||||
| **Ready for real work** | 100 | **~49%** | **~314–606** | | |
|
||||
|
||||
Dimension hours add up to the full-parity total without double counting: the feature-area rows
|
||||
for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are
|
||||
@@ -600,6 +600,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Frames, groups, sections and masks behaviour suite written from the public documentation (15 frame, group and section tests, 6 mask tests) and its fixes: a layer a mask hides can't be clicked there (clicks reach what's behind; Select layer agrees), a mask group's box and exports are the mask's (plus any layers under it), sections stay on the page or in sections (moves, drags, pastes, grouping and new sections in frames refused or kept on the page), drawing a frame or section around layers puts those fully inside it in it, resizing a section leaves its contents where they are (and a frame resized with Cmd/Ctrl held keeps its layers' sizes too), and X/Y of layers in groups are measured in the frame (groups have no coordinate space). Frames, groups, sections, masks 95%/75% → 97%/80%, 2–4 h → 1–3 h; percentages unchanged at this rounding; full parity ~323–621 h → ~322–620 h |
|
||||
| 2026-10-11 | minor | Styles and variables behaviour suite written from the public documentation (18 command-level tests) and its fixes: editing a styled fill, stroke, effect or layout grid list (directly, a paint row, an added fill or a bound variable) unlinks that style instead of being overwritten by the next style edit; applying a style replaces variables bound to what it sets; text styles go on text layers only; deleting a variable (or its collection) unbinds the layers using it, which keep their last value, and aliases to it take its values; bindings are type-checked (a colour variable can't drive a width) and number variables fill text content; aliases are checked when a variable is created. Added: duplicate, move (reorder, into a folder) and rename folder for styles, with a Local styles list grouped by type and folder (click to edit, right-click, drag to reorder); duplicate mode, set as default and move column for modes; per-platform code syntax (Web, iOS, Android) used by generated CSS and the variables JSON; font weight, paragraph spacing/indent and text content scopes for number variables. Found missing: variables inside styles. Styles & variables 92%/72% → 94%/76%, 1–2 h → 3–5 h; percentages unchanged at this rounding; full parity ~323–621 h → ~325–624 h |
|
||||
| 2026-10-11 | minor | Prototyping behaviour suite written from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag starts only for a drag its animation's way and the animation follows the pointer, key combinations (`Shift+K`) fire (and are recorded and imported), an overlay's after-delay timer starts when it opens, instances of interactive components play their variant's interactions and "Change to" changes the right instance, spring presets are physical springs that play until they settle, smart animate overshoots with spring and back easings, fixed layers draw above scrolling content; added scroll to offsets. Prototyping 88%/70% → 90%/74%, 5–9 h → 3–6 h; mainstream weekly areas 68.1% → 68.4%, mainstream ~46% → ~47%; other percentages unchanged at this rounding; full parity ~320–615 h → ~318–612 h |
|
||||
| 2026-10-11 | minor | Components behaviour suite written from the public documentation (10 tests, 45 table cases) and its fixes: instances now keep overrides of auto layout padding, gap and alignment, layout grids, export settings, prototype interactions and every text property (resizing, paragraph spacing, lists…; these were lost when the main changed); moving, rotating, resizing or re-constraining layers inside an instance, and its auto layout direction, are refused (the instance itself resizes); switching variants keeps an override only where both variants had the same value (text layers keep theirs); swapping from the Assets panel keeps only text overrides; the default variant is the top-left one (instances, Assets thumbnails, code); hidden exposed instances hide their properties; pushing changes from a nested layer goes to the outer main. Added: variant property reordering and duplicate combination warnings. Components 95%/68% → 96%/72%, 6–12 h → 4–9 h; with #133's selection and transform change, core editor ready ~68% → ~69% and mainstream ~46% → ~47% (weekly areas 68.1% → 68.8%); full parity ~320–615 h → ~315–607 h (the totals now also carry #133's area 2 hours) |
|
||||
@@ -607,7 +608,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
||||
| 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`…, bindable fields in the Design panel and REST import). Layers panel 95%/78% → 98%/82%, 2–4 h → 1–2 h; Strokes 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~321–618 h → ~320–615 h |
|
||||
| 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~323–621 h → ~321–618 h |
|
||||
| 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 | `.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 → ~322–620 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 |
|
||||
| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h |
|
||||
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; 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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; 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:
|
||||
@@ -77,6 +77,9 @@ interaction feel not yet compared.
|
||||
| Outline mode | done | Outlines, outlines for the selection, mask outlines, frame outlines |
|
||||
| Slices shown on the canvas (View ▸ Slices) | done | dashed rectangles |
|
||||
| Dragging a layer inside a group keeps it in the group | done | frames and auto layout take other drops |
|
||||
| Frames and sections drawn around layers take those fully inside; sections never drop into frames | done | not in auto layout frames |
|
||||
| Resizing a section leaves its contents in place | done | |
|
||||
| Masks: what a mask hides doesn't take clicks; a mask group's box is the mask's | done | |
|
||||
| Layout grids (Ctrl+G) | done | |
|
||||
| Gamut warnings for Display P3 files | done | colour picker note; View ▸ Soft-proof sRGB stripes what sRGB can't show (the canvas itself is sRGB) |
|
||||
| Gradient and padding/gap handles on the canvas | done | |
|
||||
@@ -99,6 +102,7 @@ interaction feel not yet compared.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Frames, groups, sections and masks: drawing a frame or section around layers takes them, sections stay out of frames, section resizing leaves contents in place, masked-out areas don't take clicks, a mask group's box follows the mask, X/Y of layers in groups are measured in the frame |
|
||||
| 2026-10-11 | minor | Layers panel keyboard: F2 renames (also from a row with the keyboard, which gets it back), Shift+F10 opens the context menu at the focused row or below the canvas selection, F8 draws a selection box the arrows move and size; stroke weights bind to variables side by side |
|
||||
| 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 |
|
||||
|
||||
Reference in new issue
Block a user