diff --git a/Cargo.lock b/Cargo.lock index 40a16fe7..24536ff9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -855,7 +855,20 @@ checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "core-graphics-types", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", "foreign-types", "libc", ] @@ -871,6 +884,28 @@ dependencies = [ "libc", ] +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core-text" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333dab512ce710ca2d08574c373d246dbeac8b22769e47da4c0e72730ce442b7" +dependencies = [ + "core-foundation 0.10.1", + "core-graphics 0.25.0", + "foreign-types", +] + [[package]] name = "core_detect" version = "1.0.0" @@ -1782,10 +1817,12 @@ dependencies = [ name = "figcraft-text" version = "0.11.2" dependencies = [ + "core-text", "figcraft-doc", "figcraft-geom", "harfrust 0.13.3", "log", + "objc2 0.6.5", "png", "skrifa 0.47.0", "unicode-bidi", @@ -5982,7 +6019,7 @@ dependencies = [ "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", - "core-graphics", + "core-graphics 0.23.2", "cursor-icon", "dpi", "js-sys", diff --git a/ROADMAP.md b/ROADMAP.md index 50a3446b..e403b02d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -19,13 +19,13 @@ start with [`docs/gaps.md`](docs/gaps.md). | Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | | Mainstream practitioner | **~50%** | estimated: weekly areas 72.7% × 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 | **~262–519 h** one agent (~83–145 h wall-clock with 5 agents) | estimated | +| Remaining to full parity | **~260–515 h** one agent (~83–145 h wall-clock with 5 agents) | estimated | ### Readiness by audience | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | +| Full target (ready for real work) | ~53% | ~266–518 h | multiplayer (42–84), localization (29–51), plugins (6–22), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | | Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the rest of the experimental `.fig` export elsewhere: effects, images, prototypes, real files (5–15) | | Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) | @@ -65,7 +65,7 @@ native speakers' reviews of the nine language drafts need people. | Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) | | Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | | Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) | -| Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) | +| Ecosystem / plugins | 40% (own WASM plugins with panels, settings and widgets that take clicks, text and a property menu; a self-hosted plugin directory) | 6–22 | [gaps](docs/gaps.md) | | AI features | 0% | 30–60 | [gaps](docs/gaps.md) | ## Features @@ -89,7 +89,7 @@ native speakers' reviews of the nine language drafts need people. | Dev Mode / inspect | 98% | 78% | 1–2 | [dev-mode-parity](docs/dev-mode-parity.md) | | Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) | | Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) | -| Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) | +| Plugins & widgets | 65% | 38% | 6–22 | [plugins](docs/plugins.md) | | Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) | | Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) | | AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) | @@ -139,6 +139,12 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). - **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%. - **2026-10-24** · Board facilitation (M12): a timer everyone on a board sees (presets or any minutes, pause, resume, a minute more, stop; synced as its end time, so nothing ticks over the network) and voting sessions (votes per person, on everything, on sticky notes or on the selection; a click votes, your badge takes it back; your votes show while it runs, everyone's with a ranked list once it ends; clear results), in a pill at the top right of the board. `board.timer`, `board.timerState`, `board.vote`, `board.voteResults`; ballots sync per voter. Checked in the app, with two app instances in one live session through a local `figcraft-server`, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). - **2026-10-24** · Board templates and tables (M12): a templates picker in the board toolbar places a retrospective, a brainstorm, a weekly planner, a flow chart or a kanban board (`board.template`, all FigCraft's own) as ordinary board objects in one undo step; `board.table` makes a table of auto layout frames (up to 50 × 20) whose rows grow with their text, edited by row and column from the bar over the selection or `board.tableEdit`, typed into with a double-click. Boards ~35% → ~40% breadth, 30–60 h → 26–54 h; the weighted numbers are unchanged. +- **2026-10-24** · Plugin widgets take text input and a property menu (M11, gap 6, plugin API 4; older plugins run + unchanged): a double-click on a widget's editable field edits it like any text, and when editing ends the value goes + into the widget's state and its plugin redraws it, one undo step (`widget.setText` from scripts and MCP); a widget + can carry a few actions, toggles and colour choices, shown in a small bar over it while it's selected + (`widget.menu`). The Counter example has a label field and a menu (reset, steps of ten, a colour). Plugins & widgets + 65%. - **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). - **2026-10-22** · CJK line breaking completes JIS X 4051's kinsoku (M4): the line-start set now also has the halfwidth small kana ァ–ッ and small tsu, the halfwidth (semi-)voiced sound marks ゙゚, the spacing (semi-)voiced sound marks ゛゜, the kana iteration marks 〱–〵 and the halfwidth closing parenthesis ⦆; the line-end set has the halfwidth opening parenthesis ⦅. Text layers, auto layout and the canvas otherwise wrap Japanese as before: ideographs break anywhere and the behaviour suite pins every category plus that a manual newline may still start a line with a prohibited character. Numbers unchanged. - **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged. @@ -348,6 +354,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding | | 2026-10-24 | minor | Board facilitation (M12): a shared timer and voting sessions (`board.timer`, `board.vote` and their queries; timer and vote sync as document state, ballots per voter; neither is an undo step). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) | | 2026-10-24 | minor | Board templates and tables (M12): five built-in templates (`board.template`, `board.templates`, a toolbar picker with pictures) and tables (`board.table`, `board.tableEdit`; rows and columns from the bar over the selection). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) | +| 2026-10-24 | minor | Plugin widgets take text input and a property menu (gap 6, plugin API 4; API 1–3 plugins and existing widgets unchanged): a widget's text layer can be an editable field, edited on the canvas with a double-click like any text (or `widget.setText`), its value cleaned and capped at 1,000 characters, written into the widget's state under the field's name and the field's text, and the plugin called back to redraw, all as one undo step that syncs in live sessions; a widget can carry a property menu of up to 8 actions, toggles and colour choices (up to 8 colours each), shown in a small bar over the widget while it is selected and driven with `widget.menu`; descriptions, values and menus read back from files are checked, an API 3 plugin that draws a field or a menu is refused, and older FigCraft builds refuse API 4 manifests. The Counter example gained a label field and a menu (reset, steps of ten, a colour); the SDK reads the new events. Hover and timers are still missing. Plugins & widgets 60%/35% → 65%/38%, 8–26 h → 6–22 h; Ecosystem dimension 38% → 40%; weighted totals unchanged at this rounding; full parity ~262–519 h → ~260–515 h | +| 2026-10-24 | minor | Branches in live sessions (gap 17): create, edit, review, update from main and merge while the file is in a session; the branch stays with its author (ids in their own client space, far above the main file's), merging is one synced edit and one undo step, peers' edits merge against the branch's own base and conflicts resolve as offline, a view link refuses merging, a branch made outside the session that adds layers is refused with a reason; a branch's history gets entries for its creation and updates from main; no wire format change. Left: branches others in the session can see, text runs item by item. Numbers unchanged | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | | 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged | | 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged | diff --git a/crates/engine/src/branch_live_tests.rs b/crates/engine/src/branch_live_tests.rs new file mode 100644 index 00000000..ae1a5c87 --- /dev/null +++ b/crates/engine/src/branch_live_tests.rs @@ -0,0 +1,240 @@ +//! Branches of a file in a live session: the branch stays with its author and makes ids in their +//! own client space; merging is one synced edit (and one step of the author's undo history) that +//! everyone receives; peers' edits on main while the branch is open merge against the branch's own +//! base, and conflicts with them resolve as offline; update from main takes them in; a view link +//! still refuses merging; a branch made outside the session that adds layers is refused instead +//! of being sent for the server to refuse. Plus a fuzz test with random real commands. + +use figcraft_collab::sim::Rng; +use figcraft_doc::ids::space_of; +use figcraft_doc::{Document, NodeId}; +use serde_json::{Map, Value, json}; + +use crate::Session; +use crate::branch_tests::{base_with_lists, random_edit}; +use crate::collab_live_tests::{Room, frame_and_rect}; +use crate::collab_tests::base; +use crate::live::{Access, Event, Role, VIEW_ONLY}; +use crate::live_tests::{info, push, relay, serve_as, session}; + +fn ok(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn me(s: &Session) -> u32 { + s.live().client().unwrap().id() +} + +fn version_names(s: &mut Session) -> Vec { + ok(s, "version.list", json!({})).as_array().unwrap().iter().map(|v| v["name"].as_str().unwrap().to_string()).collect() +} + +/// The conflict keys of branch `id`'s review. +fn conflict_keys(s: &mut Session, id: &str) -> Vec { + let review = ok(s, "branch.review", json!({"id": id})); + review["conflicts"].as_array().unwrap().iter().map(|c| c["key"].as_str().unwrap().to_string()).collect() +} + +#[test] +fn a_branch_merges_into_the_session_as_one_synced_edit() { + let mut room = Room::new(base(), 2, 7); + let (f, r) = frame_and_rect(room.server.document()); + let you = me(room.s(0)); + let id = ok(room.s(0), "branch.create", json!({"name": "Explore"}))["id"].as_str().unwrap().to_string(); + // On the branch tab: a recolour, a new layer and a new style. + ok(room.s(0), "fill.set", json!({"ids": [r], "color": "#FF0000"})); + let made = NodeId(ok(room.s(0), "shape.rectangle", json!({"x": 900, "y": 900, "width": 10, "height": 10}))["id"].as_u64().unwrap()); + assert_eq!(space_of(made.0), you, "the branch makes ids in your own client space"); + ok(room.s(0), "style.create", json!({"type": "paint", "name": "Brand", "from": r})); + room.settle(); + assert!(!room.server.document().contains(made), "the branch stays with its author"); + // Someone else edits the main file meanwhile. + ok(room.s(1), "node.set", json!({"id": f, "props": {"name": "Renamed by a peer"}})); + room.settle(); + assert_eq!(conflict_keys(room.s(0), &id), Vec::::new()); + assert_eq!(ok(room.s(0), "branch.merge", json!({"id": id}))["merged"], true); + room.settle(); + room.check(); + let doc = room.server.document(); + assert!(doc.contains(made), "everyone has the merge"); + assert_eq!(doc.node(f).unwrap().name, "Renamed by a peer", "the peer's edit stays"); + assert!(doc.styles.iter().any(|st| st.name == "Brand")); + assert_ne!(doc.node(r).unwrap().fills, base().node(r).unwrap().fills); + // The merge is one step of the author's own undo history. + ok(room.s(0), "edit.undo", json!({})); + room.settle(); + room.check(); + assert!(!room.server.document().contains(made)); + assert_eq!(room.server.document().node(f).unwrap().name, "Renamed by a peer"); +} + +#[test] +fn conflicts_with_peers_resolve_as_offline() { + let mut room = Room::new(base(), 2, 3); + let (f, r) = frame_and_rect(room.server.document()); + let r2 = room.server.document().node(f).unwrap().children[1]; + let id = ok(room.s(0), "branch.create", json!({}))["id"].as_str().unwrap().to_string(); + ok(room.s(0), "node.set", json!({"id": r, "props": {"name": "Kept on the branch"}})); + ok(room.s(0), "node.set", json!({"id": r2, "props": {"opacity": 0.5}})); + // A peer deletes the layer the branch edited and sets the same property another way. + ok(room.s(1), "edit.delete", json!({"ids": [r]})); + ok(room.s(1), "node.set", json!({"id": r2, "props": {"opacity": 0.2}})); + room.settle(); + let keys = conflict_keys(room.s(0), &id); + let mut want = vec![format!("{r}/layer"), format!("{r2}/opacity")]; + want.sort(); + let mut got = keys.clone(); + got.sort(); + assert_eq!(got, want); + assert!(room.s(0).execute("branch.merge", &json!({"id": id})).is_err(), "conflicts first"); + // The branch's edits win the deletion, main wins the opacity. + let picks: Map = keys.iter().map(|k| (k.clone(), json!(if k.ends_with("/layer") { "branch" } else { "main" }))).collect(); + ok(room.s(0), "branch.merge", json!({"id": id, "resolutions": picks})); + room.settle(); + room.check(); + let doc = room.server.document(); + assert_eq!(doc.node(r).map(|n| n.name.as_str()), Some("Kept on the branch"), "the deleted layer is back, with the branch's edit"); + assert_eq!(doc.node(r2).unwrap().opacity, 0.2); +} + +#[test] +fn update_from_main_takes_in_peers_edits_and_shows_in_the_branch_history() { + let mut room = Room::new(base(), 2, 5); + let (f, r) = frame_and_rect(room.server.document()); + let id = ok(room.s(0), "branch.create", json!({}))["id"].as_str().unwrap().to_string(); + ok(room.s(0), "node.set", json!({"id": r, "props": {"name": "Branch"}})); + ok(room.s(1), "node.set", json!({"id": f, "props": {"name": "Peer 1"}})); + room.settle(); + assert_eq!(ok(room.s(0), "branch.updateFromMain", json!({}))["updated"], true); + assert_eq!(room.s(0).doc().unwrap().node(f).unwrap().name, "Peer 1", "the branch tab has the peer's edit"); + assert_eq!(version_names(room.s(0)), ["Branch created", "Updated from main"]); + // The new base is main as it was then: a later peer edit is main's change, not a conflict. + ok(room.s(1), "node.set", json!({"id": f, "props": {"name": "Peer 2"}})); + room.settle(); + assert_eq!(conflict_keys(room.s(0), &id), Vec::::new()); + ok(room.s(0), "branch.merge", json!({})); + room.settle(); + room.check(); + let doc = room.server.document(); + assert_eq!((doc.node(f).unwrap().name.as_str(), doc.node(r).unwrap().name.as_str()), ("Peer 2", "Branch")); +} + +#[test] +fn a_view_link_still_refuses_merging() { + let (mut s, script) = session(); + s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap(); + let mut doc = Document::new("Shared"); + let page = doc.first_page().unwrap(); + let card = doc.create(page, figcraft_doc::NodeKind::Rectangle, "Card", 0.0, 0.0, 10.0, 10.0).unwrap(); + let (_server, id, welcome) = serve_as(&doc, Access::View); + let mut you = info(Role::Guest, id); + you.access = Access::View; + push(&script, vec![Event::Welcome { info: you, peers: vec![] }, welcome]); + s.live_poll(); + assert_eq!(s.active_read_only(), Some(VIEW_ONLY)); + // The branch is yours to edit; merging it would edit the file you may only view. + ok(&mut s, "branch.create", json!({})); + ok(&mut s, "node.set", json!({"id": card, "props": {"name": "Mine"}})); + ok(&mut s, "branch.updateFromMain", json!({})); + match s.execute("branch.merge", &json!({})) { + Err(crate::EngineError::Disabled(_, why)) => assert_eq!(why, VIEW_ONLY), + other => panic!("expected a refusal, got {other:?}"), + } + assert!(script.lock().unwrap().msgs.is_empty(), "nothing was sent"); +} + +#[test] +fn a_branch_made_outside_the_session_merges_edits_but_not_new_layers() { + let (mut s, script) = session(); + let r = NodeId(ok(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap()); + let edits = ok(&mut s, "branch.create", json!({"name": "Edits"}))["id"].as_str().unwrap().to_string(); + ok(&mut s, "node.set", json!({"id": r, "props": {"name": "Edited before the session"}})); + ok(&mut s, "doc.switch", json!({"index": 0})); + let adds = ok(&mut s, "branch.create", json!({"name": "Adds"}))["id"].as_str().unwrap().to_string(); + ok(&mut s, "shape.ellipse", json!({"x": 90, "y": 0, "width": 20, "height": 20})); + ok(&mut s, "doc.switch", json!({"index": 0})); + s.execute("collab.host", &json!({})).unwrap(); + let (mut server, id, welcome) = serve_as(s.doc().unwrap(), Access::Edit); + push(&script, vec![welcome]); + s.live_poll(); + assert!(s.live().syncing()); + // Its new layer is in another id space: refused here rather than by the server. + match s.execute("branch.merge", &json!({"id": adds})) { + Err(crate::EngineError::Disabled(_, why)) => assert!(why.contains("outside this live session"), "{why}"), + other => panic!("expected a refusal, got {other:?}"), + } + assert_eq!(s.doc().unwrap().node_count(), server.document().node_count(), "nothing merged"); + // Edits of layers everyone has go through. + ok(&mut s, "branch.merge", json!({"id": edits})); + let answers = relay(&script, &mut server, id); + push(&script, answers); + s.live_poll(); + assert_eq!(server.document().node(r).unwrap().name, "Edited before the session"); + assert!(*s.doc().unwrap() == *server.document()); +} + +/// The live fuzz test: three people edit with random real commands (export settings and guides +/// included) while one of them works on a branch; every conflict resolved for a random side, the +/// merge goes through the server as it is and everyone converges on it. +#[test] +fn random_edits_on_a_branch_merge_into_a_live_session() { + let (mut created, mut conflicted) = (0, 0); + for seed in 0..8 { + let mut room = Room::new(base_with_lists(), 3, seed); + let mut rng = Rng::new(seed + 50); + let id = ok(room.s(0), "branch.create", json!({}))["id"].as_str().unwrap().to_string(); + for _ in 0..30 { + let i = rng.below(3); + let (cmd, params) = random_edit(room.s(i).doc().unwrap(), &mut rng, true); + let _ = room.s(i).execute(cmd, ¶ms); + if rng.chance(0.3) { + room.settle(); + } + } + room.settle(); + let keys = conflict_keys(room.s(0), &id); + conflicted += usize::from(!keys.is_empty()); + let picks: Map = keys.into_iter().map(|k| (k, json!(if rng.chance(0.5) { "branch" } else { "main" }))).collect(); + let before = room.server.document().clone(); + ok(room.s(0), "branch.merge", json!({"id": id, "resolutions": picks})); + let merged = room.s(0).doc().unwrap().clone(); + assert!(merged.validate().is_empty(), "seed {seed}: {:?}", merged.validate()); + created += usize::from(merged.ids().any(|n| !before.contains(n))); + room.settle(); + room.check(); + assert!(*room.server.document() == merged, "seed {seed}: the server took the merge as it is"); + } + assert!(created >= 3 && conflicted >= 2, "{created} merges added layers, {conflicted} had conflicts"); +} + +/// No branch command panics on junk parameters in a live session, from the main file's tab or +/// the branch's, and the session stays in sync. +#[test] +fn branch_commands_survive_junk_params_in_a_live_session() { + let junk = [ + json!({}), + json!(null), + json!([1, 2]), + json!({"id": 5, "name": 1e308, "resolutions": [1]}), + json!({"id": "nope", "resolutions": {"1/layer": "both", "doc/name": 7}}), + json!({"name": "\u{0}".repeat(500), "resolutions": {"x": "branch"}}), + ]; + let mut room = Room::new(base(), 2, 11); + ok(room.s(0), "branch.create", json!({})); + let ids: Vec<&str> = crate::branches::specs().iter().map(|s| s.id).collect(); + for tab in [0, 1] { + for id in &ids { + for j in &junk { + let s = room.s(0); + if s.docs.len() > tab { + let _ = s.execute("doc.switch", &json!({"index": tab})); + } + if let Err(crate::EngineError::Internal { cmd, msg }) = s.execute(id, j) { + panic!("{cmd} panicked on {j}: {msg}"); + } + room.settle(); + } + } + } + room.check(); +} diff --git a/crates/engine/src/branch_tests.rs b/crates/engine/src/branch_tests.rs index 1c2cc769..f0e8a72e 100644 --- a/crates/engine/src/branch_tests.rs +++ b/crates/engine/src/branch_tests.rs @@ -1,9 +1,9 @@ //! Branches through commands: create, edit on both sides, review, resolve, merge as one undo step, //! update from main, archive, saving with the file, and a fuzz test over random real commands. -use figcraft_collab::merge::{Side, plan}; +use figcraft_collab::merge::{Key, Side, plan}; use figcraft_collab::sim::Rng; -use figcraft_collab::{Change, DocProp, Mode, Replica, Target}; +use figcraft_collab::{Change, DocProp, Mode, Prop, Replica, Target}; use figcraft_doc::{Document, NodeId, NodeKind}; use serde_json::{Value, json}; @@ -215,26 +215,68 @@ fn shows(changes: &[Change], doc: &Document) -> Result<(), String> { Ok(()) } -/// The fuzz test: random real commands on a branch and on main. Without conflicts the merge (both -/// ways) equals applying both change sets and shows every change of either side; with conflicts, -/// resolving them all for either side gives a valid document. +/// [`base`] with two export settings and two guides on its first frame (lists that merge item by +/// item). +pub(crate) fn base_with_lists() -> Document { + let mut doc = base(); + let f = doc.node(doc.first_page().unwrap()).unwrap().children[0]; + let n = doc.get_mut(f).unwrap(); + n.exports = ["@1", "@2"].map(|suffix| figcraft_doc::ExportSetting { suffix: suffix.into(), ..Default::default() }).to_vec(); + n.guides = vec![figcraft_doc::Guide { vertical: true, offset: 10.0 }, figcraft_doc::Guide { vertical: false, offset: 20.0 }]; + doc +} + +/// A random real command: often an edit of a frame's export settings or guides, else +/// [`random_command`]. +pub(crate) fn random_edit(doc: &Document, rng: &mut Rng, with_undo: bool) -> (&'static str, Value) { + if rng.chance(0.6) { + return random_command(doc, rng, with_undo); + } + let frames: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Frame)).map(|n| n.id).collect(); + let f = rng.pick(&frames).map_or(json!(0), |i| json!(i)); + let i = rng.below(3); + match rng.below(6) { + 0 => ("export.add", json!({"ids": [f]})), + 1 => ("export.update", json!({"ids": [f], "index": i, "suffix": format!("@{}", rng.below(9))})), + 2 => ("export.remove", json!({"ids": [f], "index": i})), + 3 => ("guide.add", json!({"frame": f, "vertical": rng.chance(0.5), "offset": rng.below(300)})), + 4 => ("guide.move", json!({"frame": f, "index": i, "offset": rng.below(300)})), + _ => ("guide.remove", json!({"frame": f, "index": i})), + } +} + +/// The fuzz test: random real commands on a branch and on main (export settings and guides +/// included). Without conflicts the merge (both ways) equals applying both change sets and shows +/// every change of either side; with conflicts, resolving them all for either side gives a valid +/// document. #[test] fn random_edits_on_both_sides_merge() { - let (mut disjoint, mut conflicted) = (0, 0); + let (mut disjoint, mut conflicted, mut lists) = (0, 0, 0); for seed in 0..60 { let mut rng = Rng::new(seed); - let start = base(); + let start = base_with_lists(); let mut on_branch = start.clone(); on_branch.set_client_id(start.highest_id_space() + 1); let mut sides = [Session::with_document(on_branch), Session::with_document(start.clone())]; for s in &mut sides { for _ in 0..1 + rng.below(5) { - let (cmd, params) = random_command(s.doc().unwrap(), &mut rng, false); + let (cmd, params) = random_edit(s.doc().unwrap(), &mut rng, false); let _ = s.execute(cmd, ¶ms); } } let [b, m] = sides.map(|s| s.doc().unwrap().clone()); let p = plan(&start, &b, &m).unwrap(); + // Export settings or guides of a layer changed on both sides that merged item by item. + let list_writes = |changes: &[Change]| -> std::collections::BTreeSet { + changes + .iter() + .filter_map(|c| match c { + Change::Set { id, prop: prop @ (Prop::Exports | Prop::Guides), .. } => Some(Key::Node(*id, *prop)), + _ => None, + }) + .collect() + }; + lists += list_writes(&p.branch).intersection(&list_writes(&p.main)).filter(|k| !p.conflicts.iter().any(|c| c.key == **k)).count(); if p.conflicts.is_empty() { disjoint += 1; let none = |_: &figcraft_collab::merge::Key| None; @@ -260,5 +302,5 @@ fn random_edits_on_both_sides_merge() { } } } - assert!(disjoint >= 10 && conflicted >= 5, "{disjoint} disjoint, {conflicted} with conflicts"); + assert!(disjoint >= 10 && conflicted >= 5 && lists >= 3, "{disjoint} disjoint, {conflicted} with conflicts, {lists} lists merged item by item"); } diff --git a/crates/engine/src/branch_versions_tests.rs b/crates/engine/src/branch_versions_tests.rs index 30bdf736..30f5134e 100644 --- a/crates/engine/src/branch_versions_tests.rs +++ b/crates/engine/src/branch_versions_tests.rs @@ -35,12 +35,12 @@ fn branched() -> (Session, String, NodeId) { fn versions_saved_on_a_branch_are_the_branchs_own() { let (mut s, _, r) = branched(); let original = layer_name(&s, r); - // A new branch's history starts empty: the main file's versions stay with it. - assert!(names(&mut s).is_empty()); + // A new branch's history starts with its creation: the main file's versions stay with it. + assert_eq!(names(&mut s), ["Branch created"]); ok(&mut s, "node.set", json!({"id": r, "props": {"name": "Before"}})); let v = ok(&mut s, "file.saveVersion", json!({"name": "Branch v1"}))["id"].clone(); ok(&mut s, "node.set", json!({"id": r, "props": {"name": "After"}})); - assert_eq!(names(&mut s), ["Branch v1"]); + assert_eq!(names(&mut s), ["Branch created", "Branch v1"]); // Viewing a branch version shows it read-only; going back shows the branch as it is. ok(&mut s, "version.preview", json!({"id": v})); assert_eq!(layer_name(&s, r), "Before"); @@ -77,13 +77,13 @@ fn branch_versions_save_with_the_file_and_survive_merging() { ok(&mut t, "file.open", json!({"path": path})); assert_eq!(names(&mut t), ["Main v1"]); ok(&mut t, "branch.open", json!({"id": id})); - assert_eq!(names(&mut t), ["Branch v1", "Autosave"]); + assert_eq!(names(&mut t), ["Branch created", "Branch v1", "Autosave"]); // Merging adds a version to the main file's history; the branch keeps its own. ok(&mut t, "branch.merge", json!({})); assert_eq!(names(&mut t), ["Main v1", "Autosave", "Merged “Explore”"]); assert_eq!(layer_name(&t, r), "On the branch, later"); ok(&mut t, "branch.open", json!({"id": id})); - assert_eq!(names(&mut t), ["Branch v1", "Autosave"]); + assert_eq!(names(&mut t), ["Branch created", "Branch v1", "Autosave"]); std::fs::remove_dir_all(&dir).ok(); } @@ -94,7 +94,7 @@ fn archiving_keeps_the_branch_history() { ok(&mut s, "branch.archive", json!({})); assert_eq!(names(&mut s), ["Main v1"]); ok(&mut s, "branch.open", json!({"id": id})); - assert_eq!(names(&mut s), ["Branch v1"]); + assert_eq!(names(&mut s), ["Branch created", "Branch v1"]); } #[test] diff --git a/crates/engine/src/branches.rs b/crates/engine/src/branches.rs index 8fdea0fc..a4589180 100644 --- a/crates/engine/src/branches.rs +++ b/crates/engine/src/branches.rs @@ -4,6 +4,13 @@ //! both sides with it. Branches are saved in the main file ([`figcraft_format::Branch`]); a //! branch tab's edits and its own version history go into its branch ([`sync`]) and are saved with //! the main file. Merging adds a version to the main file's history; the branch keeps its own. +//! +//! In a live session a branch stays with the person who made it (others don't see it), and +//! merging it is one edit of the session document, which syncs to everyone like any other. The +//! session server takes new layers only in the sender's own id space, so a branch made in the +//! session makes its ids in yours, far above the ids the main file makes there ([`WINDOW`]). +//! Merging a branch made outside the session is refused when it adds layers, styles, variables or +//! comments (their ids are in another space). use std::collections::BTreeMap; use std::sync::Arc; @@ -11,6 +18,7 @@ use std::sync::Arc; pub use figcraft_collab::merge::Side; use figcraft_collab::merge::{self, Conflict, Key, Plan}; use figcraft_collab::{Change, Entry, Prop, Target}; +use figcraft_doc::ids::space_of; use figcraft_doc::{Document, NodeId}; use figcraft_format::Branch; pub use figcraft_format::BranchState; @@ -27,7 +35,7 @@ pub fn specs() -> Vec { spec!( "branch.create", "Create branch", - "{name?} a copy of the file to work on separately, opened in a new tab; it remembers the file as it is now (its base) → {id, name, index (its tab)}", + "{name?} a copy of the file to work on separately, opened in a new tab; it remembers the file as it is now (its base); in a live session it stays yours → {id, name, index (its tab)}", false, create ), @@ -53,14 +61,14 @@ pub fn specs() -> Vec { spec!( "branch.updateFromMain", "Update from main", - "{id?, resolutions? ({: \"branch\" | \"main\"})} bring the main file's changes since the branch was made into the branch; refused while a conflict has no resolution. Not undoable (the branch tab's undo history starts over) → {updated, changes, conflicts}", + "{id?, resolutions? ({: \"branch\" | \"main\"})} bring the main file's changes since the branch was made into the branch (a version in its history); refused while a conflict has no resolution. Not undoable (the branch tab's undo history starts over) → {updated, changes, conflicts}", false, |s, p| combine(s, p, Side::Main) ), spec!( "branch.merge", "Merge branch", - "{id?, resolutions? ({: \"branch\" | \"main\"})} merge the branch into the main file as one undo step, then archive it and show the main file; refused while a conflict has no resolution → {merged, changes, conflicts, index}", + "{id?, resolutions? ({: \"branch\" | \"main\"})} merge the branch into the main file as one undo step (in a live session one edit everyone gets; refused on a view link), then archive it and show the main file; refused while a conflict has no resolution → {merged, changes, conflicts, index}", false, |s, p| combine(s, p, Side::Branch) ), @@ -219,11 +227,42 @@ fn disabled(cmd: &str, why: impl Into) -> EngineError { EngineError::Disabled(cmd.into(), why.into()) } -/// shortcut: branches of a file in a live session are refused (its participants' client ids -/// aren't known here, so a branch's id space could clash); allow them once the server hands out -/// branch id spaces. -fn refuse_live(s: &Session, cmd: &str, uid: u64) -> Result<()> { - if s.live_doc(uid) { Err(disabled(cmd, "branches aren't available in a live session")) } else { Ok(()) } +/// shortcut: in a live session a branch makes ids in your own client space starting this far +/// above every id made there on the main file or its other branches, so neither side makes the +/// other's ids before the merge unless one makes 16 million first; have the server hand out +/// branch id spaces if that ever matters. +const WINDOW: u64 = 1 << 24; + +/// Where a new branch of the file in tab `main` starts making ids in your live session's client +/// space `client`: [`WINDOW`] above the main file's and every branch's next id there (`None`: the +/// space is full). +fn live_window(s: &Session, main: u64, client: u32) -> Option { + let m = s.docs.iter().find(|d| d.uid == main)?; + let tabs = s.docs.iter().filter(|d| d.branch_of.as_ref().is_some_and(|(t, _)| *t == main)).map(|d| d.doc.next_id()); + let next = m.branches.iter().map(|b| b.head.next_id()).chain(tabs).chain([m.doc.next_id()]); + let top = next.filter(|n| space_of(n.0) == client).max()?; + let start = top.0.checked_add(WINDOW)?; + (space_of(start) == client).then_some(NodeId(start)) +} + +/// What merging into a live session's document would add that its server won't take from client +/// `client`: a new layer outside your id space (unless it is a layer of a base taken in this +/// session, `in_session`, that main deleted since: the server takes those back), or a new style, +/// variable, collection or comment in another client's space. `None`: everything goes through. +fn foreign(plan: &Plan, base: &Document, main: &Document, merged: &Document, client: u32, in_session: bool) -> Option { + let outside = |n: NodeId| space_of(n.0) != client && !(in_session && base.contains(n)); + if let Some(n) = merged.nodes().find(|n| !main.contains(n.id) && outside(n.id)) { + return Some(format!("layer “{}”", n.name)); + } + plan.changes(Side::Branch).iter().find_map(|c| match c { + Change::Put { entry, .. } + if entry.id_space().is_some_and(|sp| sp != 0 && sp != client) + && plan.base().value(&Target::Entry(entry.clone())).ok().flatten().is_none() => + { + Some(entry.describe()) + } + _ => None, + }) } fn create(s: &mut Session, p: &Value) -> Result { @@ -232,7 +271,7 @@ fn create(s: &mut Session, p: &Value) -> Result { if ds.branch_of.is_some() { return Err(disabled(CMD, "this tab is a branch: make branches from the main file")); } - refuse_live(s, CMD, ds.uid)?; + let live = s.live_client_id(CMD, ds.uid)?; if ds.branches.len() >= figcraft_format::MAX_BRANCHES { return Err(disabled(CMD, format!("the file has the most branches it can keep ({})", figcraft_format::MAX_BRANCHES))); } @@ -240,12 +279,22 @@ fn create(s: &mut Session, p: &Value) -> Result { string(p, "name").map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Branch {}", ds.branches.len() + 1)); let name = figcraft_format::clip(&name, figcraft_format::MAX_VERSION_NAME); let base = ds.document_itself().clone(); - // Its own id space, above every space the file, its live sessions and its branches used. - let client = ds.branches.iter().map(|b| b.client).chain([base.highest_id_space(), base.client_id()]).max().unwrap_or(0); - let client = client.checked_add(1).ok_or_else(|| disabled(CMD, "no id space left for a new branch"))?; + let no_space = || disabled(CMD, "no id space left for a new branch"); + // In a live session: your own client space, far above the main file's ids there. Otherwise its + // own id space, above every space the file, its live sessions and its branches used. + let (client, start) = match live { + Some(c) => (c, Some(live_window(s, ds.uid, c).ok_or_else(no_space)?)), + None => { + let top = ds.branches.iter().map(|b| b.client).chain([base.highest_id_space(), base.client_id()]).max().unwrap_or(0); + (top.checked_add(1).ok_or_else(no_space)?, None) + } + }; let mut head = (*base).clone(); head.name = name.clone(); head.set_client_id(client); + if let Some(start) = start { + head.reserve_ids(NodeId(start.0 - 1)); + } let id = crate::cmd::file::unique_id(|id| ds.branches.iter().any(|b| b.id == id)); let uid = ds.uid; let time = crate::cmd::comment::now_secs(); @@ -259,7 +308,13 @@ fn create(s: &mut Session, p: &Value) -> Result { head: Arc::new(head), versions: Vec::new(), }); + if live.is_some() { + s.live_branch_base(uid, &id); + } let index = open_tab(s, uid, &id)?; + if let Some(tab) = s.docs.get_mut(index) { + crate::cmd::file::add_version(tab, CREATED, "", false); + } Ok(json!({"id": id, "name": name, "index": index})) } @@ -269,11 +324,22 @@ fn open_tab(s: &mut Session, main: u64, id: &str) -> Result { s.active = i; return Ok(i); } - let m = s.docs.iter_mut().find(|d| d.uid == main).ok_or(EngineError::NoDocument)?; - let b = m.branches.iter_mut().find(|b| b.id == id).ok_or_else(|| bad("branch.open", format!("no branch `{id}`")))?; + let live = s.live_client_id("branch.open", main)?; + let m = s.docs.iter().find(|d| d.uid == main).ok_or(EngineError::NoDocument)?; + let b = m.branches.iter().find(|b| b.id == id).ok_or_else(|| bad("branch.open", format!("no branch `{id}`")))?; let mut doc = (*b.head).clone(); // A branch read from a file has no client id yet. doc.set_client_id(b.client); + // In your live session, a branch that would make ids right where the main file makes them (one + // read from a file that made none yet) moves up. + if live == Some(b.client) + && doc.next_id().0.abs_diff(m.doc.next_id().0) < WINDOW + && let Some(start) = live_window(s, main, b.client) + { + doc.reserve_ids(NodeId(start.0 - 1)); + } + let m = s.docs.iter_mut().find(|d| d.uid == main).ok_or(EngineError::NoDocument)?; + let b = m.branches.iter_mut().find(|b| b.id == id).ok_or_else(|| bad("branch.open", format!("no branch `{id}`")))?; // The branch's own version history: the tab's versions while it is open ([`sync`]). let mut tab = DocState::new(doc, None).with_versions(b.versions.clone()); tab.branch_of = Some((main, id.to_string())); @@ -340,7 +406,13 @@ fn combine(s: &mut Session, p: &Value, from: Side) -> Result { } // The main file itself, not a version shown in its place. m.end_preview(); - refuse_live(s, cmd, main_uid)?; + // Merging edits the session document, which a view link (or a file on its way) can't. + if from == Side::Branch + && let Some(why) = s.live_read_only(main_uid) + { + return Err(disabled(cmd, why)); + } + let live = s.live_client_id(cmd, main_uid)?; let picks = resolutions(cmd, p)?; let (base, head, main) = documents(s, mi, bi).ok_or(EngineError::NoDocument)?; let plan = merge::plan(&base, &head, &main).map_err(|e| EngineError::Other(e.to_string()))?; @@ -351,6 +423,14 @@ fn combine(s: &mut Session, p: &Value, from: Side) -> Result { } let into = if from == Side::Branch { &main } else { &head }; let (mut doc, touched) = plan.merge(into, from, &pick).map_err(|e| EngineError::Other(format!("can't merge: {e}")))?; + if let Some(client) = live.filter(|_| from == Side::Branch) + && let Some(what) = foreign(&plan, &base, &main, &doc, client, s.live_branch_in_session(main_uid, &id)) + { + let why = format!( + "the branch adds {what}, made outside this live session, which the session can't take: merge it once the file has left the session" + ); + return Err(disabled(cmd, why)); + } doc.mark_dirty(touched); normalize(&mut doc, into); let doc = Arc::new(doc); @@ -383,11 +463,33 @@ fn combine(s: &mut Session, p: &Value, from: Side) -> Result { b.base = main; b.head = doc; } + if live.is_some() { + s.live_branch_base(main_uid, &id); + } + note(s, mi, bi, UPDATED); out["updated"] = json!(true); } Ok(out) } +/// The branch's own history entries for its creation and its updates from main. +const CREATED: &str = "Branch created"; +const UPDATED: &str = "Updated from main"; + +/// Add version `name` to the history of branch `bi` of the file in tab `mi`: its tab's when it is +/// open (it goes into the branch with [`sync`]), else the branch's. +fn note(s: &mut Session, mi: usize, bi: usize, name: &str) { + let Some((uid, id)) = s.docs.get(mi).and_then(|m| Some((m.uid, m.branches.get(bi)?.id.clone()))) else { return }; + if let Some(tab) = tab_of(s, uid, &id).and_then(|t| s.docs.get_mut(t)) { + crate::cmd::file::add_version(tab, name, "", false); + return; + } + let Some(b) = s.docs.get_mut(mi).and_then(|m| m.branches_mut().get_mut(bi)) else { return }; + let mut tab = DocState::new((*b.head).clone(), None).with_versions(std::mem::take(&mut b.versions)); + crate::cmd::file::add_version(&mut tab, name, "", false); + b.versions = tab.versions; +} + /// Show merged `doc` in tab `ds`. fn show(ds: &mut DocState, doc: Arc) { ds.doc = doc; diff --git a/crates/engine/src/history_behaviour_tests.rs b/crates/engine/src/history_behaviour_tests.rs index e20f1bd1..45e0c7bf 100644 --- a/crates/engine/src/history_behaviour_tests.rs +++ b/crates/engine/src/history_behaviour_tests.rs @@ -547,7 +547,11 @@ fn branches_and_the_version_history() { // On the branch tab. // A branch keeps its own version history: a version saved on its tab is listed there only. ex(&mut s, "file.saveVersion", json!({"name": "On the branch"})); - assert_eq!(versions(&mut s).iter().map(|v| v["name"].clone()).collect::>(), [json!("On the branch")], "a branch's own history"); + assert_eq!( + versions(&mut s).iter().map(|v| v["name"].clone()).collect::>(), + [json!("Branch created"), json!("On the branch")], + "a branch's own history" + ); rect(&mut s, 900.0, 900.0, None); ex(&mut s, "doc.switch", json!({"index": 0})); let made_on_main = rect(&mut s, -500.0, 0.0, None); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 44502f4f..548f96ed 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1652,6 +1652,8 @@ mod booleans_tests; #[cfg(test)] mod bound_alpha_tests; #[cfg(test)] +mod branch_live_tests; +#[cfg(test)] mod branch_tests; #[cfg(test)] mod branch_versions_tests; @@ -1964,6 +1966,8 @@ mod version_tests; #[cfg(test)] mod view_behaviour_tests; #[cfg(test)] +mod widget_input_tests; +#[cfg(test)] mod widgets_tests; #[cfg(test)] diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index cd8a0f03..f33abd1c 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -37,7 +37,7 @@ //! short-lived presence the host passes on to everyone, never stored. A spotlight makes everyone //! follow the person who started it; moving your own view stops following, as with any follow. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::net::IpAddr; use std::sync::Arc; @@ -599,6 +599,9 @@ pub struct Live { pub spotlight: Option, /// You spotlight: everyone follows you. pub spotlighting: bool, + /// The branches of the session's file made or last updated from main in this session: every + /// layer of their base was in it (see [`crate::branches`]). + branch_bases: BTreeSet, } impl Live { @@ -1251,6 +1254,29 @@ impl Session { self.tab().and_then(|uid| self.live_read_only(uid)) } + /// Your client id in document `uid`'s live session (`None`: it isn't in one). Refused for + /// `cmd` while the session is on its way: there is no id yet. + pub(crate) fn live_client_id(&self, cmd: &str, uid: u64) -> Result> { + let Some(live) = self.live.doc(uid) else { return Ok(None) }; + match &live.client { + Some(c) => Ok(Some(c.id())), + None if live.status.active() => Err(EngineError::Disabled(cmd.into(), "wait until the file's live session has started".into())), + None => Ok(None), + } + } + + /// Branch `id` of document `uid` got its base from the session document just now. + pub(crate) fn live_branch_base(&mut self, uid: u64, id: &str) { + if let Some(live) = self.live.doc_mut(uid).filter(|l| l.syncing()) { + live.branch_bases.insert(id.to_string()); + } + } + + /// Whether branch `id` of document `uid` got its base in the live session it is in now. + pub(crate) fn live_branch_in_session(&self, uid: u64, id: &str) -> bool { + self.live.doc(uid).is_some_and(|l| l.syncing() && l.branch_bases.contains(id)) + } + /// Whether document `uid` is a live session's synced document: its edits sync and its undo /// history is the user's own. pub fn live_doc(&self, uid: u64) -> bool { diff --git a/crates/engine/src/save_format_tests.rs b/crates/engine/src/save_format_tests.rs index fc4ca775..37f22ce7 100644 --- a/crates/engine/src/save_format_tests.rs +++ b/crates/engine/src/save_format_tests.rs @@ -252,7 +252,7 @@ fn fig_copy_from_branch_exports_active_document_without_saving_main() { let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap(); assert!(!persisted.doc.nodes().any(|n| n.name == "Branch rectangle")); assert!(persisted.branches[0].head.nodes().any(|n| n.name == "Branch rectangle")); - assert_eq!(persisted.branches[0].versions[0].name, "Branch version"); + assert_eq!(persisted.branches[0].versions.last().unwrap().name, "Branch version"); assert!(!s.ds().unwrap().is_dirty()); assert!(!s.docs[0].is_dirty()); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ac05185f..811724f2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -415,6 +415,9 @@ fn every_command_survives_junk_params() { json!({"from": 2, "to": 1, "side": "auto"}), // Widgets: plugins, targets and fuel as junk. json!({"id": 1, "plugin": {"x": 1}, "target": [1], "fuel": -1e308, "x": 1e308}), + // Widget fields and menus: fields, items and values as junk. + json!({"id": 1, "field": {"x": 1}, "item": [1], "value": {"x": "\u{0}"}, "fuel": 1e308}), + json!({"id": [1], "field": "label\u{0}", "item": "", "value": "\u{7}".repeat(5000)}), // 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"}), diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index e236e322..2cd9bf97 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -110,6 +110,21 @@ pub fn begin(s: &mut Session, id: NodeId, caret: Option, created: bool) - pub fn end(s: &mut Session) -> Result<()> { let Some(te) = s.ds()?.text_edit.clone() else { return Ok(()) }; s.ds_mut()?.text_edit = None; + // A widget's editable field: the typing is put back and the new value goes to the widget's + // plugin through `widget.setText`, one undo step (or none when nothing changed). + if let Some((w, field)) = s.doc().ok().and_then(|d| crate::widgets::field_of(d, te.id)) { + let value = text_of(s, te.id).map(|t| t.characters).unwrap_or_default(); + s.cancel_interaction()?; + let ds = s.ds_mut()?; + ds.selection = vec![w]; + ds.revision += 1; + if text_of(s, te.id).is_ok_and(|t| t.characters != value) + && let Err(e) = s.execute("widget.setText", &serde_json::json!({"id": w, "field": field, "value": value})) + { + s.message = Some(e.to_string()); + } + return Ok(()); + } // A board object's text (a sticky's note) stays when emptied; the object is selected again. let owner = s.doc().ok().and_then(|d| crate::board::owner_of_text(d, te.id)); let empty = owner.is_none() && text_of(s, te.id).map(|t| t.characters.trim().is_empty()).unwrap_or(false); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 3e2dcd79..629c9a06 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -2105,6 +2105,19 @@ impl Session { /// cancel an in-progress move/resize/draw/marquee, handle board double-clicks, enter text /// editing (select-all on entry, word-select inside the running edit), enter vector editing /// for selected shapes, and step groups/frames one level deeper. + /// A double-click on `leaf` that a board object or a widget's editable field takes (typing into + /// a sticky or a shape on a board; editing the field like any text, its plugin told when editing + /// ends). Whether it was taken. + fn object_double_click(&mut self, doc: &Document, leaf: NodeId, sel: &[NodeId], p: Point, edits: bool) -> Result { + if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && self.board_double_click(doc, leaf, sel)? { + return Ok(true); + } + match crate::widgets::field_at(doc, leaf, p).filter(|_| edits) { + Some(field) => textedit::begin(self, field, None, false).map(|()| true), + None => Ok(false), + } + } + fn double_click(&mut self, ev: &PointerEvent) -> Result<()> { let ds = self.ds()?; let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); @@ -2157,7 +2170,7 @@ impl Session { return crate::vectoredit::begin_many(self, &[id]); } let Some(leaf) = hit_here else { return Ok(()) }; - if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && self.board_double_click(&doc, leaf, &sel)? { + if self.object_double_click(&doc, leaf, &sel, ev.p(), edits)? { return Ok(()); } let target = select_target(&doc, leaf, &sel, true); diff --git a/crates/engine/src/widget_input_tests.rs b/crates/engine/src/widget_input_tests.rs new file mode 100644 index 00000000..ba202987 --- /dev/null +++ b/crates/engine/src/widget_input_tests.rs @@ -0,0 +1,211 @@ +//! Widget fields and property menus (plugin API 4): `widget.setText` and `widget.menu` call the +//! plugin back as one undo step each, from commands and from the canvas (a double-click edits a +//! field like any text); values, items and descriptions from plugins and files are checked, and +//! junk is refused and rolled back. The test plugin outputs its input; on a field edit it draws +//! nothing (the host keeps the value), on `reset` it redraws without a menu. + +use serde_json::{Value, json}; + +use crate::components::tests::{ex, idv, node}; +use crate::plugins_tests::{install, undo_len}; +use crate::tools::{PointerEvent, PointerKind}; +use crate::widgets_tests::{W, err, state}; +use crate::{Session, textedit}; +use figcraft_doc::NodeId; + +const ID: &str = "org.test.note"; + +fn manifest(id: &str, api: u32) -> Value { + json!({"id": id, "name": "Note", "version": "1.0.0", "main": "n.wasm", "api": api, "widget": true, "permissions": {"access": "read"}}) +} + +/// The note widget: a label field, a clickable square and (unless `bare`) a menu. +fn look(bare: bool) -> Value { + let root = json!({"type": "frame", "name": "Note", "layout": "vertical", "gap": 8, "padding": 10, "fill": "#FFFFFF", "children": [ + {"type": "text", "name": "Label", "field": "label", "text": "Label", "size": 14, "width": 120, "color": "#111111"}, + {"type": "rectangle", "target": "inc", "width": 20, "height": 20, "fill": "#0D99FF"}, + ]}); + if bare { + return json!({"state": {"n": 0}, "root": root}); + } + json!({"root": root, "menu": [ + {"item": "reset"}, + {"item": "big", "label": "Big", "type": "toggle", "checked": true}, + {"item": "color", "label": "Colour", "type": "color", "options": ["#ff0000", "#00FF00"], "value": "#FF0000"}, + ]}) +} + +/// The note plugin's module; `on_field` runs on a field edit (default: return without drawing). +fn note(on_field: &str) -> Vec { + let mut w = W::default(); + let (field, reset) = (w.has(r#""field":"label""#), w.has(r#""menu":"reset""#)); + let (bare, full) = (w.render(&look(true)), w.render(&look(false))); + let on_field = if on_field.is_empty() { "(return (i32.const 0))" } else { on_field }; + w.build(&format!("(if {field} (then {on_field})) (if {reset} (then {bare} (return (i32.const 0)))) {full}")) +} + +fn session() -> (Session, NodeId) { + let mut s = Session::new(); + install(&mut s, manifest(ID, 4), ¬e("")); + let w = idv(&ex(&mut s, "widget.insert", json!({"plugin": ID, "x": 40, "y": 40}))); + (s, w) +} + +/// The text of widget `w`'s label field. +fn label(s: &Session, w: NodeId) -> String { + let first = node(s, w).children[0]; + node(s, first).kind.text().unwrap().characters.clone() +} + +#[test] +fn a_field_edit_reaches_the_state_the_layer_and_the_plugin_as_one_undo_step() { + let (mut s, w) = session(); + let list = ex(&mut s, "widget.list", json!({})); + let item = &list["widgets"][0]; + assert_eq!((item["fields"].clone(), item["targets"].clone()), (json!(["label"]), json!(["inc"]))); + assert_eq!(item["menu"][0], json!({"item": "reset", "label": "reset", "type": "action"}), "a label defaults to the item's name"); + assert_eq!(item["menu"][2]["options"], json!(["#FF0000", "#00FF00"]), "colours are normalised"); + let undo = undo_len(&s); + // Tabs become spaces and other control characters are dropped. + let inner = node(&s, w).children[1]; + let r = ex(&mut s, "widget.setText", json!({"id": inner, "field": "label", "value": "Hello\tworld\u{7}"})); + assert_eq!(r["output"]["widget"]["event"], json!({"field": "label", "value": "Hello world"})); + assert_eq!(r["output"]["widget"]["state"], json!({"label": "Hello world"}), "the plugin sees the value in the state too"); + assert_eq!((state(&mut s, w), label(&s, w), undo_len(&s)), (json!({"label": "Hello world"}), "Hello world".into(), undo + 1)); + ex(&mut s, "edit.undo", json!({})); + assert_eq!((state(&mut s, w), label(&s, w)), (json!({}), "Label".into())); + // Bad edits are refused and change nothing. + for (p, why) in [ + (json!({"id": w, "field": "nope", "value": "x"}), "it has: label"), + (json!({"id": w, "field": "label"}), "`value` required"), + (json!({"id": w, "field": "label", "value": 5}), "`value` required"), + (json!({"id": w, "field": "label", "value": "x".repeat(1001)}), "longer than 1000"), + (json!({"id": w, "value": "x"}), "`field` required"), + (json!({"id": 999_999, "field": "label", "value": "x"}), "isn't a widget"), + ] { + assert!(err(&mut s, "widget.setText", p.clone()).contains(why), "{p}"); + } + assert_eq!((state(&mut s, w), undo_len(&s)), (json!({}), undo)); + assert_eq!( + ex(&mut s, "widget.setText", json!({"id": w, "field": "label", "value": "x".repeat(1000)}))["state"]["label"].as_str().map(str::len), + Some(1000) + ); +} + +#[test] +fn a_runaway_or_failing_field_callback_rolls_the_value_back() { + let mut s = Session::new(); + install(&mut s, manifest(ID, 4), ¬e("(loop $l (br $l))")); + let w = idv(&ex(&mut s, "widget.insert", json!({"plugin": ID}))); + let undo = undo_len(&s); + assert!(err(&mut s, "widget.setText", json!({"id": w, "field": "label", "value": "Hi", "fuel": 100_000})).contains("instruction budget")); + assert_eq!((state(&mut s, w), label(&s, w), undo_len(&s)), (json!({}), "Label".into(), undo)); + // A plugin that returns an error. + install(&mut s, manifest(ID, 4), ¬e("(return (i32.const 1))")); + assert!(s.execute("widget.setText", &json!({"id": w, "field": "label", "value": "Hi"})).is_err()); + assert_eq!((state(&mut s, w), undo_len(&s)), (json!({}), undo)); +} + +#[test] +fn menu_items_call_the_plugin_back_with_their_values() { + let (mut s, w) = session(); + let event = |s: &mut Session, p: Value| ex(s, "widget.menu", p)["output"]["widget"]["event"].clone(); + // A toggle flips by default, or takes the value given; a colour is matched in any case. + assert_eq!(event(&mut s, json!({"id": w, "item": "big"})), json!({"menu": "big", "value": false})); + assert_eq!(event(&mut s, json!({"id": w, "item": "big", "value": true})), json!({"menu": "big", "value": true})); + assert_eq!(event(&mut s, json!({"id": w, "item": "color", "value": "#00ff00"})), json!({"menu": "color", "value": "#00FF00"})); + let undo = undo_len(&s); + for (p, why) in [ + (json!({"id": w, "item": "color", "value": "#0000FF"}), "must be one of #FF0000, #00FF00"), + (json!({"id": w, "item": "color"}), "must be one of"), + (json!({"id": w, "item": "color", "value": "#".repeat(5000)}), "must be one of"), + (json!({"id": w, "item": "nope"}), "it has: reset, big, color"), + (json!({"id": w}), "`item` required"), + ] { + assert!(err(&mut s, "widget.menu", p.clone()).contains(why), "{p}"); + } + assert_eq!(undo_len(&s), undo); + // An action; this one redraws without a menu, which then has nothing to choose. + let r = ex(&mut s, "widget.menu", json!({"id": w, "item": "reset"})); + assert_eq!( + (r["output"]["widget"]["event"].clone(), r["state"].clone(), r["menu"].clone()), + (json!({"menu": "reset", "value": null}), json!({"n": 0}), json!([])) + ); + assert_eq!(undo_len(&s), undo + 1); + assert!(err(&mut s, "widget.menu", json!({"id": w, "item": "reset"})).contains("no menu item")); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(crate::widgets::menu_of(s.doc().unwrap(), w).len(), 3, "undo brings the menu back"); +} + +#[test] +fn fields_and_menus_need_api_4_and_forged_menus_are_ignored() { + let mut s = Session::new(); + install(&mut s, manifest("org.test.old", 3), ¬e("")); + assert!(err(&mut s, "widget.insert", json!({"plugin": "org.test.old"})).contains("need plugin API 4")); + assert!(ex(&mut s, "widget.list", json!({}))["widgets"].as_array().unwrap().is_empty(), "rolled back"); + // A file (or a peer) can carry anything in a widget's menu data: junk reads as no menu. + let (mut s, w) = session(); + let deep = "[".repeat(100_000); + for junk in ["not json", r#"[{"item": "has space"}]"#, r#"[{"item": "c", "type": "color"}]"#, "{}", deep.as_str()] { + s.ds_mut().unwrap().doc_mut().get_mut(w).unwrap().plugin_data.insert("figcraft.widget.menu".into(), junk.to_string()); + assert!(crate::widgets::menu_of(s.doc().unwrap(), w).is_empty(), "{}", &junk[..junk.len().min(40)]); + assert!(err(&mut s, "widget.menu", json!({"id": w, "item": "c"})).contains("no menu item")); + } +} + +#[test] +fn double_clicking_a_field_edits_it_on_the_canvas() { + let (mut s, w) = session(); + let field = node(&s, w).children[0]; + // Without any font a text may have no size to click on. + let Some(b) = s.doc().unwrap().world_bounds(field).filter(|b| b.width() > 1.0 && b.height() > 1.0) else { return }; + let p = b.center(); + let at = |kind| PointerEvent::at(kind, p.x, p.y, 1.0); + s.pointer(&at(PointerKind::Down)).unwrap(); + s.pointer(&at(PointerKind::Up)).unwrap(); + assert_eq!(s.selection(), vec![w], "a click selects the widget"); + s.pointer(&at(PointerKind::Down)).unwrap(); + s.pointer(&at(PointerKind::DoubleClick)).unwrap(); + assert_eq!(s.ds().unwrap().text_edit.as_ref().map(|te| te.id), Some(field), "a double-click edits the field"); + let undo = undo_len(&s); + // Ending without a change makes no undo step and doesn't call the plugin. + textedit::end(&mut s).unwrap(); + assert_eq!((undo_len(&s), state(&mut s, w), s.selection()), (undo, json!({}), vec![w])); + // Typing replaces the text (it's all selected); ending sends it to the plugin, one undo step. + s.pointer(&at(PointerKind::Down)).unwrap(); + s.pointer(&at(PointerKind::DoubleClick)).unwrap(); + textedit::insert(&mut s, "Hi").unwrap(); + ex(&mut s, "text.edit.end", json!({})); + assert_eq!((state(&mut s, w), label(&s, w), undo_len(&s)), (json!({"label": "Hi"}), "Hi".into(), undo + 1)); + assert_eq!((s.selection(), s.ds().unwrap().text_edit.is_none()), (vec![w], true)); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(label(&s, w), "Label"); + // A failed callback leaves the field as it was and says why. + ex(&mut s, "plugin.remove", json!({"id": ID})); + s.pointer(&at(PointerKind::Down)).unwrap(); + s.pointer(&at(PointerKind::DoubleClick)).unwrap(); + textedit::insert(&mut s, "Lost").unwrap(); + textedit::end(&mut s).unwrap(); + assert_eq!(label(&s, w), "Label"); + assert!(s.message.as_deref().is_some_and(|m| m.contains("no plugin")), "{:?}", s.message); +} + +#[test] +fn a_field_edit_syncs_in_a_live_session() { + use crate::live_tests::{push, relay, serve_as, session as live}; + let (mut s, script) = live(); + install(&mut s, manifest(ID, 4), ¬e("")); + s.execute("collab.host", &json!({})).unwrap(); + let (mut server, me, welcome) = serve_as(s.doc().unwrap(), crate::live::Access::Edit); + push(&script, vec![welcome]); + s.live_poll(); + let w = idv(&ex(&mut s, "widget.insert", json!({"plugin": ID}))); + ex(&mut s, "widget.setText", json!({"id": w, "field": "label", "value": "Shared"})); + s.live_sync(); + let back = relay(&script, &mut server, me); + push(&script, back); + s.live_poll(); + let theirs = server.document().node(w).unwrap(); + assert_eq!(theirs.plugin_data.get("figcraft.widget.state").map(String::as_str), Some(r#"{"label":"Shared"}"#)); + assert!(theirs.plugin_data.contains_key("figcraft.widget.menu"), "the menu travels with the widget"); +} diff --git a/crates/engine/src/widgets.rs b/crates/engine/src/widgets.rs index c1ccd2a3..5d824363 100644 --- a/crates/engine/src/widgets.rs +++ b/crates/engine/src/widgets.rs @@ -6,13 +6,18 @@ //! of it the plugin named as a target calls the plugin back with that state, in a fresh sandbox //! (deterministic: the state is all it remembers), as one undo step; the plugin hands back the new //! state and layers. A callback can only rebuild its own widget; other layers change only through -//! the commands its manifest permits. See `docs/plugins.md`. +//! the commands its manifest permits. Since API 4 a text layer can be an editable field (edited on +//! the canvas like any text, or with `widget.setText`: the value goes into the state under the +//! field's name and the plugin is called back), and a widget can have a small property menu +//! (actions, toggles, a colour from a few) shown while it's selected (`widget.menu`). See +//! `docs/plugins.md`. use figcraft_color::{ImageScaleMode, Paint}; use figcraft_doc::{Document, Node, NodeId, NodeKind, TextData}; use figcraft_geom::{Affine, Point}; -use figcraft_plugin::widget::{MAX_IMAGE_BYTES, MAX_STATE_BYTES}; +use figcraft_plugin::widget::{MAX_IMAGE_BYTES, MAX_STATE_BYTES, check_menu, check_state, clean_field}; use figcraft_plugin::{Instance, Manifest, RunOutput, WidgetKind, WidgetNode, WidgetRender}; +pub use figcraft_plugin::{MenuItem, MenuKind}; use serde_json::{Map, Value, json}; use crate::cmd::CommandSpec; @@ -25,8 +30,12 @@ pub const DATA_PREFIX: &str = "figcraft.widget"; /// On a widget's own layer: its plugin's id, and its state (JSON). const PLUGIN: &str = "figcraft.widget"; const STATE: &str = "figcraft.widget.state"; +/// On a widget's own layer: its property menu (JSON). +const MENU: &str = "figcraft.widget.menu"; /// On a layer inside a widget: the name its plugin gets when it's clicked. const TARGET: &str = "figcraft.widget.target"; +/// On a text layer inside a widget: the name of the editable field it is. +const FIELD: &str = "figcraft.widget.field"; /// Images in a widget are at most this many pixels on a side. const MAX_IMAGE_SIDE: u32 = 4096; @@ -40,18 +49,44 @@ pub fn widget_of(doc: &Document, id: NodeId) -> Option { std::iter::once(id).chain(doc.ancestors(id)).find(|a| doc.node(*a).is_some_and(is_widget)) } -/// The clickable part of a widget at page point `p`, given the layer a click there hits: the widget -/// and its target. The layers inside a widget are locked, so the hit stops at the widget itself; -/// the front-most target whose box holds the point wins. -pub fn target_at(doc: &Document, hit: NodeId, p: Point) -> Option<(NodeId, String)> { +/// The part of a widget at page point `p` marked with layer data `key`, given the layer a click +/// there hits: the widget, the part and its name. The layers inside a widget are locked, so the +/// hit stops at the widget itself; the front-most part whose box holds the point wins. +fn part_at(doc: &Document, hit: NodeId, p: Point, key: &str) -> Option<(NodeId, NodeId, String)> { let w = widget_of(doc, hit)?; - let target = doc - .subtree(w) + doc.subtree(w) .into_iter() .filter(|i| doc.world_bounds(*i).is_some_and(|b| b.contains(p))) - .filter_map(|i| doc.node(i).filter(|n| n.visible)?.plugin_data.get(TARGET).cloned()) - .last()?; - Some((w, target)) + .filter_map(|i| Some((w, i, doc.node(i).filter(|n| n.visible)?.plugin_data.get(key).cloned()?))) + .last() +} + +/// The clickable part of a widget at page point `p`, given the layer a click there hits: the widget +/// and its target. +pub fn target_at(doc: &Document, hit: NodeId, p: Point) -> Option<(NodeId, String)> { + part_at(doc, hit, p, TARGET).map(|(w, _, t)| (w, t)) +} + +/// The editable text field of a widget at page point `p`, given the layer a click there hits: its +/// text layer. +pub fn field_at(doc: &Document, hit: NodeId, p: Point) -> Option { + part_at(doc, hit, p, FIELD).map(|(_, f, _)| f).filter(|f| field_of(doc, *f).is_some()) +} + +/// The widget and field name text layer `id` is the editable field of. +pub fn field_of(doc: &Document, id: NodeId) -> Option<(NodeId, String)> { + let field = doc.node(id).filter(|n| matches!(n.kind, NodeKind::Text(_)))?.plugin_data.get(FIELD)?.clone(); + Some((widget_of(doc, id)?, field)) +} + +/// Widget `w`'s property menu (empty when it has none or it's unusable: it may come from a file). +pub fn menu_of(doc: &Document, w: NodeId) -> Vec { + doc.node(w) + .and_then(|n| n.plugin_data.get(MENU)) + .filter(|s| s.len() <= MAX_STATE_BYTES) + .and_then(|s| serde_json::from_str::>(s).ok()) + .and_then(|mut m| check_menu(&mut m).is_ok().then_some(m)) + .unwrap_or_default() } /// A widget's state (an empty object when it has none or it's unusable). @@ -67,10 +102,11 @@ fn state_of(doc: &Document, w: NodeId) -> Map { .unwrap_or_default() } -/// The targets inside widget `w`, each once, in layer order. -fn targets(doc: &Document, w: NodeId) -> Vec { +/// The names of the parts inside widget `w` marked with layer data `key` (its targets or its +/// fields), each once, in layer order. +fn names(doc: &Document, w: NodeId, key: &str) -> Vec { let mut out: Vec = Vec::new(); - for t in doc.subtree(w).into_iter().filter_map(|i| doc.node(i)?.plugin_data.get(TARGET).cloned()) { + for t in doc.subtree(w).into_iter().filter_map(|i| doc.node(i)?.plugin_data.get(key).cloned()) { if !out.contains(&t) { out.push(t); } @@ -89,20 +125,22 @@ fn widget_plugin(s: &Session, cmd: &str, plugin: &str) -> Result, fuel: Option) -> Result { +/// Call widget `w`'s plugin `p` back (`event`: what happened, a click, an edit or a menu item; +/// none when it's inserted) and build what it hands back. Part of the caller's undo step; a failure +/// rolls the whole step back. +fn callback(s: &mut Session, p: &InstalledPlugin, w: NodeId, event: Option, fuel: Option) -> Result { let m = &p.manifest; let wasm = module_of(s, p)?; - let event = target.map(|t| json!({"target": t})); + let inserting = event.is_none(); let state = state_of(s.doc()?, w); let input = json!({"api": m.api, "command": "widget", "params": {}, "selection": s.selection(), "widget": {"id": w, "state": state, "event": event}}); let mut instance = Instance::new(&wasm, &limits(fuel)).map_err(|e| plugin_error(&m.name, e))?; let mut out = call_plugin(s, m, &m.name, &mut instance, &input.to_string()).map_err(|(e, _)| e)?; match out.widget.take() { + Some(r) if m.api < 4 && r.uses_api_4() => return Err(bad("widget.render", format!("{}: fields and menus need plugin API 4", m.name))), Some(r) => build(s, &m.id, w, &r)?, - None if target.is_none() => return Err(bad("widget.insert", format!("{} drew no widget", m.name))), + None if inserting => return Err(bad("widget.insert", format!("{} drew no widget", m.name))), None => {} } Ok(out) @@ -130,6 +168,10 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<( if let Some(state) = &r.state { n.plugin_data.insert(STATE.into(), Value::Object(state.clone()).to_string()); } + match serde_json::to_string(&r.menu) { + Ok(menu) if !r.menu.is_empty() => n.plugin_data.insert(MENU.into(), menu), + _ => n.plugin_data.remove(MENU), + }; for c in &r.root.children { add(doc, w, c, cmd)?; } @@ -154,6 +196,9 @@ fn add(doc: &mut Document, parent: NodeId, d: &WidgetNode, cmd: &str) -> Result< if !d.target.is_empty() { n.plugin_data.insert(TARGET.into(), d.target.clone()); } + if !d.field.is_empty() { + n.plugin_data.insert(FIELD.into(), d.field.clone()); + } if d.kind == WidgetKind::Image { use base64::Engine as _; let bytes = base64::engine::general_purpose::STANDARD.decode(d.data.trim()).map_err(|e| bad(cmd, format!("an image's `data`: {e}")))?; @@ -230,12 +275,13 @@ fn props(d: &WidgetNode, root: bool) -> Map { p } -/// What `widget.insert` and `widget.click` return. +/// What `widget.insert` and the callbacks return. fn report(s: &Session, w: NodeId, m: &Manifest, out: RunOutput) -> Result { let doc = s.doc()?; let output = serde_json::from_str::(&out.output).unwrap_or(Value::String(out.output)); + let (targets, fields, menu) = (names(doc, w, TARGET), names(doc, w, FIELD), menu_of(doc, w)); Ok( - json!({"id": w, "plugin": m.id, "state": state_of(doc, w), "targets": targets(doc, w), "output": output, "logs": out.logs, "calls": out.calls, "fuel": out.fuel}), + json!({"id": w, "plugin": m.id, "state": state_of(doc, w), "targets": targets, "fields": fields, "menu": menu, "output": output, "logs": out.logs, "calls": out.calls, "fuel": out.fuel}), ) } @@ -272,21 +318,87 @@ fn insert(s: &mut Session, p: &Value) -> Result { report(s, w, m, out) } +/// The widget param `id` is, or is inside. +fn widget_param(s: &Session, cmd: &str, p: &Value) -> Result { + let nid = id(p, "id").ok_or_else(|| bad(cmd, "`id` required (a widget, or a layer in one)"))?; + widget_of(s.doc()?, nid).ok_or_else(|| bad(cmd, format!("{nid} isn't a widget or inside one"))) +} + +/// Call widget `w`'s plugin back with `ev`, after `prep` (which may change the widget first), as +/// one undo step. +fn event(s: &mut Session, cmd: &str, w: NodeId, ev: Value, p: &Value, prep: impl FnOnce(&mut Session) -> Result<()>) -> Result { + let plugin = s.doc()?.node(w).and_then(|n| n.plugin_data.get(PLUGIN)).cloned().unwrap_or_default(); + let wp = widget_plugin(s, cmd, &plugin)?; + let out = s.grouped(&wp.manifest.name, |s| { + prep(s)?; + callback(s, &wp, w, Some(ev), num(p, "fuel")) + })?; + report(s, w, &wp.manifest, out) +} + fn click(s: &mut Session, p: &Value) -> Result { let cmd = "widget.click"; - let nid = id(p, "id").ok_or_else(|| bad(cmd, "`id` required (a widget, or a layer in one)"))?; + let w = widget_param(s, cmd, p)?; let target = string(p, "target").ok_or_else(|| bad(cmd, "`target` required (see widget.list)"))?; - let doc = s.doc()?; - let w = widget_of(doc, nid).ok_or_else(|| bad(cmd, format!("{nid} isn't a widget or inside one")))?; - let all = targets(doc, w); + let all = names(s.doc()?, w, TARGET); if !all.contains(&target) { return Err(bad(cmd, format!("the widget has no `{target}` to click (it has: {})", all.join(", ")))); } - let plugin = doc.node(w).and_then(|n| n.plugin_data.get(PLUGIN)).cloned().unwrap_or_default(); - let fuel = num(p, "fuel"); - let wp = widget_plugin(s, cmd, &plugin)?; - let out = s.grouped(&wp.manifest.name, |s| callback(s, &wp, w, Some(&target), fuel))?; - report(s, w, &wp.manifest, out) + event(s, cmd, w, json!({"target": target}), p, |_| Ok(())) +} + +fn set_text(s: &mut Session, p: &Value) -> Result { + let cmd = "widget.setText"; + let w = widget_param(s, cmd, p)?; + let field = string(p, "field").ok_or_else(|| bad(cmd, "`field` required (see widget.list)"))?; + let all = names(s.doc()?, w, FIELD); + if !all.contains(&field) { + return Err(bad(cmd, format!("the widget has no field `{field}` (it has: {})", all.join(", ")))); + } + let value = match p.get("value") { + Some(Value::String(v)) => clean_field(v).map_err(|e| bad(cmd, e))?, + _ => return Err(bad(cmd, "`value` required (text)")), + }; + let ev = json!({"field": field, "value": value}); + // The value goes into the state and the field's text before the plugin hears of it, so a + // plugin that doesn't redraw still keeps (and shows) it. + event(s, cmd, w, ev, p, |s| { + let ds = s.ds_mut()?; + let before = ds.doc.clone(); + let mut state = state_of(&ds.doc, w); + state.insert(field.clone(), Value::String(value.clone())); + check_state(&state).map_err(|e| bad(cmd, e))?; + let doc = ds.doc_mut(); + let text = Map::from_iter([("characters".to_string(), Value::String(value))]); + for l in doc.subtree(w) { + if doc.node(l).is_some_and(|n| n.plugin_data.get(FIELD) == Some(&field) && matches!(n.kind, NodeKind::Text(_))) { + crate::cmd::props::apply(cmd, doc, l, &text)?; + } + } + doc.get_mut(w)?.plugin_data.insert(STATE.into(), Value::Object(state).to_string()); + crate::normalize(doc, &before); + Ok(()) + }) +} + +fn menu(s: &mut Session, p: &Value) -> Result { + let cmd = "widget.menu"; + let w = widget_param(s, cmd, p)?; + let item = string(p, "item").ok_or_else(|| bad(cmd, "`item` required (see widget.list)"))?; + let all = menu_of(s.doc()?, w); + let Some(m) = all.iter().find(|m| m.item == item) else { + let names: Vec<&str> = all.iter().map(|m| m.item.as_str()).collect(); + return Err(bad(cmd, format!("the widget has no menu item `{item}` (it has: {})", names.join(", ")))); + }; + let value = match m.kind { + MenuKind::Action => Value::Null, + MenuKind::Toggle => json!(p.get("value").and_then(Value::as_bool).unwrap_or(!m.checked)), + MenuKind::Color => { + let hex = string(p, "value").filter(|v| v.len() <= 9).and_then(|v| figcraft_color::Color::from_hex(&v)).map(|c| c.to_css_hex()); + json!(hex.filter(|h| m.options.contains(h)).ok_or_else(|| bad(cmd, format!("`value` must be one of {}", m.options.join(", "))))?) + } + }; + event(s, cmd, w, json!({"menu": item, "value": value}), p, |_| Ok(())) } /// Click `target` of the widget `id` is (or is in) on `doc`, a document that isn't one of the @@ -311,7 +423,10 @@ fn list(s: &mut Session, _p: &Value) -> Result { .filter_map(|i| { let n = doc.node(i).filter(|n| is_widget(n))?; let state = Value::Object(state_of(doc, i)); - Some(json!({"id": i, "plugin": n.plugin_data.get(PLUGIN), "name": n.name, "page": doc.page_of(i), "state": state, "targets": targets(doc, i)})) + let (targets, fields, menu) = (names(doc, i, TARGET), names(doc, i, FIELD), menu_of(doc, i)); + Some( + json!({"id": i, "plugin": n.plugin_data.get(PLUGIN), "name": n.name, "page": doc.page_of(i), "state": state, "targets": targets, "fields": fields, "menu": menu}), + ) }) .collect(); Ok(json!({"widgets": widgets})) @@ -333,6 +448,20 @@ pub fn specs() -> Vec { false, click ), - spec!("widget.list", "List widgets", "{} → {widgets: [{id, plugin, name, page, state, targets}]}", false, list), + spec!( + "widget.setText", + "Edit widget field", + "{id (a widget or a layer in one), field (see widget.list), value (text, up to 1000 characters), fuel?} sets an editable field as typing in it would: the value goes into the state under the field's name and the plugin is called back; one undo step → like widget.insert", + false, + set_text + ), + spec!( + "widget.menu", + "Widget menu", + "{id (a widget or a layer in one), item (see widget.list), value? (a toggle: on or off, default the opposite of now; a colour: one of its options), fuel?} picks an item of the widget's property menu: the plugin is called back; one undo step → like widget.insert", + false, + menu + ), + spec!("widget.list", "List widgets", "{} → {widgets: [{id, plugin, name, page, state, targets, fields, menu}]}", false, list), ] } diff --git a/crates/engine/src/widgets_tests.rs b/crates/engine/src/widgets_tests.rs index f228d35c..bc952acd 100644 --- a/crates/engine/src/widgets_tests.rs +++ b/crates/engine/src/widgets_tests.rs @@ -17,13 +17,13 @@ const REPLY: usize = 200_000; /// Lays out a test plugin's strings and builds its module. #[derive(Default)] -struct W { +pub(crate) struct W { data: String, at: usize, } impl W { - fn s(&mut self, text: &str) -> (usize, usize) { + pub(crate) fn s(&mut self, text: &str) -> (usize, usize) { let a = 1000 + self.at; self.data += &format!("(data (i32.const {a}) \"{}\")\n", lit(text)); self.at += text.len(); @@ -32,7 +32,7 @@ impl W { } /// Draw `v`; when it's refused, output why and fail. - fn render(&mut self, v: &Value) -> String { + pub(crate) fn render(&mut self, v: &Value) -> String { let (a, l) = self.s(&v.to_string()); format!( "(if (call $render (i32.const {a}) (i32.const {l})) (then (call $set_output (i32.const {REPLY}) (call $reply_read (i32.const {REPLY}) (i32.const 50000))) (return (i32.const 1))))" @@ -40,13 +40,13 @@ impl W { } /// Whether the input contains `needle`. - fn has(&mut self, needle: &str) -> String { + pub(crate) fn has(&mut self, needle: &str) -> String { let (a, l) = self.s(needle); format!("(call $has (i32.const {a}) (i32.const {l}) (local.get $n))") } /// Call `cmd`; a refusal becomes the output and the run stops (successfully). - fn call(&mut self, cmd: &str, params: &Value) -> String { + pub(crate) fn call(&mut self, cmd: &str, params: &Value) -> String { let ((a, l), (b, m)) = (self.s(cmd), self.s(¶ms.to_string())); format!( "(if (call $call (i32.const {a}) (i32.const {l}) (i32.const {b}) (i32.const {m})) @@ -55,7 +55,7 @@ impl W { } /// The module: it reads and outputs its input, then runs `body`. - fn build(self, body: &str) -> Vec { + pub(crate) fn build(self, body: &str) -> Vec { let wat = format!( r#"(module (import "figcraft" "call" (func $call (param i32 i32 i32 i32) (result i32))) @@ -108,7 +108,7 @@ fn tally(extra: impl FnOnce(&mut W) -> String) -> Vec { w.build(&format!("{pre} (if {inc} (then {two} (return (i32.const 0)))) (if {dec} (then {zero} (return (i32.const 0)))) {one}")) } -fn widget_manifest(id: &str, access: &str) -> Value { +pub(crate) fn widget_manifest(id: &str, access: &str) -> Value { json!({"id": id, "name": "Tally", "version": "1.0.0", "main": "w.wasm", "api": 3, "widget": true, "permissions": {"access": access}}) } @@ -128,7 +128,7 @@ fn click(s: &mut Session, w: NodeId, target: &str) -> Value { ex(s, "widget.click", json!({"id": w, "target": target})) } -fn state(s: &mut Session, w: NodeId) -> Value { +pub(crate) fn state(s: &mut Session, w: NodeId) -> Value { let list = ex(s, "widget.list", json!({})); list["widgets"].as_array().unwrap().iter().find(|x| x["id"] == json!(w)).map(|x| x["state"].clone()).unwrap_or(Value::Null) } @@ -139,7 +139,7 @@ fn label(s: &Session, w: NodeId) -> String { node(s, first).kind.text().unwrap().characters.clone() } -fn err(s: &mut Session, cmd: &str, p: Value) -> String { +pub(crate) fn err(s: &mut Session, cmd: &str, p: Value) -> String { match s.execute(cmd, &p) { Ok(v) => panic!("{cmd} accepted {p}: {v}"), Err(e) => { diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index 80a26295..31e5234b 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -11,7 +11,7 @@ pub mod widget; pub use manifest::{API_VERSION, Access, MANIFEST_FILE, Manifest, Panel, Param, ParamKind, Permissions, PluginCommand}; pub use sandbox::{ENTRY, Host, IMPORT_MODULE, Instance, Limits, PluginError, RunOutput, UiChange, check_module, run}; -pub use widget::{WidgetKind, WidgetNode, WidgetRender}; +pub use widget::{MenuItem, MenuKind, WidgetKind, WidgetNode, WidgetRender}; #[cfg(test)] mod bundle_tests; diff --git a/crates/plugin/src/manifest.rs b/crates/plugin/src/manifest.rs index fc4839e3..92cfac7b 100644 --- a/crates/plugin/src/manifest.rs +++ b/crates/plugin/src/manifest.rs @@ -43,7 +43,7 @@ pub struct Manifest { /// The WebAssembly module, relative to the manifest (`plugin.wasm`). pub main: String, /// The plugin API version the plugin was written for (1 when not given; panels and settings - /// need 2, widgets 3, `saveFile` 4). + /// need 2, widgets 3, `saveFile`, widget fields and property menus 4). #[serde(default = "api_version", deserialize_with = "api_from_number_or_string")] pub api: u32, #[serde(default)] diff --git a/crates/plugin/src/widget.rs b/crates/plugin/src/widget.rs index 32ee7d34..be202c97 100644 --- a/crates/plugin/src/widget.rs +++ b/crates/plugin/src/widget.rs @@ -1,5 +1,5 @@ -//! Widgets (ABI v3): a plugin draws a layer on the canvas by describing it, and FigCraft builds the -//! layers. The description comes from the plugin, so it is untrusted: [`WidgetRender::parse`] +//! Widgets (ABI v3; editable text fields and a property menu since v4): a plugin draws a layer on +//! the canvas by describing it, and FigCraft builds the layers. The description comes from the plugin, so it is untrusted: [`WidgetRender::parse`] //! checks its size, node count, depth, numbers, strings, colours and image sizes before the engine //! sees it. The engine decides where the layers go (only inside the widget's own layer). @@ -24,6 +24,12 @@ const MAX_SIZE: f64 = 4000.0; /// Largest image (decoded bytes), and all images of one widget together. pub const MAX_IMAGE_BYTES: usize = 1024 * 1024; const MAX_IMAGES_BYTES: usize = 2 * 1024 * 1024; +/// Longest value of an editable text field (the same as any text layer's). +pub const MAX_FIELD_CHARS: usize = MAX_TEXT; +/// Most items in a widget's property menu, most colours in one colour item, longest item label. +const MAX_MENU: usize = 8; +const MAX_SWATCHES: usize = 8; +const MAX_MENU_LABEL: usize = 32; /// What a widget is made of: FigCraft's own layer kinds. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -65,6 +71,10 @@ pub struct WidgetNode { /// Clicking this layer calls the plugin back with this name (empty: not clickable). #[serde(default, skip_serializing_if = "String::is_empty")] pub target: String, + /// API 4, text layers: the text is an editable field with this name; an edit calls the plugin + /// back with the field and its new value, which is also kept in the state under this name. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub field: String, /// Position in the parent frame (ignored inside auto layout and for the widget itself). #[serde(default, skip_serializing_if = "Option::is_none")] pub x: Option, @@ -119,6 +129,38 @@ pub struct WidgetRender { #[serde(default, skip_serializing_if = "Option::is_none")] pub state: Option>, pub root: WidgetNode, + /// API 4: the widget's property menu, shown while it's selected (empty: none). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub menu: Vec, +} + +/// What a property menu item is. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MenuKind { + /// A button. + #[default] + Action, + /// On or off (`checked`). + Toggle, + /// One of a few colours (`options`; `value` is the current one). + Color, +} + +/// One item of a widget's property menu; choosing it calls the plugin back with `item`. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct MenuItem { + pub item: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub label: String, + #[serde(rename = "type", default)] + pub kind: MenuKind, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub checked: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub options: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, } /// What one description may still use while it's checked. @@ -141,8 +183,74 @@ impl WidgetRender { return Err("widget: the root must be a frame".into()); } w.root.check(0, &mut Budget { nodes: 0, image_bytes: 0 }).map_err(|e| format!("widget: {e}"))?; + check_menu(&mut w.menu).map_err(|e| format!("widget: {e}"))?; Ok(w) } + + /// Whether it uses what plugin API 4 added (fields or a menu). + pub fn uses_api_4(&self) -> bool { + fn field(n: &WidgetNode) -> bool { + !n.field.is_empty() || n.children.iter().any(field) + } + !self.menu.is_empty() || field(&self.root) + } +} + +/// Check and normalise a property menu: at most [`MAX_MENU`] items with distinct names and short +/// labels (an item's name when it has none); a colour item has 1–[`MAX_SWATCHES`] colours, its +/// value one of them. +pub fn check_menu(menu: &mut [MenuItem]) -> Result<(), String> { + if menu.len() > MAX_MENU { + return Err(format!("a menu has more than {MAX_MENU} items")); + } + for i in 0..menu.len() { + let Some(m) = menu.get_mut(i) else { continue }; + check_id("menu item", &m.item, MAX_NAME)?; + m.label = clean("a menu label", &m.label, MAX_MENU_LABEL)?; + if m.label.is_empty() { + m.label.clone_from(&m.item); + } + let colours = m.kind == MenuKind::Color; + if m.checked && m.kind != MenuKind::Toggle { + return Err("`checked` is for toggles".into()); + } + if colours == m.options.is_empty() || m.options.len() > MAX_SWATCHES { + return Err(format!("a colour item needs 1–{MAX_SWATCHES} `options` (and only it has them)")); + } + for o in &mut m.options { + let mut c = Some(std::mem::take(o)); + colour("options", &mut c)?; + *o = c.unwrap_or_default(); + } + colour("value", &mut m.value)?; + if m.value.as_ref().is_some_and(|v| !m.options.contains(v)) { + return Err("a colour item's `value` must be one of its `options`".into()); + } + let item = m.item.clone(); + if menu.get(..i).is_some_and(|before| before.iter().any(|b| b.item == item)) { + return Err(format!("two menu items are called `{item}`")); + } + } + Ok(()) +} + +/// An editable field's new value, cleaned: line endings become `\n`, tabs spaces, and other control +/// characters are dropped; at most [`MAX_FIELD_CHARS`]. +pub fn clean_field(value: &str) -> Result { + let v: String = value + .replace("\r\n", "\n") + .chars() + .filter_map(|c| match c { + '\r' | '\n' => Some('\n'), + '\t' => Some(' '), + c if c.is_control() => None, + c => Some(c), + }) + .collect(); + if v.chars().count() > MAX_FIELD_CHARS { + return Err(format!("a field's value is longer than {MAX_FIELD_CHARS} characters")); + } + Ok(v) } /// A widget's state: a JSON object of at most [`MAX_STATE_BYTES`] with short keys. @@ -186,6 +294,9 @@ impl WidgetNode { if !self.target.is_empty() { check_id("target", &self.target, MAX_NAME)?; } + if !self.field.is_empty() { + check_id("field", &self.field, MAX_NAME)?; + } for (what, v) in [("x", self.x), ("y", self.y)] { within(what, v, -MAX_SIZE, MAX_SIZE)?; } @@ -210,6 +321,7 @@ impl WidgetNode { )?; only("text", !self.text.is_empty() || self.size.is_some() || self.color.is_some() || self.bold, kind == WidgetKind::Text)?; only("data", !self.data.is_empty(), kind == WidgetKind::Image)?; + only("field", !self.field.is_empty(), kind == WidgetKind::Text)?; if self.text.chars().count() > MAX_TEXT { return Err(format!("a text is longer than {MAX_TEXT} characters")); } diff --git a/crates/plugin/src/widget_tests.rs b/crates/plugin/src/widget_tests.rs index 35418866..1074503d 100644 --- a/crates/plugin/src/widget_tests.rs +++ b/crates/plugin/src/widget_tests.rs @@ -3,8 +3,8 @@ use serde_json::{Value, json}; -use crate::widget::{MAX_IMAGE_BYTES, MAX_NODES, MAX_STATE_BYTES}; -use crate::{Host, Instance, Limits, Manifest, WidgetKind, WidgetRender}; +use crate::widget::{MAX_FIELD_CHARS, MAX_IMAGE_BYTES, MAX_NODES, MAX_STATE_BYTES, clean_field}; +use crate::{Host, Instance, Limits, Manifest, MenuKind, WidgetKind, WidgetRender}; struct Quiet; @@ -114,3 +114,47 @@ fn widgets_need_api_3() { let v2 = Manifest::from_value(&json!({"id": "p", "name": "P", "main": "p.wasm", "api": 2})).unwrap(); assert!(!v2.widget && v2.commands.len() == 1, "v2 manifests are unchanged"); } + +#[test] +fn fields_and_menus_are_checked_and_normalised() { + let root = json!({"type": "frame", "children": [{"type": "text", "field": "label", "text": "Hi"}]}); + let w = + parse(json!({"root": root, "menu": [{"item": "reset"}, {"item": "c", "type": "color", "options": ["#ff0000"], "value": "ff0000"}]})).unwrap(); + assert!(w.uses_api_4()); + assert_eq!((w.menu[0].label.as_str(), w.menu[0].kind), ("reset", MenuKind::Action), "a label defaults to the item"); + assert_eq!((w.menu[1].options.clone(), w.menu[1].value.clone()), (vec!["#FF0000".to_string()], Some("#FF0000".to_string()))); + assert!(!parse(json!({"root": {"type": "frame"}})).unwrap().uses_api_4(), "API 3 descriptions are unchanged"); + let menu = |m: Value| json!({"root": {"type": "frame"}, "menu": m}); + let field = |kind: &str, name: &str| json!({"root": {"type": "frame", "children": [{"type": kind, "field": name}]}}); + let junk = [ + menu(json!(vec![json!({"item": "a"}); 9])), + menu(json!([{"item": "a"}, {"item": "a"}])), + menu(json!([{"item": "has space"}])), + menu(json!([{"item": ""}])), + menu(json!([{"item": "a", "label": "x".repeat(33)}])), + menu(json!([{"item": "a", "label": "line\nbreak"}])), + menu(json!([{"item": "a", "type": "slider"}])), + menu(json!([{"item": "a", "checked": true}])), + menu(json!([{"item": "a", "type": "color"}])), + menu(json!([{"item": "a", "type": "color", "options": vec!["#000000"; 9]}])), + menu(json!([{"item": "a", "type": "color", "options": ["red"]}])), + menu(json!([{"item": "a", "type": "color", "options": ["#000000"], "value": "#FFFFFF"}])), + menu(json!([{"item": "a", "options": ["#000000"]}])), + menu(json!({"item": "a"})), + field("rectangle", "label"), + field("frame", "label"), + field("text", "has space"), + field("text", &"x".repeat(65)), + ]; + for j in junk { + assert!(parse(j.clone()).is_err(), "accepted {}", j.to_string().chars().take(100).collect::()); + } +} + +#[test] +fn field_values_are_cleaned_and_bounded() { + assert_eq!(clean_field("a\r\nb\rc\td\u{7}\u{0}é").unwrap(), "a\nb\nc dé"); + assert_eq!(clean_field(&"é".repeat(MAX_FIELD_CHARS)).unwrap().chars().count(), MAX_FIELD_CHARS); + assert!(clean_field(&"x".repeat(MAX_FIELD_CHARS + 1)).is_err()); + assert_eq!(clean_field("").unwrap(), ""); +} diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 84a80858..5322ca58 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -18,3 +18,13 @@ png = { workspace = true } unicode-bidi = { workspace = true } unicode-segmentation = { workspace = true } log = { workspace = true } + +# Fonts that font managers (RightFont, FontBase, Typeface) activate without copying them into a font +# folder are known only to Core Text. Its safe wrappers list every available font's file (Core Text +# needs unsafe FFI, which the workspace forbids). 22.0 is the first release whose +# `get_descriptors` takes ownership of the array correctly (20.x and 21.x leak it on every scan); +# it brings core-foundation 0.10, core-graphics 0.25 and core-graphics-types 0.2 beside the older +# versions other crates use. Pinned exactly: review the diff before bumping. +[target.'cfg(target_os = "macos")'.dependencies] +core-text = { version = "=22.0.0", default-features = false, features = ["link"] } +objc2 = "0.6" diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 32552837..ced18c81 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -817,9 +817,7 @@ impl FontDb { } self.system_scanned = true; let mut found = BTreeMap::new(); - for dir in system_font_dirs() { - scan_dir(&dir, 0, &mut found); - } + scan_installed_fonts(&mut found); self.merge_scan(found); } @@ -924,29 +922,45 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap MAX_FONT_FILE { - continue; - } - let Some(bytes) = usize::try_from(meta.len()).ok().and_then(|l| index_bytes(&path, l)) else { continue }; - for (index, family, style, postscript) in faces_in(&bytes) { - out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { - path: path.clone(), - index, - style, - postscript, - }); + index_font_file(&path, &meta, out); + } +} + +fn index_font_file(path: &std::path::Path, meta: &std::fs::Metadata, out: &mut BTreeMap)>) { + // Font-like names can also belong to pipes or devices; opening a pipe can block. + // `meta` follows symlinks, so links to regular font files remain supported. + if !meta.is_file() { + return; + } + let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).unwrap_or_default(); + if !matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") && !(path.extension().is_none() && starts_like_a_font(path)) { + return; + } + // Very large files are indexed all the same (Apple Color Emoji is ~180 MB and is where the + // system's colour emoji live): `index_bytes` reads headers and name tables out of a zeroed + // buffer, so only those parts are ever touched. + if meta.len() > MAX_FONT_FILE { + return; + } + let Some(bytes) = usize::try_from(meta.len()).ok().and_then(|l| index_bytes(path, l)) else { return }; + for (index, family, style, postscript) in faces_in(&bytes) { + out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { + path: path.to_path_buf(), + index, + style, + postscript, + }); + } +} + +fn scan_installed_fonts(out: &mut BTreeMap)>) { + let dirs = system_font_dirs(); + for dir in &dirs { + scan_dir(dir, 0, out); + } + for path in files_outside_dirs(font_manager_files(), &dirs) { + if let Ok(meta) = std::fs::metadata(&path) { + index_font_file(&path, &meta, out); } } } @@ -959,6 +973,28 @@ fn starts_like_a_font(path: &std::path::Path) -> bool { std::fs::File::open(path).and_then(|mut f| f.read_exact(&mut magic)).is_ok() && matches!(&magic, &[0, 1, 0, 0] | b"OTTO" | b"true" | b"ttcf") } +fn files_outside_dirs(files: Vec, dirs: &[std::path::PathBuf]) -> std::collections::BTreeSet { + files.into_iter().filter(|file| file.is_absolute() && !dirs.iter().any(|dir| file.starts_with(dir))).collect() +} + +#[cfg(target_os = "macos")] +fn font_manager_files() -> Vec { + std::panic::catch_unwind(|| { + objc2::rc::autoreleasepool(|_| { + core_text::font_collection::create_for_all_families() + .get_descriptors() + .map(|descriptors| descriptors.iter().filter_map(|descriptor| descriptor.font_path()).collect()) + .unwrap_or_default() + }) + }) + .unwrap_or_default() +} + +#[cfg(not(target_os = "macos"))] +fn font_manager_files() -> Vec { + Vec::new() +} + /// The largest font file read (CJK collections run to ~120 MB, Apple Color Emoji ~180 MB). pub const MAX_FONT_FILE: u64 = 256 * 1024 * 1024; @@ -1082,9 +1118,7 @@ pub fn scan_system_fonts() { if !system_fonts_opted_out() { SCANS.fetch_add(1, Ordering::Relaxed); let mut found: BTreeMap)> = BTreeMap::new(); - for dir in system_font_dirs() { - scan_dir(&dir, 0, &mut found); - } + scan_installed_fonts(&mut found); let mut w = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner); w.system_scanned = true; w.merge_scan(found); @@ -1108,9 +1142,7 @@ pub fn rescan_system_fonts() -> Rescan { return Rescan { changed: added.clone(), added }; } let mut found: BTreeMap)> = BTreeMap::new(); - for dir in system_font_dirs() { - scan_dir(&dir, 0, &mut found); - } + scan_installed_fonts(&mut found); let rescan = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner).merge_rescan(found); if !rescan.changed.is_empty() { crate::clear_layout_cache(); diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index 72d5eafd..85d1f3a7 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -440,3 +440,54 @@ fn every_platform_has_a_thai_font_in_the_fallbacks() { assert!(at(thai).is_some_and(|i| i > latin), "{thai}"); } } + +// The paths are Unix absolute paths (font managers are a macOS thing): on Windows they aren't absolute. +#[cfg(unix)] +#[test] +fn font_manager_files_outside_the_font_folders_are_indexed_once() { + let folders = [std::path::PathBuf::from("/Library/Fonts"), std::path::PathBuf::from("/Users/a/Library/Fonts")]; + let library = std::path::PathBuf::from("/Users/a/Fonts.rightfontlibrary/fonts/I/Inter/InterVariable.ttf"); + let files = vec![ + std::path::PathBuf::from("/Library/Fonts/Arial.ttf"), + std::path::PathBuf::from("/Users/a/Library/Fonts/Sub/Brand.otf"), + library.clone(), + library.clone(), + std::path::PathBuf::from("relative/Font.ttf"), + std::path::PathBuf::from("/Library/FontsElsewhere/Mono.ttf"), + ]; + let outside: Vec<_> = files_outside_dirs(files, &folders).into_iter().collect(); + assert_eq!(outside, [std::path::PathBuf::from("/Library/FontsElsewhere/Mono.ttf"), library]); +} + +#[test] +fn a_font_file_outside_the_font_folders_is_indexed_from_its_path() { + struct TempDir(std::path::PathBuf); + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let installed = system_font_dirs() + .into_iter() + .find_map(|d| std::fs::read_dir(d).into_iter().flatten().flatten().map(|e| e.path()).find(|p| p.extension().is_some_and(|x| x == "ttf"))); + let Some(font) = installed else { return }; + let tmp = TempDir(std::env::temp_dir().join(format!("figcraft-font-manager-{}", std::process::id()))); + std::fs::create_dir_all(&tmp.0).unwrap(); + let copy = tmp.0.join("Activated.ttf"); + std::fs::copy(&font, ©).unwrap(); + let mut pending = BTreeMap::new(); + index_font_file(©, &std::fs::metadata(©).unwrap(), &mut pending); + let paths: Vec<_> = pending.values().flat_map(|(_, p)| p.iter().map(|p| p.path.clone())).collect(); + assert!(!paths.is_empty() && paths.iter().all(|p| *p == copy), "{paths:?}"); + let mut none = BTreeMap::new(); + index_font_file(&tmp.0, &std::fs::metadata(&tmp.0).unwrap(), &mut none); + assert!(none.is_empty()); +} + +#[cfg(target_os = "macos")] +#[test] +fn core_text_lists_the_installed_font_files() { + let files = font_manager_files(); + assert!(files.iter().any(|f| f.starts_with("/System/Library/Fonts")), "{} files", files.len()); + assert!(files.iter().all(|f| f.is_absolute())); +} diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 09ff2e44..cccea99b 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -1839,3 +1839,5 @@ status = "draft — needs native review" "tool.shape" = "আকৃতি" "tool.stamp" = "স্ট্যাম্প" "tool.sticky" = "স্টিকি নোট" +"widget.menu" = "উইজেটের মেনু" +"widget.setText" = "উইজেটের ঘর সম্পাদনা" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 50775f76..f3d04ec7 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -2367,6 +2367,8 @@ status = "draft — needs native review" "widget.click" = "Kliknout na widget" "widget.insert" = "Vložit widget" "widget.list" = "Vypsat widgety" +"widget.menu" = "Nabídka widgetu" +"widget.setText" = "Upravit pole widgetu" "window.focus" = "Přenést okno do popředí" "window.new" = "Nové okno" "template.list" = "Vypsat šablony" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index dff589cd..5a555d51 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -2317,6 +2317,8 @@ status = "draft — needs native review" "widget.click" = "Widget anklicken" "widget.insert" = "Widget einfügen" "widget.list" = "Widgets auflisten" +"widget.menu" = "Widget-Menü" +"widget.setText" = "Widget-Feld bearbeiten" "window.focus" = "Fenster in den Vordergrund holen" "window.new" = "Neues Fenster" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index ef34872e..b5e19581 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -2459,6 +2459,8 @@ status = "draft — needs native review" "widget.click" = "Pulsar widget" "widget.insert" = "Insertar widget" "widget.list" = "Listar widgets" +"widget.menu" = "Menú del widget" +"widget.setText" = "Editar campo del widget" "window.focus" = "Traer la ventana al frente" "window.new" = "Nueva ventana" "template.list" = "Listar plantillas" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 0bfcd574..95b9f965 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -2416,6 +2416,8 @@ status = "draft — needs native review" "widget.click" = "Napsauta pienoisohjelmaa" "widget.insert" = "Lisää pienoisohjelma" "widget.list" = "Luettele pienoisohjelmat" +"widget.menu" = "Pienoisohjelman valikko" +"widget.setText" = "Muokkaa pienoisohjelman kenttää" "window.focus" = "Tuo ikkuna etummaiseksi" "window.new" = "Uusi ikkuna" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index b7a00ea0..9a6b3c67 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -2416,6 +2416,8 @@ status = "draft — needs native review" "widget.click" = "Cliquer sur le widget" "widget.insert" = "Insérer un widget" "widget.list" = "Lister les widgets" +"widget.menu" = "Menu du widget" +"widget.setText" = "Modifier le champ du widget" "window.focus" = "Mettre la fenêtre au premier plan" "window.new" = "Nouvelle fenêtre" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 6878bee1..aee866b1 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -2371,6 +2371,8 @@ status = "draft — needs native review" "widget.click" = "Klik widget" "widget.insert" = "Sisipkan widget" "widget.list" = "Daftar widget" +"widget.menu" = "Menu widget" +"widget.setText" = "Edit kolom widget" "window.focus" = "Bawa jendela ke depan" "window.new" = "Jendela baru" "template.list" = "Daftar templat" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index e792ec72..30650bbc 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -2394,6 +2394,8 @@ status = "draft — needs native review" "widget.click" = "ウィジェットをクリック" "widget.insert" = "ウィジェットを挿入" "widget.list" = "ウィジェットを一覧表示" +"widget.menu" = "ウィジェットのメニュー" +"widget.setText" = "ウィジェットのフィールドを編集" "window.focus" = "ウィンドウを最前面に表示" "window.new" = "新しいウィンドウ" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 8b935314..8b86338a 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -2407,6 +2407,8 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "widget.click" = "ვიჯეტზე დაწკაპუნება" "widget.insert" = "ვიჯეტის ჩასმა" "widget.list" = "ვიჯეტების სია" +"widget.menu" = "ვიჯეტის მენიუ" +"widget.setText" = "ვიჯეტის ველის რედაქტირება" "window.focus" = "ფანჯრის წინა პლანზე გადმოტანა" "window.new" = "ახალი ფანჯარა" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 6f497729..5a976f97 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1719,6 +1719,121 @@ status = "draft — needs native review" "⌘ click" = "⌘ 클릭" "⌘ scroll" = "⌘ 스크롤" "⌥ drag" = "⌥ 드래그" +"A responsive product page: hero, features, figures, a quote and a footer" = "대표 영역, 기능, 수치, 인용문, 푸터로 구성된 반응형 제품 페이지" +"A title, an agenda, a big figure, a chart and a closing slide" = "제목, 목차, 주요 수치, 차트, 마무리 슬라이드" +"A to Z" = "이름 오름차순" +"Adding to {name}" = "{name}에 추가 중" +"All the fonts this file uses are installed" = "이 파일에 사용된 모든 글꼴이 설치되어 있습니다." +"Alphabetical" = "가나다순" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "수치, 벡터 차트, 표를 격자로 배치한 분석 화면" +"Apps" = "앱" +"Apr" = "4월" +"Aug" = "8월" +"Bottom padding" = "아래쪽 안쪽 여백" +"Bound to {name}. Click to detach the variable." = "{name}에 연결되어 있습니다. 클릭하면 변수 연결을 해제합니다." +"Checking the fonts installed on this computer…" = "이 컴퓨터에 설치된 글꼴 확인 중…" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "색상 및 텍스트 스타일, 밝게·어둡게 변수, 버튼, 입력란, 카드" +"Convert to frame" = "프레임으로 변환" +"Convert to section" = "섹션으로 변환" +"Couldn't download the variables" = "변수를 다운로드할 수 없습니다." +"Created" = "만든 날짜" +"Dashboard" = "대시보드" +"Date created" = "만든 날짜" +"Dec" = "12월" +"Design system starter" = "디자인 시스템 시작 키트" +"Draw a frame" = "프레임 그리기" +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = ".figcraft, .fig, .sketch, .json, .svg 또는 이미지 파일을 놓으세요." +"Drop to import" = "놓아서 가져오기" +"Drop to open" = "놓아서 열기" +"Edited {when}" = "{when} 편집" +"Export frames and layers as PNG, JPG, SVG or PDF" = "프레임과 레이어를 PNG, JPG, SVG 또는 PDF로 내보내기" +"Feb" = "2월" +"File not found" = "파일을 찾을 수 없습니다." +"Four screens of a trail app with components and a prototype flow" = "컴포넌트와 프로토타입 흐름이 포함된 트레일 앱 화면 4개" +"Getting started" = "시작하기" +"Greyscale components and two example screens to sketch a flow with" = "흐름을 구상할 수 있는 회색 음영 컴포넌트와 예시 화면 2개" +"Hide these tips (Getting started in the Help menu shows them again)" = "팁 숨기기(도움말 메뉴의 시작하기에서 다시 표시)" +"Hide when publishing" = "게시할 때 숨기기" +"Icon components on a 24 px grid with keylines and a size preview" = "키라인과 크기 미리 보기가 포함된 24px 격자의 아이콘 컴포넌트" +"Icon set starter" = "아이콘 세트 시작 키트" +"Individual padding" = "개별 안쪽 여백" +"Interface size" = "인터페이스 크기" +"Jan" = "1월" +"Jul" = "7월" +"Jun" = "6월" +"Kits" = "키트" +"Landing page" = "랜딩 페이지" +"Last modified" = "마지막 수정" +"Left padding" = "왼쪽 안쪽 여백" +"Link frames on the Prototype tab, then present the flow" = "프로토타입 탭에서 프레임을 연결한 다음 흐름을 실행해 보세요." +"Make a component" = "컴포넌트 만들기" +"Mar" = "3월" +"May" = "5월" +"Missing fonts" = "없는 글꼴" +"Mobile app" = "모바일 앱" +"Newest first" = "최신순" +"No files or templates match your search" = "검색 조건에 맞는 파일이나 템플릿이 없습니다." +"No other pages" = "다른 페이지 없음" +"No templates match your search" = "검색 조건에 맞는 템플릿이 없습니다." +"Nov" = "11월" +"Oct" = "10월" +"Oldest first" = "오래된순" +"One announcement in five common sizes: square, portrait, story, link and banner" = "하나의 공지를 정사각형, 세로형, 스토리, 링크, 배너의 일반적인 5가지 크기로 구성" +"Opening the file…" = "파일 여는 중…" +"Opening {n} files as new files" = { other = "파일 {n}개를 새 파일로 여는 중" } +"Order" = "순서" +"Other" = "기타" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "프레임 도구를 선택한 뒤 캔버스에서 드래그하거나 기기 크기를 고르세요." +"Plugin" = "플러그인" +"Plugins & widgets" = "플러그인 및 위젯" +"Presentation slides" = "프레젠테이션 슬라이드" +"Playground" = "실험 공간" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "시스템 디스플레이 배율을 기준으로 합니다(100%는 시스템 기본 크기). 문서 확대/축소는 별도입니다." +"Remove from recents" = "최근 목록에서 제거" +"Reorder selection" = "선택 항목 순서 변경" +"Right padding" = "오른쪽 안쪽 여백" +"Search templates" = "템플릿 검색" +"See all templates" = "모든 템플릿 보기" +"Select layers and they space themselves as their content changes" = "레이어를 선택하면 내용 변경에 맞춰 간격이 자동으로 조정됩니다." +"Search files and templates" = "파일 및 템플릿 검색" +"Search results" = "검색 결과" +"Search pages" = "페이지 검색" +"Sep" = "9월" +"Share with a code" = "코드로 공유" +"Show all templates" = "모든 템플릿 표시" +"Show in Explorer" = "탐색기에서 보기" +"Show in Finder" = "Finder에서 보기" +"Show in folder" = "폴더에서 보기" +"Social and slides" = "소셜 및 슬라이드" +"Social post sizes" = "소셜 게시물 크기" +"Sort by" = "정렬 기준" +"Sort by {column}" = "{column} 기준 정렬" +"Start from a template" = "템플릿으로 시작" +"Start sharing with the Share button, then pass on the code it gives" = "공유 버튼으로 공유를 시작한 뒤 표시되는 코드를 전달하세요." +"The destination document is unavailable. Nothing was imported." = "대상 문서를 사용할 수 없습니다. 아무것도 가져오지 않았습니다." +"The destination document was closed. Nothing was imported." = "대상 문서가 닫혔습니다. 아무것도 가져오지 않았습니다." +"The destination page was removed. Nothing was imported." = "대상 페이지가 삭제되었습니다. 아무것도 가져오지 않았습니다." +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "창이 너무 작아 {size}% 대신 {drawn}%로 표시합니다. {size}%를 사용하려면 창을 키우세요." +"This file is too large to join here (up to {mb} MB)" = "이 파일은 여기에 추가하기에 너무 큽니다(최대 {mb}MB)." +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "이 파일은 이 컴퓨터에 없는 글꼴을 사용합니다. 해당 글꼴을 설치할 때까지 대체 글꼴로 표시합니다." +"Top padding" = "위쪽 안쪽 여백" +"Try another search, or browse templates." = "다른 검색어를 입력하거나 템플릿을 둘러보세요." +"Try another word, or look at every kind." = "다른 단어를 검색하거나 모든 종류를 확인하세요." +"Turn a layer into a component, reuse it, and change every copy at once" = "레이어를 컴포넌트로 만들고 재사용하여 모든 사본을 한 번에 변경하세요." +"Use your browser's zoom to change the interface size." = "인터페이스 크기는 브라우저의 확대/축소로 변경하세요." +"Uses variables: each layer shows them in its own mode" = "변수 사용: 각 레이어의 모드에 따라 표시합니다." +"Websites" = "웹사이트" +"What you try here changes this preview and the code below, not the file" = "여기서 변경한 내용은 미리 보기와 아래 코드에만 적용되며 파일은 바뀌지 않습니다." +"Widget" = "위젯" +"Wireframe kit" = "와이어프레임 키트" +"Z to A" = "이름 내림차순" +"just now" = "방금" +"only in a text style or variable" = "텍스트 스타일 또는 변수에서만 사용" +"slot layers without a matching slot property were imported as frames" = "일치하는 슬롯 속성이 없는 슬롯 레이어를 프레임으로 가져왔습니다." +"yesterday" = "어제" +"{month} {day}, {year}" = "{year}년 {month} {day}일" +"{n} text layers" = { other = "텍스트 레이어 {n}개" } +"{percent}%" = "{percent}%" [commands] "align.bottom" = "아래쪽 정렬" @@ -2308,8 +2423,28 @@ status = "draft — needs native review" "widget.click" = "위젯 클릭" "widget.insert" = "위젯 삽입" "widget.list" = "위젯 목록" +"widget.menu" = "위젯 메뉴" +"widget.setText" = "위젯 필드 편집" "window.focus" = "창을 맨 앞으로 가져오기" "window.new" = "새 창" +"arrange.reorder" = "선택 항목 순서 변경" +"component.hideWhenPublishing" = "게시할 때 숨기기" +"object.convertToGroup" = "그룹으로 변환" +"object.convertToFrame" = "프레임으로 변환" +"object.convertToSection" = "섹션으로 변환" +"text.rescanFonts" = "설치된 글꼴 다시 검색" +"text.loadFont" = "글꼴 파일 불러오기" +"view.outlinesHiddenLayers" = "윤곽선에 숨겨진 레이어 포함" +"view.outlinesObjectBounds" = "윤곽선에 개체 경계 포함" +"view.panelWidths" = "패널 너비 초기화" +"pref.uiScale" = "인터페이스 크기" +"help.gettingStarted" = "시작하기" +"style.bind" = "스타일에 변수 연결" +"component.playground" = "컴포넌트 실험실" +"text.missingFonts" = "누락된 글꼴" +"text.refit" = "글꼴에 맞게 텍스트 조정" +"template.list" = "템플릿 목록" +"file.newFromTemplate" = "템플릿에서 새로 만들기" [menu] "bool.exclude" = "제외" @@ -2341,3 +2476,5 @@ status = "draft — needs native review" "view.themeDark" = "다크" "view.themeLight" = "라이트" "view.themeSystem" = "시스템 따르기" +"view.outlinesHiddenLayers" = "숨겨진 레이어 포함" +"view.outlinesObjectBounds" = "개체 경계 포함" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 7ed56cab..6cac7aa9 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -2411,6 +2411,8 @@ status = "draft — needs native review" "widget.click" = "Op widget klikken" "widget.insert" = "Widget invoegen" "widget.list" = "Widgets weergeven" +"widget.menu" = "Widgetmenu" +"widget.setText" = "Widgetveld bewerken" "window.focus" = "Venster naar voren brengen" "window.new" = "Nieuw venster" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index d013bbe2..a435726c 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -2429,6 +2429,8 @@ name = "Polski" "widget.click" = "Kliknij widżet" "widget.insert" = "Wstaw widżet" "widget.list" = "Lista widżetów" +"widget.menu" = "Menu widżetu" +"widget.setText" = "Edytuj pole widżetu" "window.focus" = "Przesuń okno na wierzch" "window.new" = "Nowe okno" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 550c6f2e..ff73e5bc 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -2422,6 +2422,8 @@ status = "draft — needs native review" "widget.click" = "Clicar no widget" "widget.insert" = "Inserir widget" "widget.list" = "Listar widgets" +"widget.menu" = "Menu do widget" +"widget.setText" = "Editar campo do widget" "window.focus" = "Trazer a janela para a frente" "window.new" = "Nova janela" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index e141b9c8..7d1d1000 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -2430,6 +2430,8 @@ name = "Русский" "widget.click" = "Нажать виджет" "widget.insert" = "Вставить виджет" "widget.list" = "Список виджетов" +"widget.menu" = "Меню виджета" +"widget.setText" = "Изменить поле виджета" "window.focus" = "Вывести окно на передний план" "window.new" = "Новое окно" "template.list" = "Список шаблонов" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 1af7c5e4..0dfbb2fa 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -2432,6 +2432,8 @@ name = "Svenska" "widget.click" = "Klicka på widget" "widget.insert" = "Infoga widget" "widget.list" = "Lista widgetar" +"widget.menu" = "Widgetmeny" +"widget.setText" = "Redigera widgetfält" "window.focus" = "Placera fönstret längst fram" "window.new" = "Nytt fönster" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 958dcb59..58166c93 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -2371,6 +2371,8 @@ name = "Türkçe" "widget.click" = "Widget'a tıkla" "widget.insert" = "Widget ekle" "widget.list" = "Widget'ları listele" +"widget.menu" = "Widget menüsü" +"widget.setText" = "Widget alanını düzenle" "window.focus" = "Pencereyi öne getir" "window.new" = "Yeni pencere" "template.list" = "Şablonları listele" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 4fe87062..a3c5c92e 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -2322,6 +2322,8 @@ status = "draft — needs native review" "widget.click" = "点击小组件" "widget.insert" = "插入小组件" "widget.list" = "列出小组件" +"widget.menu" = "小组件菜单" +"widget.setText" = "编辑小组件字段" "window.focus" = "将窗口置于前台" "window.new" = "新建窗口" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ca82afa8..d81571c1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -275,6 +275,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { crate::board_bar::stamp_author(app, &painter, ctx.input(|i| i.pointer.hover_pos()).filter(|p| rect.contains(*p))); crate::board_bar::show(app, &ctx); crate::board_facilitate::show(app, &ctx, rect); + crate::widget_bar::show(app, &ctx); crate::selection_spacing::draw(app, &painter); crate::comments::draw(app, &painter); crate::live_text::draw(app, &painter); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bbc39628..c11bf985 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -70,6 +70,7 @@ pub mod variable_ui; pub mod variables_panel; pub mod vector_panel; pub mod versions; +pub mod widget_bar; pub mod widgets; pub mod windows; diff --git a/crates/ui-egui/src/widget_bar.rs b/crates/ui-egui/src/widget_bar.rs new file mode 100644 index 00000000..c32002d6 --- /dev/null +++ b/crates/ui-egui/src/widget_bar.rs @@ -0,0 +1,113 @@ +//! The small bar floating over a selected plugin widget that has a property menu: its actions and +//! toggles as buttons, its colour choices as swatches. Each calls `widget.menu`; the labels are the +//! plugin's own. + +use egui::{CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use figcraft_engine::widgets::{MenuItem, MenuKind, menu_of}; +use serde_json::json; + +use crate::FigApp; +use crate::a11y; +use crate::board_bar::{Mark, divider, paint_choice, place}; +use crate::theme::{Tokens, semibold}; + +const HEIGHT: f32 = 40.0; +const CELL: f32 = 28.0; +const SWATCH: f32 = 20.0; +const GAP: f32 = 4.0; +/// A text button's padding on each side. +const PAD: f32 = 8.0; + +/// What one item takes across the bar, its trailing gap included (text buttons by their label). +fn width(ctx: &egui::Context, m: &MenuItem) -> f32 { + match m.kind { + MenuKind::Color => m.options.len() as f32 * (SWATCH + GAP), + _ => ctx.fonts_mut(|f| f.layout_no_wrap(m.label.clone(), semibold(11.0), egui::Color32::WHITE).size().x) + PAD * 2.0 + GAP, + } +} + +/// The bar above the selected widget, while it is the only layer selected, nothing is being dragged +/// or edited and the file can be edited. +pub fn show(app: &mut FigApp, ctx: &egui::Context) { + if app.session.tool.busy() || app.session.active_read_only().is_some() { + return; + } + let Ok(ds) = app.session.ds() else { return }; + let ([w], None) = (ds.selection.as_slice(), &ds.text_edit) else { return }; + let w = *w; + let menu = menu_of(&ds.doc, w); + let Some(b) = ds.doc.world_bounds(w).filter(|_| !menu.is_empty()) else { return }; + let sel = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)); + // Scrolled out of view: no bar (it would float over the rest of the window). + if !sel.intersects(app.canvas.rect) { + return; + } + let widths: Vec = menu.iter().map(|m| width(ctx, m)).collect(); + // The items, a divider (8) between each two, and the padding. + let size = vec2(8.0 + widths.iter().sum::() + 8.0 * (menu.len() - 1) as f32 + 4.0, HEIGHT); + let at = place(sel, size, app.canvas.rect); + let t = Tokens::get(ctx); + egui::Area::new(egui::Id::unique("widget_bar")).fixed_pos(at).order(egui::Order::Middle).show(ctx, |ui| { + let (rect, _) = ui.allocate_exact_size(size, Sense::click()); + a11y::container_bounds(ui, rect); + let p = ui.painter(); + p.rect_filled(rect.expand(2.0).translate(vec2(0.0, 1.0)), CornerRadius::same(12), egui::Color32::from_black_alpha(18)); + p.rect_filled(rect, CornerRadius::same(10), t.panel); + p.rect_stroke(rect, CornerRadius::same(10), Stroke::new(1.0, t.border), StrokeKind::Inside); + let cy = rect.center().y; + let mut x = rect.min.x + 8.0; + for (i, (m, wd)) in menu.iter().zip(&widths).enumerate() { + if i > 0 { + x = divider(ui, &t, x, cy); + } + if let Some(value) = item(ui, &t, m, x, cy, *wd) { + app.run("widget.menu", json!({"id": w, "item": m.item, "value": value})); + } + x += wd; + } + }); +} + +/// Draw item `m` from `x`, `wd` wide; the value to send when it's used. +fn item(ui: &mut Ui, t: &Tokens, m: &MenuItem, x: f32, cy: f32, wd: f32) -> Option { + if m.kind == MenuKind::Color { + let mut picked = None; + for (i, hex) in m.options.iter().enumerate() { + let r = Rect::from_center_size(pos2(x + i as f32 * (SWATCH + GAP) + SWATCH / 2.0, cy), vec2(SWATCH, SWATCH)); + let tip = format!("{} {hex}", m.label); + let resp = ui.interact(r, ui.scope_id().with(("wb_color", &m.item, i)), Sense::click()).on_hover_text(&tip); + let on = m.value.as_ref() == Some(hex); + a11y::toggle(&resp, &tip, on); + if on { + ui.painter().rect_stroke(r.expand(2.5), CornerRadius::same(6), Stroke::new(1.5, t.text), StrokeKind::Inside); + } else if resp.hovered() { + ui.painter().rect_filled(r.expand(2.0), CornerRadius::same(6), t.hover); + } + if let Some(c) = figcraft_color::Color::from_hex(hex) { + paint_choice(ui.painter(), r, &Mark::Color(c), t.icon); + } + if resp.clicked() && !on { + picked = Some(json!(hex)); + } + } + return picked; + } + let r = Rect::from_min_size(pos2(x, cy - CELL / 2.0), vec2(wd - GAP, CELL)); + let resp = ui.interact(r, ui.scope_id().with(("wb_item", &m.item)), Sense::click()); + let on = m.kind == MenuKind::Toggle && m.checked; + if m.kind == MenuKind::Toggle { + a11y::toggle(&resp, &m.label, on); + } else { + a11y::button(&resp, &m.label); + } + let bg = if on { + t.accent + } else if resp.hovered() { + t.hover + } else { + egui::Color32::TRANSPARENT + }; + ui.painter().rect_filled(r, CornerRadius::same(5), bg); + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, &m.label, semibold(11.0), if on { t.accent_on } else { t.text }); + resp.clicked().then(|| if m.kind == MenuKind::Toggle { json!(!m.checked) } else { serde_json::Value::Null }) +} diff --git a/docs/cli.md b/docs/cli.md index 1e1e1967..68e37823 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -25,6 +25,12 @@ On Linux and other Unix systems except macOS, user fonts are read from `$XDG_DATA_HOME/fonts`, or `$HOME/.local/share/fonts` when `XDG_DATA_HOME` is unset, empty or relative. The legacy `$HOME/.fonts` directory is also scanned. +On macOS the font folders (`/System/Library/Fonts`, `/Library/Fonts`, `~/Library/Fonts` and the +system's downloadable fonts) are read, and so is every other font file Core Text lists as +available: fonts a font manager such as RightFont, FontBase or Typeface activates from its own +library, without copying them into a font folder. Fonts activated while the app runs are found by +`text.rescanFonts`, like newly installed ones. + On macOS, fonts activated through Adobe Fonts are read where Creative Cloud keeps them (`~/Library/Application Support/Adobe/CoreSync/plugins/livetype`), so they're available while they're active there. Files without an extension are read when they start like a font file. diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 061fd728..641a7040 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -99,6 +99,15 @@ fit FigCraft's architecture: collide with what a connected client makes, because a client's numbers continue above every id of its space already in the document. Removed entries are part of the saved session state (`SessionState::removed`). +- **Branches** of a file in a session stay with the person who made them; nothing about them goes + over the wire until a merge, which is an ordinary batch from that person (no message or format + change). So that the server takes the layers a branch made, a branch made in the session makes + its ids in its author's own client space, 2^24 above every id made there on the main file and the + author's other branches. Layers of the branch's base that main deleted during the session come + back with their ids (the server accepts re-creating deleted ids). Merging a branch whose new + layers, styles, variables or comments lie in another id space (one made offline or in an earlier + session) is refused by the app before anything is sent, since the server would refuse it (see + `crates/engine/src/branches.rs`). - Objects inside an entry are not separate objects: modes are part of their collection, a variable's values per mode part of the variable and replies part of their comment thread. Two people adding a mode to the same collection, editing different modes of one variable, or replying diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index ae503633..c9af478f 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -18,7 +18,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen; the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | | Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | | Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — | -| Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived); fills, strokes and effects merge item by item. Left: branches in live sessions; other lists (text runs, export settings) conflict as a whole | 1–2 | +| Branching and merging | yes (paid plans) | **done, also in live sessions**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Dev Mode resources merge item by item. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 | | Team / organization libraries | yes | **partial**: a file's library is published to `figcraft-server` under the file and enabled in other files by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names ([components-parity](components-parity.md), [server.md](server.md#team-libraries)). No team-wide library list, accounts or analytics over time | 4–8 | | Notifications, activity | yes | no | with sharing | @@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Branches in live sessions (gap 17): creating, editing, reviewing, updating from main and merging a branch work while its file is in a session. The branch stays with its author and makes its ids in their own client space 2^24 above the main file's, so the server takes the layers it adds; merging is one synced edit and one step of the author's undo history; others' edits on main merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers, styles, variables or comments is refused with a reason. A branch's history gets entries for its creation and updates from main. No wire format change. Row wording also brought up to date with #798 (export settings and guides merge item by item). Numbers unchanged | | 2026-10-17 | minor | From community batch #735: server message limits follow `max_file_mb`, so files above 64 MiB publish and open, the client takes at most 1 GiB and the server warns about large limits with open publishing (#705); a damaged generation field no longer drops a log's flushed edits (#687); the web build joins a session from a `#join=` link in its URL (#712, unit-tested and built for wasm, not run in a browser). Percentages and hours unchanged | | 2026-10-14 | minor | Removing someone from a live session (M9, from the owner's use: whoever joined with the invite couldn't be put out short of ending the session for everyone): right-click a person's avatar ▸ "Remove from session", or `collab.removePerson {client}`, when you host the session or own the file on a server (owner token). The hub closes their connection as the session's end for them (they keep their copy and don't reconnect) and bars their address from that file while the session or server runs, so the code or link they hold no longer gets them in; everyone else stays. Protocol addition within version 2 (`Admin removePeer`; older servers say they can't). Addresses are the only identity without accounts: people behind one address are barred together. Percentages and hours unchanged | | 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. | diff --git a/docs/gaps.md b/docs/gaps.md index ba4e0294..8fce3103 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; 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; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; 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; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; 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 @@ -19,7 +19,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) | | 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) | | 5 | Localization: English, reviewed Russian and Turkish, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs | Localization | 29–51 | [localization-parity](localization-parity.md) | -| 6 | Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 8–26 | [target-app-parity](target-app-parity.md) | +| 6 | Plugins: widgets without hover or timers, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 6–22 | [target-app-parity](target-app-parity.md) | | 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) | | 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | | 9 | Figma Draw: pen pressure on macOS/Linux tablets (multi-edit, brushes, pressure, text on a path and pattern fills landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) | @@ -30,7 +30,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 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; 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: publishing mappings from the code repository; the editor extension is left to the MCP server (measurements kept on the canvas and a read-only layers panel landed 2026-10-24) | Feature | 1–2 | [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) | +| 17 | Branching: text runs merged whole, branches only their author sees in a live session (branches in live sessions, export settings and guides item by item, and history entries for creation and updates landed 2026-10-24) | 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) | | 19 | AI features | AI | 30–60 | [target-app-parity](target-app-parity.md) | | 20 | Mobile apps (view, mirror, comment) | Platforms | 20–40 | [hardware-parity](hardware-parity.md) | @@ -207,7 +207,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe [`localization-parity.md`](localization-parity.md); how to add one in [`translating.md`](translating.md). -### 6. Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run +### 6. Plugins: widgets without hover or timers, a self-hosted directory but no public catalogue; Figma's plugins don't run - **Landed (#101):** sandboxed WebAssembly plugins (wasmi, metered, memory cap, no WASI, also in the browser) acting through the command registry, one undo step per run, manifest permissions, Plugins menu, Run last plugin, Manage plugins, `plugin.*` commands, a Rust SDK and two examples @@ -224,15 +224,20 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe key and checked in full (`figcraft-cli plugin publish`), browsed, installed after a permissions confirmation and updated from Plugins ▸ Manage plugins ▸ Browse, downloads checked against their SHA-256, `plugin.directory.*` for scripts and MCP, moderation with `figcraft-server plugins`. + Widget fields and property menus (plugin API 4, 2026-10-24): a widget's text layer can be an + editable field, edited on the canvas with a double-click like any text or with `widget.setText` + (the value, cleaned and capped, goes into the widget's state and the plugin redraws; one undo + step), and a widget can carry a property menu of actions, toggles and colour choices, shown in a + small bar while it's selected and driven with `widget.menu`; the Counter example uses both. - **Missing:** a public catalogue and Community files, publisher accounts and signatures, ratings - and install counts, background update checks, the directory in the browser build; widgets take only clicks (no - text input, property menus, hover or timers) and draw only frames, text, shapes and images; + and install counts, background update checks, the directory in the browser build; widgets have no hover or + timers, can't open a panel and draw only frames, text, shapes and images; no images or sliders in panels. Figma's own plugins are JavaScript against Figma's plugin API and don't run. - **Impact:** teams that depend on specific Figma plugins (icons, content, accessibility checks) can't bring them. FigCraft's CLI, control channel and MCP server cover agent automation better than Figma does. -- **Estimate:** 8–26 h for the missing pieces of FigCraft's own API. Running Figma's plugins +- **Estimate:** 6–22 h for the missing pieces of FigCraft's own API. Running Figma's plugins needs a JS engine and an API shim: **owner decision needed**. ### 7. CPU canvas where Figma uses the GPU @@ -428,9 +433,26 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe different items changed on each side, or items added at the end on one side, merge without a conflict; only the same item changed both ways, an item removed on one side, or different items added on both sides conflict (as the whole list). -- **Missing:** branches of a file in a live session are refused; other lists (text runs, export - settings, guides…) still conflict as a whole; the branch history has no entries of its own for - its creation or updates from main (only saved versions, autosaves and the merge on main). +- **Landed (2026-10-18, #798):** layout grids, export settings, guides, prototype interactions, + accessibility annotations and Dev Mode resources merge item by item like fills (code links merge + whole: one per language). +- **Landed (2026-10-24):** branches of a file in a live session: creating, editing, reviewing, + updating from main and merging work while the file is in a session. A branch stays with the + person who made it; merging it is one edit of the session document that everyone gets, and one + step of its author's own undo history. A branch made in the session makes its ids in its author's + own client space, 2^24 above every id made there on the main file, so the session server takes + the layers it adds; others' edits on main while it is open are main's changes against the + branch's own base, and conflicts with them resolve as offline (a layer a peer deleted comes back + with its id when the branch's edits win). A view link still refuses merging, and merging a branch + made outside the session that adds layers, styles, variables or comments (ids in another space) + is refused with a reason instead of being sent for the server to refuse. A branch's history gets + entries for its creation and each update from main. Engine tests through a session server with + several people, a fuzz test with random real commands (export settings and guides included, + offline and live) and a junk-parameter sweep of the branch commands in a session. +- **Missing:** text runs still conflict as a whole (runs are character ranges into the text, so + lining them up item by item would need a text diff, not positions); branches in a live session + are their author's only (others can't see or open them: that needs the server to keep them); + a branch made offline that adds layers merges only outside a session. - **Estimate:** 1–2 h. ### 18. Display P3 on screen (several windows landed) @@ -501,6 +523,8 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 2026-10-24 | minor | Gap 16: Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Left: publishing mappings with a CLI; 2–4 h → 1–2 h | | 2026-10-24 | minor | Gap 24: a shared board timer and voting sessions landed (M12); 30–60 h → 26–54 h, ranking unchanged | | 2026-10-24 | minor | Gap 24: board templates (retrospective, brainstorm, weekly planner, flow chart, kanban board; `board.template`, a toolbar picker) and tables (`board.table`, `board.tableEdit`); 30–60 h → 26–54 h, rank unchanged | +| 2026-10-24 | minor | Gap 6: plugin widgets take text input (editable fields, `widget.setText`) and a property menu (`widget.menu`, a bar over the selected widget), plugin API 4; 8–26 h → 6–22 h; ranking unchanged | +| 2026-10-24 | minor | Gap 17: branches of a file in a live session (create, edit, review, update from main and merge while the file is in a session; the branch stays with its author and makes ids in their own client space far above the main file's; merging is one synced edit and one step of the author's undo history; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers is refused with a reason); a branch's history gets entries for its creation and updates from main; the list-merge wording brought up to date with #798 (export settings and guides already merged item by item); text runs still merge whole. Ranking and hours unchanged | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. New gap 24 (the board tail, 30–60 h); the ranking above it is unchanged | | 2026-10-15 | minor | Gap 5: Turkish reviewed by a native speaker (#263), Czech (#469) and Indonesian (#477) drafts, the Japanese draft caught up (#288); Indonesian is no longer missing; counts re-measured (2,177 entries) and corrected: only Spanish is held to completeness, Russian included. Estimate unchanged at this rounding | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 7f8955a5..b930b881 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1168,7 +1168,8 @@ branches). Driven with `run_command`: - `branch.create {name?}` copies the file into a new branch tab and returns `{id, name, index}`. The branch remembers the file as it is now (its base) and makes new ids in its own id space, so - layers, styles and comments made on both sides never clash. + layers, styles and comments made on both sides never clash. Its history starts with a `Branch + created` version. - `branch.list` lists the branches of the file shown (or of the file a branch tab belongs to): `[{id, name, time, state: active|merged|archived, index?}]` (`index`: its open tab). - `branch.open {id}` opens a branch in a tab (or switches to it). Edit it like any document. @@ -1190,15 +1191,26 @@ branches). Driven with `run_command`: conflict key to `"branch"` or `"main"`; the merge is refused while a conflict has none, so neither side's work is dropped without a choice. - `branch.updateFromMain {id?, resolutions?}` brings the main file's changes since the base into the - branch the same way (the new base is the main file as it is now). It is not undoable: the branch - tab's undo history starts over. + branch the same way (the new base is the main file as it is now) and adds an `Updated from main` + version to the branch's history. It is not undoable: the branch tab's undo history starts over. - `branch.archive {id?}` sets a branch aside without merging (it stays in the file and can still be opened, but not merged). Each branch keeps its own version history: on a branch tab, `file.saveVersion`, `version.autosave` and the `version.*` commands (list, preview, restore, rename, delete, duplicate) act on the branch's versions, which are saved in the file with the branch and stay with it when it is merged -or archived. Branches aren't available in a live session. +or archived. + +In a live session branches work the same way and stay with the person who made them (others +don't see them). A branch made in the session makes its ids in your own client space, far above +the ids the main file makes, so the session server takes them. Everyone's edits on main while the +branch is open are main's changes in the review (against the branch's own base), and conflicts +with them are resolved as offline. `branch.merge` is one edit of the session document: everyone +gets it, and it is one step of your own undo history. It is refused on a view link, and for a +branch made outside the session (or in an earlier one) whose new layers, styles, variables or +comments lie in another id space, which the server would refuse; merge those once the file has +left the session. While a session is still starting, branch commands that need your client id +wait for it. ### Collaboration changes @@ -1429,17 +1441,24 @@ Plugin settings are kept per plugin with the preferences (in a headless session, {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.event","params":{"id":"org.figcraft.examples.swatch-grid","control":"generate"}}}} ``` -Widgets (API 3, layers on the canvas a plugin draws) are driven the same way: `widget.insert +Widgets (API 3 and 4, layers on the canvas a plugin draws) are driven the same way: `widget.insert {plugin, x?, y?}` puts one on the page (centred in the view without a position) and returns `{id, plugin, state, targets, output, logs, calls, fuel}`; `widget.click {id, target}` clicks one of its `targets` as a click on the canvas would (the plugin redraws it; one undo step; returns the same); -`widget.list` lists every widget in the file with its plugin, page, state and targets. A widget's -state is layer data, so it is saved, undone and synced with the file. +`widget.list` lists every widget in the file with its plugin, page, state and targets. With plugin +API 4 a widget can have editable text fields and a property menu (both listed with it, as `fields` +and `menu`): `widget.setText {id, field, value}` sets a field as typing in it would (the value, +at most 1,000 characters, goes into the state under the field's name and the plugin redraws; one +undo step), and `widget.menu {id, item, value?}` uses a menu item (a toggle's `value` is `true` or +`false`, by default the opposite of now; a colour's is one of its options). A widget's state is +layer data, so it is saved, undone and synced with the file. ```json {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.insert","params":{"plugin":"org.figcraft.examples.counter"}}}} {"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.click","params":{"id":12,"target":"increment"}}}} {"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.list","params":{}}}} +{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.setText","params":{"id":12,"field":"label","value":"Votes"}}}} +{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.menu","params":{"id":12,"item":"big"}}}} ``` Plugins from a `figcraft-server`'s plugin directory ([`plugins.md`](plugins.md#plugin-directories)): diff --git a/docs/plugins.md b/docs/plugins.md index 73c93cf6..2d4001bc 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -18,7 +18,8 @@ permissions its manifest asks for, and nothing more. plugin can also keep settings from one run to the next. - **Widgets (API 3).** A plugin can put a layer of its own on the canvas: it describes the layer (frames with auto layout, text, shapes, images), FigCraft builds it, and a click on one of its - parts calls the plugin back to redraw it from its state, which is kept in the document. + parts calls the plugin back to redraw it from its state, which is kept in the document. Since + API 4 a widget can also have editable text fields and a small property menu. - **Permissions.** The manifest says whether the plugin only reads the document or may edit it, and can narrow the commands it may call. FigCraft enforces both. - **Distribution.** A team's own `figcraft-server` can keep a plugin directory: plugins are @@ -91,6 +92,17 @@ 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. +**Fields and menus (API 4).** A text layer of a widget can be an editable field: double-click it +with the Move tool and it is edited like any text (all of it selected first). When editing ends +(Escape, or a click elsewhere), the typing is put back and the new value goes to the widget +instead: it is cleaned (line breaks kept, tabs become spaces, other control characters dropped), +refused when longer than 1,000 characters, written into the widget's state under the field's +name and into the field's text, and the plugin is called back to redraw, all as one undo step +(none when the text didn't change). The value lives in the state, so it syncs and saves like the +rest of it. A widget can also carry a property menu: while it is the only layer selected, a +small bar over it shows its actions and toggles as buttons and its colour choices as swatches; +using one calls the plugin back, one undo step. The labels are the plugin's own text. + 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, @@ -115,6 +127,8 @@ From scripts, the CLI and MCP the same commands apply, panels included: {"command": "widget.insert", "params": {"plugin": "org.figcraft.examples.counter", "x": 0, "y": 0}} {"command": "widget.list", "params": {}} {"command": "widget.click", "params": {"id": 12, "target": "increment"}} +{"command": "widget.setText", "params": {"id": 12, "field": "label", "value": "Votes"}} +{"command": "widget.menu", "params": {"id": 12, "item": "color", "value": "#14AE5C"}} ``` `figcraft-cli run script.jsonl --in design.figcraft --out tidy.figcraft` runs that headless; over @@ -132,9 +146,11 @@ plugins: import them in the script. | `plugin.close` | `{id}` | Closes plugin `id`'s panel and ends its session. `{closed}` says whether one was open. | | `plugins.runLast` | `{}` | Runs the last plugin command again. | | `plugins.manage` | `{}` | Opens the Plugins window; returns the list like `plugin.list`. | -| `widget.insert` | `{plugin, x?, y?, fuel?}` | Puts widget plugin `plugin`'s widget on the current page, its top-left at `x`, `y` (default: centred in the view), and selects it; the plugin draws it. One undo step. Returns `{id, plugin, state, targets, output, logs, calls, fuel}`: `targets` are the names of its clickable parts. | +| `widget.insert` | `{plugin, x?, y?, fuel?}` | Puts widget plugin `plugin`'s widget on the current page, its top-left at `x`, `y` (default: centred in the view), and selects it; the plugin draws it. One undo step. Returns `{id, plugin, state, targets, fields, menu, output, logs, calls, fuel}`: `targets` are the names of its clickable parts, `fields` of its editable fields, `menu` its property menu items. | | `widget.click` | `{id, target, fuel?}` | Clicks part `target` of widget `id` (or of the widget a layer `id` is in), as a click on the canvas would: the plugin is called back and redraws it. One undo step; returns like `widget.insert`. | -| `widget.list` | `{}` | `{widgets: [{id, plugin, name, page, state, targets}]}`: every widget in the file. | +| `widget.setText` | `{id, field, value, fuel?}` | Sets editable field `field` of widget `id` (or of the widget a layer `id` is in) to the text `value`, as editing it on the canvas would: the value (at most 1,000 characters, cleaned) goes into the state under the field's name and into the field's text, and the plugin is called back. One undo step; returns like `widget.insert`. | +| `widget.menu` | `{id, item, value?, fuel?}` | Uses item `item` of widget `id`'s property menu, as its bar would: an action takes no `value`; a toggle takes `true` or `false` (default: the opposite of now); a colour takes one of its options (hex, any case). The plugin is called back. One undo step; returns like `widget.insert`. | +| `widget.list` | `{}` | `{widgets: [{id, plugin, name, page, state, targets, fields, menu}]}`: every widget in the file. | | `plugin.directory.list` | `{server?, query?}` | The plugin directory at `server` (`ws://host:port/`; default: the one browsed last): `{server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}`, the latest version of each plugin whose id, name, description or author contains `query`. `installed` is the installed version (or null), `update` whether the listed one is newer. | | `plugin.directory.install` | `{id, version?, confirm, server?, sha256?}` | Downloads version `version` (default: the latest) and installs it, or updates the installed one. `confirm` must be `true` (installing runs the plugin's code: show its permissions first); without it nothing is downloaded. The download must match the SHA-256 the directory lists (and `sha256`, the one shown, when given). Returns `{installed, name, version, permissions, sha256, path, source, replaced}`. | | `plugin.directory.publish` | `{path \| plugin, server}` | Publishes a plugin folder (or its `figcraft-plugin.json`), or an installed plugin, as a new version. `server` carries the server's key: `ws://host:port/?key=…`. Returns `{published, server}`. | @@ -224,7 +240,7 @@ plugin directory requests (`plugin.directory.*` says so). Import plugins with `p | `version`, `description` | Optional text. A plugin published to a [directory](#plugin-directories) needs a `version` of one to four numbers (`1.2.0`), each version newer than the last. | | `author` | Optional: who made it, as you type it (64 characters at most); plugin directories show it. | | `main` | Required. The module, a `.wasm` path relative to the manifest, inside its folder. | -| `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings), `3` (widgets) or `4` (saving files). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels, widgets or `saveFile` fails clearly on an older FigCraft. | +| `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings), `3` (widgets) or `4` (saving files, widget fields and property menus). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels, widgets or `saveFile` fails clearly on an older FigCraft. | | `permissions.saveFile` | API 4: `true` lets the plugin offer a file to save (an exporter's output). FigCraft asks the user where it goes with its own Save dialog (a download in the browser); the plugin never gets a path. Manage plugins lists it ("can save files"). A plugin directory's entries don't carry it (their wire format stays as it is), so installing any API 4 plugin from one says it may offer files to save. | | `widget` | API 3: `true` when the plugin draws a widget (it's listed in Plugins ▸ Widgets). | | `permissions.access` | `read` (the default): commands that leave the document as it is; such plugins also run from Dev Mode's Inspect tab. `write`: edits too. | @@ -263,7 +279,9 @@ WebAssembly in a debug build, far less in a release build), 64 MB of memory, 100 16 MB. A panel spec is at most 64 KB with 64 controls; settings are at most 64 KB per plugin (keys and JSON values), keys 1–128 characters. A widget callback gets the same limits; its description is at most 4 MB with 256 layers nested 16 deep, its state at most 16 KB (keys 1–128 characters), and -its images 1 MB each, 2 MB together and 4096 pixels on a side. +its images 1 MB each, 2 MB together and 4096 pixels on a side. A property menu has at most 8 +items (labels up to 32 characters), a colour item 1–8 colours; a field's value is at most 1,000 +characters. Copying a byte across the guest-memory boundary costs one additional fuel, reserved before the allocation or copy. This also applies when the configured per-call overhead is zero. A reply @@ -272,9 +290,11 @@ every ABI import, including calls which only query the reply length. ## ABI (version 4) -Version 2 adds panels and settings to version 1, version 3 adds widgets and version 4 adds -`save_file`; a version 1, 2 or 3 -module runs unchanged and gets the input it always got. Strings are UTF-8 `(ptr, len)` pairs in the plugin's linear memory; all values +Version 2 adds panels and settings to version 1, version 3 adds widgets, and version 4 adds +`save_file` and widget fields and property menus (new keys in the widget description and new +widget events); a module written for an older version runs unchanged and gets the input it always +got. A widget description with a field or a menu from a plugin whose manifest says `3` is refused, +and a FigCraft that runs an older API refuses a manifest that asks for `4` when it's imported. Strings are UTF-8 `(ptr, len)` pairs in the plugin's linear memory; all values are `i32`. The module **exports** `memory` and `figcraft_run(input_len) -> status`. When `figcraft_run` starts, @@ -300,6 +320,15 @@ for a new widget) and what was clicked (`event` is null when it's inserted): {"api": 3, "command": "widget", "params": {}, "selection": [], "widget": {"id": 12, "state": {"count": 2}, "event": {"target": "increment"}}} ``` +With API 4 the event can also be an edited field (its value is already in `state` under the +field's name) or a property menu item (`value`: null for an action, the new `true`/`false` for a +toggle, the colour picked for a colour item): + +```json +{"api": 4, "command": "widget", "params": {}, "selection": [], "widget": {"id": 12, "state": {"count": 2, "label": "Votes"}, "event": {"field": "label", "value": "Votes"}}} +{"api": 4, "command": "widget", "params": {}, "selection": [], "widget": {"id": 12, "state": {"count": 2}, "event": {"menu": "color", "value": "#14AE5C"}}} +``` + It returns `0` for success; anything else fails the run (and rolls it back), with the text passed to `set_output` as the error message. @@ -370,11 +399,24 @@ ignored in auto layout), `width` and `height`, `fill` and `stroke` (hex colours) `vertical`), `gap`, `padding`, `justify` and `align` (`min`, `center`, `max`: along and across the layout) and `children`; with auto layout it hugs its contents on an axis without a size. A text has `text`, `size`, -`color` and `bold` (with a `width` it wraps). An image has `data`, the image's bytes (PNG, JPEG…) +`color` and `bold` (with a `width` it wraps), and with API 4 may have a `field` (a name like a +target's): the text is then an editable field showing its current value. An image has `data`, the image's bytes (PNG, JPEG…) as base64, usually carried in the module itself; without a size it takes the image's. FigCraft checks the whole description (kinds, sizes, numbers, colours, lengths, counts, depth, image sizes) and refuses it if anything is off. +With API 4, `menu` beside `root` is the widget's property menu (left out: none), for example: + +```json +"menu": [{"item": "reset", "label": "Reset"}, + {"item": "big", "label": "Steps of 10", "type": "toggle", "checked": true}, + {"item": "color", "label": "Colour", "type": "color", "options": ["#1E1E1E", "#0D99FF"], "value": "#0D99FF"}] +``` + +Each item has an `item` name (like a target's, each once), a `label` (default: the name) and a +`type`: `action` (the default), `toggle` (with `checked`) or `color` (with `options`, 1–8 hex +colours, and optionally `value`, one of them: the current one). Every redraw replaces the menu. + ## Writing a plugin in Rust `plugins/` in the repository is a workspace of its own: `plugins/sdk` (the crate @@ -390,8 +432,9 @@ image sizes) and refuses it if anything is off. button; the palette picked last is remembered as a setting for next time. **From hex colours…** takes a palette of hex colours in the parameters form. -- **Counter** (`counter`, API 3): a widget, a count between − and + buttons; each click changes the - count kept in the widget's state. It only reads the document. +- **Counter** (`counter`, API 4): a widget, a count between − and + buttons under a label; each + click changes the count kept in the widget's state, a double-click on the label edits it, and its + menu resets the count, counts in tens or colours it. It only reads the document. - **Export names** (`export-names`, API 4): saves the names of the selected layers (optionally the layers inside them too) as `layer-names.txt`. It reads the document and offers the file with `save_file`; FigCraft asks where it goes. @@ -455,8 +498,10 @@ rolls it back. With `"api": 2` in the manifest: `show_ui(&json!({...}))` and `cl panel, `setting(key)`, `set_setting(key, &value)` and `delete_setting(key)` for settings. With `"api": 3` and `"widget": true`: `input.widget` (`id`, `state`, `target`) when the widget is inserted or clicked, and `render_widget(&json!({"state": ..., "root": ...}))` to draw it. With -`"api": 4` and `"permissions": {"saveFile": true}`: `save_file(name, mime, &bytes)` offers a file; -FigCraft asks the user where it goes and the plugin never learns a path. Outside +`"api": 4`, `input.widget` also has `field` or `menu` and the `value` that came with it, and the +description may have fields and a `menu`; with `"permissions": {"saveFile": true}` too, +`save_file(name, mime, &bytes)` offers a file; FigCraft asks the user where it goes and the plugin +never learns a path. Outside WebAssembly every host call fails, so the plugin's own logic can be unit-tested with `cargo test`. Any language that compiles to WebAssembly works the same way: export `memory` and `figcraft_run`, @@ -469,8 +514,8 @@ import what you need from `figcraft`. install counts, and updates are found when you browse, not in the background. The browser build can't use a directory yet. - 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 +- Widgets: clicks, field edits and menu items are their only events (no hover or timers), a field + is plain text (no placeholder, length or single-line setting), they can't open a panel, and their layers are frames, text, rectangles, ellipses and images only (no 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: diff --git a/docs/roadmap.md b/docs/roadmap.md index 4340d4db..c22f88f8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **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 @@ -59,7 +59,7 @@ blockers. | M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 | | M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 | | M10 Localization | ~39%: the layer, English, Russian and Turkish reviewed by native speakers; Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts | native reviews, Hindi, Vietnamese, Arabic (with mirrored panels), engine messages and number formatting ([localization-parity](localization-parity.md)) | 29–51 | -| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update) | a public catalogue, publisher accounts and signatures, the directory in the browser, richer widget events; Figma plugin compatibility is an owner decision | 8–26 | +| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update); widget fields and property menus (API 4) | a public catalogue, publisher accounts and signatures, the directory in the browser, widget hover and timers; Figma plugin compatibility is an owner decision | 6–22 | | M12 Boards (new) | first slice landed (2026-10-24): board files, sticky notes, shapes with text, connectors that follow, marker, highlighter, eraser, stamps, board toolbar and contextual bar, live sessions; then a shared timer and voting sessions | widgets, templates, tables, mind maps ([boards-parity](boards-parity.md)) | 20–44 | | M13 AI features (new, owner decision) | not started | local models | 30–60 | @@ -77,6 +77,7 @@ blockers. |---|---|---| | 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged | | 2026-10-24 | minor | M12: a shared board timer and voting sessions; 30–60 h → 26–54 h | +| 2026-10-24 | minor | M11: plugin widgets take text input (editable fields, `widget.setText`) and a property menu (`widget.menu`), plugin API 4; 8–26 h → 6–22 h | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Milestone M12 Boards added; AI features move to M13 | | 2026-10-21 | minor | M6: best-effort `.sketch` import (Sketch 48 and newer), see [file-format-parity](file-format-parity.md). Percentages unchanged | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1e9d3a1f..5c8dcc01 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; 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; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; 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; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; 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; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; 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; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; 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; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; 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; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; 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 | **~59%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.94 × 0.88) | | Mainstream practitioner | **~50%** | estimated ([below](#mainstream-practitioner): weekly areas 72.7% × 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 | **~262–519 h** one agent (~83–145 h wall-clock with 5 parallel agents) | estimated | +| Remaining to full parity | **~260–515 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 @@ -35,7 +35,7 @@ tool; presence was being graded as behaviour in places. | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | +| Full target (ready for real work) | ~53% | ~266–518 h | multiplayer (42–84), localization (29–51), plugins (6–22), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | | Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the experimental `.fig` export opens elsewhere (5–15) | | Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) | @@ -113,7 +113,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 15 | Dev Mode / inspect | 2 | 98% | 78% | Publishing mappings from the code repository; 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, dev resources links, import lines, compare with versions, an inspect-only canvas and layers panel and measurements kept on the canvas landed) ([dev-mode-parity](dev-mode-parity.md)) | 1–2 | | 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 | | 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view, which can be open to the public (owner tokens, limits per address, expiry, moderation). Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 | -| 18 | Plugins & widgets | 4 | 60% | 35% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), with log byte/count limits checked before allocation and host forwarding ([logging limits](plugins.md#logging-limits)), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version); layer data written with `node.set {props: {pluginData}}` reads back through `node.get` (#879). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 | +| 18 | Plugins & widgets | 4 | 65% | 38% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), with log byte/count limits checked before allocation and host forwarding ([logging limits](plugins.md#logging-limits)), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version); layer data written with `node.set {props: {pluginData}}` reads back through `node.get` (#879), then widget fields and property menus (API 4: a text layer edited on the canvas like any text or with `widget.setText`, its value kept in the widget's state; actions, toggles and colour choices in a bar over the selected widget, `widget.menu`). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets have no hover or timers and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 | | 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 | @@ -134,9 +134,9 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Hardware | 3 | 60% | 10–21 | Trackpad pinch and HiDPI yes; pen pressure on Windows and from browser styluses (none from macOS/Linux tablets); several windows (screen-reader tree in the main one only); no GPU canvas, no P3 on screen | [hardware-parity](hardware-parity.md) | | Localization | 3 | 39% | 29–51 | The layer, English, Russian reviewed by VanTan, and machine drafts of Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech (none reviewed by a native speaker; measured 2026-10-17, the drafts cover 95–99% of the 2,272 current strings), Swedish and Turkish catalogs, CJK UI text in installed faces loaded on demand (web: only the craft-fonts CJK build); engine messages stay English | [localization-parity](localization-parity.md) | | Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | -| Ecosystem/plugins | 3 | 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) | +| Ecosystem/plugins | 3 | 40% | 6–22 | Own WASM plugins with a Rust SDK and three examples (#101), plugin panels and settings (ABI v2), widgets that take clicks (API 3), text input and a property menu (API 4), 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%** | **~262–519** | | | +| **Ready for real work** | 100 | **~53%** | **~260–515** | | | 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 @@ -517,11 +517,12 @@ Hours in this narrative are the builders' estimates; the tables above supersede a plugin describes a layer of its own, validated and built as a frame with locked layers; clicks on its named parts call the plugin back in a fresh sandbox with the state kept in the layer, one undo step each; `widget.insert` / `widget.click` / `widget.list`; a Counter example), panels of - several plugins side by side and the colour picker on plugin colour fields. Then a plugin + several plugins side by side and the colour picker on plugin colour fields; then widget fields + and property menus (API 4: `widget.setText`, `widget.menu`). Then 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, downloads checked against their SHA-256, updates by version. *Left:* a public catalogue, publisher - accounts and signatures; richer widget events; the directory in the browser. 8–26 h. + accounts and signatures; widget hover and timers; the directory in the browser. 6–22 h. 9. **Hardening and performance**: *landed:* `cargo xtask bench` (synthetic 1k/10k/50k-layer pages: engine, render and headless UI-frame timings). The canvas keeps its last image and re-renders only what a pan uncovers or an edit touches: moving one layer at 10k layers re-renders @@ -645,6 +646,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | 2026-10-24 | patch | Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds, which landed (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`, `crates/engine/src/outline_options_tests.rs`). Numbers unchanged | | 2026-10-24 | minor | Boards (area 22, weight 0): a shared timer and voting sessions (M12); ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; weighted numbers unchanged | | 2026-10-24 | minor | Boards (M12): templates (five built in, `board.template`) and tables (`board.table`, `board.tableEdit`); area 22 ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h (outside the weighted sum) | +| 2026-10-24 | minor | Plugin widgets take text input and a property menu (gap 6, plugin API 4; API 1–3 plugins and existing widgets unchanged): a widget's text layer can be an editable field, edited on the canvas with a double-click like any text (or `widget.setText`), its value cleaned and capped at 1,000 characters, written into the widget's state under the field's name and the field's text, and the plugin called back to redraw, all as one undo step that syncs in live sessions; a widget can carry a property menu of up to 8 actions, toggles and colour choices (up to 8 colours each), shown in a small bar over the widget while it is selected and driven with `widget.menu`; descriptions, values and menus read back from files are checked, an API 3 plugin that draws a field or a menu is refused, and older FigCraft builds refuse API 4 manifests. The Counter example gained a label field and a menu (reset, steps of ten, a colour); the SDK reads the new events. Hover and timers are still missing. Plugins & widgets 60%/35% → 65%/38%, 8–26 h → 6–22 h; Ecosystem dimension 38% → 40%; weighted totals unchanged at this rounding; full parity ~262–519 h → ~260–515 h | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Area 22 added at weight 0 (breadth ~35%, ready ~20%); headline numbers unchanged (the weighted sum leaves area 22 out) | | 2026-10-15 | minor | Localization (#263, #469, #477, #288): Turkish reviewed by a native speaker, Czech and Indonesian drafts, the Japanese draft caught up; coverage re-measured (2,177 entries; the five older drafts at 95%). Localization 39% and 29–51 h unchanged at this rounding (see [localization-parity](localization-parity.md)) | | 2026-10-22 | minor | Vector edit mode: the transform box around points selected in several layers (in the page's axes; scale, rotate and drag in one undo step). Shapes & vector's remaining work loses that item; numbers unchanged | diff --git a/plugins/examples/counter/figcraft-plugin.json b/plugins/examples/counter/figcraft-plugin.json index a046ebc5..fa83c414 100644 --- a/plugins/examples/counter/figcraft-plugin.json +++ b/plugins/examples/counter/figcraft-plugin.json @@ -1,10 +1,10 @@ { "id": "org.figcraft.examples.counter", "name": "Counter", - "version": "1.0.0", - "description": "A widget that counts on the canvas: click its + and − buttons. The count is kept in the document.", + "version": "1.1.0", + "description": "A widget that counts on the canvas: click its + and − buttons, double-click its label to edit it, and use its menu to reset it, count in tens or colour it. Everything is kept in the document.", "main": "counter.wasm", - "api": 3, + "api": 4, "widget": true, "permissions": { "access": "read" } } diff --git a/plugins/examples/counter/src/lib.rs b/plugins/examples/counter/src/lib.rs index d149a53d..92815c4f 100644 --- a/plugins/examples/counter/src/lib.rs +++ b/plugins/examples/counter/src/lib.rs @@ -1,19 +1,53 @@ -//! Counter: a widget on the canvas with − and + buttons around a count. Each click calls the -//! plugin back with the widget's state; it hands back the new count and the redrawn widget. +//! Counter: a widget on the canvas with − and + buttons around a count, a label you can edit on the +//! canvas, and a property menu (reset, steps of ten, a colour). Each click, edit or menu choice +//! calls the plugin back with the widget's state; it hands back the new state and the redrawn +//! widget. -use figcraft_plugin_sdk::{Input, Result, Value, entry, json, render_widget}; +use figcraft_plugin_sdk::{Input, Result, Value, Widget, entry, json, render_widget}; /// The count stays within ± this. const LIMIT: i64 = 999_999; +/// The colours the menu offers for the count (the first is the default). +const COLORS: [&str; 4] = ["#1E1E1E", "#0D99FF", "#14AE5C", "#F24822"]; +/// Shown in the label field while it's empty. +const PLACEHOLDER: &str = "Add a label"; -/// The count after clicking `target` (none: the widget was just inserted). -fn next(count: i64, target: Option<&str>) -> i64 { - let n = match target { - Some("increment") => count.saturating_add(1), - Some("decrement") => count.saturating_sub(1), - _ => count, - }; - n.clamp(-LIMIT, LIMIT) +/// What the widget shows. +#[derive(Debug, PartialEq)] +struct Counter { + count: i64, + big: bool, + color: String, +} + +impl Counter { + fn from_state(state: &Value) -> Counter { + let color = state.get("color").and_then(Value::as_str).filter(|c| COLORS.contains(c)).unwrap_or(COLORS[0]); + Counter { + count: state.get("count").and_then(Value::as_i64).unwrap_or(0), + big: state.get("big").and_then(Value::as_bool).unwrap_or(false), + color: color.into(), + } + } + + /// After the click, edit or menu choice `w` brings (nothing: the widget was just inserted). + fn next(mut self, w: &Widget) -> Counter { + let step = if self.big { 10 } else { 1 }; + match (w.target.as_deref(), w.menu.as_deref()) { + (Some("increment"), _) => self.count = self.count.saturating_add(step), + (Some("decrement"), _) => self.count = self.count.saturating_sub(step), + (_, Some("reset")) => self.count = 0, + (_, Some("big")) => self.big = w.value.as_bool().unwrap_or(!self.big), + (_, Some("color")) => { + if let Some(c) = w.value.as_str().filter(|c| COLORS.contains(c)) { + self.color = c.into(); + } + } + _ => {} + } + self.count = self.count.clamp(-LIMIT, LIMIT); + self + } } /// A square button that calls back with `target`. @@ -22,21 +56,38 @@ fn button(target: &str, name: &str, label: &str) -> Value { "children": [{"type": "text", "text": label, "size": 16, "bold": true, "color": "#1E1E1E"}]}) } -/// The widget showing `count`. -fn view(count: i64) -> Value { - json!({"type": "frame", "name": "Counter", "layout": "horizontal", "gap": 12, "padding": 12, "radius": 12, +/// The widget showing `c` under `label`. +fn view(c: &Counter, label: &str) -> Value { + let (text, ink) = if label.trim().is_empty() { (PLACEHOLDER, "#B3B3B3") } else { (label, "#757575") }; + let (minus, plus) = if c.big { ("−10", "+10") } else { ("−", "+") }; + json!({"type": "frame", "name": "Counter", "layout": "vertical", "gap": 6, "padding": 12, "radius": 12, "align": "min", "fill": "#FFFFFF", "stroke": "#D9D9D9", "children": [ - button("decrement", "Decrease", "−"), - {"type": "text", "name": "Count", "text": count.to_string(), "size": 24, "bold": true, "color": "#1E1E1E"}, - button("increment", "Increase", "+"), + {"type": "text", "name": "Label", "field": "label", "text": text, "size": 12, "color": ink}, + {"type": "frame", "name": "Row", "layout": "horizontal", "gap": 12, "children": [ + button("decrement", "Decrease", minus), + {"type": "text", "name": "Count", "text": c.count.to_string(), "size": 24, "bold": true, "color": c.color}, + button("increment", "Increase", plus), + ]}, ]}) } +/// The property menu for `c`. +fn menu(c: &Counter) -> Value { + json!([ + {"item": "reset", "label": "Reset", "type": "action"}, + {"item": "big", "label": "Steps of 10", "type": "toggle", "checked": c.big}, + {"item": "color", "label": "Colour", "type": "color", "options": COLORS, "value": c.color}, + ]) +} + fn run(input: Input) -> Result { let w = input.widget.ok_or("Insert the counter from Plugins ▸ Widgets")?; - let count = next(w.state.get("count").and_then(Value::as_i64).unwrap_or(0), w.target.as_deref()); - render_widget(&json!({"state": {"count": count}, "root": view(count)}))?; - Ok(json!({"count": count})) + let c = Counter::from_state(&w.state).next(&w); + // An edited label is in the state already, under its field's name. + let label = w.state.get("label").and_then(Value::as_str).unwrap_or("Counter").to_string(); + let state = json!({"count": c.count, "big": c.big, "color": c.color, "label": label}); + render_widget(&json!({"state": state, "root": view(&c, &label), "menu": menu(&c)}))?; + Ok(json!({"count": c.count})) } entry!(run); @@ -45,15 +96,28 @@ entry!(run); mod tests { use super::*; + fn widget(target: Option<&str>, menu: Option<&str>, value: Value) -> Widget { + Widget { target: target.map(Into::into), menu: menu.map(Into::into), value, ..Widget::default() } + } + #[test] - fn counts_within_its_limit() { - assert_eq!(next(0, None), 0); - assert_eq!(next(4, Some("increment")), 5); - assert_eq!(next(4, Some("decrement")), 3); - assert_eq!(next(LIMIT, Some("increment")), LIMIT); - assert_eq!(next(i64::MIN, Some("decrement")), -LIMIT); - assert_eq!(next(2, Some("other")), 2); - assert_eq!(view(3)["children"][1]["text"], "3"); + fn counts_within_its_limit_and_follows_its_menu() { + let c = |count: i64| Counter { count, big: false, color: COLORS[0].into() }; + let at = |n: i64, t: &str| c(n).next(&widget(Some(t), None, Value::Null)).count; + assert_eq!(c(0).next(&Widget::default()).count, 0); + assert_eq!((at(4, "increment"), at(4, "decrement")), (5, 3)); + assert_eq!((at(LIMIT, "increment"), at(i64::MIN, "decrement"), at(2, "other")), (LIMIT, -LIMIT, 2)); + assert_eq!(c(7).next(&widget(None, Some("reset"), Value::Null)).count, 0); + let big = c(1).next(&widget(None, Some("big"), json!(true))); + assert!(big.big); + assert_eq!(big.next(&widget(Some("increment"), None, Value::Null)).count, 11, "steps of ten"); + assert_eq!(c(1).next(&widget(None, Some("color"), json!("#14AE5C"))).color, "#14AE5C"); + assert_eq!(c(1).next(&widget(None, Some("color"), json!("#123456"))).color, COLORS[0], "only the colours offered"); + assert_eq!(Counter::from_state(&json!({"count": 3, "color": "junk"})), c(3)); + let v = view(&c(3), "Score"); + assert_eq!((v["children"][0]["text"].clone(), v["children"][1]["children"][1]["text"].clone()), (json!("Score"), json!("3"))); + assert_eq!(view(&c(3), " ")["children"][0]["text"], PLACEHOLDER); + assert_eq!(menu(&c(0))[1]["checked"], false); assert!(run(Input::default()).is_err(), "it only runs as a widget"); } } diff --git a/plugins/sdk/src/lib.rs b/plugins/sdk/src/lib.rs index 65f70f8d..be2a3208 100644 --- a/plugins/sdk/src/lib.rs +++ b/plugins/sdk/src/lib.rs @@ -27,6 +27,10 @@ //! each time a part of it named as a `target` is clicked, the plugin is called with //! [`Input::widget`] set (its state and what was clicked) and hands back the new state and the //! layers with [`render_widget`]. Each call starts afresh: the state is all a widget remembers. +//! With API 4 a widget can also have editable text fields (a text layer with a `field` name; an +//! edit calls back with [`Widget::field`] and [`Widget::value`], and the value is already in the +//! state under the field's name) and a property menu (`menu` beside `root`; choosing an item calls +//! back with [`Widget::menu`] and, for a toggle or a colour, [`Widget::value`]). //! //! The SDK is `no_std` (it needs `alloc`); outside WebAssembly every host call fails, so a plugin's //! own logic can still be unit-tested on the host. See `docs/plugins.md` for the ABI underneath. @@ -59,7 +63,7 @@ pub struct Input { pub widget: Option, } -/// A widget being inserted (no `target`) or clicked. +/// A widget being inserted (no `target`, `field` or `menu`), clicked, edited or given a menu item. #[derive(Clone, Debug, Default, PartialEq)] pub struct Widget { /// The widget's layer id. @@ -68,6 +72,13 @@ pub struct Widget { pub state: Value, /// The target clicked. pub target: Option, + /// API 4: the editable field edited. + pub field: Option, + /// API 4: the property menu item chosen. + pub menu: Option, + /// API 4: the field's new text, a toggle's new state (`true`/`false`) or the colour chosen + /// (null for a click or an action). + pub value: Value, } /// A button pressed or a value changed in the plugin's panel. @@ -94,10 +105,17 @@ impl Input { value: e.get("value").cloned().unwrap_or(Value::Null), values: v.get("values").cloned().unwrap_or(Value::Null), }), - widget: v.get("widget").map(|w| Widget { - id: w.get("id").and_then(Value::as_u64).unwrap_or_default(), - state: w.get("state").cloned().unwrap_or_else(|| json!({})), - target: w.get("event").and_then(|e| e.get("target")).and_then(Value::as_str).map(ToString::to_string), + widget: v.get("widget").map(|w| { + let event = |key: &str| w.get("event").and_then(|e| e.get(key)); + let name = |key: &str| event(key).and_then(Value::as_str).map(ToString::to_string); + Widget { + id: w.get("id").and_then(Value::as_u64).unwrap_or_default(), + state: w.get("state").cloned().unwrap_or_else(|| json!({})), + target: name("target"), + field: name("field"), + menu: name("menu"), + value: event("value").cloned().unwrap_or(Value::Null), + } }), }) } @@ -360,5 +378,13 @@ mod tests { .widget .unwrap_or_default(); assert_eq!((w.id, &w.state, w.target.as_deref()), (9, &json!({"n": 1}), Some("inc"))); + let e = + Input::parse(r#"{"widget":{"id":9,"state":{},"event":{"field":"label","value":"Hi"}}}"#).unwrap_or_default().widget.unwrap_or_default(); + assert_eq!((e.field.as_deref(), &e.value, e.target.is_none()), (Some("label"), &json!("Hi"), true)); + let m = Input::parse(r##"{"widget":{"id":9,"state":{},"event":{"menu":"color","value":"#FF0000"}}}"##) + .unwrap_or_default() + .widget + .unwrap_or_default(); + assert_eq!((m.menu.as_deref(), &m.value), (Some("color"), &json!("#FF0000"))); } }