M7: plugin panels and settings (#124)

Plugin ABI v2: a plugin can show a panel (text, number, checkbox, select, colour, list, notes, buttons) rendered with FigCraft's widgets, react to events with its state kept between calls (each editing callback one undo step, full sandbox limits on every call), and store per-plugin settings (64 KB). plugin.ui / plugin.event / plugin.close drive panels from the CLI and MCP; v1 plugins run unchanged. The Swatch grid example gains a Palettes panel that remembers the last palette.
This commit is contained in:
BFlat
2026-10-11 14:45:19 +07:00
committed by GitHub
parent 09a3b74466
commit a003e3de4f
27 changed files with 1639 additions and 233 deletions
+94 -22
View File
@@ -9,9 +9,13 @@ permissions its manifest asks for, and nothing more.
WebAssembly interpreter (no JIT), in the desktop app and in the browser build alike. There is no
WASI: no file system, network, clock or randomness. A run has an instruction budget (fuel) and a
memory cap, so an endless loop or a runaway allocation fails that run, not the app.
- **One undo step.** Everything a run does is one step in the undo history, named after the plugin.
When a run fails (the plugin returns an error, traps, panics or runs out of fuel or memory), the
document, selection and page are put back as they were.
- **One undo step.** Everything a run does is one step in the undo history, named after the plugin;
so is everything one callback from its panel does. When a run or a callback fails (the plugin
returns an error, traps, panics or runs out of fuel or memory), the document, selection and page
are put back as they were.
- **Panels and settings (API 2).** A plugin can show a small panel of its own while it runs: FigCraft
draws its fields and buttons with its own widgets and calls the plugin back when one is used. A
plugin can also keep settings from one run to the next.
- **Permissions.** The manifest says whether the plugin only reads the document or may edit it, and
can narrow the commands it may call. FigCraft enforces both.
@@ -30,12 +34,30 @@ permissions its manifest asks for, and nothing more.
A message the plugin shows (`notify`) appears as a toast; so does a text result.
From scripts, the CLI and MCP the same commands apply:
**Plugin panels.** A plugin written for API 2 can show a panel while it runs: a floating window,
first placed beside the properties panel (drag its title bar to move it), with the plugin's notes,
fields (text, number, checkbox, menu, list, colour) and buttons, drawn with FigCraft's own widgets
and named for screen readers. Pressing a button or changing a field calls the plugin back with every
field's value; what that callback does is one undo step (none when it changes nothing), and the
plugin can update the panel or close it. The module stays loaded while its panel is open, so the
plugin keeps its state between callbacks. Closing the window ends the plugin's session. One plugin
panel is open at a time: running another plugin that shows one replaces it. An open panel costs
nothing while nobody uses it (no frames are drawn).
If a callback fails, it is rolled back and its message shown; a plugin that returned an error
keeps its panel, one that crashed or ran out of fuel or memory loses it.
From scripts, the CLI and MCP the same commands apply, panels included:
```jsonl
{"command": "plugin.import", "params": {"path": "plugins/tidy-names"}}
{"command": "select.all", "params": {}}
{"command": "plugin.run", "params": {"id": "org.figcraft.examples.tidy-names", "command": "tidy", "params": {"case": "Title Case"}}}
{"command": "plugin.import", "params": {"path": "target/plugins/dist/swatch-grid"}}
{"command": "plugin.run", "params": {"id": "org.figcraft.examples.swatch-grid", "command": "panel"}}
{"command": "plugin.event", "params": {"id": "org.figcraft.examples.swatch-grid", "control": "palette", "value": "Ocean"}}
{"command": "plugin.event", "params": {"id": "org.figcraft.examples.swatch-grid", "control": "generate"}}
{"command": "plugin.close", "params": {"id": "org.figcraft.examples.swatch-grid"}}
```
`figcraft-cli run script.jsonl --in design.figcraft --out tidy.figcraft` runs that headless; over
@@ -47,7 +69,10 @@ plugins: import them in the script.
| `plugin.list` | `{}` | `{plugins: [{id, name, version, description, main, api, permissions, commands, path, loaded}], last: {id, command}}` |
| `plugin.import` | `{path}` or `{manifest, wasm}` | `path` is a `figcraft-plugin.json` or its folder. Without files (the browser, tests), pass the manifest (an object or JSON text) and the module as base64 `wasm`; such a plugin lasts for the session only. A plugin with the same id is replaced. The module is checked first. |
| `plugin.remove` | `{id}` | Uninstalls it. |
| `plugin.run` | `{id, command?, params?, fuel?}` | Runs a command (default: the first) with its parameters checked against the manifest and the missing ones filled with their defaults; `fuel` lowers the instruction budget. Returns `{plugin, command, output, logs, calls, fuel}`; `output` is the plugin's result, parsed as JSON when it is JSON. |
| `plugin.run` | `{id, command?, params?, fuel?}` | Runs a command (default: the first) with its parameters checked against the manifest and the missing ones filled with their defaults; `fuel` lowers the instruction budget (for the panel's callbacks too). Returns `{plugin, command, output, logs, calls, fuel, panel}`; `output` is the plugin's result, parsed as JSON when it is JSON; `panel` is the plugin's open panel (as `plugin.ui` returns it) or null. |
| `plugin.ui` | `{id?}` | The open plugin panel: `{plugin, command, title, controls, values}` (`values`: every field's current value by name), or null. With `id`, only that plugin's. |
| `plugin.event` | `{id, control, value?}` | Uses a control of plugin `id`'s open panel, as a click would: a button (no `value`), or a field set to `value` (checked like a parameter: a number is clamped, a list or menu value must be one of its options, a colour must be hex). The plugin is called back; returns like `plugin.run`. |
| `plugin.close` | `{id}` | Closes plugin `id`'s panel and ends its session. `{closed}` says whether one was open. |
| `plugins.runLast` | `{}` | Runs the last plugin command again. |
| `plugins.manage` | `{}` | Opens the Plugins window; returns the list like `plugin.list`. |
@@ -85,11 +110,11 @@ plugins: import them in the script.
| `name` | Required. Shown in the Plugins menu (64 characters at most). |
| `version`, `description` | Optional text. |
| `main` | Required. The module, a `.wasm` path relative to the manifest, inside its folder. |
| `api` | The plugin API version, `1` (the default). A manifest asking for a newer one is refused. |
| `api` | The plugin API version the plugin was written for: `1` (the default) or `2` (panels and settings). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels fails clearly on an older FigCraft. |
| `permissions.access` | `read` (the default): commands that leave the document as it is; such plugins also run from Dev Mode's Inspect tab. `write`: edits too. |
| `permissions.commands` | Optional allowlist: exact command ids or prefixes ending in `*` (`node.*`). Empty: any command the access allows. |
| `commands` | The menu commands, each `{id, label, params?}`. Without any, the plugin has one, `run`. |
| `params[]` | `{name, label?, type, default?, options?, min?, max?}`; `type` is `text`, `number` (clamped to `min`/`max`), `boolean` or `select` (one of `options`). |
| `params[]` | `{name, label?, type, default?, options?, min?, max?}`; `type` is `text`, `number` (clamped to `min`/`max`), `boolean`, `select` (one of `options`, a menu), `list` (one of `options`, shown as a list) or `color` (hex, `#RRGGBB` or `#RRGGBBAA`). A default that doesn't fit is dropped. |
## What a plugin can't do
@@ -107,13 +132,17 @@ it can handle):
- for a `read` plugin, any command that changes the document (checked before the call for commands
that make an undo step, and after it for the rest: a change is undone and refused).
Limits per run: 1,000,000,000 fuel (about a second or two of plain WebAssembly in a debug build,
far less in a release build), 64 MB of memory, 100,000 host calls (each also costs 10,000 fuel),
16 MB per string either way, 1,000 log lines, and modules up to 16 MB.
Limits per run, and again per panel callback: 1,000,000,000 fuel (about a second or two of plain
WebAssembly in a debug build, far less in a release build), 64 MB of memory, 100,000 host calls
(each also costs 10,000 fuel), 16 MB per string either way, 1,000 log lines, and modules up to
16 MB. A panel spec is at most 64 KB with 64 controls; settings are at most 64 KB per plugin (keys
and JSON values), keys 1–128 characters.
## ABI (version 1)
## ABI (version 2)
Strings are UTF-8 `(ptr, len)` pairs in the plugin's linear memory; all values are `i32`.
Version 2 adds panels and settings to version 1; a version 1 module runs unchanged and gets the
input it always got. Strings are UTF-8 `(ptr, len)` pairs in the plugin's linear memory; all values
are `i32`.
The module **exports** `memory` and `figcraft_run(input_len) -> status`. When `figcraft_run` starts,
the reply buffer holds the input JSON, `input_len` bytes:
@@ -122,6 +151,13 @@ the reply buffer holds the input JSON, `input_len` bytes:
{"api": 1, "command": "grid", "params": {"palette": "#FF5A5F", "columns": 3, "labels": true}, "selection": [12, 15]}
```
`api` is the manifest's. When the plugin's panel calls it back, `figcraft_run` runs again in the
same instance (its memory as the last call left it) with the event and every field's value added:
```json
{"api": 2, "command": "panel", "params": {}, "selection": [], "event": {"control": "generate", "value": null}, "values": {"palette": "Ocean", "columns": 3, "labels": true}}
```
It returns `0` for success; anything else fails the run (and rolls it back), with the text passed
to `set_output` as the error message.
@@ -137,8 +173,37 @@ included, is refused when the plugin is imported):
| `log` | `(ptr, len)` | A line of the run's log (returned in `logs`, and written to FigCraft's log). |
| `notify` | `(ptr, len)` | A short message for the user (a toast in the app). |
| `set_output` | `(ptr, len)` | The run's result (JSON or text), or its error message when it fails. |
| `ui_show` | `(ptr, len) -> status` | API 2. Shows the plugin's panel, or replaces the one showing (the panel spec below, as JSON). `1`: the spec is invalid, the reply says why. |
| `ui_close` | `()` | API 2. Closes the panel when the call returns. |
| `setting_get` | `(key_ptr, key_len) -> status` | API 2. `0`: the reply holds the setting's JSON. `1`: there is none. |
| `setting_set` | `(key_ptr, key_len, value_ptr, value_len) -> status` | API 2. Stores JSON under the key, for this plugin only, kept with the preferences. `1`: refused (not JSON, a bad key, over 64 KB), the reply says why. |
| `setting_delete` | `(key_ptr, key_len)` | API 2. Forgets a setting. |
A pointer or length outside the plugin's memory traps, which fails the run.
A pointer or length outside the plugin's memory traps, which fails the run. Host functions can only
be called while `figcraft_run` runs (not from a start function). Settings are forgotten when the
plugin is removed.
### Panel spec
```json
{"title": "Swatch grid", "controls": [
{"type": "list", "name": "palette", "label": "Palette", "options": ["Sunset", "Ocean"], "value": "Ocean"},
{"type": "note", "label": "#0A2463, #1E5AA8, #3E92CC"},
{"type": "number", "name": "columns", "label": "Columns", "min": 1, "max": 24, "value": 3},
{"type": "boolean", "name": "labels", "label": "Label each swatch", "value": true},
{"type": "color", "name": "accent", "label": "Accent", "value": "#FF5A5F"},
{"type": "button", "name": "generate", "label": "Generate"},
{"type": "button", "name": "close", "label": "Close"}
]}
```
`title` defaults to the plugin's name. Controls are the manifest's parameter types (`text`,
`number`, `boolean`, `select`, `list`, `color`) plus `button` and `note` (text in `label`, up to
1,000 characters, no `name` needed), in order, each `name` unique. A field shows its `value`; a
field without one keeps what the user entered before the panel was updated, so a plugin can update
a note without resetting the other fields. Buttons next to each other share a row; the first button
is the primary one. FigCraft checks the whole spec (sizes, kinds, options, bounds) and drops values
that don't fit their field.
## Writing a plugin in Rust
@@ -149,8 +214,11 @@ A pointer or length outside the plugin's memory traps, which fails the run.
- **Tidy names** (`tidy-names`): cleans up the selected layers' names (extra spaces, the "copy" left
by duplicating) and optionally their letter case, inside the selection too. Two commands: one
with a form (case, include layers inside), and a quick one without.
- **Swatch grid** (`swatch-grid`): a grid of colour swatches from a palette of hex colours, centred in
the view, named after their colours and optionally labelled.
- **Swatch grid** (`swatch-grid`, API 2): a grid of colour swatches, centred in the view, named after
their colours and optionally labelled. **Palettes…** opens a panel with a list of built-in
palettes (picking one shows its colours), columns, labels and a Generate button; the palette
picked last is remembered as a setting for next time. **From hex colours…** takes a palette of
hex colours in the parameters form.
`cargo xtask plugins` (part of `cargo xtask wasm` and `cargo xtask ci`; it needs the
`wasm32-unknown-unknown` target: `rustup target add wasm32-unknown-unknown`) checks, tests and
@@ -202,8 +270,10 @@ entry!(run);
Build it with `cargo build --release --target wasm32-unknown-unknown`, put
`target/wasm32-unknown-unknown/release/my_plugin.wasm` next to a `figcraft-plugin.json` whose `main`
names it, and import the manifest. The SDK gives `call`, `selection`, `log`, `notify` and the
`Input` (`command`, `params` with `str`/`f64`/`bool` helpers, `selection`); returning `Err` fails
the run and rolls it back. Outside WebAssembly every host call fails, so the plugin's own logic can
`Input` (`command`, `params` with `str`/`f64`/`bool` helpers, `selection`, and `event` when the
panel calls back: `control`, `value` and every field's `values`); returning `Err` fails the run and
rolls it back. With `"api": 2` in the manifest: `show_ui(&json!({...}))` and `close_ui()` for the
panel, `setting(key)`, `set_setting(key, &value)` and `delete_setting(key)` for settings. Outside WebAssembly every host call fails, so the plugin's own logic can
be unit-tested with `cargo test`.
Any language that compiles to WebAssembly works the same way: export `memory` and `figcraft_run`,
@@ -211,9 +281,11 @@ import what you need from `figcraft`.
## Not yet
- Plugin-defined UI: a plugin can't yet return a form or panel of its own at run time; parameters
come from the manifest.
- Storage of the plugin's own: plugins can keep data on layers with `node.set {props: {pluginData:
{key: value}}}`, but have no settings of their own between runs.
- Distribution: no plugin browser, signing or automatic updates; plugins are imported from a
- Distribution: no plugin directory, signing or automatic updates; plugins are imported from a
manifest.
- Widgets (objects on the canvas drawn by a plugin) and plugins written for other apps (JavaScript
plugins): neither runs.
- Panels: one at a time; a colour field is a swatch with a hex field (no colour picker); no images,
sliders or free layout inside a panel.
- Data in the document: plugins keep data on layers (pages included) with `node.set {props:
{pluginData: {key: value}}}`; there is no separate per-document store.