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}");
}
}
}