mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 10:56:48 -06:00
Widgets for FigCraft's own WebAssembly plugins (plugin API 3; API 1 and 2 plugins run unchanged), plus the panel tail.
- **Widgets.** A manifest with `"api": 3, "widget": true` makes a widget plugin. Plugins > Widgets (or `widget.insert {plugin, x?, y?}`) puts its widget on the page: the plugin is called with `widget: {id, state, event}` and hands back a description through the new `widget_render` import (`{state?, root}`: frames with auto layout, text, rectangles, ellipses and images it carries as base64). FigCraft validates the description (4 MB, 256 layers, 16 deep, finite numbers within ranges, hex colours, text length and control characters, a 16 KB state, images 1 MB each / 2 MB together / 4096 px a side) and builds it as a frame whose layers are locked, so the widget moves as one layer.
- **Clicks.** A plain click with the Move tool on a part the plugin named as a `target` (the cursor turns into a pointing hand there; quick repeated clicks each count) calls the plugin back in a fresh, metered sandbox (same fuel, memory and call limits as runs; nothing kept between calls, so the state and the click alone decide the result) as one undo step, and the widget is redrawn. Dragging moves it; Shift/Cmd/Alt clicks only select. Agents use `widget.click {id, target}` and `widget.list`.
- **State in the document.** The plugin id and the state live in the widget's own layer data (`figcraft.widget*`), so widgets survive save and reload, duplicate/copy and paste (the copy is a widget of its own), undo/redo, export (the built layers draw) and live sessions (synced like any layer data). No plugin can write `figcraft.widget*` data or call `widget.*`; a callback can only rebuild its own widget, and touches other layers only through the commands its manifest permits. A widget whose plugin is missing stays as drawn.
- **Example.** `plugins/examples/counter`: a counter between - and + buttons (read access, no assets). The SDK gained `Input::widget` and `render_widget`.
- **Panel tail.** Panels of several plugins are open at once, side by side (`plugin.ui` without an id returns the one opened last); a colour field's swatch opens FigCraft's colour picker beside the plugin's window (the plugin hears of the colour when a drag ends; also for parameters forms). Swatch grid's palette panel gained a label colour field.
- Docs: plugins.md (ABI v3, widget description, commands, limits), mcp.md, target-app-parity area 18, ROADMAP, gaps gap 6, roadmap M11; Spanish labels for the new commands.
349 lines
13 KiB
Rust
349 lines
13 KiB
Rust
//! Write FigCraft plugins in Rust.
|
|
//!
|
|
//! A plugin is a `cdylib` built for `wasm32-unknown-unknown`. It gets the command the user picked
|
|
//! with its parameters and the selection, and acts on the document by calling FigCraft commands
|
|
//! (the ids `figcraft-cli commands` lists) with JSON parameters. Everything one run does is one
|
|
//! undo step; when the run returns an error (or panics), the document is put back.
|
|
//!
|
|
//! ```ignore
|
|
//! use figcraft_plugin_sdk::{Input, Result, Value, call, entry, json};
|
|
//!
|
|
//! fn run(input: Input) -> Result<Value> {
|
|
//! for id in &input.selection {
|
|
//! call("object.rename", &json!({"ids": [id], "name": "Tidy"}))?;
|
|
//! }
|
|
//! Ok(json!(format!("Renamed {} layers", input.selection.len())))
|
|
//! }
|
|
//!
|
|
//! entry!(run);
|
|
//! ```
|
|
//!
|
|
//! A plugin can also show a panel ([`show_ui`]): FigCraft draws its controls and calls the plugin
|
|
//! again for each button press or changed value, with [`Input::event`] set. The module stays
|
|
//! loaded while the panel is open, so statics keep their values between those calls. Settings
|
|
//! ([`setting`], [`set_setting`]) last from one run to the next.
|
|
//!
|
|
//! A widget plugin (API 3, `"widget": true`) draws a layer on the canvas: when it's inserted and
|
|
//! each time a part of it named as a `target` is clicked, the plugin is called with
|
|
//! [`Input::widget`] set (its state and what was clicked) and hands back the new state and the
|
|
//! layers with [`render_widget`]. Each call starts afresh: the state is all a widget remembers.
|
|
//!
|
|
//! 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.
|
|
#![cfg_attr(not(test), no_std)]
|
|
|
|
extern crate alloc;
|
|
|
|
use alloc::format;
|
|
use alloc::string::{String, ToString};
|
|
use alloc::vec::Vec;
|
|
|
|
pub use serde_json::{self, Value, json};
|
|
|
|
/// What a plugin function returns: its output (shown or returned to the caller), or an error that
|
|
/// fails the run and rolls the document back.
|
|
pub type Result<T> = core::result::Result<T, String>;
|
|
|
|
/// The run's input.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct Input {
|
|
/// The manifest command the user picked.
|
|
pub command: String,
|
|
/// Its parameters, with defaults filled in.
|
|
pub params: Value,
|
|
/// The selected layer ids.
|
|
pub selection: Vec<u64>,
|
|
/// Set when the plugin's panel calls it back.
|
|
pub event: Option<Event>,
|
|
/// Set when the plugin's widget is inserted or clicked.
|
|
pub widget: Option<Widget>,
|
|
}
|
|
|
|
/// A widget being inserted (no `target`) or clicked.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct Widget {
|
|
/// The widget's layer id.
|
|
pub id: u64,
|
|
/// Its state as last handed back (an empty object when it's new).
|
|
pub state: Value,
|
|
/// The target clicked.
|
|
pub target: Option<String>,
|
|
}
|
|
|
|
/// A button pressed or a value changed in the plugin's panel.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct Event {
|
|
/// The control's name.
|
|
pub control: String,
|
|
/// A field's new value (null for a button).
|
|
pub value: Value,
|
|
/// Every field's value, by name.
|
|
pub values: Value,
|
|
}
|
|
|
|
impl Input {
|
|
/// Parse the input JSON FigCraft hands to the plugin.
|
|
pub fn parse(text: &str) -> Result<Input> {
|
|
let v: Value = serde_json::from_str(text).map_err(|e| format!("input: {e}"))?;
|
|
Ok(Input {
|
|
command: v.get("command").and_then(Value::as_str).unwrap_or_default().to_string(),
|
|
params: v.get("params").cloned().unwrap_or(Value::Null),
|
|
selection: ids(v.get("selection")),
|
|
event: v.get("event").map(|e| Event {
|
|
control: e.get("control").and_then(Value::as_str).unwrap_or_default().to_string(),
|
|
value: e.get("value").cloned().unwrap_or(Value::Null),
|
|
values: v.get("values").cloned().unwrap_or(Value::Null),
|
|
}),
|
|
widget: v.get("widget").map(|w| Widget {
|
|
id: w.get("id").and_then(Value::as_u64).unwrap_or_default(),
|
|
state: w.get("state").cloned().unwrap_or_else(|| json!({})),
|
|
target: w.get("event").and_then(|e| e.get("target")).and_then(Value::as_str).map(ToString::to_string),
|
|
}),
|
|
})
|
|
}
|
|
|
|
pub fn str(&self, name: &str) -> Option<&str> {
|
|
self.params.get(name).and_then(Value::as_str)
|
|
}
|
|
|
|
pub fn f64(&self, name: &str) -> Option<f64> {
|
|
self.params.get(name).and_then(Value::as_f64).filter(|v| v.is_finite())
|
|
}
|
|
|
|
pub fn bool(&self, name: &str) -> Option<bool> {
|
|
self.params.get(name).and_then(Value::as_bool)
|
|
}
|
|
}
|
|
|
|
/// Layer ids in a JSON array.
|
|
fn ids(v: Option<&Value>) -> Vec<u64> {
|
|
v.and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default()
|
|
}
|
|
|
|
/// Run FigCraft command `command` with `params`: its result, or why it was refused or failed.
|
|
pub fn call(command: &str, params: &Value) -> Result<Value> {
|
|
let params = serde_json::to_string(params).map_err(|e| e.to_string())?;
|
|
let status = host::call(command.as_bytes(), params.as_bytes());
|
|
let reply = String::from_utf8(host::reply()).map_err(|_| "the reply isn't UTF-8".to_string())?;
|
|
if status != 0 {
|
|
return Err(reply);
|
|
}
|
|
serde_json::from_str(&reply).map_err(|e| format!("`{command}` returned {e}"))
|
|
}
|
|
|
|
/// The selected layer ids now (the input has them as they were when the run started).
|
|
pub fn selection() -> Vec<u64> {
|
|
host::selection();
|
|
let reply = host::reply();
|
|
ids(serde_json::from_slice::<Value>(&reply).ok().as_ref())
|
|
}
|
|
|
|
/// A line for the run's log (returned with the result, and in FigCraft's log).
|
|
pub fn log(line: &str) {
|
|
host::log(line.as_bytes());
|
|
}
|
|
|
|
/// A short message shown to the user.
|
|
pub fn notify(message: &str) {
|
|
host::notify(message.as_bytes());
|
|
}
|
|
|
|
/// Show the plugin's panel, or replace the one showing: `{"title", "controls": [...]}` (see
|
|
/// `docs/plugins.md`). An invalid panel is an error.
|
|
pub fn show_ui(panel: &Value) -> Result<()> {
|
|
let spec = serde_json::to_string(panel).map_err(|e| e.to_string())?;
|
|
if host::ui_show(spec.as_bytes()) == 0 { Ok(()) } else { Err(String::from_utf8_lossy(&host::reply()).into_owned()) }
|
|
}
|
|
|
|
/// Close the plugin's panel (the plugin isn't called again until it's run again).
|
|
pub fn close_ui() {
|
|
host::ui_close();
|
|
}
|
|
|
|
/// Draw the widget being inserted or clicked: `{"state": {...}, "root": {"type": "frame", ...}}`
|
|
/// (see `docs/plugins.md`). Without `state` the widget keeps the one it has. An invalid
|
|
/// description is an error.
|
|
pub fn render_widget(widget: &Value) -> Result<()> {
|
|
let spec = serde_json::to_string(widget).map_err(|e| e.to_string())?;
|
|
if host::widget_render(spec.as_bytes()) == 0 { Ok(()) } else { Err(String::from_utf8_lossy(&host::reply()).into_owned()) }
|
|
}
|
|
|
|
/// The plugin's setting `key`, as stored by [`set_setting`].
|
|
pub fn setting(key: &str) -> Option<Value> {
|
|
if host::setting_get(key.as_bytes()) != 0 {
|
|
return None;
|
|
}
|
|
serde_json::from_slice(&host::reply()).ok()
|
|
}
|
|
|
|
/// Remember `value` under `key` for later runs (64 KB of settings per plugin).
|
|
pub fn set_setting(key: &str, value: &Value) -> Result<()> {
|
|
let v = serde_json::to_string(value).map_err(|e| e.to_string())?;
|
|
if host::setting_set(key.as_bytes(), v.as_bytes()) == 0 { Ok(()) } else { Err(String::from_utf8_lossy(&host::reply()).into_owned()) }
|
|
}
|
|
|
|
/// Forget setting `key`.
|
|
pub fn delete_setting(key: &str) {
|
|
host::setting_delete(key.as_bytes());
|
|
}
|
|
|
|
/// The body of `figcraft_run` (use [`entry!`]): read the input, run `f`, hand back its output.
|
|
pub fn run_plugin(input_len: i32, f: fn(Input) -> Result<Value>) -> i32 {
|
|
let input = host::reply();
|
|
let input = input.get(..usize::try_from(input_len).unwrap_or(0)).unwrap_or(&input);
|
|
let result = core::str::from_utf8(input).map_err(|_| "the input isn't UTF-8".to_string()).and_then(Input::parse).and_then(f);
|
|
match result {
|
|
Ok(v) => {
|
|
host::set_output(serde_json::to_string(&v).unwrap_or_default().as_bytes());
|
|
0
|
|
}
|
|
Err(e) => {
|
|
host::set_output(e.as_bytes());
|
|
1
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Export `f: fn(Input) -> Result<Value>` as the plugin's entry point.
|
|
#[macro_export]
|
|
macro_rules! entry {
|
|
($f:path) => {
|
|
#[unsafe(no_mangle)]
|
|
pub extern "C" fn figcraft_run(input_len: i32) -> i32 {
|
|
$crate::run_plugin(input_len, $f)
|
|
}
|
|
};
|
|
}
|
|
|
|
/// The host functions FigCraft provides (module `figcraft`).
|
|
#[cfg(target_arch = "wasm32")]
|
|
#[allow(unsafe_code)]
|
|
mod host {
|
|
use alloc::vec;
|
|
use alloc::vec::Vec;
|
|
|
|
mod sys {
|
|
#[link(wasm_import_module = "figcraft")]
|
|
unsafe extern "C" {
|
|
pub fn call(cmd: *const u8, cmd_len: usize, params: *const u8, params_len: usize) -> i32;
|
|
pub fn selection() -> i32;
|
|
pub fn reply_len() -> i32;
|
|
pub fn reply_read(ptr: *mut u8, len: usize) -> i32;
|
|
pub fn log(ptr: *const u8, len: usize);
|
|
pub fn notify(ptr: *const u8, len: usize);
|
|
pub fn set_output(ptr: *const u8, len: usize);
|
|
pub fn ui_show(ptr: *const u8, len: usize) -> i32;
|
|
pub fn ui_close();
|
|
pub fn setting_get(key: *const u8, key_len: usize) -> i32;
|
|
pub fn setting_set(key: *const u8, key_len: usize, value: *const u8, value_len: usize) -> i32;
|
|
pub fn setting_delete(key: *const u8, key_len: usize);
|
|
pub fn widget_render(ptr: *const u8, len: usize) -> i32;
|
|
}
|
|
}
|
|
|
|
// SAFETY (every call below): the host reads only the `len` bytes at `ptr`, which are a live
|
|
// slice, and writes only up to `len` bytes into the buffer it's given; it checks bounds itself.
|
|
pub fn call(cmd: &[u8], params: &[u8]) -> i32 {
|
|
unsafe { sys::call(cmd.as_ptr(), cmd.len(), params.as_ptr(), params.len()) }
|
|
}
|
|
pub fn selection() -> i32 {
|
|
unsafe { sys::selection() }
|
|
}
|
|
pub fn reply() -> Vec<u8> {
|
|
let len = usize::try_from(unsafe { sys::reply_len() }).unwrap_or(0);
|
|
let mut buf = vec![0u8; len];
|
|
let got = unsafe { sys::reply_read(buf.as_mut_ptr(), buf.len()) };
|
|
buf.truncate(usize::try_from(got).unwrap_or(0));
|
|
buf
|
|
}
|
|
pub fn log(s: &[u8]) {
|
|
unsafe { sys::log(s.as_ptr(), s.len()) }
|
|
}
|
|
pub fn notify(s: &[u8]) {
|
|
unsafe { sys::notify(s.as_ptr(), s.len()) }
|
|
}
|
|
pub fn set_output(s: &[u8]) {
|
|
unsafe { sys::set_output(s.as_ptr(), s.len()) }
|
|
}
|
|
pub fn ui_show(s: &[u8]) -> i32 {
|
|
unsafe { sys::ui_show(s.as_ptr(), s.len()) }
|
|
}
|
|
pub fn ui_close() {
|
|
unsafe { sys::ui_close() }
|
|
}
|
|
pub fn setting_get(key: &[u8]) -> i32 {
|
|
unsafe { sys::setting_get(key.as_ptr(), key.len()) }
|
|
}
|
|
pub fn setting_set(key: &[u8], value: &[u8]) -> i32 {
|
|
unsafe { sys::setting_set(key.as_ptr(), key.len(), value.as_ptr(), value.len()) }
|
|
}
|
|
pub fn setting_delete(key: &[u8]) {
|
|
unsafe { sys::setting_delete(key.as_ptr(), key.len()) }
|
|
}
|
|
pub fn widget_render(s: &[u8]) -> i32 {
|
|
unsafe { sys::widget_render(s.as_ptr(), s.len()) }
|
|
}
|
|
}
|
|
|
|
/// Outside WebAssembly (a plugin's own unit tests) every host call fails.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
mod host {
|
|
use alloc::vec::Vec;
|
|
|
|
pub fn call(_cmd: &[u8], _params: &[u8]) -> i32 {
|
|
1
|
|
}
|
|
pub fn selection() -> i32 {
|
|
0
|
|
}
|
|
pub fn reply() -> Vec<u8> {
|
|
b"not running inside FigCraft".to_vec()
|
|
}
|
|
pub fn log(_s: &[u8]) {}
|
|
pub fn notify(_s: &[u8]) {}
|
|
pub fn set_output(_s: &[u8]) {}
|
|
pub fn ui_show(_s: &[u8]) -> i32 {
|
|
1
|
|
}
|
|
pub fn ui_close() {}
|
|
pub fn setting_get(_key: &[u8]) -> i32 {
|
|
1
|
|
}
|
|
pub fn setting_set(_key: &[u8], _value: &[u8]) -> i32 {
|
|
1
|
|
}
|
|
pub fn setting_delete(_key: &[u8]) {}
|
|
pub fn widget_render(_s: &[u8]) -> i32 {
|
|
1
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn input_parses_and_host_calls_fail_off_wasm() {
|
|
let i = Input::parse(r#"{"api":1,"command":"grid","params":{"n":3,"s":"x","b":true},"selection":[4,"x",7]}"#).unwrap_or_default();
|
|
assert_eq!(i.command, "grid");
|
|
assert_eq!(i.selection, alloc::vec![4, 7]);
|
|
assert_eq!((i.f64("n"), i.str("s"), i.bool("b")), (Some(3.0), Some("x"), Some(true)));
|
|
assert!(Input::parse("nope").is_err());
|
|
assert!(call("node.get", &json!({"id": 1})).is_err());
|
|
assert!(selection().is_empty());
|
|
assert!(i.event.is_none());
|
|
let e = Input::parse(r#"{"command":"panel","event":{"control":"go","value":null},"values":{"n":2}}"#)
|
|
.unwrap_or_default()
|
|
.event
|
|
.unwrap_or_default();
|
|
assert_eq!((e.control.as_str(), &e.value, &e.values), ("go", &Value::Null, &json!({"n": 2})));
|
|
assert!(show_ui(&json!({})).is_err() && setting("k").is_none() && set_setting("k", &json!(1)).is_err());
|
|
assert!(render_widget(&json!({})).is_err() && i.widget.is_none());
|
|
let w = Input::parse(r#"{"command":"widget","widget":{"id":9,"state":{"n":1},"event":{"target":"inc"}}}"#)
|
|
.unwrap_or_default()
|
|
.widget
|
|
.unwrap_or_default();
|
|
assert_eq!((w.id, &w.state, w.target.as_deref()), (9, &json!({"n": 1}), Some("inc")));
|
|
}
|
|
}
|