mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
M11: plugin widgets and the plugin panel tail
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.
This commit is contained in:
Generated
+7
@@ -2,6 +2,13 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "counter"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"figcraft-plugin-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-plugin-sdk"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "counter"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
figcraft-plugin-sdk = { workspace = true }
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "org.figcraft.examples.counter",
|
||||
"name": "Counter",
|
||||
"version": "1.0.0",
|
||||
"description": "A widget that counts on the canvas: click its + and − buttons. The count is kept in the document.",
|
||||
"main": "counter.wasm",
|
||||
"api": 3,
|
||||
"widget": true,
|
||||
"permissions": { "access": "read" }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Counter: a widget on the canvas with − and + buttons around a count. Each click calls the
|
||||
//! plugin back with the widget's state; it hands back the new count and the redrawn widget.
|
||||
|
||||
use figcraft_plugin_sdk::{Input, Result, Value, entry, json, render_widget};
|
||||
|
||||
/// The count stays within ± this.
|
||||
const LIMIT: i64 = 999_999;
|
||||
|
||||
/// The count after clicking `target` (none: the widget was just inserted).
|
||||
fn next(count: i64, target: Option<&str>) -> i64 {
|
||||
let n = match target {
|
||||
Some("increment") => count.saturating_add(1),
|
||||
Some("decrement") => count.saturating_sub(1),
|
||||
_ => count,
|
||||
};
|
||||
n.clamp(-LIMIT, LIMIT)
|
||||
}
|
||||
|
||||
/// A square button that calls back with `target`.
|
||||
fn button(target: &str, name: &str, label: &str) -> Value {
|
||||
json!({"type": "frame", "name": name, "target": target, "layout": "horizontal", "justify": "center", "width": 32, "height": 32, "radius": 8, "fill": "#F0F0F0",
|
||||
"children": [{"type": "text", "text": label, "size": 16, "bold": true, "color": "#1E1E1E"}]})
|
||||
}
|
||||
|
||||
/// The widget showing `count`.
|
||||
fn view(count: i64) -> Value {
|
||||
json!({"type": "frame", "name": "Counter", "layout": "horizontal", "gap": 12, "padding": 12, "radius": 12,
|
||||
"fill": "#FFFFFF", "stroke": "#D9D9D9", "children": [
|
||||
button("decrement", "Decrease", "−"),
|
||||
{"type": "text", "name": "Count", "text": count.to_string(), "size": 24, "bold": true, "color": "#1E1E1E"},
|
||||
button("increment", "Increase", "+"),
|
||||
]})
|
||||
}
|
||||
|
||||
fn run(input: Input) -> Result<Value> {
|
||||
let w = input.widget.ok_or("Insert the counter from Plugins ▸ Widgets")?;
|
||||
let count = next(w.state.get("count").and_then(Value::as_i64).unwrap_or(0), w.target.as_deref());
|
||||
render_widget(&json!({"state": {"count": count}, "root": view(count)}))?;
|
||||
Ok(json!({"count": count}))
|
||||
}
|
||||
|
||||
entry!(run);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn counts_within_its_limit() {
|
||||
assert_eq!(next(0, None), 0);
|
||||
assert_eq!(next(4, Some("increment")), 5);
|
||||
assert_eq!(next(4, Some("decrement")), 3);
|
||||
assert_eq!(next(LIMIT, Some("increment")), LIMIT);
|
||||
assert_eq!(next(i64::MIN, Some("decrement")), -LIMIT);
|
||||
assert_eq!(next(2, Some("other")), 2);
|
||||
assert_eq!(view(3)["children"][1]["text"], "3");
|
||||
assert!(run(Input::default()).is_err(), "it only runs as a widget");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "org.figcraft.examples.swatch-grid",
|
||||
"name": "Swatch grid",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"description": "Makes a grid of colour swatches from a built-in palette (in a panel) or from hex colours.",
|
||||
"main": "swatch_grid.wasm",
|
||||
"api": 2,
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
use figcraft_plugin_sdk::{Event, Input, Result, Value, call, close_ui, entry, json, log, notify, set_setting, setting, show_ui};
|
||||
|
||||
/// The labels' colour unless the panel picks another.
|
||||
const LABEL: &str = "#000000";
|
||||
|
||||
/// Swatches made in one run, at most.
|
||||
const MAX_SWATCHES: usize = 256;
|
||||
|
||||
@@ -49,15 +52,16 @@ fn named(name: &str) -> &'static str {
|
||||
PALETTES.iter().find(|(n, _)| *n == name).map_or("", |(_, c)| c)
|
||||
}
|
||||
|
||||
/// Swatches of `colors` in a grid centred in the view (labelled when `labels`), then selected.
|
||||
fn grid(colors: &[String], columns: f64, size: f64, gap: f64, labels: bool) -> Result<Value> {
|
||||
/// Swatches of `colors` in a grid centred in the view (labelled in colour `labels` when given),
|
||||
/// then selected.
|
||||
fn grid(colors: &[String], columns: f64, size: f64, gap: f64, labels: Option<&str>) -> Result<Value> {
|
||||
if colors.is_empty() {
|
||||
return Err("The palette has no hex colours (like #FF5A5F, #00A699)".into());
|
||||
}
|
||||
let columns = columns.clamp(1.0, 24.0) as usize;
|
||||
let (size, gap) = (size.clamp(8.0, 512.0), gap.clamp(0.0, 256.0));
|
||||
let rows = colors.len().div_ceil(columns);
|
||||
let cell_h = if labels { size + 20.0 } else { size };
|
||||
let cell_h = if labels.is_some() { size + 20.0 } else { size };
|
||||
let (w, h) = (columns as f64 * (size + gap) - gap, rows as f64 * (cell_h + gap) - gap);
|
||||
// Centred in the view, on whole pixels.
|
||||
let view = call("view.get", &json!({}))?;
|
||||
@@ -67,8 +71,8 @@ fn grid(colors: &[String], columns: f64, size: f64, gap: f64, labels: bool) -> R
|
||||
let (x, y) = (x0 + (i % columns) as f64 * (size + gap), y0 + (i / columns) as f64 * (cell_h + gap));
|
||||
let r = call("shape.rectangle", &json!({"x": x, "y": y, "width": size, "height": size, "props": {"name": color, "fill": color}}))?;
|
||||
made.extend(r["id"].as_u64());
|
||||
if labels {
|
||||
let t = call("text.create", &json!({"text": color, "x": x, "y": y + size + 4.0, "props": {"fontSize": 11}}))?;
|
||||
if let Some(label) = labels {
|
||||
let t = call("text.create", &json!({"text": color, "x": x, "y": y + size + 4.0, "props": {"fontSize": 11, "fill": label}}))?;
|
||||
made.extend(t["id"].as_u64());
|
||||
}
|
||||
}
|
||||
@@ -86,6 +90,7 @@ fn panel(name: &str, values: &Value) -> Result<Value> {
|
||||
{"type": "note", "label": named(name)},
|
||||
{"type": "number", "name": "columns", "label": "Columns", "min": 1, "max": 24, "value": values["columns"].as_f64().unwrap_or(3.0)},
|
||||
{"type": "boolean", "name": "labels", "label": "Label each swatch", "value": values["labels"].as_bool().unwrap_or(true)},
|
||||
{"type": "color", "name": "labelColor", "label": "Label colour", "value": values["labelColor"].as_str().unwrap_or(LABEL)},
|
||||
{"type": "button", "name": "generate", "label": "Generate"},
|
||||
{"type": "button", "name": "close", "label": "Close"}
|
||||
]}))?;
|
||||
@@ -102,7 +107,8 @@ fn on_event(e: Event) -> Result<Value> {
|
||||
panel(name, &e.values)
|
||||
}
|
||||
"generate" => {
|
||||
grid(&palette(named(name)), e.values["columns"].as_f64().unwrap_or(3.0), 64.0, 16.0, e.values["labels"].as_bool().unwrap_or(true))
|
||||
let labels = e.values["labels"].as_bool().unwrap_or(true).then(|| e.values["labelColor"].as_str().unwrap_or(LABEL));
|
||||
grid(&palette(named(name)), e.values["columns"].as_f64().unwrap_or(3.0), 64.0, 16.0, labels)
|
||||
}
|
||||
"close" => {
|
||||
close_ui();
|
||||
@@ -126,7 +132,7 @@ fn run(input: Input) -> Result<Value> {
|
||||
input.f64("columns").unwrap_or(3.0),
|
||||
input.f64("size").unwrap_or(64.0),
|
||||
input.f64("gap").unwrap_or(16.0),
|
||||
input.bool("labels").unwrap_or(true),
|
||||
input.bool("labels").unwrap_or(true).then_some(LABEL),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
//! 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)]
|
||||
@@ -50,6 +55,19 @@ pub struct Input {
|
||||
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.
|
||||
@@ -76,6 +94,11 @@ impl Input {
|
||||
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),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -137,6 +160,14 @@ 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 {
|
||||
@@ -206,6 +237,7 @@ mod host {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +280,9 @@ mod host {
|
||||
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.
|
||||
@@ -278,6 +313,9 @@ mod host {
|
||||
1
|
||||
}
|
||||
pub fn setting_delete(_key: &[u8]) {}
|
||||
pub fn widget_render(_s: &[u8]) -> i32 {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -300,5 +338,11 @@ mod tests {
|
||||
.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")));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user