mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
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:
+2
-1
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
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" }
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user