M11: plugin API 4: SDK builders, Counter menu, Focus list and Dot grid examples, examples smoke run

This commit is contained in:
bflatastic
2026-10-13 13:38:21 +07:00
parent 71dabf7072
commit 1f9c606eb6
12 changed files with 1088 additions and 39 deletions
+14
View File
@@ -9,6 +9,13 @@ dependencies = [
"figcraft-plugin-sdk",
]
[[package]]
name = "dot-grid"
version = "0.1.0"
dependencies = [
"figcraft-plugin-sdk",
]
[[package]]
name = "figcraft-plugin-sdk"
version = "0.1.0"
@@ -16,6 +23,13 @@ dependencies = [
"serde_json",
]
[[package]]
name = "focus-list"
version = "0.1.0"
dependencies = [
"figcraft-plugin-sdk",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -1,10 +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.",
"version": "1.1.0",
"description": "A widget that counts on the canvas: click its + and − buttons. Its property menu resets it and picks the step and the colour. The count is kept in the document.",
"main": "counter.wasm",
"api": 3,
"api": 4,
"widget": true,
"permissions": { "access": "read" }
}
+112 -33
View File
@@ -1,42 +1,94 @@
//! 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.
//! plugin back with the widget's state; it hands back the new count and the redrawn widget. Its
//! property menu (shown by the selected widget and in the Design panel) resets the count, picks
//! the step of a click and the count's colour; the buttons have a hover style and a tooltip.
use figcraft_plugin_sdk::{Input, Result, Value, entry, json, render_widget};
use figcraft_plugin_sdk::ui::{Node, menu_action, menu_color, menu_dropdown, menu_link, menu_separator, render};
use figcraft_plugin_sdk::{Input, Result, Value, WidgetEvent, entry, json};
/// The count stays within ± this.
const LIMIT: i64 = 999_999;
/// The steps the menu offers, and the colours for the count.
const STEPS: [&str; 3] = ["1", "5", "10"];
const TINTS: [&str; 4] = ["#1E1E1E", "#0D99FF", "#14AE5C", "#F24822"];
/// 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)
/// What the widget remembers.
#[derive(Clone, Debug, PartialEq)]
struct Counter {
count: i64,
step: i64,
tint: String,
}
/// 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"}]})
}
impl Counter {
fn from(state: &Value) -> Counter {
let tint = state.get("tint").and_then(Value::as_str).filter(|t| TINTS.contains(t)).unwrap_or(TINTS[0]);
let step = state.get("step").and_then(Value::as_i64).filter(|s| STEPS.contains(&s.to_string().as_str())).unwrap_or(1);
Counter { count: state.get("count").and_then(Value::as_i64).unwrap_or(0).clamp(-LIMIT, LIMIT), step, tint: tint.into() }
}
/// 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", "+"),
]})
/// The counter after `event`.
fn after(mut self, event: &WidgetEvent) -> Counter {
match event {
WidgetEvent::Click(target) if target == "increment" => self.count = self.count.saturating_add(self.step),
WidgetEvent::Click(target) if target == "decrement" => self.count = self.count.saturating_sub(self.step),
WidgetEvent::Menu { name, .. } if name == "reset" => self.count = 0,
WidgetEvent::Menu { name, value } if name == "step" => {
let offered = value.as_str().filter(|s| STEPS.contains(s));
self.step = offered.and_then(|s| s.parse().ok()).unwrap_or(self.step);
}
WidgetEvent::Menu { name, value } if name == "tint" => {
self.tint = value.as_str().filter(|t| TINTS.contains(t)).unwrap_or(&self.tint).into();
}
_ => {}
}
self.count = self.count.clamp(-LIMIT, LIMIT);
self
}
fn state(&self) -> Value {
json!({"count": self.count, "step": self.step, "tint": self.tint})
}
/// A square button that calls back with `target`.
fn button(target: &str, name: &str, label: &str, tip: &str) -> Node {
Node::row()
.name(name)
.target(target)
.justify("center")
.size(32.0, 32.0)
.radius(8.0)
.fill("#F0F0F0")
.hover(Some("#DCDCDC"), None, None)
.tooltip(tip)
.child(Node::text(label).font_size(16.0).bold().color("#1E1E1E"))
}
fn view(&self) -> Node {
let by = |verb: &str| if self.step == 1 { verb.to_string() } else { format!("{verb} by {}", self.step) };
Node::row().name("Counter").gap(12.0).padding(12.0).radius(12.0).fill("#FFFFFF").stroke("#D9D9D9", 1.0).children([
Counter::button("decrement", "Decrease", "−", &by("Decrease")),
Node::text(&self.count.to_string()).name("Count").font_size(24.0).bold().color(&self.tint),
Counter::button("increment", "Increase", "+", &by("Increase")),
])
}
fn menu(&self) -> Vec<Value> {
vec![
menu_action("reset", "Reset"),
menu_dropdown("step", "Step", &STEPS, &self.step.to_string()),
menu_color("tint", "Colour", &TINTS, &self.tint),
menu_separator(),
menu_link("help", "About FigCraft", "https://getartcraft.com/apps/figcraft"),
]
}
}
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}))
let counter = Counter::from(&w.state).after(&w.event);
render(&counter.state(), counter.view(), Some(counter.menu()))?;
Ok(json!({"count": counter.count}))
}
entry!(run);
@@ -45,15 +97,42 @@ entry!(run);
mod tests {
use super::*;
fn click(target: &str) -> WidgetEvent {
WidgetEvent::Click(target.into())
}
fn menu(name: &str, value: Value) -> WidgetEvent {
WidgetEvent::Menu { name: name.into(), value }
}
#[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");
let at = |count: i64| Counter::from(&json!({"count": count}));
assert_eq!(at(0).after(&WidgetEvent::Insert).count, 0);
assert_eq!(at(4).after(&click("increment")).count, 5);
assert_eq!(at(4).after(&click("decrement")).count, 3);
assert_eq!(at(LIMIT).after(&click("increment")).count, LIMIT);
assert_eq!(at(i64::MIN).after(&click("decrement")).count, -LIMIT);
assert_eq!(at(2).after(&click("other")).count, 2);
assert_eq!(Value::from(at(3).view())["children"][1]["text"], "3");
assert!(run(Input::default()).is_err(), "it only runs as a widget");
}
#[test]
fn the_menu_resets_steps_and_tints() {
let c = Counter::from(&json!({"count": 7})).after(&menu("step", json!("5")));
assert_eq!((c.step, c.clone().after(&click("increment")).count), (5, 12));
assert_eq!(c.clone().after(&menu("step", json!("7"))).step, 5, "only the offered steps");
assert_eq!(c.clone().after(&menu("reset", Value::Null)).count, 0);
let tinted = c.after(&menu("tint", json!("#0D99FF")));
assert_eq!(tinted.tint, "#0D99FF");
assert_eq!(tinted.clone().after(&menu("tint", json!("red"))).tint, "#0D99FF");
assert_eq!(Counter::from(&tinted.state()), tinted, "the state round-trips");
assert_eq!(tinted.menu()[1]["value"], "5");
assert_eq!(
Counter::from(&json!({"step": 3, "tint": "#123456"})),
Counter { count: 0, step: 1, tint: TINTS[0].into() },
"junk state falls back"
);
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "dot-grid"
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,13 @@
{
"id": "org.figcraft.examples.dot-grid",
"name": "Dot grid",
"version": "1.0.0",
"description": "Makes a grid of dots. Its panel has sliders for the count, size and gap, and a preview picture that follows them.",
"main": "dot_grid.wasm",
"api": 4,
"permissions": {
"access": "write",
"commands": ["view.get", "shape.ellipse", "object.group", "select.set"]
},
"commands": [{ "id": "panel", "label": "Dot grid…" }]
}
+177
View File
@@ -0,0 +1,177 @@
//! Dot grid: a panel with sliders (dots per row, their size, the gap between them), a colour and
//! a preview picture that follows them, drawn here pixel by pixel. Generate puts the grid in the
//! middle of the view as one group. The grid needs a name: without one the field says so and
//! Generate is greyed out.
use figcraft_plugin_sdk::ui::{disabled, divider, image, invalid, progress, section, slider};
use figcraft_plugin_sdk::{Event, Input, Result, Value, call, close_ui, entry, json, notify, show_ui};
/// The preview's side in pixels, and the most dots on a side.
const PREVIEW: u32 = 96;
const MAX_COLUMNS: f64 = 12.0;
/// What the panel's fields say.
#[derive(Clone, Debug, PartialEq)]
struct Grid {
name: String,
columns: f64,
size: f64,
gap: f64,
color: String,
}
impl Grid {
/// From the panel's values (null: the defaults).
fn from(values: &Value) -> Grid {
let num = |key: &str, default: f64, max: f64| values[key].as_f64().filter(|v| v.is_finite()).unwrap_or(default).clamp(0.0, max);
Grid {
name: values["name"].as_str().unwrap_or("Dot grid").trim().into(),
columns: num("columns", 6.0, MAX_COLUMNS).max(2.0).round(),
size: num("size", 16.0, 48.0).max(4.0),
gap: num("gap", 8.0, 32.0),
color: values["color"].as_str().unwrap_or("#0D99FF").into(),
}
}
fn dots(&self) -> usize {
(self.columns * self.columns) as usize
}
/// The colour as red, green and blue (black when it isn't `#RRGGBB…`).
fn rgb(&self) -> [u8; 3] {
let hex = self.color.trim_start_matches('#');
[0, 2, 4].map(|i| hex.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok()).unwrap_or(0))
}
/// Whether preview pixel (`x`, `y`) is on a dot: the grid fills the picture, the dots keep
/// their share of each cell.
fn on_dot(&self, x: u32, y: u32) -> bool {
let cell = f64::from(PREVIEW) / self.columns;
let radius = cell * self.size / (self.size + self.gap) / 2.0;
let off = |v: u32| (f64::from(v) + 0.5) % cell - cell / 2.0;
off(x).hypot(off(y)) <= radius
}
fn preview(&self) -> Vec<u8> {
let ink = self.rgb();
bmp(PREVIEW, |x, y| if self.on_dot(x, y) { ink } else { [245, 245, 245] })
}
/// Show the panel with these values.
fn show(&self) -> Result<()> {
let unnamed = self.name.is_empty();
let name = json!({"type": "text", "name": "name", "label": "Name", "value": self.name});
let generate = json!({"type": "button", "name": "generate", "label": "Generate"});
show_ui(&json!({"title": "Dot grid", "controls": [
if unnamed { invalid(name, "Give the grid a name") } else { name },
slider("columns", "Dots per row", 2.0, MAX_COLUMNS, 1.0, self.columns),
slider("size", "Dot size", 4.0, 48.0, 2.0, self.size),
{"type": "color", "name": "color", "label": "Colour", "value": self.color},
section("Spacing", false),
slider("gap", "Gap", 0.0, 32.0, 1.0, self.gap),
divider(),
image("A preview of the dot grid", &self.preview(), 120.0),
progress(&format!("{} of {} dots", self.dots(), (MAX_COLUMNS * MAX_COLUMNS) as usize), self.dots() as f64 / (MAX_COLUMNS * MAX_COLUMNS)),
if unnamed { disabled(generate) } else { generate },
{"type": "button", "name": "close", "label": "Close"}
]}))
}
/// Put the dots in the middle of the view, grouped and selected.
fn generate(&self) -> Result<Value> {
if self.name.is_empty() {
return Err("Give the grid a name".into());
}
let step = self.size + self.gap;
let side = self.columns * step - self.gap;
let view = call("view.get", &json!({}))?;
let corner = |c: &str| (view[c].as_f64().unwrap_or(0.0) - side / 2.0).round();
let (x0, y0) = (corner("cx"), corner("cy"));
let mut made = Vec::new();
for i in 0..self.dots() {
let (col, row) = ((i % self.columns as usize) as f64, (i / self.columns as usize) as f64);
let dot = json!({"x": x0 + col * step, "y": y0 + row * step, "width": self.size, "height": self.size, "props": {"name": "Dot", "fill": self.color}});
made.extend(call("shape.ellipse", &dot)?["id"].as_u64());
}
let group = call("object.group", &json!({"ids": made, "name": self.name}))?;
call("select.set", &json!({"ids": [group["id"]]}))?;
notify(&format!("Made {} dots", made.len()));
Ok(json!({"dots": made.len(), "group": group["id"]}))
}
}
/// A `size`×`size` picture as an uncompressed 24-bit BMP file, `pixel` giving red, green and blue
/// for each (x, y) from the top-left.
fn bmp(size: u32, pixel: impl Fn(u32, u32) -> [u8; 3]) -> Vec<u8> {
let row = (size * 3).div_ceil(4) * 4;
let mut out = Vec::with_capacity((54 + row * size) as usize);
// The file header (the pixels start at byte 54), then the 40-byte picture header.
out.extend(b"BM");
for v in [54 + row * size, 0, 54, 40, size, size] {
out.extend(v.to_le_bytes());
}
out.extend([1, 0, 24, 0]);
out.extend([0u8; 24]);
// Rows run bottom to top, each padded to four bytes, blue first.
for y in (0..size).rev() {
for x in 0..size {
let [r, g, b] = pixel(x, y);
out.extend([b, g, r]);
}
out.extend(core::iter::repeat_n(0u8, (row - size * 3) as usize));
}
out
}
fn on_event(e: &Event) -> Result<Value> {
let grid = Grid::from(&e.values);
match e.control.as_str() {
"generate" => grid.generate(),
"close" => {
close_ui();
Ok(Value::Null)
}
// A field changed: the preview, the count and the name's message follow.
_ => grid.show().map(|()| Value::Null),
}
}
fn run(input: Input) -> Result<Value> {
match &input.event {
Some(e) => on_event(e),
None => Grid::from(&Value::Null).show().map(|()| Value::Null),
}
}
entry!(run);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn values_are_read_and_clamped() {
let g = Grid::from(&Value::Null);
assert_eq!((g.name.as_str(), g.columns, g.dots()), ("Dot grid", 6.0, 36));
let g = Grid::from(&json!({"name": " ", "columns": 99, "size": 1, "gap": -4, "color": "#FF8000"}));
assert_eq!((g.name.as_str(), g.columns, g.size, g.gap, g.rgb()), ("", MAX_COLUMNS, 4.0, 0.0, [255, 128, 0]));
assert!(g.generate().is_err(), "a grid needs a name");
assert_eq!(Grid { color: "nope".into(), ..g }.rgb(), [0, 0, 0]);
}
#[test]
fn the_preview_is_a_bmp_of_dots() {
let g = Grid::from(&json!({"columns": 2, "size": 10, "gap": 10, "color": "#FF0000"}));
// Two dots a side: a dot's middle is on, a cell's corner is off.
assert!(g.on_dot(24, 24) && g.on_dot(72, 72) && !g.on_dot(0, 0) && !g.on_dot(48, 48));
let file = g.preview();
assert_eq!(&file[..2], b"BM");
assert_eq!(file.len(), 54 + (PREVIEW * PREVIEW * 3) as usize);
assert_eq!(u32::from_le_bytes([file[2], file[3], file[4], file[5]]) as usize, file.len());
assert_eq!((u32::from_le_bytes([file[18], file[19], file[20], file[21]]), file[28]), (PREVIEW, 24));
// The last row stored is the top one: its first pixel is background, blue first.
let top_left = file.len() - (PREVIEW * 3) as usize;
assert_eq!(&file[top_left..top_left + 3], &[245, 245, 245]);
assert_eq!(bmp(1, |_, _| [1, 2, 3])[54..], [3, 2, 1, 0], "rows are padded to four bytes");
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "focus-list"
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.focus-list",
"name": "Focus list",
"version": "1.0.0",
"description": "A sticky checklist widget: edit its title and items in place, tick them off, and start a focus countdown that ticks every second.",
"main": "focus_list.wasm",
"api": 4,
"widget": true,
"permissions": { "access": "read" }
}
+261
View File
@@ -0,0 +1,261 @@
//! Focus list: a sticky checklist widget. Its title and items are texts edited in place (a click,
//! type, Enter), each item has a box to tick, a last line adds an item, and a button starts a
//! focus countdown that a timer ticks down every second. The property menu clears the ticked
//! items, sets the countdown's length and picks the note's colour. Everything it knows is its state.
use figcraft_plugin_sdk::ui::{Node, menu_action, menu_color, menu_dropdown, render};
use figcraft_plugin_sdk::{Input, Result, Value, WidgetEvent, cancel_timer, entry, json, notify, set_timer};
/// Most items on one list, and the longest title or item.
const MAX_ITEMS: usize = 8;
const MAX_CHARS: u32 = 60;
/// The countdown lengths the menu offers (label, seconds), and the note colours.
const LENGTHS: [(&str, i64); 3] = [("30 seconds", 30), ("1 minute", 60), ("5 minutes", 300)];
const PAPERS: [&str; 4] = ["#FFF3B0", "#D9F2E3", "#DDE9FF", "#FFE0E0"];
const INK: &str = "#2B2B2B";
const WIDTH: f64 = 240.0;
/// What the widget remembers.
#[derive(Clone, Debug, PartialEq)]
struct List {
title: String,
/// (text, ticked)
items: Vec<(String, bool)>,
/// Seconds a countdown runs, and what's left of the one running (0: none is).
length: i64,
left: i64,
paper: String,
}
impl List {
/// The list a new widget starts with.
fn new() -> List {
let items = ["Write the release notes", "Tag the build", "Tell the team"].map(|t| (t.to_string(), false)).to_vec();
List { title: "Launch checklist".into(), items, length: 30, left: 0, paper: PAPERS[0].into() }
}
fn from(state: &Value) -> List {
let new = List::new();
if state.as_object().is_none_or(|o| o.is_empty()) {
return new;
}
let item = |i: &Value| Some((i.get("text")?.as_str()?.to_string(), i.get("done").and_then(Value::as_bool).unwrap_or(false)));
let length = state["length"].as_i64().filter(|l| LENGTHS.iter().any(|(_, s)| s == l)).unwrap_or(new.length);
List {
title: state["title"].as_str().map_or(new.title, str::to_string),
items: state["items"].as_array().map(|a| a.iter().filter_map(item).take(MAX_ITEMS).collect()).unwrap_or_default(),
length,
left: state["left"].as_i64().unwrap_or(0).clamp(0, length),
paper: state["paper"].as_str().filter(|p| PAPERS.contains(p)).unwrap_or(PAPERS[0]).into(),
}
}
fn state(&self) -> Value {
let items: Vec<Value> = self.items.iter().map(|(text, done)| json!({"text": text, "done": done})).collect();
json!({"title": self.title, "items": items, "length": self.length, "left": self.left, "paper": self.paper})
}
/// The list after `event`, and what to do about its timer.
fn after(mut self, event: &WidgetEvent) -> (List, Clock) {
let mut clock = Clock::Leave;
let nth = |name: &str, prefix: &str| name.strip_prefix(prefix).and_then(|n| n.parse::<usize>().ok());
match event {
WidgetEvent::Click(target) if target == "focus" => {
// The button starts a countdown, or stops the one running.
(self.left, clock) = if self.left > 0 { (0, Clock::Stop) } else { (self.length, Clock::Tick) };
}
WidgetEvent::Click(target) => {
if let Some((_, done)) = nth(target, "toggle.").and_then(|i| self.items.get_mut(i)) {
*done = !*done;
}
}
WidgetEvent::Text { input, text } => {
let text = text.trim();
match (input.as_str(), nth(input, "item.")) {
("title", _) if !text.is_empty() => self.title = text.into(),
("new", _) if !text.is_empty() && self.items.len() < MAX_ITEMS => self.items.push((text.into(), false)),
// An item left empty goes.
(_, Some(i)) if text.is_empty() && i < self.items.len() => drop(self.items.remove(i)),
(_, Some(i)) => {
if let Some(item) = self.items.get_mut(i) {
item.0 = text.into();
}
}
_ => {}
}
}
WidgetEvent::Menu { name, value } => match (name.as_str(), value.as_str()) {
("clear", _) => self.items.retain(|(_, done)| !done),
("length", Some(label)) => self.length = LENGTHS.iter().find(|(l, _)| *l == label).map_or(self.length, |(_, s)| *s),
("paper", Some(p)) if PAPERS.contains(&p) => self.paper = p.into(),
_ => {}
},
WidgetEvent::Timer if self.left > 0 => {
self.left -= 1;
clock = if self.left > 0 { Clock::Tick } else { Clock::Done };
}
_ => {}
}
(self, clock)
}
/// "0:42"
fn clock_text(&self) -> String {
format!("{}:{:02}", self.left / 60, self.left % 60)
}
fn view(&self) -> Node {
let rule = || Node::line(WIDTH).stroke("#00000022", 1.0).fill_width();
let items = self.items.iter().enumerate().map(|(i, (text, done))| {
let mut tick =
Node::row().name("Tick").target(&format!("toggle.{i}")).size(16.0, 16.0).radius(4.0).justify("center").tooltip("Tick it off");
tick = if *done {
tick.fill(INK).child(Node::vector("M2 6 L5 9 L10 3").stroke("#FFFFFF", 2.0))
} else {
tick.fill("#FFFFFF").stroke(INK, 1.5).hover(Some("#00000018"), None, None)
};
let label = Node::text(text).name("Item").input(&format!("item.{i}"), MAX_CHARS).width(WIDTH - 24.0).font_size(13.0).color(INK);
Node::row().name("Row").gap(8.0).align("min").child(tick).child(if *done { label.opacity(0.45) } else { label })
});
let running = self.left > 0;
let button = Node::row()
.name("Focus")
.target("focus")
.paddings([6.0, 12.0, 6.0, 12.0])
.radius(14.0)
.fill(INK)
.hover(None, None, Some(0.8))
.tooltip(if running { "Stop the countdown" } else { "Start a focus countdown" })
.child(
Node::text(&if running { format!("Stop · {}", self.clock_text()) } else { "Start focus".into() })
.font_size(12.0)
.weight(600.0)
.color("#FFFFFF"),
);
// How much of the countdown is gone.
let gone = if running { (self.length - self.left) as f64 / self.length.max(1) as f64 } else { 0.0 };
let track = Node::frame()
.name("Progress")
.height(4.0)
.radius(2.0)
.fill("#00000018")
.fill_width()
.child(Node::rectangle((WIDTH - 110.0) * gone, 4.0).radius(2.0).fill(INK));
let mut note = Node::column()
.name("Focus list")
.width(WIDTH + 32.0)
.gap(10.0)
.padding(16.0)
.radii([2.0, 2.0, 14.0, 2.0])
.fill(&self.paper)
.shadow(0.0, 4.0, 12.0, "#00000030")
.align("min")
.child(
Node::text(&self.title)
.name("Title")
.input("title", MAX_CHARS)
.width(WIDTH)
.font_size(16.0)
.weight(700.0)
.color(INK)
.tooltip("Click to rename"),
)
.child(rule())
.children(items);
if self.items.len() < MAX_ITEMS {
note = note.child(Node::text("Add an item…").name("New item").input("new", MAX_CHARS).width(WIDTH).font_size(13.0).color("#00000066"));
}
note.child(rule()).child(Node::row().name("Footer").gap(10.0).width(WIDTH).child(button).child(track))
}
fn menu(&self) -> Vec<Value> {
let labels = LENGTHS.map(|(label, _)| label);
let length = LENGTHS.iter().find(|(_, s)| *s == self.length).map_or(labels[0], |(l, _)| l);
vec![
menu_action("clear", "Clear ticked"),
menu_dropdown("length", "Countdown", &labels, length),
menu_color("paper", "Paper", &PAPERS, &self.paper),
]
}
}
/// What an event means for the widget's timer.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Clock {
/// Keep the timer as it is.
Leave,
/// Call back in a second.
Tick,
Stop,
/// The countdown just ran out.
Done,
}
fn run(input: Input) -> Result<Value> {
let w = input.widget.ok_or("Insert the focus list from Plugins ▸ Widgets")?;
let (list, clock) = List::from(&w.state).after(&w.event);
match clock {
Clock::Tick => set_timer(1000)?,
Clock::Stop => cancel_timer(),
Clock::Done => notify(&format!("{}: time's up", list.title)),
Clock::Leave => {}
}
render(&list.state(), list.view(), Some(list.menu()))?;
Ok(json!({"left": list.left, "items": list.items.len()}))
}
entry!(run);
#[cfg(test)]
mod tests {
use super::*;
fn text(input: &str, text: &str) -> WidgetEvent {
WidgetEvent::Text { input: input.into(), text: text.into() }
}
#[test]
fn items_are_edited_ticked_added_and_removed() {
let (l, _) = List::from(&json!({})).after(&WidgetEvent::Insert);
assert_eq!((l.title.as_str(), l.items.len()), ("Launch checklist", 3));
let (l, _) = l.after(&text("title", " Ship it "));
let (l, _) = l.after(&text("item.1", "Tag v2"));
let (l, _) = l.after(&text("new", "Celebrate"));
let (l, _) = l.after(&WidgetEvent::Click("toggle.0".into()));
assert_eq!(l.title, "Ship it");
assert_eq!(l.items[1], ("Tag v2".to_string(), false));
assert_eq!((l.items.len(), l.items[3].0.as_str(), l.items[0].1), (4, "Celebrate", true));
assert_eq!(List::from(&l.state()), l, "the state round-trips");
// An emptied item goes; an empty title or new item changes nothing; junk is ignored.
let (l, _) = l.after(&text("item.2", " "));
let (l, _) = l.after(&text("title", ""));
let (l, _) = l.after(&text("new", ""));
let (l, _) = l.after(&text("item.99", "x"));
let (l, _) = l.after(&WidgetEvent::Click("toggle.99".into()));
assert_eq!((l.items.len(), l.title.as_str()), (3, "Ship it"));
let (l, _) = l.after(&WidgetEvent::Menu { name: "clear".into(), value: Value::Null });
assert_eq!(l.items.len(), 2);
let full = (0..20).fold(l, |l, i| l.after(&text("new", &format!("Item {i}"))).0);
assert_eq!(full.items.len(), MAX_ITEMS);
assert!(!Value::from(full.view()).to_string().contains("\"new\""), "a full list has no line to add one");
assert!(run(Input::default()).is_err(), "it only runs as a widget");
}
#[test]
fn the_countdown_ticks_down_and_stops() {
let l = List::from(&json!({"title": "T", "items": [], "length": 30}));
let (l, clock) = l.after(&WidgetEvent::Click("focus".into()));
assert_eq!((l.left, clock, l.clock_text().as_str()), (30, Clock::Tick, "0:30"));
let (l, clock) = l.after(&WidgetEvent::Timer);
assert_eq!((l.left, clock), (29, Clock::Tick));
let (stopped, clock) = l.clone().after(&WidgetEvent::Click("focus".into()));
assert_eq!((stopped.left, clock), (0, Clock::Stop));
let (l, clock) = List { left: 1, ..l }.after(&WidgetEvent::Timer);
assert_eq!((l.left, clock), (0, Clock::Done));
// A timer that fires with nothing running does nothing.
assert_eq!(l.clone().after(&WidgetEvent::Timer).1, Clock::Leave);
let (l, _) = l.after(&WidgetEvent::Menu { name: "length".into(), value: json!("5 minutes") });
assert_eq!((l.length, l.menu()[1]["value"].as_str()), (300, Some("5 minutes")));
assert_eq!(List::from(&json!({"title": "T", "length": 7, "left": 99, "paper": "blue"})).length, 30, "junk state falls back");
}
}
+73
View File
@@ -28,6 +28,11 @@
//! [`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.
//!
//! With API 4 a widget hears of more than clicks ([`Widget::event`]): a text of it the user edited
//! in place, an item of its property menu, a timer it asked for ([`set_timer`]). It can also draw
//! more (lines, vectors, gradients, shadows, text styles, hover styles, tooltips); [`ui`] has
//! builders for descriptions and panels, so a plugin needn't spell the JSON out.
//!
//! 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)]
@@ -40,6 +45,8 @@ use alloc::vec::Vec;
pub use serde_json::{self, Value, json};
pub mod ui;
/// 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>;
@@ -68,6 +75,43 @@ pub struct Widget {
pub state: Value,
/// The target clicked.
pub target: Option<String>,
/// What happened to the widget.
pub event: WidgetEvent,
}
/// What a widget is called for.
#[derive(Clone, Debug, Default, PartialEq)]
pub enum WidgetEvent {
/// It was just inserted: draw it for the first time.
#[default]
Insert,
/// The part with this `target` was clicked.
Click(String),
/// API 4. The user edited the text named `input` in place; `text` is what they left there
/// (already cut to its maximum length, and one line unless it's multiline).
Text { input: String, text: String },
/// API 4. The user chose item `name` of the property menu; `value` is a toggle's new state
/// (a boolean), a dropdown's or colour's choice (a string), or null for an action.
Menu { name: String, value: Value },
/// API 4. The timer asked for with [`set_timer`] is due.
Timer,
}
impl WidgetEvent {
fn parse(e: Option<&Value>) -> WidgetEvent {
let text = |key: &str| e.and_then(|e| e.get(key)).and_then(Value::as_str).map(ToString::to_string);
if let Some(target) = text("target") {
WidgetEvent::Click(target)
} else if let Some(input) = text("input") {
WidgetEvent::Text { input, text: text("text").unwrap_or_default() }
} else if let Some(name) = text("menu") {
WidgetEvent::Menu { name, value: e.and_then(|e| e.get("value")).cloned().unwrap_or(Value::Null) }
} else if e.and_then(|e| e.get("timer")).is_some() {
WidgetEvent::Timer
} else {
WidgetEvent::Insert
}
}
}
/// A button pressed or a value changed in the plugin's panel.
@@ -98,6 +142,7 @@ impl Input {
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),
event: WidgetEvent::parse(w.get("event")),
}),
})
}
@@ -168,6 +213,19 @@ pub fn render_widget(widget: &Value) -> Result<()> {
if host::widget_render(spec.as_bytes()) == 0 { Ok(()) } else { Err(String::from_utf8_lossy(&host::reply()).into_owned()) }
}
/// API 4. Ask for the widget being called to be called again (with [`WidgetEvent::Timer`]) in
/// `ms` milliseconds: 100 to 3,600,000 (an hour). A widget has one timer at most (asking again
/// replaces it), and it runs only while its file is open and in front, for whoever started it
/// last. Calling back for a timer is like any other callback: draw the widget again, or don't.
pub fn set_timer(ms: u32) -> Result<()> {
if host::widget_timer(i32::try_from(ms).unwrap_or(i32::MAX)) == 0 { Ok(()) } else { Err(String::from_utf8_lossy(&host::reply()).into_owned()) }
}
/// API 4. Stop the widget's pending timer.
pub fn cancel_timer() {
host::widget_timer(0);
}
/// 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 {
@@ -238,6 +296,7 @@ mod host {
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;
pub fn widget_timer(ms: i32) -> i32;
}
}
@@ -283,6 +342,9 @@ mod host {
pub fn widget_render(s: &[u8]) -> i32 {
unsafe { sys::widget_render(s.as_ptr(), s.len()) }
}
pub fn widget_timer(ms: i32) -> i32 {
unsafe { sys::widget_timer(ms) }
}
}
/// Outside WebAssembly (a plugin's own unit tests) every host call fails.
@@ -316,6 +378,9 @@ mod host {
pub fn widget_render(_s: &[u8]) -> i32 {
1
}
pub fn widget_timer(_ms: i32) -> i32 {
1
}
}
#[cfg(test)]
@@ -344,5 +409,13 @@ mod tests {
.widget
.unwrap_or_default();
assert_eq!((w.id, &w.state, w.target.as_deref()), (9, &json!({"n": 1}), Some("inc")));
assert_eq!(w.event, WidgetEvent::Click("inc".into()));
let event =
|e: Value| Input::parse(&json!({"widget": {"id": 1, "event": e}}).to_string()).unwrap_or_default().widget.unwrap_or_default().event;
assert_eq!(event(Value::Null), WidgetEvent::Insert);
assert_eq!(event(json!({"input": "title", "text": "Hi"})), WidgetEvent::Text { input: "title".into(), text: "Hi".into() });
assert_eq!(event(json!({"menu": "tint", "value": "#FF0000"})), WidgetEvent::Menu { name: "tint".into(), value: json!("#FF0000") });
assert_eq!(event(json!({"timer": true})), WidgetEvent::Timer);
assert!(set_timer(1000).is_err(), "no host off WebAssembly");
}
}
+350
View File
@@ -0,0 +1,350 @@
//! Builders for what a plugin describes to FigCraft: a widget's layers ([`Node`]) and property
//! menu ([`menu_action`] and its siblings), and the controls of a panel that plugin API 4 added
//! ([`slider`], [`image`], [`progress`], [`divider`], [`section`]). They only assemble the JSON
//! that `docs/plugins.md` documents; [`Node::set`] reaches anything they don't name, and plain
//! `json!` works everywhere too.
//!
//! ```ignore
//! use figcraft_plugin_sdk::ui::{Node, menu_toggle, render};
//!
//! let root = Node::column()
//! .padding(12.0)
//! .gap(8.0)
//! .radius(12.0)
//! .fill("#FFFFFF")
//! .shadow(0.0, 2.0, 8.0, "#00000033")
//! .child(Node::text("Title").input("title", 60).weight(600.0).tooltip("Click to rename"))
//! .child(Node::line(160.0).stroke("#E6E6E6", 1.0).fill_width());
//! render(&json!({"title": "Title"}), root, Some(vec![menu_toggle("done", "Done", false)]))?;
//! ```
use alloc::string::String;
use alloc::vec::Vec;
use serde_json::{Map, Value, json};
/// One layer of a widget's description.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Node(Map<String, Value>);
impl From<Node> for Value {
fn from(n: Node) -> Value {
Value::Object(n.0)
}
}
/// Setters that put one value under one key.
macro_rules! setters {
($($(#[$doc:meta])* $name:ident($ty:ty) => $key:literal;)*) => {
$($(#[$doc])* pub fn $name(self, v: $ty) -> Node { self.set($key, v) })*
};
}
impl Node {
fn of(kind: &str) -> Node {
Node::default().set("type", kind)
}
/// A frame without auto layout: its children sit at their `x` and `y`.
pub fn frame() -> Node {
Node::of("frame")
}
/// An auto layout frame with its children side by side; it hugs them on an axis without a size.
pub fn row() -> Node {
Node::of("frame").set("layout", "horizontal")
}
/// An auto layout frame with its children one under the other.
pub fn column() -> Node {
Node::of("frame").set("layout", "vertical")
}
/// A text. With a `width` it wraps; without one it grows with its text.
pub fn text(text: &str) -> Node {
Node::of("text").set("text", text)
}
pub fn rectangle(width: f64, height: f64) -> Node {
Node::of("rectangle").width(width).height(height)
}
pub fn ellipse(width: f64, height: f64) -> Node {
Node::of("ellipse").width(width).height(height)
}
/// API 4. A horizontal line (black and 1 thick unless it gets a [`stroke`](Node::stroke)).
pub fn line(length: f64) -> Node {
Node::of("line").width(length)
}
/// API 4. A shape from SVG path data (`M0 0 L10 0 L5 8 Z`); with a size it's scaled to it.
pub fn vector(path: &str) -> Node {
Node::of("vector").set("path", path)
}
/// An image from its file's bytes (PNG, JPEG, BMP…), usually `include_bytes!` in the plugin.
pub fn image(bytes: &[u8]) -> Node {
Node::of("image").set("data", base64(bytes))
}
/// Any property by its name in the widget description.
pub fn set(mut self, key: &str, value: impl Into<Value>) -> Node {
self.0.insert(key.into(), value.into());
self
}
setters! {
/// The layer's name in the layers panel.
name(&str) => "name";
/// Clicking the layer calls the plugin back with this name.
target(&str) => "target";
x(f64) => "x";
y(f64) => "y";
width(f64) => "width";
height(f64) => "height";
/// A hex colour (`#RRGGBB` or `#RRGGBBAA`).
fill(&str) => "fill";
opacity(f64) => "opacity";
/// The same radius on every corner.
radius(f64) => "radius";
/// Auto layout: the space between children.
gap(f64) => "gap";
/// Auto layout: the same padding on every side.
padding(f64) => "padding";
/// Auto layout: where the children sit across it: `min`, `center` or `max`.
align(&str) => "align";
/// Auto layout: where the children sit along it: `min`, `center` or `max`.
justify(&str) => "justify";
/// A text's font size.
font_size(f64) => "size";
/// A text's colour (hex).
color(&str) => "color";
/// API 4. A text's font weight, 100–900.
weight(f64) => "weight";
/// API 4. A text's alignment in its width: `left`, `center` or `right`.
text_align(&str) => "textAlign";
/// API 4. A text with a width shows this many lines at most; the rest is cut with an ellipsis.
max_lines(f64) => "maxLines";
/// API 4. An image's fit in its box: `fill`, `fit`, `crop` or `tile`.
fit(&str) => "fit";
/// API 4. Shown while the pointer rests on the layer.
tooltip(&str) => "tooltip";
}
pub fn size(self, width: f64, height: f64) -> Node {
self.width(width).height(height)
}
pub fn stroke(self, color: &str, weight: f64) -> Node {
self.set("stroke", color).set("strokeWeight", weight)
}
pub fn bold(self) -> Node {
self.set("bold", true)
}
/// API 4. Radii for the top-left, top-right, bottom-right and bottom-left corners.
pub fn radii(self, corners: [f64; 4]) -> Node {
self.set("radius", json!(corners))
}
/// API 4. Paddings for the top, right, bottom and left sides.
pub fn paddings(self, sides: [f64; 4]) -> Node {
self.set("padding", json!(sides))
}
/// API 4. In an auto layout frame: take the width that's left.
pub fn fill_width(self) -> Node {
self.set("fillWidth", true)
}
/// API 4. In an auto layout frame: take the height that's left.
pub fn fill_height(self) -> Node {
self.set("fillHeight", true)
}
fn gradient(self, kind: &str, angle: Option<f64>, stops: &[(f64, &str)]) -> Node {
let stops: Vec<Value> = stops.iter().map(|(at, color)| json!({"at": at, "color": color})).collect();
self.set("gradient", json!({"type": kind, "angle": angle, "stops": stops}))
}
/// API 4. A linear gradient instead of a fill: `angle` in degrees (0 runs left to right, 90
/// top to bottom) and two to eight `(position 0–1, hex colour)` stops.
pub fn linear_gradient(self, angle: f64, stops: &[(f64, &str)]) -> Node {
self.gradient("linear", Some(angle), stops)
}
/// API 4. A radial gradient from the centre instead of a fill.
pub fn radial_gradient(self, stops: &[(f64, &str)]) -> Node {
self.gradient("radial", None, stops)
}
/// API 4. A drop shadow.
pub fn shadow(self, x: f64, y: f64, blur: f64, color: &str) -> Node {
self.set("shadow", json!({"x": x, "y": y, "blur": blur, "color": color}))
}
/// API 4. A text the user can edit in place (a click starts it; Enter or clicking elsewhere
/// commits): the plugin is then called with [`WidgetEvent::Text`](crate::WidgetEvent::Text)
/// carrying `name` and the text, at most `max_length` characters, on one line.
pub fn input(self, name: &str, max_length: u32) -> Node {
self.set("input", name).set("maxLength", max_length)
}
/// API 4. An [`input`](Node::input) that takes several lines (Enter starts a new one;
/// clicking elsewhere or Escape commits).
pub fn multiline(self) -> Node {
self.set("multiline", true)
}
/// API 4. What changes while the pointer is over the layer: its fill, its stroke and its
/// opacity (`None`: as it is). FigCraft shows it; the plugin isn't called.
pub fn hover(self, fill: Option<&str>, stroke: Option<&str>, opacity: Option<f64>) -> Node {
self.set("hover", json!({"fill": fill, "stroke": stroke, "opacity": opacity}))
}
/// Add a layer inside this frame.
pub fn child(mut self, child: Node) -> Node {
match self.0.entry("children").or_insert_with(|| Value::Array(Vec::new())) {
Value::Array(a) => a.push(child.into()),
other => *other = json!([Value::from(child)]),
}
self
}
/// Add layers inside this frame.
pub fn children(self, children: impl IntoIterator<Item = Node>) -> Node {
children.into_iter().fold(self, Node::child)
}
}
/// Draw the widget being called: its new `state` (a JSON object), its layers and, with `Some`,
/// its property menu (`None` keeps the menu it has; an empty one removes it).
pub fn render(state: &Value, root: Node, menu: Option<Vec<Value>>) -> crate::Result<()> {
let mut w = json!({"state": state, "root": Value::from(root)});
if let Some(menu) = menu {
w["menu"] = Value::Array(menu);
}
crate::render_widget(&w)
}
/// API 4. A button in the widget's property menu: choosing it calls the plugin with
/// [`WidgetEvent::Menu`](crate::WidgetEvent::Menu) carrying `name`.
pub fn menu_action(name: &str, label: &str) -> Value {
json!({"type": "action", "name": name, "label": label})
}
/// API 4. An on/off item; the plugin gets the new state as the value.
pub fn menu_toggle(name: &str, label: &str, on: bool) -> Value {
json!({"type": "toggle", "name": name, "label": label, "value": on})
}
/// API 4. A menu of `options` showing `value`; the plugin gets the option picked.
pub fn menu_dropdown(name: &str, label: &str, options: &[&str], value: &str) -> Value {
json!({"type": "dropdown", "name": name, "label": label, "options": options, "value": value})
}
/// API 4. A row of hex colours to pick one from, `value` marked; the plugin gets the one picked.
pub fn menu_color(name: &str, label: &str, palette: &[&str], value: &str) -> Value {
json!({"type": "color", "name": name, "label": label, "options": palette, "value": value})
}
/// API 4. A line between menu items.
pub fn menu_separator() -> Value {
json!({"type": "separator"})
}
/// API 4. A link (http or https) FigCraft opens when it's clicked; the plugin isn't called.
pub fn menu_link(name: &str, label: &str, url: &str) -> Value {
json!({"type": "link", "name": name, "label": label, "url": url})
}
/// API 4, panels. A slider from `min` to `max` in `step`s. While it's dragged the plugin is
/// called back a few times a second, and once more when it's let go.
pub fn slider(name: &str, label: &str, min: f64, max: f64, step: f64, value: f64) -> Value {
json!({"type": "slider", "name": name, "label": label, "min": min, "max": max, "step": step, "value": value})
}
/// API 4, panels. A picture from its file's bytes, `height` high (16–400), fitted in the panel's
/// width; `label` names it for screen readers.
pub fn image(label: &str, bytes: &[u8], height: f64) -> Value {
json!({"type": "image", "label": label, "data": base64(bytes), "height": height, "fit": "fit"})
}
/// API 4, panels. A progress bar filled to `fraction` (0–1).
pub fn progress(label: &str, fraction: f64) -> Value {
json!({"type": "progress", "label": label, "value": fraction})
}
/// API 4, panels. A line between controls.
pub fn divider() -> Value {
json!({"type": "divider"})
}
/// API 4, panels. A heading that folds the controls after it, up to the next section or divider.
pub fn section(label: &str, open: bool) -> Value {
json!({"type": "section", "label": label, "value": open})
}
/// API 4, panels. `control` greyed out: it can't be used.
pub fn disabled(mut control: Value) -> Value {
control["disabled"] = Value::Bool(true);
control
}
/// API 4, panels. `control` with a validation message under it.
pub fn invalid(mut control: Value, message: &str) -> Value {
control["error"] = Value::String(message.into());
control
}
/// `bytes` as base64, the way image data travels in descriptions.
pub fn base64(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let n = chunk.iter().enumerate().fold(0u32, |n, (i, b)| n | u32::from(*b) << (16 - 8 * i));
for i in 0..4 {
let sextet = ALPHABET.get((n >> (18 - 6 * i)) as usize & 63).copied().unwrap_or(b'A');
out.push(if i <= chunk.len() { char::from(sextet) } else { '=' });
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builders_make_the_documented_json() {
let n = Node::row().gap(4.0).paddings([1.0, 2.0, 3.0, 4.0]).child(Node::text("Hi").input("title", 20).multiline()).child(Node::line(10.0));
let v = Value::from(n);
assert_eq!(
(v["type"].as_str(), v["layout"].as_str(), v["padding"].clone()),
(Some("frame"), Some("horizontal"), json!([1.0, 2.0, 3.0, 4.0]))
);
assert_eq!(v["children"][0], json!({"type": "text", "text": "Hi", "input": "title", "maxLength": 20, "multiline": true}));
assert_eq!(v["children"][1], json!({"type": "line", "width": 10.0}));
let g = Value::from(Node::rectangle(8.0, 8.0).linear_gradient(90.0, &[(0.0, "#000000"), (1.0, "#FFFFFF")]).hover(
Some("#FF0000"),
None,
Some(0.5),
));
assert_eq!(g["gradient"]["stops"][1], json!({"at": 1.0, "color": "#FFFFFF"}));
assert_eq!(g["hover"], json!({"fill": "#FF0000", "stroke": null, "opacity": 0.5}));
assert_eq!(menu_color("tint", "Tint", &["#FF0000"], "#FF0000")["options"], json!(["#FF0000"]));
assert_eq!(disabled(invalid(slider("n", "N", 0.0, 10.0, 1.0, 3.0), "Too low"))["error"], "Too low");
assert!(render(&json!({}), Node::frame(), Some(Vec::new())).is_err(), "no host off WebAssembly");
}
#[test]
fn base64_pads() {
for (bytes, text) in
[("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")]
{
assert_eq!(base64(bytes.as_bytes()), text);
}
}
}
+45 -3
View File
@@ -9,7 +9,8 @@
//! - `wasm` check the core crates and web app for wasm32, and build the plugin SDK and examples
//! - `plugins` format-check, lint, test and build the plugin SDK and example plugins (`plugins/`, a
//! workspace of its own) for wasm32: `<target>/plugins/dist/<example>/` holds each manifest and
//! module, ready for Plugins ▸ Development ▸ Import plugin from manifest…
//! module, ready for Plugins ▸ Development ▸ Import plugin from manifest…; then runs every
//! built example in the engine (`figcraft-cli run`), so a module that no longer works fails here
//! - `bench` release-build timings at 1k/10k/50k layers (`bench 1000,10000` for other sizes) and
//! with a synthetic design system used by 2,000 instances
@@ -81,7 +82,7 @@ fn wasm() -> bool {
/// The example plugins in `plugins/examples` (each folder's name is its package; its module is
/// `<name with _>.wasm`).
const EXAMPLE_PLUGINS: &[&str] = &["tidy-names", "swatch-grid", "counter"];
const EXAMPLE_PLUGINS: &[&str] = &["tidy-names", "swatch-grid", "counter", "focus-list", "dot-grid"];
/// The plugin workspace: fmt, clippy and build for wasm32, host tests, then each example's manifest
/// and module side by side in `<target>/plugins/dist/<example>/`.
@@ -116,7 +117,48 @@ fn plugins() -> bool {
}
}
}
true
smoke(&target)
}
/// What the built examples are asked to do, each in the engine as a script would: the commands of
/// every plugin API (a run, a panel, a widget with its clicks, typed text, menu and timer). A
/// widget command without an `id` acts on the selected widget (the one just inserted).
const SMOKE: &[(&str, &str)] = &[
("shape.rectangle", r#"{"x": 0, "y": 0, "width": 40, "height": 40, "props": {"name": " a layer copy "}}"#),
("select.all", "{}"),
("plugin.run", r#"{"id": "org.figcraft.examples.tidy-names", "command": "tidy", "params": {"case": "Title Case"}}"#),
("plugin.run", r#"{"id": "org.figcraft.examples.swatch-grid", "command": "panel"}"#),
("plugin.event", r#"{"id": "org.figcraft.examples.swatch-grid", "control": "palette", "value": "Ocean"}"#),
("plugin.event", r#"{"id": "org.figcraft.examples.swatch-grid", "control": "generate"}"#),
("plugin.close", r#"{"id": "org.figcraft.examples.swatch-grid"}"#),
("widget.insert", r#"{"plugin": "org.figcraft.examples.counter", "x": 0, "y": 200}"#),
("widget.click", r#"{"target": "increment"}"#),
("widget.menuAction", r#"{"item": "step", "value": "5"}"#),
("widget.menuAction", r#"{"item": "reset"}"#),
("widget.insert", r#"{"plugin": "org.figcraft.examples.focus-list", "x": 0, "y": 400}"#),
("widget.setText", r#"{"input": "title", "text": "Smoke test"}"#),
("widget.setText", r#"{"input": "new", "text": "One more"}"#),
("widget.click", r#"{"target": "toggle.0"}"#),
("widget.click", r#"{"target": "focus"}"#),
("widget.fireTimer", "{}"),
("widget.list", "{}"),
("plugin.run", r#"{"id": "org.figcraft.examples.dot-grid"}"#),
("plugin.event", r#"{"id": "org.figcraft.examples.dot-grid", "control": "columns", "value": 4}"#),
("plugin.event", r#"{"id": "org.figcraft.examples.dot-grid", "control": "generate"}"#),
("plugin.close", r#"{"id": "org.figcraft.examples.dot-grid"}"#),
];
/// Import the examples built into `<target>/dist` and run [`SMOKE`] with them.
fn smoke(target: &std::path::Path) -> bool {
let dist = target.join("dist").to_string_lossy().replace('\\', "/");
let imports = EXAMPLE_PLUGINS.iter().map(|name| format!(r#"{{"command": "plugin.import", "params": {{"path": "{dist}/{name}"}}}}"#));
let steps = SMOKE.iter().map(|(command, params)| format!(r#"{{"command": "{command}", "params": {params}}}"#));
let script = target.join("smoke.jsonl");
if let Err(e) = std::fs::write(&script, imports.chain(steps).collect::<Vec<_>>().join("\n")) {
eprintln!("plugins: {}: {e}", script.display());
return false;
}
run(cargo().args(["run", "-q", "-p", "figcraft-cli", "--", "run"]).arg(&script).stdout(std::process::Stdio::null()))
}
/// Engine/render timings (`figcraft-cli perf`) and UI frame timings (an ignored test driving the