diff --git a/ROADMAP.md b/ROADMAP.md index ae99c9c..47a8dfc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -19,13 +19,13 @@ start with [`docs/gaps.md`](docs/gaps.md). | Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | | Mainstream practitioner | **~49%** | estimated: weekly areas 72.6% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | | Remaining to beta | **~162–292 h** one agent | estimated | -| Remaining to full parity | **~281–548 h** one agent (~85–148 h wall-clock with 5 agents) | estimated | +| Remaining to full parity | **~276–538 h** one agent (~85–148 h wall-clock with 5 agents) | estimated | ### Readiness by audience | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~52% | ~286–550 h | multiplayer (42–84), localization (40–65), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | +| Full target (ready for real work) | ~52% | ~281–540 h | multiplayer (42–84), localization (40–65), plugins (10–30), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | | Mainstream practitioner | ~49% | ~130–244 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | | Essentials user | ~57% | ~60–110 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) | @@ -65,7 +65,7 @@ Figma's last two languages, Japanese and Korean, on the landed localization laye | Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) | | Localization | 30% (layer; English; Spanish, French, German, Brazilian Portuguese drafts) | 40–65 | [localization-parity](docs/localization-parity.md) | | Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) | -| Ecosystem / plugins | 28% (own WASM plugins with panels and settings) | 15–40 | [gaps](docs/gaps.md) | +| Ecosystem / plugins | 34% (own WASM plugins with panels, settings and widgets) | 10–30 | [gaps](docs/gaps.md) | | AI features | 0% | 30–60 | [gaps](docs/gaps.md) | ## Features @@ -89,7 +89,7 @@ Figma's last two languages, Japanese and Korean, on the landed localization laye | Dev Mode / inspect | 96% | 72% | 0–1 | [dev-mode-parity](docs/dev-mode-parity.md) | | Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) | | Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) | -| Plugins & widgets | 45% | 28% | 15–40 | [plugins](docs/plugins.md) | +| Plugins & widgets | 55% | 32% | 10–30 | [plugins](docs/plugins.md) | | Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) | | Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) | | AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) | @@ -129,6 +129,12 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-12** · Plugin widgets and the panel tail (gap 6, plugin API 3; older plugins run unchanged): a widget + plugin puts a layer of its own on the canvas (Plugins ▸ Widgets, `widget.insert`), described by the plugin, + validated and built with locked layers inside; clicking a part it named calls it back with the widget's state + (kept in the layer, so it saves, copies, undoes, exports and syncs) as one undo step, and it redraws; + `widget.click` and `widget.list` for agents; a Counter example. Panels of several plugins open side by side and + plugin colour fields open the colour picker. Plugins & widgets 55%. - **2026-10-12** · Layers and Assets panels behaviour suite (gap 4): several layers dropped together land together, Collapse layers keeps the selection's rows open, dragging across eyes or padlocks sets several layers at once, a Rename layers dialog for several layers (number codes as documented, regular expressions, a start @@ -223,6 +229,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Plugin widgets and the panel tail (gap 6, plugin API 3; v1 and v2 plugins unchanged): a plugin marked as a widget puts a layer of its own on the canvas from Plugins ▸ Widgets or `widget.insert`: it describes the layer (frames with auto layout, text, rectangles, ellipses, images it carries) and FigCraft validates the description (size, layer count, depth, numbers, colours, text and image sizes) and builds it as a frame with locked layers inside; a click on a part the plugin named (on the canvas with the Move tool, a pointing hand over it, or `widget.click`) calls the plugin back in a fresh, metered sandbox with the widget's state, as one undo step, and the widget is redrawn; the state lives in the widget's own layer data, so widgets survive save and reload, copying, undo, export and live sessions; no plugin can write that data or call `widget.*`. `widget.list` lists them. A Counter example widget. Plugin panels of different plugins open side by side, and a colour field's swatch opens the colour picker (the plugin hears of the colour when a drag ends). Plugins & widgets 45%/28% → 55%/32%, 15–40 h → 10–30 h; Ecosystem dimension 28% → 34%; weighted totals unchanged at this rounding; full parity ~286–558 h → ~281–548 h | | 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Localization 20% → 30%, 45–75 h → 40–65 h; ready for real work unchanged (~52%); full parity ~289–564 h → ~284–554 h; beta ~170–305 h → ~162–292 h | | 2026-10-12 | minor | Cursor chat and spotlight in live sessions: `/` opens a chat bubble at your pointer that everyone sees as you type and that fades a few seconds after the last change; a spotlight (your avatar's menu, `collab.spotlight`) makes everyone follow your view and page until they move their own. Ephemeral presence the host validates and rate-limits, passed only to clients that read it (older clients keep working). Multiplayer & sharing breadth 15% → 17%, ready unchanged (5%), 45–90 h → 42–84 h; collaboration 13% unchanged, 49–98 h → 46–92 h; feature breadth (~84%) and ready for real work (~51%) unchanged at this rounding; full parity ~301–587 h → ~298–581 h | | 2026-10-12 | minor | Layers and Assets panels behaviour suite written from the public documentation (6 engine tests with about 50 table rows and 5 panel tests driven through the UI; about 30 rows that the code before the fixes contradicts) and its fixes (several layers dropped together, Collapse layers, eye and padlock sweeps, Rename layers dialog with the documented number codes, page dragging, dividers, "Copy of" duplicates, Assets folders, hidden-from-publishing names both ways). Layers panel 98%/82% → 99%/85%; percentages and full parity hours unchanged at this rounding | diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 47eb7d9..725dc7a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -100,6 +100,7 @@ fn registry() -> &'static Registry { list.extend(crate::collab::specs()); list.extend(crate::branches::specs()); list.extend(crate::plugins::specs()); + list.extend(crate::widgets::specs()); list.extend(crate::live::specs()); list.extend(crate::invite_links::specs()); let mut index = HashMap::new(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9e6adda..71111cf 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -47,6 +47,7 @@ pub mod textedit; pub mod textpreview; pub mod tools; pub mod vectoredit; +pub mod widgets; pub use cmd::{CommandSpec, all_commands, find_command}; pub use figcraft_collab; @@ -1429,3 +1430,5 @@ mod version_preview_tests; mod version_tests; #[cfg(test)] mod view_behaviour_tests; +#[cfg(test)] +mod widgets_tests; diff --git a/crates/engine/src/plugin_panels_tests.rs b/crates/engine/src/plugin_panels_tests.rs index ea953a5..4eb01c5 100644 --- a/crates/engine/src/plugin_panels_tests.rs +++ b/crates/engine/src/plugin_panels_tests.rs @@ -282,7 +282,7 @@ fn a_callback_out_of_fuel_rolls_back_and_closes_the_panel() { } #[test] -fn closing_removing_or_another_panel_ends_the_session() { +fn closing_or_removing_ends_the_session_and_panels_open_side_by_side() { let mut s = Session::new(); rect(&mut s, 0.0); let mut w = Wat::default(); @@ -295,11 +295,20 @@ fn closing_removing_or_another_panel_ends_the_session() { assert_eq!(ex(&mut s, "plugin.close", json!({"id": "org.test.a"}))["closed"], true); assert_eq!(ui(&mut s), Value::Null); assert!(event_err(&mut s, json!({"id": "org.test.a", "control": "go"})).contains("no panel open")); - // Another plugin's panel replaces it; removing the plugin closes it. + // Two plugins' panels are open side by side (a plugin's own run replaces its panel); + // removing a plugin closes its panel only. ex(&mut s, "plugin.run", json!({"id": "org.test.a"})); ex(&mut s, "plugin.run", json!({"id": "org.test.b"})); - assert_eq!(ui(&mut s)["plugin"], "org.test.b"); - assert_eq!(ex(&mut s, "plugin.ui", json!({"id": "org.test.a"})), Value::Null); + ex(&mut s, "plugin.run", json!({"id": "org.test.a"})); + assert_eq!(s.plugins.panels.len(), 2); + assert_eq!(ui(&mut s)["plugin"], "org.test.a", "without an id: the one opened last"); + assert_eq!(ex(&mut s, "plugin.ui", json!({"id": "org.test.b"}))["plugin"], "org.test.b"); + ex(&mut s, "plugin.event", json!({"id": "org.test.b", "control": "n", "value": 7})); + assert_eq!(ex(&mut s, "plugin.ui", json!({"id": "org.test.b"}))["values"]["n"], 7.0, "the event reached b's panel"); + assert_eq!(ui(&mut s)["plugin"], "org.test.a", "and b's panel kept its place"); ex(&mut s, "plugin.remove", json!({"id": "org.test.b"})); + assert_eq!(ex(&mut s, "plugin.ui", json!({"id": "org.test.b"})), Value::Null); + assert_eq!(ui(&mut s)["plugin"], "org.test.a"); + ex(&mut s, "plugin.close", json!({"id": "org.test.a"})); assert_eq!(ui(&mut s), Value::Null); } diff --git a/crates/engine/src/plugins.rs b/crates/engine/src/plugins.rs index 5d403f6..2f729bf 100644 --- a/crates/engine/src/plugins.rs +++ b/crates/engine/src/plugins.rs @@ -1,9 +1,9 @@ //! Plugins: WebAssembly modules that act on the document through the same commands as the menus, //! CLI and MCP server, inside the sandbox of `figcraft-plugin`. This module is the policy: which //! commands a plugin may call (its manifest's permissions, and never files, other documents, the -//! undo history, dialogs or plugins), one undo step per run or panel callback, the panel a plugin -//! keeps open, its settings and the installed-plugin list kept in the preferences. See -//! `docs/plugins.md`. +//! undo history, dialogs or plugins), one undo step per run or panel callback, the panels plugins +//! keep open, their settings and the installed-plugin list kept in the preferences. Widgets (plugin +//! layers on the canvas) are in [`crate::widgets`]. See `docs/plugins.md`. use std::collections::HashMap; use std::sync::Arc; @@ -44,10 +44,15 @@ pub struct Runtime { modules: HashMap>>, /// The last run (plugin id, command id, params): "Run last plugin". pub last: Option<(String, String, Value)>, - /// The plugin panel open now. - // shortcut: one panel at a time (another plugin's panel replaces it); keep a list when - // plugins need panels side by side. - pub panel: Option, + /// The open plugin panels, one per plugin, in the order they opened. + pub panels: Vec, +} + +impl Runtime { + /// Plugin `id`'s open panel. + pub fn panel(&self, id: &str) -> Option<&OpenPanel> { + self.panels.iter().find(|o| o.manifest.id == id) + } } /// A plugin's open panel: the running module (its memory lasts from one callback to the next), @@ -77,8 +82,9 @@ impl OpenPanel { /// Command prefixes no plugin may call, whatever its permissions: files and the app (plugins get /// no file system), other documents, version history, the undo history, plugins themselves and -/// nested batches (a run already is one undo step). +/// nested batches (a run already is one undo step), widgets (only a click calls one back). const DENIED: &[&str] = &[ + "widget.", "file.", "doc.", "version.", @@ -103,6 +109,10 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option { if DENIED.iter().any(|p| command.starts_with(p)) && !ALLOWED.contains(&command) { return Some(format!("`{command}` isn't available to plugins")); } + // A widget's own data is FigCraft's to write, so no plugin can forge or retarget a widget. + if writes_widget_data(params, 0) { + return Some(format!("plugins can't write `{}…` layer data", crate::widgets::DATA_PREFIX)); + } if command == "image.place" && get(params, "path").is_some() { return Some("plugins can't read files: pass the image as `data`".into()); } @@ -115,6 +125,17 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option { None } +/// Whether `params` set layer data (`pluginData`, anywhere in them) under a widget's own keys. +fn writes_widget_data(params: &Value, depth: usize) -> bool { + let own = |data: &Value| data.as_object().is_some_and(|m| m.keys().any(|k| k.starts_with(crate::widgets::DATA_PREFIX))); + depth < 64 + && match params { + Value::Object(m) => m.iter().any(|(k, v)| (k == "pluginData" && own(v)) || writes_widget_data(v, depth + 1)), + Value::Array(a) => a.iter().any(|v| writes_widget_data(v, depth + 1)), + _ => false, + } +} + /// The sandbox's view of the session: commands go through [`refusal`] and run as nested groups, /// so a refused one leaves nothing behind. struct EngineHost<'s> { @@ -192,7 +213,7 @@ impl figcraft_plugin::Host for EngineHost<'_> { } /// The installed plugin `id`. -fn installed<'a>(s: &'a Session, cmd: &str, id: &str) -> Result<&'a InstalledPlugin> { +pub(crate) fn installed<'a>(s: &'a Session, cmd: &str, id: &str) -> Result<&'a InstalledPlugin> { s.prefs.plugins.iter().find(|p| p.manifest.id == id).ok_or_else(|| bad(cmd, format!("no plugin `{id}` is installed"))) } @@ -236,7 +257,7 @@ fn module_file(manifest_file: &std::path::Path, m: &Manifest) -> Result> /// The module of an installed plugin: from the session, else read again from disk (so a rebuilt /// development plugin runs its new code). -fn module_of(s: &Session, p: &InstalledPlugin) -> Result>> { +pub(crate) fn module_of(s: &Session, p: &InstalledPlugin) -> Result>> { if let Some(m) = s.plugins.modules.get(&p.manifest.id) { return Ok(m.clone()); } @@ -249,7 +270,16 @@ fn module_of(s: &Session, p: &InstalledPlugin) -> Result>> { return Err(EngineError::Other(format!("{path}: there are no files in the browser"))); } -fn plugin_error(name: &str, e: PluginError) -> EngineError { +/// The sandbox limits for a run, with `fuel` lowering the instruction budget. +pub(crate) fn limits(fuel: Option) -> Limits { + let mut limits = Limits::default(); + if let Some(f) = fuel.filter(|f| f.is_finite()) { + limits.fuel = (f.max(1000.0) as u64).min(limits.fuel); + } + limits +} + +pub(crate) fn plugin_error(name: &str, e: PluginError) -> EngineError { EngineError::Other(format!("{name}: {e}")) } @@ -321,9 +351,7 @@ fn remove(s: &mut Session, p: &Value) -> Result { s.prefs.plugins.retain(|x| x.manifest.id != id); s.prefs.plugin_settings.remove(&id); s.plugins.modules.remove(&id); - if s.plugins.panel.as_ref().is_some_and(|o| o.manifest.id == id) { - s.plugins.panel = None; - } + close_panel(s, &id); if s.plugins.last.as_ref().is_some_and(|(last, ..)| *last == id) { s.plugins.last = None; } @@ -340,10 +368,7 @@ pub fn run(s: &mut Session, id: &str, command: Option<&str>, params: &Value, fue let c = m.command(command).ok_or_else(|| bad(cmd, format!("{} has no command `{}`", m.name, command.unwrap_or_default())))?; let params = c.resolve_params(params).map_err(|e| bad(cmd, e))?; let wasm = module_of(s, &p)?; - let mut limits = Limits::default(); - if let Some(f) = fuel { - limits.fuel = (f.max(1000.0) as u64).min(limits.fuel); - } + let limits = limits(fuel); // The API the plugin was written for: a v1 plugin gets the input it always got. let input = json!({"api": m.api, "command": c.id, "params": params, "selection": s.selection()}).to_string(); s.plugins.last = Some((m.id.clone(), c.id.clone(), params.clone())); @@ -353,7 +378,8 @@ pub fn run(s: &mut Session, id: &str, command: Option<&str>, params: &Value, fue match out.ui.take() { Some(UiChange::Show(panel)) => { let values = panel.values(&Map::new()); - s.plugins.panel = Some(OpenPanel { manifest: m.clone(), command: c.id.clone(), params, label, panel, values, instance }); + close_panel(s, &m.id); + s.plugins.panels.push(OpenPanel { manifest: m.clone(), command: c.id.clone(), params, label, panel, values, instance }); } Some(UiChange::Close) => close_panel(s, &m.id), None => {} @@ -364,7 +390,7 @@ pub fn run(s: &mut Session, id: &str, command: Option<&str>, params: &Value, fue /// Call the plugin with `input` as one undo step (none when it changed nothing), rolled back when /// it fails. On failure, also whether the module is still fit for more calls: it returned an /// error itself rather than trapping or running out of fuel or memory. -fn call_plugin( +pub(crate) fn call_plugin( s: &mut Session, m: &Manifest, label: &str, @@ -385,20 +411,20 @@ fn call_plugin( /// What `plugin.run` and `plugin.event` return. fn report(s: &Session, id: &str, command: &str, out: RunOutput) -> Value { let output = serde_json::from_str::(&out.output).unwrap_or(Value::String(out.output)); - let panel = s.plugins.panel.as_ref().filter(|o| o.manifest.id == id).map(OpenPanel::describe); + let panel = s.plugins.panel(id).map(OpenPanel::describe); json!({"plugin": id, "command": command, "output": output, "logs": out.logs, "calls": out.calls, "fuel": out.fuel, "panel": panel}) } /// Close plugin `id`'s panel, if it has one open. fn close_panel(s: &mut Session, id: &str) { - if s.plugins.panel.as_ref().is_some_and(|o| o.manifest.id == id) { - s.plugins.panel = None; - } + s.plugins.panels.retain(|o| o.manifest.id != id); } fn ui(s: &mut Session, p: &Value) -> Result { - let id = string(p, "id"); - let open = s.plugins.panel.as_ref().filter(|o| id.as_ref().is_none_or(|id| *id == o.manifest.id)); + let open = match string(p, "id") { + Some(id) => s.plugins.panel(&id), + None => s.plugins.panels.last(), + }; Ok(open.map_or(Value::Null, OpenPanel::describe)) } @@ -408,7 +434,8 @@ fn event(s: &mut Session, p: &Value) -> Result { let cmd = "plugin.event"; let id = string(p, "id").ok_or_else(|| bad(cmd, "`id` required (the plugin whose panel is open)"))?; let control = string(p, "control").ok_or_else(|| bad(cmd, "`control` required (a control's name)"))?; - let open = s.plugins.panel.as_ref().filter(|o| o.manifest.id == id).ok_or_else(|| bad(cmd, format!("`{id}` has no panel open")))?; + let at = s.plugins.panels.iter().position(|o| o.manifest.id == id).ok_or_else(|| bad(cmd, format!("`{id}` has no panel open")))?; + let open = s.plugins.panels.get(at).ok_or_else(|| bad(cmd, "no panel is open"))?; let c = open .panel .control(&control) @@ -416,7 +443,7 @@ fn event(s: &mut Session, p: &Value) -> Result { .ok_or_else(|| bad(cmd, format!("the panel has no control `{control}`")))?; let field = c.is_field(); let value = if field { c.coerce(get(p, "value").unwrap_or(&Value::Null)).map_err(|e| bad(cmd, e))? } else { Value::Null }; - let Some(mut open) = s.plugins.panel.take() else { return Err(bad(cmd, "no panel is open")) }; + let mut open = s.plugins.panels.remove(at); if field { open.values.insert(control.clone(), value.clone()); } @@ -428,7 +455,7 @@ fn event(s: &mut Session, p: &Value) -> Result { Err((e, usable)) => { // A plugin that returned an error keeps its panel; one that crashed loses it. if usable { - s.plugins.panel = Some(open); + keep(s, at, open); } return Err(e); } @@ -437,17 +464,23 @@ fn event(s: &mut Session, p: &Value) -> Result { Some(UiChange::Show(panel)) => { open.values = panel.values(&open.values); open.panel = panel; - s.plugins.panel = Some(open); + keep(s, at, open); } Some(UiChange::Close) => {} - None => s.plugins.panel = Some(open), + None => keep(s, at, open), } Ok(report(s, &id, &command, out)) } +/// Put a panel taken out for a callback back in its place among the open ones. +fn keep(s: &mut Session, at: usize, open: OpenPanel) { + let at = at.min(s.plugins.panels.len()); + s.plugins.panels.insert(at, open); +} + fn close(s: &mut Session, p: &Value) -> Result { let id = string(p, "id").ok_or_else(|| bad("plugin.close", "`id` required"))?; - let closed = s.plugins.panel.as_ref().is_some_and(|o| o.manifest.id == id); + let closed = s.plugins.panel(&id).is_some(); close_panel(s, &id); Ok(json!({"closed": closed})) } @@ -488,7 +521,13 @@ pub fn specs() -> Vec { false, run_cmd ), - spec!("plugin.ui", "Plugin panel", "{id?} → the open plugin panel {plugin, command, title, controls, values}, or null", false, ui), + spec!( + "plugin.ui", + "Plugin panel", + "{id?} → plugin id's open panel (without id: the one opened last) {plugin, command, title, controls, values}, or null", + false, + ui + ), spec!( "plugin.event", "Use plugin panel control", diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b87230f..980d010 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -261,6 +261,8 @@ fn every_command_survives_junk_params() { // Links (dev resources) and indexes as junk. json!({"id": 2, "url": "javascript:alert(1)", "index": 1e308, "name": {"x": 1}}), json!({"id": 2, "url": "https://\u{0}x", "index": -1, "name": ""}), + // Widgets: plugins, targets and fuel as junk. + json!({"id": 1, "plugin": {"x": 1}, "target": [1], "fuel": -1e308, "x": 1e308}), ]; for spec in all_commands() { if matches!( diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index eee555b..4e385fb 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -192,6 +192,8 @@ pub enum Cursor { Text, Pen, Copy, + /// Over a clickable part of a widget. + Pointer, } /// Selection handle kinds: corners (0 tl, 1 tr, 2 br, 3 bl), edges (0 t, 1 r, 2 b, 3 l) and the @@ -888,8 +890,11 @@ impl Session { if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom) { cursor = resize_cursor(&doc, &sel, h); } else { - hover = label_at(&doc, page, &self.tool.labels, ev.p()) - .or_else(|| self.hit(&doc, page, ev.p(), ev.tol() * 2.0).map(|leaf| select_target(&doc, leaf, &sel, ev.cmd))); + let leaf = self.hit(&doc, page, ev.p(), ev.tol() * 2.0); + if leaf.and_then(|l| crate::widgets::target_at(&doc, l, ev.p())).is_some() { + cursor = Cursor::Pointer; + } + hover = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| leaf.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd))); if ev.alt || self.tool.redlines { use crate::distance::{Hover, guide_at, hover_lines, padding_frame, parent_of}; // Shift+Alt over an auto layout frame: its padding and gaps. Otherwise from the @@ -982,9 +987,12 @@ impl Session { } // A frame's name label picks the frame itself. The background of a top-level frame with // layers in it is like the canvas (a press starts a marquee inside it) unless the frame is - // selected; an empty frame is picked and dragged. + // selected; an empty frame (or a widget) is picked and dragged. let board_background = |leaf: NodeId| { - !sel.contains(&leaf) && doc.node(leaf).is_some_and(|n| n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty()) + !sel.contains(&leaf) + && doc + .node(leaf) + .is_some_and(|n| n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty() && !crate::widgets::is_widget(n)) }; let target = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| { self.hit(&doc, page, ev.p(), ev.tol() * 2.0) @@ -1506,7 +1514,7 @@ impl Session { self.tool.overlay.drop_cell = None; let drop_target = self.tool.overlay.drop_target.take(); match g { - Gesture::Move { ids, moved, duplicate, .. } => { + Gesture::Move { ids, moved, duplicate, start, .. } => { if moved { // A free move lands in the frame under the pointer (or the page) where it was // dropped; layers in an auto layout frame already sit in their slot. @@ -1550,8 +1558,9 @@ impl Session { } } else { self.cancel_interaction()?; - // Plain click on an already-selected layer among several: select just it. - let _ = ev; + if !(ev.shift || ev.cmd || duplicate) { + self.widget_click(start, ev.tol() * 2.0)?; + } } } Gesture::Resize { .. } | Gesture::Rotate { .. } | Gesture::Arc { .. } | Gesture::Shape { .. } => { @@ -1629,6 +1638,16 @@ impl Session { if self.tool.tool == ToolId::Pen { return self.pen_finish(); } + // Quick clicks on a widget's clickable part are clicks, one by one. + if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) + && !(ev.shift || ev.cmd || ev.alt) + && self.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|hit| crate::widgets::target_at(&doc, hit, ev.p())).is_some() + { + self.tool.gesture = Gesture::None; + self.cancel_interaction()?; + self.widget_click(ev.p(), ev.tol() * 2.0)?; + return Ok(()); + } let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) }; let target = select_target(&doc, leaf, &sel, true); // Text: edit at the click. @@ -1655,6 +1674,17 @@ impl Session { Ok(()) } + /// A plain click at `p` on a clickable part of a widget calls its plugin back (a failure is + /// shown as a message). + fn widget_click(&mut self, p: Point, tol: f64) -> Result<()> { + let (doc, page) = (self.doc()?.clone(), self.page()?); + let Some((id, target)) = self.hit(&doc, page, p, tol).and_then(|hit| crate::widgets::target_at(&doc, hit, p)) else { return Ok(()) }; + if let Err(e) = self.execute("widget.click", &serde_json::json!({"id": id, "target": target})) { + self.message = Some(e.to_string()); + } + Ok(()) + } + /// The char index in text node `id` at page point `p`. pub fn text_index_at(&self, id: NodeId, p: Point) -> Result { let doc = self.doc()?; diff --git a/crates/engine/src/widgets.rs b/crates/engine/src/widgets.rs new file mode 100644 index 0000000..9fdc1e2 --- /dev/null +++ b/crates/engine/src/widgets.rs @@ -0,0 +1,323 @@ +//! Widgets: layers on the canvas that a plugin draws (plugin API 3). A widget is a frame owned by +//! its plugin: the plugin describes it ([`WidgetRender`]: frames with auto layout, text, shapes and +//! images) and FigCraft builds the layers inside it, locked, so the widget moves as one layer. +//! Each widget keeps its plugin's id and a small state (a JSON object) in its own layer data, so it +//! is saved, copied, undone, exported and synced in a live session like any layer. Clicking a part +//! of it the plugin named as a target calls the plugin back with that state, in a fresh sandbox +//! (deterministic: the state is all it remembers), as one undo step; the plugin hands back the new +//! state and layers. A callback can only rebuild its own widget; other layers change only through +//! the commands its manifest permits. See `docs/plugins.md`. + +use figcraft_color::{ImageScaleMode, Paint}; +use figcraft_doc::{Document, Node, NodeId, NodeKind, TextData}; +use figcraft_geom::{Affine, Point}; +use figcraft_plugin::widget::{MAX_IMAGE_BYTES, MAX_STATE_BYTES}; +use figcraft_plugin::{Instance, Manifest, RunOutput, WidgetKind, WidgetNode, WidgetRender}; +use serde_json::{Map, Value, json}; + +use crate::cmd::CommandSpec; +use crate::params::{id, num, string}; +use crate::plugins::{InstalledPlugin, call_plugin, installed, limits, module_of, plugin_error}; +use crate::{Result, Session, bad, spec}; + +/// Layer data FigCraft keeps for widgets starts with this; plugins can't write it. +pub const DATA_PREFIX: &str = "figcraft.widget"; +/// On a widget's own layer: its plugin's id, and its state (JSON). +const PLUGIN: &str = "figcraft.widget"; +const STATE: &str = "figcraft.widget.state"; +/// On a layer inside a widget: the name its plugin gets when it's clicked. +const TARGET: &str = "figcraft.widget.target"; +/// Images in a widget are at most this many pixels on a side. +const MAX_IMAGE_SIDE: u32 = 4096; + +/// Whether `n` is a widget's own layer. +pub fn is_widget(n: &Node) -> bool { + matches!(n.kind, NodeKind::Frame) && n.plugin_data.contains_key(PLUGIN) +} + +/// The widget layer `id` is, or is inside (the innermost). +pub fn widget_of(doc: &Document, id: NodeId) -> Option { + std::iter::once(id).chain(doc.ancestors(id)).find(|a| doc.node(*a).is_some_and(is_widget)) +} + +/// The clickable part of a widget at page point `p`, given the layer a click there hits: the widget +/// and its target. The layers inside a widget are locked, so the hit stops at the widget itself; +/// the front-most target whose box holds the point wins. +pub fn target_at(doc: &Document, hit: NodeId, p: Point) -> Option<(NodeId, String)> { + let w = widget_of(doc, hit)?; + let target = doc + .subtree(w) + .into_iter() + .filter(|i| doc.world_bounds(*i).is_some_and(|b| b.contains(p))) + .filter_map(|i| doc.node(i).filter(|n| n.visible)?.plugin_data.get(TARGET).cloned()) + .last()?; + Some((w, target)) +} + +/// A widget's state (an empty object when it has none or it's unusable). +fn state_of(doc: &Document, w: NodeId) -> Map { + doc.node(w) + .and_then(|n| n.plugin_data.get(STATE)) + .filter(|s| s.len() <= MAX_STATE_BYTES) + .and_then(|s| serde_json::from_str::(s).ok()) + .and_then(|v| match v { + Value::Object(m) => Some(m), + _ => None, + }) + .unwrap_or_default() +} + +/// The targets inside widget `w`, each once, in layer order. +fn targets(doc: &Document, w: NodeId) -> Vec { + let mut out: Vec = Vec::new(); + for t in doc.subtree(w).into_iter().filter_map(|i| doc.node(i)?.plugin_data.get(TARGET).cloned()) { + if !out.contains(&t) { + out.push(t); + } + } + out +} + +/// Installed widget plugin `plugin`, its manifest checked again (the preferences may have been +/// edited by hand). +fn widget_plugin(s: &Session, cmd: &str, plugin: &str) -> Result { + let mut p = installed(s, cmd, plugin)?.clone(); + p.manifest = Manifest::from_value(&json!(p.manifest)).map_err(|e| bad(cmd, e))?; + if !p.manifest.widget { + return Err(bad(cmd, format!("{} doesn't draw a widget", p.manifest.name))); + } + Ok(p) +} + +/// Call widget `w`'s plugin `p` back (`target`: what was clicked; none when it's inserted) and +/// build what it hands back. Part of the caller's undo step; a failure rolls the whole step back. +fn callback(s: &mut Session, p: &InstalledPlugin, w: NodeId, target: Option<&str>, fuel: Option) -> Result { + let m = &p.manifest; + let wasm = module_of(s, p)?; + let event = target.map(|t| json!({"target": t})); + let state = state_of(s.doc()?, w); + let input = + json!({"api": m.api, "command": "widget", "params": {}, "selection": s.selection(), "widget": {"id": w, "state": state, "event": event}}); + let mut instance = Instance::new(&wasm, &limits(fuel)).map_err(|e| plugin_error(&m.name, e))?; + let mut out = call_plugin(s, m, &m.name, &mut instance, &input.to_string()).map_err(|(e, _)| e)?; + match out.widget.take() { + Some(r) => build(s, &m.id, w, &r)?, + None if target.is_none() => return Err(bad("widget.insert", format!("{} drew no widget", m.name))), + None => {} + } + Ok(out) +} + +/// Rebuild widget `w` of `plugin` from `r`: its own look and state, and new locked layers inside. +fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<()> { + let cmd = "widget.render"; + let ds = s.ds_mut()?; + let before = ds.doc.clone(); + let doc = ds.doc_mut(); + let n = doc.get(w).map_err(|_| bad(cmd, "the widget was removed while its plugin ran"))?; + if !is_widget(n) || n.plugin_data.get(PLUGIN).map(String::as_str) != Some(plugin) { + return Err(bad(cmd, "the layer is no longer this plugin's widget")); + } + for c in n.children.clone() { + doc.remove(c)?; + } + crate::cmd::props::apply(cmd, doc, w, &props(&r.root, true))?; + let n = doc.get_mut(w)?; + n.locked = false; + if !r.root.name.is_empty() { + n.name = r.root.name.clone(); + } + if let Some(state) = &r.state { + n.plugin_data.insert(STATE.into(), Value::Object(state.clone()).to_string()); + } + for c in &r.root.children { + add(doc, w, c, cmd)?; + } + crate::normalize(doc, &before); + Ok(()) +} + +/// Add layer `d` (and what it holds) inside `parent`, locked. +fn add(doc: &mut Document, parent: NodeId, d: &WidgetNode, cmd: &str) -> Result<()> { + let (kind, name) = match d.kind { + WidgetKind::Frame => (NodeKind::Frame, "Frame"), + WidgetKind::Text => (NodeKind::Text(TextData { characters: d.text.clone(), ..TextData::default() }), "Text"), + WidgetKind::Rectangle => (NodeKind::Rectangle, "Rectangle"), + WidgetKind::Ellipse => (NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Ellipse"), + WidgetKind::Image => (NodeKind::Rectangle, "Image"), + }; + let name = if d.name.is_empty() { name.to_string() } else { d.name.clone() }; + let id = doc.alloc_id(); + let mut n = Node::new(id, kind, name, d.width.unwrap_or(100.0), d.height.unwrap_or(100.0)); + n.transform = Affine::translate((d.x.unwrap_or(0.0), d.y.unwrap_or(0.0))); + n.locked = true; + if !d.target.is_empty() { + n.plugin_data.insert(TARGET.into(), d.target.clone()); + } + if d.kind == WidgetKind::Image { + use base64::Engine as _; + let bytes = base64::engine::general_purpose::STANDARD.decode(d.data.trim()).map_err(|e| bad(cmd, format!("an image's `data`: {e}")))?; + if bytes.len() > MAX_IMAGE_BYTES { + return Err(bad(cmd, format!("an image is larger than {} KB", MAX_IMAGE_BYTES / 1024))); + } + let (hash, iw, ih) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?; + if iw > MAX_IMAGE_SIDE || ih > MAX_IMAGE_SIDE { + return Err(bad(cmd, format!("an image is larger than {MAX_IMAGE_SIDE} pixels on a side"))); + } + n.width = d.width.unwrap_or(f64::from(iw)); + n.height = d.height.unwrap_or(f64::from(ih)); + n.fills = vec![Paint::image(hash, ImageScaleMode::Fill)]; + } + doc.insert(n, parent, None)?; + crate::cmd::props::apply(cmd, doc, id, &props(d, false))?; + d.children.iter().try_for_each(|c| add(doc, id, c, cmd)) +} + +/// `node.set` properties for described layer `d` (validated already); `root`: the widget itself, +/// which keeps its place and size unless the description gives one. +fn props(d: &WidgetNode, root: bool) -> Map { + let mut p = Map::new(); + let mut set = |k: &str, v: Value| { + p.insert(k.into(), v); + }; + match d.kind { + WidgetKind::Text => { + if let Some(c) = &d.color { + set("fills", json!([c])); + } + set("fontSize", json!(d.size.unwrap_or(14.0))); + if d.bold { + set("fontStyle", json!("Bold")); + } + // A text with a width wraps; one without grows with its text. + set("textAutoResize", json!(if d.width.is_some() { "height" } else { "widthAndHeight" })); + } + WidgetKind::Image => {} + _ => set("fills", json!(d.fill.iter().collect::>())), + } + if let Some(c) = &d.stroke { + set("strokes", json!([c])); + set("strokeWeight", json!(d.stroke_weight.unwrap_or(1.0))); + } else if root { + set("strokes", json!([])); + } + if matches!(d.kind, WidgetKind::Frame | WidgetKind::Rectangle | WidgetKind::Image) { + set("cornerRadius", json!(d.radius.unwrap_or(0.0))); + } + set("opacity", json!(d.opacity.unwrap_or(1.0))); + if d.kind == WidgetKind::Frame { + match d.layout { + Some(l) => { + set("layout", json!(l)); + set("gap", json!(d.gap.unwrap_or(0.0))); + set("padding", json!(d.padding.unwrap_or(0.0))); + set("crossAlign", json!(d.align.map_or(json!("center"), |a| json!(a)))); + set("mainAlign", json!(d.justify.map_or(json!("min"), |a| json!(a)))); + set("sizingH", json!(if d.width.is_some() { "fixed" } else { "hug" })); + set("sizingV", json!(if d.height.is_some() { "fixed" } else { "hug" })); + } + None => set("layout", Value::Null), + } + } + // A new layer got its size when it was made. + if root { + for (k, v) in [("width", d.width), ("height", d.height)] { + if let Some(v) = v { + set(k, json!(v)); + } + } + } + p +} + +/// What `widget.insert` and `widget.click` return. +fn report(s: &Session, w: NodeId, m: &Manifest, out: RunOutput) -> Result { + let doc = s.doc()?; + let output = serde_json::from_str::(&out.output).unwrap_or(Value::String(out.output)); + Ok( + json!({"id": w, "plugin": m.id, "state": state_of(doc, w), "targets": targets(doc, w), "output": output, "logs": out.logs, "calls": out.calls, "fuel": out.fuel}), + ) +} + +fn insert(s: &mut Session, p: &Value) -> Result { + let cmd = "widget.insert"; + let fuel = num(p, "fuel"); + let plugin = string(p, "plugin").ok_or_else(|| bad(cmd, "`plugin` required (a widget plugin's id; see plugin.list)"))?; + let wp = widget_plugin(s, cmd, &plugin)?; + let m = &wp.manifest; + let (x, y) = (num(p, "x").filter(|v| v.abs() < 1e7), num(p, "y").filter(|v| v.abs() < 1e7)); + let page = s.page()?; + let (cx, cy) = { + let v = &s.ds()?.view; + (v.cx, v.cy) + }; + let (w, out) = s.grouped(&m.name, |s| { + let doc = s.ds_mut()?.doc_mut(); + let w = doc.alloc_id(); + let mut n = Node::new(w, NodeKind::Frame, m.name.clone(), 100.0, 100.0); + n.transform = Affine::translate((x.unwrap_or(cx), y.unwrap_or(cy))); + n.fills.clear(); + n.plugin_data.insert(PLUGIN.into(), m.id.clone()); + doc.insert(n, page, None)?; + let out = callback(s, &wp, w, None, fuel)?; + let ds = s.ds_mut()?; + // Without a position, centred in the view on whole pixels. + if x.is_none() && y.is_none() { + let (ww, wh) = ds.doc.node(w).map_or((0.0, 0.0), |n| (n.width, n.height)); + ds.doc_mut().get_mut(w)?.transform = Affine::translate(((cx - ww / 2.0).round(), (cy - wh / 2.0).round())); + } + ds.selection = vec![w]; + Ok((w, out)) + })?; + report(s, w, m, out) +} + +fn click(s: &mut Session, p: &Value) -> Result { + let cmd = "widget.click"; + let nid = id(p, "id").ok_or_else(|| bad(cmd, "`id` required (a widget, or a layer in one)"))?; + let target = string(p, "target").ok_or_else(|| bad(cmd, "`target` required (see widget.list)"))?; + let doc = s.doc()?; + let w = widget_of(doc, nid).ok_or_else(|| bad(cmd, format!("{nid} isn't a widget or inside one")))?; + let all = targets(doc, w); + if !all.contains(&target) { + return Err(bad(cmd, format!("the widget has no `{target}` to click (it has: {})", all.join(", ")))); + } + let plugin = doc.node(w).and_then(|n| n.plugin_data.get(PLUGIN)).cloned().unwrap_or_default(); + let fuel = num(p, "fuel"); + let wp = widget_plugin(s, cmd, &plugin)?; + let out = s.grouped(&wp.manifest.name, |s| callback(s, &wp, w, Some(&target), fuel))?; + report(s, w, &wp.manifest, out) +} + +fn list(s: &mut Session, _p: &Value) -> Result { + let doc = s.doc()?; + let widgets: Vec = doc + .ids() + .filter_map(|i| { + let n = doc.node(i).filter(|n| is_widget(n))?; + let state = Value::Object(state_of(doc, i)); + Some(json!({"id": i, "plugin": n.plugin_data.get(PLUGIN), "name": n.name, "page": doc.page_of(i), "state": state, "targets": targets(doc, i)})) + }) + .collect(); + Ok(json!({"widgets": widgets})) +} + +pub fn specs() -> Vec { + vec![ + spec!( + "widget.insert", + "Insert widget", + "{plugin (a widget plugin's id), x?, y? (top-left; default: centred in the view), fuel?} the plugin draws it; one undo step → {id, plugin, state, targets, output, logs, calls, fuel}", + false, + insert + ), + spec!( + "widget.click", + "Click widget", + "{id (a widget or a layer in one), target (see widget.list), fuel?} calls the widget's plugin back, which redraws it; one undo step → like widget.insert", + false, + click + ), + spec!("widget.list", "List widgets", "{} → {widgets: [{id, plugin, name, page, state, targets}]}", false, list), + ] +} diff --git a/crates/engine/src/widgets_tests.rs b/crates/engine/src/widgets_tests.rs new file mode 100644 index 0000000..4874572 --- /dev/null +++ b/crates/engine/src/widgets_tests.rs @@ -0,0 +1,348 @@ +//! 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(¶ms.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 { + 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 { + 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). +fn session() -> Session { + let mut s = Session::new(); + install(&mut s, widget_manifest("org.test.tally", "read"), &tally(|_| String::new())); + s +} + +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(" 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, ¶ms); + 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"); +} diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index f0f9404..a4bbbb6 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -1,16 +1,20 @@ //! FigCraft plugins: the manifest (`figcraft-plugin.json`) and the WebAssembly sandbox a plugin -//! runs in. The engine decides what a plugin may do (its permissions, which commands it may call, -//! one undo step per run or panel callback, where settings are kept); this crate only runs modules -//! safely: a metered interpreter with a memory cap, no WASI and a handful of host functions. See -//! `docs/plugins.md`. +//! runs in, and the widget descriptions a plugin hands back. The engine decides what a plugin may +//! do (its permissions, which commands it may call, one undo step per run, panel or widget +//! callback, where settings are kept); this crate only runs modules safely: a metered interpreter +//! with a memory cap, no WASI and a handful of host functions. See `docs/plugins.md`. pub mod manifest; mod sandbox; +pub mod widget; pub use manifest::{API_VERSION, Access, MANIFEST_FILE, Manifest, Panel, Param, ParamKind, Permissions, PluginCommand}; pub use sandbox::{ENTRY, Host, IMPORT_MODULE, Instance, Limits, PluginError, RunOutput, UiChange, check_module, run}; +pub use widget::{WidgetKind, WidgetNode, WidgetRender}; #[cfg(test)] mod tests; #[cfg(test)] mod v2_tests; +#[cfg(test)] +mod widget_tests; diff --git a/crates/plugin/src/manifest.rs b/crates/plugin/src/manifest.rs index 6d488c5..5dfa409 100644 --- a/crates/plugin/src/manifest.rs +++ b/crates/plugin/src/manifest.rs @@ -8,7 +8,7 @@ use serde_json::{Map, Value}; /// The manifest's file name. pub const MANIFEST_FILE: &str = "figcraft-plugin.json"; /// The plugin API version this build implements (`api` in the manifest). -pub const API_VERSION: u32 = 2; +pub const API_VERSION: u32 = 3; /// Largest manifest read. pub const MAX_MANIFEST_BYTES: usize = 256 * 1024; @@ -40,14 +40,17 @@ pub struct Manifest { /// The WebAssembly module, relative to the manifest (`plugin.wasm`). pub main: String, /// The plugin API version the plugin was written for (1 when not given; panels and settings - /// need 2). + /// need 2, widgets 3). #[serde(default = "api_version")] pub api: u32, #[serde(default)] pub permissions: Permissions, - /// Menu commands; a manifest without any gets one, `run`. + /// Menu commands; a manifest without any gets one, `run` (a widget needs none). #[serde(default)] pub commands: Vec, + /// API 3: the plugin draws a widget (Plugins ▸ Widgets, `widget.insert`). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub widget: bool, } /// A manifest without `api` was written for the first one. @@ -304,7 +307,10 @@ impl Manifest { return Err(format!("`{}` in `permissions.commands` isn't a command id or `prefix.*`", truncate(p))); } } - if self.commands.is_empty() { + if self.widget && self.api < 3 { + return Err("a widget needs plugin API 3 (`\"api\": 3`)".into()); + } + if self.commands.is_empty() && !self.widget { self.commands.push(PluginCommand { id: "run".into(), label: "Run".into(), params: Vec::new() }); } if self.commands.len() > MAX_COMMANDS { @@ -392,7 +398,7 @@ fn first_duplicate<'a>(names: impl Iterator) -> Option<&'a str> } /// An id: 1..=`max` ASCII letters, digits, `.`, `-` or `_`. -fn check_id(what: &str, id: &str, max: usize) -> Result<(), String> { +pub(crate) fn check_id(what: &str, id: &str, max: usize) -> Result<(), String> { if id.is_empty() || id.len() > max || !id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) { return Err(format!("{what} `{}` must be 1–{max} letters, digits, `.`, `-` or `_`", truncate(id))); } @@ -400,7 +406,7 @@ fn check_id(what: &str, id: &str, max: usize) -> Result<(), String> { } /// Trimmed text of at most `max` characters without control characters. -fn clean(what: &str, s: &str, max: usize) -> Result { +pub(crate) fn clean(what: &str, s: &str, max: usize) -> Result { let s = s.trim(); if s.chars().count() > max { return Err(format!("{what} is longer than {max} characters")); diff --git a/crates/plugin/src/sandbox.rs b/crates/plugin/src/sandbox.rs index 5061ca8..fa6d3c6 100644 --- a/crates/plugin/src/sandbox.rs +++ b/crates/plugin/src/sandbox.rs @@ -2,7 +2,7 @@ //! memory cap, and sees the outside world only through the host functions below. There is no WASI: //! no file system, network, clock or randomness. //! -//! # ABI (version 2; version 1 is the first seven imports) +//! # ABI (version 3; version 1 is the first seven imports, version 2 adds panels and settings) //! //! The module exports its `memory` and `figcraft_run(input_len: i32) -> i32`. Host functions are //! imported from the module `figcraft`; strings are UTF-8 `(ptr, len)` pairs in the plugin's memory. @@ -21,10 +21,11 @@ //! | `setting_get` | `(key_ptr, key_len) -> i32` | 0: the reply holds the stored JSON; 1: no such setting. | //! | `setting_set` | `(key_ptr, key_len, value_ptr, value_len) -> i32` | Stores JSON under the key. 1: refused, the reply says why. | //! | `setting_delete` | `(key_ptr, key_len)` | Forgets a setting. | +//! | `widget_render` | `(ptr, len) -> i32` | Draws the widget being called back: a [`WidgetRender`] as JSON. 1: invalid, the reply says why. | //! //! When `figcraft_run` starts, the reply holds the input JSON (`input_len` bytes): //! `{"api", "command", "params", "selection"}`, plus `event` and `values` when a panel calls the -//! plugin back. It returns 0 on success; anything else fails the run. +//! plugin back, or `widget` (`{id, state, event}`) when a widget does. It returns 0 on success; anything else fails the run. //! //! An [`Instance`] keeps the module's memory between calls, so a plugin with a panel open keeps its //! state from one callback to the next. Every call gets the full [`Limits`] again. @@ -35,6 +36,7 @@ use wasmi::{ }; use crate::manifest::Panel; +use crate::widget::WidgetRender; /// The module host functions are imported from. pub const IMPORT_MODULE: &str = "figcraft"; @@ -55,6 +57,7 @@ const IMPORTS: &[(&str, usize, usize)] = &[ ("setting_get", 2, 1), ("setting_set", 4, 1), ("setting_delete", 2, 0), + ("widget_render", 2, 1), ]; /// What a run may use. @@ -147,6 +150,8 @@ pub struct RunOutput { pub fuel: u64, /// The last `ui_show` / `ui_close` of the run. pub ui: Option, + /// The last `widget_render` of the run. + pub widget: Option, } /// A host call that needs the [`Host`]: the plugin is paused while the host answers it (see @@ -487,6 +492,22 @@ fn linker(engine: &Engine) -> Result, PluginError> { }) }) .map_err(err)?; + l.func_wrap(IMPORT_MODULE, "widget_render", |mut c: Ctx<'_>, ptr: i32, len: i32| -> Result { + charge(&mut c)?; + let spec = read_str(&mut c, ptr, len)?; + let st = c.data_mut(); + Ok(match WidgetRender::parse(&spec) { + Ok(w) => { + st.out.widget = Some(w); + 0 + } + Err(why) => { + st.set_reply(why.into_bytes()); + 1 + } + }) + }) + .map_err(err)?; l.func_wrap(IMPORT_MODULE, "ui_close", |mut c: Ctx<'_>| -> Result<(), wasmi::Error> { charge(&mut c)?; c.data_mut().out.ui = Some(UiChange::Close); diff --git a/crates/plugin/src/tests.rs b/crates/plugin/src/tests.rs index 6dc3a0f..d012e5b 100644 --- a/crates/plugin/src/tests.rs +++ b/crates/plugin/src/tests.rs @@ -268,7 +268,7 @@ fn bad_manifests_are_refused() { json!({"main": "C:\\plugin.wasm"}), json!({"main": "plugin.js"}), json!({"main": "a//b.wasm"}), - json!({"api": 3}), + json!({"api": 4}), json!({"api": 0}), json!({"permissions": {"access": "root"}}), json!({"permissions": {"commands": ["*.x"]}}), diff --git a/crates/plugin/src/widget.rs b/crates/plugin/src/widget.rs new file mode 100644 index 0000000..32ee7d3 --- /dev/null +++ b/crates/plugin/src/widget.rs @@ -0,0 +1,232 @@ +//! Widgets (ABI v3): a plugin draws a layer on the canvas by describing it, and FigCraft builds the +//! layers. The description comes from the plugin, so it is untrusted: [`WidgetRender::parse`] +//! checks its size, node count, depth, numbers, strings, colours and image sizes before the engine +//! sees it. The engine decides where the layers go (only inside the widget's own layer). + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::manifest::{check_id, clean}; + +/// Largest description a plugin may pass to `widget_render` (images included, as base64). +pub const MAX_WIDGET_BYTES: usize = 4 * 1024 * 1024; +/// Largest widget state (its JSON), and its longest key. +pub const MAX_STATE_BYTES: usize = 16 * 1024; +const MAX_STATE_KEY: usize = 128; +/// Most layers in one widget, and how deep they nest. +pub const MAX_NODES: usize = 256; +const MAX_DEPTH: usize = 16; +/// Longest text in a text layer. +const MAX_TEXT: usize = 1000; +const MAX_NAME: usize = 64; +/// Sizes, positions, gaps and paddings stay within this. +const MAX_SIZE: f64 = 4000.0; +/// Largest image (decoded bytes), and all images of one widget together. +pub const MAX_IMAGE_BYTES: usize = 1024 * 1024; +const MAX_IMAGES_BYTES: usize = 2 * 1024 * 1024; + +/// What a widget is made of: FigCraft's own layer kinds. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WidgetKind { + #[default] + Frame, + Text, + Rectangle, + Ellipse, + /// A rectangle filled with an image (PNG, JPEG…) the plugin carries in its module. + Image, +} + +/// Auto layout direction of a frame. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WidgetLayout { + Horizontal, + Vertical, +} + +/// Alignment of an auto layout frame's children, along (`justify`) or across (`align`) it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WidgetAlign { + Min, + Center, + Max, +} + +/// One layer of a widget's description. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct WidgetNode { + #[serde(rename = "type", default)] + pub kind: WidgetKind, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub name: String, + /// Clicking this layer calls the plugin back with this name (empty: not clickable). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub target: String, + /// Position in the parent frame (ignored inside auto layout and for the widget itself). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub x: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub y: Option, + /// Size; a frame with auto layout hugs its contents on an axis without one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub width: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub height: Option, + /// Hex colours (`#RRGGBB` or `#RRGGBBAA`); for text, `color` is the text's. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fill: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stroke: Option, + #[serde(rename = "strokeWeight", default, skip_serializing_if = "Option::is_none")] + pub stroke_weight: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub radius: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opacity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layout: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gap: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub padding: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub align: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub justify: Option, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub text: String, + /// Font size of a text layer. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub bold: bool, + /// An image layer's bytes, base64. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub data: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub children: Vec, +} + +/// What a widget callback hands back with `widget_render`: the widget's new state (`None`: keep +/// it) and its layers, the root a frame. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct WidgetRender { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state: Option>, + pub root: WidgetNode, +} + +/// What one description may still use while it's checked. +struct Budget { + nodes: usize, + image_bytes: usize, +} + +impl WidgetRender { + /// Parse and validate a widget description. + pub fn parse(text: &str) -> Result { + if text.len() > MAX_WIDGET_BYTES { + return Err(format!("the widget is larger than {} MB", MAX_WIDGET_BYTES / (1024 * 1024))); + } + let mut w: WidgetRender = serde_json::from_str(text).map_err(|e| format!("widget: {e}"))?; + if let Some(state) = &w.state { + check_state(state)?; + } + if w.root.kind != WidgetKind::Frame { + return Err("widget: the root must be a frame".into()); + } + w.root.check(0, &mut Budget { nodes: 0, image_bytes: 0 }).map_err(|e| format!("widget: {e}"))?; + Ok(w) + } +} + +/// A widget's state: a JSON object of at most [`MAX_STATE_BYTES`] with short keys. +pub fn check_state(state: &Map) -> Result<(), String> { + if state.keys().any(|k| k.is_empty() || k.chars().count() > MAX_STATE_KEY || k.chars().any(char::is_control)) { + return Err(format!("widget: a state key must be 1–{MAX_STATE_KEY} characters without control characters")); + } + if serde_json::to_string(state).map_or(usize::MAX, |s| s.len()) > MAX_STATE_BYTES { + return Err(format!("widget: the state is larger than {} KB", MAX_STATE_BYTES / 1024)); + } + Ok(()) +} + +/// `v` when it's finite and within `lo..=hi`. +fn within(what: &str, v: Option, lo: f64, hi: f64) -> Result<(), String> { + match v { + Some(n) if !n.is_finite() || n < lo || n > hi => Err(format!("`{what}` must be a number from {lo} to {hi}")), + _ => Ok(()), + } +} + +/// A colour as `#RRGGBB(AA)`. +fn colour(what: &str, c: &mut Option) -> Result<(), String> { + if let Some(s) = c { + let parsed = Some(s.as_str()).filter(|s| s.len() <= 9).and_then(figcraft_color::Color::from_hex); + *s = parsed.ok_or_else(|| format!("`{what}` must be a hex colour like #FF5A5F"))?.to_css_hex(); + } + Ok(()) +} + +impl WidgetNode { + fn check(&mut self, depth: usize, budget: &mut Budget) -> Result<(), String> { + budget.nodes += 1; + if budget.nodes > MAX_NODES { + return Err(format!("more than {MAX_NODES} layers")); + } + if depth >= MAX_DEPTH { + return Err(format!("layers nest more than {MAX_DEPTH} deep")); + } + self.name = clean("name", &self.name, MAX_NAME)?; + if !self.target.is_empty() { + check_id("target", &self.target, MAX_NAME)?; + } + for (what, v) in [("x", self.x), ("y", self.y)] { + within(what, v, -MAX_SIZE, MAX_SIZE)?; + } + for (what, v) in [("width", self.width), ("height", self.height), ("gap", self.gap), ("padding", self.padding), ("radius", self.radius)] { + within(what, v, 0.0, MAX_SIZE)?; + } + within("strokeWeight", self.stroke_weight, 0.0, 64.0)?; + within("opacity", self.opacity, 0.0, 1.0)?; + within("size", self.size, 1.0, 256.0)?; + colour("fill", &mut self.fill)?; + colour("stroke", &mut self.stroke)?; + colour("color", &mut self.color)?; + let kind = self.kind; + let only = |what: &str, used: bool, allowed: bool| { + if used && !allowed { Err(format!("`{what}` doesn't apply to a {kind:?} layer")) } else { Ok(()) } + }; + only("children", !self.children.is_empty(), kind == WidgetKind::Frame)?; + only( + "layout", + self.layout.is_some() || self.gap.is_some() || self.padding.is_some() || self.align.is_some() || self.justify.is_some(), + kind == WidgetKind::Frame, + )?; + only("text", !self.text.is_empty() || self.size.is_some() || self.color.is_some() || self.bold, kind == WidgetKind::Text)?; + only("data", !self.data.is_empty(), kind == WidgetKind::Image)?; + if self.text.chars().count() > MAX_TEXT { + return Err(format!("a text is longer than {MAX_TEXT} characters")); + } + if self.text.chars().any(|c| c.is_control() && c != '\n') { + return Err("a text contains control characters".into()); + } + if kind == WidgetKind::Image { + // Base64: four characters for three bytes (checked again when decoded). + let bytes = self.data.trim().len() / 4 * 3; + if self.data.trim().is_empty() || bytes > MAX_IMAGE_BYTES + 3 { + return Err(format!("an image needs `data` (base64) of at most {} KB", MAX_IMAGE_BYTES / 1024)); + } + budget.image_bytes += bytes; + if budget.image_bytes > MAX_IMAGES_BYTES + 3 { + return Err(format!("the images are larger than {} MB together", MAX_IMAGES_BYTES / (1024 * 1024))); + } + } + self.children.iter_mut().try_for_each(|c| c.check(depth + 1, budget)) + } +} diff --git a/crates/plugin/src/widget_tests.rs b/crates/plugin/src/widget_tests.rs new file mode 100644 index 0000000..0d4ec2d --- /dev/null +++ b/crates/plugin/src/widget_tests.rs @@ -0,0 +1,116 @@ +//! Widgets (ABI v3): descriptions are validated (sizes, counts, depth, numbers, colours, images) +//! and reported by `widget_render`; manifests ask for API 3; junk never gets through. + +use serde_json::{Value, json}; + +use crate::widget::{MAX_IMAGE_BYTES, MAX_NODES, MAX_STATE_BYTES}; +use crate::{Host, Instance, Limits, Manifest, WidgetKind, WidgetRender}; + +struct Quiet; + +impl Host for Quiet { + fn call(&mut self, _command: &str, _params: &str) -> Result { + Ok("{}".into()) + } + fn selection(&mut self) -> String { + "[]".into() + } + fn notify(&mut self, _message: &str) {} +} + +fn parse(v: Value) -> Result { + WidgetRender::parse(&v.to_string()) +} + +#[test] +fn descriptions_are_checked_and_normalised() { + let w = parse( + json!({"state": {"n": 2}, "root": {"type": "frame", "layout": "horizontal", "gap": 8, "padding": 12, "fill": "#fff", "justify": "center", + "children": [{"type": "text", "text": "2", "size": 24, "color": "#112233", "bold": true}, + {"type": "rectangle", "target": "inc", "width": 24, "height": 24, "radius": 4, "fill": "0d99ff80"}]}}), + ) + .unwrap(); + assert_eq!(w.root.fill.as_deref(), Some("#FFFFFF"), "colours come out as #RRGGBB(AA)"); + assert_eq!(w.root.children[1].fill.as_deref(), Some("#0D99FF80")); + assert_eq!(w.root.children[0].kind, WidgetKind::Text); + assert_eq!(w.state, Some(json!({"n": 2}).as_object().unwrap().clone())); + assert!(parse(json!({"root": {"type": "frame"}})).unwrap().state.is_none(), "no state: keep the old one"); +} + +#[test] +fn junk_descriptions_are_refused() { + let deep = (0..20).fold(json!({"type": "frame"}), |inner, _| json!({"type": "frame", "children": [inner]})); + let wide = json!({"root": {"type": "frame", "children": vec![json!({"type": "rectangle"}); MAX_NODES]}}); + let image = |bytes: usize| json!({"root": {"type": "frame", "children": [{"type": "image", "data": "A".repeat(bytes / 3 * 4)}]}}); + let junk = [ + json!(null), + json!({}), + json!({"root": {"type": "text", "text": "a root must be a frame"}}), + json!({"root": {"type": "frame", "width": -1}}), + json!({"root": {"type": "frame", "width": 1e9}}), + json!({"root": {"type": "frame", "opacity": 2}}), + json!({"root": {"type": "frame", "fill": "red"}}), + json!({"root": {"type": "frame", "fill": "#1234567890"}}), + json!({"root": {"type": "frame", "children": [{"type": "text", "text": "x", "children": [{"type": "frame"}]}]}}), + json!({"root": {"type": "frame", "children": [{"type": "rectangle", "text": "not here"}]}}), + json!({"root": {"type": "frame", "children": [{"type": "text", "justify": "center"}]}}), + json!({"root": {"type": "frame", "justify": "middle"}}), + json!({"root": {"type": "frame", "children": [{"type": "text", "text": "a\u{7}b"}]}}), + json!({"root": {"type": "frame", "children": [{"type": "text", "text": "x".repeat(1001)}]}}), + json!({"root": {"type": "frame", "children": [{"type": "text", "size": 0}]}}), + json!({"root": {"type": "frame", "target": "has space"}}), + json!({"root": {"type": "frame", "name": "x".repeat(65)}}), + json!({"root": {"type": "frame", "children": [{"type": "image"}]}}), + json!({"root": {"type": "frame", "children": [{"type": "video"}]}}), + json!({"state": [1], "root": {"type": "frame"}}), + json!({"state": {"": 1}, "root": {"type": "frame"}}), + json!({"state": {"k": "x".repeat(MAX_STATE_BYTES)}, "root": {"type": "frame"}}), + json!({"root": deep}), + wide, + image(MAX_IMAGE_BYTES + 3000), + ]; + for j in junk { + assert!(parse(j.clone()).is_err(), "accepted {}", j.to_string().chars().take(80).collect::()); + } + assert!(parse(image(1000)).is_ok()); + assert!(WidgetRender::parse(&"[".repeat(100_000)).is_err(), "deep JSON fails cleanly"); + assert!(WidgetRender::parse(&" ".repeat(5 * 1024 * 1024)).is_err()); +} + +#[test] +fn widget_render_reports_the_description_or_why_not() { + let good = r#"{"root":{"type":"frame","children":[{"type":"text","text":"hi"}]}}"#; + let bad = r#"{"root":{"type":"text"}}"#; + let wat = format!( + r#"(module + (import "figcraft" "widget_render" (func $render (param 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))) + (memory (export "memory") 1) + (data (i32.const 100) "{}") (data (i32.const 1000) "{}") + (func (export "figcraft_run") (param $n i32) (result i32) + (if (call $render (i32.const 100) (i32.const {})) + (then (call $set_output (i32.const 5000) (call $reply_read (i32.const 5000) (i32.const 1000))))) + (drop (call $render (i32.const 1000) (i32.const {}))) + (i32.const 0)))"#, + bad.replace('"', "\\\""), + good.replace('"', "\\\""), + bad.len(), + good.len() + ); + let wasm = wat::parse_str(&wat).unwrap(); + let out = Instance::new(&wasm, &Limits::default()).unwrap().call("{}", &mut Quiet).unwrap(); + assert!(out.output.contains("root must be a frame"), "{}", out.output); + assert_eq!(out.widget.unwrap().root.children[0].text, "hi"); +} + +#[test] +fn widgets_need_api_3() { + let m = |api: u32| Manifest::from_value(&json!({"id": "w", "name": "W", "main": "w.wasm", "api": api, "widget": true})); + assert!(m(2).unwrap_err().contains("API 3")); + let ok = m(3).unwrap(); + assert!(ok.widget && ok.commands.is_empty(), "a widget-only plugin has no menu command"); + assert!(Manifest::from_value(&json!({"id": "w", "name": "W", "main": "w.wasm", "api": 4})).is_err()); + let v2 = Manifest::from_value(&json!({"id": "p", "name": "P", "main": "p.wasm", "api": 2})).unwrap(); + assert!(!v2.widget && v2.commands.len() == 1, "v2 manifests are unchanged"); +} diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 9612734..6478278 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1270,6 +1270,7 @@ name = "English" "While pressing" = "While pressing" "White" = "White" "Why it changed" = "Why it changed" +"Widgets" = "Widgets" "Width" = "Width" "Width point" = "Width point" "Width profile" = "Width profile" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index c41ed33..7fc494e 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1271,6 +1271,7 @@ status = "draft — needs native review" "While pressing" = "Mientras se presiona" "White" = "Blanco" "Why it changed" = "Por qué cambió" +"Widgets" = "Widgets" "Width" = "Ancho" "Width point" = "Punto de ancho" "Width profile" = "Perfil de ancho" @@ -1900,6 +1901,9 @@ status = "draft — needs native review" "view.zoomIn" = "Acercar" "view.zoomOut" = "Alejar" "view.zoomSelection" = "Zoom a la selección" +"widget.click" = "Pulsar widget" +"widget.insert" = "Insertar widget" +"widget.list" = "Listar widgets" "window.focus" = "Traer la ventana al frente" "window.new" = "Nueva ventana" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1a6109b..28fb784 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -248,6 +248,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { Cursor::Grabbing => egui::CursorIcon::Grabbing, Cursor::Text => egui::CursorIcon::Text, Cursor::Copy => egui::CursorIcon::Copy, + Cursor::Pointer => egui::CursorIcon::PointingHand, } }; ctx.set_cursor_icon(c); diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index 0728515..e0b4bcf 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -45,6 +45,13 @@ pub enum PickerTarget { id: String, index: usize, }, + /// A colour field of a plugin's panel or parameters form. A drag only shows `pending`; the + /// plugin hears of the colour when the drag ends. + PluginField { + plugin: String, + control: String, + pending: Option, + }, } /// The paints of colour style `id`. @@ -85,11 +92,31 @@ fn current_paint(app: &FigApp, target: &PickerTarget) -> Option { _ => None, }, PickerTarget::Style { id, index } => style_paints(doc, id)?.get(*index).cloned(), + PickerTarget::PluginField { plugin, control, pending } => { + let hex = match pending { + Some(hex) => hex.clone(), + None => crate::plugins_ui::field_value(app, plugin, control)?, + }; + let c = Color::from_hex(&hex)?; + let mut p = Paint::solid(c.with_alpha(1.0)); + p.opacity = c.a; + Some(p) + } } } /// Write `paint` to the target. `live` edits are part of an ongoing drag. fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) { + if let PickerTarget::PluginField { plugin, control, .. } = target { + let c = paint.solid_color().unwrap_or(Color::BLACK); + let hex = c.with_alpha(c.a * paint.opacity).to_css_hex(); + let (plugin, control) = (plugin.clone(), control.clone()); + if !live { + crate::plugins_ui::set_field(app, &plugin, &control, &hex); + } + app.ui.picker = Some(PickerTarget::PluginField { plugin, control, pending: live.then_some(hex) }); + return; + } if live { let _ = app.session.begin_interaction("Color"); } @@ -115,6 +142,7 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) { let c = paint.solid_color().unwrap_or(Color::BLACK); app.session.execute("variable.set", &json!({"variable": id, "mode": mode, "value": c.with_alpha(c.a * paint.opacity).to_css_hex()})) } + PickerTarget::PluginField { .. } => return, PickerTarget::Style { id, index } => { let Some(mut paints) = app.session.doc().ok().and_then(|d| style_paints(d, id)) else { return }; if let Some(slot) = paints.get_mut(*index) { @@ -134,6 +162,10 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) { } fn finish(app: &mut FigApp) { + if let Some(PickerTarget::PluginField { plugin, control, pending: Some(hex) }) = app.ui.picker.clone() { + crate::plugins_ui::set_field(app, &plugin, &control, &hex); + app.ui.picker = Some(PickerTarget::PluginField { plugin, control, pending: None }); + } if app.session.ds().map(|d| d.interaction.is_some()).unwrap_or(false) { let _ = app.session.commit_interaction(); } @@ -374,7 +406,12 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { }; let t = Tokens::get(ctx); let screen = ctx.content_rect(); - let pos = pos2(screen.max.x - app.ui.right_width - 260.0, 140.0); + // A plugin's colour field: beside its window, which sits where the picker otherwise would. + let beside = match &target { + PickerTarget::PluginField { plugin, .. } => crate::plugins_ui::field_window(app, ctx, plugin).map(|r| pos2(r.min.x - 248.0, r.min.y)), + _ => None, + }; + let pos = beside.unwrap_or(pos2(screen.max.x - app.ui.right_width - 260.0, 140.0)); let mut open = true; let mut close_now = false; egui::Window::new("color_picker") @@ -395,7 +432,10 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { close_now = true; } // Paint type. - if !matches!(target, PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. }) { + if !matches!( + target, + PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. } + ) { let kinds = [ (Icon::Fill, tr("Solid")), (Icon::LayoutH, tr("Linear")), @@ -460,7 +500,10 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { }); } // The paint's own blend mode, with the paints and layers under it. - if !matches!(target, PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. }) { + if !matches!( + target, + PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. } + ) { ui.horizontal(|ui| { ui.add_space(16.0); let resp = widgets::dropdown(ui, tr(paint.blend.label()), 208.0, Some(Icon::Blend)).on_hover_text(tr("Blend mode")); diff --git a/crates/ui-egui/src/plugin_panel_tests.rs b/crates/ui-egui/src/plugin_panel_tests.rs index 74b7def..f154c62 100644 --- a/crates/ui-egui/src/plugin_panel_tests.rs +++ b/crates/ui-egui/src/plugin_panel_tests.rs @@ -1,4 +1,4 @@ -//! A plugin's panel in the UI, driven headless: its controls are drawn with names a screen reader +//! Plugin panels in the UI, driven headless: their controls are drawn with names a screen reader //! reads, pressing its button calls the plugin back, closing it ends the plugin's session, and an //! open panel draws no frames while nothing changes. @@ -7,12 +7,13 @@ use egui::Pos2; use figcraft_engine::Session; use serde_json::{Value, json}; +use crate::colorpicker::PickerTarget; use crate::idle_tests::Harness; use crate::layers_drag_tests::Ui; -/// A session with a plugin whose first call shows a panel with every kind of control and whose -/// later calls (the panel's events) make a rectangle. -fn session() -> Session { +/// A plugin whose first call shows a panel with every kind of control and whose later calls (the +/// panel's events) make a rectangle. +fn panel_module() -> Vec { let spec = json!({"title": "Test panel", "controls": [ {"type": "note", "label": "Hello from the plugin"}, {"type": "number", "name": "size", "label": "Size", "value": 10}, @@ -41,7 +42,12 @@ fn session() -> Session { spec.len(), rect.len() ); - let wasm = wat::parse_str(&wat).unwrap(); + wat::parse_str(&wat).unwrap() +} + +/// A session with that plugin's panel open. +fn session() -> Session { + let wasm = panel_module(); let manifest = json!({"id": "org.test.panel", "name": "Panel test", "main": "p.wasm", "api": 2, "permissions": {"access": "write"}}); let mut s = Session::new(); s.execute("plugin.import", &json!({"manifest": manifest, "wasm": base64::engine::general_purpose::STANDARD.encode(wasm)})).unwrap(); @@ -71,7 +77,7 @@ fn the_panel_names_its_controls_and_its_button_calls_the_plugin_back() { u.button(at, false); assert_eq!(layers(&u), before + 1, "Go made the plugin draw a rectangle"); assert_eq!(u.app.session.ds().unwrap().history.undo.len(), 1); - assert!(u.app.session.plugins.panel.is_some(), "the panel stays open"); + assert_eq!(u.app.session.plugins.panels.len(), 1, "the panel stays open"); // Its close button ends the session. u.app.run("plugin.close", json!({"id": "org.test.panel"})); u.frame(vec![]); @@ -82,3 +88,33 @@ fn the_panel_names_its_controls_and_its_button_calls_the_plugin_back() { fn an_open_plugin_panel_draws_no_frames_while_idle() { Harness::new(session()).assert_idle("a plugin panel"); } + +#[test] +fn a_colour_field_opens_the_colour_picker_and_two_panels_show_side_by_side() { + let mut u = Ui::new(session()); + u.ctx.enable_accesskit(); + u.frame(vec![]); + let nodes = |u: &mut Ui| u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes; + // The swatch beside a colour field opens the colour picker on it. + let swatch = nodes(&mut u).iter().map(|(_, n)| n).find(|n| n.label() == Some("Color")).and_then(|n| n.bounds()).unwrap(); + let at = Pos2::new(((swatch.x0 + swatch.x1) / 2.0) as f32, ((swatch.y0 + swatch.y1) / 2.0) as f32); + u.button(at, true); + u.button(at, false); + let picked = Some(PickerTarget::PluginField { plugin: "org.test.panel".into(), control: "accent".into(), pending: None }); + assert_eq!(u.app.ui.picker, picked); + u.frame(vec![]); + assert_eq!(u.app.ui.picker, picked, "the picker shows the field's colour"); + // A colour picked there calls the plugin back with it. + let before = layers(&u); + crate::plugins_ui::set_field(&mut u.app, "org.test.panel", "accent", "#00FF00"); + assert_eq!(u.app.session.plugins.panel("org.test.panel").unwrap().values["accent"], "#00FF00"); + assert_eq!(layers(&u), before + 1); + // A second plugin's panel opens beside the first. + let other = json!({"id": "org.test.other", "name": "Other", "main": "p.wasm", "api": 2, "permissions": {"access": "write"}}); + let module = base64::engine::general_purpose::STANDARD.encode(panel_module()); + u.app.session.execute("plugin.import", &json!({"manifest": other, "wasm": module})).unwrap(); + u.app.session.execute("plugin.run", &json!({"id": "org.test.other"})).unwrap(); + u.frame(vec![]); + let accents = nodes(&mut u).iter().filter(|(_, n)| n.label() == Some("Accent")).count(); + assert_eq!((accents, u.app.session.plugins.panels.len()), (2, 2), "both panels are drawn"); +} diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index d2244e8..cdc660a 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -1,12 +1,14 @@ -//! Plugins in the UI: the installed plugins in the Plugins menu, the parameters form a command -//! with parameters asks for before it runs, the panel a running plugin shows, and the Manage -//! plugins window. Everything runs through the engine's `plugin.*` commands. +//! Plugins in the UI: the installed plugins in the Plugins menu (widgets in Plugins ▸ Widgets), the +//! parameters form a command with parameters asks for before it runs, the panels running plugins +//! show, and the Manage plugins window. Everything runs through the engine's `plugin.*` and +//! `widget.*` commands. use egui::{Align2, Context, Id, RichText, Ui}; use figcraft_engine::plugins::{Access, InstalledPlugin, Param, ParamKind}; use serde_json::{Map, Value, json}; use crate::FigApp; +use crate::colorpicker::PickerTarget; use crate::dialogs::{floating, window}; use crate::i18n::{tr, trf}; use crate::theme::{Tokens, regular, semibold}; @@ -22,6 +24,47 @@ pub struct PluginForm { /// Width of the plugin windows' fields. const FIELD_W: f32 = 220.0; +/// How far apart plugin panels open, side by side (a panel and a gap). +const PANEL_STEP: f32 = 372.0; + +/// What the user did to a parameter or panel control. +enum Used { + /// Changed a field to a value, or pressed a button (null). + Set(String, Value), + /// Asked for the colour picker for a colour field. + Pick(String), +} + +/// The value of colour field `control` of plugin `plugin`'s parameters form (when it's open) or +/// panel, as hex. +pub(crate) fn field_value(app: &FigApp, plugin: &str, control: &str) -> Option { + let values = match &app.ui.plugin_form { + Some(f) if f.id == plugin => &f.values, + _ => &app.session.plugins.panel(plugin)?.values, + }; + values.get(control)?.as_str().map(str::to_string) +} + +/// Where the window holding plugin `plugin`'s colour fields is (its parameters form when it's +/// open, else its panel), as last drawn. +pub(crate) fn field_window(app: &FigApp, ctx: &Context, plugin: &str) -> Option { + let id = match &app.ui.plugin_form { + Some(f) if f.id == plugin => Id::new("plugin_form"), + _ => Id::new(("plugin_panel", plugin)), + }; + ctx.memory(|m| m.area_rect(id)) +} + +/// Set colour field `control` of plugin `plugin`'s parameters form (when it's open), or of its +/// panel (calling the plugin back), to `hex`. +pub(crate) fn set_field(app: &mut FigApp, plugin: &str, control: &str, hex: &str) { + match &mut app.ui.plugin_form { + Some(f) if f.id == plugin => { + f.values.insert(control.to_string(), json!(hex)); + } + _ => run(app, "plugin.event", json!({"id": plugin, "control": control, "value": hex})), + } +} /// Start plugin `id`'s `command` (empty: its first): ask for its parameters first when it has any. pub fn start(app: &mut FigApp, id: &str, command: &str) { @@ -65,6 +108,8 @@ pub fn menu(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> bool { for p in &app.session.prefs.plugins { let m = &p.manifest; match m.commands.as_slice() { + // A widget may have no commands: it's in Widgets. + [] => {} [only] => { // An ellipsis: it asks for parameters first. let label = if only.params.is_empty() { m.name.clone() } else { format!("{}…", m.name) }; @@ -82,13 +127,26 @@ pub fn menu(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> bool { } } } + let widgets: Vec<(&str, &str)> = + app.session.prefs.plugins.iter().filter(|p| p.manifest.widget).map(|p| (p.manifest.id.as_str(), p.manifest.name.as_str())).collect(); + let widget = (!widgets.is_empty()) + .then(|| { + crate::menus::submenu(ui, "Plugins", "Plugins/widgets", tr("Widgets"), t, |ui| { + widgets.iter().find(|(_, name)| crate::menus::item(ui, name, "", true, false, t)).map(|(id, _)| id.to_string()) + }) + }) + .flatten(); crate::menus::separator(ui); + if let Some(plugin) = widget { + app.run("widget.insert", json!({"plugin": plugin})); + return true; + } let Some((id, command)) = chosen else { return false }; start(app, &id, &command); true } -/// The plugin windows (Manage plugins, a command's parameters, a plugin's panel). +/// The plugin windows (Manage plugins, a command's parameters, the plugins' panels). pub fn show(app: &mut FigApp, ctx: &Context) { if app.ui.plugins_window { manage_window(app, ctx); @@ -96,30 +154,43 @@ pub fn show(app: &mut FigApp, ctx: &Context) { if app.ui.plugin_form.is_some() { form_window(app, ctx); } - if app.session.plugins.panel.is_some() { - panel_window(app, ctx); + if !app.session.plugins.panels.is_empty() { + panel_windows(app, ctx); } } -/// The panel a running plugin shows, at first beside the properties panel (it can be moved): a -/// pressed button or a changed value calls the plugin back (`plugin.event`); closing it ends the -/// plugin's session. -fn panel_window(app: &mut FigApp, ctx: &Context) { - let Some(open) = app.session.plugins.panel.as_ref() else { return }; +/// The panels running plugins show, at first beside the properties panel and each other (they +/// can be moved): a pressed button or a changed value calls its plugin +/// back (`plugin.event`); closing one ends its plugin's session. +fn panel_windows(app: &mut FigApp, ctx: &Context) { let t = Tokens::get(ctx); - let mut keep = true; - let mut changed = None; - let id = open.manifest.id.as_str(); - let at = egui::pos2(ctx.content_rect().right() - 252.0, 52.0); - floating(open.title(), ctx).id(Id::new(("plugin_panel", id))).pivot(Align2::RIGHT_TOP).default_pos(at).open(&mut keep).show(ctx, |ui| { - ui.set_width(320.0); - changed = controls(ui, id, &open.panel.controls, &open.values, &t); - }); - let id = id.to_string(); - if !keep { - app.run("plugin.close", json!({"id": id})); - } else if let Some((control, value)) = changed { - run(app, "plugin.event", json!({"id": id, "control": control, "value": value})); + let right = ctx.content_rect().right() - 252.0; + // (plugin, what was used; None: closed) + let mut actions: Vec<(String, Option)> = Vec::new(); + for (i, open) in app.session.plugins.panels.iter().enumerate() { + let id = open.manifest.id.as_str(); + let mut keep = true; + let mut used = None; + // Side by side leftwards from the properties panel, three to a row. + let at = egui::pos2(right - PANEL_STEP * (i % 3) as f32, 52.0 + 28.0 * (i / 3) as f32); + floating(open.title(), ctx).id(Id::new(("plugin_panel", id))).pivot(Align2::RIGHT_TOP).default_pos(at).open(&mut keep).show(ctx, |ui| { + ui.set_width(320.0); + used = controls(ui, id, &open.panel.controls, &open.values, &t); + }); + if !keep { + actions.push((id.to_string(), None)); + } else if used.is_some() { + actions.push((id.to_string(), used)); + } + } + for (id, used) in actions { + match used { + None => { + app.run("plugin.close", json!({"id": id})); + } + Some(Used::Set(control, value)) => run(app, "plugin.event", json!({"id": id, "control": control, "value": value})), + Some(Used::Pick(control)) => app.ui.picker = Some(PickerTarget::PluginField { plugin: id, control, pending: None }), + } } } @@ -199,12 +270,17 @@ fn form_window(app: &mut FigApp, ctx: &Context) { let mut open = true; let mut submit = false; let mut cancel = false; + let mut pick = None; let label = command.label.trim_end_matches(['…', '.']).trim(); let title = if label.contains(name.as_str()) { label.to_string() } else { format!("{name}: {label}") }; window(&title, ctx).id(Id::new("plugin_form")).open(&mut open).show(ctx, |ui| { ui.set_width(320.0); - if let Some((name, v)) = controls(ui, &format!("{}/{}", form.id, form.command), &command.params, &form.values, &t) { - form.values.insert(name, v); + match controls(ui, &format!("{}/{}", form.id, form.command), &command.params, &form.values, &t) { + Some(Used::Set(name, v)) => { + form.values.insert(name, v); + } + Some(Used::Pick(control)) => pick = Some(control), + None => {} } ui.add_space(12.0); ui.horizontal(|ui| { @@ -213,15 +289,17 @@ fn form_window(app: &mut FigApp, ctx: &Context) { }); }); app.ui.plugin_form = (open && !submit && !cancel).then_some(form.clone()); + if let Some(control) = pick { + app.ui.picker = Some(PickerTarget::PluginField { plugin: form.id.clone(), control, pending: None }); + } if submit { run(app, "plugin.run", json!({"id": form.id, "command": form.command, "params": form.values})); } } /// Parameters or panel controls in two columns (label, field); buttons in a row share one line, -/// the first button filled. Returns the control the user changed or pressed: its name and new -/// value (null for a button). -fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map, t: &Tokens) -> Option<(String, Value)> { +/// the first button filled. Returns what the user did to which control. +fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map, t: &Tokens) -> Option { let mut changed = None; let mut first_button = true; egui::Grid::new(("plugin_controls", salt)).num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { @@ -246,13 +324,13 @@ fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map { ui.horizontal(|ui| { let pressed = row.iter().filter(|b| widgets::button(ui, b.title(), std::mem::take(&mut first_button), true).clicked()); - pressed.last().map(|b| (b.name.clone(), Value::Null)) + pressed.last().map(|b| Used::Set(b.name.clone(), Value::Null)) }) .inner } _ => { let current = values.get(&p.name).cloned().unwrap_or_else(|| p.default_value()); - field(ui, Id::new(("plugin_control", salt, &p.name)), p, ¤t).map(|v| (p.name.clone(), v)) + field(ui, Id::new(("plugin_control", salt, &p.name)), p, ¤t) } }; if hit.is_some() { @@ -264,10 +342,10 @@ fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map Option { +/// One parameter's field, named for screen readers: its new value when it changed. +fn field(ui: &mut Ui, id: Id, p: &Param, current: &Value) -> Option { let name = p.title(); - match p.kind { + let v = match p.kind { ParamKind::Text => widgets::text_field_named(ui, id, current.as_str().unwrap_or_default(), FIELD_W, name).map(Value::String), ParamKind::Number => match widgets::number_field(ui, id, Lead::Named(name), current.as_f64(), "", FIELD_W, 1.0)? { FieldEdit::Set(v) | FieldEdit::Scrub(v, true) => p.coerce(&json!(v)).ok(), @@ -303,17 +381,22 @@ fn field(ui: &mut Ui, id: Id, p: &Param, current: &Value) -> Option { }) .inner } - // shortcut: a swatch and a hex field, no colour picker popover; hook the picker up when - // plugins need more than hex. + // A swatch that opens the colour picker, and the hex. ParamKind::Color => { let hex = current.as_str().unwrap_or_default(); - ui.horizontal(|ui| { - let color = figcraft_color::Color::from_hex(hex).unwrap_or(figcraft_color::Color::BLACK); - widgets::swatch(ui, &figcraft_color::Paint::solid(color), widgets::FIELD_H); - widgets::text_field_named(ui, id, hex, FIELD_W - widgets::FIELD_H - 8.0, name).and_then(|s| p.coerce(&json!(s)).ok()) - }) - .inner + let (picked, typed) = ui + .horizontal(|ui| { + let color = figcraft_color::Color::from_hex(hex).unwrap_or(figcraft_color::Color::BLACK); + let picked = widgets::swatch(ui, &figcraft_color::Paint::solid(color), widgets::FIELD_H).clicked(); + (picked, widgets::text_field_named(ui, id, hex, FIELD_W - widgets::FIELD_H - 8.0, name).and_then(|s| p.coerce(&json!(s)).ok())) + }) + .inner; + if picked { + return Some(Used::Pick(p.name.clone())); + } + typed } ParamKind::Button | ParamKind::Note => None, - } + }; + v.map(|v| Used::Set(p.name.clone(), v)) } diff --git a/docs/gaps.md b/docs/gaps.md index f97daf1..4da81c1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -19,7 +19,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) | | 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) | | 5 | Localization: English plus unreviewed Spanish, French, German and Brazilian Portuguese drafts; no Japanese or Korean | Localization | 40–65 | [localization-parity](localization-parity.md) | -| 6 | Plugins: no distribution or widgets; Figma's plugins don't run | Ecosystem | 15–40 | [target-app-parity](target-app-parity.md) | +| 6 | Plugins: no distribution, widgets take clicks only; Figma's plugins don't run | Ecosystem | 10–30 | [target-app-parity](target-app-parity.md) | | 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) | | 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | | 9 | Figma Draw: pen pressure on macOS/Linux tablets (multi-edit, brushes, pressure, text on a path and pattern fills landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) | @@ -116,7 +116,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe [`localization-parity.md`](localization-parity.md); how to add one in [`translating.md`](translating.md). -### 6. Plugins: no distribution or widgets; Figma's plugins don't run +### 6. Plugins: no distribution, widgets take clicks only; Figma's plugins don't run - **Landed (#101):** sandboxed WebAssembly plugins (wasmi, metered, memory cap, no WASI, also in the browser) acting through the command registry, one undo step per run, manifest permissions, Plugins menu, Run last plugin, Manage plugins, `plugin.*` commands, a Rust SDK and two examples @@ -124,13 +124,20 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe panel of fields and buttons drawn with FigCraft's widgets and is called back for each button or changed value (one undo step per callback, same sandbox limits), driven headless with `plugin.ui` / `plugin.event` / `plugin.close`; per-plugin settings kept with the preferences. -- **Missing:** distribution (a plugin directory), widgets, Community files; panels are one at a - time with no colour picker or images. Figma's own plugins are JavaScript against Figma's plugin - API and don't run. + Widgets (plugin API 3, 2026-10-12): a plugin draws a layer of its own on the canvas (Plugins ▸ + Widgets, `widget.insert`), validated and built with locked layers inside, called back for clicks + on the parts it names (one undo step each, a fresh metered sandbox, the state kept in the layer + so it saves, copies, undoes, exports and syncs), `widget.click` / `widget.list` for agents, a + Counter example; panels of several plugins side by side; the colour picker on plugin colour + fields. +- **Missing:** distribution (a plugin directory), Community files; widgets take only clicks (no + text input, property menus, hover or timers) and draw only frames, text, shapes and images; + no images or sliders in panels. Figma's own plugins are JavaScript against Figma's plugin API and + don't run. - **Impact:** teams that depend on specific Figma plugins (icons, content, accessibility checks) can't bring them. FigCraft's CLI, control channel and MCP server cover agent automation better than Figma does. -- **Estimate:** 15–40 h for the missing pieces of FigCraft's own API. Running Figma's plugins +- **Estimate:** 10–30 h for the missing pieces of FigCraft's own API. Running Figma's plugins needs a JS engine and an API shim: **owner decision needed**. ### 7. CPU canvas where Figma uses the GPU @@ -327,6 +334,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Plugin widgets (API 3) and the panel tail (panels side by side, colour picker on plugin fields) added to gap 6's landed list; gap 6 now 10–30 h; ranking unchanged | | 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Gap 5 45–75 h → 40–65 h, rank unchanged | | 2026-10-12 | minor | Cursor chat and spotlight in live sessions (gap 2): `/` opens a chat bubble at your pointer, a spotlight makes everyone follow your view; ephemeral presence the host validates and rate-limits. Gap 2 45–90 h → 42–84 h | | 2026-10-12 | minor | Layers and Assets panels behaviour suite and fixes added to gap 4's landed list; ranking and hours unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 8d55b72..8b92b50 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -920,8 +920,9 @@ some commands, and can never touch files, other documents, the undo history, dia plugins. A headless MCP session starts with no plugins: import them first. `plugins.runLast` runs the last plugin command again. -A plugin that shows a panel (API 2) can be used headless too: `plugin.ui {id?}` returns the open -panel (`{plugin, command, title, controls, values}`, or null), `plugin.event {id, control, value?}` +A plugin that shows a panel (API 2) can be used headless too: `plugin.ui {id?}` returns plugin +`id`'s open panel, or without `id` the one opened last (`{plugin, command, title, controls, +values}`, or null; several plugins can have panels open at once), `plugin.event {id, control, value?}` presses a button or sets a field as a click would (the plugin is called back; one undo step; returns like `plugin.run`, with the updated `panel`), and `plugin.close {id}` ends the session. Plugin settings are kept per plugin with the preferences (in a headless session, for the session). @@ -932,6 +933,19 @@ Plugin settings are kept per plugin with the preferences (in a headless session, {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.event","params":{"id":"org.figcraft.examples.swatch-grid","control":"generate"}}}} ``` +Widgets (API 3, layers on the canvas a plugin draws) are driven the same way: `widget.insert +{plugin, x?, y?}` puts one on the page (centred in the view without a position) and returns `{id, +plugin, state, targets, output, logs, calls, fuel}`; `widget.click {id, target}` clicks one of its +`targets` as a click on the canvas would (the plugin redraws it; one undo step; returns the same); +`widget.list` lists every widget in the file with its plugin, page, state and targets. A widget's +state is layer data, so it is saved, undone and synced with the file. + +```json +{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.insert","params":{"plugin":"org.figcraft.examples.counter"}}}} +{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.click","params":{"id":12,"target":"increment"}}}} +{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.list","params":{}}}} +``` + ### Dev Mode - **Component code:** `inspect.code {id, language: react|swiftui|compose}` on a component, a diff --git a/docs/plugins.md b/docs/plugins.md index 4979833..0ecd4f8 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -16,6 +16,9 @@ permissions its manifest asks for, and nothing more. - **Panels and settings (API 2).** A plugin can show a small panel of its own while it runs: FigCraft draws its fields and buttons with its own widgets and calls the plugin back when one is used. A plugin can also keep settings from one run to the next. +- **Widgets (API 3).** A plugin can put a layer of its own on the canvas: it describes the layer + (frames with auto layout, text, shapes, images), FigCraft builds it, and a click on one of its + parts calls the plugin back to redraw it from its state, which is kept in the document. - **Permissions.** The manifest says whether the plugin only reads the document or may edit it, and can narrow the commands it may call. FigCraft enforces both. @@ -31,18 +34,32 @@ permissions its manifest asks for, and nothing more. Ctrl+Alt+P is Pixel preview) runs the last plugin command again with the same parameters. - **Plugins ▸ Manage plugins…** lists the installed plugins (version, access, manifest path) with a Remove button for each, and an Import button. +- **Plugins ▸ Widgets** lists the installed widget plugins; picking one puts its widget in the + middle of the view. A message the plugin shows (`notify`) appears as a toast; so does a text result. **Plugin panels.** A plugin written for API 2 can show a panel while it runs: a floating window, first placed beside the properties panel (drag its title bar to move it), with the plugin's notes, fields (text, number, checkbox, menu, list, colour) and buttons, drawn with FigCraft's own widgets -and named for screen readers. Pressing a button or changing a field calls the plugin back with every -field's value; what that callback does is one undo step (none when it changes nothing), and the -plugin can update the panel or close it. The module stays loaded while its panel is open, so the -plugin keeps its state between callbacks. Closing the window ends the plugin's session. One plugin -panel is open at a time: running another plugin that shows one replaces it. An open panel costs -nothing while nobody uses it (no frames are drawn). +and named for screen readers. A colour field's swatch opens FigCraft's colour picker (the plugin hears +of the colour when a drag in the picker ends); its hex can also be typed. Pressing a button or +changing a field calls the plugin back with every field's value; what that callback does is one +undo step (none when it changes nothing), and the plugin can update the panel or close it. The +module stays loaded while its panel is open, so the plugin keeps its state between callbacks. +Closing the window ends the plugin's session. Each plugin has at most one panel, and the panels of +different plugins open side by side (leftwards from the first, three to a row); +running a plugin again replaces its own panel. An open panel costs nothing while nobody uses it (no +frames are drawn). + +**Widgets.** A widget is a frame on the canvas that belongs to its plugin. It moves, copies, +deletes, exports and undoes like any layer, as one layer: the layers inside it are locked. Clicking +one of its parts that the plugin made clickable (with the Move tool; the pointer turns into a hand +over them) calls the plugin back, which redraws the widget; that click is one undo step. Dragging +moves the widget instead, and Shift- or Cmd-clicks only select. Each widget keeps its plugin's id +and its state (a small JSON object) in its own layer data, so a saved file, a copy, an undo step or +a live session carries the widget as it is, and a copy is a widget of its own. A widget whose +plugin isn't installed stays as it was drawn; clicking it says the plugin is missing. If a callback fails, it is rolled back and its message shown; a plugin that returned an error keeps its panel, one that crashed or ran out of fuel or memory loses it. @@ -58,6 +75,10 @@ From scripts, the CLI and MCP the same commands apply, panels included: {"command": "plugin.event", "params": {"id": "org.figcraft.examples.swatch-grid", "control": "palette", "value": "Ocean"}} {"command": "plugin.event", "params": {"id": "org.figcraft.examples.swatch-grid", "control": "generate"}} {"command": "plugin.close", "params": {"id": "org.figcraft.examples.swatch-grid"}} +{"command": "plugin.import", "params": {"path": "target/plugins/dist/counter"}} +{"command": "widget.insert", "params": {"plugin": "org.figcraft.examples.counter", "x": 0, "y": 0}} +{"command": "widget.list", "params": {}} +{"command": "widget.click", "params": {"id": 12, "target": "increment"}} ``` `figcraft-cli run script.jsonl --in design.figcraft --out tidy.figcraft` runs that headless; over @@ -70,11 +91,14 @@ plugins: import them in the script. | `plugin.import` | `{path}` or `{manifest, wasm}` | `path` is a `figcraft-plugin.json` or its folder. Without files (the browser, tests), pass the manifest (an object or JSON text) and the module as base64 `wasm`; such a plugin lasts for the session only. A plugin with the same id is replaced. The module is checked first. | | `plugin.remove` | `{id}` | Uninstalls it. | | `plugin.run` | `{id, command?, params?, fuel?}` | Runs a command (default: the first) with its parameters checked against the manifest and the missing ones filled with their defaults; `fuel` lowers the instruction budget (for the panel's callbacks too). Returns `{plugin, command, output, logs, calls, fuel, panel}`; `output` is the plugin's result, parsed as JSON when it is JSON; `panel` is the plugin's open panel (as `plugin.ui` returns it) or null. | -| `plugin.ui` | `{id?}` | The open plugin panel: `{plugin, command, title, controls, values}` (`values`: every field's current value by name), or null. With `id`, only that plugin's. | +| `plugin.ui` | `{id?}` | Plugin `id`'s open panel: `{plugin, command, title, controls, values}` (`values`: every field's current value by name), or null. Without `id`, the panel opened last. | | `plugin.event` | `{id, control, value?}` | Uses a control of plugin `id`'s open panel, as a click would: a button (no `value`), or a field set to `value` (checked like a parameter: a number is clamped, a list or menu value must be one of its options, a colour must be hex). The plugin is called back; returns like `plugin.run`. | | `plugin.close` | `{id}` | Closes plugin `id`'s panel and ends its session. `{closed}` says whether one was open. | | `plugins.runLast` | `{}` | Runs the last plugin command again. | | `plugins.manage` | `{}` | Opens the Plugins window; returns the list like `plugin.list`. | +| `widget.insert` | `{plugin, x?, y?, fuel?}` | Puts widget plugin `plugin`'s widget on the current page, its top-left at `x`, `y` (default: centred in the view), and selects it; the plugin draws it. One undo step. Returns `{id, plugin, state, targets, output, logs, calls, fuel}`: `targets` are the names of its clickable parts. | +| `widget.click` | `{id, target, fuel?}` | Clicks part `target` of widget `id` (or of the widget a layer `id` is in), as a click on the canvas would: the plugin is called back and redraws it. One undo step; returns like `widget.insert`. | +| `widget.list` | `{}` | `{widgets: [{id, plugin, name, page, state, targets}]}`: every widget in the file. | ## The manifest: `figcraft-plugin.json` @@ -110,10 +134,11 @@ plugins: import them in the script. | `name` | Required. Shown in the Plugins menu (64 characters at most). | | `version`, `description` | Optional text. | | `main` | Required. The module, a `.wasm` path relative to the manifest, inside its folder. | -| `api` | The plugin API version the plugin was written for: `1` (the default) or `2` (panels and settings). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels fails clearly on an older FigCraft. | +| `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings) or `3` (widgets). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels or widgets fails clearly on an older FigCraft. | +| `widget` | API 3: `true` when the plugin draws a widget (it's listed in Plugins ▸ Widgets). | | `permissions.access` | `read` (the default): commands that leave the document as it is; such plugins also run from Dev Mode's Inspect tab. `write`: edits too. | | `permissions.commands` | Optional allowlist: exact command ids or prefixes ending in `*` (`node.*`). Empty: any command the access allows. | -| `commands` | The menu commands, each `{id, label, params?}`. Without any, the plugin has one, `run`. | +| `commands` | The menu commands, each `{id, label, params?}`. Without any, the plugin has one, `run`, unless it's a widget (a widget needs no command). | | `params[]` | `{name, label?, type, default?, options?, min?, max?}`; `type` is `text`, `number` (clamped to `min`/`max`), `boolean`, `select` (one of `options`, a menu), `list` (one of `options`, shown as a list) or `color` (hex, `#RRGGBB` or `#RRGGBBAA`). A default that doesn't fit is dropped. | ## What a plugin can't do @@ -126,7 +151,9 @@ it can handle): - other documents and the version history: `doc.*` and `version.*` (except `doc.list` and `version.list`); - the undo history and nesting: `edit.undo`, `edit.redo`, `edit.repeat`, `command.batch` (a run is - already one undo step), and `plugin.*` / `plugins.*` (no plugin runs another); + already one undo step), `plugin.*` / `plugins.*` (no plugin runs another) and `widget.*`; +- writing layer data whose key starts with `figcraft.widget` (a widget's own data: no plugin can + forge a widget or change another one's state); - commands that open a dialog or panel (for example `object.rename` without a `name`, which would start renaming in the layers panel): the call is undone and refused; - for a `read` plugin, any command that changes the document (checked before the call for commands @@ -136,12 +163,14 @@ Limits per run, and again per panel callback: 1,000,000,000 fuel (about a second WebAssembly in a debug build, far less in a release build), 64 MB of memory, 100,000 host calls (each also costs 10,000 fuel), 16 MB per string either way, 1,000 log lines, and modules up to 16 MB. A panel spec is at most 64 KB with 64 controls; settings are at most 64 KB per plugin (keys -and JSON values), keys 1–128 characters. +and JSON values), keys 1–128 characters. A widget callback gets the same limits; its description is +at most 4 MB with 256 layers nested 16 deep, its state at most 16 KB (keys 1–128 characters), and +its images 1 MB each, 2 MB together and 4096 pixels on a side. -## ABI (version 2) +## ABI (version 3) -Version 2 adds panels and settings to version 1; a version 1 module runs unchanged and gets the -input it always got. Strings are UTF-8 `(ptr, len)` pairs in the plugin's linear memory; all values +Version 2 adds panels and settings to version 1, and version 3 adds widgets; a version 1 or 2 +module runs unchanged and gets the input it always got. Strings are UTF-8 `(ptr, len)` pairs in the plugin's linear memory; all values are `i32`. The module **exports** `memory` and `figcraft_run(input_len) -> status`. When `figcraft_run` starts, @@ -158,6 +187,15 @@ same instance (its memory as the last call left it) with the event and every fie {"api": 2, "command": "panel", "params": {}, "selection": [], "event": {"control": "generate", "value": null}, "values": {"palette": "Ocean", "columns": 3, "labels": true}} ``` +When its widget is inserted or clicked, `figcraft_run` runs in a fresh instance (nothing is kept +from one call to the next: a widget's state is all it remembers, so the same state and click +always give the same widget) with `widget` added: the widget's layer id, its state (an empty object +for a new widget) and what was clicked (`event` is null when it's inserted): + +```json +{"api": 3, "command": "widget", "params": {}, "selection": [], "widget": {"id": 12, "state": {"count": 2}, "event": {"target": "increment"}}} +``` + It returns `0` for success; anything else fails the run (and rolls it back), with the text passed to `set_output` as the error message. @@ -178,6 +216,7 @@ included, is refused when the plugin is imported): | `setting_get` | `(key_ptr, key_len) -> status` | API 2. `0`: the reply holds the setting's JSON. `1`: there is none. | | `setting_set` | `(key_ptr, key_len, value_ptr, value_len) -> status` | API 2. Stores JSON under the key, for this plugin only, kept with the preferences. `1`: refused (not JSON, a bad key, over 64 KB), the reply says why. | | `setting_delete` | `(key_ptr, key_len)` | API 2. Forgets a setting. | +| `widget_render` | `(ptr, len) -> status` | API 3. Draws the widget being inserted or clicked (the widget description below, as JSON). `1`: the description is invalid, the reply says why. A widget call that doesn't draw keeps the widget as it was (inserting one must draw it). | A pointer or length outside the plugin's memory traps, which fails the run. Host functions can only be called while `figcraft_run` runs (not from a start function). Settings are forgotten when the @@ -205,20 +244,49 @@ a note without resetting the other fields. Buttons next to each other share a ro is the primary one. FigCraft checks the whole spec (sizes, kinds, options, bounds) and drops values that don't fit their field. +### Widget description + +```json +{"state": {"count": 2}, "root": {"type": "frame", "layout": "horizontal", "gap": 12, "padding": 12, "radius": 12, "fill": "#FFFFFF", "stroke": "#D9D9D9", "children": [ + {"type": "frame", "target": "decrement", "layout": "horizontal", "justify": "center", "width": 32, "height": 32, "radius": 8, "fill": "#F0F0F0", + "children": [{"type": "text", "text": "−", "size": 16, "bold": true}]}, + {"type": "text", "name": "Count", "text": "2", "size": 24, "bold": true, "color": "#1E1E1E"}, + {"type": "image", "data": "iVBORw0KGgo…", "width": 24, "height": 24} +]}} +``` + +`state` is the widget's new state, a JSON object (left out: it keeps the one it has). `root` is the +widget itself, a frame; FigCraft replaces everything inside it with the layers described and keeps +the widget where it is (and its size, unless the description gives one). Every layer has a `type` +(`frame`, `text`, `rectangle`, `ellipse`, `image`) and may have a `name`, a `target` (clicking it +calls the plugin back with that name: letters, digits, `.`, `-`, `_`), `x` and `y` (in its frame, +ignored in auto layout), `width` and `height`, `fill` and `stroke` (hex colours), `strokeWeight`, +`radius` (frames, rectangles, images) and `opacity`. A frame may have `layout` (`horizontal` or +`vertical`), `gap`, `padding`, `justify` and `align` (`min`, `center`, `max`: along and across the +layout) and `children`; +with auto layout it hugs its contents on an axis without a size. A text has `text`, `size`, +`color` and `bold` (with a `width` it wraps). An image has `data`, the image's bytes (PNG, JPEG…) +as base64, usually carried in the module itself; without a size it takes the image's. +FigCraft checks the whole description (kinds, sizes, numbers, colours, lengths, counts, depth, +image sizes) and refuses it if anything is off. + ## Writing a plugin in Rust `plugins/` in the repository is a workspace of its own: `plugins/sdk` (the crate `figcraft-plugin-sdk`, `no_std` with `alloc`) wraps the ABI with serde_json, and -`plugins/examples` has two plugins: +`plugins/examples` has three plugins: - **Tidy names** (`tidy-names`): cleans up the selected layers' names (extra spaces, the "copy" left by duplicating) and optionally their letter case, inside the selection too. Two commands: one with a form (case, include layers inside), and a quick one without. - **Swatch grid** (`swatch-grid`, API 2): a grid of colour swatches, centred in the view, named after their colours and optionally labelled. **Palettes…** opens a panel with a list of built-in - palettes (picking one shows its colours), columns, labels and a Generate button; the palette + palettes (picking one shows its colours), columns, labels and their colour, and a Generate + button; the palette picked last is remembered as a setting for next time. **From hex colours…** takes a palette of hex colours in the parameters form. +- **Counter** (`counter`, API 3): a widget, a count between − and + buttons; each click changes the + count kept in the widget's state. It only reads the document. `cargo xtask plugins` (part of `cargo xtask wasm` and `cargo xtask ci`; it needs the `wasm32-unknown-unknown` target: `rustup target add wasm32-unknown-unknown`) checks, tests and @@ -273,8 +341,10 @@ names it, and import the manifest. The SDK gives `call`, `selection`, `log`, `no `Input` (`command`, `params` with `str`/`f64`/`bool` helpers, `selection`, and `event` when the panel calls back: `control`, `value` and every field's `values`); returning `Err` fails the run and rolls it back. With `"api": 2` in the manifest: `show_ui(&json!({...}))` and `close_ui()` for the -panel, `setting(key)`, `set_setting(key, &value)` and `delete_setting(key)` for settings. Outside WebAssembly every host call fails, so the plugin's own logic can -be unit-tested with `cargo test`. +panel, `setting(key)`, `set_setting(key, &value)` and `delete_setting(key)` for settings. With +`"api": 3` and `"widget": true`: `input.widget` (`id`, `state`, `target`) when the widget is +inserted or clicked, and `render_widget(&json!({"state": ..., "root": ...}))` to draw it. Outside +WebAssembly every host call fails, so the plugin's own logic can be unit-tested with `cargo test`. Any language that compiles to WebAssembly works the same way: export `memory` and `figcraft_run`, import what you need from `figcraft`. @@ -283,9 +353,11 @@ import what you need from `figcraft`. - Distribution: no plugin directory, signing or automatic updates; plugins are imported from a manifest. -- Widgets (objects on the canvas drawn by a plugin) and plugins written for other apps (JavaScript - plugins): neither runs. -- Panels: one at a time; a colour field is a swatch with a hex field (no colour picker); no images, - sliders or free layout inside a panel. +- Plugins written for other apps (JavaScript plugins) don't run. +- Widgets: clicks are their only events (no text input, hover, property menu or timers), they + can't open a panel, and their layers are frames, text, rectangles, ellipses and images only (no + vectors, components or variables). Clicks reach them in the editor, not in a prototype being + presented. +- Panels: no images, sliders or free layout inside a panel. - Data in the document: plugins keep data on layers (pages included) with `node.set {props: {pluginData: {key: value}}}`; there is no separate per-document store. diff --git a/docs/roadmap.md b/docs/roadmap.md index 82171a4..67c6f17 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -57,7 +57,7 @@ blockers. | M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 | | M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 | | M10 Localization (new) | not started | layer + 11 languages | 60–100 | -| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2) | distribution, widgets; Figma plugin compatibility is an owner decision | 15–40 | +| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields | distribution, richer widget events; Figma plugin compatibility is an owner decision | 10–30 | | M12 AI features (new, owner decision) | not started | local models | 30–60 | ## Next, after the current focus @@ -72,6 +72,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M11: plugin widgets (API 3: plugin-drawn layers on the canvas, redrawn on clicks from state kept in the document), panels of several plugins side by side and the colour picker on plugin colour fields; 15–40 h → 10–30 h | | 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Current focus 4 now lists Japanese and Korean | | 2026-10-11 | minor | `.fig` import, best effort: current focus 1 and the alpha gate's `.fig` row move to the import's tail (30–60 h → 8–15 h); M6 ~55% → ~65%, 37–75 h → 15–30 h | | 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f3fa5bc..75b2ccc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -21,7 +21,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c | Essentials user | **~57%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.88) | | Mainstream practitioner | **~49%** | estimated ([below](#mainstream-practitioner): weekly areas 72.6% × discounts 0.9 × 0.9 × 0.85) | | Remaining to beta | **~162–292 h** one Opus 5.5 agent (~33–67 h wall-clock with 5 parallel agents) | estimated | -| Remaining to full parity | **~281–548 h** one agent (~85–148 h wall-clock with 5 parallel agents) | estimated | +| Remaining to full parity | **~276–538 h** one agent (~85–148 h wall-clock with 5 parallel agents) | estimated | | Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | | The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were @@ -35,7 +35,7 @@ tool; presence was being graded as behaviour in places. | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~52% | ~286–550 h | multiplayer (42–84), localization (40–65), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | +| Full target (ready for real work) | ~52% | ~281–540 h | multiplayer (42–84), localization (40–65), plugins (10–30), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | | Mainstream practitioner | ~49% | ~130–244 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | | Essentials user | ~57% | ~60–110 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) | @@ -111,11 +111,11 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 15 | Dev Mode / inspect | 2 | 96% | 72% | Import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode and dev resources links landed) ([dev-mode-parity](dev-mode-parity.md)) | 0–1 | | 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 | | 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 | -| 18 | Plugins & widgets | 4 | 45% | 28% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK, 2 examples), then plugin panels called back per button or changed value and per-plugin settings (ABI v2). Left: distribution, widgets; panels one at a time without a colour picker; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 15–40 | +| 18 | Plugins & widgets | 4 | 55% | 32% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields. Left: distribution; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 10–30 | | 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | | 20 | Figma Draw extras | 2 | 88% | 60% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses), multi-edit (several vector layers in edit mode at once). Missing: pressure from macOS/Linux tablets | 1–3 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | -| | **Weighted (÷113)** | 113 | **~84%** | **~63%** | | **125–270** | +| | **Weighted (÷113)** | 113 | **~84%** | **~63%** | | **120–260** | | | Core editor only (1–16, ÷94) | 94 | ~94% | ~72% | | 33–75 | ## By dimension (ready for real work) @@ -131,9 +131,9 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Hardware | 3 | 60% | 10–21 | Trackpad pinch and HiDPI yes; pen pressure on Windows and from browser styluses (none from macOS/Linux tablets); several windows (screen-reader tree in the main one only); no GPU canvas, no P3 on screen | [hardware-parity](hardware-parity.md) | | Localization | 3 | 30% | 40–65 | The layer, an English catalog and complete machine drafts of Spanish, French, German and Brazilian Portuguese (none reviewed by a native speaker); Figma's Japanese, Korean and Latin American Spanish are missing; engine messages stay English | [localization-parity](localization-parity.md) | | Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | -| Ecosystem/plugins | 3 | 28% | 15–40 | Own WASM plugins with a Rust SDK and two examples (#101), plugin panels and settings (ABI v2); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | +| Ecosystem/plugins | 3 | 34% | 10–30 | Own WASM plugins with a Rust SDK and three examples (#101), plugin panels and settings (ABI v2), widgets that take clicks (API 3); no Figma plugin compatibility, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | | AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | -| **Ready for real work** | 100 | **~52%** | **~281–548** | | | +| **Ready for real work** | 100 | **~52%** | **~276–538** | | | Dimension hours add up to the full-parity total without double counting: the feature-area rows for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are @@ -496,8 +496,13 @@ Hours in this narrative are the builders' estimates; the tables above supersede plugins (Tidy names, Swatch grid) in `plugins/`, built by `cargo xtask plugins`. Then plugin panels (ABI v2: fields and buttons drawn with FigCraft's widgets, the plugin called back for each button or changed value as one undo step, `plugin.ui` / `plugin.event` / `plugin.close` - for scripts and MCP) and per-plugin settings kept with the preferences. *Left:* plugin - distribution; for collaboration, the browser client and a standalone server. 15–40 h. + for scripts and MCP) and per-plugin settings kept with the preferences. Then widgets (API 3: + a plugin describes a layer of its own, validated and built as a frame with locked layers; clicks + on its named parts call the plugin back in a fresh sandbox with the state kept in the layer, one + undo step each; `widget.insert` / `widget.click` / `widget.list`; a Counter example), panels of + several plugins side by side and the colour picker on plugin colour fields. *Left:* plugin + distribution; richer widget events; for collaboration, the browser client and a standalone + server. 10–30 h. 9. **Hardening and performance**: *landed:* `cargo xtask bench` (synthetic 1k/10k/50k-layer pages: engine, render and headless UI-frame timings). The canvas keeps its last image and re-renders only what a pan uncovers or an edit touches: moving one layer at 10k layers re-renders @@ -612,6 +617,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Plugin widgets and the panel tail (gap 6, plugin API 3; v1 and v2 plugins unchanged): a plugin marked as a widget puts a layer of its own on the canvas from Plugins ▸ Widgets or `widget.insert`: it describes the layer (frames with auto layout, text, rectangles, ellipses, images it carries) and FigCraft validates the description (size, layer count, depth, numbers, colours, text and image sizes) and builds it as a frame with locked layers inside; a click on a part the plugin named (on the canvas with the Move tool, a pointing hand over it, or `widget.click`) calls the plugin back in a fresh, metered sandbox with the widget's state, as one undo step, and the widget is redrawn; the state lives in the widget's own layer data, so widgets survive save and reload, copying, undo, export and live sessions; no plugin can write that data or call `widget.*`. `widget.list` lists them. A Counter example widget. Plugin panels of different plugins open side by side, and a colour field's swatch opens the colour picker (the plugin hears of the colour when a drag ends). Plugins & widgets 45%/28% → 55%/32%, 15–40 h → 10–30 h; Ecosystem dimension 28% → 34%; weighted totals unchanged at this rounding; full parity ~286–558 h → ~281–548 h | | 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Localization 20% → 30%, 45–75 h → 40–65 h; ready for real work unchanged (~52%; 52.6 → 52.9); full parity ~289–564 h → ~284–554 h; beta ~170–305 h → ~162–292 h (Figma's languages left on the way to beta: Japanese and Korean, 7–12 h) | | 2026-10-12 | minor | Cursor chat and spotlight in live sessions: `/` opens a chat bubble at your pointer that everyone sees as you type and that fades a few seconds after the last change; a spotlight (your avatar's menu, `collab.spotlight`) makes everyone follow your view and page, latecomers included, until they move their own. Both are ephemeral presence the host validates and rate-limits, passed only to clients that read them. Multiplayer & sharing breadth 15% → 17%, ready unchanged (5%), 45–90 h → 42–84 h; the Collaboration dimension's hours 55–110 → 52–104 h (the dimension hours still add up to the total); feature breadth (~84%) and ready for real work (~51%) unchanged at this rounding; full parity ~301–587 h → ~298–581 h | | 2026-10-12 | minor | Layers and Assets panels behaviour suite written from the public documentation (6 engine tests with about 50 table rows and 5 panel tests driven through the UI; about 30 rows that the code before the fixes contradicts) and its fixes: several layers dropped together in the layers panel land together (layers from apart split around the drop point); Collapse layers keeps the selection's rows open (it closed everything); the eye and padlock stay under the pointer (they vanished as it reached them, so clicks could miss) and dragging across them sets several layers in one undo step; several layers selected open a Rename layers dialog (match as text or a regular expression, Rename to, current name and number buttons, a start value, preview) instead of renaming the top one; `object.batchRename` counts `$n` up and `$N` down (they were swapped), pads with `$nnn`, takes `start`, regular expressions with `$1`…`$9`, `` $` `` / `$'` and codes in the replacement; pages are dragged to reorder, a blank page named with a hyphen is a divider (never opened, holds no layers), duplicates are named "Copy of …", the last page that isn't a divider can't be deleted; the Assets panel files components by page, frame and slash names in folders that fold away (`component.list` gives each `folder`); a leading `.` or `_` now keeps a component out of the library even after it was published, and removing it brings the component back. Pinned where the documentation is silent: Alt-click on an arrow, numbering in stacking order, hidden layers and locked frames taking drops, locking deselects. Layers panel 98%/82% → 99%/85%; percentages and full parity hours unchanged at this rounding | diff --git a/plugins/Cargo.lock b/plugins/Cargo.lock index fb96261..7738ceb 100644 --- a/plugins/Cargo.lock +++ b/plugins/Cargo.lock @@ -2,6 +2,13 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "counter" +version = "0.1.0" +dependencies = [ + "figcraft-plugin-sdk", +] + [[package]] name = "figcraft-plugin-sdk" version = "0.1.0" diff --git a/plugins/examples/counter/Cargo.toml b/plugins/examples/counter/Cargo.toml new file mode 100644 index 0000000..49603cb --- /dev/null +++ b/plugins/examples/counter/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "counter" +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/counter/figcraft-plugin.json b/plugins/examples/counter/figcraft-plugin.json new file mode 100644 index 0000000..a046ebc --- /dev/null +++ b/plugins/examples/counter/figcraft-plugin.json @@ -0,0 +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.", + "main": "counter.wasm", + "api": 3, + "widget": true, + "permissions": { "access": "read" } +} diff --git a/plugins/examples/counter/src/lib.rs b/plugins/examples/counter/src/lib.rs new file mode 100644 index 0000000..d149a53 --- /dev/null +++ b/plugins/examples/counter/src/lib.rs @@ -0,0 +1,59 @@ +//! 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. + +use figcraft_plugin_sdk::{Input, Result, Value, entry, json, render_widget}; + +/// The count stays within ± this. +const LIMIT: i64 = 999_999; + +/// 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) +} + +/// 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"}]}) +} + +/// 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", "+"), + ]}) +} + +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})) +} + +entry!(run); + +#[cfg(test)] +mod tests { + use super::*; + + #[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"); + assert!(run(Input::default()).is_err(), "it only runs as a widget"); + } +} diff --git a/plugins/examples/swatch-grid/figcraft-plugin.json b/plugins/examples/swatch-grid/figcraft-plugin.json index 42f860c..4712ea9 100644 --- a/plugins/examples/swatch-grid/figcraft-plugin.json +++ b/plugins/examples/swatch-grid/figcraft-plugin.json @@ -1,7 +1,7 @@ { "id": "org.figcraft.examples.swatch-grid", "name": "Swatch grid", - "version": "1.1.0", + "version": "1.2.0", "description": "Makes a grid of colour swatches from a built-in palette (in a panel) or from hex colours.", "main": "swatch_grid.wasm", "api": 2, diff --git a/plugins/examples/swatch-grid/src/lib.rs b/plugins/examples/swatch-grid/src/lib.rs index 1826b70..15f572a 100644 --- a/plugins/examples/swatch-grid/src/lib.rs +++ b/plugins/examples/swatch-grid/src/lib.rs @@ -4,6 +4,9 @@ use figcraft_plugin_sdk::{Event, Input, Result, Value, call, close_ui, entry, json, log, notify, set_setting, setting, show_ui}; +/// The labels' colour unless the panel picks another. +const LABEL: &str = "#000000"; + /// Swatches made in one run, at most. const MAX_SWATCHES: usize = 256; @@ -49,15 +52,16 @@ fn named(name: &str) -> &'static str { PALETTES.iter().find(|(n, _)| *n == name).map_or("", |(_, c)| c) } -/// Swatches of `colors` in a grid centred in the view (labelled when `labels`), then selected. -fn grid(colors: &[String], columns: f64, size: f64, gap: f64, labels: bool) -> Result { +/// Swatches of `colors` in a grid centred in the view (labelled in colour `labels` when given), +/// then selected. +fn grid(colors: &[String], columns: f64, size: f64, gap: f64, labels: Option<&str>) -> Result { if colors.is_empty() { return Err("The palette has no hex colours (like #FF5A5F, #00A699)".into()); } let columns = columns.clamp(1.0, 24.0) as usize; let (size, gap) = (size.clamp(8.0, 512.0), gap.clamp(0.0, 256.0)); let rows = colors.len().div_ceil(columns); - let cell_h = if labels { size + 20.0 } else { size }; + let cell_h = if labels.is_some() { size + 20.0 } else { size }; let (w, h) = (columns as f64 * (size + gap) - gap, rows as f64 * (cell_h + gap) - gap); // Centred in the view, on whole pixels. let view = call("view.get", &json!({}))?; @@ -67,8 +71,8 @@ fn grid(colors: &[String], columns: f64, size: f64, gap: f64, labels: bool) -> R let (x, y) = (x0 + (i % columns) as f64 * (size + gap), y0 + (i / columns) as f64 * (cell_h + gap)); let r = call("shape.rectangle", &json!({"x": x, "y": y, "width": size, "height": size, "props": {"name": color, "fill": color}}))?; made.extend(r["id"].as_u64()); - if labels { - let t = call("text.create", &json!({"text": color, "x": x, "y": y + size + 4.0, "props": {"fontSize": 11}}))?; + if let Some(label) = labels { + let t = call("text.create", &json!({"text": color, "x": x, "y": y + size + 4.0, "props": {"fontSize": 11, "fill": label}}))?; made.extend(t["id"].as_u64()); } } @@ -86,6 +90,7 @@ fn panel(name: &str, values: &Value) -> Result { {"type": "note", "label": named(name)}, {"type": "number", "name": "columns", "label": "Columns", "min": 1, "max": 24, "value": values["columns"].as_f64().unwrap_or(3.0)}, {"type": "boolean", "name": "labels", "label": "Label each swatch", "value": values["labels"].as_bool().unwrap_or(true)}, + {"type": "color", "name": "labelColor", "label": "Label colour", "value": values["labelColor"].as_str().unwrap_or(LABEL)}, {"type": "button", "name": "generate", "label": "Generate"}, {"type": "button", "name": "close", "label": "Close"} ]}))?; @@ -102,7 +107,8 @@ fn on_event(e: Event) -> Result { panel(name, &e.values) } "generate" => { - grid(&palette(named(name)), e.values["columns"].as_f64().unwrap_or(3.0), 64.0, 16.0, e.values["labels"].as_bool().unwrap_or(true)) + let labels = e.values["labels"].as_bool().unwrap_or(true).then(|| e.values["labelColor"].as_str().unwrap_or(LABEL)); + grid(&palette(named(name)), e.values["columns"].as_f64().unwrap_or(3.0), 64.0, 16.0, labels) } "close" => { close_ui(); @@ -126,7 +132,7 @@ fn run(input: Input) -> Result { input.f64("columns").unwrap_or(3.0), input.f64("size").unwrap_or(64.0), input.f64("gap").unwrap_or(16.0), - input.bool("labels").unwrap_or(true), + input.bool("labels").unwrap_or(true).then_some(LABEL), ) } diff --git a/plugins/sdk/src/lib.rs b/plugins/sdk/src/lib.rs index 8f8373c..967af78 100644 --- a/plugins/sdk/src/lib.rs +++ b/plugins/sdk/src/lib.rs @@ -23,6 +23,11 @@ //! loaded while the panel is open, so statics keep their values between those calls. Settings //! ([`setting`], [`set_setting`]) last from one run to the next. //! +//! A widget plugin (API 3, `"widget": true`) draws a layer on the canvas: when it's inserted and +//! each time a part of it named as a `target` is clicked, the plugin is called with +//! [`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. +//! //! 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)] @@ -50,6 +55,19 @@ pub struct Input { pub selection: Vec, /// Set when the plugin's panel calls it back. pub event: Option, + /// Set when the plugin's widget is inserted or clicked. + pub widget: Option, +} + +/// A widget being inserted (no `target`) or clicked. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Widget { + /// The widget's layer id. + pub id: u64, + /// Its state as last handed back (an empty object when it's new). + pub state: Value, + /// The target clicked. + pub target: Option, } /// A button pressed or a value changed in the plugin's panel. @@ -76,6 +94,11 @@ impl Input { value: e.get("value").cloned().unwrap_or(Value::Null), values: v.get("values").cloned().unwrap_or(Value::Null), }), + widget: v.get("widget").map(|w| Widget { + 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), + }), }) } @@ -137,6 +160,14 @@ pub fn close_ui() { host::ui_close(); } +/// Draw the widget being inserted or clicked: `{"state": {...}, "root": {"type": "frame", ...}}` +/// (see `docs/plugins.md`). Without `state` the widget keeps the one it has. An invalid +/// description is an error. +pub fn render_widget(widget: &Value) -> Result<()> { + let spec = serde_json::to_string(widget).map_err(|e| e.to_string())?; + if host::widget_render(spec.as_bytes()) == 0 { Ok(()) } else { Err(String::from_utf8_lossy(&host::reply()).into_owned()) } +} + /// The plugin's setting `key`, as stored by [`set_setting`]. pub fn setting(key: &str) -> Option { if host::setting_get(key.as_bytes()) != 0 { @@ -206,6 +237,7 @@ mod host { pub fn setting_get(key: *const u8, key_len: usize) -> i32; 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; } } @@ -248,6 +280,9 @@ mod host { pub fn setting_delete(key: &[u8]) { unsafe { sys::setting_delete(key.as_ptr(), key.len()) } } + pub fn widget_render(s: &[u8]) -> i32 { + unsafe { sys::widget_render(s.as_ptr(), s.len()) } + } } /// Outside WebAssembly (a plugin's own unit tests) every host call fails. @@ -278,6 +313,9 @@ mod host { 1 } pub fn setting_delete(_key: &[u8]) {} + pub fn widget_render(_s: &[u8]) -> i32 { + 1 + } } #[cfg(test)] @@ -300,5 +338,11 @@ mod tests { .unwrap_or_default(); assert_eq!((e.control.as_str(), &e.value, &e.values), ("go", &Value::Null, &json!({"n": 2}))); assert!(show_ui(&json!({})).is_err() && setting("k").is_none() && set_setting("k", &json!(1)).is_err()); + assert!(render_widget(&json!({})).is_err() && i.widget.is_none()); + let w = Input::parse(r#"{"command":"widget","widget":{"id":9,"state":{"n":1},"event":{"target":"inc"}}}"#) + .unwrap_or_default() + .widget + .unwrap_or_default(); + assert_eq!((w.id, &w.state, w.target.as_deref()), (9, &json!({"n": 1}), Some("inc"))); } } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 8b83618..771e095 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -81,7 +81,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"]; +const EXAMPLE_PLUGINS: &[&str] = &["tidy-names", "swatch-grid", "counter"]; /// The plugin workspace: fmt, clippy and build for wasm32, host tests, then each example's manifest /// and module side by side in `/plugins/dist//`.