Files
figcraft/crates/engine/src/widgets_tests.rs
T
bflatastic 119b8c7441 M5: prototyping tail: gamepad triggers, widgets in prototypes, mode lookups
The part of gap 15 that doesn't need video, from the public documentation of prototype triggers, plugins/widgets and prototype expressions.

- **Controller buttons on key/gamepad triggers.** A key trigger's key can be a controller button: `Gamepad A` ... `Gamepad Home`, the standard gamepad layout's 17 buttons (face buttons by position, shoulders, triggers, View/Menu, stick presses, d-pad, Home). The engine keeps its input-agnostic model (a key trigger with a key name; `figcraft_doc::proto::GAMEPAD_BUTTONS`, `gamepad_button`, `is_gamepad_button`, `uses_gamepad`). The interaction editor gets a Controller menu beside the key field; the trigger list shows the button's own name.
- **Reading controllers.** A new `Services::gamepad_held()` (bitmask of held buttons; default none). The player polls it only while the presented prototype has a controller trigger and the window has the focus (about 30 times a second), plays each press once (held buttons don't repeat), and fires the first matching key/gamepad trigger on the screen like a key.
  - Desktop, Windows: Windows.Gaming.Input (Xbox-compatible controllers) through the `windows` crate the app already depends on (one more feature flag, `Gaming_Input`; no new crate).
  - Browser: the Gamepad API through `web-sys` (features `Gamepad`, `GamepadButton`, `GamepadMappingType`; standard-mapped controllers only).
  - macOS, Linux, FreeBSD: no controllers yet (documented gap).
  - Control channel: `ui.input {events: [{gamepad: "A" | "Gamepad A" | 0}]}` presses a button in the prototype playing (presenter, else preview pane); an error when nothing plays.
- **REST import** maps controller triggers (`device` other than `KEYBOARD`) to the button at the code's standard-layout index instead of skipping them; two buttons at once or an unknown code is skipped with a warning (`.fig` goes through the same path).
- **Widgets react to clicks in a presented prototype.** A click on a widget's clickable part in the presenter or the preview pane (when no interaction on the layer takes it) calls the plugin back through the canvas's own callback with the same sandbox limits, on the player's copy of the file (`widgets::click_on`, a scratch session holding the copy and the installed plugins): the widget changes in the prototype only, the file and its undo history stay untouched, restarting starts the widget over. Failures (plugin missing or failing) toast; toasts now also show over the full-window presenter.
- **Variable mode lookups.** Lookups (`accent[Dark]`, `mode(Theme)`), set-variable-mode affecting later expressions in the same interaction, and every operator the documentation lists already held; they are now behaviour cases. New `proto.evaluate {expression | expr, id?}` (MCP and agents) computes an expression where a layer's interaction would run, without changing anything. No other cheap expression gap found: every documented operator (numbers, text join, booleans, comparisons, negation, mode lookup) already existed.
- Docs: prototyping-parity (new rule rows, features, revision), target-app-parity area 13 and totals, ROADMAP, gaps.md gap 15, roadmap.md M5, mcp.md, plugins.md, control-protocol.md.

Dependencies: none added. Windows uses the existing `windows` 0.62 dependency with the `Gaming_Input` feature; the browser uses the existing `web-sys` with three more features. The usual Rust controller library (gilrs) was not taken: its Linux backend links the system's libudev (a C library, needing headers on CI and the FreeBSD job), which breaks the pure-Rust rule.
2026-10-12 05:28:53 +07:00

349 lines
17 KiB
Rust

//! Widgets (plugin API 3): inserted and clicked as one undo step each (from commands and from the
//! canvas), kept through undo, save and reload, duplicating, export and live sessions; junk
//! descriptions, bad clicks, runaway callbacks and forged widget data are refused and roll back.
//! Test plugins are WAT: they output their input, then draw the widget the input asks for.
use serde_json::{Value, json};
use crate::components::tests::{ex, idv, node};
use crate::plugins_tests::{install, layer_count, lit, rect, undo_len};
use crate::tools::{Cursor, PointerEvent, PointerKind};
use crate::{EngineError, Session};
use figcraft_doc::NodeId;
use figcraft_geom::Point;
const INPUT: usize = 100_000;
const REPLY: usize = 200_000;
/// Lays out a test plugin's strings and builds its module.
#[derive(Default)]
struct W {
data: String,
at: usize,
}
impl W {
fn s(&mut self, text: &str) -> (usize, usize) {
let a = 1000 + self.at;
self.data += &format!("(data (i32.const {a}) \"{}\")\n", lit(text));
self.at += text.len();
assert!(self.at < INPUT - 1000);
(a, text.len())
}
/// Draw `v`; when it's refused, output why and fail.
fn render(&mut self, v: &Value) -> String {
let (a, l) = self.s(&v.to_string());
format!(
"(if (call $render (i32.const {a}) (i32.const {l})) (then (call $set_output (i32.const {REPLY}) (call $reply_read (i32.const {REPLY}) (i32.const 50000))) (return (i32.const 1))))"
)
}
/// Whether the input contains `needle`.
fn has(&mut self, needle: &str) -> String {
let (a, l) = self.s(needle);
format!("(call $has (i32.const {a}) (i32.const {l}) (local.get $n))")
}
/// Call `cmd`; a refusal becomes the output and the run stops (successfully).
fn call(&mut self, cmd: &str, params: &Value) -> String {
let ((a, l), (b, m)) = (self.s(cmd), self.s(&params.to_string()));
format!(
"(if (call $call (i32.const {a}) (i32.const {l}) (i32.const {b}) (i32.const {m}))
(then (call $set_output (i32.const {REPLY}) (call $reply_read (i32.const {REPLY}) (i32.const 50000))) (return (i32.const 0))))"
)
}
/// The module: it reads and outputs its input, then runs `body`.
fn build(self, body: &str) -> Vec<u8> {
let wat = format!(
r#"(module
(import "figcraft" "call" (func $call (param i32 i32 i32 i32) (result i32)))
(import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32)))
(import "figcraft" "set_output" (func $set_output (param i32 i32)))
(import "figcraft" "widget_render" (func $render (param i32 i32) (result i32)))
(memory (export "memory") 4)
{}
(func $has (param $p i32) (param $l i32) (param $n i32) (result i32) (local $i i32) (local $j i32)
(block $no (loop $outer
(br_if $no (i32.gt_s (i32.add (local.get $i) (local.get $l)) (local.get $n)))
(local.set $j (i32.const 0))
(block $miss (loop $inner
(if (i32.ge_s (local.get $j) (local.get $l)) (then (return (i32.const 1))))
(br_if $miss (i32.ne (i32.load8_u (i32.add (i32.const {INPUT}) (i32.add (local.get $i) (local.get $j))))
(i32.load8_u (i32.add (local.get $p) (local.get $j)))))
(local.set $j (i32.add (local.get $j) (i32.const 1)))
(br $inner)))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $outer)))
(i32.const 0))
(func (export "figcraft_run") (param $n i32) (result i32)
(drop (call $reply_read (i32.const {INPUT}) (local.get $n)))
(call $set_output (i32.const {INPUT}) (local.get $n))
{body}
(i32.const 0)))"#,
self.data
);
wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}"))
}
}
/// A widget showing `label` with the clickable parts `targets` (a rectangle, then ellipses).
fn look(n: i64, label: &str, targets: &[&str]) -> Value {
let mut children = vec![json!({"type": "text", "name": "Label", "text": label, "size": 12, "color": "#111111"})];
for (i, t) in targets.iter().enumerate() {
let kind = if i == 0 { "rectangle" } else { "ellipse" };
children.push(json!({"type": kind, "target": t, "width": 20, "height": 20, "fill": "#0D99FF"}));
}
json!({"state": {"n": n}, "root": {"type": "frame", "name": "Tally", "layout": "horizontal", "gap": 8, "padding": 10, "fill": "#FFFFFF", "children": children}})
}
/// The test widget: it shows 1 when inserted, 2 after `inc` and 0 after `dec`; `extra` runs
/// first on every call.
fn tally(extra: impl FnOnce(&mut W) -> String) -> Vec<u8> {
let mut w = W::default();
let pre = extra(&mut w);
let (inc, dec) = (w.has(r#""target":"inc""#), w.has(r#""target":"dec""#));
let (two, zero, one) = (w.render(&look(2, "two", &["inc", "dec"])), w.render(&look(0, "zero", &["inc"])), w.render(&look(1, "one", &["inc"])));
w.build(&format!("{pre} (if {inc} (then {two} (return (i32.const 0)))) (if {dec} (then {zero} (return (i32.const 0)))) {one}"))
}
fn widget_manifest(id: &str, access: &str) -> Value {
json!({"id": id, "name": "Tally", "version": "1.0.0", "main": "w.wasm", "api": 3, "widget": true, "permissions": {"access": access}})
}
/// A session with the tally widget plugin installed (read access).
pub(crate) fn session() -> Session {
let mut s = Session::new();
install(&mut s, widget_manifest("org.test.tally", "read"), &tally(|_| String::new()));
s
}
pub(crate) fn insert(s: &mut Session) -> (NodeId, Value) {
let r = ex(s, "widget.insert", json!({"plugin": "org.test.tally", "x": 50, "y": 60}));
(idv(&r), r)
}
fn click(s: &mut Session, w: NodeId, target: &str) -> Value {
ex(s, "widget.click", json!({"id": w, "target": target}))
}
fn state(s: &mut Session, w: NodeId) -> Value {
let list = ex(s, "widget.list", json!({}));
list["widgets"].as_array().unwrap().iter().find(|x| x["id"] == json!(w)).map(|x| x["state"].clone()).unwrap_or(Value::Null)
}
/// The text of widget `w`'s first layer.
fn label(s: &Session, w: NodeId) -> String {
let first = node(s, w).children[0];
node(s, first).kind.text().unwrap().characters.clone()
}
fn err(s: &mut Session, cmd: &str, p: Value) -> String {
match s.execute(cmd, &p) {
Ok(v) => panic!("{cmd} accepted {p}: {v}"),
Err(e) => {
assert!(!matches!(e, EngineError::Internal { .. }), "{e}");
e.to_string()
}
}
}
#[test]
fn a_widget_is_inserted_and_clicked_as_one_undo_step_each() {
let mut s = session();
let (w, r) = insert(&mut s);
assert_eq!((r["state"].clone(), r["targets"].clone()), (json!({"n": 1}), json!(["inc"])));
let input = &r["output"];
assert_eq!((input["api"].clone(), input["command"].clone()), (json!(3), json!("widget")));
assert_eq!(input["widget"], json!({"id": w, "state": {}, "event": null}), "a new widget starts with no state");
assert_eq!(undo_len(&s), 1);
assert_eq!(s.selection(), vec![w]);
let n = node(&s, w);
assert!(n.layout.is_some() && !n.locked && n.children.len() == 2);
let at = n.transform.translation();
assert!((at.x - 50.0).abs() < 1e-9 && (at.y - 60.0).abs() < 1e-9, "{at:?}");
assert!(n.children.iter().all(|c| node(&s, *c).locked), "the layers inside are locked");
assert_eq!(label(&s, w), "one");
// A click on a layer inside reaches its widget, with the state and the target.
let c = click(&mut s, n.children[1], "inc");
assert_eq!(c["id"], json!(w));
assert_eq!(c["output"]["widget"]["state"], json!({"n": 1}));
assert_eq!(c["output"]["widget"]["event"], json!({"target": "inc"}));
assert_eq!((c["state"].clone(), c["targets"].clone()), (json!({"n": 2}), json!(["inc", "dec"])));
assert_eq!((label(&s, w), node(&s, w).children.len(), undo_len(&s)), ("two".into(), 3, 2));
ex(&mut s, "edit.undo", json!({}));
assert_eq!((state(&mut s, w), label(&s, w)), (json!({"n": 1}), "one".into()));
ex(&mut s, "edit.redo", json!({}));
assert_eq!((state(&mut s, w), label(&s, w)), (json!({"n": 2}), "two".into()));
ex(&mut s, "edit.undo", json!({}));
ex(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().node(w).is_none(), "undoing the insert removes the widget");
// Without a position it's centred in the view.
let (cx, cy) = (s.ds().unwrap().view.cx, s.ds().unwrap().view.cy);
let w = idv(&ex(&mut s, "widget.insert", json!({"plugin": "org.test.tally"})));
let b = s.doc().unwrap().world_bounds(w).unwrap();
assert!((b.center().x - cx).abs() <= 1.0 && (b.center().y - cy).abs() <= 1.0, "{b:?} vs {cx},{cy}");
}
#[test]
fn widgets_survive_save_reload_duplicating_and_export() {
let mut s = session();
let (w, _) = insert(&mut s);
click(&mut s, w, "inc");
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let (back, _) = figcraft_format::load_bytes(&bytes).unwrap();
let mut t = session();
t.ds_mut().unwrap().doc = std::sync::Arc::new(back);
assert_eq!(state(&mut t, w), json!({"n": 2}), "the state is saved with the file");
assert_eq!(click(&mut t, w, "dec")["state"], json!({"n": 0}), "and the reloaded widget still works");
// A duplicate is a widget of its own, with the same state to start with.
s.execute("select.set", &json!({"ids": [w]})).unwrap();
ex(&mut s, "edit.duplicate", json!({}));
let copy = s.selection()[0];
assert_ne!(copy, w);
assert_eq!(state(&mut s, copy), json!({"n": 2}));
click(&mut s, copy, "dec");
assert_eq!((state(&mut s, copy), state(&mut s, w)), (json!({"n": 0}), json!({"n": 2})));
assert_eq!(ex(&mut s, "widget.list", json!({}))["widgets"].as_array().unwrap().len(), 2);
// Exports draw the layers it's made of.
let svg = crate::cmd::file::export_bytes(s.doc().unwrap(), &[w], figcraft_doc::ExportFormat::Svg, 1.0).unwrap();
let svg = String::from_utf8(svg).unwrap().to_ascii_lowercase();
assert!(svg.contains("<svg") && svg.contains("0d99ff"), "{svg}");
}
#[test]
fn clicking_a_widget_on_the_canvas_calls_its_plugin_back() {
let mut s = session();
let (w, _) = insert(&mut s);
s.execute("select.set", &json!({"ids": []})).unwrap();
let kids = node(&s, w).children.clone();
let center = |s: &Session, id: NodeId| s.doc().unwrap().world_bounds(id).unwrap().center();
let (target, text) = (center(&s, kids[1]), center(&s, kids[0]));
let at = |kind, p: Point| PointerEvent::at(kind, p.x, p.y, 1.0);
// Over the label: picked as one layer, no callback.
s.pointer(&at(PointerKind::Move, text)).unwrap();
assert_eq!(s.tool.overlay.cursor, Cursor::Default);
s.pointer(&at(PointerKind::Down, text)).unwrap();
s.pointer(&at(PointerKind::Up, text)).unwrap();
assert_eq!((s.selection(), state(&mut s, w)), (vec![w], json!({"n": 1})));
// Over a target: a pointing hand, and a click calls back (one undo step).
s.pointer(&at(PointerKind::Move, target)).unwrap();
assert_eq!(s.tool.overlay.cursor, Cursor::Pointer);
let undo = undo_len(&s);
s.pointer(&at(PointerKind::Down, target)).unwrap();
s.pointer(&at(PointerKind::Up, target)).unwrap();
assert_eq!((state(&mut s, w), undo_len(&s)), (json!({"n": 2}), undo + 1));
// A quick second click (a double click) is a click too.
s.pointer(&at(PointerKind::Down, target)).unwrap();
s.pointer(&at(PointerKind::DoubleClick, target)).unwrap();
assert_eq!((undo_len(&s), s.ds().unwrap().interaction.is_none()), (undo + 2, true));
// Dragging by a target moves the widget instead.
let before = node(&s, w).transform.translation();
let to = Point::new(target.x + 40.0, target.y + 30.0);
s.pointer(&at(PointerKind::Down, target)).unwrap();
s.pointer(&at(PointerKind::Move, to)).unwrap();
s.pointer(&at(PointerKind::Up, to)).unwrap();
let after = node(&s, w).transform.translation();
assert!(after.x - before.x > 30.0 && state(&mut s, w) == json!({"n": 2}), "{before:?} → {after:?}");
}
#[test]
fn bad_widgets_and_clicks_are_refused_and_roll_back() {
let mut s = session();
let r = rect(&mut s, 400.0);
assert!(err(&mut s, "widget.insert", json!({"plugin": "org.nope"})).contains("no plugin"));
// A plugin that isn't a widget.
let plain = json!({"id": "org.test.plain", "name": "Plain", "main": "p.wasm", "api": 2, "permissions": {"access": "write"}});
install(&mut s, plain, &tally(|_| String::new()));
assert!(err(&mut s, "widget.insert", json!({"plugin": "org.test.plain"})).contains("doesn't draw a widget"));
// Junk, or nothing, drawn: refused, nothing left behind.
let (layers, undo) = (layer_count(&s), undo_len(&s));
let mut junk = W::default();
let body = junk.render(&json!({"root": {"type": "text", "text": "not a frame"}}));
install(&mut s, widget_manifest("org.test.junk", "read"), &junk.build(&body));
assert!(err(&mut s, "widget.insert", json!({"plugin": "org.test.junk"})).contains("root must be a frame"));
install(&mut s, widget_manifest("org.test.blank", "read"), &W::default().build(""));
assert!(err(&mut s, "widget.insert", json!({"plugin": "org.test.blank"})).contains("drew no widget"));
assert_eq!((layer_count(&s), undo_len(&s)), (layers, undo));
// Clicks on what isn't a widget, or on what it doesn't have.
let (w, _) = insert(&mut s);
assert!(err(&mut s, "widget.click", json!({"id": r, "target": "inc"})).contains("isn't a widget"));
assert!(err(&mut s, "widget.click", json!({"id": w, "target": "nope"})).contains("it has: inc"));
assert!(err(&mut s, "widget.click", json!({"id": w})).contains("`target` required"));
// A runaway callback runs out of fuel and is rolled back.
install(&mut s, widget_manifest("org.test.tally", "read"), &tally(|_| "(loop $l (br $l))".into()));
let undo = undo_len(&s);
assert!(err(&mut s, "widget.click", json!({"id": w, "target": "inc", "fuel": 100000})).contains("instruction budget"));
assert_eq!((state(&mut s, w), undo_len(&s), label(&s, w)), (json!({"n": 1}), undo, "one".into()));
// Without its plugin a widget stays as it is, and says so when clicked.
ex(&mut s, "plugin.remove", json!({"id": "org.test.tally"}));
assert!(err(&mut s, "widget.click", json!({"id": w, "target": "inc"})).contains("no plugin"));
assert_eq!(node(&s, w).children.len(), 2);
}
#[test]
fn plugins_cant_forge_widgets_or_call_widget_commands() {
let mut s = session();
let r = rect(&mut s, 0.0);
let (w, _) = insert(&mut s);
for (i, (cmd, params)) in [
("node.set", json!({"ids": [r], "props": {"pluginData": {"figcraft.widget": "org.test.tally"}}})),
("node.set", json!({"ids": [w], "props": {"pluginData": {"figcraft.widget.state": "{\"n\": 99}"}}})),
("shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5, "props": {"pluginData": {"figcraft.widget.target": "inc"}}})),
("widget.click", json!({"id": w, "target": "inc"})),
("widget.list", json!({})),
]
.into_iter()
.enumerate()
{
let mut b = W::default();
let body = b.call(cmd, &params);
let id = format!("org.test.forge{i}");
install(&mut s, json!({"id": id, "name": "Forge", "main": "f.wasm", "permissions": {"access": "write"}}), &b.build(&body));
let out = ex(&mut s, "plugin.run", json!({"id": id}))["output"].clone();
let out = out.as_str().unwrap_or_default().to_string();
assert!(out.contains("can't write") || out.contains("isn't available to plugins"), "{cmd}: {out}");
}
assert!(!node(&s, r).plugin_data.contains_key("figcraft.widget"));
// Text that merely mentions the keys is fine.
let mut b = W::default();
let body = b.call("text.create", &json!({"text": "figcraft.widget", "props": {"pluginData": {"note": "figcraft.widget"}}}));
install(&mut s, json!({"id": "org.test.text", "name": "Text", "main": "t.wasm", "permissions": {"access": "write"}}), &b.build(&body));
let layers = layer_count(&s);
ex(&mut s, "plugin.run", json!({"id": "org.test.text"}));
assert_eq!(layer_count(&s), layers + 1);
assert_eq!(state(&mut s, w), json!({"n": 1}));
// A write widget's own edits elsewhere join its click's undo step.
let mut s = Session::new();
install(
&mut s,
widget_manifest("org.test.tally", "write"),
&tally(|b| b.call("shape.rectangle", &json!({"x": 300, "y": 0, "width": 5, "height": 5}))),
);
let (w, _) = insert(&mut s);
let (layers, undo) = (layer_count(&s), undo_len(&s));
click(&mut s, w, "inc");
assert_eq!((layer_count(&s), undo_len(&s)), (layers + 1, undo + 1));
}
#[test]
fn widget_state_syncs_in_a_live_session() {
use crate::live_tests::{push, relay, serve_as, session as live};
let (mut s, script) = live();
install(&mut s, widget_manifest("org.test.tally", "read"), &tally(|_| String::new()));
s.execute("collab.host", &json!({})).unwrap();
let (mut server, me, welcome) = serve_as(s.doc().unwrap(), crate::live::Access::Edit);
push(&script, vec![welcome]);
s.live_poll();
let (w, _) = insert(&mut s);
click(&mut s, w, "inc");
s.live_sync();
let back = relay(&script, &mut server, me);
push(&script, back);
s.live_poll();
let theirs = server.document().node(w).unwrap();
assert_eq!(theirs.plugin_data.get("figcraft.widget.state").map(String::as_str), Some(r#"{"n":2}"#));
assert_eq!(theirs.children.len(), 3, "the redrawn layers reached the server");
}