From 1f9c606eb6fadc32fa68f8b1c0468b9dc3a9d885 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 10:22:18 +0700 Subject: [PATCH] M11: plugin API 4: SDK builders, Counter menu, Focus list and Dot grid examples, examples smoke run --- plugins/Cargo.lock | 14 + plugins/examples/counter/figcraft-plugin.json | 6 +- plugins/examples/counter/src/lib.rs | 145 ++++++-- plugins/examples/dot-grid/Cargo.toml | 15 + .../examples/dot-grid/figcraft-plugin.json | 13 + plugins/examples/dot-grid/src/lib.rs | 177 +++++++++ plugins/examples/focus-list/Cargo.toml | 15 + .../examples/focus-list/figcraft-plugin.json | 10 + plugins/examples/focus-list/src/lib.rs | 261 +++++++++++++ plugins/sdk/src/lib.rs | 73 ++++ plugins/sdk/src/ui.rs | 350 ++++++++++++++++++ xtask/src/main.rs | 48 ++- 12 files changed, 1088 insertions(+), 39 deletions(-) create mode 100644 plugins/examples/dot-grid/Cargo.toml create mode 100644 plugins/examples/dot-grid/figcraft-plugin.json create mode 100644 plugins/examples/dot-grid/src/lib.rs create mode 100644 plugins/examples/focus-list/Cargo.toml create mode 100644 plugins/examples/focus-list/figcraft-plugin.json create mode 100644 plugins/examples/focus-list/src/lib.rs create mode 100644 plugins/sdk/src/ui.rs diff --git a/plugins/Cargo.lock b/plugins/Cargo.lock index 7738ceb5e..d1d8da3af 100644 --- a/plugins/Cargo.lock +++ b/plugins/Cargo.lock @@ -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" diff --git a/plugins/examples/counter/figcraft-plugin.json b/plugins/examples/counter/figcraft-plugin.json index a046ebc5c..326417b1e 100644 --- a/plugins/examples/counter/figcraft-plugin.json +++ b/plugins/examples/counter/figcraft-plugin.json @@ -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" } } diff --git a/plugins/examples/counter/src/lib.rs b/plugins/examples/counter/src/lib.rs index d149a53d1..09832330c 100644 --- a/plugins/examples/counter/src/lib.rs +++ b/plugins/examples/counter/src/lib.rs @@ -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 { + 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 { 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" + ); + } } diff --git a/plugins/examples/dot-grid/Cargo.toml b/plugins/examples/dot-grid/Cargo.toml new file mode 100644 index 000000000..54b042b1a --- /dev/null +++ b/plugins/examples/dot-grid/Cargo.toml @@ -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 } diff --git a/plugins/examples/dot-grid/figcraft-plugin.json b/plugins/examples/dot-grid/figcraft-plugin.json new file mode 100644 index 000000000..cb8dddd0b --- /dev/null +++ b/plugins/examples/dot-grid/figcraft-plugin.json @@ -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…" }] +} diff --git a/plugins/examples/dot-grid/src/lib.rs b/plugins/examples/dot-grid/src/lib.rs new file mode 100644 index 000000000..20071b8e1 --- /dev/null +++ b/plugins/examples/dot-grid/src/lib.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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"); + } +} diff --git a/plugins/examples/focus-list/Cargo.toml b/plugins/examples/focus-list/Cargo.toml new file mode 100644 index 000000000..a187108e5 --- /dev/null +++ b/plugins/examples/focus-list/Cargo.toml @@ -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 } diff --git a/plugins/examples/focus-list/figcraft-plugin.json b/plugins/examples/focus-list/figcraft-plugin.json new file mode 100644 index 000000000..add716a57 --- /dev/null +++ b/plugins/examples/focus-list/figcraft-plugin.json @@ -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" } +} diff --git a/plugins/examples/focus-list/src/lib.rs b/plugins/examples/focus-list/src/lib.rs new file mode 100644 index 000000000..e762da4be --- /dev/null +++ b/plugins/examples/focus-list/src/lib.rs @@ -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 = 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::().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 { + 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 { + 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"); + } +} diff --git a/plugins/sdk/src/lib.rs b/plugins/sdk/src/lib.rs index 967af7888..4c992dd3a 100644 --- a/plugins/sdk/src/lib.rs +++ b/plugins/sdk/src/lib.rs @@ -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 = core::result::Result; @@ -68,6 +75,43 @@ pub struct Widget { pub state: Value, /// The target clicked. pub target: Option, + /// 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 { 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"); } } diff --git a/plugins/sdk/src/ui.rs b/plugins/sdk/src/ui.rs new file mode 100644 index 000000000..d14fa76de --- /dev/null +++ b/plugins/sdk/src/ui.rs @@ -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); + +impl From 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) -> 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, stops: &[(f64, &str)]) -> Node { + let stops: Vec = 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) -> 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) -> 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>) -> 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); + } + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index e3f00e8bc..a8df4a179 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -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: `/plugins/dist//` 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 /// `.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 `/plugins/dist//`. @@ -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 `/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::>().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