M11: plugin widgets and the plugin panel tail
Widgets for FigCraft's own WebAssembly plugins (plugin API 3; API 1 and 2 plugins run unchanged), plus the panel tail.
- **Widgets.** A manifest with `"api": 3, "widget": true` makes a widget plugin. Plugins > Widgets (or `widget.insert {plugin, x?, y?}`) puts its widget on the page: the plugin is called with `widget: {id, state, event}` and hands back a description through the new `widget_render` import (`{state?, root}`: frames with auto layout, text, rectangles, ellipses and images it carries as base64). FigCraft validates the description (4 MB, 256 layers, 16 deep, finite numbers within ranges, hex colours, text length and control characters, a 16 KB state, images 1 MB each / 2 MB together / 4096 px a side) and builds it as a frame whose layers are locked, so the widget moves as one layer.
- **Clicks.** A plain click with the Move tool on a part the plugin named as a `target` (the cursor turns into a pointing hand there; quick repeated clicks each count) calls the plugin back in a fresh, metered sandbox (same fuel, memory and call limits as runs; nothing kept between calls, so the state and the click alone decide the result) as one undo step, and the widget is redrawn. Dragging moves it; Shift/Cmd/Alt clicks only select. Agents use `widget.click {id, target}` and `widget.list`.
- **State in the document.** The plugin id and the state live in the widget's own layer data (`figcraft.widget*`), so widgets survive save and reload, duplicate/copy and paste (the copy is a widget of its own), undo/redo, export (the built layers draw) and live sessions (synced like any layer data). No plugin can write `figcraft.widget*` data or call `widget.*`; a callback can only rebuild its own widget, and touches other layers only through the commands its manifest permits. A widget whose plugin is missing stays as drawn.
- **Example.** `plugins/examples/counter`: a counter between - and + buttons (read access, no assets). The SDK gained `Input::widget` and `render_widget`.
- **Panel tail.** Panels of several plugins are open at once, side by side (`plugin.ui` without an id returns the one opened last); a colour field's swatch opens FigCraft's colour picker beside the plugin's window (the plugin hears of the colour when a drag ends; also for parameters forms). Swatch grid's palette panel gained a label colour field.
- Docs: plugins.md (ABI v3, widget description, commands, limits), mcp.md, target-app-parity area 18, ROADMAP, gaps gap 6, roadmap M11; Spanish labels for the new commands.
This commit is contained in:
parent
1e0b51398a
commit
207373be15
17
ROADMAP.md
17
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)
|
**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,
|
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
|
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)) |
|
| 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)) |
|
| 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 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
|
### Readiness by audience
|
||||||
|
|
||||||
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
|
||||||
|
|
||||||
## Features
|
## 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) |
|
||||||
| Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) |
|
| Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) |
|
||||||
| AI features | 0% | 0% | 30–60 | [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
|
## 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
|
- **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
|
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
|
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 |
|
| 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 | 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 | 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 |
|
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ fn registry() -> &'static Registry {
|
||||||
list.extend(crate::collab::specs());
|
list.extend(crate::collab::specs());
|
||||||
list.extend(crate::branches::specs());
|
list.extend(crate::branches::specs());
|
||||||
list.extend(crate::plugins::specs());
|
list.extend(crate::plugins::specs());
|
||||||
|
list.extend(crate::widgets::specs());
|
||||||
list.extend(crate::live::specs());
|
list.extend(crate::live::specs());
|
||||||
list.extend(crate::invite_links::specs());
|
list.extend(crate::invite_links::specs());
|
||||||
let mut index = HashMap::new();
|
let mut index = HashMap::new();
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ pub mod textedit;
|
||||||
pub mod textpreview;
|
pub mod textpreview;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
pub mod vectoredit;
|
pub mod vectoredit;
|
||||||
|
pub mod widgets;
|
||||||
|
|
||||||
pub use cmd::{CommandSpec, all_commands, find_command};
|
pub use cmd::{CommandSpec, all_commands, find_command};
|
||||||
pub use figcraft_collab;
|
pub use figcraft_collab;
|
||||||
|
|
@ -1429,3 +1430,5 @@ mod version_preview_tests;
|
||||||
mod version_tests;
|
mod version_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod view_behaviour_tests;
|
mod view_behaviour_tests;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod widgets_tests;
|
||||||
|
|
|
||||||
|
|
@ -282,7 +282,7 @@ fn a_callback_out_of_fuel_rolls_back_and_closes_the_panel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let mut s = Session::new();
|
||||||
rect(&mut s, 0.0);
|
rect(&mut s, 0.0);
|
||||||
let mut w = Wat::default();
|
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!(ex(&mut s, "plugin.close", json!({"id": "org.test.a"}))["closed"], true);
|
||||||
assert_eq!(ui(&mut s), Value::Null);
|
assert_eq!(ui(&mut s), Value::Null);
|
||||||
assert!(event_err(&mut s, json!({"id": "org.test.a", "control": "go"})).contains("no panel open"));
|
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.a"}));
|
||||||
ex(&mut s, "plugin.run", json!({"id": "org.test.b"}));
|
ex(&mut s, "plugin.run", json!({"id": "org.test.b"}));
|
||||||
assert_eq!(ui(&mut s)["plugin"], "org.test.b");
|
ex(&mut s, "plugin.run", json!({"id": "org.test.a"}));
|
||||||
assert_eq!(ex(&mut s, "plugin.ui", json!({"id": "org.test.a"})), Value::Null);
|
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"}));
|
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);
|
assert_eq!(ui(&mut s), Value::Null);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
//! Plugins: WebAssembly modules that act on the document through the same commands as the menus,
|
//! 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
|
//! 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
|
//! 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
|
//! undo history, dialogs or plugins), one undo step per run or panel callback, the panels plugins
|
||||||
//! keeps open, its settings and the installed-plugin list kept in the preferences. See
|
//! keep open, their settings and the installed-plugin list kept in the preferences. Widgets (plugin
|
||||||
//! `docs/plugins.md`.
|
//! layers on the canvas) are in [`crate::widgets`]. See `docs/plugins.md`.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -44,10 +44,15 @@ pub struct Runtime {
|
||||||
modules: HashMap<String, Arc<Vec<u8>>>,
|
modules: HashMap<String, Arc<Vec<u8>>>,
|
||||||
/// The last run (plugin id, command id, params): "Run last plugin".
|
/// The last run (plugin id, command id, params): "Run last plugin".
|
||||||
pub last: Option<(String, String, Value)>,
|
pub last: Option<(String, String, Value)>,
|
||||||
/// The plugin panel open now.
|
/// The open plugin panels, one per plugin, in the order they opened.
|
||||||
// shortcut: one panel at a time (another plugin's panel replaces it); keep a list when
|
pub panels: Vec<OpenPanel>,
|
||||||
// plugins need panels side by side.
|
}
|
||||||
pub panel: Option<OpenPanel>,
|
|
||||||
|
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),
|
/// 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
|
/// 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
|
/// 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] = &[
|
const DENIED: &[&str] = &[
|
||||||
|
"widget.",
|
||||||
"file.",
|
"file.",
|
||||||
"doc.",
|
"doc.",
|
||||||
"version.",
|
"version.",
|
||||||
|
|
@ -103,6 +109,10 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option<String> {
|
||||||
if DENIED.iter().any(|p| command.starts_with(p)) && !ALLOWED.contains(&command) {
|
if DENIED.iter().any(|p| command.starts_with(p)) && !ALLOWED.contains(&command) {
|
||||||
return Some(format!("`{command}` isn't available to plugins"));
|
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() {
|
if command == "image.place" && get(params, "path").is_some() {
|
||||||
return Some("plugins can't read files: pass the image as `data`".into());
|
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<String> {
|
||||||
None
|
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,
|
/// The sandbox's view of the session: commands go through [`refusal`] and run as nested groups,
|
||||||
/// so a refused one leaves nothing behind.
|
/// so a refused one leaves nothing behind.
|
||||||
struct EngineHost<'s> {
|
struct EngineHost<'s> {
|
||||||
|
|
@ -192,7 +213,7 @@ impl figcraft_plugin::Host for EngineHost<'_> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The installed plugin `id`.
|
/// 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")))
|
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<Vec<u8>>
|
||||||
|
|
||||||
/// The module of an installed plugin: from the session, else read again from disk (so a rebuilt
|
/// The module of an installed plugin: from the session, else read again from disk (so a rebuilt
|
||||||
/// development plugin runs its new code).
|
/// development plugin runs its new code).
|
||||||
fn module_of(s: &Session, p: &InstalledPlugin) -> Result<Arc<Vec<u8>>> {
|
pub(crate) fn module_of(s: &Session, p: &InstalledPlugin) -> Result<Arc<Vec<u8>>> {
|
||||||
if let Some(m) = s.plugins.modules.get(&p.manifest.id) {
|
if let Some(m) = s.plugins.modules.get(&p.manifest.id) {
|
||||||
return Ok(m.clone());
|
return Ok(m.clone());
|
||||||
}
|
}
|
||||||
|
|
@ -249,7 +270,16 @@ fn module_of(s: &Session, p: &InstalledPlugin) -> Result<Arc<Vec<u8>>> {
|
||||||
return Err(EngineError::Other(format!("{path}: there are no files in the browser")));
|
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<f64>) -> 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}"))
|
EngineError::Other(format!("{name}: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -321,9 +351,7 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
s.prefs.plugins.retain(|x| x.manifest.id != id);
|
s.prefs.plugins.retain(|x| x.manifest.id != id);
|
||||||
s.prefs.plugin_settings.remove(&id);
|
s.prefs.plugin_settings.remove(&id);
|
||||||
s.plugins.modules.remove(&id);
|
s.plugins.modules.remove(&id);
|
||||||
if s.plugins.panel.as_ref().is_some_and(|o| o.manifest.id == id) {
|
close_panel(s, &id);
|
||||||
s.plugins.panel = None;
|
|
||||||
}
|
|
||||||
if s.plugins.last.as_ref().is_some_and(|(last, ..)| *last == id) {
|
if s.plugins.last.as_ref().is_some_and(|(last, ..)| *last == id) {
|
||||||
s.plugins.last = None;
|
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 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 params = c.resolve_params(params).map_err(|e| bad(cmd, e))?;
|
||||||
let wasm = module_of(s, &p)?;
|
let wasm = module_of(s, &p)?;
|
||||||
let mut limits = Limits::default();
|
let limits = limits(fuel);
|
||||||
if let Some(f) = fuel {
|
|
||||||
limits.fuel = (f.max(1000.0) as u64).min(limits.fuel);
|
|
||||||
}
|
|
||||||
// The API the plugin was written for: a v1 plugin gets the input it always got.
|
// 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();
|
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()));
|
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() {
|
match out.ui.take() {
|
||||||
Some(UiChange::Show(panel)) => {
|
Some(UiChange::Show(panel)) => {
|
||||||
let values = panel.values(&Map::new());
|
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),
|
Some(UiChange::Close) => close_panel(s, &m.id),
|
||||||
None => {}
|
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
|
/// 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
|
/// 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.
|
/// error itself rather than trapping or running out of fuel or memory.
|
||||||
fn call_plugin(
|
pub(crate) fn call_plugin(
|
||||||
s: &mut Session,
|
s: &mut Session,
|
||||||
m: &Manifest,
|
m: &Manifest,
|
||||||
label: &str,
|
label: &str,
|
||||||
|
|
@ -385,20 +411,20 @@ fn call_plugin(
|
||||||
/// What `plugin.run` and `plugin.event` return.
|
/// What `plugin.run` and `plugin.event` return.
|
||||||
fn report(s: &Session, id: &str, command: &str, out: RunOutput) -> Value {
|
fn report(s: &Session, id: &str, command: &str, out: RunOutput) -> Value {
|
||||||
let output = serde_json::from_str::<Value>(&out.output).unwrap_or(Value::String(out.output));
|
let output = serde_json::from_str::<Value>(&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})
|
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.
|
/// Close plugin `id`'s panel, if it has one open.
|
||||||
fn close_panel(s: &mut Session, id: &str) {
|
fn close_panel(s: &mut Session, id: &str) {
|
||||||
if s.plugins.panel.as_ref().is_some_and(|o| o.manifest.id == id) {
|
s.plugins.panels.retain(|o| o.manifest.id != id);
|
||||||
s.plugins.panel = None;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ui(s: &mut Session, p: &Value) -> Result<Value> {
|
fn ui(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let id = string(p, "id");
|
let open = match string(p, "id") {
|
||||||
let open = s.plugins.panel.as_ref().filter(|o| id.as_ref().is_none_or(|id| *id == o.manifest.id));
|
Some(id) => s.plugins.panel(&id),
|
||||||
|
None => s.plugins.panels.last(),
|
||||||
|
};
|
||||||
Ok(open.map_or(Value::Null, OpenPanel::describe))
|
Ok(open.map_or(Value::Null, OpenPanel::describe))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -408,7 +434,8 @@ fn event(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let cmd = "plugin.event";
|
let cmd = "plugin.event";
|
||||||
let id = string(p, "id").ok_or_else(|| bad(cmd, "`id` required (the plugin whose panel is open)"))?;
|
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 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
|
let c = open
|
||||||
.panel
|
.panel
|
||||||
.control(&control)
|
.control(&control)
|
||||||
|
|
@ -416,7 +443,7 @@ fn event(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
.ok_or_else(|| bad(cmd, format!("the panel has no control `{control}`")))?;
|
.ok_or_else(|| bad(cmd, format!("the panel has no control `{control}`")))?;
|
||||||
let field = c.is_field();
|
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 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 {
|
if field {
|
||||||
open.values.insert(control.clone(), value.clone());
|
open.values.insert(control.clone(), value.clone());
|
||||||
}
|
}
|
||||||
|
|
@ -428,7 +455,7 @@ fn event(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
Err((e, usable)) => {
|
Err((e, usable)) => {
|
||||||
// A plugin that returned an error keeps its panel; one that crashed loses it.
|
// A plugin that returned an error keeps its panel; one that crashed loses it.
|
||||||
if usable {
|
if usable {
|
||||||
s.plugins.panel = Some(open);
|
keep(s, at, open);
|
||||||
}
|
}
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
|
@ -437,17 +464,23 @@ fn event(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
Some(UiChange::Show(panel)) => {
|
Some(UiChange::Show(panel)) => {
|
||||||
open.values = panel.values(&open.values);
|
open.values = panel.values(&open.values);
|
||||||
open.panel = panel;
|
open.panel = panel;
|
||||||
s.plugins.panel = Some(open);
|
keep(s, at, open);
|
||||||
}
|
}
|
||||||
Some(UiChange::Close) => {}
|
Some(UiChange::Close) => {}
|
||||||
None => s.plugins.panel = Some(open),
|
None => keep(s, at, open),
|
||||||
}
|
}
|
||||||
Ok(report(s, &id, &command, out))
|
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<Value> {
|
fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let id = string(p, "id").ok_or_else(|| bad("plugin.close", "`id` required"))?;
|
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);
|
close_panel(s, &id);
|
||||||
Ok(json!({"closed": closed}))
|
Ok(json!({"closed": closed}))
|
||||||
}
|
}
|
||||||
|
|
@ -488,7 +521,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
false,
|
false,
|
||||||
run_cmd
|
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!(
|
spec!(
|
||||||
"plugin.event",
|
"plugin.event",
|
||||||
"Use plugin panel control",
|
"Use plugin panel control",
|
||||||
|
|
|
||||||
|
|
@ -261,6 +261,8 @@ fn every_command_survives_junk_params() {
|
||||||
// Links (dev resources) and indexes as junk.
|
// Links (dev resources) and indexes as junk.
|
||||||
json!({"id": 2, "url": "javascript:alert(1)", "index": 1e308, "name": {"x": 1}}),
|
json!({"id": 2, "url": "javascript:alert(1)", "index": 1e308, "name": {"x": 1}}),
|
||||||
json!({"id": 2, "url": "https://\u{0}x", "index": -1, "name": ""}),
|
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() {
|
for spec in all_commands() {
|
||||||
if matches!(
|
if matches!(
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,8 @@ pub enum Cursor {
|
||||||
Text,
|
Text,
|
||||||
Pen,
|
Pen,
|
||||||
Copy,
|
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
|
/// 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) {
|
if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom) {
|
||||||
cursor = resize_cursor(&doc, &sel, h);
|
cursor = resize_cursor(&doc, &sel, h);
|
||||||
} else {
|
} else {
|
||||||
hover = label_at(&doc, page, &self.tool.labels, ev.p())
|
let leaf = self.hit(&doc, page, ev.p(), ev.tol() * 2.0);
|
||||||
.or_else(|| self.hit(&doc, page, ev.p(), ev.tol() * 2.0).map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)));
|
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 {
|
if ev.alt || self.tool.redlines {
|
||||||
use crate::distance::{Hover, guide_at, hover_lines, padding_frame, parent_of};
|
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
|
// 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
|
// 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
|
// 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| {
|
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(|| {
|
let target = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| {
|
||||||
self.hit(&doc, page, ev.p(), ev.tol() * 2.0)
|
self.hit(&doc, page, ev.p(), ev.tol() * 2.0)
|
||||||
|
|
@ -1506,7 +1514,7 @@ impl Session {
|
||||||
self.tool.overlay.drop_cell = None;
|
self.tool.overlay.drop_cell = None;
|
||||||
let drop_target = self.tool.overlay.drop_target.take();
|
let drop_target = self.tool.overlay.drop_target.take();
|
||||||
match g {
|
match g {
|
||||||
Gesture::Move { ids, moved, duplicate, .. } => {
|
Gesture::Move { ids, moved, duplicate, start, .. } => {
|
||||||
if moved {
|
if moved {
|
||||||
// A free move lands in the frame under the pointer (or the page) where it was
|
// 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.
|
// dropped; layers in an auto layout frame already sit in their slot.
|
||||||
|
|
@ -1550,8 +1558,9 @@ impl Session {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.cancel_interaction()?;
|
self.cancel_interaction()?;
|
||||||
// Plain click on an already-selected layer among several: select just it.
|
if !(ev.shift || ev.cmd || duplicate) {
|
||||||
let _ = ev;
|
self.widget_click(start, ev.tol() * 2.0)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Gesture::Resize { .. } | Gesture::Rotate { .. } | Gesture::Arc { .. } | Gesture::Shape { .. } => {
|
Gesture::Resize { .. } | Gesture::Rotate { .. } | Gesture::Arc { .. } | Gesture::Shape { .. } => {
|
||||||
|
|
@ -1629,6 +1638,16 @@ impl Session {
|
||||||
if self.tool.tool == ToolId::Pen {
|
if self.tool.tool == ToolId::Pen {
|
||||||
return self.pen_finish();
|
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 Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) };
|
||||||
let target = select_target(&doc, leaf, &sel, true);
|
let target = select_target(&doc, leaf, &sel, true);
|
||||||
// Text: edit at the click.
|
// Text: edit at the click.
|
||||||
|
|
@ -1655,6 +1674,17 @@ impl Session {
|
||||||
Ok(())
|
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`.
|
/// The char index in text node `id` at page point `p`.
|
||||||
pub fn text_index_at(&self, id: NodeId, p: Point) -> Result<usize> {
|
pub fn text_index_at(&self, id: NodeId, p: Point) -> Result<usize> {
|
||||||
let doc = self.doc()?;
|
let doc = self.doc()?;
|
||||||
|
|
|
||||||
|
|
@ -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<NodeId> {
|
||||||
|
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<String, Value> {
|
||||||
|
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::<Value>(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<String> {
|
||||||
|
let mut out: Vec<String> = 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<InstalledPlugin> {
|
||||||
|
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<f64>) -> Result<RunOutput> {
|
||||||
|
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<String, Value> {
|
||||||
|
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::<Vec<_>>())),
|
||||||
|
}
|
||||||
|
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<Value> {
|
||||||
|
let doc = s.doc()?;
|
||||||
|
let output = serde_json::from_str::<Value>(&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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
let doc = s.doc()?;
|
||||||
|
let widgets: Vec<Value> = 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<CommandSpec> {
|
||||||
|
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),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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<u8> {
|
||||||
|
let wat = format!(
|
||||||
|
r#"(module
|
||||||
|
(import "figcraft" "call" (func $call (param i32 i32 i32 i32) (result i32)))
|
||||||
|
(import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32)))
|
||||||
|
(import "figcraft" "set_output" (func $set_output (param i32 i32)))
|
||||||
|
(import "figcraft" "widget_render" (func $render (param i32 i32) (result i32)))
|
||||||
|
(memory (export "memory") 4)
|
||||||
|
{}
|
||||||
|
(func $has (param $p i32) (param $l i32) (param $n i32) (result i32) (local $i i32) (local $j i32)
|
||||||
|
(block $no (loop $outer
|
||||||
|
(br_if $no (i32.gt_s (i32.add (local.get $i) (local.get $l)) (local.get $n)))
|
||||||
|
(local.set $j (i32.const 0))
|
||||||
|
(block $miss (loop $inner
|
||||||
|
(if (i32.ge_s (local.get $j) (local.get $l)) (then (return (i32.const 1))))
|
||||||
|
(br_if $miss (i32.ne (i32.load8_u (i32.add (i32.const {INPUT}) (i32.add (local.get $i) (local.get $j))))
|
||||||
|
(i32.load8_u (i32.add (local.get $p) (local.get $j)))))
|
||||||
|
(local.set $j (i32.add (local.get $j) (i32.const 1)))
|
||||||
|
(br $inner)))
|
||||||
|
(local.set $i (i32.add (local.get $i) (i32.const 1)))
|
||||||
|
(br $outer)))
|
||||||
|
(i32.const 0))
|
||||||
|
(func (export "figcraft_run") (param $n i32) (result i32)
|
||||||
|
(drop (call $reply_read (i32.const {INPUT}) (local.get $n)))
|
||||||
|
(call $set_output (i32.const {INPUT}) (local.get $n))
|
||||||
|
{body}
|
||||||
|
(i32.const 0)))"#,
|
||||||
|
self.data
|
||||||
|
);
|
||||||
|
wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A widget showing `label` with the clickable parts `targets` (a rectangle, then ellipses).
|
||||||
|
fn look(n: i64, label: &str, targets: &[&str]) -> Value {
|
||||||
|
let mut children = vec![json!({"type": "text", "name": "Label", "text": label, "size": 12, "color": "#111111"})];
|
||||||
|
for (i, t) in targets.iter().enumerate() {
|
||||||
|
let kind = if i == 0 { "rectangle" } else { "ellipse" };
|
||||||
|
children.push(json!({"type": kind, "target": t, "width": 20, "height": 20, "fill": "#0D99FF"}));
|
||||||
|
}
|
||||||
|
json!({"state": {"n": n}, "root": {"type": "frame", "name": "Tally", "layout": "horizontal", "gap": 8, "padding": 10, "fill": "#FFFFFF", "children": children}})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The test widget: it shows 1 when inserted, 2 after `inc` and 0 after `dec`; `extra` runs
|
||||||
|
/// first on every call.
|
||||||
|
fn tally(extra: impl FnOnce(&mut W) -> String) -> Vec<u8> {
|
||||||
|
let mut w = W::default();
|
||||||
|
let pre = extra(&mut w);
|
||||||
|
let (inc, dec) = (w.has(r#""target":"inc""#), w.has(r#""target":"dec""#));
|
||||||
|
let (two, zero, one) = (w.render(&look(2, "two", &["inc", "dec"])), w.render(&look(0, "zero", &["inc"])), w.render(&look(1, "one", &["inc"])));
|
||||||
|
w.build(&format!("{pre} (if {inc} (then {two} (return (i32.const 0)))) (if {dec} (then {zero} (return (i32.const 0)))) {one}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn widget_manifest(id: &str, access: &str) -> Value {
|
||||||
|
json!({"id": id, "name": "Tally", "version": "1.0.0", "main": "w.wasm", "api": 3, "widget": true, "permissions": {"access": access}})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session with the tally widget plugin installed (read access).
|
||||||
|
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("<svg") && svg.contains("0d99ff"), "{svg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clicking_a_widget_on_the_canvas_calls_its_plugin_back() {
|
||||||
|
let mut s = session();
|
||||||
|
let (w, _) = insert(&mut s);
|
||||||
|
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||||
|
let kids = node(&s, w).children.clone();
|
||||||
|
let center = |s: &Session, id: NodeId| s.doc().unwrap().world_bounds(id).unwrap().center();
|
||||||
|
let (target, text) = (center(&s, kids[1]), center(&s, kids[0]));
|
||||||
|
let at = |kind, p: Point| PointerEvent::at(kind, p.x, p.y, 1.0);
|
||||||
|
// Over the label: picked as one layer, no callback.
|
||||||
|
s.pointer(&at(PointerKind::Move, text)).unwrap();
|
||||||
|
assert_eq!(s.tool.overlay.cursor, Cursor::Default);
|
||||||
|
s.pointer(&at(PointerKind::Down, text)).unwrap();
|
||||||
|
s.pointer(&at(PointerKind::Up, text)).unwrap();
|
||||||
|
assert_eq!((s.selection(), state(&mut s, w)), (vec![w], json!({"n": 1})));
|
||||||
|
// Over a target: a pointing hand, and a click calls back (one undo step).
|
||||||
|
s.pointer(&at(PointerKind::Move, target)).unwrap();
|
||||||
|
assert_eq!(s.tool.overlay.cursor, Cursor::Pointer);
|
||||||
|
let undo = undo_len(&s);
|
||||||
|
s.pointer(&at(PointerKind::Down, target)).unwrap();
|
||||||
|
s.pointer(&at(PointerKind::Up, target)).unwrap();
|
||||||
|
assert_eq!((state(&mut s, w), undo_len(&s)), (json!({"n": 2}), undo + 1));
|
||||||
|
// A quick second click (a double click) is a click too.
|
||||||
|
s.pointer(&at(PointerKind::Down, target)).unwrap();
|
||||||
|
s.pointer(&at(PointerKind::DoubleClick, target)).unwrap();
|
||||||
|
assert_eq!((undo_len(&s), s.ds().unwrap().interaction.is_none()), (undo + 2, true));
|
||||||
|
// Dragging by a target moves the widget instead.
|
||||||
|
let before = node(&s, w).transform.translation();
|
||||||
|
let to = Point::new(target.x + 40.0, target.y + 30.0);
|
||||||
|
s.pointer(&at(PointerKind::Down, target)).unwrap();
|
||||||
|
s.pointer(&at(PointerKind::Move, to)).unwrap();
|
||||||
|
s.pointer(&at(PointerKind::Up, to)).unwrap();
|
||||||
|
let after = node(&s, w).transform.translation();
|
||||||
|
assert!(after.x - before.x > 30.0 && state(&mut s, w) == json!({"n": 2}), "{before:?} → {after:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bad_widgets_and_clicks_are_refused_and_roll_back() {
|
||||||
|
let mut s = session();
|
||||||
|
let r = rect(&mut s, 400.0);
|
||||||
|
assert!(err(&mut s, "widget.insert", json!({"plugin": "org.nope"})).contains("no plugin"));
|
||||||
|
// A plugin that isn't a widget.
|
||||||
|
let plain = json!({"id": "org.test.plain", "name": "Plain", "main": "p.wasm", "api": 2, "permissions": {"access": "write"}});
|
||||||
|
install(&mut s, plain, &tally(|_| String::new()));
|
||||||
|
assert!(err(&mut s, "widget.insert", json!({"plugin": "org.test.plain"})).contains("doesn't draw a widget"));
|
||||||
|
// Junk, or nothing, drawn: refused, nothing left behind.
|
||||||
|
let (layers, undo) = (layer_count(&s), undo_len(&s));
|
||||||
|
let mut junk = W::default();
|
||||||
|
let body = junk.render(&json!({"root": {"type": "text", "text": "not a frame"}}));
|
||||||
|
install(&mut s, widget_manifest("org.test.junk", "read"), &junk.build(&body));
|
||||||
|
assert!(err(&mut s, "widget.insert", json!({"plugin": "org.test.junk"})).contains("root must be a frame"));
|
||||||
|
install(&mut s, widget_manifest("org.test.blank", "read"), &W::default().build(""));
|
||||||
|
assert!(err(&mut s, "widget.insert", json!({"plugin": "org.test.blank"})).contains("drew no widget"));
|
||||||
|
assert_eq!((layer_count(&s), undo_len(&s)), (layers, undo));
|
||||||
|
// Clicks on what isn't a widget, or on what it doesn't have.
|
||||||
|
let (w, _) = insert(&mut s);
|
||||||
|
assert!(err(&mut s, "widget.click", json!({"id": r, "target": "inc"})).contains("isn't a widget"));
|
||||||
|
assert!(err(&mut s, "widget.click", json!({"id": w, "target": "nope"})).contains("it has: inc"));
|
||||||
|
assert!(err(&mut s, "widget.click", json!({"id": w})).contains("`target` required"));
|
||||||
|
// A runaway callback runs out of fuel and is rolled back.
|
||||||
|
install(&mut s, widget_manifest("org.test.tally", "read"), &tally(|_| "(loop $l (br $l))".into()));
|
||||||
|
let undo = undo_len(&s);
|
||||||
|
assert!(err(&mut s, "widget.click", json!({"id": w, "target": "inc", "fuel": 100000})).contains("instruction budget"));
|
||||||
|
assert_eq!((state(&mut s, w), undo_len(&s), label(&s, w)), (json!({"n": 1}), undo, "one".into()));
|
||||||
|
// Without its plugin a widget stays as it is, and says so when clicked.
|
||||||
|
ex(&mut s, "plugin.remove", json!({"id": "org.test.tally"}));
|
||||||
|
assert!(err(&mut s, "widget.click", json!({"id": w, "target": "inc"})).contains("no plugin"));
|
||||||
|
assert_eq!(node(&s, w).children.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugins_cant_forge_widgets_or_call_widget_commands() {
|
||||||
|
let mut s = session();
|
||||||
|
let r = rect(&mut s, 0.0);
|
||||||
|
let (w, _) = insert(&mut s);
|
||||||
|
for (i, (cmd, params)) in [
|
||||||
|
("node.set", json!({"ids": [r], "props": {"pluginData": {"figcraft.widget": "org.test.tally"}}})),
|
||||||
|
("node.set", json!({"ids": [w], "props": {"pluginData": {"figcraft.widget.state": "{\"n\": 99}"}}})),
|
||||||
|
("shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5, "props": {"pluginData": {"figcraft.widget.target": "inc"}}})),
|
||||||
|
("widget.click", json!({"id": w, "target": "inc"})),
|
||||||
|
("widget.list", json!({})),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let mut b = W::default();
|
||||||
|
let body = b.call(cmd, ¶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");
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,20 @@
|
||||||
//! FigCraft plugins: the manifest (`figcraft-plugin.json`) and the WebAssembly sandbox a plugin
|
//! 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,
|
//! runs in, and the widget descriptions a plugin hands back. The engine decides what a plugin may
|
||||||
//! one undo step per run or panel callback, where settings are kept); this crate only runs modules
|
//! do (its permissions, which commands it may call, one undo step per run, panel or widget
|
||||||
//! safely: a metered interpreter with a memory cap, no WASI and a handful of host functions. See
|
//! callback, where settings are kept); this crate only runs modules safely: a metered interpreter
|
||||||
//! `docs/plugins.md`.
|
//! with a memory cap, no WASI and a handful of host functions. See `docs/plugins.md`.
|
||||||
|
|
||||||
pub mod manifest;
|
pub mod manifest;
|
||||||
mod sandbox;
|
mod sandbox;
|
||||||
|
pub mod widget;
|
||||||
|
|
||||||
pub use manifest::{API_VERSION, Access, MANIFEST_FILE, Manifest, Panel, Param, ParamKind, Permissions, PluginCommand};
|
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 sandbox::{ENTRY, Host, IMPORT_MODULE, Instance, Limits, PluginError, RunOutput, UiChange, check_module, run};
|
||||||
|
pub use widget::{WidgetKind, WidgetNode, WidgetRender};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod v2_tests;
|
mod v2_tests;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod widget_tests;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use serde_json::{Map, Value};
|
||||||
/// The manifest's file name.
|
/// The manifest's file name.
|
||||||
pub const MANIFEST_FILE: &str = "figcraft-plugin.json";
|
pub const MANIFEST_FILE: &str = "figcraft-plugin.json";
|
||||||
/// The plugin API version this build implements (`api` in the manifest).
|
/// 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.
|
/// Largest manifest read.
|
||||||
pub const MAX_MANIFEST_BYTES: usize = 256 * 1024;
|
pub const MAX_MANIFEST_BYTES: usize = 256 * 1024;
|
||||||
|
|
||||||
|
|
@ -40,14 +40,17 @@ pub struct Manifest {
|
||||||
/// The WebAssembly module, relative to the manifest (`plugin.wasm`).
|
/// The WebAssembly module, relative to the manifest (`plugin.wasm`).
|
||||||
pub main: String,
|
pub main: String,
|
||||||
/// The plugin API version the plugin was written for (1 when not given; panels and settings
|
/// 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")]
|
#[serde(default = "api_version")]
|
||||||
pub api: u32,
|
pub api: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub permissions: Permissions,
|
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)]
|
#[serde(default)]
|
||||||
pub commands: Vec<PluginCommand>,
|
pub commands: Vec<PluginCommand>,
|
||||||
|
/// 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.
|
/// 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)));
|
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() });
|
self.commands.push(PluginCommand { id: "run".into(), label: "Run".into(), params: Vec::new() });
|
||||||
}
|
}
|
||||||
if self.commands.len() > MAX_COMMANDS {
|
if self.commands.len() > MAX_COMMANDS {
|
||||||
|
|
@ -392,7 +398,7 @@ fn first_duplicate<'a>(names: impl Iterator<Item = &'a str>) -> Option<&'a str>
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An id: 1..=`max` ASCII letters, digits, `.`, `-` or `_`.
|
/// 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, '.' | '-' | '_')) {
|
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)));
|
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.
|
/// Trimmed text of at most `max` characters without control characters.
|
||||||
fn clean(what: &str, s: &str, max: usize) -> Result<String, String> {
|
pub(crate) fn clean(what: &str, s: &str, max: usize) -> Result<String, String> {
|
||||||
let s = s.trim();
|
let s = s.trim();
|
||||||
if s.chars().count() > max {
|
if s.chars().count() > max {
|
||||||
return Err(format!("{what} is longer than {max} characters"));
|
return Err(format!("{what} is longer than {max} characters"));
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
//! memory cap, and sees the outside world only through the host functions below. There is no WASI:
|
//! memory cap, and sees the outside world only through the host functions below. There is no WASI:
|
||||||
//! no file system, network, clock or randomness.
|
//! 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
|
//! 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.
|
//! 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_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_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. |
|
//! | `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):
|
//! 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
|
//! `{"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
|
//! 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.
|
//! 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::manifest::Panel;
|
||||||
|
use crate::widget::WidgetRender;
|
||||||
|
|
||||||
/// The module host functions are imported from.
|
/// The module host functions are imported from.
|
||||||
pub const IMPORT_MODULE: &str = "figcraft";
|
pub const IMPORT_MODULE: &str = "figcraft";
|
||||||
|
|
@ -55,6 +57,7 @@ const IMPORTS: &[(&str, usize, usize)] = &[
|
||||||
("setting_get", 2, 1),
|
("setting_get", 2, 1),
|
||||||
("setting_set", 4, 1),
|
("setting_set", 4, 1),
|
||||||
("setting_delete", 2, 0),
|
("setting_delete", 2, 0),
|
||||||
|
("widget_render", 2, 1),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// What a run may use.
|
/// What a run may use.
|
||||||
|
|
@ -147,6 +150,8 @@ pub struct RunOutput {
|
||||||
pub fuel: u64,
|
pub fuel: u64,
|
||||||
/// The last `ui_show` / `ui_close` of the run.
|
/// The last `ui_show` / `ui_close` of the run.
|
||||||
pub ui: Option<UiChange>,
|
pub ui: Option<UiChange>,
|
||||||
|
/// The last `widget_render` of the run.
|
||||||
|
pub widget: Option<WidgetRender>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A host call that needs the [`Host`]: the plugin is paused while the host answers it (see
|
/// 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<Linker<State>, PluginError> {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(err)?;
|
.map_err(err)?;
|
||||||
|
l.func_wrap(IMPORT_MODULE, "widget_render", |mut c: Ctx<'_>, ptr: i32, len: i32| -> Result<i32, wasmi::Error> {
|
||||||
|
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> {
|
l.func_wrap(IMPORT_MODULE, "ui_close", |mut c: Ctx<'_>| -> Result<(), wasmi::Error> {
|
||||||
charge(&mut c)?;
|
charge(&mut c)?;
|
||||||
c.data_mut().out.ui = Some(UiChange::Close);
|
c.data_mut().out.ui = Some(UiChange::Close);
|
||||||
|
|
|
||||||
|
|
@ -268,7 +268,7 @@ fn bad_manifests_are_refused() {
|
||||||
json!({"main": "C:\\plugin.wasm"}),
|
json!({"main": "C:\\plugin.wasm"}),
|
||||||
json!({"main": "plugin.js"}),
|
json!({"main": "plugin.js"}),
|
||||||
json!({"main": "a//b.wasm"}),
|
json!({"main": "a//b.wasm"}),
|
||||||
json!({"api": 3}),
|
json!({"api": 4}),
|
||||||
json!({"api": 0}),
|
json!({"api": 0}),
|
||||||
json!({"permissions": {"access": "root"}}),
|
json!({"permissions": {"access": "root"}}),
|
||||||
json!({"permissions": {"commands": ["*.x"]}}),
|
json!({"permissions": {"commands": ["*.x"]}}),
|
||||||
|
|
|
||||||
|
|
@ -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<f64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub y: Option<f64>,
|
||||||
|
/// 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<f64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub height: Option<f64>,
|
||||||
|
/// Hex colours (`#RRGGBB` or `#RRGGBBAA`); for text, `color` is the text's.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub fill: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub stroke: Option<String>,
|
||||||
|
#[serde(rename = "strokeWeight", default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub stroke_weight: Option<f64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub radius: Option<f64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub opacity: Option<f64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub layout: Option<WidgetLayout>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gap: Option<f64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub padding: Option<f64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub align: Option<WidgetAlign>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub justify: Option<WidgetAlign>,
|
||||||
|
#[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<f64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color: Option<String>,
|
||||||
|
#[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<WidgetNode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Map<String, Value>>,
|
||||||
|
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<WidgetRender, String> {
|
||||||
|
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<String, Value>) -> 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<f64>, 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<String>) -> 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String, String> {
|
||||||
|
Ok("{}".into())
|
||||||
|
}
|
||||||
|
fn selection(&mut self) -> String {
|
||||||
|
"[]".into()
|
||||||
|
}
|
||||||
|
fn notify(&mut self, _message: &str) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(v: Value) -> Result<WidgetRender, String> {
|
||||||
|
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::<String>());
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
@ -1270,6 +1270,7 @@ name = "English"
|
||||||
"While pressing" = "While pressing"
|
"While pressing" = "While pressing"
|
||||||
"White" = "White"
|
"White" = "White"
|
||||||
"Why it changed" = "Why it changed"
|
"Why it changed" = "Why it changed"
|
||||||
|
"Widgets" = "Widgets"
|
||||||
"Width" = "Width"
|
"Width" = "Width"
|
||||||
"Width point" = "Width point"
|
"Width point" = "Width point"
|
||||||
"Width profile" = "Width profile"
|
"Width profile" = "Width profile"
|
||||||
|
|
|
||||||
|
|
@ -1271,6 +1271,7 @@ status = "draft — needs native review"
|
||||||
"While pressing" = "Mientras se presiona"
|
"While pressing" = "Mientras se presiona"
|
||||||
"White" = "Blanco"
|
"White" = "Blanco"
|
||||||
"Why it changed" = "Por qué cambió"
|
"Why it changed" = "Por qué cambió"
|
||||||
|
"Widgets" = "Widgets"
|
||||||
"Width" = "Ancho"
|
"Width" = "Ancho"
|
||||||
"Width point" = "Punto de ancho"
|
"Width point" = "Punto de ancho"
|
||||||
"Width profile" = "Perfil de ancho"
|
"Width profile" = "Perfil de ancho"
|
||||||
|
|
@ -1900,6 +1901,9 @@ status = "draft — needs native review"
|
||||||
"view.zoomIn" = "Acercar"
|
"view.zoomIn" = "Acercar"
|
||||||
"view.zoomOut" = "Alejar"
|
"view.zoomOut" = "Alejar"
|
||||||
"view.zoomSelection" = "Zoom a la selección"
|
"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.focus" = "Traer la ventana al frente"
|
||||||
"window.new" = "Nueva ventana"
|
"window.new" = "Nueva ventana"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -248,6 +248,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
|
||||||
Cursor::Grabbing => egui::CursorIcon::Grabbing,
|
Cursor::Grabbing => egui::CursorIcon::Grabbing,
|
||||||
Cursor::Text => egui::CursorIcon::Text,
|
Cursor::Text => egui::CursorIcon::Text,
|
||||||
Cursor::Copy => egui::CursorIcon::Copy,
|
Cursor::Copy => egui::CursorIcon::Copy,
|
||||||
|
Cursor::Pointer => egui::CursorIcon::PointingHand,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
ctx.set_cursor_icon(c);
|
ctx.set_cursor_icon(c);
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,13 @@ pub enum PickerTarget {
|
||||||
id: String,
|
id: String,
|
||||||
index: usize,
|
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<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The paints of colour style `id`.
|
/// The paints of colour style `id`.
|
||||||
|
|
@ -85,11 +92,31 @@ fn current_paint(app: &FigApp, target: &PickerTarget) -> Option<Paint> {
|
||||||
_ => None,
|
_ => None,
|
||||||
},
|
},
|
||||||
PickerTarget::Style { id, index } => style_paints(doc, id)?.get(*index).cloned(),
|
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.
|
/// Write `paint` to the target. `live` edits are part of an ongoing drag.
|
||||||
fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) {
|
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 {
|
if live {
|
||||||
let _ = app.session.begin_interaction("Color");
|
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);
|
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()}))
|
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 } => {
|
PickerTarget::Style { id, index } => {
|
||||||
let Some(mut paints) = app.session.doc().ok().and_then(|d| style_paints(d, id)) else { return };
|
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) {
|
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) {
|
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) {
|
if app.session.ds().map(|d| d.interaction.is_some()).unwrap_or(false) {
|
||||||
let _ = app.session.commit_interaction();
|
let _ = app.session.commit_interaction();
|
||||||
}
|
}
|
||||||
|
|
@ -374,7 +406,12 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||||
};
|
};
|
||||||
let t = Tokens::get(ctx);
|
let t = Tokens::get(ctx);
|
||||||
let screen = ctx.content_rect();
|
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 open = true;
|
||||||
let mut close_now = false;
|
let mut close_now = false;
|
||||||
egui::Window::new("color_picker")
|
egui::Window::new("color_picker")
|
||||||
|
|
@ -395,7 +432,10 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||||
close_now = true;
|
close_now = true;
|
||||||
}
|
}
|
||||||
// Paint type.
|
// Paint type.
|
||||||
if !matches!(target, PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. }) {
|
if !matches!(
|
||||||
|
target,
|
||||||
|
PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. }
|
||||||
|
) {
|
||||||
let kinds = [
|
let kinds = [
|
||||||
(Icon::Fill, tr("Solid")),
|
(Icon::Fill, tr("Solid")),
|
||||||
(Icon::LayoutH, tr("Linear")),
|
(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.
|
// 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.horizontal(|ui| {
|
||||||
ui.add_space(16.0);
|
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"));
|
let resp = widgets::dropdown(ui, tr(paint.blend.label()), 208.0, Some(Icon::Blend)).on_hover_text(tr("Blend mode"));
|
||||||
|
|
|
||||||
|
|
@ -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
|
//! 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.
|
//! open panel draws no frames while nothing changes.
|
||||||
|
|
||||||
|
|
@ -7,12 +7,13 @@ use egui::Pos2;
|
||||||
use figcraft_engine::Session;
|
use figcraft_engine::Session;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::colorpicker::PickerTarget;
|
||||||
use crate::idle_tests::Harness;
|
use crate::idle_tests::Harness;
|
||||||
use crate::layers_drag_tests::Ui;
|
use crate::layers_drag_tests::Ui;
|
||||||
|
|
||||||
/// A session with a plugin whose first call shows a panel with every kind of control and whose
|
/// A plugin whose first call shows a panel with every kind of control and whose later calls (the
|
||||||
/// later calls (the panel's events) make a rectangle.
|
/// panel's events) make a rectangle.
|
||||||
fn session() -> Session {
|
fn panel_module() -> Vec<u8> {
|
||||||
let spec = json!({"title": "Test panel", "controls": [
|
let spec = json!({"title": "Test panel", "controls": [
|
||||||
{"type": "note", "label": "Hello from the plugin"},
|
{"type": "note", "label": "Hello from the plugin"},
|
||||||
{"type": "number", "name": "size", "label": "Size", "value": 10},
|
{"type": "number", "name": "size", "label": "Size", "value": 10},
|
||||||
|
|
@ -41,7 +42,12 @@ fn session() -> Session {
|
||||||
spec.len(),
|
spec.len(),
|
||||||
rect.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 manifest = json!({"id": "org.test.panel", "name": "Panel test", "main": "p.wasm", "api": 2, "permissions": {"access": "write"}});
|
||||||
let mut s = Session::new();
|
let mut s = Session::new();
|
||||||
s.execute("plugin.import", &json!({"manifest": manifest, "wasm": base64::engine::general_purpose::STANDARD.encode(wasm)})).unwrap();
|
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);
|
u.button(at, false);
|
||||||
assert_eq!(layers(&u), before + 1, "Go made the plugin draw a rectangle");
|
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_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.
|
// Its close button ends the session.
|
||||||
u.app.run("plugin.close", json!({"id": "org.test.panel"}));
|
u.app.run("plugin.close", json!({"id": "org.test.panel"}));
|
||||||
u.frame(vec![]);
|
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() {
|
fn an_open_plugin_panel_draws_no_frames_while_idle() {
|
||||||
Harness::new(session()).assert_idle("a plugin panel");
|
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");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
//! Plugins in the UI: the installed plugins in the Plugins menu, the parameters form a command
|
//! Plugins in the UI: the installed plugins in the Plugins menu (widgets in Plugins ▸ Widgets), the
|
||||||
//! with parameters asks for before it runs, the panel a running plugin shows, and the Manage
|
//! parameters form a command with parameters asks for before it runs, the panels running plugins
|
||||||
//! plugins window. Everything runs through the engine's `plugin.*` commands.
|
//! show, and the Manage plugins window. Everything runs through the engine's `plugin.*` and
|
||||||
|
//! `widget.*` commands.
|
||||||
|
|
||||||
use egui::{Align2, Context, Id, RichText, Ui};
|
use egui::{Align2, Context, Id, RichText, Ui};
|
||||||
use figcraft_engine::plugins::{Access, InstalledPlugin, Param, ParamKind};
|
use figcraft_engine::plugins::{Access, InstalledPlugin, Param, ParamKind};
|
||||||
use serde_json::{Map, Value, json};
|
use serde_json::{Map, Value, json};
|
||||||
|
|
||||||
use crate::FigApp;
|
use crate::FigApp;
|
||||||
|
use crate::colorpicker::PickerTarget;
|
||||||
use crate::dialogs::{floating, window};
|
use crate::dialogs::{floating, window};
|
||||||
use crate::i18n::{tr, trf};
|
use crate::i18n::{tr, trf};
|
||||||
use crate::theme::{Tokens, regular, semibold};
|
use crate::theme::{Tokens, regular, semibold};
|
||||||
|
|
@ -22,6 +24,47 @@ pub struct PluginForm {
|
||||||
|
|
||||||
/// Width of the plugin windows' fields.
|
/// Width of the plugin windows' fields.
|
||||||
const FIELD_W: f32 = 220.0;
|
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<String> {
|
||||||
|
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<egui::Rect> {
|
||||||
|
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.
|
/// 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) {
|
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 {
|
for p in &app.session.prefs.plugins {
|
||||||
let m = &p.manifest;
|
let m = &p.manifest;
|
||||||
match m.commands.as_slice() {
|
match m.commands.as_slice() {
|
||||||
|
// A widget may have no commands: it's in Widgets.
|
||||||
|
[] => {}
|
||||||
[only] => {
|
[only] => {
|
||||||
// An ellipsis: it asks for parameters first.
|
// An ellipsis: it asks for parameters first.
|
||||||
let label = if only.params.is_empty() { m.name.clone() } else { format!("{}…", m.name) };
|
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);
|
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 };
|
let Some((id, command)) = chosen else { return false };
|
||||||
start(app, &id, &command);
|
start(app, &id, &command);
|
||||||
true
|
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) {
|
pub fn show(app: &mut FigApp, ctx: &Context) {
|
||||||
if app.ui.plugins_window {
|
if app.ui.plugins_window {
|
||||||
manage_window(app, ctx);
|
manage_window(app, ctx);
|
||||||
|
|
@ -96,30 +154,43 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
||||||
if app.ui.plugin_form.is_some() {
|
if app.ui.plugin_form.is_some() {
|
||||||
form_window(app, ctx);
|
form_window(app, ctx);
|
||||||
}
|
}
|
||||||
if app.session.plugins.panel.is_some() {
|
if !app.session.plugins.panels.is_empty() {
|
||||||
panel_window(app, ctx);
|
panel_windows(app, ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The panel a running plugin shows, at first beside the properties panel (it can be moved): a
|
/// The panels running plugins show, at first beside the properties panel and each other (they
|
||||||
/// pressed button or a changed value calls the plugin back (`plugin.event`); closing it ends the
|
/// can be moved): a pressed button or a changed value calls its plugin
|
||||||
/// plugin's session.
|
/// back (`plugin.event`); closing one ends its plugin's session.
|
||||||
fn panel_window(app: &mut FigApp, ctx: &Context) {
|
fn panel_windows(app: &mut FigApp, ctx: &Context) {
|
||||||
let Some(open) = app.session.plugins.panel.as_ref() else { return };
|
|
||||||
let t = Tokens::get(ctx);
|
let t = Tokens::get(ctx);
|
||||||
let mut keep = true;
|
let right = ctx.content_rect().right() - 252.0;
|
||||||
let mut changed = None;
|
// (plugin, what was used; None: closed)
|
||||||
let id = open.manifest.id.as_str();
|
let mut actions: Vec<(String, Option<Used>)> = Vec::new();
|
||||||
let at = egui::pos2(ctx.content_rect().right() - 252.0, 52.0);
|
for (i, open) in app.session.plugins.panels.iter().enumerate() {
|
||||||
floating(open.title(), ctx).id(Id::new(("plugin_panel", id))).pivot(Align2::RIGHT_TOP).default_pos(at).open(&mut keep).show(ctx, |ui| {
|
let id = open.manifest.id.as_str();
|
||||||
ui.set_width(320.0);
|
let mut keep = true;
|
||||||
changed = controls(ui, id, &open.panel.controls, &open.values, &t);
|
let mut used = None;
|
||||||
});
|
// Side by side leftwards from the properties panel, three to a row.
|
||||||
let id = id.to_string();
|
let at = egui::pos2(right - PANEL_STEP * (i % 3) as f32, 52.0 + 28.0 * (i / 3) as f32);
|
||||||
if !keep {
|
floating(open.title(), ctx).id(Id::new(("plugin_panel", id))).pivot(Align2::RIGHT_TOP).default_pos(at).open(&mut keep).show(ctx, |ui| {
|
||||||
app.run("plugin.close", json!({"id": id}));
|
ui.set_width(320.0);
|
||||||
} else if let Some((control, value)) = changed {
|
used = controls(ui, id, &open.panel.controls, &open.values, &t);
|
||||||
run(app, "plugin.event", json!({"id": id, "control": control, "value": value}));
|
});
|
||||||
|
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 open = true;
|
||||||
let mut submit = false;
|
let mut submit = false;
|
||||||
let mut cancel = false;
|
let mut cancel = false;
|
||||||
|
let mut pick = None;
|
||||||
let label = command.label.trim_end_matches(['…', '.']).trim();
|
let label = command.label.trim_end_matches(['…', '.']).trim();
|
||||||
let title = if label.contains(name.as_str()) { label.to_string() } else { format!("{name}: {label}") };
|
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| {
|
window(&title, ctx).id(Id::new("plugin_form")).open(&mut open).show(ctx, |ui| {
|
||||||
ui.set_width(320.0);
|
ui.set_width(320.0);
|
||||||
if let Some((name, v)) = controls(ui, &format!("{}/{}", form.id, form.command), &command.params, &form.values, &t) {
|
match controls(ui, &format!("{}/{}", form.id, form.command), &command.params, &form.values, &t) {
|
||||||
form.values.insert(name, v);
|
Some(Used::Set(name, v)) => {
|
||||||
|
form.values.insert(name, v);
|
||||||
|
}
|
||||||
|
Some(Used::Pick(control)) => pick = Some(control),
|
||||||
|
None => {}
|
||||||
}
|
}
|
||||||
ui.add_space(12.0);
|
ui.add_space(12.0);
|
||||||
ui.horizontal(|ui| {
|
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());
|
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 {
|
if submit {
|
||||||
run(app, "plugin.run", json!({"id": form.id, "command": form.command, "params": form.values}));
|
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,
|
/// 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
|
/// the first button filled. Returns what the user did to which control.
|
||||||
/// value (null for a button).
|
fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map<String, Value>, t: &Tokens) -> Option<Used> {
|
||||||
fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map<String, Value>, t: &Tokens) -> Option<(String, Value)> {
|
|
||||||
let mut changed = None;
|
let mut changed = None;
|
||||||
let mut first_button = true;
|
let mut first_button = true;
|
||||||
egui::Grid::new(("plugin_controls", salt)).num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
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<String, Valu
|
||||||
ParamKind::Button => {
|
ParamKind::Button => {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
let pressed = row.iter().filter(|b| widgets::button(ui, b.title(), std::mem::take(&mut first_button), true).clicked());
|
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
|
.inner
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let current = values.get(&p.name).cloned().unwrap_or_else(|| p.default_value());
|
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() {
|
if hit.is_some() {
|
||||||
|
|
@ -264,10 +342,10 @@ fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map<String, Valu
|
||||||
changed
|
changed
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One parameter's field, named for screen readers; the new value when it changed.
|
/// 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<Value> {
|
fn field(ui: &mut Ui, id: Id, p: &Param, current: &Value) -> Option<Used> {
|
||||||
let name = p.title();
|
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::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)? {
|
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(),
|
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<Value> {
|
||||||
})
|
})
|
||||||
.inner
|
.inner
|
||||||
}
|
}
|
||||||
// shortcut: a swatch and a hex field, no colour picker popover; hook the picker up when
|
// A swatch that opens the colour picker, and the hex.
|
||||||
// plugins need more than hex.
|
|
||||||
ParamKind::Color => {
|
ParamKind::Color => {
|
||||||
let hex = current.as_str().unwrap_or_default();
|
let hex = current.as_str().unwrap_or_default();
|
||||||
ui.horizontal(|ui| {
|
let (picked, typed) = ui
|
||||||
let color = figcraft_color::Color::from_hex(hex).unwrap_or(figcraft_color::Color::BLACK);
|
.horizontal(|ui| {
|
||||||
widgets::swatch(ui, &figcraft_color::Paint::solid(color), widgets::FIELD_H);
|
let color = figcraft_color::Color::from_hex(hex).unwrap_or(figcraft_color::Color::BLACK);
|
||||||
widgets::text_field_named(ui, id, hex, FIELD_W - widgets::FIELD_H - 8.0, name).and_then(|s| p.coerce(&json!(s)).ok())
|
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
|
})
|
||||||
|
.inner;
|
||||||
|
if picked {
|
||||||
|
return Some(Used::Pick(p.name.clone()));
|
||||||
|
}
|
||||||
|
typed
|
||||||
}
|
}
|
||||||
ParamKind::Button | ParamKind::Note => None,
|
ParamKind::Button | ParamKind::Note => None,
|
||||||
}
|
};
|
||||||
|
v.map(|v| Used::Set(p.name.clone(), v))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
docs/gaps.md
22
docs/gaps.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Where FigCraft falls short of Figma
|
# 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
|
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
|
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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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
|
[`localization-parity.md`](localization-parity.md); how to add one in
|
||||||
[`translating.md`](translating.md).
|
[`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
|
- **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,
|
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
|
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
|
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
|
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.
|
`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
|
Widgets (plugin API 3, 2026-10-12): a plugin draws a layer of its own on the canvas (Plugins ▸
|
||||||
time with no colour picker or images. Figma's own plugins are JavaScript against Figma's plugin
|
Widgets, `widget.insert`), validated and built with locked layers inside, called back for clicks
|
||||||
API and don't run.
|
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)
|
- **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
|
can't bring them. FigCraft's CLI, control channel and MCP server cover agent automation better
|
||||||
than Figma does.
|
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**.
|
needs a JS engine and an API shim: **owner decision needed**.
|
||||||
|
|
||||||
### 7. CPU canvas where Figma uses the GPU
|
### 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 |
|
| 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 | 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 | 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 |
|
| 2026-10-12 | minor | Layers and Assets panels behaviour suite and fixes added to gap 4's landed list; ranking and hours unchanged |
|
||||||
|
|
|
||||||
18
docs/mcp.md
18
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
|
plugins. A headless MCP session starts with no plugins: import them first. `plugins.runLast` runs
|
||||||
the last plugin command again.
|
the last plugin command again.
|
||||||
|
|
||||||
A plugin that shows a panel (API 2) can be used headless too: `plugin.ui {id?}` returns the open
|
A plugin that shows a panel (API 2) can be used headless too: `plugin.ui {id?}` returns plugin
|
||||||
panel (`{plugin, command, title, controls, values}`, or null), `plugin.event {id, control, value?}`
|
`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;
|
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.
|
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).
|
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"}}}}
|
{"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
|
### Dev Mode
|
||||||
|
|
||||||
- **Component code:** `inspect.code {id, language: react|swiftui|compose}` on a component, a
|
- **Component code:** `inspect.code {id, language: react|swiftui|compose}` on a component, a
|
||||||
|
|
|
||||||
116
docs/plugins.md
116
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
|
- **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
|
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.
|
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
|
- **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.
|
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.
|
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
|
- **Plugins ▸ Manage plugins…** lists the installed plugins (version, access, manifest path) with a
|
||||||
Remove button for each, and an Import button.
|
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.
|
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,
|
**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,
|
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
|
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
|
and named for screen readers. A colour field's swatch opens FigCraft's colour picker (the plugin hears
|
||||||
field's value; what that callback does is one undo step (none when it changes nothing), and the
|
of the colour when a drag in the picker ends); its hex can also be typed. Pressing a button or
|
||||||
plugin can update the panel or close it. The module stays loaded while its panel is open, so the
|
changing a field calls the plugin back with every field's value; what that callback does is one
|
||||||
plugin keeps its state between callbacks. Closing the window ends the plugin's session. One plugin
|
undo step (none when it changes nothing), and the plugin can update the panel or close it. The
|
||||||
panel is open at a time: running another plugin that shows one replaces it. An open panel costs
|
module stays loaded while its panel is open, so the plugin keeps its state between callbacks.
|
||||||
nothing while nobody uses it (no frames are drawn).
|
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
|
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.
|
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": "palette", "value": "Ocean"}}
|
||||||
{"command": "plugin.event", "params": {"id": "org.figcraft.examples.swatch-grid", "control": "generate"}}
|
{"command": "plugin.event", "params": {"id": "org.figcraft.examples.swatch-grid", "control": "generate"}}
|
||||||
{"command": "plugin.close", "params": {"id": "org.figcraft.examples.swatch-grid"}}
|
{"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
|
`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.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.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.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.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. |
|
| `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.runLast` | `{}` | Runs the last plugin command again. |
|
||||||
| `plugins.manage` | `{}` | Opens the Plugins window; returns the list like `plugin.list`. |
|
| `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`
|
## 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). |
|
| `name` | Required. Shown in the Plugins menu (64 characters at most). |
|
||||||
| `version`, `description` | Optional text. |
|
| `version`, `description` | Optional text. |
|
||||||
| `main` | Required. The module, a `.wasm` path relative to the manifest, inside its folder. |
|
| `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.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. |
|
| `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. |
|
| `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
|
## 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
|
- other documents and the version history: `doc.*` and `version.*` (except `doc.list` and
|
||||||
`version.list`);
|
`version.list`);
|
||||||
- the undo history and nesting: `edit.undo`, `edit.redo`, `edit.repeat`, `command.batch` (a run is
|
- 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
|
- 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;
|
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
|
- 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
|
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
|
(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
|
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
|
Version 2 adds panels and settings to version 1, and version 3 adds widgets; a version 1 or 2
|
||||||
input it always got. Strings are UTF-8 `(ptr, len)` pairs in the plugin's linear memory; all values
|
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`.
|
are `i32`.
|
||||||
|
|
||||||
The module **exports** `memory` and `figcraft_run(input_len) -> status`. When `figcraft_run` starts,
|
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}}
|
{"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
|
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.
|
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_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_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. |
|
| `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
|
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
|
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
|
is the primary one. FigCraft checks the whole spec (sizes, kinds, options, bounds) and drops values
|
||||||
that don't fit their field.
|
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
|
## Writing a plugin in Rust
|
||||||
|
|
||||||
`plugins/` in the repository is a workspace of its own: `plugins/sdk` (the crate
|
`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
|
`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
|
- **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
|
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.
|
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
|
- **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
|
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
|
picked last is remembered as a setting for next time. **From hex colours…** takes a palette of
|
||||||
hex colours in the parameters form.
|
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
|
`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
|
`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
|
`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
|
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
|
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
|
panel, `setting(key)`, `set_setting(key, &value)` and `delete_setting(key)` for settings. With
|
||||||
be unit-tested with `cargo test`.
|
`"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`,
|
Any language that compiles to WebAssembly works the same way: export `memory` and `figcraft_run`,
|
||||||
import what you need from `figcraft`.
|
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
|
- Distribution: no plugin directory, signing or automatic updates; plugins are imported from a
|
||||||
manifest.
|
manifest.
|
||||||
- Widgets (objects on the canvas drawn by a plugin) and plugins written for other apps (JavaScript
|
- Plugins written for other apps (JavaScript plugins) don't run.
|
||||||
plugins): neither runs.
|
- Widgets: clicks are their only events (no text input, hover, property menu or timers), they
|
||||||
- Panels: one at a time; a colour field is a swatch with a hex field (no colour picker); no images,
|
can't open a panel, and their layers are frames, text, rectangles, ellipses and images only (no
|
||||||
sliders or free layout inside a panel.
|
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:
|
- 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.
|
{pluginData: {key: value}}}`; there is no separate per-document store.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# FigCraft roadmap: milestones and what's next
|
# 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
|
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
|
[`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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| M12 AI features (new, owner decision) | not started | local models | 30–60 |
|
||||||
|
|
||||||
## Next, after the current focus
|
## Next, after the current focus
|
||||||
|
|
@ -72,6 +72,7 @@ blockers.
|
||||||
|
|
||||||
| Date | Change | Summary |
|
| 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-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 | `.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 |
|
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# FigCraft parity with Figma
|
# 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)
|
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
|
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) |
|
| 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) |
|
| 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 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)) | |
|
| 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
|
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 |
|
| 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) |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| | Core editor only (1–16, ÷94) | 94 | ~94% | ~72% | | 33–75 |
|
||||||
|
|
||||||
## By dimension (ready for real work)
|
## 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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
|
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
|
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
|
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
|
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`
|
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
|
for scripts and MCP) and per-plugin settings kept with the preferences. Then widgets (API 3:
|
||||||
distribution; for collaboration, the browser client and a standalone server. 15–40 h.
|
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
|
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
|
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
|
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 |
|
| 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 | 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 | 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 |
|
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,13 @@
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "counter"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"figcraft-plugin-sdk",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "figcraft-plugin-sdk"
|
name = "figcraft-plugin-sdk"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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" }
|
||||||
|
}
|
||||||
|
|
@ -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<Value> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"id": "org.figcraft.examples.swatch-grid",
|
"id": "org.figcraft.examples.swatch-grid",
|
||||||
"name": "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.",
|
"description": "Makes a grid of colour swatches from a built-in palette (in a panel) or from hex colours.",
|
||||||
"main": "swatch_grid.wasm",
|
"main": "swatch_grid.wasm",
|
||||||
"api": 2,
|
"api": 2,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@
|
||||||
|
|
||||||
use figcraft_plugin_sdk::{Event, Input, Result, Value, call, close_ui, entry, json, log, notify, set_setting, setting, show_ui};
|
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.
|
/// Swatches made in one run, at most.
|
||||||
const MAX_SWATCHES: usize = 256;
|
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)
|
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.
|
/// Swatches of `colors` in a grid centred in the view (labelled in colour `labels` when given),
|
||||||
fn grid(colors: &[String], columns: f64, size: f64, gap: f64, labels: bool) -> Result<Value> {
|
/// then selected.
|
||||||
|
fn grid(colors: &[String], columns: f64, size: f64, gap: f64, labels: Option<&str>) -> Result<Value> {
|
||||||
if colors.is_empty() {
|
if colors.is_empty() {
|
||||||
return Err("The palette has no hex colours (like #FF5A5F, #00A699)".into());
|
return Err("The palette has no hex colours (like #FF5A5F, #00A699)".into());
|
||||||
}
|
}
|
||||||
let columns = columns.clamp(1.0, 24.0) as usize;
|
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 (size, gap) = (size.clamp(8.0, 512.0), gap.clamp(0.0, 256.0));
|
||||||
let rows = colors.len().div_ceil(columns);
|
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);
|
let (w, h) = (columns as f64 * (size + gap) - gap, rows as f64 * (cell_h + gap) - gap);
|
||||||
// Centred in the view, on whole pixels.
|
// Centred in the view, on whole pixels.
|
||||||
let view = call("view.get", &json!({}))?;
|
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 (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}}))?;
|
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());
|
made.extend(r["id"].as_u64());
|
||||||
if labels {
|
if let Some(label) = labels {
|
||||||
let t = call("text.create", &json!({"text": color, "x": x, "y": y + size + 4.0, "props": {"fontSize": 11}}))?;
|
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());
|
made.extend(t["id"].as_u64());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -86,6 +90,7 @@ fn panel(name: &str, values: &Value) -> Result<Value> {
|
||||||
{"type": "note", "label": named(name)},
|
{"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": "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": "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": "generate", "label": "Generate"},
|
||||||
{"type": "button", "name": "close", "label": "Close"}
|
{"type": "button", "name": "close", "label": "Close"}
|
||||||
]}))?;
|
]}))?;
|
||||||
|
|
@ -102,7 +107,8 @@ fn on_event(e: Event) -> Result<Value> {
|
||||||
panel(name, &e.values)
|
panel(name, &e.values)
|
||||||
}
|
}
|
||||||
"generate" => {
|
"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" => {
|
||||||
close_ui();
|
close_ui();
|
||||||
|
|
@ -126,7 +132,7 @@ fn run(input: Input) -> Result<Value> {
|
||||||
input.f64("columns").unwrap_or(3.0),
|
input.f64("columns").unwrap_or(3.0),
|
||||||
input.f64("size").unwrap_or(64.0),
|
input.f64("size").unwrap_or(64.0),
|
||||||
input.f64("gap").unwrap_or(16.0),
|
input.f64("gap").unwrap_or(16.0),
|
||||||
input.bool("labels").unwrap_or(true),
|
input.bool("labels").unwrap_or(true).then_some(LABEL),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,11 @@
|
||||||
//! loaded while the panel is open, so statics keep their values between those calls. Settings
|
//! 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.
|
//! ([`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
|
//! 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.
|
//! own logic can still be unit-tested on the host. See `docs/plugins.md` for the ABI underneath.
|
||||||
#![cfg_attr(not(test), no_std)]
|
#![cfg_attr(not(test), no_std)]
|
||||||
|
|
@ -50,6 +55,19 @@ pub struct Input {
|
||||||
pub selection: Vec<u64>,
|
pub selection: Vec<u64>,
|
||||||
/// Set when the plugin's panel calls it back.
|
/// Set when the plugin's panel calls it back.
|
||||||
pub event: Option<Event>,
|
pub event: Option<Event>,
|
||||||
|
/// Set when the plugin's widget is inserted or clicked.
|
||||||
|
pub widget: Option<Widget>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A button pressed or a value changed in the plugin's panel.
|
/// 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),
|
value: e.get("value").cloned().unwrap_or(Value::Null),
|
||||||
values: v.get("values").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();
|
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`].
|
/// The plugin's setting `key`, as stored by [`set_setting`].
|
||||||
pub fn setting(key: &str) -> Option<Value> {
|
pub fn setting(key: &str) -> Option<Value> {
|
||||||
if host::setting_get(key.as_bytes()) != 0 {
|
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_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_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 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]) {
|
pub fn setting_delete(key: &[u8]) {
|
||||||
unsafe { sys::setting_delete(key.as_ptr(), key.len()) }
|
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.
|
/// Outside WebAssembly (a plugin's own unit tests) every host call fails.
|
||||||
|
|
@ -278,6 +313,9 @@ mod host {
|
||||||
1
|
1
|
||||||
}
|
}
|
||||||
pub fn setting_delete(_key: &[u8]) {}
|
pub fn setting_delete(_key: &[u8]) {}
|
||||||
|
pub fn widget_render(_s: &[u8]) -> i32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -300,5 +338,11 @@ mod tests {
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
assert_eq!((e.control.as_str(), &e.value, &e.values), ("go", &Value::Null, &json!({"n": 2})));
|
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!(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")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ fn wasm() -> bool {
|
||||||
|
|
||||||
/// The example plugins in `plugins/examples` (each folder's name is its package; its module is
|
/// The example plugins in `plugins/examples` (each folder's name is its package; its module is
|
||||||
/// `<name with _>.wasm`).
|
/// `<name with _>.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
|
/// The plugin workspace: fmt, clippy and build for wasm32, host tests, then each example's manifest
|
||||||
/// and module side by side in `<target>/plugins/dist/<example>/`.
|
/// and module side by side in `<target>/plugins/dist/<example>/`.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue