Large documents stay interactive: the canvas re-renders only what a pan uncovers or an edit touches, the layers panel lays out only the rows in view, and per-frame work no longer walks the whole page. - Benchmark: `cargo xtask bench [SIZES]` builds synthetic pages (`figcraft_engine::synthetic`: screens with an auto layout header, a card group, button instances and shapes/text) at 1k/10k/50k layers and prints engine/render timings (`figcraft-cli perf --layers 1000,10000`) and headless whole-UI frame timings (an ignored ui-egui test). - Retained canvas rendering (`figcraft_render::Retained`, used by the render worker): views snap to whole device pixels, so a pan shifts the previous image in place and renders only the uncovered strips; an edit renders only where changed layers were and are (`Document::changed_ids` diffs the two versions skipping shared structure; ancestors that paint from their children's pixels - booleans, effects, masks - count whole; background blur reach and noise are allowed for). Zoom, page, options, font or page-level changes, or large changes, render the whole view as before. `ui.inspect` reports `canvas.renderWork`. - The texture image is converted on the render thread, not the UI thread. - Layers panel: only visible rows are laid out (stable ids while scrolling); rows are cached per document version, page, expanded set and filter. - Frame labels and layout grids of off-screen top-level frames are skipped; the Prototype tab's connection sources are cached per document version. - Text: the layout cache keys by a cheap fingerprint confirmed by `TextData` equality (no Debug formatting per lookup, 32k entries) and hands out shared layouts; a layout generation marks renders stale when fonts arrive. Overflowing text is culled by its glyphs, so it no longer vanishes while its box is off screen. Before -> after (release, Windows, 1440x900 view, ms; 10k / 50k layers): - move one layer, re-render @100%: 7.65 / 6.49 -> 0.18 / 0.81 - move one layer, re-render zoomed out: 11.95 / 24.10 -> 0.16 / 0.77 - pan 40 px @100%: 7.53 / 6.82 -> 0.69 / 0.81 - pan 40 px zoomed out: 12.07 / 24.30 -> 1.62 / 4.17 - full render @100% / zoomed to fit: 7.78 / 44.22 -> 6.56 / 38.83 (10k @100%, 50k fit) - UI frame, every screen expanded in layers: 6.16 / 39.07 -> 0.24 / 0.57 - UI frame, scrolling that panel: 6.11 / 40.18 -> 0.43 / 0.40 - UI frame, layers filtered by name: 1.03 / 6.92 -> 0.17 / 0.36 - UI frame, Prototype tab: 0.89 / 6.45 -> 0.18 / 0.80 - UI frame, idle: 0.31 / 1.43 -> 0.11 / 0.39 Unchanged and within budget: single-layer edits through the engine ~0.01 ms, normalize after a one-node edit < 0.01 ms, hit test 26 / 93 us. Left: save/load at 50k (~0.7 s each), zoomed-out renders at 50k (~40 ms). Tests: retained renders match full renders after pans, moves, recolours, hide/remove/add, undo, masks, overflowing text, edits under a background blur and an inner-shadow/noise layer, a drop- shadowed group, and a blur panned in from the view's edge; zoom/page/options/page background render full; junk views don't panic. Layer-row and prototype-source caches follow edits, undo, page switches, expansion and the filter. `changed_ids`, the text cache, and the perf CLI. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
6.0 KiB
FigCraft control protocol
The desktop app can be driven by other programs (tests, scripts, figcraft-cli mcp --attach,
sibling Crafting Apps) through a loopback socket:
cargo run -p figcraft -- --control 7979 [file.figcraft|file.svg|image]
The app listens on 127.0.0.1:<port> only. Each connection carries newline-delimited JSON:
one request object per line, one reply line per request, in order.
request: {"id": 1, "method": "engine.execute", "params": {...}}
reply: {"id": 1, "ok": true, "result": ...}
{"id": 1, "ok": false, "error": "message"}
Requests are queued and run on the UI thread at the start of the next frame, so replies reflect the
real UI state. A request that the UI doesn't answer within 60 s gets a timeout error. A line that is
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.
Methods
engine.execute {command, params?}
Run any command (the same ids as the menus, shortcuts, Actions palette, CLI and MCP). Programmatic
calls never open dialogs: dialog requests are dropped. Returns the command's result (creation
commands return {"id": N}).
{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}
{"id":1,"ok":true,"result":{"id":2}}
{"id":2,"method":"engine.execute","params":{"command":"node.set","params":{"ids":[2],"props":{"fill":"#FF6B6B","cornerRadius":8}}}}
{"id":3,"method":"engine.execute","params":{"command":"command.batch","params":{"label":"Tidy","commands":[{"command":"align.left","params":{"ids":[2,3]}},{"command":"distribute.vertical","params":{"ids":[2,3]}}]}}}
engine.commands {}
Every command: [{id, label, params, menu, shortcut, undoable}].
document.inspect {depth?, allPages?}
The active document as JSON: name, path, dirty, currentPage, selection, pages (the current page's
layer tree to depth, default 8; others summarised unless allPages), styles, variable count,
images, the text being edited and the undo history.
node.get {id, depth?}
One layer (children to depth, default 1). Fields: id, name, type, x, y, width, height, rotation,
absoluteBounds, visible, locked, opacity, blend, fills, strokes, strokeWeight, effects, cornerRadius,
layout (including grid columnTracks / rowTracks), sizing, minWidth / maxWidth /
minHeight / maxHeight, absolute, constraints (children of plain frames and absolute
children), gridCell (a grid auto layout child's column, row and spans), clip, text properties,
component/instance links, overrides, boundVariables, interactions, exports. The auto layout props
that node.set accepts are listed in mcp.md.
ui.inspect {}
UI state: panels and tabs, current tool, canvas rect, dialogs, view, timings (frameMs,
renderMs) and what the last canvas render had to do (canvas.renderWork: full, partial with
the pixels re-rendered after a pan or an edit, or reused).
ui.pointer {kind, x, y | sx, sy, to?, steps?, shift?, alt?, cmd?, ctrl?}
A mouse gesture with the current tool. kind: down, move, up, click, double, hover,
drag (needs to: [x, y]; steps intermediate moves, default 8). Position in page coordinates
(x, y) or screen pixels (sx, sy). Returns {selection, tool}.
{"id":4,"method":"engine.execute","params":{"command":"tool.set","params":{"tool":"ellipse"}}}
{"id":5,"method":"ui.pointer","params":{"kind":"drag","x":200,"y":200,"to":[300,260],"shift":true}}
ui.key {key, shift?, alt?, cmd?, ctrl?}
A key as if typed on the canvas (in vector edit mode the edit keys come first: V P Q X Shift+B tools, arrows nudge points, Delete/Shift+Delete, Cmd+A, Cmd+J): a menu shortcut ({"key":"G","cmd":true} groups), a tool key,
Escape, Delete/Backspace, Enter, arrows (nudge; shift = big nudge; auto layout children move one place along the flow; with cmd they resize the selection). While text is being
edited, keys go to the text editor. Returns {"command": id} for shortcuts.
ui.type {text}
Type into the text being edited (start editing with a double-click, text.edit.begin {id}, or the
text tool).
ui.menu.invoke {id}
Invoke a menu item exactly like clicking it (this may open a dialog). Returns {ok, lastError}.
ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, colorPicker?, variables?, layerFilter?, collapseLayers?}
Switch panels and modes: rightTab design|prototype|inspect, leftTab file|assets, theme,
home (bool), palette (bool: the Actions palette), mainMenu (bool), colorPicker
({fills?, index?}: open the colour picker on the selection's paint; null closes it), variables (bool,
or a collection id or name to show: the Variables editor), layerFilter (string:
open the layers search with that filter; null closes it), collapseLayers (true: collapse the
layers tree). Returns ui.inspect.
ui.screenshot {path}
PNG of the whole window, UI included (needs a visible window). Returns {path, width, height}.
ui.render {path, scale?, ids?}
PNG of the document without UI chrome (works without a window). Without ids it exports the
selection, else the page's top-level layers. Returns the export result.
app.quit {}
Close the app.
Example session (shell)
port=7979
figcraft --control $port &
exec 3<>/dev/tcp/127.0.0.1/$port
echo '{"id":1,"method":"engine.execute","params":{"command":"frame.create","params":{"preset":"iPhone 16","x":0,"y":0}}}' >&3
read -r reply <&3; echo "$reply"
echo '{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/figcraft.png"}}' >&3
read -r reply <&3; echo "$reply"
For agents, prefer the MCP server (figcraft-cli mcp --attach 7979, see docs/mcp.md): it wraps
these methods in documented tools and returns images inline.