Files
figcraft/docs/control-protocol.md
T
BFlatandClaude Opus 5.5 b2738d8f0b M5: prototype device rotation, presentation scaling, scroll momentum, mode picker (#62)
- Device rotation: phones and tablets turn to landscape (`proto.settings {rotation}`), saved in
  the file and imported from REST files (`CCW_90`); the presenter and the preview pane size the
  screen to the turned device and draw its body turned. A rotate toggle sits beside the device
  picker in the Prototype panel.
- Presentation options: fit, fit width, fill and 100% (`proto.presentOptions {scale?,
  deviceFrame?}`, an Options menu in the presenter, Z cycles them, R restarts), the device frame
  shown or hidden, kept between presentations; what overflows the window pans by wheel or drag.
  Presenter textures are capped to the GPU's limit (big frames at 100% no longer overflow it).
- Scroll momentum: drags fling and slow down exactly per frame-rate-independent sub-steps, the
  wheel coasts after it stops, a drag past the ends stretches and springs back; repaints only
  while moving, a press catches a fling.
- Variable modes in the Design panel: frames pick a mode per multi-mode collection, or Auto
  (inherited); `variable.setMode {mode: null}` sets Auto.
- Tests in new modules (engine proto_present_tests, UI present_view_tests); docs and ROADMAP.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:48:29 +07:00

7.7 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 colour profile (colorProfile: srgb or displayP3), 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, slot (a slot layer's property name), 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). While a prototype plays, presenter (full window) and preview (the preview pane) hold the player's state: the current frame and its name, start, history, open overlays, the screen's scroll offset and scrollRange, the device (and whether it is turned to landscape) and the viewport of the frame its screen shows, the size option (scale: fit|fitWidth|fill|actual, deviceFrame), the zoom it shows at, how far the view is panned (pan, panRange) and whether a scroll is still coasting (flinging) (null when nothing plays). presentOptions holds the options the next presentation starts with.

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; with the shape builder, Enter finishes): 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?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?}

Switch panels and modes: rightTab design|prototype|inspect, leftTab file|assets, theme, home (bool), palette (bool: the Actions palette), mainMenu (bool), iconGallery (bool: every UI icon at 12, 16, 24 and 32 px over the window, for reviewing the icon set), 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, or {library, collection?} for an enabled library's collection: the Variables editor), layerFilter (string: open the layers search with that filter; null closes it), collapseLayers (true: collapse the layers tree), exportPreview (bool: the Design panel Export section's preview), preview (true or a frame id: open the prototype preview pane like proto.preview; false or null closes it), presentScroll ([x, y]: scroll the presented screen, the full window's else the pane's, clamped to what it can scroll), colorProfileDialog (srgb|displayP3: open File ▸ Color profile with that profile chosen; null closes it), stylePicker (fill|stroke|effect|grid: open the style picker for the selection; null closes it) with stylePickerEdit (a style id or name: open its editor), stylePickerView (list|grid). Returns ui.inspect (which reports stylePicker and stylePickerView).

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.