M7: plugin panels and settings (#124)

Plugin ABI v2: a plugin can show a panel (text, number, checkbox, select, colour, list, notes, buttons) rendered with FigCraft's widgets, react to events with its state kept between calls (each editing callback one undo step, full sandbox limits on every call), and store per-plugin settings (64 KB). plugin.ui / plugin.event / plugin.close drive panels from the CLI and MCP; v1 plugins run unchanged. The Swatch grid example gains a Palettes panel that remembers the last palette.
This commit is contained in:
BFlat
2026-10-11 14:45:19 +07:00
committed by GitHub
parent 09a3b74466
commit a003e3de4f
27 changed files with 1639 additions and 233 deletions
@@ -1,18 +1,19 @@
{
"id": "org.figcraft.examples.swatch-grid",
"name": "Swatch grid",
"version": "1.0.0",
"description": "Makes a grid of colour swatches from a palette of hex colours.",
"version": "1.1.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": 1,
"api": 2,
"permissions": {
"access": "write",
"commands": ["view.get", "shape.rectangle", "text.create", "select.set"]
},
"commands": [
{ "id": "panel", "label": "Palettes…" },
{
"id": "grid",
"label": "Swatch grid…",
"label": "From hex colours…",
"params": [
{ "name": "palette", "label": "Palette", "type": "text", "default": "#FF5A5F, #FFB400, #00A699, #007A87, #7B0051, #3D3D3D" },
{ "name": "columns", "label": "Columns", "type": "number", "default": 3, "min": 1, "max": 24 },
+78 -10
View File
@@ -1,11 +1,20 @@
//! Swatch grid: a grid of colour swatches from a palette (hex colours), centred in the view, each
//! named after its colour and optionally labelled.
//! Swatch grid: a grid of colour swatches, centred in the view, each named after its colour and
//! optionally labelled. Two commands: a panel to pick one of the built-in palettes and Generate
//! (the palette picked last is remembered for next time), and a form taking hex colours.
use figcraft_plugin_sdk::{Input, Result, Value, call, entry, json, log, notify};
use figcraft_plugin_sdk::{Event, Input, Result, Value, call, close_ui, entry, json, log, notify, set_setting, setting, show_ui};
/// Swatches made in one run, at most.
const MAX_SWATCHES: usize = 256;
/// The panel's palettes: (name, colours).
const PALETTES: &[(&str, &str)] = &[
("Sunset", "#FF5A5F, #FFB400, #00A699, #007A87, #7B0051, #3D3D3D"),
("Ocean", "#0A2463, #1E5AA8, #3E92CC, #8CC7E8, #D8F1FF"),
("Forest", "#183A2B, #2F6B4F, #4E9F6E, #8CCB9B, #D3EED8"),
("Greys", "#111111, #444444, #777777, #AAAAAA, #DDDDDD"),
];
/// `#RGB` or `#RRGGBB` (the `#` optional) as `#RRGGBB`.
fn hex(s: &str) -> Option<String> {
let h = s.trim().trim_start_matches('#');
@@ -35,15 +44,18 @@ fn palette(text: &str) -> Vec<String> {
.collect()
}
fn run(input: Input) -> Result<Value> {
let colors = palette(input.str("palette").unwrap_or_default());
/// The colours of the built-in palette `name` (empty when there's none).
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> {
if colors.is_empty() {
return Err("The palette has no hex colours (like #FF5A5F, #00A699)".into());
}
let columns = input.f64("columns").unwrap_or(3.0).clamp(1.0, 24.0) as usize;
let size = input.f64("size").unwrap_or(64.0).clamp(8.0, 512.0);
let gap = input.f64("gap").unwrap_or(16.0).clamp(0.0, 256.0);
let labels = input.bool("labels").unwrap_or(true);
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 (w, h) = (columns as f64 * (size + gap) - gap, rows as f64 * (cell_h + gap) - gap);
@@ -65,11 +77,64 @@ fn run(input: Input) -> Result<Value> {
Ok(json!({"swatches": colors.len(), "ids": made}))
}
/// Show the panel with palette `name` picked and the other fields as in `values` (null: their
/// defaults).
fn panel(name: &str, values: &Value) -> Result<Value> {
let names: Vec<&str> = PALETTES.iter().map(|(n, _)| *n).collect();
show_ui(&json!({"title": "Swatch grid", "controls": [
{"type": "list", "name": "palette", "label": "Palette", "options": names, "value": name},
{"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": "button", "name": "generate", "label": "Generate"},
{"type": "button", "name": "close", "label": "Close"}
]}))?;
Ok(Value::Null)
}
/// A button or a changed value in the panel.
fn on_event(e: Event) -> Result<Value> {
let name = e.values["palette"].as_str().unwrap_or("Sunset");
match e.control.as_str() {
// Show the picked palette's colours, and remember it for next time.
"palette" => {
set_setting("palette", &json!(name))?;
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))
}
"close" => {
close_ui();
Ok(Value::Null)
}
_ => Ok(Value::Null),
}
}
fn run(input: Input) -> Result<Value> {
if let Some(e) = input.event {
return on_event(e);
}
if input.command == "panel" {
let last = setting("palette").and_then(|v| v.as_str().map(str::to_string)).filter(|n| !named(n).is_empty());
return panel(last.as_deref().unwrap_or("Sunset"), &Value::Null);
}
let colors = palette(input.str("palette").unwrap_or_default());
grid(
&colors,
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),
)
}
entry!(run);
#[cfg(test)]
mod tests {
use super::{hex, palette};
use super::{PALETTES, hex, palette};
#[test]
fn palettes_parse() {
@@ -78,5 +143,8 @@ mod tests {
assert_eq!(hex("#12345"), None);
assert_eq!(hex("red"), None);
assert_eq!(palette("#FF5A5F, nope;#0f0\n00A699"), vec!["#FF5A5F", "#00FF00", "#00A699"]);
for (name, colors) in PALETTES {
assert_eq!(palette(colors).len(), colors.split(',').count(), "{name}");
}
}
}
+92
View File
@@ -18,6 +18,11 @@
//! 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.
//!
//! 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)]
@@ -43,6 +48,19 @@ pub struct Input {
pub params: Value,
/// The selected layer ids.
pub selection: Vec<u64>,
/// Set when the plugin's panel calls it back.
pub event: Option<Event>,
}
/// 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 {
@@ -53,6 +71,11 @@ impl 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),
}),
})
}
@@ -102,6 +125,37 @@ 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();
}
/// 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();
@@ -147,6 +201,11 @@ mod host {
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);
}
}
@@ -174,6 +233,21 @@ mod host {
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()) }
}
}
/// Outside WebAssembly (a plugin's own unit tests) every host call fails.
@@ -193,6 +267,17 @@ mod host {
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]) {}
}
#[cfg(test)]
@@ -208,5 +293,12 @@ mod tests {
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());
}
}