mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
VectorCraft had an Additional Plug-ins Folder preference but nothing loaded from it. Port PhotoCraft's wasmi plug-in host (same sandbox, manifest and parameter schema) as the new vectorcraft-plugins crate (L2), extended for vector documents: - Object filters read the selected paths and compound paths as JSON (native PathData and Paint forms: geometry, fills, strokes, gradients, opacity) and return the objects to keep, change, add or delete; the output is validated in full and applied as one undo step (plugin.run). A failing plug-in leaves the document untouched. - Live-effect plug-ins are geometry effects in the appearance stack (effect id plugin.<id>), re-evaluated (and cached) when the object or parameters change, editable, exported and baked by Expand Appearance; a missing plug-in leaves the geometry as is. - plugin.list / info / run / install / remove / reload; the preference folder installs every *.wasm at startup and when it changes; File > Open of a .wasm installs it (web). - Object > Plug-ins and Effect > Plug-ins menus with dialogs generated from the parameter schema (live preview), the Appearance panel's fx menu, docs/plugins.md, an original example plug-in (Desaturate, a Rust crate outside the workspace) and fuzzing of the plug-in boundary (engine/tests/plugin_fuzz.rs). Tool plug-ins and paint-changing live effects are left for later (docs/plugins.md). Co-Authored-By: Claude Opus 5.5 <[email protected]>
116 lines
6.1 KiB
Rust
116 lines
6.1 KiB
Rust
//! WebAssembly plug-ins in the UI (#121): Object › Plug-ins and Effect › Plug-ins, the generated
|
||
//! filter dialog with its live preview, plug-in effects in the effect dialog, and installing
|
||
//! `.wasm` files through File › Open.
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_engine::Session;
|
||
use vectorcraft_plugins::wat as tpl;
|
||
|
||
use crate::state::Dialog;
|
||
use crate::{VectorcraftApp, dialogs, io, menus, theme};
|
||
|
||
const DESATURATE: &[u8] = include_bytes!("../../plugins/tests/fixtures/desaturate.wasm");
|
||
const DESATURATE_ID: &str = "org.vectorcraft.example.desaturate";
|
||
|
||
fn app() -> VectorcraftApp {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||
app
|
||
}
|
||
|
||
/// One headless frame of the dialog layer.
|
||
fn frame(app: &mut VectorcraftApp) {
|
||
let ctx = egui::Context::default();
|
||
theme::install_fonts(&ctx);
|
||
theme::apply(&ctx, Default::default());
|
||
let mut out = ctx.run_ui(Default::default(), |ui| dialogs::show(app, ui.ctx()));
|
||
out.textures_delta.clear();
|
||
}
|
||
|
||
fn red_rect(app: &mut VectorcraftApp) -> vectorcraft_doc::NodeId {
|
||
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||
app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap();
|
||
vectorcraft_doc::NodeId(id)
|
||
}
|
||
|
||
fn fill_rgb(app: &VectorcraftApp, id: vectorcraft_doc::NodeId) -> [f32; 3] {
|
||
app.session.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint().color().unwrap().to_rgb_uncalibrated()
|
||
}
|
||
|
||
fn entry<'a>(entries: &'a [menus::MenuEntry], path: &[&str], label: &str) -> Option<&'a menus::MenuEntry> {
|
||
entries.iter().find(|e| e.path == path && e.label == label)
|
||
}
|
||
|
||
#[test]
|
||
fn plugins_are_listed_in_the_object_and_effect_menus() {
|
||
let mut app = app();
|
||
let before = menus::plugin_revision();
|
||
io::open_bytes(&mut app, "desaturate.wasm", DESATURATE, None).unwrap();
|
||
let effect = tpl::module(&tpl::manifest("test.ui.echo", "effect", r#"{"wiggle":{"type":"bool"}}"#), tpl::ECHO, "", 1);
|
||
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(effect).unwrap())})).unwrap();
|
||
assert_ne!(menus::plugin_revision(), before, "the native menu is rebuilt");
|
||
let entries = menus::menu_entries(&app);
|
||
let filter = entry(&entries, &["Object", "Plug-ins"], "Desaturate…").expect("filter listed");
|
||
assert_eq!((filter.command.as_deref(), &filter.params), (Some("plugin.dialog"), &json!({"id": DESATURATE_ID})));
|
||
assert!(entry(&entries, &["Object", "Plug-ins"], "Install Plug-in…").is_some());
|
||
assert!(entry(&entries, &["Object", "Plug-ins"], "Reload Plug-ins").is_some());
|
||
let fx = entry(&entries, &["Effect", "Plug-ins"], "Test test.ui.echo…").expect("effect listed");
|
||
assert_eq!((fx.command.as_deref(), &fx.params), (Some("effect.dialog"), &json!({"effect": "plugin.test.ui.echo"})));
|
||
// Menu clicks on items with params don't turn into the generic parameter dialog.
|
||
assert_eq!(menus::click_target("Desaturate…", "plugin.dialog", &filter.params).0, "plugin.dialog");
|
||
}
|
||
|
||
#[test]
|
||
fn filter_dialog_previews_and_commits_one_undo_step() {
|
||
let mut app = app();
|
||
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE)})).unwrap();
|
||
let id = red_rect(&mut app);
|
||
let undo = app.session.doc().unwrap().history.undo.len();
|
||
menus::invoke(&mut app, "plugin.dialog", json!({"id": DESATURATE_ID}));
|
||
let d = app.ui.dialog.as_ref().expect("dialog open");
|
||
assert_eq!((d.kind.as_str(), d.str("__label"), d.fields.get("amount")), ("plugin", "Desaturate".to_string(), Some(&json!(100.0))));
|
||
// Agents set fields like `ui.dialog.set` does; the frame previews them.
|
||
app.ui.dialog.as_mut().unwrap().fields.insert("amount".into(), json!(50));
|
||
frame(&mut app);
|
||
assert!(app.session.in_interaction(), "previewing");
|
||
let [r, g, _] = fill_rgb(&app, id);
|
||
assert!(r < 1.0 && g > 0.0, "half desaturated on the canvas: {r} {g}");
|
||
dialogs::confirm(&mut app).unwrap();
|
||
assert!(app.ui.dialog.is_none());
|
||
let st = app.session.doc().unwrap();
|
||
assert_eq!(st.history.undo.len(), undo + 1);
|
||
assert_eq!(st.history.undo.last().unwrap().label, "Desaturate");
|
||
// Cancel rolls the preview back.
|
||
menus::invoke(&mut app, "plugin.dialog", json!({"id": DESATURATE_ID}));
|
||
frame(&mut app);
|
||
let previewed = fill_rgb(&app, id);
|
||
dialogs::cancel(&mut app);
|
||
assert_ne!(fill_rgb(&app, id), previewed);
|
||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
|
||
// With params, the menu command runs at once.
|
||
app.run("plugin.dialog", json!({"id": DESATURATE_ID, "params": {"amount": 100}})).unwrap();
|
||
let [r, g, b] = fill_rgb(&app, id);
|
||
assert!((r - g).abs() < 1e-6 && (g - b).abs() < 1e-6);
|
||
assert!(app.run("plugin.dialog", json!({"id": "test.ui.missing"})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn plugin_effects_open_the_effect_dialog_with_their_schema() {
|
||
let mut app = app();
|
||
let src =
|
||
tpl::module(&tpl::manifest("test.ui.fx", "effect", r#"{"mode":{"type":"choice","options":["a","b"],"default":"b"}}"#), tpl::ECHO, "", 1);
|
||
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap())})).unwrap();
|
||
let id = red_rect(&mut app);
|
||
app.run("effect.dialog", json!({"effect": "plugin.test.ui.fx"})).unwrap();
|
||
let d: &Dialog = app.ui.dialog.as_ref().unwrap();
|
||
assert_eq!((d.kind.as_str(), d.str("__label"), d.str("mode")), ("effect", "Test test.ui.fx".to_string(), "b".to_string()));
|
||
frame(&mut app);
|
||
dialogs::confirm(&mut app).unwrap();
|
||
let fx = &app.session.doc().unwrap().doc.node(id).unwrap().appearance.effects;
|
||
assert_eq!((fx[0].id.as_str(), &fx[0].params), ("plugin.test.ui.fx", &json!({"mode": "b"})));
|
||
assert_eq!(crate::panels::appearance::effect_label("plugin.test.ui.fx"), "Test test.ui.fx");
|
||
// Editing it again starts from its values.
|
||
app.run("effect.dialog", json!({"effect": "plugin.test.ui.fx", "index": 0})).unwrap();
|
||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get("mode"), Some(&Value::from("b")));
|
||
}
|