mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-01 06:16:46 -06:00
* M6: plugin panels show images An image control in a plugin panel carries a picture as base64 (at most 256 KB; panel specs up to 1 MB), drawn at its size up to a field's width and named by its label, which shows instead when the picture can't be decoded or is over 4,096 px on a side. Images can't be clicked or be command parameters. Panel and form captions no longer wrap. * Render worker: in_flight counts the watchdog's late preview The watchdog that previews a render running past PREVIEW_AFTER_MS could pass its still-rendering check, lose the race to the full render, and then hand over its preview and wake the UI after Worker::in_flight had gone false. An idle test that had settled then saw a 0 ns repaint whose cause egui reports a frame late (CI on #1639: 'asked for a repaint in 0ns: [app/files.rs:211]', the autosave tick of the previous frame). The watchdog now marks itself busy from before that check until it has handed over and woken (or skipped), and in_flight counts it; the full render's done markers are SeqCst so the watchdog sees a finished render before in_flight can read idle. * Docs: drop a status item the merge listed twice
189 lines
9.1 KiB
Rust
189 lines
9.1 KiB
Rust
//! ABI v2 in the sandbox: an instance keeps its memory between calls, panels are validated and
|
|
//! reported, settings go through the host.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use serde_json::{Map, json};
|
|
|
|
use crate::{Host, Instance, Limits, Manifest, Panel, ParamKind, PluginError, UiChange};
|
|
|
|
/// A host that keeps settings.
|
|
#[derive(Default)]
|
|
struct Settings(BTreeMap<String, String>);
|
|
|
|
impl Host for Settings {
|
|
fn call(&mut self, _command: &str, _params: &str) -> Result<String, String> {
|
|
Ok("{}".into())
|
|
}
|
|
fn selection(&mut self) -> String {
|
|
"[]".into()
|
|
}
|
|
fn notify(&mut self, _message: &str) {}
|
|
fn setting(&mut self, key: &str) -> Option<String> {
|
|
self.0.get(key).cloned()
|
|
}
|
|
fn set_setting(&mut self, key: &str, value: Option<&str>) -> Result<(), String> {
|
|
match value {
|
|
Some(v) if v.len() > 10 => Err("too big".into()),
|
|
Some(v) => {
|
|
self.0.insert(key.into(), v.into());
|
|
Ok(())
|
|
}
|
|
None => {
|
|
self.0.remove(key);
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn v2_module(data: &str, body: &str) -> Vec<u8> {
|
|
let wat = format!(
|
|
r#"(module
|
|
(import "figcraft" "reply_len" (func $reply_len (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" "ui_show" (func $ui_show (param i32 i32) (result i32)))
|
|
(import "figcraft" "ui_close" (func $ui_close))
|
|
(import "figcraft" "setting_get" (func $setting_get (param i32 i32) (result i32)))
|
|
(import "figcraft" "setting_set" (func $setting_set (param i32 i32 i32 i32) (result i32)))
|
|
(import "figcraft" "setting_delete" (func $setting_delete (param i32 i32)))
|
|
(memory (export "memory") 1)
|
|
{data}
|
|
(func (export "figcraft_run") (param $n i32) (result i32) {body}))"#
|
|
);
|
|
wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}"))
|
|
}
|
|
|
|
const PANEL: &str = r#"{"title":"T","controls":[{"type":"button","name":"go","label":"Go"}]}"#;
|
|
|
|
#[test]
|
|
fn an_instance_keeps_its_memory_and_reports_panel_changes() {
|
|
// A counter at 100 ('0' + calls): call 1 shows a panel, call 2 an invalid one (the refusal is
|
|
// its output), call 3 closes it.
|
|
let bad = r#"{"controls":[{"type":"rocket"}]}"#;
|
|
let m = v2_module(
|
|
&format!(
|
|
r#"(data (i32.const 100) "0") (data (i32.const 200) "{}") (data (i32.const 1000) "{}")"#,
|
|
PANEL.replace('"', "\\\""),
|
|
bad.replace('"', "\\\"")
|
|
),
|
|
&format!(
|
|
r#"(i32.store8 (i32.const 100) (i32.add (i32.load8_u (i32.const 100)) (i32.const 1)))
|
|
(if (i32.eq (i32.load8_u (i32.const 100)) (i32.const 49)) (then (drop (call $ui_show (i32.const 200) (i32.const {})))))
|
|
(if (i32.eq (i32.load8_u (i32.const 100)) (i32.const 50))
|
|
(then (if (call $ui_show (i32.const 1000) (i32.const {}))
|
|
(then (call $set_output (i32.const 2000) (call $reply_read (i32.const 2000) (call $reply_len))) (return (i32.const 0))))))
|
|
(if (i32.eq (i32.load8_u (i32.const 100)) (i32.const 51)) (then (call $ui_close)))
|
|
(call $set_output (i32.const 100) (i32.const 1))
|
|
(i32.const 0)"#,
|
|
PANEL.len(),
|
|
bad.len()
|
|
),
|
|
);
|
|
let mut inst = Instance::new(&m, &Limits::default()).unwrap();
|
|
let mut host = Settings::default();
|
|
let first = inst.call("{}", &mut host).unwrap();
|
|
assert_eq!((first.output.as_str(), first.ui), ("1", Some(UiChange::Show(Panel::parse(PANEL).unwrap()))));
|
|
let second = inst.call("{}", &mut host).unwrap();
|
|
assert!(second.output.contains("panel:") && second.ui.is_none(), "{second:?}");
|
|
let third = inst.call("{}", &mut host).unwrap();
|
|
assert_eq!((third.output.as_str(), third.ui), ("3", Some(UiChange::Close)));
|
|
}
|
|
|
|
#[test]
|
|
fn settings_go_through_the_host() {
|
|
let m = v2_module(
|
|
r#"(data (i32.const 0) "k") (data (i32.const 10) "[1,2]") (data (i32.const 20) "\"far too long\"")"#,
|
|
r#"(if (call $setting_set (i32.const 0) (i32.const 1) (i32.const 10) (i32.const 5)) (then (return (i32.const 2))))
|
|
;; Refused by the host: status 1.
|
|
(if (i32.eqz (call $setting_set (i32.const 0) (i32.const 1) (i32.const 20) (i32.const 14))) (then (return (i32.const 3))))
|
|
(if (call $setting_get (i32.const 0) (i32.const 1)) (then (return (i32.const 4))))
|
|
(call $set_output (i32.const 100) (call $reply_read (i32.const 100) (call $reply_len)))
|
|
(call $setting_delete (i32.const 0) (i32.const 1))
|
|
(if (i32.eqz (call $setting_get (i32.const 0) (i32.const 1))) (then (return (i32.const 5))))
|
|
(i32.const 0)"#,
|
|
);
|
|
let mut host = Settings::default();
|
|
let out = crate::run(&m, "{}", &Limits::default(), &mut host).unwrap();
|
|
assert_eq!(out.output, "[1,2]");
|
|
assert!(host.0.is_empty());
|
|
// Settings cost host calls like any other.
|
|
let limits = Limits { max_calls: 2, ..Limits::default() };
|
|
assert!(matches!(crate::run(&m, "{}", &limits, &mut host), Err(PluginError::Limit(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn panels_are_validated_and_keep_entered_values() {
|
|
let p = Panel::parse(
|
|
r##"{"title": "Swatches", "controls": [
|
|
{"type": "list", "name": "palette", "label": "Palette", "options": ["Warm", "Cool"], "value": "Cool"},
|
|
{"type": "note", "label": "Pick a palette"},
|
|
{"type": "color", "name": "accent", "value": "#f0a"},
|
|
{"type": "number", "name": "n", "min": 1, "max": 4, "value": 9},
|
|
{"type": "text", "name": "t"},
|
|
{"type": "image", "label": "A chart", "data": "iVBORw0KGgo="},
|
|
{"type": "slider", "name": "o", "min": 0, "max": 100, "value": -5},
|
|
{"type": "button", "name": "go", "label": "Go"}
|
|
]}"##,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(p.control("accent").unwrap().default, Some(json!("#FF00AA")));
|
|
assert_eq!(p.control("n").unwrap().default, Some(json!(4.0)), "values are clamped");
|
|
assert_eq!(p.control("go").unwrap().kind, ParamKind::Button);
|
|
assert_eq!(p.controls[5].kind, ParamKind::Image, "an image needs no name and holds no value");
|
|
let o = p.control("o").unwrap();
|
|
assert_eq!((o.kind, o.default.clone()), (ParamKind::Slider, Some(json!(0.0))), "a slider is clamped like a number");
|
|
assert_eq!(o.coerce(&json!("250")), Ok(json!(100.0)));
|
|
assert!(o.coerce(&json!(true)).is_err());
|
|
let mut old = Map::new();
|
|
old.insert("t".into(), json!("typed"));
|
|
old.insert("palette".into(), json!("Warm"));
|
|
assert_eq!(
|
|
json!(p.values(&old)),
|
|
json!({"palette": "Cool", "accent": "#FF00AA", "n": 4.0, "t": "typed", "o": 0.0}),
|
|
"the spec's values win, then the entered ones"
|
|
);
|
|
let big_image = json!({"controls": [{"type": "image", "data": "A".repeat(256 * 1024 / 3 * 4 + 8)}]}).to_string();
|
|
let deep = format!("{{\"controls\": {}{}}}", "[".repeat(5000), "]".repeat(5000));
|
|
let many = json!({"controls": (0..65).map(|i| json!({"type": "button", "name": format!("b{i}")})).collect::<Vec<_>>()}).to_string();
|
|
let junk = [
|
|
"",
|
|
"null",
|
|
r#"{"controls": 5}"#,
|
|
r#"{"controls": [{"type": "rocket", "name": "x"}]}"#,
|
|
r#"{"controls": [{"type": "button"}]}"#,
|
|
r#"{"controls": [{"type": "list", "name": "x"}]}"#,
|
|
r#"{"controls": [{"type": "button", "name": "a"}, {"type": "text", "name": "a"}]}"#,
|
|
r#"{"controls": [{"type": "number", "name": "a", "min": 3, "max": 1}]}"#,
|
|
r#"{"controls": [{"type": "slider", "name": "a", "min": 3}]}"#,
|
|
r#"{"controls": [{"type": "slider", "name": "a", "max": 3}]}"#,
|
|
r#"{"title": "line\nbreak"}"#,
|
|
r#"{"controls": [{"type": "image", "label": "no data"}]}"#,
|
|
r#"{"controls": [{"type": "note", "label": "x", "data": "iVBORw0KGgo="}]}"#,
|
|
&big_image,
|
|
&deep,
|
|
&many,
|
|
];
|
|
for j in junk {
|
|
assert!(Panel::parse(j).is_err(), "accepted {}", j.chars().take(60).collect::<String>());
|
|
}
|
|
let huge = format!(r#"{{"controls": [{{"type": "note", "label": "{}"}}]}}"#, "x".repeat(1_100_000));
|
|
assert!(Panel::parse(&huge).unwrap_err().contains("larger"));
|
|
let long_note = format!(r#"{{"controls": [{{"type": "note", "label": "{}"}}]}}"#, "x".repeat(1001));
|
|
assert!(Panel::parse(&long_note).is_err());
|
|
// Junk values are dropped, not kept.
|
|
let p = Panel::parse(
|
|
r#"{"controls": [{"type": "color", "name": "c", "value": {"deep": [1, 2]}}, {"type": "boolean", "name": "b", "value": "yes"}]}"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(json!(p.values(&Map::new())), json!({"c": "#000000", "b": false}));
|
|
// Buttons and notes don't go in manifests; colours and lists do.
|
|
let manifest = |kind: &str| {
|
|
let params = json!([{"name": "x", "type": kind, "options": ["a"]}]);
|
|
Manifest::from_value(&json!({"id": "a.b", "name": "A", "main": "a.wasm", "commands": [{"id": "c", "label": "C", "params": params}]}))
|
|
};
|
|
assert!(manifest("button").is_err() && manifest("note").is_err());
|
|
assert!(manifest("color").is_ok() && manifest("list").is_ok());
|
|
}
|