M5: prototyping tail: gamepad triggers, widgets in prototypes, mode lookups

The part of gap 15 that doesn't need video, from the public documentation of prototype triggers, plugins/widgets and prototype expressions.

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

Dependencies: none added. Windows uses the existing `windows` 0.62 dependency with the `Gaming_Input` feature; the browser uses the existing `web-sys` with three more features. The usual Rust controller library (gilrs) was not taken: its Linux backend links the system's libudev (a C library, needing headers on CI and the FreeBSD job), which breaks the pure-Rust rule.
This commit is contained in:
bflatastic
2026-10-12 05:28:53 +07:00
committed by BFlat
parent def6f84b22
commit 119b8c7441
34 changed files with 945 additions and 59 deletions
+5 -4
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; 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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -19,7 +19,7 @@ start with [`docs/gaps.md`](docs/gaps.md).
| Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
| Mainstream practitioner | **~49%** | estimated: weekly areas 72.6% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
| Remaining to beta | **~155–280 h** one agent | estimated |
| Remaining to full parity | **~266–524 h** one agent (~83–145 h wall-clock with 5 agents) | estimated |
| Remaining to full parity | **~265–523 h** one agent (~83–145 h wall-clock with 5 agents) | estimated |
### Readiness by audience
@@ -56,7 +56,7 @@ native speakers' reviews of the six language drafts need people.
| Dimension | Ready | Hours | Doc |
|---|---|---|---|
| Features (depth) | 63% (core editor 72%) | 29–68 | [target-app-parity](docs/target-app-parity.md) |
| Features (depth) | 63% (core editor 72%) | 28–67 | [target-app-parity](docs/target-app-parity.md) |
| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
| File formats | 59% (`.fig` read best effort with editable vectors, no write) | 5–10 | [file-format-parity](docs/file-format-parity.md) |
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) |
@@ -84,7 +84,7 @@ native speakers' reviews of the six language drafts need people.
| Text | 90% | 70% | 3–7 | [target-app-parity](docs/target-app-parity.md) |
| Components & variants | 96% | 72% | 4–9 | [components-parity](docs/components-parity.md) |
| Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) |
| Prototyping | 90% | 74% | 3–6 | [prototyping-parity](docs/prototyping-parity.md) |
| Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) |
| Import/export | 84% | 57% | 5–10 | [file-format-parity](docs/file-format-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) |
@@ -236,6 +236,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Prototyping tail (gap 15, the part that doesn't need video): key/gamepad triggers take controller buttons (`Gamepad A` … `Gamepad Home`, the standard layout's 17 buttons, from a Controller menu beside the key field; the REST import maps controller triggers instead of skipping them); the player reads controllers on Windows (Windows.Gaming.Input through the `windows` crate the app already used; no new dependency) and in the browser (the Gamepad API), polling only while the prototype has such a trigger and the window has the focus, and the control channel presses buttons (`ui.input {gamepad}`); macOS, Linux and FreeBSD read no controllers yet. Widgets react to clicks in a presented prototype (the canvas's callback and sandbox limits, on the prototype's copy: the file and its history don't change, restarting starts over). `proto.evaluate` computes an expression where a layer's interaction would run (MCP); the mode lookup and set-variable-mode rules are cases in a behaviour suite (they already held). Prototyping breadth 90% → 92%, ready unchanged at 74%, 3–6 h → 2–5 h; area hours 120–260 → 119–259 (core 33–75 → 32–74), Features (depth) 29–68 h → 28–67 h; percentages unchanged at this rounding; full parity ~276–538 h → ~275–537 h |
| 2026-10-12 | minor | Plugin directory on `figcraft-server` (gap 6): the server keeps plugin bundles (manifest, module, up to 32 assets; an uncompressed length-prefixed format named by its SHA-256) published with the server key (`figcraft-cli plugin publish`, `plugin.directory.publish`), checked in full before they are kept (size cap, manifest and plugin API version, a version newer than the one kept, the module compiled but never run, plain asset names) and capped (`max_plugins`, `max_plugin_mb`, `max_plugins_mb`); anyone who reaches the server lists, searches and fetches them (hash-checked again on read, a shared download budget); the operator moderates with `figcraft-server plugins [remove]`. The app's Plugins ▸ Manage plugins gains a Browse tab: a server's plugins with Install / Update / Remove, an install confirmation that shows the permissions asked for, the source and the SHA-256, the download checked against the listed hash before anything is read, plugins kept per user in hash-named folders; `plugin.directory.list` / `install` (needs `confirm: true`) / `publish` / `unpublish` for scripts and MCP. Not in the browser build yet; no public catalogue, publisher accounts, signatures, ratings or background update checks. Plugins & widgets 55%/32% → 60%/35%, 10–30 h → 8–26 h; Ecosystem dimension 34% → 38%; weighted totals unchanged at this rounding; full parity ~281–548 h → ~279–544 h |
| 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Localization 30% → 35%, 40–65 h → 32–55 h; ready for real work ~52% → ~53% (52.9 → 53.1); full parity ~284–554 h → ~276–544 h; beta ~162–292 h → ~155–280 h |
| 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 |
+1 -1
View File
@@ -25,4 +25,4 @@ log = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
+17
View File
@@ -90,4 +90,21 @@ impl Services for WebServices {
fn system_language(&self) -> Option<String> {
web_sys::window().and_then(|w| w.navigator().language())
}
/// The Gamepad API's controllers with the standard mapping (its button order is the layout's;
/// others are skipped). Browsers list controllers once one of their buttons is pressed.
fn gamepad_held(&mut self) -> u32 {
let Some(pads) = web_sys::window().and_then(|w| w.navigator().get_gamepads().ok()) else { return 0 };
let mut held = 0;
for pad in pads.iter().filter_map(|p| p.dyn_into::<web_sys::Gamepad>().ok()) {
if pad.mapping() != web_sys::GamepadMappingType::Standard {
continue;
}
for (i, b) in pad.buttons().iter().take(32).enumerate() {
if b.dyn_into::<web_sys::GamepadButton>().is_ok_and(|b| b.pressed()) {
held |= 1 << i;
}
}
}
held
}
}
+3 -3
View File
@@ -26,10 +26,10 @@ webbrowser = { workspace = true }
image = { workspace = true }
env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] }
# The system's reduce-motion setting (UISettings.AnimationsEnabled) and interface language
# (GlobalizationPreferences.Languages).
# The system's reduce-motion setting (UISettings.AnimationsEnabled), interface language
# (GlobalizationPreferences.Languages) and controllers for prototypes (Gaming.Input.Gamepad).
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections"] }
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] }
[build-dependencies]
winresource = "0.1"
+50
View File
@@ -0,0 +1,50 @@
//! Controllers for the prototype player's key/gamepad triggers: the buttons held, as bits at their
//! standard gamepad layout indices (`figcraft_doc::proto::GAMEPAD_BUTTONS`).
//! Windows reads Windows.Gaming.Input (Xbox-compatible controllers, through the `windows` crate the
//! app already uses). macOS, Linux and FreeBSD report none yet: the usual Rust controller library
//! links the system's libudev on Linux, which the pure-Rust builds avoid.
/// The standard layout's buttons held on any controller (bit i: index i).
#[cfg(target_os = "windows")]
pub fn held() -> u32 {
use windows::Gaming::Input::{Gamepad, GamepadButtons as B};
// The standard index of each button Windows reports; the triggers are analog (down past halfway).
const MAP: [(B, u32); 14] = [
(B::A, 0),
(B::B, 1),
(B::X, 2),
(B::Y, 3),
(B::LeftShoulder, 4),
(B::RightShoulder, 5),
(B::View, 8),
(B::Menu, 9),
(B::LeftThumbstick, 10),
(B::RightThumbstick, 11),
(B::DPadUp, 12),
(B::DPadDown, 13),
(B::DPadLeft, 14),
(B::DPadRight, 15),
];
let Ok(pads) = Gamepad::Gamepads() else { return 0 };
let mut held = 0;
for pad in pads {
let Ok(r) = pad.GetCurrentReading() else { continue };
for (b, i) in MAP {
if r.Buttons.contains(b) {
held |= 1 << i;
}
}
if r.LeftTrigger > 0.5 {
held |= 1 << 6;
}
if r.RightTrigger > 0.5 {
held |= 1 << 7;
}
}
held
}
#[cfg(not(target_os = "windows"))]
pub fn held() -> u32 {
0
}
+1
View File
@@ -11,6 +11,7 @@
mod args;
mod control_server;
mod gamepad;
mod recovery;
mod services;
+3
View File
@@ -179,6 +179,9 @@ impl Services for Desktop {
fn reduce_motion(&self) -> bool {
self.reduce_motion.load(Ordering::Relaxed)
}
fn gamepad_held(&mut self) -> u32 {
crate::gamepad::held()
}
fn system_language(&self) -> Option<String> {
self.language.clone()
}
+44 -1
View File
@@ -47,12 +47,15 @@ impl Trigger {
Trigger::MouseDown => "Mouse down".into(),
Trigger::MouseUp => "Mouse up".into(),
Trigger::AfterDelay { ms } => format!("After delay {ms}ms"),
// A controller button names itself ("Gamepad A").
Trigger::KeyPress { key } if is_gamepad_button(key) => key.clone(),
Trigger::KeyPress { key } => format!("Key/Gamepad {key}"),
}
}
/// A key trigger fires for the key named `key` (`Space`, `K`, `ArrowLeft`) pressed with
/// exactly the modifiers `mods`: its key is a combination such as `Shift+K`.
/// exactly the modifiers `mods`: its key is a combination such as `Shift+K`. A controller
/// button is a key named from [`GAMEPAD_BUTTONS`], pressed without modifiers.
pub fn fires_on_key(&self, key: &str, mods: KeyMods) -> bool {
let Trigger::KeyPress { key: want } = self else { return false };
let (m, k) = KeyMods::split(want);
@@ -102,6 +105,46 @@ impl KeyMods {
}
}
/// The controller buttons a key/gamepad trigger can name (as its key), in the order of the
/// standard gamepad layout's button indices (the browser Gamepad API's `standard` mapping): face
/// buttons by position (bottom, right, left, top), shoulders, triggers, the two centre buttons,
/// stick presses, the d-pad and the home button.
pub const GAMEPAD_BUTTONS: [&str; 17] = [
"Gamepad A",
"Gamepad B",
"Gamepad X",
"Gamepad Y",
"Gamepad LB",
"Gamepad RB",
"Gamepad LT",
"Gamepad RT",
"Gamepad View",
"Gamepad Menu",
"Gamepad LS",
"Gamepad RS",
"Gamepad Up",
"Gamepad Down",
"Gamepad Left",
"Gamepad Right",
"Gamepad Home",
];
/// The button at standard-layout index `index`, as a key trigger names it.
pub fn gamepad_button(index: usize) -> Option<&'static str> {
GAMEPAD_BUTTONS.get(index).copied()
}
/// Whether a key trigger's key is a controller button.
pub fn is_gamepad_button(key: &str) -> bool {
GAMEPAD_BUTTONS.iter().any(|b| b.eq_ignore_ascii_case(key.trim()))
}
/// Whether any layer of `doc` has a key/gamepad trigger on a controller button (the player then
/// polls controllers).
pub fn uses_gamepad(doc: &crate::Document) -> bool {
doc.nodes().any(|n| n.interactions.iter().any(|it| matches!(&it.trigger, Trigger::KeyPress { key } if is_gamepad_button(key))))
}
/// Alternative spellings people type for keys, as the keys' names.
pub fn key_alias(k: &str) -> Option<&'static str> {
Some(match k.to_ascii_lowercase().as_str() {
+27
View File
@@ -148,4 +148,31 @@ fn hostile_text_is_bounded() {
{
let _ = Expr::parse(junk, &v);
}
// Mode lookups and modes as junk: whatever parses writes back to the same expression and
// evaluates without trouble.
let modes = std::collections::BTreeMap::from([("c".to_string(), "dark".to_string())]);
for junk in [
"n-name[",
"n-name[]]",
"n-name[[Dark]]",
"n-name[Dark][Light]",
"n-name [\u{0}]",
"{n-name}[\"",
"mode(",
"mode((",
"mode(Theme)[Dark]",
"mode(mode(Theme))",
"mode()()",
"mode(\"Theme\"",
"n-name[Dark] + mode(Theme)",
"-n-name[Dark] * n-name[Light] / 0",
"n-name[🙂]",
"not mode(Theme)",
"mode(Theme) < mode(Theme) == true",
] {
if let Ok(e) = Expr::parse(junk, &v) {
assert_eq!(Expr::parse(&e.display(&v), &v), Ok(e.clone()), "{junk}");
let _ = e.eval(&v, &modes);
}
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"proto.interaction.add",
"Add interaction",
"{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}} — or {id, dest} for a click-navigate. Returns {index}",
"{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}} — or {id, dest} for a click-navigate. Returns {index}",
true,
add
),
+30
View File
@@ -68,9 +68,39 @@ pub fn specs() -> Vec<CommandSpec> {
true,
remove_block
),
spec!(
"proto.evaluate",
"Evaluate prototype expression",
"{expression (text, e.g. \"count + 1\", \"accent[Dark]\", \"mode(Theme) == 'Dark'\") | expr (expression JSON), id? (the layer it runs on: variables resolve in its modes; default: the document's default modes)} → {value, type, text} without changing anything",
false,
evaluate
),
]
}
/// An expression's value where it would run, as the prototype player computes it.
fn evaluate(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "proto.evaluate";
let doc = s.doc()?;
let vars = &doc.variables;
let e = match (string(p, "expression"), parse::<Expr>(cmd, p, "expr")?) {
(Some(text), _) => Expr::parse(&text, vars).map_err(|e| bad(cmd, format!("`{text}`: {e}")))?,
(None, Some(mut e)) => {
super::proto::canonical_expr(vars, &mut e, 0);
e
}
(None, None) => return Err(bad(cmd, "`expression` (text) or `expr` (expression JSON) required")),
};
let kind = e.type_of(vars).map_err(|m| bad(cmd, m))?;
let modes = match id(p, "id") {
Some(n) if doc.contains(n) => doc.modes_for(n),
Some(n) => return Err(bad(cmd, format!("no layer {n}"))),
None => BTreeMap::new(),
};
let v = e.eval(vars, &modes).ok_or_else(|| bad(cmd, "it gives no value here (a division by zero, or a number too large)"))?;
Ok(json!({"value": v, "type": kind, "text": e.display(vars)}))
}
/// The action at `path` in `actions`.
pub fn action_at<'a>(actions: &'a [Action], path: &[usize]) -> Option<&'a Action> {
let (last, rest) = path.split_last()?;
+2
View File
@@ -1368,6 +1368,8 @@ mod proto_responsive_tests;
#[cfg(test)]
mod proto_run_tests;
#[cfg(test)]
mod proto_tail_tests;
#[cfg(test)]
mod retained_walk_tests;
#[cfg(test)]
mod rotated_guides_tests;
+1 -1
View File
@@ -45,7 +45,7 @@ pub(crate) fn persisted<S: Serializer>(list: &[InstalledPlugin], s: S) -> std::r
#[derive(Debug, Default)]
pub struct Runtime {
/// Modules of plugins imported without a manifest file, by plugin id.
modules: HashMap<String, Arc<Vec<u8>>>,
pub(crate) modules: HashMap<String, Arc<Vec<u8>>>,
/// The last run (plugin id, command id, params): "Run last plugin".
pub last: Option<(String, String, Value)>,
/// The open plugin panels, one per plugin, in the order they opened.
+186
View File
@@ -0,0 +1,186 @@
//! The prototyping tail, written from the public documentation: controller buttons on key/gamepad
//! triggers, widgets reacting to clicks in a presented prototype, and variable mode lookups in
//! expressions. Each case states the rule and a one-line derivation; where the documentation is
//! silent the case says so and pins what FigCraft does. The player's side (injected controller
//! presses, clicks on a widget in the presenter) is `ui-egui`'s `present_tail_tests`.
use figcraft_doc::proto::{Action, GAMEPAD_BUTTONS, KeyMods, Trigger, gamepad_button, uses_gamepad};
use figcraft_doc::{NodeId, VariableValue};
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use crate::proto_run::{branch, set_mode, set_variable};
use crate::widgets::click_on;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn idv(v: &Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
#[test]
fn controller_buttons_fire_key_gamepad_triggers() {
let none = KeyMods::default();
let shift = KeyMods { shift: true, ..none };
// (the trigger's key, key or button pressed, modifiers held, fires, derivation)
let cases = [
("Gamepad A", "Gamepad A", none, true, "the trigger takes a controller button as its key"),
("gamepad a", "Gamepad A", none, true, "names in any case, like keys"),
("Gamepad A", "Gamepad B", none, false, "another button"),
("Gamepad A", "A", none, false, "the keyboard's A is not the controller's A"),
("A", "Gamepad A", none, false, "nor the other way round"),
("Gamepad Up", "ArrowUp", none, false, "the d-pad is not the arrow keys (docs silent; pinned)"),
(
"Gamepad A",
"Gamepad A",
shift,
false,
"a controller button is pressed alone: a keyboard modifier held makes it another input (docs silent; pinned)",
),
];
for (want, key, mods, fires, why) in cases {
let t = Trigger::KeyPress { key: want.into() };
assert_eq!(t.fires_on_key(key, mods), fires, "{want:?} vs {mods:?}+{key}: {why}");
}
// The standard layout's buttons, each with its own name.
assert_eq!(gamepad_button(0), Some("Gamepad A"));
assert_eq!(gamepad_button(16), Some("Gamepad Home"));
assert_eq!(gamepad_button(17), None);
let mut names: Vec<&str> = GAMEPAD_BUTTONS.to_vec();
names.dedup();
assert_eq!(names.len(), GAMEPAD_BUTTONS.len());
// Authored through the interaction commands (and so the MCP server), stored as the key.
let mut s = Session::new();
let a = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
assert!(!uses_gamepad(s.doc().unwrap()));
ex(&mut s, "proto.interaction.add", json!({"id": a, "trigger": {"type": "keyPress", "key": "Gamepad Menu"}, "action": {"type": "back"}}));
let doc = s.doc().unwrap();
assert_eq!(doc.node(a).unwrap().interactions[0].trigger, Trigger::KeyPress { key: "Gamepad Menu".into() });
assert!(uses_gamepad(doc), "the player polls controllers only for prototypes that use them");
}
#[test]
fn widgets_react_to_clicks_on_the_prototypes_own_copy() {
let mut s = crate::widgets_tests::session();
let (w, _) = crate::widgets_tests::insert(&mut s);
let editor = s.doc().unwrap().clone();
let undo = crate::plugins_tests::undo_len(&s);
let state = |d: &figcraft_doc::Document| d.node(w).and_then(|n| n.plugin_data.get("figcraft.widget.state").cloned()).unwrap_or_default();
// Clicking calls the plugin back with the prototype's state of the widget, which it redraws.
let (copy, out) = click_on(&s, editor.clone(), w, "inc").unwrap();
assert_eq!(out["state"], json!({"n": 2}));
assert_eq!(out["targets"], json!(["inc", "dec"]));
assert_eq!(out["output"]["widget"]["event"], json!({"target": "inc"}), "the same callback as on the canvas");
assert_eq!(state(&copy), r#"{"n":2}"#);
// The editor's document and its history don't change: the state is the prototype's.
assert_eq!(state(s.doc().unwrap()), r#"{"n":1}"#);
assert_eq!(crate::plugins_tests::undo_len(&s), undo);
// Later clicks start from the copy's state (a target only the redrawn widget has).
let (copy, out) = click_on(&s, copy, w, "dec").unwrap();
assert_eq!(out["state"], json!({"n": 0}));
// Refusals leave the copy with the caller.
let e = click_on(&s, copy.clone(), w, "nope").unwrap_err().to_string();
assert!(e.contains("it has: inc"), "{e}");
let rect = idv(&ex(&mut s, "shape.rectangle", json!({"x": 400, "y": 0, "width": 10, "height": 10})));
assert!(click_on(&s, s.doc().unwrap().clone(), rect, "inc").is_err(), "not a widget");
ex(&mut s, "plugin.remove", json!({"id": "org.test.tally"}));
assert!(click_on(&s, copy, w, "inc").unwrap_err().to_string().contains("no plugin"), "without its plugin the widget stays as it is");
}
/// Screen A (a number `size`: 10 in Light, 20 in Dark; `label`: "light" / "dark"), its button, and a
/// card inside A that chose Dark.
fn modes_session() -> (Session, NodeId, NodeId, NodeId) {
let mut s = Session::new();
let a = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 200, "name": "A"})));
let button = idv(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20, "parent": a})));
let card = idv(&ex(&mut s, "frame.create", json!({"x": 50, "y": 50, "width": 100, "height": 100, "parent": a, "name": "Card"})));
let inner = idv(&ex(&mut s, "shape.rectangle", json!({"x": 60, "y": 60, "width": 10, "height": 10, "parent": card})));
ex(&mut s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]}));
ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "size", "type": "number", "value": 10}));
ex(&mut s, "variable.set", json!({"variable": "size", "mode": "Dark", "value": 20}));
ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "label", "type": "string", "value": "light"}));
ex(&mut s, "variable.set", json!({"variable": "label", "mode": "Dark", "value": "dark"}));
ex(&mut s, "variable.setMode", json!({"ids": [card], "collection": "Theme", "mode": "Dark"}));
(s, button, inner, a)
}
fn eval(s: &mut Session, text: &str, at: NodeId) -> Value {
ex(s, "proto.evaluate", json!({"expression": text, "id": at}))["value"].clone()
}
#[test]
fn mode_lookups_read_a_given_mode_or_the_layers_current_one() {
let (mut s, button, inner, _) = modes_session();
let num = |n: f64| json!({"type": "number", "value": n});
let text = |t: &str| json!({"type": "string", "value": t});
// (expression, layer, value, derivation)
let cases = [
("size", button, num(10.0), "a plain reference reads the mode where the interaction runs: the screen is on the default"),
("size", inner, num(20.0), "inside a frame that chose Dark it reads Dark"),
("size[Dark]", button, num(20.0), "a lookup reads the mode it names, whatever mode is chosen"),
("size[Light]", inner, num(10.0), "also where another mode is chosen"),
("mode(Theme)", inner, text("Dark"), "the current mode by name"),
("size[Dark] - size", button, num(10.0), "lookups mix with operators"),
("label + \" mode\"", inner, text("dark mode"), "text joins with +"),
("size * 2 >= 40 and not (mode(Theme) == \"Light\")", inner, json!({"type": "boolean", "value": true}), "comparisons and boolean operators"),
("size / 4", button, num(2.5), "division gives fractions"),
("-size[Dark]", button, num(-20.0), "negation"),
];
for (expr, at, want, why) in cases {
assert_eq!(eval(&mut s, expr, at), want, "{expr} on {at}: {why}");
}
// Without a layer: the default modes.
assert_eq!(ex(&mut s, "proto.evaluate", json!({"expression": "size"}))["value"], num(10.0));
let r = ex(&mut s, "proto.evaluate", json!({"expression": "size [ Dark ]"}));
assert_eq!((r["type"].clone(), r["text"].clone()), (json!("number"), json!("size[Dark]")));
// Mistakes are errors, not values; nothing is changed.
let rev = s.ds().unwrap().revision;
for (bad, why) in [
("size[Sepia]", "Theme has no mode `Sepia`"),
("size / 0", "gives no value"),
("size +", "missing at the end"),
("label - 1", "needs numbers"),
] {
let e = s.execute("proto.evaluate", &json!({"expression": bad, "id": button})).unwrap_err().to_string();
assert!(e.contains(why), "{bad}: {e}");
}
assert!(s.execute("proto.evaluate", &json!({"expression": "size", "id": 99999})).is_err());
assert!(s.execute("proto.evaluate", &json!({})).is_err());
assert_eq!(s.ds().unwrap().revision, rev);
}
#[test]
fn set_variable_mode_changes_what_later_expressions_read() {
let (s, button, inner, a) = modes_session();
let mut doc = s.doc().unwrap().clone();
let size = doc.variables.get("size").unwrap().id.clone();
let cond = |text: &str, d: &figcraft_doc::Document| {
let e = figcraft_doc::proto::Expr::parse(text, &d.variables).unwrap();
vec![figcraft_doc::proto::ConditionalBlock { condition: Some(e), actions: vec![Action::Back] }]
};
assert!(branch(&doc, button, &cond("mode(Theme) == \"Dark\"", &doc)).is_empty(), "Light before the action");
// The action switches the screen's mode for the prototype: a conditional after it sees Dark.
assert!(set_mode(&mut doc, a, "Theme", "Dark"));
assert_eq!(branch(&doc, button, &cond("mode(Theme) == \"Dark\"", &doc)), vec![Action::Back]);
assert_eq!(branch(&doc, button, &cond("size == 20", &doc)), vec![Action::Back], "plain references follow the new mode");
// A set variable action computes its expression in the new mode.
let e = figcraft_doc::proto::Expr::parse("size[Light] + size", &doc.variables).unwrap();
assert!(set_variable(&mut doc, button, &size, &Value::Null, Some(&e)));
let resolve = |d: &figcraft_doc::Document, mode: &str| {
let m = std::collections::BTreeMap::from([(
d.variables.collection("Theme").unwrap().id.clone(),
d.variables.collection("Theme").unwrap().find_mode(mode).unwrap().id.clone(),
)]);
d.variables.resolve(&size, &m)
};
// Docs silent: a set variable writes every mode (pinned), so lookups of any mode read it.
assert_eq!(resolve(&doc, "Light"), Some(VariableValue::Number(30.0)));
assert_eq!(resolve(&doc, "Dark"), Some(VariableValue::Number(30.0)));
// Back to Light; a frame inside the screen that chose a mode keeps it (docs silent; pinned).
assert!(set_mode(&mut doc, a, "Theme", "Light"));
assert_eq!(branch(&doc, inner, &cond("mode(Theme) == \"Dark\"", &doc)), vec![Action::Back]);
assert!(!set_mode(&mut doc, a, "Theme", "Sepia"), "an unknown mode changes nothing");
}
+3
View File
@@ -266,6 +266,9 @@ fn every_command_survives_junk_params() {
// Plugin directories: ids, versions, servers and confirmations as junk.
json!({"id": "../../x", "version": {"x": 1}, "server": "ws://\u{0}/?key=", "confirm": "yes", "path": [5], "plugin": [1], "query": 7, "sha256": 3}),
json!({"id": "org.example.x", "version": "1.0.0", "server": "wss://[::1", "confirm": true, "plugin": "nope"}),
// Prototype expressions and gamepad keys as junk.
json!({"id": -5, "expression": "x[\u{0}", "expr": {"op": {"op": "add", "args": [1]}}, "trigger": {"type": "keyPress", "key": "Gamepad \u{0}"}}),
json!({"id": 1, "expr": {"inMode": {"variable": [], "mode": 1e308}}, "trigger": {"type": "keyPress", "key": 7}}),
];
for spec in all_commands() {
if matches!(
+15
View File
@@ -289,6 +289,21 @@ fn click(s: &mut Session, p: &Value) -> Result<Value> {
report(s, w, &wp.manifest, out)
}
/// Click `target` of the widget `id` is (or is in) on `doc`, a document that isn't one of the
/// session's: the prototype player's copy. The same callback as on the canvas, with the session's
/// installed plugins and the same sandbox limits, but the widget's state and layers are the
/// copy's, nothing reaches the session's documents and there is no undo step. Returns the
/// document as the callback left it (on failure the caller keeps its copy as it was) and what
/// `widget.click` returns.
pub fn click_on(s: &Session, doc: Document, id: NodeId, target: &str) -> Result<(Document, Value)> {
let mut scratch = Session::with_document(doc);
scratch.prefs.plugins.clone_from(&s.prefs.plugins);
scratch.plugins.modules.clone_from(&s.plugins.modules);
let out = click(&mut scratch, &json!({"id": id, "target": target}))?;
let ds = scratch.docs.pop().ok_or(crate::EngineError::NoDocument)?;
Ok((std::sync::Arc::unwrap_or_clone(ds.doc), out))
}
fn list(s: &mut Session, _p: &Value) -> Result<Value> {
let doc = s.doc()?;
let widgets: Vec<Value> = doc
+2 -2
View File
@@ -113,13 +113,13 @@ fn widget_manifest(id: &str, access: &str) -> Value {
}
/// A session with the tally widget plugin installed (read access).
fn session() -> Session {
pub(crate) 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) {
pub(crate) fn insert(s: &mut Session) -> (NodeId, Value) {
let r = ex(s, "widget.insert", json!({"plugin": "org.test.tally", "x": 50, "y": 60}));
(idv(&r), r)
}
+60
View File
@@ -0,0 +1,60 @@
//! REST import of key/gamepad triggers on controllers. Fixtures are synthetic, written by hand
//! from the published schema (`KeyTrigger`: `device`, `keyCodes`).
use super::proto_variable_tests::at;
use super::*;
use figcraft_doc::proto::{GAMEPAD_BUTTONS, Trigger};
use serde_json::json;
fn import_trigger(trigger: Value) -> (Option<Trigger>, Vec<String>) {
let file = json!({"name": "Pad", "document": {"id": "0:0", "type": "DOCUMENT", "children": [
{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [
{"id": "1:1", "name": "Home", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": 200, "y": 200}, "children": [
{"id": "1:2", "name": "Button", "type": "RECTANGLE", "relativeTransform": at(10.0, 10.0), "size": {"x": 40, "y": 20},
"interactions": [{"trigger": trigger, "actions": [{"type": "BACK"}]}]}
]}
]}
]}});
let (doc, warnings) = import_with(&file, None, &no_images).unwrap();
let t = doc.nodes().find(|n| n.name == "Button").and_then(|n| n.interactions.first()).map(|it| it.trigger.clone());
(t, warnings)
}
#[test]
fn controller_triggers_import_as_their_button() {
let key = |k: &str| Some(Trigger::KeyPress { key: k.into() });
// (trigger JSON, imported trigger, derivation)
let cases = [
(json!({"type": "ON_KEY_DOWN", "device": "XBOX_ONE", "keyCodes": [0]}), key("Gamepad A"), "button 0 is the bottom face button"),
(
json!({"type": "ON_KEY_DOWN", "device": "PS4", "keyCodes": [3]}),
key("Gamepad Y"),
"the same layout on any controller: 3 is the top face button",
),
(json!({"type": "ON_KEY_DOWN", "device": "SWITCH_PRO", "keyCodes": [12]}), key("Gamepad Up"), "the d-pad"),
(json!({"type": "ON_KEY_DOWN", "device": "UNKNOWN_CONTROLLER", "keyCodes": [16]}), key("Gamepad Home"), "the last standard button"),
(json!({"type": "ON_KEY_UP", "device": "XBOX_ONE", "keyCodes": [9]}), key("Gamepad Menu"), "key up imports as a press, like keyboard keys"),
(json!({"type": "ON_KEY_DOWN", "device": "XBOX_ONE", "keyCodes": [17]}), None, "beyond the standard layout: skipped"),
(json!({"type": "ON_KEY_DOWN", "device": "XBOX_ONE", "keyCodes": [0, 1]}), None, "two buttons at once (docs silent): skipped, not guessed"),
(json!({"type": "ON_KEY_DOWN", "device": "XBOX_ONE", "keyCodes": [1.5]}), None, "not an index"),
(json!({"type": "ON_KEY_DOWN", "device": "XBOX_ONE", "keyCodes": [-1]}), None, "negative"),
(json!({"type": "ON_KEY_DOWN", "device": "XBOX_ONE"}), None, "no button"),
(json!({"type": "ON_KEY_DOWN", "device": "KEYBOARD", "keyCodes": [65]}), key("A"), "keyboards are unchanged"),
];
for (trigger, want, why) in cases {
let (got, warnings) = import_trigger(trigger.clone());
assert_eq!(got, want, "{trigger}: {why} ({warnings:?})");
assert_eq!(got.is_none(), warnings.iter().any(|w| w.contains("gamepad triggers without")), "{trigger}: a skip says why");
assert!(!warnings.iter().any(|w| w.contains("not supported")), "{trigger}: {warnings:?}");
}
assert_eq!(GAMEPAD_BUTTONS.len(), 17, "the standard layout's buttons");
}
#[test]
fn junk_controller_triggers_never_panic() {
for codes in [json!(null), json!("0"), json!([null]), json!([1e300]), json!([f64::MAX]), json!([i64::MIN]), json!({"0": 1})] {
for device in [json!("XBOX_ONE"), json!(""), json!(7), json!(null)] {
let _ = import_trigger(json!({"type": "ON_KEY_DOWN", "device": device, "keyCodes": codes}));
}
}
}
+2
View File
@@ -24,6 +24,8 @@ mod blur_tests;
mod conditional_tests;
#[cfg(test)]
mod effects_tests;
#[cfg(test)]
mod gamepad_tests;
mod props;
mod proto;
#[cfg(test)]
+14 -5
View File
@@ -4,7 +4,7 @@
use figcraft_doc::proto::{
Action, ConditionalBlock, DeviceRotation, Direction, Easing, Expr, ExprOp, Interaction, KeyMods, MAX_ACTION_DEPTH, MAX_EXPR_DEPTH, Overflow,
OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger,
OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger, gamepad_button,
};
use figcraft_doc::{Node, NodeId, VariableType, VariableValue};
use serde_json::{Value, json};
@@ -174,11 +174,20 @@ impl Ctx<'_> {
Some("MOUSE_DOWN") => mouse(self, Trigger::MouseDown),
Some("MOUSE_UP") => mouse(self, Trigger::MouseUp),
Some(ty @ ("ON_KEY_DOWN" | "ON_KEY_UP")) => {
if text(t, "device").is_some_and(|d| d != "KEYBOARD") {
self.warn("gamepad triggers are not supported and were skipped");
return None;
}
let codes: Vec<f64> = list(t, "keyCodes").iter().filter_map(Value::as_f64).collect();
// Controllers: their codes read as standard-layout button indices; one button each.
if text(t, "device").is_some_and(|d| d != "KEYBOARD") {
let button = match codes.as_slice() {
[c] if c.fract() == 0.0 && *c >= 0.0 => gamepad_button(*c as usize),
_ => None,
};
if button.is_none() {
self.warn("gamepad triggers without exactly one known controller button were skipped");
} else if ty == "ON_KEY_UP" {
self.warn("key up triggers import as key presses");
}
return button.map(|b| Trigger::KeyPress { key: b.to_string() });
}
let Some(key) = codes.iter().rev().find(|c| modifier(**c).is_none()).and_then(|c| key_name(*c)) else {
self.warn("key triggers without a supported key were skipped");
return None;
+1
View File
@@ -266,6 +266,7 @@ name = "English"
"Contrast" = "Contrast"
"Contributors" = "Contributors"
"Contributors: {people} · Commits: {commits}" = "Contributors: {people} · Commits: {commits}"
"Controller" = "Controller"
"Cool" = "Cool"
"Copied as CSS" = "Copied as CSS"
"Copied as PNG" = "Copied as PNG"
+2
View File
@@ -267,6 +267,7 @@ status = "draft — needs native review"
"Contrast" = "Contraste"
"Contributors" = "Colaboradores"
"Contributors: {people} · Commits: {commits}" = "Colaboradores: {people} · Commits: {commits}"
"Controller" = "Mando"
"Cool" = "Frío"
"Copied as CSS" = "Copiado como CSS"
"Copied as PNG" = "Copiado como PNG"
@@ -1684,6 +1685,7 @@ status = "draft — needs native review"
"proto.conditional.addBlock" = "Añadir si no, si / si no"
"proto.conditional.removeBlock" = "Quitar si / si no, si / si no"
"proto.conditional.setCondition" = "Editar condición"
"proto.evaluate" = "Evaluar expresión de prototipo"
"proto.flow.add" = "Añadir punto de inicio de flujo"
"proto.flow.remove" = "Quitar flujo"
"proto.flow.rename" = "Cambiar nombre del flujo"
+27 -3
View File
@@ -10,9 +10,10 @@
//! - `ui.key {key, shift?, cmd?, alt?}` — a shortcut as if typed on the canvas
//! - `ui.type {text}` — type into the text being edited
//! - `ui.input {events: [{move|down|up: [x, y], button?} | {key, shift?, alt?, cmd?} | {text} | {leave}
//! | {hold: {alt?, shift?, cmd?}}]}` — raw pointer and key events for the next frame, as if from the
//! window (panels, popovers); `hold` keeps modifier keys down for the frames that follow (all
//! false: released)
//! | {hold: {alt?, shift?, cmd?}} | {gamepad: "A" | "Gamepad A" | 0}]}` — raw pointer and key events
//! for the next frame, as if from the window (panels, popovers); `hold` keeps modifier keys down
//! for the frames that follow (all false: released); `gamepad` presses a controller button in the
//! prototype playing (presenter or preview pane)
//! - `ui.menu.invoke {id}` — a menu item exactly like clicking it (may open a dialog)
//! - `ui.set {rightTab?, leftTab?, theme?, home?, palette?, focusRegion?, preview?, presentScroll?, …}`
//! - `ui.screenshot {path}` — PNG of the window (needs a visible window)
@@ -134,6 +135,17 @@ fn save_color_image(img: &egui::ColorImage, path: &str) -> Result<(), String> {
buf.save(path).map_err(|e| e.to_string())
}
/// A controller button given as its standard-layout index, its key trigger name ("Gamepad A") or
/// its short name ("A", "LB", "Up").
fn pad_name(v: &Value) -> Option<&'static str> {
use figcraft_doc::proto::{GAMEPAD_BUTTONS, gamepad_button};
if let Some(i) = v.as_u64() {
return usize::try_from(i).ok().and_then(gamepad_button);
}
let s = v.as_str()?.trim();
GAMEPAD_BUTTONS.iter().copied().find(|b| b.eq_ignore_ascii_case(s) || b.get(8..).is_some_and(|short| short.eq_ignore_ascii_case(s)))
}
fn b(p: &Value, k: &str) -> bool {
p.get(k).and_then(Value::as_bool).unwrap_or(false)
}
@@ -244,6 +256,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
Some(egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32))
};
let mut events = Vec::new();
let mut pads = Vec::new();
for e in list.iter().take(256) {
let button = if e.get("button").and_then(Value::as_str) == Some("secondary") {
egui::PointerButton::Secondary
@@ -274,6 +287,9 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
phase: egui::TouchPhase::Move,
modifiers: mods,
});
} else if let Some(g) = e.get("gamepad") {
let names = figcraft_doc::proto::GAMEPAD_BUTTONS.join(", ");
pads.push(pad_name(g).ok_or_else(|| format!("unknown controller button {g} (an index 0-16 or one of {names})"))?.to_string());
} else if b(e, "leave") {
events.push(egui::Event::PointerGone);
} else if let Some(h) = e.get("hold") {
@@ -289,6 +305,14 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
return Err(format!("unknown input event {e}"));
}
}
// Controller buttons go straight to the prototype playing (its next frame takes them).
if !pads.is_empty() {
match (app.ui.present.as_mut(), app.ui.preview.as_mut()) {
(Some(p), _) => p.pads.extend(pads),
(None, Some(pane)) => pane.player.pads.extend(pads),
(None, None) => return Err("controller buttons go to a prototype that is playing: run proto.present or proto.preview".into()),
}
}
app.control_input.extend(events);
ctx.request_repaint();
Ok(json!({"queued": list.len().min(256)}))
+8
View File
@@ -167,6 +167,12 @@ pub trait Services {
fn system_language(&self) -> Option<String> {
None
}
/// The controller buttons held now on any controller: bit i is standard gamepad layout index i
/// (0 the bottom face button … 16 home; `figcraft_doc::proto::GAMEPAD_BUTTONS`). The prototype
/// player asks every frame while its prototype has gamepad triggers.
fn gamepad_held(&mut self) -> u32 {
0
}
}
/// No-op services (tests, headless screenshots).
@@ -810,6 +816,8 @@ impl FigApp {
self.session.tool.redlines = self.ui.right_tab == RightTab::Inspect;
if self.ui.present.is_some() {
present::show(self, ui);
// What went wrong while presenting (a widget's plugin failing) shows over it.
self.toast_ui(&ctx);
return;
}
if !self.has_files() || self.ui.home {
+90 -6
View File
@@ -290,6 +290,14 @@ pub struct Presenter {
sections: HashMap<NodeId, NodeId>,
/// The frame shown last, where it was placed and what it could draw into (comment pins go there).
shown: Option<(NodeId, Place, Rect)>,
/// Controller buttons pressed since the last frame (as key trigger names), for the next frame.
pub pads: Vec<String>,
/// The prototype has gamepad triggers: the player polls the controllers.
uses_pads: bool,
/// The controller buttons held at the last poll (bit i: standard layout index i).
pads_held: u32,
/// Clicks on widgets' clickable parts (widget, target), for [`Presenter::click_widgets`].
widget_clicks: Vec<(NodeId, String)>,
}
impl Presenter {
@@ -345,6 +353,10 @@ impl Presenter {
wheel: None,
sections: HashMap::new(),
shown: None,
pads: Vec::new(),
uses_pads: false,
pads_held: 0,
widget_clicks: Vec::new(),
}
}
@@ -463,6 +475,7 @@ impl Presenter {
figcraft_engine::proto_run::play_interactions(d);
raise_fixed(d);
}
self.uses_pads = d.as_ref().is_some_and(figcraft_doc::proto::uses_gamepad);
self.doc = d;
self.rev += 1;
}
@@ -568,6 +581,7 @@ impl Presenter {
"panRange": [self.pan_range.0, self.pan_range.1],
"zoom": self.zoom,
"flinging": self.fling.is_some(),
"gamepad": self.uses_pads,
})
}
@@ -679,6 +693,24 @@ impl Presenter {
did
}
/// Run the widget clicks the player took: each calls its plugin back on the player's copy (the
/// prototype's own state of the widget), as on the canvas but without touching the file.
/// Returns what went wrong, for a toast.
pub fn click_widgets(&mut self, s: &figcraft_engine::Session) -> Option<String> {
let mut error = None;
for (id, target) in std::mem::take(&mut self.widget_clicks) {
let Some(doc) = self.design.clone() else { break };
match figcraft_engine::widgets::click_on(s, doc, id, &target) {
Ok((d, _)) => {
self.design = Some(d);
self.relayout();
}
Err(e) => error = Some(e.to_string()),
}
}
error
}
fn perform(&mut self, a: &Action, node: NodeId, tr: Transition, dur: f64, easing: Easing, rect: Option<Rect>, now: f64) -> bool {
self.perform_at(a, node, tr, dur, easing, rect, now, 0)
}
@@ -879,6 +911,10 @@ impl Place {
pub fn rect(&self, r: figcraft_geom::Rect) -> Rect {
Rect::from_min_max(self.pt(r.x0, r.y0), self.pt(r.x1, r.y1))
}
/// The page point under screen point `q`.
pub fn page(&self, q: Pos2) -> Point {
Point::new(self.page_min.0 + f64::from(q.x - self.origin.x) / self.scale, self.page_min.1 + f64::from(q.y - self.origin.y) / self.scale)
}
}
// ---------------------------------------------------------------------------------------------
@@ -1542,6 +1578,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
if let Ok(ds) = app.session.ds() {
pres.sync(&ds.doc, now);
}
poll_pads(app, &mut pres, &ctx);
let mut action = None;
// Typing a reply to a comment keeps the keys from the prototype; Shift+C shows the comments.
let typing = ctx.egui_wants_keyboard_input();
@@ -1571,9 +1608,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
} else if app.ui.comments.open.is_some() {
app.ui.comments.close();
}
for u in pres.urls.drain(..) {
app.services.open_url(&u);
}
finish(app, &mut pres);
// The options carry over to the next presentation.
app.ui.present_view = pres.view;
if let Some(BarAction::Responsive(on)) = action {
@@ -1585,6 +1620,35 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
app.ui.present = Some(pres);
}
/// How often the player polls the controllers while its prototype has gamepad triggers (seconds).
const PAD_POLL_SECS: f64 = 1.0 / 30.0;
/// Before a player's frame: the controller buttons pressed since the last one, polled only while
/// the prototype has gamepad triggers and the window has the focus (systems and browsers report
/// controllers to the focused window only; focus coming back draws a frame, which polls again).
fn poll_pads(app: &mut FigApp, p: &mut Presenter, ctx: &egui::Context) {
if !p.uses_pads || !ctx.input(|i| i.focused) {
return;
}
let held = app.services.gamepad_held();
let down = held & !p.pads_held;
p.pads_held = held;
// Each press once, in layout order.
let pressed = (0..u32::BITS as usize).filter(|i| down & (1 << i) != 0).filter_map(figcraft_doc::proto::gamepad_button);
p.pads.extend(pressed.map(str::to_string));
crate::repaint_in(ctx, PAD_POLL_SECS);
}
/// After a player's frame: the links it opens and the widget clicks it took.
fn finish(app: &mut FigApp, p: &mut Presenter) {
for u in p.urls.drain(..) {
app.services.open_url(&u);
}
if let Some(e) = p.click_widgets(&app.session) {
app.toast(e);
}
}
/// The width of the presenter's Inspect panel.
const INSPECT_W: f32 = 280.0;
@@ -1672,6 +1736,7 @@ pub fn show_pane(app: &mut FigApp, ctx: &egui::Context) {
let style = ctx.global_style();
let typing = ds.text_edit.is_some() || crate::a11y::field_focused(ctx);
let chrome = Chrome { pane: true, bar_h: 36.0, pad: 12.0, round: style.visuals.window_corner_radius.nw, keys: !typing };
poll_pads(app, &mut pane.player, ctx);
let mut action = None;
egui::Window::new(tr("Prototype preview"))
.id(pane_id())
@@ -1687,9 +1752,7 @@ pub fn show_pane(app: &mut FigApp, ctx: &egui::Context) {
let (rect, _) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
action = player(&mut pane.player, ui, rect, now, chrome);
});
for u in pane.player.urls.drain(..) {
app.services.open_url(&u);
}
finish(app, &mut pane.player);
match action {
Some(BarAction::Close) => {}
Some(BarAction::Present) => {
@@ -2125,6 +2188,7 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke
)
});
let keys = if keys_on { keys } else { Vec::new() };
let pads = std::mem::take(&mut p.pads);
// A transition a drag started follows the pointer while it stays down, then plays on from there.
if let Some(a) = p.anim.as_mut()
&& let Some((from, dir, len)) = a.follow
@@ -2325,12 +2389,28 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke
{
return;
}
// A widget's clickable part calls its plugin back (after the frame, with the session).
if let Some(q) = pos
&& let Some(t) = layer_at(doc, root, &place, q).and_then(|hit| figcraft_engine::widgets::target_at(doc, hit, place.page(q)))
{
p.widget_clicks.push(t);
return;
}
// Clicking where nothing reacts flashes the hotspots.
if p.view.hotspot_hints {
p.flash_until = now + FLASH_SECS;
}
}
}
// Controller buttons: pressed alone, anywhere on the screen, like keys.
for name in &pads {
let found = doc.subtree(root).into_iter().find_map(|id| find_trigger(doc, id, |t| t.fires_on_key(name, KeyMods::default())).map(|k| (id, k)));
if let Some((id, k)) = found
&& p.fire(id, k, None, now)
{
return;
}
}
// Keys.
for (key, mods) in &keys {
let (name, held) = (key.name(), KeyMods { ctrl: mods.ctrl, alt: mods.alt, shift: mods.shift, cmd: mods.mac_cmd });
@@ -2513,6 +2593,10 @@ mod variables_tests;
#[path = "present_behaviour_tests.rs"]
mod behaviour_tests;
#[cfg(test)]
#[path = "present_tail_tests.rs"]
mod tail_tests;
#[cfg(test)]
mod tests {
use super::*;
+257
View File
@@ -0,0 +1,257 @@
//! The player's side of the prototyping tail, written from the public documentation: controller
//! buttons play key/gamepad triggers (from the system's controllers and injected through the
//! control channel; no physical controller needed), and widgets react to clicks in a presented
//! prototype. Each case states the rule and a one-line derivation; "docs silent" cases pin what
//! FigCraft does. The model's side is the engine's `proto_tail_tests`.
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
use base64::Engine as _;
use egui::{Event, PointerButton, Rect, pos2};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::{Value, json};
use crate::Services;
use crate::present_tests::{Ui as Harness, id};
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn frame(s: &mut Session, name: &str, x: f64) -> NodeId {
id(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 300, "height": 400, "name": name})))
}
fn key_trigger(s: &mut Session, layer: NodeId, key: &str, action: Value) {
ex(
s,
"proto.interaction.add",
json!({"id": layer, "trigger": {"type": "keyPress", "key": key}, "action": action, "transition": {"type": "instant"}}),
);
}
fn screen(u: &Harness) -> String {
u.app.inspect()["presenter"]["name"].as_str().unwrap_or_default().to_string()
}
/// Controllers as the system reports them: the buttons held (bit i: standard index i), and how
/// often the player asked.
struct FakePads {
held: Arc<AtomicU32>,
polls: Arc<AtomicUsize>,
}
impl Services for FakePads {
fn gamepad_held(&mut self) -> u32 {
self.polls.fetch_add(1, Ordering::Relaxed);
self.held.load(Ordering::Relaxed)
}
}
/// Screens A (presented) and B: a layer on A whose "Gamepad A" trigger goes to B, one on B whose
/// "Gamepad B" trigger goes back; the fake controllers attached.
fn pads_setup() -> (Harness, Arc<AtomicU32>, Arc<AtomicUsize>) {
let mut s = Session::new();
let a = frame(&mut s, "A", 0.0);
let b = frame(&mut s, "B", 400.0);
let on_a = id(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30, "parent": a})));
let on_b = id(&ex(&mut s, "shape.rectangle", json!({"x": 410, "y": 10, "width": 30, "height": 30, "parent": b})));
key_trigger(&mut s, on_a, "Gamepad A", json!({"type": "navigate", "dest": b}));
key_trigger(&mut s, on_b, "Gamepad B", json!({"type": "back"}));
let (held, polls) = (Arc::new(AtomicU32::new(0)), Arc::new(AtomicUsize::new(0)));
let mut u = Harness::new(s);
u.app.services = Box::new(FakePads { held: held.clone(), polls: polls.clone() });
u.run("proto.present", json!({"start": a}));
(u, held, polls)
}
#[test]
fn controller_buttons_play_key_gamepad_triggers() {
let (mut u, held, polls) = pads_setup();
assert_eq!(u.app.inspect()["presenter"]["gamepad"], json!(true));
assert_eq!(screen(&u), "A");
// (buttons held, screen after, derivation)
let steps = [
(0b01, "B", "pressing the trigger's button anywhere on the screen plays it, like its key"),
(0b01, "B", "a button held down plays once, not again every frame"),
(0b11, "A", "another button while one is held: B's trigger goes back"),
(0b00, "A", "releasing does nothing (the trigger is a press)"),
(1 << 2, "A", "a button no trigger names does nothing"),
(0b01, "B", "pressed again after letting go: plays again"),
];
for (bits, want, why) in steps {
held.store(bits, Ordering::Relaxed);
u.frames(2, Vec::new);
assert_eq!(screen(&u), want, "held {bits:#b}: {why}");
}
assert!(polls.load(Ordering::Relaxed) >= steps.len() * 2, "polled every frame while presenting");
// The control channel presses buttons too: by short name, trigger name or index.
let ctx = u.ctx.clone();
held.store(0, Ordering::Relaxed);
for (button, want) in [(json!("B"), "A"), (json!("gamepad a"), "B"), (json!(1), "A")] {
crate::control::handle(&mut u.app, &ctx, "ui.input", &json!({"events": [{"gamepad": button}]})).unwrap();
u.frames(2, Vec::new);
assert_eq!(screen(&u), want, "{button}");
}
for junk in [json!("Z"), json!(17), json!(-1), json!(null), json!([0])] {
let e = crate::control::handle(&mut u.app, &ctx, "ui.input", &json!({"events": [{"gamepad": junk}]})).unwrap_err();
assert!(e.contains("unknown controller button"), "{junk}: {e}");
}
}
#[test]
fn prototypes_without_gamepad_triggers_dont_poll() {
let mut s = Session::new();
let a = frame(&mut s, "A", 0.0);
let held = Arc::new(AtomicU32::new(1));
let polls = Arc::new(AtomicUsize::new(0));
let mut u = Harness::new(s);
u.app.services = Box::new(FakePads { held, polls: polls.clone() });
let ctx = u.ctx.clone();
// Nothing plays: a controller button needs a prototype to go to.
let e = crate::control::handle(&mut u.app, &ctx, "ui.input", &json!({"events": [{"gamepad": "A"}]})).unwrap_err();
assert!(e.contains("prototype that is playing"), "{e}");
u.run("proto.present", json!({"start": a}));
u.frames(5, Vec::new);
assert_eq!(u.app.inspect()["presenter"]["gamepad"], json!(false));
assert_eq!(polls.load(Ordering::Relaxed), 0, "no gamepad triggers: the controllers are left alone");
}
/// A widget plugin: inserted it shows "one" (state n: 1) with a clickable square `inc`; clicked,
/// it shows "two" (n: 2).
fn widget_module() -> Vec<u8> {
let look = |n: u32, label: &str| {
json!({"state": {"n": n}, "root": {"type": "frame", "name": "Tally", "layout": "horizontal", "gap": 8, "padding": 10, "fill": "#FFFFFF",
"children": [{"type": "text", "text": label, "size": 12}, {"type": "rectangle", "target": "inc", "width": 40, "height": 40, "fill": "#0D99FF"}]}})
.to_string()
};
let (one, two) = (look(1, "one"), look(2, "two"));
// Clicked when the input holds `"tar` (the event's target), read as one little-endian i32.
let wat = format!(
r#"(module
(import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32)))
(import "figcraft" "widget_render" (func $render (param i32 i32) (result i32)))
(memory (export "memory") 2)
(data (i32.const 1000) "{}") (data (i32.const 5000) "{}")
(func (export "figcraft_run") (param $n i32) (result i32) (local $i i32)
(drop (call $reply_read (i32.const 20000) (local.get $n)))
(block $done (loop $scan
(br_if $done (i32.gt_s (i32.add (local.get $i) (i32.const 4)) (local.get $n)))
(if (i32.eq (i32.load (i32.add (i32.const 20000) (local.get $i))) (i32.const 0x72617422))
(then (drop (call $render (i32.const 5000) (i32.const {}))) (return (i32.const 0))))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $scan)))
(drop (call $render (i32.const 1000) (i32.const {})))
(i32.const 0)))"#,
one.replace('"', "\\\""),
two.replace('"', "\\\""),
two.len(),
one.len()
);
wat::parse_str(&wat).unwrap()
}
/// Screen A (presented) holding the widget; the widget and its clickable square.
fn widget_setup() -> (Harness, NodeId, NodeId) {
let mut s = Session::new();
let manifest = json!({"id": "org.test.tally", "name": "Tally", "version": "1.0.0", "main": "w.wasm", "api": 3, "widget": true, "permissions": {"access": "read"}});
ex(&mut s, "plugin.import", json!({"manifest": manifest, "wasm": base64::engine::general_purpose::STANDARD.encode(widget_module())}));
let a = frame(&mut s, "A", 0.0);
let w = id(&ex(&mut s, "widget.insert", json!({"plugin": "org.test.tally", "x": 40, "y": 40})));
ex(&mut s, "node.reparent", json!({"ids": [w], "parent": a}));
let doc = s.doc().unwrap();
let square = doc.subtree(w).into_iter().find(|i| doc.node(*i).is_some_and(|n| n.plugin_data.contains_key("figcraft.widget.target"))).unwrap();
let mut u = Harness::new(s);
u.run("proto.present", json!({"start": a}));
u.frames(2, Vec::new);
(u, w, square)
}
fn state(doc: &figcraft_doc::Document, w: NodeId) -> String {
doc.node(w).and_then(|n| n.plugin_data.get("figcraft.widget.state").cloned()).unwrap_or_default()
}
/// Where layer `id` shows in the presenter.
fn on_screen(u: &Harness, id: NodeId) -> Rect {
let p = u.app.ui.present.as_ref().unwrap();
let (_, place, _) = p.shown.unwrap();
place.rect(p.doc.as_ref().unwrap().world_bounds(id).unwrap())
}
fn toast(u: &Harness) -> Option<String> {
u.app.ui.toast.as_ref().map(|t| t.0.clone())
}
fn click(u: &mut Harness, at: egui::Pos2) {
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
u.frame(vec![Event::PointerMoved(at)]);
u.frame(vec![b(true)]);
u.frame(vec![b(false)]);
u.frames(2, Vec::new);
}
#[test]
fn widgets_react_to_clicks_in_the_presented_prototype() {
let (mut u, w, square) = widget_setup();
let player = |u: &Harness| state(u.app.ui.present.as_ref().unwrap().doc.as_ref().unwrap(), w);
let undo = u.app.session.ds().unwrap().history.undo.len();
assert_eq!(player(&u), r#"{"n":1}"#);
// A click on a clickable part calls the plugin back, which redraws the widget in the prototype.
let r = on_screen(&u, square);
click(&mut u, r.center());
assert_eq!(player(&u), r#"{"n":2}"#, "the widget reacted in the prototype");
// Docs silent (pinned): the state is the prototype's; the file, its history and the canvas
// keep theirs, and restarting starts over.
assert_eq!(state(u.app.session.doc().unwrap(), w), r#"{"n":1}"#);
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), undo);
assert_eq!(toast(&u), None, "no error");
let r_key = |pressed| Event::Key { key: egui::Key::R, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
u.frame(vec![r_key(true), r_key(false)]);
u.frames(2, Vec::new);
assert_eq!(player(&u), r#"{"n":1}"#, "restarted");
// A click on the widget outside its clickable parts, or beside it, calls nothing.
let whole = on_screen(&u, w);
click(&mut u, pos2(whole.min.x + 3.0, whole.min.y + 3.0));
click(&mut u, pos2(whole.max.x + 30.0, whole.max.y + 30.0));
assert_eq!(player(&u), r#"{"n":1}"#);
}
#[test]
fn a_widget_without_its_plugin_says_so_and_stays() {
let (mut u, w, square) = widget_setup();
u.app.session.execute("plugin.remove", &json!({"id": "org.test.tally"})).unwrap();
let r = on_screen(&u, square);
click(&mut u, r.center());
let p = u.app.ui.present.as_ref().unwrap();
assert_eq!(state(p.doc.as_ref().unwrap(), w), r#"{"n":1}"#);
assert!(toast(&u).is_some_and(|t| t.contains("no plugin")), "{:?}", toast(&u));
}
#[test]
fn the_interaction_editor_picks_a_controller_button() {
let mut s = Session::new();
let a = frame(&mut s, "A", 0.0);
let layer = id(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30, "parent": a})));
key_trigger(&mut s, layer, "K", json!({"type": "back"}));
ex(&mut s, "select.set", json!({"ids": [layer]}));
let mut u = crate::layers_drag_tests::Ui::new(s);
u.app.ui.right_tab = crate::RightTab::Prototype;
crate::proto_panel::open_editor(&u.ctx, Some((layer, 0)));
u.ctx.enable_accesskit();
u.frame(vec![]);
let at = |u: &mut crate::layers_drag_tests::Ui, name: &str| {
let nodes = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes;
let b = nodes.iter().map(|(_, n)| n).find(|n| n.label() == Some(name) || n.value() == Some(name)).and_then(|n| n.bounds());
let b = b.unwrap_or_else(|| panic!("no control {name:?}"));
let p = egui::Pos2::new(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32);
u.button(p, true);
u.button(p, false);
};
// The key field keeps recording keys; the menu beside it names the controller's buttons.
at(&mut u, "Controller");
at(&mut u, "Gamepad Menu");
let trigger = u.app.session.doc().unwrap().node(layer).unwrap().interactions[0].trigger.clone();
assert_eq!(trigger, figcraft_doc::proto::Trigger::KeyPress { key: "Gamepad Menu".into() });
}
+22 -2
View File
@@ -5,7 +5,8 @@
use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_color::Paint;
use figcraft_doc::proto::{
Action, ConditionalBlock, Direction, Easing, Interaction, KeyMods, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger,
Action, ConditionalBlock, Direction, Easing, GAMEPAD_BUTTONS, Interaction, KeyMods, Overflow, OverlayPosition, ScrollPosition, Transition,
Trigger, is_gamepad_button,
};
use figcraft_doc::*;
use serde_json::{Value, json};
@@ -92,6 +93,8 @@ fn trigger_of(kind: &str, cur: &Trigger) -> Trigger {
fn trigger_summary(t: &Trigger) -> String {
match t {
Trigger::KeyPress { key } if key.is_empty() => tr("Key/Gamepad").into(),
// A controller button names itself ("Gamepad A").
Trigger::KeyPress { key } if is_gamepad_button(key) => key.clone(),
Trigger::KeyPress { key } => trf("Key/Gamepad {key}", &[("key", key)]),
Trigger::AfterDelay { ms } => trf("After delay {ms}ms", &[("ms", ms)]),
other => tr(trigger_kind(other)).into(),
@@ -714,7 +717,8 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte
} else {
key.clone()
};
let w = ui.available_width();
// The key field, and a menu of controller buttons beside it.
let w = (ui.available_width() - PAD_MENU_W - ui.spacing().item_spacing.x).max(40.0);
let (r, resp) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::click());
let id = capture_id().with("field");
ui.painter().rect_filled(r, CornerRadius::same(5), t.field);
@@ -750,12 +754,28 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte
if resp.clicked() && !cap {
ui.ctx().data_mut(|d| d.insert_temp(capture_id(), true));
}
let pads = widgets::dropdown(ui, tr("Controller"), PAD_MENU_W, None);
menu_popup(&pads).show(|ui| {
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for b in GAMEPAD_BUTTONS {
let mark = if key.eq_ignore_ascii_case(b) { format!("✓ {b}") } else { b.to_string() };
if menu_item(ui, &mark, "") {
ui.ctx().data_mut(|d| d.remove::<bool>(capture_id()));
patch(app, node, k, json!({"trigger": {"type": "keyPress", "key": b}}));
ui.close();
}
}
});
});
});
}
_ => {}
}
}
/// The width of the controller button menu beside a key trigger's key.
const PAD_MENU_W: f32 = 92.0;
/// A destination picker for the action at `slot`: `make` builds the action for the destination.
#[allow(clippy::too_many_arguments)]
fn dest_dropdown(
+5 -2
View File
@@ -145,9 +145,12 @@ popovers and fields (`ui.pointer` and `ui.key` drive the canvas tools and shortc
`{up: [x, y], button?}`, `{key, shift?, alt?, cmd?}` (pressed and released; egui key names such as
`ArrowLeft`, `Escape`, `A`), `{text}` (typed text), `{scroll: [dx, dy], shift?, alt?, cmd?}` (a
mouse wheel at the pointer: the content moves by that many points, so a negative `dy` scrolls down)
`{leave: true}` (the pointer left the window) and `{hold: {alt?, shift?, cmd?}}` (keep those modifier
`{leave: true}` (the pointer left the window), `{hold: {alt?, shift?, cmd?}}` (keep those modifier
keys down for the frames that follow, e.g. Alt while dragging a component from the Assets panel onto
an instance to swap it; all false releases them); positions are window points. Events of one call
an instance to swap it; all false releases them) and `{gamepad: "A" | "Gamepad A" | 0}` (a
controller button pressed in the prototype playing, the presenter's or else the preview pane's: a
short name, a key/gamepad trigger's name or a standard layout index 0–16; an error when no prototype
plays); positions are window points. Events of one call
arrive in the same frame, so send a press and its release (or a double-click's two clicks) as
separate calls. Returns `{queued}`.
+16 -6
View File
@@ -1,6 +1,6 @@
# Where FigCraft falls short of Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; 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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
@@ -28,7 +28,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| 12 | Video fills; HEIC images | File format | 5–10 | [file-format-parity](file-format-parity.md) |
| 13 | Vector tail: none left (boolean hit testing on the result landed 2026-10-11) | Feature | — | [target-app-parity](target-app-parity.md) |
| 14 | Variables: extended collections checked on real libraries (the tail landed 2026-10-12) | Feature | 0–1 | [components-parity](components-parity.md) |
| 15 | Prototyping: media triggers and actions, gamepad | Feature | 3–6 | [prototyping-parity](prototyping-parity.md) |
| 15 | Prototyping: media triggers and actions; controllers on macOS, Linux and FreeBSD (controller buttons on key/gamepad triggers, widgets in prototypes landed) | Feature | 2–5 | [prototyping-parity](prototyping-parity.md) |
| 16 | Dev Mode: import lines inside generated layer trees; the editor extension is left to the MCP server (code connect, Dev Mode plugins and dev resources landed) | Feature | 0–1 | [dev-mode-parity](dev-mode-parity.md) |
| 17 | Branching: branches in live sessions, lists other than fills, strokes and effects merged whole (a branch's own version history and per-item list merges landed 2026-10-12) | Feature | 1–2 | [collaboration-parity](collaboration-parity.md) |
| 18 | Display P3 on screen: blocked upstream (egui-wgpu picks an 8-bit sRGB surface); gamut warning and soft-proof sRGB landed, as did several windows | Hardware | 4–8 | [hardware-parity](hardware-parity.md) |
@@ -245,10 +245,19 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
offered (extending takes library collections).
- **Estimate:** 0–1 h.
### 15. Prototyping: media triggers and actions, gamepad
- **Missing:** video triggers (on media hit/end) and play/pause/mute/seek actions; gamepad input.
The REST importer warns and skips them (`crates/format/src/rest/proto.rs`).
- **Estimate:** 3–6 h after video fills (gap 12).
### 15. Prototyping: media triggers and actions, controllers off Windows
- **Landed (2026-10-12):** key/gamepad triggers take controller buttons (`Gamepad A` … `Gamepad
Home`, the standard layout's 17 buttons; a Controller menu beside the key field; REST files'
controller triggers import); the player reads controllers on Windows and in the browser (Gamepad
API) and takes `ui.input {gamepad}` on the control channel; widgets react to clicks in a
presented prototype, on the prototype's own copy; `proto.evaluate` computes expressions (mode
lookups included) where a layer's interaction would run (`crates/engine/src/proto_tail_tests.rs`,
`crates/ui-egui/src/present_tail_tests.rs`, `crates/format/src/rest/gamepad_tests.rs`).
- **Missing:** video triggers (on media hit/end) and play/pause/mute/seek actions (the REST
importer warns and skips them, `crates/format/src/rest/proto.rs`); controllers on macOS, Linux
and FreeBSD (the usual Rust controller library links libudev on Linux; a pure-Rust reader per
platform, or an owner decision on that dependency); PlayStation and Switch button labels.
- **Estimate:** 2–5 h, the media part after video fills (gap 12).
### 16. Dev Mode: the tail after dev resources
- **Landed (2026-10-11):** code connect: a component or set links to its code component per
@@ -340,6 +349,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Gap 15: controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes and `proto.evaluate` landed; gap 15 now media plus controllers off Windows, 3–6 h → 2–5 h; ranking unchanged |
| 2026-10-12 | minor | Plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version, operator moderation) added to gap 6's landed list; gap 6 10–30 h → 8–26 h; ranking unchanged |
| 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Gap 5 40–65 h → 32–55 h, rank unchanged |
| 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 |
+12 -4
View File
@@ -358,7 +358,12 @@ Interactions are `proto.interaction.add` / `set` / `remove` / `clear` (a navigat
the scroll position with `preserveScroll: true`; `{type: "scrollTo", dest, offset?: [x, y]}` stops
the layer that far from the top-left of the innermost frame scrolling it, e.g. below a fixed
header). A key trigger takes a key or a combination (`{type: "keyPress", key: "Shift+K"}`; Ctrl,
Alt, Shift and Cmd, all held and no others). In the player, an "on drag" trigger with a move, push
Alt, Shift and Cmd, all held and no others) or a controller button (`key: "Gamepad A"`; `Gamepad`
followed by `A`, `B`, `X`, `Y`, `LB`, `RB`, `LT`, `RT`, `View`, `Menu`, `LS`, `RS`, `Up`, `Down`,
`Left`, `Right` or `Home`: the standard layout, face buttons by position), which the player reads
from controllers on Windows and in the browser and which the control channel presses with
`ui.input {events: [{gamepad: "A"}]}`. Widgets react to clicks in the player, on its own copy of
the file (see [plugins.md](plugins.md)). In the player, an "on drag" trigger with a move, push
or slide animation starts only for a drag that way, and the animation follows the pointer until it
lets go; spring easings play until they settle (their duration follows from the spring, not
`durationMs`); an overlay's after-delay timer starts when it opens; instances of a component play
@@ -374,7 +379,10 @@ a variable) or `{op: {op, args}}` with `add` (text joins when a string is involv
`and`, `or`, `not` and `negate`; `{inMode: {variable, mode}}` reads a variable's value in one mode
of its collection whichever mode is chosen, and `{mode: {collection}}` gives the name of the mode a
collection is in where the interaction runs (as text; it follows set variable mode actions).
Variables, collections and modes may be given by name; they are stored by id. Expressions can also
Variables, collections and modes may be given by name; they are stored by id.
`proto.evaluate {expression | expr, id?}` computes an expression without changing anything, with
variables in the modes where layer `id`'s interaction would run (default: the default modes) →
`{value, type, text}`; it fails with what's wrong, like the interaction commands. Expressions can also
be written as text: `expression: "count + 1"`
on a set variable action, or `expression: "count >= 3 and not done"` on a conditional block, in
place of `expr` / `condition` (variables by name, `{name}` for any name; numbers, `"text"`,
@@ -678,13 +686,13 @@ The JSON returned by the public REST API's file endpoint (`GET /v1/files/:key`,
`run_command {command: "file.import", params: {path}}` (pages appended to the current document,
with their flows, prototype settings and variables).
The result's `warnings` list what was skipped or approximated (unsupported layer, paint and effect
types, video and gamepad triggers, expression functions other than a variable mode lookup over a
types, video triggers, controller triggers on more than one button, expression functions other than a variable mode lookup over a
variable and one of its modes, bindings to library
variables, instances of components outside the file, which come in as frames). Pages, the layer
tree, geometry, paints, strokes, effects, corners, constraints, auto layout, layout grids, exports,
text with styled runs, vectors, components, component sets, instances and component properties are
mapped, and so is prototyping: `interactions` (on click, hover, press, drag, after delay, mouse
enter/leave/down/up and key triggers; navigate, overlays, back, scroll to, change to, open link,
enter/leave/down/up and key triggers, controller triggers as controller buttons; navigate, overlays, back, scroll to, change to, open link,
set variable (values, variable references and expressions), set variable mode and conditional
(if / else if / else over expressions) actions; dissolve, smart animate, move, push and slide transitions with their easing,
custom bezier and spring curves included), flow starting points, scroll overflow and fixed/sticky
+7 -2
View File
@@ -67,6 +67,12 @@ and its state (a small JSON object) in its own layer data, so a saved file, a co
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.
In a presented prototype (the presenter or the preview pane) clicks on a widget's clickable parts
call its plugin back the same way, with the same limits, but on the prototype's own copy of the
file: the widget changes in the prototype only, the file and its undo history stay as they are,
and restarting the prototype starts the widget over. An interaction on the clicked layer wins over
the widget.
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.
@@ -414,8 +420,7 @@ import what you need from `figcraft`.
- Plugins written for other apps (JavaScript plugins) don't run.
- Widgets: clicks are their only events (no text input, hover, property menu or timers), they
can't open a panel, and their layers are frames, text, rectangles, ellipses and images only (no
vectors, components or variables). Clicks reach them in the editor, not in a prototype being
presented.
vectors, components or variables).
- Panels: no images, sliders or free layout inside a panel.
- Data in the document: plugins keep data on layers (pages included) with `node.set {props:
{pluginData: {key: value}}}`; there is no separate per-document store.
+20 -7
View File
@@ -1,18 +1,20 @@
# Prototyping parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (behaviour suite written from the public documentation, every rule's status; fixes and the missing setting it found) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7)
**Breadth ~90%, ready ~74% (estimated).** Every Figma trigger except media and gamepad, every
action except media control, every transition and easing exist. Every rule of the public
**Breadth ~92%, ready ~74% (estimated).** Every Figma trigger except media, every action except
media control, every transition and easing exist; key/gamepad triggers take controller buttons,
read from controllers on Windows and in the browser. Every rule of the public
documentation on triggers, actions, overlays, animations, easing, scrolling and interactive
components is a test case with what it implies (`crates/engine/src/proto_behaviour_tests.rs`, 7
tests on the model, and `crates/ui-egui/src/present_behaviour_tests.rs`, 12 tests driving the
player, 21 gestures among them), and all pass. Not yet checked against larger real prototypes.
Remaining: 3–6 h.
player, 21 gestures among them; the tail: `crates/engine/src/proto_tail_tests.rs`, 4 tests, and
`crates/ui-egui/src/present_tail_tests.rs`, 5 tests), and all pass. Not yet checked against
larger real prototypes. Remaining: 2–5 h.
| Group | Figma | FigCraft | Missing |
|---|---|---|---|
| Triggers | On click, on drag, while hovering, while pressing, key/gamepad, mouse enter, mouse leave, mouse down, mouse up, after delay, on media hit, on media end | 10: OnClick, OnHover, OnPress, OnDrag, MouseEnter, MouseLeave, MouseDown, MouseUp, AfterDelay, KeyPress (keys and combinations) | gamepad, media triggers, delays on mouse enter/leave/down/up |
| Triggers | On click, on drag, while hovering, while pressing, key/gamepad, mouse enter, mouse leave, mouse down, mouse up, after delay, on media hit, on media end | 10: OnClick, OnHover, OnPress, OnDrag, MouseEnter, MouseLeave, MouseDown, MouseUp, AfterDelay, KeyPress (keys, combinations and controller buttons) | media triggers, delays on mouse enter/leave/down/up; controllers on macOS, Linux and FreeBSD |
| Actions | Navigate to, change to, back, scroll to, open link, open / swap / close overlay, set variable, set variable mode, conditional, play/pause/mute/seek media | 11: Navigate, Back, ScrollTo (with an offset), OpenOverlay, SwapOverlay, CloseOverlay, OpenUrl, ChangeTo, SetVariable, SetVariableMode, Conditional | media actions |
| Transitions | Instant, dissolve, smart animate, move in/out, push, slide in/out | all 8 | — |
| Easing | Linear, ease in/out/in-and-out, back variants, gentle, quick, bouncy, slow, custom bezier, custom spring | all 13 (springs physical) | — |
@@ -33,6 +35,9 @@ case pins the current behaviour).
| While pressing: shown while the button is down, reverts on release | pass | same |
| While hovering: shown while the pointer is over the layer, reverts when it leaves; mouse enter fires once and doesn't revert; mouse leave fires on leaving | pass | same |
| Key/gamepad: a key or a combination of keys, pressed anywhere on the screen | **fixed** | combinations never fired (`Shift+K` didn't match) and `K` also fired for Shift+K; the editor's recorder now records the modifiers held, and REST files' modifier key codes import as a combination (they imported as the key alone); `key_triggers_fire_for_their_key_or_combination` |
| Key/gamepad on a controller: the trigger takes a controller button in place of a key, pressed anywhere on the screen | **added** | `Gamepad A` … `Gamepad Home` (the standard layout's 17 buttons, face buttons by position) as the trigger's key, picked from the Controller menu beside the key field; the player polls controllers only while the prototype has such a trigger and the window has the focus, and plays each press once (held buttons don't repeat); Windows reads Xbox-compatible controllers, the browser its Gamepad API's standard-mapped ones; `controller_buttons_fire_key_gamepad_triggers`, `controller_buttons_play_key_gamepad_triggers` |
| A controller button is not the keyboard key of the same name; the d-pad is not the arrow keys; a keyboard modifier held makes a button press another input | silent | pinned as stated |
| REST files: controller triggers (`device` other than `KEYBOARD`) | **added** | they were skipped; their code reads as the standard layout's button index (silent: the schema doesn't say what the codes count); two buttons at once or an unknown code is skipped with a warning; `controller_triggers_import_as_their_button` |
| After delay: timed from when the screen shows | pass | `triggers_fire_on_their_gesture` |
| After delay on an overlay: timed from when the overlay opens, again each time it opens | **fixed** | timed from when the screen under it arrived (a toast opened late closed at once) and fired only the first time; `an_overlays_after_delay_counts_from_when_it_opens` |
| Nested layers with the same trigger: the top-most layer's interaction wins; a layer without that trigger lets it through to the one under it | pass | `nested_hotspots_and_click_through`, `hotspots_priority_and_click_through` |
@@ -50,6 +55,10 @@ case pins the current behaviour).
| Interactive components: every instance plays the interactions of the variant it shows; an interaction on the instance with the same trigger wins | **fixed** | instances played only interactions copied onto them when they were placed: an instance placed before its main got interactions did nothing, and one placed after kept the first variant's interactions after changing (a toggle went on but never off). New instances no longer copy them, and copies in older files give way to the variant shown; `instances_play_their_variants_interactions` |
| Change to on a layer inside a nested instance changes the instance whose variant set holds the destination | **fixed** | the innermost instance (an icon inside a button) was changed instead |
| Set variable, set variable mode, conditional | pass | `proto_run_tests`, `proto_conditional_tests`, `present_variables_tests` |
| Expressions: a variable reads the mode where the interaction runs (a frame that chose a mode, else the page's); `name[Mode]` reads a given mode whatever is chosen; operators on numbers, text (`+` joins), booleans and comparisons | pass | `mode_lookups_read_a_given_mode_or_the_layers_current_one`, `proto_expr_mode_tests` |
| Set variable mode, then a conditional or set variable later in the same interaction: they see the new mode | pass | `set_variable_mode_changes_what_later_expressions_read` |
| A set variable writes every mode of the variable, so a lookup of any mode reads the new value; a frame inside the screen that chose a mode keeps it after set variable mode | silent | pinned as stated |
| Widgets in a presented prototype react to clicks on their clickable parts | **added** | the plugin is called back as on the canvas (same callback, sandbox and fuel limits), on the prototype's copy: the widget's state is the prototype's, the file and its undo history don't change, restarting starts over (silent; pinned); an interaction on the layer wins over the widget; a plugin that's gone or fails shows its error over the presenter; `widgets_react_to_clicks_on_the_prototypes_own_copy`, `widgets_react_to_clicks_in_the_presented_prototype` |
| Smart animate: layers matched by name and hierarchy tween position, size, rotation, opacity, solid fills and strokes and corner radius; unmatched layers dissolve | pass | `smart_animate_tweens_matched_layers_and_overshoots_with_the_easing`, `smart_interpolate_tweens_matched_and_fades_the_rest` |
| Smart animate with a spring or back easing overshoots (and pulls back) | **fixed** | layers snapped to their end once the curve passed 1 |
| Visibility, text content, gradients and images in smart animate | silent | they show the destination's |
@@ -67,11 +76,13 @@ case pins the current behaviour).
|---|---|---|
| Connections drawn and dragged on the canvas; interaction editor | done | |
| Several actions per interaction; drag to reorder; conditional blocks with else-if/else | done | one undo step per edit |
| Expressions (`count + 1`, `mode(Theme) == "Dark"`, `accent[Dark]`) | done | checked when entered |
| Expressions (`count + 1`, `mode(Theme) == "Dark"`, `accent[Dark]`) | done | checked when entered; `proto.evaluate` computes one where a layer's interaction would run (MCP and agents) |
| Flows, starting points, descriptions | done | |
| Overlays: position, close on click outside, background, animate out, stacking, timers from when they open | done | |
| Scroll behaviour: overflow directions, fixed (drawn on top), sticky, preserve scroll position, scroll to with an offset, momentum and overscroll | done | |
| Key combinations (`Shift+K`), recorded in the editor and imported from REST files | done | |
| Controller buttons on key/gamepad triggers (Controller menu, REST import, `ui.input {gamepad}` on the control channel) | done | read from controllers on Windows (Windows.Gaming.Input) and in the browser (Gamepad API); macOS, Linux and FreeBSD don't read controllers yet; button names follow one layout (no PlayStation or Switch labels) |
| Widgets react to clicks in a presented prototype | done | clicks only, like on the canvas |
| Interactive components: instances play their variant's interactions, overridden per trigger | done | |
| Drag-driven animations that follow the pointer, in their direction | done | |
| Presenter: device frames and colours, rotation, custom sizes, fit/fill/100%, responsive frames | done | device bodies drawn in code |
@@ -79,6 +90,7 @@ case pins the current behaviour).
| Preview pane following edits live | done | |
| Edit interactions on several layers at once | done | |
| Delays on mouse enter / leave / down / up; manual overlay offsets; opening a link in place | missing | the REST import warns about mouse-trigger delays |
| Controllers on macOS, Linux and FreeBSD | missing | the usual Rust controller library links the system's libudev on Linux; the pure-Rust desktop builds read none there yet |
| Prototype in a separate window; mirror to a phone | missing | see [hardware-parity](hardware-parity.md) |
| Video in prototypes | missing | needs video fills ([gaps](gaps.md) #12, #15) |
| Share a prototype link | missing | needs multiplayer/sharing |
@@ -87,5 +99,6 @@ case pins the current behaviour).
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Controller buttons on key/gamepad triggers (Controller menu, player polling on Windows and in the browser, REST import instead of skipping, control channel injection), widgets reacting to clicks in presented prototypes on the prototype's own copy, `proto.evaluate`; the mode lookup and set-variable-mode rules written as cases (they already held). Breadth 90% → 92%, ready unchanged at 74% (controllers on Windows and the browser only; no real prototype QA yet), 3–6 h → 2–5 h |
| 2026-10-11 | minor | Behaviour suite from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag follows its animation's direction and the pointer, key combinations, overlay timers from when they open, interactive components play their variant's interactions and change the right instance, physical springs that play until they settle, smart animate overshoots with the easing, fixed layers above scrolling content; added scroll to offsets. Breadth 88% → 90%, ready 70% → 74%, 5–9 h → 3–6 h |
| 2026-10-10 | major | Created from the 2026-10-10 re-measure |
+3 -2
View File
@@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; 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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
@@ -52,7 +52,7 @@ blockers.
| M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 |
| M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 |
| M4 Vector & text depth | ~80% (live booleans, arc fields, shape builder on strokes, boolean hit testing on the result, one-sided widths, text on a path, pattern fills, brushes and pen pressure landed 2026-10-11) | spell check, Figma Draw multi-edit | 7–18 |
| M5 Prototype & present | ~85% | media, gamepad, separate window | 3–6 |
| M5 Prototype & present | ~85% | media, controllers on macOS/Linux/FreeBSD, separate window | 2–5 |
| M6 Interop & Dev Mode | ~65% (code connect, Dev Mode plugins and best-effort `.fig` import landed 2026-10-11) | `.fig` import's tail, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 15–30 |
| M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 |
| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 |
@@ -73,6 +73,7 @@ blockers.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | M5: prototyping tail: controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, `proto.evaluate`; M5's tail 3–6 h → 2–5 h |
| 2026-10-12 | minor | M11: a plugin directory on `figcraft-server` (bundles published with the server key and checked in full, browsed and installed from Plugins ▸ Manage plugins after a permissions confirmation, hash-checked downloads, updates by version, `plugin.directory.*`, `figcraft-cli plugin publish`); 10–30 h → 8–26 h |
| 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Current focus 4 now lists the drafts' native reviews |
| 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 |
+8 -7
View File
@@ -1,6 +1,6 @@
# FigCraft parity with Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; 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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
@@ -21,7 +21,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c
| Essentials user | **~57%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.88) |
| Mainstream practitioner | **~49%** | estimated ([below](#mainstream-practitioner): weekly areas 72.6% × discounts 0.9 × 0.9 × 0.85) |
| Remaining to beta | **~155–280 h** one Opus 5.5 agent (~32–64 h wall-clock with 5 parallel agents) | estimated |
| Remaining to full parity | **~266–524 h** one agent (~83–145 h wall-clock with 5 parallel agents) | estimated |
| Remaining to full parity | **~265–523 h** one agent (~83–145 h wall-clock with 5 parallel agents) | estimated |
| Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | |
The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were
@@ -106,7 +106,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 10 | Text | 8 | 90% | 70% | Spell check, real-file text metrics (behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 |
| 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries, component playground (behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 |
| 12 | Styles & variables | 5 | 95% | 76% | Variables inside styles, real-library QA (behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 |
| 13 | Prototyping | 6 | 90% | 74% | Video triggers and media actions, gamepad triggers, delays on mouse triggers, manual overlay offsets, real prototype QA (behaviour suite from the documentation, key combinations, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 3–6 |
| 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, delays on mouse triggers, manual overlay offsets, real prototype QA (behaviour suite from the documentation, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 |
| 14 | Import/export | 6 | 84% | 57% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed; no real-file checks yet), `.fig` export, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 |
| 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 |
@@ -115,14 +115,14 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 |
| 20 | Figma Draw extras | 2 | 88% | 60% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses), multi-edit (several vector layers in edit mode at once). Missing: pressure from macOS/Linux tablets | 1–3 |
| 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 |
| | **Weighted (÷113)** | 113 | **~84%** | **~63%** | | **118–256** |
| | Core editor only (1–16, ÷94) | 94 | ~94% | ~72% | | 33–75 |
| | **Weighted (÷113)** | 113 | **~84%** | **~63%** | | **117–255** |
| | Core editor only (1–16, ÷94) | 94 | ~94% | ~72% | | 32–74 |
## By dimension (ready for real work)
| Dimension | Weight | Ready | Hours | Evidence | Doc |
|---|---|---|---|---|---|
| Features (depth) | 35 | 63% | 29–68 (areas 1–13, 15, 16, 20) | The table above; core editor ~72% | this file |
| Features (depth) | 35 | 63% | 28–67 (areas 1–13, 15, 16, 20) | The table above; core editor ~72% | this file |
| UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) |
| File formats | 12 | 59% | 5–10 | `.fig` read, best effort, with editable vector networks (tested on generated fixtures, not real files), no `.fig` write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [file-format-parity](file-format-parity.md) |
| Collaboration | 10 | 13% | 52–104 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser, with cursor chat and spotlight (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) |
@@ -133,7 +133,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 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 | 38% | 8–26 | Own WASM plugins with a Rust SDK and three examples (#101), plugin panels and settings (ABI v2), widgets that take clicks (API 3), a self-hosted plugin directory on `figcraft-server`; no Figma plugin compatibility, no public catalogue or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) |
| AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) |
| **Ready for real work** | 100 | **~53%** | **~266–524** | | |
| **Ready for real work** | 100 | **~53%** | **~265–523** | | |
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
@@ -619,6 +619,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Prototyping tail (gap 15, the part that doesn't need video): key/gamepad triggers take controller buttons (`Gamepad A` … `Gamepad Home`, the standard layout's 17 buttons, from a Controller menu beside the key field; the REST import maps controller triggers instead of skipping them); the player reads controllers on Windows (Windows.Gaming.Input through the `windows` crate the app already used; no new dependency) and in the browser (the Gamepad API), polling only while the prototype has such a trigger and the window has the focus, and the control channel presses buttons (`ui.input {gamepad}`); macOS, Linux and FreeBSD read no controllers yet. Widgets react to clicks in a presented prototype (the canvas's callback and sandbox limits, on the prototype's copy: the file and its history don't change, restarting starts over). `proto.evaluate` computes an expression where a layer's interaction would run (MCP); the mode lookup and set-variable-mode rules are cases in a behaviour suite (they already held). Prototyping breadth 90% → 92%, ready unchanged at 74%, 3–6 h → 2–5 h; area hours 120–260 → 119–259 (core 33–75 → 32–74), Features (depth) 29–68 h → 28–67 h; percentages unchanged at this rounding; full parity ~276–538 h → ~275–537 h |
| 2026-10-12 | minor | Plugin directory on `figcraft-server` (gap 6): the server keeps plugin bundles (manifest, module, up to 32 assets; an uncompressed length-prefixed format named by its SHA-256) published with the server key (`figcraft-cli plugin publish`, `plugin.directory.publish`), checked in full before they are kept (size cap, manifest and plugin API version, a version newer than the one kept, the module compiled but never run, plain asset names) and capped (`max_plugins`, `max_plugin_mb`, `max_plugins_mb`); anyone who reaches the server lists, searches and fetches them (hash-checked again on read, a shared download budget); the operator moderates with `figcraft-server plugins [remove]`. The app's Plugins ▸ Manage plugins gains a Browse tab: a server's plugins with Install / Update / Remove, an install confirmation that shows the permissions asked for, the source and the SHA-256, the download checked against the listed hash before anything is read, plugins kept per user in hash-named folders; `plugin.directory.list` / `install` (needs `confirm: true`) / `publish` / `unpublish` for scripts and MCP. Not in the browser build yet; no public catalogue, publisher accounts, signatures, ratings or background update checks. Plugins & widgets 55%/32% → 60%/35%, 10–30 h → 8–26 h; Ecosystem dimension 34% → 38%; weighted totals unchanged at this rounding; full parity ~281–548 h → ~279–544 h |
| 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Localization 30% → 35% (drafts, not reviewed: graded low), 40–65 h → 32–55 h; ready for real work ~52% → ~53% (52.9 → 53.1 by the dimension table); full parity ~284–554 h → ~276–544 h; beta ~162–292 h → ~155–280 h (Japanese and Korean leave the beta list; their native reviews need people) |
| 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 |