M11: a widget opens its plugin's panel (plugin API 5) (#1684)

* M11: a widget opens its plugin's panel (plugin API 5)

Gap 6: a widget callback's ui_show opens the plugin's panel and keeps the module loaded; the panel's callbacks get the widget's id and state and redraw it in the same undo step. Older plugins' ui_show from a widget still opens nothing. The Counter example sets its count from a panel.

* M11: open_panel takes its opener as one value
This commit is contained in:
BFlat
2026-10-31 03:24:32 +07:00
committed by GitHub
parent 9dafa22a20
commit fd8d49fc95
17 changed files with 284 additions and 45 deletions
+2 -1
View File
File diff suppressed because one or more lines are too long
+25 -7
View File
@@ -79,6 +79,24 @@ pub struct OpenPanel {
pub panel: Panel,
pub values: Map<String, Value>,
instance: Instance,
/// API 5: the widget whose callback opened the panel; its callbacks can redraw it.
pub widget: Option<figcraft_doc::NodeId>,
}
/// Show `panel` as plugin `m`'s one open panel (replacing any it has), keeping `instance` loaded
/// for its callbacks. `opener` is the command that opened it, its parameters and its undo step's name.
pub(crate) fn open_panel(
s: &mut Session,
m: &Manifest,
opener: (&str, Value, String),
panel: Panel,
instance: Instance,
widget: Option<figcraft_doc::NodeId>,
) {
let (command, params, label) = opener;
let values = panel.values(&Map::new());
close_panel(s, &m.id);
s.plugins.panels.push(OpenPanel { manifest: m.clone(), command: command.into(), params, label, panel, values, instance, widget });
}
impl OpenPanel {
@@ -498,11 +516,7 @@ pub fn run(s: &mut Session, id: &str, command: Option<&str>, params: &Value, fue
let mut instance = Instance::new(&wasm, &limits).map_err(|e| plugin_error(&m.name, e))?;
let mut out = call_plugin(s, m, &label, &mut instance, &input).map_err(|(e, _)| e)?;
match out.ui.take() {
Some(UiChange::Show(panel)) => {
let values = panel.values(&Map::new());
close_panel(s, &m.id);
s.plugins.panels.push(OpenPanel { manifest: m.clone(), command: c.id.clone(), params, label, panel, values, instance });
}
Some(UiChange::Show(panel)) => open_panel(s, m, (&c.id, params, label), panel, instance, None),
Some(UiChange::Close) => close_panel(s, &m.id),
None => {}
}
@@ -545,7 +559,7 @@ fn report(s: &Session, id: &str, command: &str, out: RunOutput) -> Value {
}
/// Close plugin `id`'s panel, if it has one open.
fn close_panel(s: &mut Session, id: &str) {
pub(crate) fn close_panel(s: &mut Session, id: &str) {
s.plugins.panels.retain(|o| o.manifest.id != id);
}
@@ -579,7 +593,11 @@ fn event(s: &mut Session, p: &Value) -> Result<Value> {
let command = open.command.clone();
let event = json!({"control": control, "value": value});
let input = json!({"api": open.manifest.api, "command": open.command, "params": open.params, "selection": s.selection(), "event": event, "values": open.values});
let mut out = match call_plugin(s, &open.manifest, &open.label, &mut open.instance, &input.to_string()) {
let called = match open.widget {
Some(w) => crate::widgets::panel_callback(s, &open.manifest, &open.label, &mut open.instance, input, w),
None => call_plugin(s, &open.manifest, &open.label, &mut open.instance, &input.to_string()),
};
let mut out = match called {
Ok(out) => out,
Err((e, usable)) => {
// A plugin that returned an error keeps its panel; one that crashed loses it.
+40 -4
View File
@@ -9,20 +9,21 @@
//! the commands its manifest permits. Since API 4 a text layer can be an editable field (edited on
//! the canvas like any text, or with `widget.setText`: the value goes into the state under the
//! field's name and the plugin is called back), and a widget can have a small property menu
//! (actions, toggles, a colour from a few) shown while it's selected (`widget.menu`). See
//! `docs/plugins.md`.
//! (actions, toggles, a colour from a few) shown while it's selected (`widget.menu`). Since API 5 a
//! callback can open the plugin's panel, whose callbacks get the widget and can redraw it
//! ([`panel_callback`]). See `docs/plugins.md`.
use figcraft_color::{ImageScaleMode, Paint};
use figcraft_doc::{Document, Node, NodeId, NodeKind, TextData};
use figcraft_geom::{Affine, Point};
use figcraft_plugin::widget::{MAX_IMAGE_BYTES, MAX_STATE_BYTES, check_menu, check_state, clean_field};
use figcraft_plugin::{Instance, Manifest, RunOutput, WidgetHover, WidgetKind, WidgetNode, WidgetRender};
use figcraft_plugin::{Instance, Manifest, RunOutput, UiChange, WidgetHover, WidgetKind, WidgetNode, WidgetRender};
pub use figcraft_plugin::{MenuItem, MenuKind};
use serde_json::{Map, Value, json};
use crate::cmd::CommandSpec;
use crate::params::{id, num, string};
use crate::plugins::{InstalledPlugin, call_plugin, installed, limits, module_of, plugin_error};
use crate::plugins::{InstalledPlugin, call_plugin, close_panel, installed, limits, module_of, open_panel, plugin_error};
use crate::{Result, Session, bad, spec};
/// Layer data FigCraft keeps for widgets starts with this; plugins can't write it.
@@ -193,9 +194,44 @@ fn callback(s: &mut Session, p: &InstalledPlugin, w: NodeId, event: Option<Value
None if inserting => return Err(bad("widget.insert", format!("{} drew no widget", m.name))),
None => {}
}
// API 5: a widget can open its plugin's panel (an older one's `ui_show` is ignored, as before);
// the module stays loaded for the panel's callbacks, which can redraw this widget.
match out.ui.take() {
Some(UiChange::Show(panel)) if m.api >= 5 => open_panel(s, m, ("widget", json!({}), m.name.clone()), panel, instance, Some(w)),
Some(UiChange::Close) if m.api >= 5 => close_panel(s, &m.id),
_ => {}
}
Ok(out)
}
/// A callback of a panel widget `w` opened: the input gets the widget (its id and state, no
/// `event` of its own), and what the plugin draws rebuilds it, all in one undo step.
pub(crate) fn panel_callback(
s: &mut Session,
m: &Manifest,
label: &str,
instance: &mut Instance,
mut input: Value,
w: NodeId,
) -> std::result::Result<RunOutput, (crate::EngineError, bool)> {
// Only a crash or a spent budget unloads the module; a failed redraw keeps the panel.
let mut usable = true;
s.grouped(label, |s| {
if let Some(o) = input.as_object_mut() {
o.insert("widget".into(), json!({"id": w, "state": state_of(s.doc()?, w)}));
}
let mut out = call_plugin(s, m, label, instance, &input.to_string()).map_err(|(e, u)| {
usable = u;
e
})?;
if let Some(r) = out.widget.take() {
build(s, &m.id, w, &r)?;
}
Ok(out)
})
.map_err(|e| (e, usable))
}
/// Rebuild widget `w` of `plugin` from `r`: its own look and state, and new locked layers inside.
fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<()> {
let cmd = "widget.render";
+2
View File
@@ -617,6 +617,8 @@ mod widget_hover_tests;
#[cfg(test)]
mod widget_input_tests;
#[cfg(test)]
mod widget_panel_tests;
#[cfg(test)]
mod widgets_tests;
#[cfg(test)]
+104
View File
@@ -0,0 +1,104 @@
//! A widget opens its plugin's panel (plugin API 5), as a Figma widget's event handler shows UI:
//! the module stays loaded while the panel is open, and the panel's callbacks get the widget's id
//! and state and can redraw it, one undo step each. An older plugin's `ui_show` from a widget is
//! ignored as before. The test plugin outputs its input; a click on `open` shows a panel, its
//! `keep` button redraws the widget, its `set` button redraws it and closes the panel.
use serde_json::{Value, json};
use crate::Session;
use crate::components::tests::{ex, idv, node};
use crate::plugins_tests::{install, undo_len};
use crate::widgets_tests::{W, err, state};
use figcraft_doc::NodeId;
const ID: &str = "org.test.dial";
fn manifest(api: u32) -> Value {
json!({"id": ID, "name": "Dial", "version": "1.0.0", "main": "d.wasm", "api": api, "widget": true, "permissions": {"access": "read"}})
}
fn look(n: i64, label: &str) -> Value {
json!({"state": {"n": n}, "root": {"type": "frame", "name": "Dial", "layout": "horizontal", "padding": 10, "fill": "#FFFFFF", "children": [
{"type": "text", "name": "Label", "text": label, "size": 12, "color": "#111111"},
{"type": "rectangle", "target": "open", "width": 20, "height": 20, "fill": "#0D99FF"},
]}})
}
fn dial() -> Vec<u8> {
let mut w = W::default();
let (set, keep, open) = (w.has(r#""control":"set""#), w.has(r#""control":"keep""#), w.has(r#""target":"open""#));
let panel = w.show(&json!({"title": "Dial", "controls": [{"type": "button", "name": "set", "label": "Set"}, {"type": "button", "name": "keep", "label": "Keep"}]}));
let (five, seven, one) = (w.render(&look(5, "five")), w.render(&look(7, "seven")), w.render(&look(1, "one")));
w.build(&format!(
"(if {set} (then {five} (call $ui_close) (return (i32.const 0))))
(if {keep} (then {seven} (return (i32.const 0))))
(if {open} (then {panel} (return (i32.const 0))))
{one}"
))
}
fn session(api: u32) -> (Session, NodeId) {
let mut s = Session::new();
install(&mut s, manifest(api), &dial());
let w = idv(&ex(&mut s, "widget.insert", json!({"plugin": ID, "x": 40, "y": 40})));
(s, w)
}
fn label(s: &Session, w: NodeId) -> String {
let first = node(s, w).children[0];
node(s, first).kind.text().unwrap().characters.clone()
}
#[test]
fn a_widget_opens_a_panel_whose_callbacks_redraw_it() {
let (mut s, w) = session(5);
let steps = undo_len(&s);
ex(&mut s, "widget.click", json!({"id": w, "target": "open"}));
let ui = ex(&mut s, "plugin.ui", json!({"id": ID}));
assert_eq!((ui["title"].clone(), ui["command"].clone()), (json!("Dial"), json!("widget")), "{ui}");
assert_eq!(undo_len(&s), steps, "opening the panel changed nothing");
// A button: the plugin gets the widget's id and state and redraws it; the panel stays.
let r = ex(&mut s, "plugin.event", json!({"id": ID, "control": "keep"}));
assert_eq!(r["output"]["widget"], json!({"id": w, "state": {"n": 1}}), "{r}");
assert_eq!(r["output"]["event"]["control"], "keep");
assert_eq!((label(&s, w), state(&mut s, w)), ("seven".into(), json!({"n": 7})));
assert!(!r["panel"].is_null());
assert_eq!(undo_len(&s), steps + 1, "one undo step");
// Another redraws and closes it.
let r = ex(&mut s, "plugin.event", json!({"id": ID, "control": "set"}));
assert_eq!(r["output"]["widget"]["state"], json!({"n": 7}));
assert_eq!(label(&s, w), "five");
assert!(r["panel"].is_null() && ex(&mut s, "plugin.ui", json!({"id": ID})).is_null());
assert_eq!(undo_len(&s), steps + 2);
s.undo().unwrap();
assert_eq!(label(&s, w), "seven");
s.undo().unwrap();
assert_eq!(label(&s, w), "one");
}
#[test]
fn a_removed_widget_keeps_its_panel_but_is_not_redrawn() {
let (mut s, w) = session(5);
ex(&mut s, "widget.click", json!({"id": w, "target": "open"}));
ex(&mut s, "edit.delete", json!({"ids": [w]}));
let steps = undo_len(&s);
let e = err(&mut s, "plugin.event", json!({"id": ID, "control": "keep"}));
assert!(e.contains("removed"), "{e}");
assert_eq!(undo_len(&s), steps, "rolled back");
assert!(!ex(&mut s, "plugin.ui", json!({"id": ID})).is_null(), "the panel stays for its own buttons");
// Undoing the delete brings the widget back for the panel to redraw.
s.undo().unwrap();
ex(&mut s, "plugin.event", json!({"id": ID, "control": "keep"}));
assert_eq!(label(&s, w), "seven");
}
#[test]
fn an_older_widget_plugin_opens_no_panel() {
let (mut s, w) = session(4);
ex(&mut s, "widget.click", json!({"id": w, "target": "open"}));
assert!(ex(&mut s, "plugin.ui", json!({"id": ID})).is_null(), "API 4 ignores ui_show from a widget, as before");
assert_eq!(label(&s, w), "one");
}
+8
View File
@@ -39,6 +39,12 @@ impl W {
)
}
/// Show the panel `spec` (API 2 panels; from a widget, API 5).
pub(crate) fn show(&mut self, spec: &Value) -> String {
let (a, l) = self.s(&spec.to_string());
format!("(drop (call $ui_show (i32.const {a}) (i32.const {l})))")
}
/// Whether the input contains `needle`.
pub(crate) fn has(&mut self, needle: &str) -> String {
let (a, l) = self.s(needle);
@@ -62,6 +68,8 @@ impl W {
(import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32)))
(import "figcraft" "set_output" (func $set_output (param i32 i32)))
(import "figcraft" "widget_render" (func $render (param i32 i32) (result i32)))
(import "figcraft" "ui_show" (func $ui_show (param i32 i32) (result i32)))
(import "figcraft" "ui_close" (func $ui_close))
(memory (export "memory") 4)
{}
(func $has (param $p i32) (param $l i32) (param $n i32) (result i32) (local $i i32) (local $j i32)
+1 -1
View File
@@ -8,7 +8,7 @@ use serde_json::{Map, Value};
/// The manifest's file name.
pub const MANIFEST_FILE: &str = "figcraft-plugin.json";
/// The plugin API version this build implements (`api` in the manifest).
pub const API_VERSION: u32 = 4;
pub const API_VERSION: u32 = 5;
/// Largest manifest read.
pub const MAX_MANIFEST_BYTES: usize = 256 * 1024;
+2 -2
View File
@@ -441,7 +441,7 @@ fn bad_manifests_are_refused() {
json!({"main": "C:\\plugin.wasm"}),
json!({"main": "plugin.js"}),
json!({"main": "a//b.wasm"}),
json!({"api": 5}),
json!({"api": 6}),
json!({"api": 0}),
json!({"permissions": {"access": "root"}}),
json!({"permissions": {"commands": ["*.x"]}}),
@@ -469,6 +469,6 @@ fn api_may_be_a_version_string() {
assert_eq!(manifest(json!({"api": "1.0.0"})).unwrap().api, 1);
assert_eq!(manifest(json!({"api": "2"})).unwrap().api, 2);
assert_eq!(manifest(json!({"api": 2})).unwrap().api, 2);
assert!(manifest(json!({"api": "5.0.0"})).is_err());
assert!(manifest(json!({"api": "6.0.0"})).is_err());
assert!(manifest(json!({"api": "x"})).is_err());
}
+1 -1
View File
@@ -110,7 +110,7 @@ fn widgets_need_api_3() {
assert!(m(2).unwrap_err().contains("API 3"));
let ok = m(3).unwrap();
assert!(ok.widget && ok.commands.is_empty(), "a widget-only plugin has no menu command");
assert!(Manifest::from_value(&json!({"id": "w", "name": "W", "main": "w.wasm", "api": 5})).is_err());
assert!(Manifest::from_value(&json!({"id": "w", "name": "W", "main": "w.wasm", "api": 6})).is_err());
let v2 = Manifest::from_value(&json!({"id": "p", "name": "P", "main": "p.wasm", "api": 2})).unwrap();
assert!(!v2.widget && v2.commands.len() == 1, "v2 manifests are unchanged");
}
+6 -7
View File
@@ -1,6 +1,6 @@
# Where FigCraft falls short of Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the keyboard's Menu key opens the context menu, as Shift+F10 does; gap 14: a collection of this file extends, following its edits at once; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 6: plugin widgets have hover styles; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; gap 14: a collection of this file extends, following its edits at once; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 6: plugin widgets have hover styles; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan) · **Target:** Figma Design (desktop app 126.10.7)
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
@@ -263,14 +263,12 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
once, when the drag ends.
Panel images (2026-10-30): an `image` control shows a picture the plugin carries (base64, at most
256 KB and 4,096 pixels on a side), as wide as a field at most, named by its label.
Widgets open panels (plugin API 5, 2026-10-30): a widget's callback can show its plugin's panel,
whose callbacks get the widget's id and state and redraw it, one undo step each.
- **Missing:** a public catalogue and Community files, publisher accounts and signatures, ratings
and install counts, background update checks, the directory in the browser build; widgets have no hover or
timers, can't open a panel and draw only frames, text, shapes and images.
Figma's own plugins are JavaScript against Figma's plugin API and
and install counts, background update checks, the directory in the browser build; widgets have no
timers, can't open a panel and draw only frames, text, shapes and images;
no images in panels. Figma's own plugins are JavaScript against Figma's plugin API and
don't run.
timers and draw only frames, text, shapes and images. Figma's own plugins are JavaScript against
Figma's plugin API and don't run.
- **Impact:** teams that depend on specific Figma plugins (icons, content, accessibility checks)
can't bring them. FigCraft's CLI, control channel and MCP server cover agent automation better
than Figma does.
@@ -577,6 +575,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | Gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks get the widget and redraw it; the Counter example sets its count from a panel. The entry's duplicated Missing text is merged. Estimate unchanged |
| 2026-10-30 | minor | Gap 4: the keyboard's Menu key opens the context menu, as Shift+F10 does (at the focused layer row or below the canvas selection, with the keyboard in the menu). egui already reported the key; FigCraft's keymap had no name or binding for it. `view.contextMenu` now takes `Menu` too, and the Context menus row is done (`layers_keyboard_tests`). Stale rows corrected: smart-selection spacing has had reorder rings since the "M1: smart-selection drag reordering" landing (the row is still partial: irregular grids and several marked layers aren't covered), and gap 4 and the parity summary quoted shortcut counts from before the last bindings (now 128 bound, none missing, 57 undocumented pinned, as `shortcut_parity_tests` counts). |
| 2026-10-30 | minor | Gap 14: a collection of this file extends (Extend collection on its row, `variable.extendCollection`), following its edits at once; extensions of this file extend again. The help center's rule that an extension takes no variables of its own is noted, not applied. Estimate unchanged |
| 2026-10-30 | minor | Gap 24: stamps stick to the objects they land on and move with them; that item leaves the missing list (the stamp wheel, emotes and stickers stay). Estimate unchanged. Test: `board_tests::stamps_stick_to_what_they_land_on` |
+3 -1
View File
@@ -1619,7 +1619,9 @@ API 4 a widget can have editable text fields and a property menu (both listed wi
and `menu`): `widget.setText {id, field, value}` sets a field as typing in it would (the value,
at most 1,000 characters, goes into the state under the field's name and the plugin redraws; one
undo step), and `widget.menu {id, item, value?}` uses a menu item (a toggle's `value` is `true` or
`false`, by default the opposite of now; a colour's is one of its options). A widget's state is
`false`, by default the opposite of now; a colour's is one of its options). With API 5 a widget's
callback can open its plugin's panel: `plugin.ui` shows it (`command` is `widget`) and
`plugin.event` uses it, the plugin redrawing the widget in the same undo step. A widget's state is
layer data, so it is saved, undone and synced with the file.
```json
+29 -10
View File
@@ -19,7 +19,8 @@ permissions its manifest asks for, and nothing more.
- **Widgets (API 3).** A plugin can put a layer of its own on the canvas: it describes the layer
(frames with auto layout, text, shapes, images), FigCraft builds it, and a click on one of its
parts calls the plugin back to redraw it from its state, which is kept in the document. Since
API 4 a widget can also have editable text fields and a small property menu.
API 4 a widget can also have editable text fields and a small property menu, and since API 5 it
can open its plugin's panel, whose buttons and fields redraw it.
- **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.
- **Distribution.** A team's own `figcraft-server` can keep a plugin directory: plugins are
@@ -92,6 +93,16 @@ and its state (a small JSON object) in its own layer data, so a saved file, a co
a live session carries the widget as it is, and a copy is a widget of its own. A widget whose
plugin isn't installed stays as it was drawn; clicking it says the plugin is missing.
**Panels from widgets (API 5).** As a Figma widget's click handler can show a plugin window, a
widget's callback (a click, a field edit or a menu item) can show its plugin's panel with `ui_show`.
The module then stays loaded while the panel is open, like any panel's, and each of the panel's
callbacks gets the widget too (`widget`: its id and current state, no `event` of its own), so the
plugin can redraw the widget with `widget_render`: the callback and the redraw are one undo step. A
widget removed meanwhile isn't redrawn (the callback fails and rolls back; the panel stays open), and
`ui_close` from either side closes the panel. A plugin written for an older API that calls `ui_show`
from a widget still opens nothing, as before. In a presented prototype, where widgets react on the
prototype's own copy, no panel opens.
**Fields and menus (API 4).** A text layer of a widget can be an editable field: double-click it
with the Move tool and it is edited like any text (all of it selected first). When editing ends
(Escape, or a click elsewhere), the typing is put back and the new value goes to the widget
@@ -241,7 +252,7 @@ plugin directory requests (`plugin.directory.*` says so). Import plugins with `p
| `version`, `description` | Optional text. A plugin published to a [directory](#plugin-directories) needs a `version` of one to four numbers (`1.2.0`), each version newer than the last. |
| `author` | Optional: who made it, as you type it (64 characters at most); plugin directories show it. |
| `main` | Required. The module, a `.wasm` path relative to the manifest, inside its folder. |
| `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings), `3` (widgets) or `4` (saving files, widget fields and property menus). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels, widgets or `saveFile` fails clearly on an older FigCraft. |
| `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings), `3` (widgets), `4` (saving files, widget fields and property menus) or `5` (panels from widgets). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels, widgets or `saveFile` fails clearly on an older FigCraft. |
| `permissions.saveFile` | API 4: `true` lets the plugin offer a file to save (an exporter's output). FigCraft asks the user where it goes with its own Save dialog (a download in the browser); the plugin never gets a path. Manage plugins lists it ("can save files"). A plugin directory's entries don't carry it (their wire format stays as it is), so installing any API 4 plugin from one says it may offer files to save. |
| `widget` | API 3: `true` when the plugin draws a widget (it's listed in Plugins ▸ Widgets). |
| `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. |
@@ -289,11 +300,11 @@ allocation or copy. This also applies when the configured per-call overhead is z
read is charged for the bytes actually returned, not the requested buffer size. `calls` counts
every ABI import, including calls which only query the reply length.
## ABI (version 4)
## ABI (version 5)
Version 2 adds panels and settings to version 1, version 3 adds widgets, and version 4 adds
Version 2 adds panels and settings to version 1, version 3 adds widgets, version 4 adds
`save_file` and widget fields and property menus (new keys in the widget description and new
widget events); a module written for an older version runs unchanged and gets the input it always
widget events), and version 5 lets a widget open its plugin's panel (no new imports); a module written for an older version runs unchanged and gets the input it always
got. A widget description with a field or a menu from a plugin whose manifest says `3` is refused,
and a FigCraft that runs an older API refuses a manifest that asks for `4` when it's imported. Strings are UTF-8 `(ptr, len)` pairs in the plugin's linear memory; all values
are `i32`.
@@ -330,6 +341,13 @@ toggle, the colour picked for a colour item):
{"api": 4, "command": "widget", "params": {}, "selection": [], "widget": {"id": 12, "state": {"count": 2}, "event": {"menu": "color", "value": "#14AE5C"}}}
```
With API 5, a callback of a panel a widget opened has the panel's `event` and `values` and the
widget's id and state:
```json
{"api": 5, "command": "widget", "params": {}, "selection": [], "event": {"control": "set", "value": null}, "values": {"count": 42}, "widget": {"id": 12, "state": {"count": 2}}}
```
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.
@@ -345,13 +363,13 @@ 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_show` | `(ptr, len) -> status` | API 2. Shows the plugin's panel, or replaces the one showing (the panel spec below, as JSON); from a widget's callback, API 5. `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. |
| `save_file` | `(name_ptr, name_len, mime_ptr, mime_len, data_ptr, data_len) -> status` | API 4, needs `permissions.saveFile`. Offers a file to save; FigCraft asks the user where it goes once the run succeeds. `1`: refused (no permission, a bad file name, over 16 MB, a second file in one run), the reply says why. |
| `widget_render` | `(ptr, len) -> status` | API 3. Draws the widget being inserted or clicked (the widget description below, as JSON). `1`: the description is invalid, the reply says why. A widget call that doesn't draw keeps the widget as it was (inserting one must draw it). |
| `widget_render` | `(ptr, len) -> status` | API 3. Draws the widget being inserted or clicked, or (API 5) the widget whose panel called back (the widget description below, as JSON). `1`: the description is invalid, the reply says why. A widget call that doesn't draw keeps the widget as it was (inserting one must draw it). |
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
@@ -440,9 +458,10 @@ colours, and optionally `value`, one of them: the current one). Every redraw rep
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.
- **Counter** (`counter`, API 4): a widget, a count between − and + buttons under a label; each
- **Counter** (`counter`, API 5): a widget, a count between − and + buttons under a label; each
click changes the count kept in the widget's state, a double-click on the label edits it, and its
menu resets the count, counts in tens or colours it. It only reads the document.
menu resets the count, counts in tens or colours it; its **Set count…** opens a panel whose Set
button redraws the widget with the count entered. It only reads the document.
- **Export names** (`export-names`, API 4): saves the names of the selected layers (optionally the
layers inside them too) as `layer-names.txt`. It reads the document and offers the file with
`save_file`; FigCraft asks where it goes.
@@ -523,7 +542,7 @@ import what you need from `figcraft`.
can't use a directory yet.
- Plugins written for other apps (JavaScript plugins) don't run.
- Widgets: clicks, field edits and menu items are their only events (no hover or timers), a field
is plain text (no placeholder, length or single-line setting), they can't open a panel, and their layers are frames, text, rectangles, ellipses and images only (no
is plain text (no placeholder, length or single-line setting), and their layers are frames, text, rectangles, ellipses and images only (no
vectors, components or variables).
- Panels: no free layout inside a panel; images can't be clicked.
- Data in the document: plugins keep data on layers (pages included) with `node.set {props:
+2 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,10 +1,10 @@
{
"id": "org.figcraft.examples.counter",
"name": "Counter",
"version": "1.1.0",
"description": "A widget that counts on the canvas: click its + and − buttons, double-click its label to edit it, and use its menu to reset it, count in tens or colour it. Everything is kept in the document.",
"version": "1.2.0",
"description": "A widget that counts on the canvas: click its + and − buttons, double-click its label to edit it, and use its menu to reset it, set it in a panel, count in tens or colour it. Everything is kept in the document.",
"main": "counter.wasm",
"api": 4,
"api": 5,
"widget": true,
"permissions": { "access": "read" }
}
+50 -4
View File
@@ -1,9 +1,9 @@
//! Counter: a widget on the canvas with − and + buttons around a count, a label you can edit on the
//! canvas, and a property menu (reset, steps of ten, a colour). Each click, edit or menu choice
//! calls the plugin back with the widget's state; it hands back the new state and the redrawn
//! widget.
//! widget. Its menu's "Set count…" opens a panel (API 5) whose Set button redraws the widget.
use figcraft_plugin_sdk::{Input, Result, Value, Widget, entry, json, render_widget};
use figcraft_plugin_sdk::{Event, Input, Result, Value, Widget, close_ui, entry, json, render_widget, show_ui};
/// The count stays within ± this.
const LIMIT: i64 = 999_999;
@@ -76,14 +76,54 @@ fn view(c: &Counter, label: &str) -> Value {
fn menu(c: &Counter) -> Value {
json!([
{"item": "reset", "label": "Reset", "type": "action"},
{"item": "set", "label": "Set count…", "type": "action"},
{"item": "big", "label": "Steps of 10", "type": "toggle", "checked": c.big},
{"item": "color", "label": "Colour", "type": "color", "options": COLORS, "value": c.color},
])
}
/// The panel "Set count…" opens, showing `c`'s count.
fn set_panel(c: &Counter) -> Value {
json!({"title": "Set count", "controls": [
{"type": "number", "name": "count", "label": "Count", "min": -LIMIT, "max": LIMIT, "value": c.count},
{"type": "button", "name": "set", "label": "Set"},
{"type": "button", "name": "cancel", "label": "Cancel"},
]})
}
/// `c` after its panel's `e`: Set takes the count entered; Cancel changes nothing.
fn from_panel(mut c: Counter, e: &Event) -> Counter {
if e.control == "set"
&& let Some(n) = e.values.get("count").and_then(Value::as_f64).filter(|n| n.is_finite())
{
// Panel numbers arrive as decimals; the bounds keep the cast exact.
c.count = n.round().clamp(-LIMIT as f64, LIMIT as f64) as i64;
}
c
}
fn run(input: Input) -> Result<Value> {
let w = input.widget.ok_or("Insert the counter from Plugins ▸ Widgets")?;
let c = Counter::from_state(&w.state).next(&w);
let c = Counter::from_state(&w.state);
let c = match &input.event {
// The "Set count…" panel: Set redraws and closes it, Cancel only closes it, and typing in
// its field waits for them.
Some(e) if e.control == "set" => {
close_ui();
from_panel(c, e)
}
Some(e) => {
if e.control == "cancel" {
close_ui();
}
return Ok(json!({"count": c.count}));
}
None if w.menu.as_deref() == Some("set") => {
show_ui(&set_panel(&c))?;
return Ok(json!({"count": c.count}));
}
None => c.next(&w),
};
// An edited label is in the state already, under its field's name.
let label = w.state.get("label").and_then(Value::as_str).unwrap_or("Counter").to_string();
let state = json!({"count": c.count, "big": c.big, "color": c.color, "label": label});
@@ -118,8 +158,14 @@ mod tests {
let v = view(&c(3), "Score");
assert_eq!((v["children"][0]["text"].clone(), v["children"][1]["children"][1]["text"].clone()), (json!("Score"), json!("3")));
assert_eq!(view(&c(3), " ")["children"][0]["text"], PLACEHOLDER);
assert_eq!(menu(&c(0))[1]["checked"], false);
assert_eq!(menu(&c(0))[2]["checked"], false);
assert_eq!(v["children"][1]["children"][0]["hover"], json!({"fill": "#E0E0E0"}), "buttons darken under the pointer");
assert!(run(Input::default()).is_err(), "it only runs as a widget");
let ev = |control: &str, count: Value| Event { control: control.into(), values: json!({"count": count}), ..Event::default() };
assert_eq!(from_panel(c(3), &ev("set", json!(42.0))).count, 42);
assert_eq!(from_panel(c(3), &ev("set", json!(5_000_000))).count, LIMIT);
assert_eq!(from_panel(c(3), &ev("cancel", json!(42))).count, 3);
assert_eq!(set_panel(&c(3))["controls"][0]["value"], 3);
assert_eq!(menu(&c(0))[1]["item"], "set");
}
}
+3 -1
View File
@@ -30,7 +30,9 @@
//! With API 4 a widget can also have editable text fields (a text layer with a `field` name; an
//! edit calls back with [`Widget::field`] and [`Widget::value`], and the value is already in the
//! state under the field's name) and a property menu (`menu` beside `root`; choosing an item calls
//! back with [`Widget::menu`] and, for a toggle or a colour, [`Widget::value`]).
//! back with [`Widget::menu`] and, for a toggle or a colour, [`Widget::value`]). With API 5 a
//! widget's callback can [`show_ui`]: the panel's callbacks then come with both [`Input::event`] and
//! [`Input::widget`] (its state; no target, field or menu), and [`render_widget`] redraws it.
//!
//! The SDK is `no_std` (it needs `alloc`); outside WebAssembly every host call fails, so a plugin's
//! own logic can still be unit-tested on the host. See `docs/plugins.md` for the ABI underneath.